mcpField
P
performance

performance

github.com/cognitedata開発61.9k 回インストール1 日前に更新
リポジトリ

performance について

アプリ開発者向けのパフォーマンス改善スキルです。まず基準値を測定し、描画、データアクセス、リクエスト、バンドルなどを調べます。

主な機能

  1. 01Lighthouse と React Profiler で基準測定
  2. 02不要な React の再レンダリングを特定
  3. 03DMS のクエリとフィルターを確認
  4. 04ページネーションと API 呼び出し頻度を改善
  5. 05リストの仮想化とコード分割を確認

活用シーン

  1. 01改善前にアプリの性能を測定
  2. 02コンポーネントの過剰な再レンダリングを調査
  3. 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.queryElasticsearch に対応するフィルター(テキスト、等価、範囲)を使った読み取り—
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)を使います
momentdate-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 パフォーマンス7291+19
最大コンテンツの描画時間3.2 s1.8 s−1.4 s
合計ブロッキング時間420 ms80 ms−340 ms
バンドルサイズ(gzip 圧縮後)410 KB290 KB−120 KB
AssetTable のレンダー回数(フィルター変更時)82−6

ステップを実施しても改善がなかった場合は、その旨を明記してください。数値を捏造しないでください。


完了

変更したファイルをすべて、絶対パスと修正内容を説明する 1 行の説明とともに列挙します。さらなる改善にサーバー側またはインフラストラクチャの変更(例:CDF レスポンスのキャッシュ、CDN の設定)が必要な場合は、対象外の推奨事項として別途記載します。

コメント