关于 performance
面向应用开发者的性能优化 Skill,要求先测量基线,再检查渲染、数据访问、请求和打包等问题。
主要功能
- 01用 Lighthouse 和 React Profiler 测量基线
- 02识别不必要的 React 重渲染
- 03检查 DMS 查询与过滤模式
- 04改进分页和 API 请求频率
- 05检查列表虚拟化与代码分割
适用场景
- 01优化前测量应用性能
- 02排查组件频繁重渲染
- 03检查 DMS 应用的数据获取
安装
复制下面的命令,在终端中运行。
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 → Record an interaction)
- 记录渲染时间最长、渲染次数最多的组件
记录基线数据。每项修复都必须与这些数据进行对比。
第 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} />
每次渲染时都会变化的 Context → 对 Context 值进行记忆化:
// 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 查询模式
对于读取密集型工作负载,优先使用命中搜索/Elasticsearch 路径(对实例调用 query 或 search)的 API,而不是会给 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/ 目录(如果存在)。
硬性门槛 — 对查询结果调用 LLM
grep -rn --include="*.ts" --include="*.tsx" -E "chat\.completions|agents/chat|useAtlasChat|openai|anthropic" src/
不要对 DMS 行逐项映射调用补全。修复方式:调用一次 sendAgentMessage,或使用 Agent 资源(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 参数 | 明确添加 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,添加缺失的清理函数:
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 |
如果某个步骤没有带来改进,请明确说明。不要编造数字。
完成
列出所有修改文件的绝对路径,并用一行说明修复了什么。如果要进一步提升性能,需要进行服务端或基础设施更改(例如,CDF 响应缓存、CDN 配置),请将其作为范围外建议另行注明。