mcpField
P
performance

performance

github.com/cognitedata开发338 次安装
代码仓库

关于 performance

用于系统排查并修复应用性能问题的技能,面向前端开发者。它要求先测量基线,再检查渲染、数据请求、列表和代码拆分等环节并实施优化。

主要功能

  1. 01先采集构建和性能基线
  2. 02定位不必要的组件重渲染
  3. 03优化 DMS 查询与客户端过滤
  4. 04检查分页和请求频率
  5. 05处理长列表、代码拆分与包体积

适用场景

  1. 01诊断 React 界面的渲染瓶颈
  2. 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 标签页 → 运行审计)
  • 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}>

对接收稳定属性的纯展示组件应用 React.memo。不要包装每个组件——只包装经 Profiler 确认存在不必要重新渲染的组件。


第 3 步——查找并修复 DMS 查询模式

对于读取密集型工作负载,优先使用访问搜索/Elasticsearch 路径的 API(实例上的 query 或 search),而不是会增加 Postgres 负载的 list 路径。

# 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 参数显式添加 limit,使其与 UI 的页大小一致(例如 25、50、100)
大型数据集使用基于偏移量的分页替换为基于游标的分页,使用响应中的 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 });
}
问题应用的修复
搜索输入每次击键都会触发查询添加 useDebouncedValue 钩子,延迟 300ms
轮询没有退避机制或间隔过短将间隔设为 ≥30s,并在出错时采用指数退避
每次渲染都重新获取数据(没有缓存)在 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
未进行 tree-shaking 的图表库改用具名导入(例如,import { LineChart } from "echarts/charts")
只在一处使用的大型库使用 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,添加缺失的清理函数:

未使用 abort 的 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

如果某个步骤没有带来改善,请明确说明。不要编造数据。


完成

列出所有更改过的文件,包含绝对路径以及对修复内容的一句话说明。如果要进一步提升性能,需要进行服务端或基础设施更改(例如,CDF 响应缓存、CDN 配置),请将其单独列为范围外建议。

评论

支持 Markdown