Old-School Front-End · Frameworks / 2026 ・ No.08
古法前端入门 框架篇
第8讲 重绘的成本 ── memo、useCallback、React Compiler,还有浏览器
发布
第 5 讲数过:输入 1 个字,组件就全部重绘。第 8 讲测量这到底花了多少时间,并比较 memo・useCallback・React Compiler 1.0 能减少多少。
先说答案:申请表单的 3 行,无论哪种写法都约 2 毫秒,没有必要改。超过 100 行才出现差别。而在 1000 行时,即使让 React 变快,浏览器的绘制仍然原样留下。
改之前先测
新井输入 1 个字就全部重绘,太浪费了吧。我给所有组件都加上 memo!
古井加之前,你能说出现在要花几毫秒吗?
全 15 讲路线图(现在:第 8 讲)
# 申请表单(明细 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 和上次相同时,不调用组件函数,直接用上次的结果。
结果正如②,1 行都没减少。反而因为比较 props 的工夫,稍微变慢了(JS 47 → 52 毫秒)。
memo 逐个比较 props
原因是 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 ?? ''}`); // 没能改的理由
}
}
第 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 个字的时间
无论 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 可以确认编译器怎么处理了各组件。
结 — 一页总结与下一讲
- 改之前用生产用测量。申请表单的 3 行约 2 毫秒,不需要改。开发用约慢 6.5 倍。
- 只用 memo 减不下来。每次绘制都传新函数,props 每次都不同。
- 用 useCallback 和 prev => 固定函数,1000 行时 JS 47 → 3 毫秒。
- React Compiler 只要改成 prev 形式就 47 → 7 毫秒。不过,它悄悄跳过了含 try/catch 的 App。
- 即使让 React 变快,浏览器的绘制(约 50 毫秒)仍会留下。要减少,就减少绘制的行。
新井幸好没给所有组件都加 memo……对了,我想把登录者的名字也显示在页头里。
古井那就要从 App 用 props 传好几层了。下一讲是状态的存放位置。用 Context 和 useReducer,让画面也掌握审批状态的变化。
参考
- React — memo
- React — useCallback
- React — useMemo
- React — <Profiler>
- React — React Compiler
- React Blog — React Compiler v1.0(2025-10-07)
- @vitejs/plugin-react — React Compiler
- web.dev — Rendering performance
记录是笔者于 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