performance について
アプリ開発者向けのパフォーマンス改善スキルです。まず基準値を測定し、描画、データアクセス、リクエスト、バンドルなどを調べます。
主な機能
- 01Lighthouse と React Profiler で基準測定
- 02不要な React の再レンダリングを特定
- 03DMS のクエリとフィルターを確認
- 04ページネーションと API 呼び出し頻度を改善
- 05リストの仮想化とコード分割を確認
活用シーン
- 01改善前にアプリの性能を測定
- 02コンポーネントの過剰な再レンダリングを調査
- 03DMS アプリのデータ取得を点検
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cognitedata/ builder-skills --skill performanceインストール後、Claude Code で/performanceを入力して読み込まれたか確認します。
詳細
パフォーマンスの修正
$ARGUMENTS(引数が指定されていない場合はアプリ全体)のパフォーマンス問題を体系的に特定して修正します。必ず最初に測定してください。やみくもに最適化してはいけません。
ステップ 1 — 変更を加える前にベースラインを測定する
変更を加える前に本番ビルドを実行し、メトリクスを記録します。
pnpm run build
pnpm run preview
Chrome でアプリを開き、以下を記録します。
- Lighthouse スコア(Performance タブ → Run audit)
- React Profiler(React DevTools → Profiler → インタラクションを記録)
- レンダリング時間が最も長いコンポーネントと、レンダリング回数が最も多いコンポーネントを記録します
ベースラインの数値を記録します。すべての修正は、これらの数値と比較して測定する必要があります。
ステップ 2 — 不要な再レンダリングを特定して修正する
コンポーネントツリーを読み込み(src/App.tsx から始めます)、以下のパターンを検索します。
grep -rn --include="*.tsx" \
-E "value=\{\{|onClick=\{\(\)" src/
見つかった箇所それぞれに、直接修正を適用します。
JSX 内でオブジェクトや配列をインライン作成している場合 → useMemo でラップします。
// BAD — new object on every render causes children to re-render
<Chart options={{ color: "red" }} />
// FIX — wrap with useMemo
const chartOptions = useMemo(() => ({ color: "red" }), []);
<Chart options={chartOptions} />
レンダリングのたびにイベントハンドラーが再作成される場合 → useCallback でラップします。
// BAD
<Button onClick={() => doSomething(id)} />
// FIX — wrap with useCallback
const handleClick = useCallback(() => doSomething(id), [id]);
<Button onClick={handleClick} />
レンダリングのたびに値が変わるコンテキスト → コンテキストの値をメモ化します。
// BAD — new object reference every render
<MyContext.Provider value={{ user, sdk }}>
// FIX — memoize the context value
const ctxValue = useMemo(() => ({ user, sdk }), [user, sdk]);
<MyContext.Provider value={ctxValue}>
安定した props を受け取る純粋なプレゼンテーションコンポーネントには、React.memo を適用します。すべてのコンポーネントをラップしてはいけません。Profiler で不要な再レンダリングが確認されたものだけを対象にします。
ステップ 3 — DMS クエリのパターンを特定して修正する
読み取りが多いワークロードでは、Postgres に負荷をかける list パスではなく、検索/Elasticsearch パスを使用する API(インスタンスに対する query または search)を優先します。
# Find all DMS instance API calls
grep -rn --include="*.ts" --include="*.tsx" -E "instances\.(list|search|query|aggregate|retrieve)" src/
# Find direct SDK calls to other CDF resources
grep -rn --include="*.ts" --include="*.tsx" -E "\.(assets|timeseries|events|files|sequences|relationships)\.(list|search|retrieve)" src/
読み取りの多いパス(テーブル、ドロップダウン、検索結果の表示など)にある instances.list の呼び出しは、それぞれ、同等のフィルターを使用する instances.query に書き換えます。既存のフィルターロジックを維持したまま、クエリ API の形式で表現します。
// BAD — instances.list hits Postgres, expensive for read-heavy UI
const result = await client.instances.list({
instanceType: "node",
filter: { equals: { property: ["node", "space"], value: "my-space" } },
limit: 100,
});
// FIX — rewrite to instances.query which hits Elasticsearch
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: { equals: { property: ["node", "space"], value: "my-space" } },
},
limit: 100,
},
},
select: {
nodes: {},
},
});
| 使用する API | 適切な場合 | 書き換える場合 |
|---|---|---|
instances.query | Elasticsearch に対応するフィルター(テキスト、等価、範囲)を使った読み取り | — |
instances.search | 全文検索またはあいまい検索 | — |
instances.list | 書き込み、同期、または query/search では利用できないセマンティクスが必要な場合 | 読み取りの多い UI 表示に使っている場合は instances.query に書き換えます |
instances.retrieve | 既知の外部 ID による取得 | — |
instances.aggregate | 件数、ヒストグラム | — |
検索パスとリレーショナルパスの違い、カーディナリティ、マテリアライズのトレードオフについて詳しくは、ワークスペースに存在する場合は semantic-knowledge/ ディレクトリを参照してください。
ハードゲート — クエリ結果に対する LLM
grep -rn --include="*.ts" --include="*.tsx" -E "chat\.completions|agents/chat|useAtlasChat|openai|anthropic" src/
DMS の行に対して補完をマッピングしてはいけません。修正方法:sendAgentMessage を 1 回呼び出すか、エージェントリソース(integrate-fusion-agent)を使います。アイテムごとの補完が残る場合は、5/上限 50 件に制限し、space:externalId:lastUpdatedTime でキャッシュし、ユーザーが開始した場合にのみ実行します。
ステップ 4 — クライアント側フィルタリングを特定して修正する(サーバー側に移す)
フィルター、上限、プロジェクションは、API リクエスト内で適用する必要があります。大量の結果セットをダウンロードしてからブラウザーでフィルタリングしてはいけません。
# Find client-side filtering after data fetch (common anti-pattern)
grep -rn --include="*.ts" --include="*.tsx" -B 5 "\.filter(" src/ | grep -B 5 "data\|items\|result\|response\|nodes"
# Find .map() or .reduce() on full datasets that suggest client-side processing
grep -rn --include="*.ts" --include="*.tsx" -E "\.(map|reduce|find|some|every)\(" src/hooks/ src/services/ src/api/
クライアント側フィルターパターンが見つかったら、それぞれ、フィルターロジックを SDK 呼び出しの filter パラメーターに移し、.filter() 呼び出しを削除します。
// BAD — fetches all nodes then filters client-side
const result = await client.instances.query({ ... });
const activeNodes = result.items.nodes.filter(n => n.properties.status === "active");
// FIX — move filter into the API request, remove client-side .filter()
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: {
and: [
existingFilters,
{ equals: { property: ["mySpace", "myView/v1", "status"], value: "active" } },
],
},
},
limit: 100,
},
},
select: { nodes: {} },
});
const activeNodes = result.items.nodes; // no client-side filter needed
| 問題 | 修正 |
|---|---|
SDK 呼び出し後、結果セット全体に対して .filter() を実行している | フィルターを API リクエストの filter パラメーターに移し、.filter() を削除します |
DMS クエリで properties を選択していない | 必要なフィールドだけを取得するために、sources または properties パラメーターを追加します |
| すべてのアイテムを取得してから一部だけを表示している | API 呼び出しに limit と filter を追加し、表示するものだけを取得します |
| 取得した配列に対してクライアント側でテキスト検索している | SDK の search エンドポイントに置き換えます |
厳守事項: クライアント側で適用している条件を API がフィルターとしてサポートしている場合は、直ちにサーバー側に移してください。クライアント側のフィルタリングが許容されるのは、ローカル状態の単純な処理(例:キャッシュ済みのユーザー設定 10 件のフィルタリング)に限られます。API が該当するフィルターをサポートしていない場合は、クライアント側でのフィルタリングが必要な理由を説明するコードコメントを追加してください。
ステップ 5 — CDF データの取得とページネーションを確認して修正する
すべての CDF SDK 呼び出しを読みます(sdk., client., useQuery, useCogniteClient を検索します)。
# Find pagination patterns
grep -rn --include="*.ts" --include="*.tsx" -E "(nextCursor|cursor|hasNextPage|fetchNextPage|offset|skip|page)" src/
# Find "fetch all" loops
grep -rn --include="*.ts" --include="*.tsx" -B 3 -A 3 "while.*cursor\|while.*hasMore\|while.*nextPage" src/
各呼び出しについて、問題を特定して修正を適用します。
| 問題 | 適用する修正 |
|---|---|
limit が設定されていない | SDK 呼び出しに limit: 100(または実際に必要なページサイズ)を追加します |
| すべてのプロパティを取得している | 必要なフィールドのみを選択する properties フィルターを追加します |
| レンダリングのたびに取得している | 依存配列を安定させて useQuery/useMemo 内に移動します |
| 並列化できるリクエストを逐次実行している | Promise.all またはバッチ処理対応の SDK メソッドを使用するように書き換えます |
limit パラメーターがない | UI のページサイズ(例:25、50、100)に合わせて 明示的な limit を追加します |
| 大規模なデータセットにオフセットベースのページネーションを使用している | レスポンスの nextCursor を使用するカーソルベースのページネーションに 置き換えます |
| 「すべて取得」するループ(カーソルを先にすべて使い切る) | TanStack Query の useInfiniteQuery を使ったオンデマンドのページネーションに 置き換えます |
全件取得ループの修正 — while ループを useInfiniteQuery に置き換えます。
// BAD — fetches ALL pages before rendering
let allItems = [];
let cursor = undefined;
while (true) {
const result = await client.instances.list({ limit: 1000, cursor });
allItems.push(...result.items);
if (!result.nextCursor) break;
cursor = result.nextCursor;
}
// FIX — paginate on demand with useInfiniteQuery
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["instances", filters],
queryFn: ({ pageParam }) =>
client.instances.list({ limit: 100, cursor: pageParam, ...filters }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 30_000,
});
オフセットベースのページネーションの修正 — カーソルベースに切り替えます。
// BAD — offset pagination degrades at scale
const result = await client.instances.list({ limit: 100, offset: page * 100 });
// FIX — cursor-based pagination
const result = await client.instances.list({ limit: 100, cursor: nextCursor });
ステップ 6 — 過剰な API 呼び出し頻度を確認して修正する
# Find search/filter inputs that trigger queries
grep -rn --include="*.tsx" --include="*.ts" -E "onChange|onInput|onSearch|onFilter" src/ | grep -i "search\|filter\|query"
# Find debounce usage
grep -rn --include="*.ts" --include="*.tsx" -i -E "debounce|useDebouncedValue|useDebounce" src/
# Find polling/interval patterns
grep -rn --include="*.ts" --include="*.tsx" -E "setInterval|refetchInterval|pollingInterval|refetchOnWindowFocus" src/
# Find useQuery options that control refetch behavior
grep -rn --include="*.ts" --include="*.tsx" -E "staleTime|cacheTime|gcTime|refetchOnMount|refetchOnWindowFocus" src/
見つかった問題ごとに、修正を適用します。
キー入力ごとに呼び出される検索入力 → 300ms の遅延を設けてデバウンスを追加します。
// BAD — fires API call on every keystroke
const [search, setSearch] = useState("");
const { data } = useQuery({ queryKey: ["search", search], queryFn: () => api.search(search) });
// FIX — create or use a useDebouncedValue hook with 300ms delay
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 300);
const { data } = useQuery({
queryKey: ["search", debouncedSearch],
queryFn: () => api.search(debouncedSearch),
enabled: debouncedSearch.length > 0,
});
staleTime のない useQuery 呼び出し → 適切な staleTime を追加します。
// BAD — refetches on every mount/focus
useQuery({ queryKey: ["data"], queryFn: fetchData });
// FIX — add staleTime to prevent unnecessary refetches
useQuery({ queryKey: ["data"], queryFn: fetchData, staleTime: 30_000 });
重複する同一の並列リクエスト → クエリを共通フックに移動します。
// BAD — multiple components each call the same query independently
// ComponentA.tsx: useQuery({ queryKey: ["assets"], queryFn: fetchAssets });
// ComponentB.tsx: useQuery({ queryKey: ["assets"], queryFn: fetchAssets });
// FIX — create a shared hook, import it from both components
// hooks/useAssets.ts
export function useAssets() {
return useQuery({ queryKey: ["assets"], queryFn: fetchAssets, staleTime: 30_000 });
}
| 問題 | 適用する修正 |
|---|---|
| 検索入力でキー入力のたびにクエリが呼び出される | 300ms の遅延を設けた useDebouncedValue フックを追加します |
| バックオフがない、または間隔が非常に短いポーリング | 間隔を 30 秒以上に設定し、エラー時は指数バックオフを適用します |
| レンダリングのたびに再取得している(キャッシュなし) | useQuery のオプションに staleTime: 30_000(または適切な値)を追加します |
負荷の高いクエリで refetchOnWindowFocus: true を設定している | refetchOnWindowFocus: false に設定するか、staleTime を長くします |
| 重複する同一の並列リクエスト | クエリを共通フックに移動し、両方のコンポーネントからインポートします |
| 複数のコンポーネントが同じ取得処理を実行している | hooks/ ディレクトリに 共通フックとして切り出します |
ステップ 7 — 仮想化されていない大きなリストを確認して修正する
約 50 件を超える項目をレンダリングするリストを検索します。
grep -rn --include="*.tsx" -E "\.(map|forEach)\(" src/
データソースが 50 件を超える可能性があるリストでは、通常の .map() によるレンダリングを仮想化リストに置き換えます。@tanstack/react-virtual がインストールされていない場合はインストールします。
pnpm add @tanstack/react-virtual
仮想化のパターンを直接適用します。
// BAD — renders all items in the DOM
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
// FIX — replace with virtualized list
import { useVirtualizer } from "@tanstack/react-virtual";
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
ステップ 8 — コード分割の不足を確認して修正する
ルーターの設定を読み込み、ランディングページに表示されないにもかかわらず静的にインポートされているルートを特定します。
静的にインポートされている重いページは、それぞれ React.lazy() と Suspense を使った遅延インポートに変更します。
// BAD — statically imported, loaded in initial bundle
import { ReportPage } from "./pages/ReportPage";
// FIX — convert to lazy import
import { lazy, Suspense } from "react";
const ReportPage = lazy(() => import("./pages/ReportPage"));
// In the route — wrap with Suspense
<Suspense fallback={<PageSkeleton />}>
<ReportPage />
</Suspense>
同様に、サードパーティ製の大きなコンポーネント(チャートライブラリ、PDF ビューアー、地図レンダラー)は、モジュールレベルではなく、それを必要とするコンポーネント内で動的にインポートします。見つかった重いインポートそれぞれに、この変換を直接適用します。
ステップ 9 — バンドルサイズを分析して修正する
# Install if not already present, then run
pnpm add -D rollup-plugin-visualizer
一時的に vite.config.ts に追加します:
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({ open: true, gzipSize: true, brotliSize: true }),
],
});
pnpm run build を実行して、ツリーマップを確認します。100 KB(gzip 圧縮後)を超えるチャンクのうち、初期依存関係として必要でないものについては、修正を適用します:
| 問題 | 適用する修正 |
|---|---|
lodash(フルバンドル) | 個別にインポートする lodash-es に置き換えるか、ネイティブ機能(例:Array.prototype.map、Object.entries、structuredClone)を使います |
moment | date-fns またはネイティブの Intl.DateTimeFormat に置き換えます |
| ツリーシェイキングされないチャートライブラリ | 名前付きインポートに切り替えます(例:import { LineChart } from "echarts/charts") |
| 1 か所だけで使われる大規模ライブラリ | React.lazy またはインラインの import() で動的にインポートします |
// BAD
import _ from "lodash";
const sorted = _.sortBy(items, "name");
// FIX — use lodash-es or native
import sortBy from "lodash-es/sortBy";
const sorted = sortBy(items, "name");
// OR native:
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
// BAD
import moment from "moment";
const formatted = moment(date).format("YYYY-MM-DD");
// FIX — use date-fns
import { format } from "date-fns";
const formatted = format(date, "yyyy-MM-dd");
分析後、vite.config.ts からビジュアライザープラグインを削除し、アンインストールします:
pnpm remove rollup-plugin-visualizer
ステップ 10 — メモリリークを見つけて修正する
クリーンアップ処理なしでサブスクリプション、タイマー、イベントリスナーを設定している useEffect フックを検索します:
grep -rn --include="*.tsx" --include="*.ts" -A 10 "useEffect" src/
addEventListener、setInterval、setTimeout、subscribe を呼び出す、または CDF ストリーミング接続を設定するすべての useEffect について、不足しているクリーンアップ関数を追加します:
中断処理のない Fetch → AbortController を追加:
// BAD — no cleanup, fetch continues after unmount
useEffect(() => {
fetchData(id);
}, [id]);
// FIX — add AbortController for cleanup
useEffect(() => {
const controller = new AbortController();
fetchData(id, controller.signal);
return () => controller.abort();
}, [id]);
クリーンアップ処理のないタイマー → clearInterval/clearTimeout を追加:
// BAD — interval keeps running after unmount
useEffect(() => {
const id = setInterval(() => poll(), 5000);
}, []);
// FIX — add clearInterval cleanup
useEffect(() => {
const id = setInterval(() => poll(), 5000);
return () => clearInterval(id);
}, []);
クリーンアップ処理のないイベントリスナー → removeEventListener を追加:
// BAD — listener accumulates on each render
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
// FIX — add removeEventListener cleanup
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
ステップ 11 — 再測定して差分を報告する
ステップ 1 と同じ Lighthouse 監査と React Profiler の計測を再実行します。差分を報告し、変更したファイルをすべて列挙します:
| 指標 | 変更前 | 変更後 | 変化 |
|---|---|---|---|
| Lighthouse パフォーマンス | 72 | 91 | +19 |
| 最大コンテンツの描画時間 | 3.2 s | 1.8 s | −1.4 s |
| 合計ブロッキング時間 | 420 ms | 80 ms | −340 ms |
| バンドルサイズ(gzip 圧縮後) | 410 KB | 290 KB | −120 KB |
AssetTable のレンダー回数(フィルター変更時) | 8 | 2 | −6 |
ステップを実施しても改善がなかった場合は、その旨を明記してください。数値を捏造しないでください。
完了
変更したファイルをすべて、絶対パスと修正内容を説明する 1 行の説明とともに列挙します。さらなる改善にサーバー側またはインフラストラクチャの変更(例:CDF レスポンスのキャッシュ、CDN の設定)が必要な場合は、対象外の推奨事項として別途記載します。