Old-School Front-End · Frameworks / 2026 ・ No.08

古法前端入门 框架篇

第8讲 重绘的成本 ── memo、useCallback、React Compiler,还有浏览器

发布

浏览器画面。明细 n=1000 how=memocb。第 1 行金额为 1472012345,右边是红色的 5。从第 2 行起都是灰色的 0
1000 行中,重绘的只有输入的那 1 行。这是 memo 和 useCallback 组合使用时(笔者于 2026 年 11 月 3 日在云端 Linux 上拍摄。各行右边的数字是该行 ItemRow 被调用的次数,为照片补写。窗口上方的标题栏也是为了说明而补画的)

第 5 讲数过:输入 1 个字,组件就全部重绘。第 8 讲测量这到底花了多少时间,并比较 memo・useCallback・React Compiler 1.0 能减少多少。
先说答案:申请表单的 3 行,无论哪种写法都约 2 毫秒,没有必要改。超过 100 行才出现差别。而在 1000 行时,即使让 React 变快,浏览器的绘制仍然原样留下。

一

改之前先测

新井输入 1 个字就全部重绘,太浪费了吧。我给所有组件都加上 memo!

古井加之前,你能说出现在要花几毫秒吗?

全 15 讲路线图(现在:第 8 讲)

1 为什么要框架 2 jQuery→MVC 3 React 以后 4 React 第一步 5 状态与重绘 6 表单 7 获取数据 8 重绘的代价 9 状态放哪里 10 页面切换 11 乐观更新 12 Server Comp. 13 缓存 14 测试 15 发布与选择 当前位置 缘由与历史 React 基础 速度与位置 页面与服务器 Next.js 收尾
# 申请表单(明细 3 行)中,第 1 行金额每输入 1 个字被调用的次数(生产用)
  组件原样Compiler(第 7 讲的 App) Compiler(改好的 App)
  App               1                         1                         1
  AboutSection      1                         1                         0
  ItemsSection      1                         1                         1
  ItemTable         1                         1                         1
  ItemRow           3                         3                         3
  TotalLine         1                         1                         1
  ReceiptSection    1                         1                         0
  SendButtons       1                         1                         0
  JS(1 个字)1.95 ms                   2.09 ms                   1.81 ms
# 3 行时都在 2 ms 左右。人分辨不出

在第 7 讲的申请表单(明细 3 行)里,测量每输入 1 个字的 JavaScript 时间,约 2 毫秒。假设 1 秒绘制画面 60 次,每次能用的约 16 毫秒。连它的八分之一都没用到。在这里动手,谁也察觉不到。这次增加行数,确认从哪里开始能察觉到。

二

测量的工具

const onRender = (id, phase, actualDuration, baseDuration) => …; // 记录绘制的时间(毫秒)

<Profiler id="rows" onRender={onRender}>
  <table> … </table>
  <TotalLine … />
</Profiler>

React 有一个叫 <Profiler> 的组件,里面每次绘制,都会告诉我们花了多少时间(actualDuration)。React Developer Tools 的 Profiler 标签也是用同样的机制记录的。

// 第 8 讲:为了测量的开关(平时两个都不加)
//   COMPILER=1 … 经过 React Compiler(Babel 插件)
//   PROFILE=1  … 使用在生产构建里也会调用 <Profiler> 的 onRender 的 react-dom
const compiler = process.env.COMPILER === '1'
const profile = process.env.PROFILE === '1'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), ...(compiler ? [babel({ presets: [reactCompilerPreset()] })] : [])],
  resolve: profile ? { alias: { 'react-dom/client': 'react-dom/profiling' } } : {},
  server: { … },
  build: {
    rolldownOptions: { input: { main: 'index.html', lab: 'lab.html' } }, // 第 8 讲:实验画面也用生产方式构建
  },
})

不过在生产用构建里,Profiler 平时什么都不做。换成 react-dom/profiling 版,生产用也能测量。不在开发用下测,是因为正如第三节看到的,开发用为了 StrictMode 和检查要慢好几倍。时间和第 3 讲一样,从 Chromium 的记录(tracing)分成“JavaScript”和“绘制(Layout・Paint 等)”,求出每个字的时间。

三

增加行数

实验画面 lab.html?lab=rows 可以把和申请表单相同的 ItemRow 和 TotalLine 增加到 10・100・1000 行。

# lab.html?lab=rows。第 1 行金额每输入 1 个字的时间(生产用、Chromium 的记录、3 次的中位数)
  行数JS          绘制(浏览器)ItemRow 被调用
  102.34 ms     1.88 ms10
  1007.2 ms      10.95 ms100
  100047.15 ms    50.92 ms1000
  1000(开发用)308.59 ms   54.63 ms2000
