React 与 Vue 运行时优化
从更新边界、稳定引用、状态所有权、列表 key、批量更新到 React Compiler 和 Vue v-memo。
React 与 Vue 运行时优化
1. 优化目标:少做无效工作
框架优化不是“每个组件都包一层 memo”,而是找出一次状态变化实际影响了哪些视图:
状态变化 -> 组件更新/虚拟节点创建 -> Diff -> 提交 DOM/副作用 -> 浏览器布局绘制
先用 Profiler、Performance 面板和业务操作复现,再判断瓶颈在组件更新、昂贵计算、DOM 提交还是浏览器渲染。没有测量时,优先改善状态所有权和数据流,而不是堆缓存 API。
2. React 更新边界
2.1 React.memo、PureComponent 和比较函数
React.memo 对函数组件 props 做浅比较;PureComponent 对类组件 props/state 做浅比较。它们只在 props 引用稳定且组件重新渲染成本值得比较时有收益:
const Price = React.memo(function Price({ value }: { value: number }) {
return <span>{value.toFixed(2)}</span>
})
自定义比较函数要保持纯、快速,并完整比较影响渲染的字段:
const Row = React.memo(
function Row({ item, selected }: Props) {
return <div aria-selected={selected}>{item.name}</div>
},
(prev, next) => prev.item.id === next.item.id
&& prev.item.name === next.item.name
&& prev.selected === next.selected,
)
漏比较字段会导致界面停留在旧值,深比较又可能比重新渲染更贵。不要把“子组件没有重新 render”当作绝对目标:有时计算比较本身就是瓶颈。
2.2 稳定的派生值和回调
父组件每次 render 都创建新的对象、数组和函数,会使浅比较失效:
function Parent({ items, onPick }: Props) {
const visibleItems = React.useMemo(
() => items.filter((item) => item.enabled),
[items],
)
const handlePick = React.useCallback(
(id: string) => onPick(id),
[onPick],
)
return <List items={visibleItems} onPick={handlePick} />
}
useMemo/useCallback 有缓存维护和依赖正确性成本:
- 只在计算昂贵、引用稳定能跳过下游工作或依赖 API 要求稳定时使用;
- 依赖数组必须完整,不能为“稳定”故意省略依赖;
- 如果依赖每次都是新对象,先重构数据边界;
- 不能把缓存当作语义保证,React 允许在特定情况下丢弃 memoized 值。
必要时可用 useMemo 缓存一段昂贵的 JSX,但仍应以 profiler 证据为前提。
2.3 状态下放和订阅粒度
如果状态只被一小段子树使用,就把状态和交互组件提取到更低的公共祖先,避免每次输入都让昂贵兄弟树重新执行:
function Page() {
return (
<>
<FilterForm />
<ExpensiveReport />
</>
)
}
function FilterForm() {
const [color, setColor] = React.useState('red')
return (
<>
<input value={color} onChange={(event) => setColor(event.target.value)} />
<p style={{ color }}>Preview</p>
</>
)
}
跨层共享状态可采用 Context、外部 store 或发布者/订阅者模式,但要控制订阅粒度:Context value 改变时,读取该 Context 的消费者会更新;把大对象和高频状态放在一个 Context 中会扩大影响范围。可拆分 Context、提供 selector API 或把高频状态放在局部 store。
2.4 列表 key 与复用
key 是 Diff 识别同一列表项的身份,应使用稳定业务 ID:
items.map((item) => <Row key={item.id} item={item} />)
数组头部插入、排序或删除时,索引 key 可能让组件状态错配,也削弱节点复用。key 不需要全局唯一,只需在同一父节点的兄弟列表中稳定且唯一。列表很长时再结合窗口化/虚拟列表,见渲染与运行时优化。
2.5 提交阶段和 Effect
在 useLayoutEffect、生命周期或某些同步执行的 Effect 中调用 setState,可能导致额外 render/commit,并阻塞浏览器绘制。优先:
- 在 render 中直接计算派生值,不把它复制成 state;
- 用事件处理器更新由用户操作产生的状态;
- 只有必须读取 DOM 后才能确定的值才使用 layout effect,并尽量合并更新;
- Effect 订阅、定时器和 Observer 必须在 cleanup 中释放。
// 不推荐:把 props 派生值再写回 state,容易多一次更新
const [fullName, setFullName] = useState('')
useEffect(() => setFullName(`${first} ${last}`), [first, last])
// 推荐:render 时计算;若计算昂贵再使用 useMemo
const fullName = `${first} ${last}`
这也能避免“在 effect 中同步 setState 导致 cascading renders”的告警和额外提交。
2.6 批量更新与优先级
现代 React(尤其 React 18 的 createRoot)会在更多异步边界自动批量更新;不能再简单背诵“事件里异步、Promise 里同步”。旧版本或第三方调度边界可能需要 unstable_batchedUpdates,应以项目 React 版本和 profiler 为准。
需要让输入反馈先出现时,可以把非紧急更新标记为 transition:
const [isPending, startTransition] = React.useTransition()
function onChange(value: string) {
setQuery(value) // 紧急:立即更新输入框
startTransition(() => {
setDeferredQuery(value) // 非紧急:派生结果随低优先级更新
})
}
const results = useMemo(
() => filterLargeDataset(deferredQuery),
[deferredQuery],
)
startTransition 只改变状态更新的优先级,不会自动把 filterLargeDataset 变成可抢占任务;计算本身仍应通过 useMemo、分块调度或 Worker 降低主线程成本。setTimeout 也只是把任务推迟到下一个宏任务。
3. React Compiler
React Compiler 通过编译阶段分析组件依赖,自动插入部分 memoization,目标是减少手写 useMemo/useCallback 的负担。它不是性能保证,也不会修复错误的状态建模、过大的 Context、昂贵同步计算或副作用。
3.1 接入前检查
在存量项目中先运行官方健康检查(版本命令随工具发布变化):
npx react-compiler-healthcheck@latest
重点处理:不兼容库、违反 Hooks 规则、非纯 render、依赖隐式可变数据等问题。开发和生产应设置不同的错误阈值,并保留关闭编译器的回滚开关。
3.2 Vite/Babel 配置示意
import babel from 'vite-plugin-babel'
import type { Plugin } from 'vite'
export function reactCompilerPlugin(): Plugin {
const development = process.env.NODE_ENV === 'development'
return babel({
babelConfig: {
presets: ['@babel/preset-react'],
plugins: [[
'babel-plugin-react-compiler',
{
compilationMode: 'infer',
panicThreshold: development ? 'critical_errors' : 'none',
target: '19',
},
]],
},
})
}
接入时固定 React、编译器、Babel/Vite 插件版本,检查 source map 和构建产物,并用代表性页面比较 render 次数、INP、包体积和错误率。不同 React 版本的 runtime 依赖和配置字段可能变化,不能照抄旧配置。
4. Vue 更新优化
4.1 稳定 props
让父组件先计算子组件真正关心的布尔值,避免所有列表项都订阅高频的原始状态:
<ListItem
v-for="item in list"
:key="item.id"
:id="item.id"
:active="item.id === activeId"
/>
相比让每个 ListItem 同时接收整个 activeId,稳定的 active prop 能缩小更新范围。传递大对象时要留意对象引用和深层响应式开销。
4.2 v-once
v-once 将一棵子树视为静态内容,后续更新跳过它:
<section v-once>
<h2>{{ initialTitle }}</h2>
<p>{{ initialDescription }}</p>
</section>
只有确认内容在组件生命周期内不会变化时才使用;误用会让界面不再响应更新。
4.3 v-memo
v-memo 根据固定长度依赖数组跳过子树更新,适合大型 v-for 的微优化:
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<p>{{ item.id }} - {{ item.id === selectedId }}</p>
</div>
依赖数组必须包含所有影响该子树输出的值;漏依赖会显示旧内容。Vue 官方将其定位为性能至上场景的优化,不应替代合理组件拆分、分页和虚拟列表。
4.4 计算属性与事件
computed 缓存纯派生值,避免在模板中重复昂贵计算;不要在 computed 中执行网络请求或修改状态。高频 scroll/input 事件需防抖/节流,并在 onUnmounted 清理监听器和定时器。
5. 性能验证矩阵
| 改动 | 关注指标 | 必测边界 |
|---|---|---|
| memo/props 稳定 | render 次数、INP、CPU | props 深比较成本、字段遗漏 |
| 状态下放/Context 拆分 | 交互延迟、更新组件数 | 路由切换、订阅清理、SSR 隔离 |
| 列表 key/虚拟化 | 滚动帧率、内存 | 插入/删除/排序、键盘焦点、动态高度 |
| transition/任务拆分 | INP、长任务、完成时间 | 更新被打断、错误回滚、低端设备 |
| React Compiler/v-memo | 构建错误、运行时指标 | 不兼容库、旧浏览器、关闭开关回滚 |
6. 面试追问速答
Q: useMemo 和 React.memo 有什么区别?
A: useMemo 缓存组件内部的值或 JSX,React.memo 包装组件并基于 props 比较决定是否执行;二者都依赖稳定引用和正确依赖,不能脱离 profiler 盲目使用。
Q: Context 为什么会导致大面积更新?
A: Provider 的 value 引用改变时,读取该 Context 的消费者会收到更新。拆分 Context、稳定 value、selector store 或下放状态可以缩小订阅范围;memo 不能阻止消费者因 Context 改变而更新。
Q: React 事件外的 setState 一定不批量吗?
A: 取决于 React 版本和 root API。React 18 createRoot 已扩大自动批量范围,旧版本和特殊调度边界不同;应以实际版本和 profiler 验证,不要背旧结论。
Q: React Compiler 是否替代所有手写优化?
A: 不能。它主要自动化部分 memoization,仍要求组件纯、Hooks 规则正确,并不能解决状态边界、数据结构、长任务和 DOM/网络瓶颈;接入要做健康检查、灰度和可回滚验证。
7. 资料来源与现有专题
本篇综合性能 PDF 关于 React 不必要更新、PureComponent/React.memo、shouldComponentUpdate、稳定 props、发布订阅、状态下放、key、批量/优先级更新、IntersectionObserver、Vue v-once/v-memo 和 React Compiler 的内容,并按 React 18+ 与当前 Web Vitals 口径修正边界。相关专题:React 组件性能优化、Vue3 性能提升、渲染与运行时优化。