•
阅读时长 4 分钟

React 页面卡顿:用 Performance 找到重复计算

目录

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 文档。