# 绘制是 Layout(布局计算)和 Paint(绘制)。1000 行时每个字合计约 100 ms
# 开发用因 StrictMode 调用 2 次、还要跑开发用检查,JS 约为生产用的 6.5 倍

100 行时 JS 7 毫秒,1000 行时 47 毫秒。除了输入的那行,其他 999 行每次也都调用了 ItemRow。而容易忽略的是右边一列:浏览器的绘制也花了 51 毫秒。合计约 100 毫秒,输入的字稍晚才出现,肉眼就能看出来。

四

只用 memo 减不下来

按新井的方案,用 memo 包住 ItemRow。memo 在 props 和上次相同时,不调用组件函数,直接用上次的结果。

3 个小浏览器画面。都是 1000 行明细,在第 1 行金额输入 5 个字之后。① 原样:每行右边都是 5。② 只用 memo:每行也是 5。③ memo 加 useCallback:只有第 1 行是 5,其他是 0
只加 memo,1 行都没减少(笔者于 2026 年 11 月 3 日在云端 Linux 上拍摄。各行右边的数字是该行 ItemRow 被调用的次数,为照片补写。窗口上方的标题栏也是为了说明而补画的)

结果正如②,1 行都没减少。反而因为比较 props 的工夫,稍微变慢了(JS 47 → 52 毫秒)。

memo 逐个比较 props

② 只用 memo 上次的 props 这次的 props row row = n n = errorOf errorOf ≠ onChange onChange ≠ onRemove onRemove ≠ 有 1 个不同 → 绘制(1000 行全部) ③ memo + useCallback 上次的 props 这次的 props row row = n n = errorOf errorOf = onChange onChange = onRemove onRemove = 全部相同 → 不调用(只画输入的行) 比较方法是“是不是同一个”(Object.is)。即使内容相同,新创建的函数也是不同的 把函数固定为 1 个的是 useCallback。用 prev => … 形式就不用 rows,可以固定

原因是 App 每次绘制都重新创建的函数。onChange、onRemove、errorOf 即使内容相同,每次也都是新创建的另一个函数。memo 的比较方法是“是不是同一个”,所以判断为不同。

五

把函数固定为 1 个

const MemoRow = memo(ItemRow); // props 和上次相同,就不调用 ItemRow 的函数

  // plain・memo:和第 7 讲的 App 写法相同。每次绘制都会产生新的函数
  const changeEach = (id, name, value) => setRows(rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  const removeEach = (id) => setRows(rows.filter((r) => r.id !== id));
  const errorOfEach = () => undefined;

  // memocb:改成接收上一个值的形式(prev => …)就不用 rows,可以把函数固定为 1 个
  const changeOnce = useCallback((id, name, value) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [name]: value } : r))), []);
  const removeOnce = useCallback((id) => setRows((prev) => prev.filter((r) => r.id !== id)), []);

useCallback 只创建 1 次函数,下次绘制也返回同一个。不过,如果在里面用 rows,rows 每次变化时不重新创建就会看到旧的 rows。所以改成第 5 讲的④,接收上一个值的形式(setRows((prev) => …))。不用 rows,函数就可以一直是 1 个。

# 1000 行时,第 1 行金额每输入 1 个字(生产用、3 次的中位数)
  写法                          JS        绘制      React     ItemRow
  ① 原样47.1      50.9      23.6      1000
  ② 只用 memo52.0      54.1      26.7      1000
  ③ memo + useCallback3.0       51.9      1.5       1
  ④ Compiler(①原样)39.9      48.8      20.2      1000
  ⑤ Compiler(只改 prev =>)6.9       49.8      5.5       1000
  ⑥ Compiler + ③3.1       50.5      1.4       1
  ⑦ ③ + 只画看得见的行2.6       2.9       1.2       1
# 单位是 ms。React 是 <Profiler> 的 actualDuration(用 profiling 版构建另外测量)。ItemRow 是被调用的次数

在③里,每个字被调用的 ItemRow 变成 1 个,JS 从 47 毫秒降到 3 毫秒(React 的部分 23.6 → 1.5 毫秒)。memo 要等 props 都对齐了才有效,这就是第四、五节的答案。

# 按行数比较(JS,每个字 ms)
  写法                          10 行     100 行    1000 行
  ① 原样2.3       7.2       47.1
  ② 只用 memo1.8       9.5       52.0
  ③ memo + useCallback1.8       1.3       3.0
  ④ Compiler(①原样)2.4       6.1       39.9
  ⑥ Compiler + ③1.4       1.3       3.1
