performance について
アプリの性能問題を調査・修正する開発者向けスキルです。まず基準値を測定し、描画、データ取得、リスト、コード分割、バンドルサイズを見直します。
主な機能
- 01ビルドと性能の基準値を記録
- 02不要なコンポーネント再描画の特定
- 03DMS クエリとクライアント側フィルターの最適化
- 04ページネーションとリクエスト頻度の確認
- 05長いリスト、コード分割、バンドルサイズへの対処
活用シーン
- 01React UI の描画ボトルネックの診断
- 02アプリのデータ取得と読み込み負荷の軽減
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cognitedata/ dune-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/ ディレクトリがあれば参照します。
ステップ 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 に設定するか、より長い stale time を使います |
| 同一のリクエストが並列に重複している | クエリを共有フックに移動し、両方のコンポーネントからインポートします |
| 複数のコンポーネントが同じフェッチを実行している | 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 の設定)が必要な場合は、対象範囲外の推奨事項として別途記載します。