React 页面卡顿,可能是重复计算,也可能是布局、绘制或其他脚本。只看组件渲染次数,还不能知道时间花在哪里。
Performance 录制需要一个固定操作,前后才好比较。下面故意把筛选重复执行 200 遍,造一个容易观察的计算负载。它是练习定位用的,不是实际业务算法。
两个组件,只区别是否复用计算结果
创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。
npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9
创建 build.cjs:
const webpack = require("webpack");
const path = require("path");
webpack(
{
mode: "development",
entry: path.resolve("main.js"),
output: { path: path.resolve("dist"), filename: "main.js" },
devtool: "source-map",
},
(error, stats) => {
if (error || stats.hasErrors()) {
console.error(error || stats.toString("errors-only"));
process.exitCode = 1;
}
},
);
创建 index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
<title>React 实验</title>
<div id="root"></div>
<script src="./dist/main.js"></script>
</html>
把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。
import React, {
useState,
useRef,
useEffect,
useMemo,
useContext,
memo,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function expensiveFilter(rows, query) {
// Deliberately redundant work makes a repeatable profiling target.
let result = [];
for (let repeat = 0; repeat < 200; repeat++)
result = rows.filter((row) => row.includes(query));
return result;
}
const rows = Array.from({ length: 2500 }, (_, i) => "note-" + i);
function PerformanceCase({ optimized }) {
const [count, setCount] = useState(0);
const [query, setQuery] = useState("1");
const calculate = () => {
const begin = performance.now();
const result = expensiveFilter(rows, query);
window.lab.measurements.push({ optimized, ms: performance.now() - begin });
return result;
};
const cached = useMemo(calculate, [query]);
const result = optimized ? cached : calculate();
return e(
"section",
null,
e("h2", null, optimized ? "缓存派生计算" : "每次重复计算"),
e("input", {
"aria-label": optimized ? "optimized query" : "baseline query",
value: query,
onChange: (event) => setQuery(event.target.value),
}),
e(
"button",
{ id: optimized ? "fast" : "slow", onClick: () => setCount((v) => v + 1) },
"无关计数 " + count,
),
e("p", null, result.length + " 条结果"),
);
}
createRoot(document.querySelector("#root")).render(
e(
StrictMode,
null,
e(
"main",
null,
e(PerformanceCase, { optimized: false }),
e(PerformanceCase, { optimized: true }),
),
),
);
数据在模块外固定,筛选条件是 query。优化版本使用 useMemo,query 不变时可以复用结果;基线版本每次渲染重新执行筛选。
为了让两组 Hook 的顺序一致,示例两边都声明了 cached,基线最后仍执行 calculate。这会影响首次渲染的计算次数,下面比较的是初次加载完成之后的五次无关计数更新。
window.lab.measurements 只用来调试,不驱动界面。真实业务组件不需要把计时记录写进渲染逻辑。
用相同操作录制
Chrome / Edge 打开页面,在 Performance 设置 4 倍 CPU 节流。加载完成后开始录制,点击基线的无关计数五次,停止并保存;优化版本也做相同操作。展开 Main 的任务和调用栈,看时间是否集中在 expensiveFilter。
也可以安装 npm install —save-dev —save-exact playwright@1.51.1,创建 profile.mjs 自动录制:
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
const files = new Map([
["/", ["index.html", "text/html"]],
["/dist/main.js", ["dist/main.js", "text/javascript"]],
]);
const server = createServer(async (req, res) => {
const file = files.get(new URL(req.url, "http://localhost").pathname);
if (!file) {
res.writeHead(404);
return res.end();
}
try {
res.setHeader("Content-Type", file[1]);
res.end(await readFile(new URL(file[0], import.meta.url)));
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
// Windows 用已安装的 Edge;也可以改成安装好的 Chromium。
const browser = await chromium.launch({ channel: "msedge", headless: true });
const summary = [];
try {
const page = await browser.newPage();
await page.goto("http://127.0.0.1:" + server.address().port);
await page.waitForFunction(() => window.lab.measurements.length > 0);
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
for (const mode of ["slow", "fast"]) {
await page.evaluate(() => {
window.lab.measurements = [];
});
await cdp.send("Tracing.start", {
categories: "toplevel,devtools.timeline,v8.execute,blink.user_timing",
transferMode: "ReturnAsStream",
});
for (let i = 0; i < 5; i++) await page.click("#" + mode);
const complete = new Promise((resolve) =>
cdp.once("Tracing.tracingComplete", resolve),
);
await cdp.send("Tracing.end");
const { stream } = await complete;
let data = "";
while (true) {
const part = await cdp.send("IO.read", { handle: stream });
data += part.data;
if (part.eof) break;
}
await cdp.send("IO.close", { handle: stream });
await writeFile("trace-" + mode + ".json", data);
const trace = JSON.parse(data);
const main = trace.traceEvents.find(
(event) => event.name === "thread_name" && event.args?.name === "CrRendererMain",
);
if (!main) throw new Error("找不到渲染主线程");
const longTasks = trace.traceEvents.filter(
(event) =>
event.name?.includes("RunTask") && event.dur > 50000 && event.tid === main.tid,
).length;
summary.push({
mode,
longTasks,
samples: await page.evaluate(() => window.lab.measurements),
});
}
console.log(
JSON.stringify({ browser: browser.version(), cpuThrottle: 4, summary }, null, 2),
);
} finally {
await browser.close();
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
运行 node profile.mjs,生成两份 trace。可以导入 Performance 面板,不必只信终端统计。这里按渲染主线程中超过 50ms 的 RunTask 统计长任务,trace 中的其他事件不能重复累加成总耗时。
数据只对应这次负载
React 18.3.1 开发构建、根节点 StrictMode、Edge 155.0.4283.45、4 倍 CPU 节流,2500 条字符串,每次筛选重复 200 遍。五次点击对应十次基线计算,开发模式的重复渲染检查影响了次数。
本次录制的基线计算为 48.6–94.0ms,主线程超过 50ms 的 RunTask 为 10 个;优化版本为 0 个,筛选计算记录为 0。数字只对应这个人为负载和一次本机录制,不代表业务页面会有相同比例的改善。
优化一侧在这些无关更新里没有再次筛选。如果 query 真改变,还是要计算。长任务数量和耗时会受本机状态影响,应以自己生成的 trace 为准,不能把这个小页面当成产品性能数据。
useMemo 没把工作搬出主线程,也不是每个地方都需要加。输入一直变化时,可能更需要减少算法成本、拆分任务或使用 Worker。确认是哪段工作重复执行,再选处理方法。面板使用方法参考 Chrome Performance 文档。