# 10 行时,哪种写法都在 2 ms 左右。从 100 行起才看得出差别
六

交给 React Compiler

考虑在哪里加 memo 和 useCallback 很费事。2025 年 10 月发布 1.0 的 React Compiler,会在构建时改写组件,自动加入“和上次相同就用上次结果”的处理。在 Vite 里,像第二节的设置那样作为 Babel 插件加入(COMPILER=1)。

第五节表里的④〜⑥就是结果。代码保持①不变的④,47 → 40 毫秒,只减少了一点。因为函数用了 rows,编译器也只能每次重新创建。可是只把函数改成 prev => 形式的⑤(既不写 memo 也不写 useCallback),降到了 7 毫秒。ItemRow 的函数被调用了 1000 次,但内容和上次相同,编译器立刻返回记住的结果。和手写的③组合的⑥,和③几乎相同。

# npm run build(3 次)和申请表单的 JavaScript(main-*.js)
                    构建时间main-*.js
  不用 Compiler1.35〜1.95 s11.91 kB
  使用 Compiler3.06〜3.48 s15.48 kB  + compiler-runtime 0.23 kB
# React 本体等共同部分(211 kB)相同。Compiler 加上了用于记忆的容器和比较的处理

代价是构建时间约为 2 倍,申请表单的 JavaScript 增加了 3.6 kB。

七

编译器跳过了 App

请再看一次第一节的表。即使经过 Compiler,用第 7 讲的 App 时,8 个组件全都被调用了。让编译器按组件显示它做了什么。

// 显示 React Compiler 把哪些组件改成了能记住的形式(或没能改)(第 8 讲)
// 用法:node scripts/rc-log.mjs src/App.jsx src/components/*.jsx
import { transformSync } from '@babel/core';
import fs from 'node:fs';
import path from 'node:path';

for (const file of process.argv.slice(2)) {
  const events = [];
  transformSync(fs.readFileSync(file, 'utf8'), {
    filename: file, babelrc: false, configFile: false, parserOpts: { plugins: ['jsx'] },
    plugins: [['babel-plugin-react-compiler', { logger: { logEvent: (_, e) => events.push(e) } }]],
  });
  for (const e of events) {
    if (e.kind === 'CompileSuccess') console.log(`${path.basename(file).padEnd(20)}${e.kind.padEnd(16)}${e.fnName}  slots=${e.memoSlots}`);
    else console.log(`${path.basename(file).padEnd(20)}${e.kind}\n  └ ${e.detail?.reason ?? e.detail?.options?.reason ?? ''}`); // 没能改的理由
  }
}
终端画面。node scripts/rc-log.mjs before/App.jsx 的结果是 App.jsx CompileError,理由是 Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement。把 try/catch 移到组件外之后的 src/App.jsx 和各组件,App slots=73、ItemRow slots=72 等全部 CompileSuccess
第 7 讲的 App.jsx 被 React Compiler 悄悄跳过了(笔者于 2026 年 11 月 3 日在云端 Linux 上拍摄。各行右边的数字是该行 ItemRow 被调用的次数,为照片补写。窗口上方的标题栏也是为了说明而补画的)

第 7 讲的 App.jsx 是 CompileError,原样(以什么都不记住的形式)构建了。理由是“还不支持 try/catch 里的条件表达式(?? 等)”。就是第 7 讲 submitClaim 里为了接收 401 写的 try/catch。构建成功,也没有警告,不这样检查就发现不了。

/** 发送给 Spring Boot。登录失效时返回 null(第 8 讲:把 try/catch 移到组件外面,免得 React Compiler 跳过 App) */
async function trySend(body, files) {
  try {
    return await sendClaim(body, files);
  } catch (e) {
    if (e instanceof SignedOut) return null;
    throw e;
  }
}

// App 里面
  // 第 8 讲:改成接收上一个值(prev)的形式。不用 rows,所以 React Compiler 能把函数固定为 1 个
  const change = (id, name, value) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  const add = () => {
    const row = newRow(); // 新行在外面创建(prev => … 里面要做到调用 2 次结果也相同)
    setRows((prev) => [...prev, row]);
  };
  const remove = (id) => {
    const empty = newRow();
    setRows((prev) => {
      const rest = prev.filter((r) => r.id !== id);
      return rest.length > 0 ? rest : [empty];
    });
  };

把 try/catch 移到组件外的函数 trySend 后,App 也 CompileSuccess 了,正如第一节表的右列,事由和收据的组件不再被调用。同时把 change・add・remove 也改成 prev => 形式。新行在外面创建再传入,因为 prev => … 里面即使在 StrictMode 下被调用 2 次,结果也必须相同。

八

剩下的是浏览器的部分

1000 行时每输入 1 个字的时间

JS(React 和组件) 绘制(浏览器的 Layout・Paint) ① 原样 98 ms ② 只用 memo 106 ms ③ memo + useCallback 55 ms ④ Compiler(①原样) 89 ms ⑤ Compiler(prev =>) 57 ms ⑥ Compiler + ③ 54 ms ⑦ ③ + 只画看得见的行 5 ms memo 和 Compiler 减少的只有红色(JS)。只要有 1000 行,灰色(绘制)就会留下 要减少灰色,就得减少让浏览器绘制的行本身(⑦:只画看得见的 20 行) 生产用构建,从 Chromium 的记录求每个字的时间(3 次的中位数)

无论 memo 还是 Compiler,减少的只有红色部分(JS),灰色的绘制约 50 毫秒原样留下。看记录,Layout 22 毫秒,Paint 20 毫秒。1000 行的表里 1 个栏的文字一变,浏览器就重新计算整个表的布局并重新绘制(用 table-layout: fixed 或放进固定高度的盒子,几乎没变)。

要减少,只能减少交给浏览器的行本身。⑦是在 600px 高的盒子里,只画看得见的 20 行,上下用相同高度的空行填满(列表画面常用的“虚拟滚动”思路)。每个字从 98 → 5 毫秒。

# 只用 Node.js 测合计计算(calcTotal)2000 次,每次的时间
  10 行9.2 µs
  100 行50.0 µs
  1000 行453.6 µs
# 1000 行也只要 0.45 ms。和每个字的 100 ms 相比,用 useMemo 记住没有意义

顺便测了合计计算该不该用 useMemo 记住。1000 行也只要 0.45 毫秒。useMemo 等“测过发现计算很重”再用就够了。

九

在这个应用里

  • 不手写 memo・useCallback ── 明细最多 10 行。10 行时哪种写法都约 2 毫秒(第五节第 2 个表)
  • 写成不会被编译器跳过的形式 ── try/catch 移到组件外,状态用 prev =>。两者都没有坏处,可读性也不变
  • 暂不一直启用 Compiler ── 构建约慢 2 倍,3 行时却看不出差别。只保留 COMPILER=1 的开关,在第 15 讲决定发布形式时再考虑
  • 做行数会增加的画面(申请列表等)时,先减少绘制的行 ── 分页,或只画看得见的行
十

在自己的电脑上

在 React Developer Tools 的 Profiler 标签按“录制”,在第 1 行金额输入几个字后停止,就会用条形显示各组件的时间。在齿轮设置里启用“Record why each component rendered while profiling.”,还会显示“Props changed: (onChange)”这样的重绘理由。在第四节只用 memo 时看,原因函数的名字会直接显示出来。

要在生产用下测,就用 PROFILE=1 npm run build 和 npm run preview;要试 Compiler,就加上 COMPILER=1。用 npm run rc-log 可以确认编译器怎么处理了各组件。

结 — 一页总结与下一讲

  1. 改之前用生产用测量。申请表单的 3 行约 2 毫秒,不需要改。开发用约慢 6.5 倍。
  2. 只用 memo 减不下来。每次绘制都传新函数,props 每次都不同。
  3. 用 useCallback 和 prev => 固定函数,1000 行时 JS 47 → 3 毫秒。
  4. React Compiler 只要改成 prev 形式就 47 → 7 毫秒。不过,它悄悄跳过了含 try/catch 的 App。
  5. 即使让 React 变快,浏览器的绘制(约 50 毫秒)仍会留下。要减少,就减少绘制的行。

新井幸好没给所有组件都加 memo……对了,我想把登录者的名字也显示在页头里。

古井那就要从 App 用 props 传好几层了。下一讲是状态的存放位置。用 Context 和 useReducer,让画面也掌握审批状态的变化。

参考

记录是笔者于 2026 年 11 月 3 日在云端 Linux 上取得的。在第 7 讲的项目(Vite 8.3.5、React 19.3.1)上加入实验画面和 babel-plugin-react-compiler 1.0.0(@rolldown/plugin-babel 0.2.4、@babel/core 8.1.0)。用 npm run preview 提供生产用构建,用 Playwright 驱动的 Chromium 153.0.8010.12 打开,逐字发送 input 事件。时间从 Chromium 的记录(devtools.timeline)求出每个字的值,取 3 次的中位数。测量申请表单时连接了后端篇的 Spring Boot。

古法前端入门 框架篇 第8讲 — 重绘的成本 / 2026.11

← 返回文章列表