React 基础体系 · 第 15/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
React 性能优化的核心不是“让每个组件都更快”,而是减少不必要的工作,并让必要的工作出现在正确的时间、正确的边界内。
一个更新通常包含以下几类成本:
- 触发更新:状态、属性、Context 或外部 store 发生变化。
- Render 阶段:React 调用组件函数,生成新的 React 元素树,并通过 Fiber 计算差异。
- Commit 阶段:React 将必要的 DOM 变化提交到浏览器,并执行相关副作用。
- 浏览器阶段:样式计算、布局、绘制、合成,以及 JavaScript、网络和内存带来的额外成本。
- 初始加载成本:下载、解析、编译和执行 JavaScript,以及服务端渲染后的 hydration。
因此,“页面慢”可能来自完全不同的问题:
- 组件 Render 次数太多;
- 每次 Render 的计算太重;
- Commit 修改了过多 DOM;
- 列表同时创建了数千个节点;
- Bundle 太大,导致首次交互延迟;
- 服务端输出很快,但客户端 hydration 仍然需要执行大量代码;
- 并发更新、Suspense 或 Transition 使用不当,导致用户感知到等待或输入延迟。
Profiler 用于确认问题在哪里,memo 和更新边界用于减少传播范围,长列表技术用于减少同时存在的节点,Bundle 优化用于减少客户端必须下载和执行的代码。它们解决的是不同层次的问题,不能互相替代。
一、先建立正确的性能模型:Render 不等于 DOM 更新
1. React 的 Render 阶段
当组件更新时,React 会重新执行一部分组件函数。例如:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
点击按钮后,React 至少需要重新执行 Counter,得到新的元素:
<button>1</button>
然后 React 会比较更新前后的结构,发现只有文本内容从 0 变成 1,最终只修改对应的 DOM 文本节点。
因此:
- 组件函数被调用,表示发生了 Render 工作;
- DOM 被修改,表示 Commit 阶段产生了实际宿主环境变化;
- Render 发生,不代表整个子树都被重新创建成 DOM;
- DOM 没有变化,也不代表 Render 阶段没有成本。
一个常见误判是:
“这个组件每次都重新执行,所以 DOM 一定被重新渲染了。”
实际上,React 的 Reconciliation 会尽量复用已有 Fiber 和 DOM。性能问题可能出在组件函数内部昂贵的计算,而不一定出在 DOM 操作。
2. Render 和 Commit 的因果关系
可以把一次更新抽象成:
其中:
- :事件处理、状态更新排队等成本;
- :组件执行、元素创建、Reconciliation 成本;
- :DOM 属性、文本、事件和 Ref 等提交成本;
- :布局、绘制和合成成本。
memo 主要尝试降低 ,并可能间接减少 ;虚拟列表主要减少 DOM 数量,影响 、 和浏览器布局成本;Bundle 优化主要影响初始加载和代码执行,不会自动解决一次更新中昂贵的计算。
3. “重新渲染”的准确含义
在 React 语境中,“重新渲染”通常指组件函数再次执行,或者 Fiber 子树再次参与工作。它不等于:
- 重新下载代码;
- 重新创建所有 DOM;
- 浏览器整页刷新;
- 所有子组件都必然产生实际 DOM 变化。
后文区分“组件是否执行”“Fiber 是否继续工作”和“DOM 是否提交”,否则很容易错误使用 memo 或误读 Profiler 数据。
二、Profiler:先测量,再决定优化方向
1. Profiler 测量什么
React 提供了 <Profiler> 组件,用于测量其子树在一次提交中的渲染表现:
import { Profiler, type ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
) => {
console.table({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
export function App() {
return (
<Profiler id="product-page" onRender={onRender}>
<ProductPage />
</Profiler>
);
}
回调中的关键参数含义如下:
id:当前 Profiler 的标识;phase:通常是"mount"或"update";actualDuration:这次提交中实际渲染该子树所花的时间;baseDuration:React 对该子树在没有优化性跳过时的基准渲染成本估计;startTime:本次 React 渲染开始的时间;commitTime:本次提交发生的时间。
actualDuration 不是整个页面从点击到可交互的总耗时,它主要反映 React 渲染阶段的成本。网络、事件处理、浏览器布局和绘制需要通过浏览器 Performance 工具等其他手段测量。
2. actualDuration 和 baseDuration 的关系
假设一个页面包含搜索框和 1000 个结果项:
function SearchPage({ query }: { query: string }) {
const results = searchProducts(query);
return (
<>
<SearchInput />
<ResultList results={results} />
</>
);
}
在输入框更新时:
- 如果结果列表也参与了大量 Render,
actualDuration可能较高; - 如果使用
memo或拆分更新边界,结果列表被跳过,actualDuration可能下降; baseDuration仍可能较高,因为它表达的是在缺少跳过时的估计基线。
所以:
不能直接把 baseDuration 当成真实耗时,也不能只看某次偶然采样得出结论。
3. Profiler 的正确测量流程
一次有意义的测量需要固定输入和操作:
- 使用生产构建或接近生产的环境测试;
- 记录初始加载和交互更新,不要只测页面首次打开;
- 固定数据规模,例如 100、1000、10000 条;
- 重复同一操作,例如连续输入同样长度的查询词;
- 对比优化前后的
actualDuration、交互延迟和内存; - 同时观察浏览器 Performance 面板中的 Long Task、布局和绘制。
开发环境中的 StrictMode 可能为了发现副作用问题而额外调用某些逻辑,因此开发环境中的调用次数和耗时不能直接当作生产数据。开发工具本身也会增加开销。
4. 用 Profiler 定位,而不是把它当成优化器
例如,Profiler 显示:
id phase actualDuration
product-page update 18.4ms
result-list update 16.9ms
search-input update 0.2ms
这说明:
- 输入框本身不是主要成本;
ResultList是这次 React Render 的主要成本;- 下一步应查看结果列表是否因为输入变化而重新计算、是否创建了大量元素、是否有昂贵的排序或格式化;
- 不能仅凭这些数据断言 DOM 提交或浏览器布局就是瓶颈。
如果浏览器 Performance 显示 React 只花了 3ms,但一次点击任务总计 80ms,剩余时间可能来自事件处理、JSON 解析、布局、第三方库或其他脚本。
5. 服务端和客户端的边界
React <Profiler> 测量的是 React 客户端渲染树中的工作。对于服务端渲染:
- 服务端生成 HTML 的时间需要在服务端请求、渲染和日志系统中测量;
- 客户端 hydration 的 React 工作可以通过浏览器 Performance 和客户端 Profiler 观察;
- Server Component 在服务端执行,不能用浏览器中的
<Profiler>测量其服务端执行耗时; - 浏览器中只存在发送到客户端的 Client Component 代码和最终客户端树的一部分。
因此,SSR 页面“首字节很快”不代表客户端已经完成 hydration,也不代表用户立即可以交互。
三、Memo:只有在“跳过成本低于重算成本”时才有价值
1. memo 的基本机制
memo 接收一个组件,返回一个具有属性比较能力的组件:
import { memo } from 'react';
type RowProps = {
id: string;
name: string;
selected: boolean;
onSelect: (id: string) => void;
};
const ProductRow = memo(function ProductRow({
id,
name,
selected,
onSelect,
}: RowProps) {
console.log('render row', id);
return (
<button
aria-pressed={selected}
onClick={() => onSelect(id)}
>
{name}
</button>
);
});
当父组件重新 Render 时,React 会比较 ProductRow 的新旧 Props。如果所有 Props 都相等,React 可以跳过该组件的重新渲染。
默认比较不是深比较,而是逐个使用类似 Object.is 的浅层比较:
Object.is(previousProps.name, nextProps.name);
Object.is(previousProps.selected, nextProps.selected);
Object.is(previousProps.onSelect, nextProps.onSelect);
因此,下面两个对象内容相同,但引用不同:
const previous = { id: '1', name: 'Keyboard' };
const next = { id: '1', name: 'Keyboard' };
Object.is(previous, next); // false
如果每次父组件 Render 都创建新的对象或函数,memo 就可能无法跳过。
2. memo 的形式化条件
设组件本次重新计算的成本为 ,比较 Props 的成本为 ,组件实际更新的概率为 。
使用 memo 的期望成本可以粗略写成:
不使用 memo 的成本是:
只有当:
也就是:
时,memo 才可能带来收益。
直觉是:
- 组件足够昂贵;
- 父组件经常更新;
- 子组件的 Props 经常保持相同;
- 比较 Props 的成本明显低于重新计算组件。
对于只有一个简单 <span> 的组件,比较 Props 可能比直接执行组件还贵。对于包含复杂表格、图表或大量计算的稳定子树,memo 更可能有价值。
3. 让引用稳定:useCallback 和 useMemo
以下代码中,onSelect 每次父组件 Render 都是新函数:
function ProductList({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
return products.map((product) => (
<ProductRow
key={product.id}
id={product.id}
name={product.name}
selected={product.id === selectedId}
onSelect={(id) => setSelectedId(id)}
/>
));
}
即使 ProductRow 使用了 memo,onSelect 也会导致每一行 Props 变化。
可以将回调稳定下来:
function ProductList({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return products.map((product) => (
<ProductRow
key={product.id}
id={product.id}
name={product.name}
selected={product.id === selectedId}
onSelect={handleSelect}
/>
));
}
useCallback 的作用不是让函数执行更快,而是让函数引用在依赖不变时保持稳定。它本身也有依赖管理和缓存成本,因此不应机械地包裹所有函数。
同样,以下对象每次都会产生新引用:
<ProductRow
options={{ dense: true, color: 'blue' }}
/>
如果对象实际代表稳定配置,可以使用:
const options = useMemo(
() => ({ dense: true, color: 'blue' }),
[],
);
但更简单的方式通常是直接传递原始值:
<ProductRow dense color="blue" />
4. memo 不是组件内部状态的缓存
const Panel = memo(function Panel({ title }: { title: string }) {
const [open, setOpen] = useState(false);
return (
<section>
<h2>{title}</h2>
<button onClick={() => setOpen((value) => !value)}>
{open ? '关闭' : '打开'}
</button>
</section>
);
});
当 Panel 自己的 open 状态更新时,它仍然会重新 Render。memo 只用于父级传入 Props 没有变化时跳过来自父级的更新传播,不能阻止组件自身状态更新。
Context 也是类似边界:
const ThemeContext = createContext<'light' | 'dark'>('light');
const Button = memo(function Button() {
const theme = useContext(ThemeContext);
return <button data-theme={theme}>保存</button>;
});
即使 Button 没有 Props,只要它读取的 Context 值变化,它仍然需要更新。memo 不能屏蔽组件实际读取的 Context 变化。
5. children 是常见的 memo 失效来源
const Card = memo(function Card({
children,
}: {
children: React.ReactNode;
}) {
return <div className="card">{children}</div>;
});
function Page() {
return (
<Card>
<ExpensiveContent />
</Card>
);
}
<ExpensiveContent /> 每次父组件执行时都会产生新的 React 元素对象,因此 children Props 的引用通常不同。此时 Card 的 memo 可能无法跳过。
这不表示 children 用法错误,而是说明:
- React 元素也是对象;
- Props 比较默认比较引用;
- 稳定引用必须有明确的收益场景;
- 不要为了让每个
children引用稳定而增加复杂缓存。
6. 自定义比较函数的风险
可以传入第二个参数:
const UserRow = memo(
function UserRow({ user }: { user: User }) {
return <div>{user.name}</div>;
},
(prev, next) => prev.user.id === next.user.id,
);
这段代码只有在“用户 ID 相同就代表该行所有显示内容都相同”时才正确。如果 user.name 可能变化,而比较函数仍返回 true,界面就会显示旧数据。
一个正确的比较函数必须满足:
它不是“对象 ID 相同就跳过”的通用优化。比较函数本身如果做深比较,可能在数据较大时比直接 Render 更慢;如果比较函数读取可变对象,还会隐藏状态变化,造成陈旧 UI。
7. React Compiler 与手写 Memo
React 生态正在提供编译器能力,用于在满足条件时自动进行部分记忆化优化。是否启用、支持哪些语法、构建工具如何配置,取决于具体 React 版本和框架配置,不能假设所有项目都默认开启。
即使使用编译器,也仍然需要理解:
- 不可变数据和稳定数据流;
- 纯组件;
- 正确的 Effect 依赖;
- Context 传播;
- 列表 Key;
- Bundle 和服务器边界。
编译器可以减少手写 memo、useMemo、useCallback 的数量,但不会把一个错误的深度比较函数变正确,也不会把 10000 个 DOM 节点变成 30 个可见节点。
四、更新边界:性能优化的真正单位是“状态传播范围”
1. 什么是更新边界
更新边界是指一次状态变化能够影响到的组件子树范围。
例如:
function Page() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
<Footer />
</>
);
}
query 位于 Page,所以输入时 Page 会更新,两个子树都有机会参与 Render。即使 Footer 没有使用 query,它仍可能被父组件更新传播。
把状态下移:
function SearchArea() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
function Page() {
return (
<>
<SearchArea />
<Footer />
</>
);
}
现在输入只触发 SearchArea 子树更新,Footer 位于更新边界之外。
状态下移通常比“给所有子组件加 memo”更直接,因为它从数据流层面减少了需要参与更新的 Fiber 数量。
2. 状态提升与状态下移的取舍
状态应放在所有需要共享它的组件的最近公共祖先,但不应为了方便而提升到过高层级。
错误倾向:
function App() {
const [isTooltipOpen, setTooltipOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedTab, setSelectedTab] = useState('all');
const [modal, setModal] = useState<ModalState>(null);
return <EntireApplication />;
}
如果这些状态只被局部区域使用,把它们集中放在顶层会扩大每次更新的传播范围。
更合理的拆分是:
- 搜索状态放在搜索区域;
- Tab 状态放在 Tab 区域;
- Tooltip 状态放在 Tooltip 所属组件;
- 真正跨页面共享的状态才放入更高层或外部 store。
这不是“状态越低越好”,而是让状态的作用域与业务依赖一致。
3. Context 是隐式的更新广播
Context 方便跨层传值,但 Provider 的 value 改变时,读取该 Context 的组件都可能更新:
function App() {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value = { user, theme, setTheme };
return (
<AppContext.Provider value={value}>
<Routes />
</AppContext.Provider>
);
}
每次 App Render 时,value 都是新对象。即使 user 和 theme 没变,Context value 的引用也变了。
可以先稳定对象:
const value = useMemo(
() => ({ user, theme, setTheme }),
[user, theme],
);
但这只解决 Provider value 引用变化,不能改变“所有读取该 Context 的组件都依赖这个整体对象”的事实。
更有效的拆分通常是多个 Context:
<AuthContext.Provider value={authValue}>
<ThemeContext.Provider value={themeValue}>
<Routes />
</ThemeContext.Provider>
</AuthContext.Provider>
如果某组件只读取主题,就不必因为用户信息变化而参与同一个 Context 的更新。
需要注意,memo 不能阻止组件读取的 Context 变化。若希望细粒度订阅,应使用拆分 Context、选择器型 store,或把 Context 读取放在外层,再把稳定的原始值传给 memo 子组件。
4. key 是列表更新边界的一部分
列表的 key 用于描述“这个位置上的元素对应哪个逻辑实体”:
items.map((item) => (
<Row key={item.id} item={item} />
));
使用索引作为 Key:
items.map((item, index) => (
<Row key={index} item={item} />
));
在列表头部插入、删除或排序时,会导致旧 Fiber 与新数据错误对应。后果不只是不必要的渲染,还可能包括:
- 输入框状态出现在错误行;
- 动画状态错位;
- 组件本地状态跟随位置而不是实体;
- React 无法正确复用对应子树。
Key 不要求全局唯一,只要求在同一个兄弟列表中稳定且能标识实体。它也不是为了“让 React 更快”而随意生成的随机值:
key={Math.random()}
随机 Key 会让每次 Render 都像卸载并重新挂载,破坏 DOM、状态和缓存复用。
五、并发更新与更新边界:让输入保持可响应
React 的并发能力允许某些更新被推迟、打断或重新开始,但它不会减少业务计算本身。它主要改变工作调度和用户感知。
1. useTransition:标记非紧急更新
搜索场景中,输入框的值应立即更新,而结果列表可以作为较低优先级更新:
import { useState, useTransition } from 'react';
function SearchBox({
onChange,
}: {
onChange: (value: string) => void;
}) {
const [text, setText] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextText = event.target.value;
setText(nextText);
startTransition(() => {
onChange(nextText);
});
}
return (
<>
<input value={text} onChange={handleChange} />
{isPending && <span>更新结果中…</span>}
</>
);
}
这里有两个逻辑:
setText是紧急更新,保证输入框跟手;onChange触发的结果更新被标记为 Transition,React 可以在更高优先级输入到来时优先处理输入。
Transition 不是后台线程,也不会自动把同步计算移出主线程。如果 onChange 内部同步执行了一个巨大的排序,仍可能阻塞 JavaScript 主线程。此时需要进一步减少计算、使用 Web Worker,或采用服务端查询。
2. useDeferredValue:延后读取某个值
也可以保留即时输入值,再产生一个延迟使用的值:
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<SearchResults query={deferredQuery} />
</>
);
}
在输入快速变化时:
- 输入框使用最新的
query; - 结果区域可以暂时使用旧的
deferredQuery; - React 在合适时机追上最新结果。
这会产生短暂的“输入内容已变化,但结果还没跟上”的状态,因此结果区域应提供合适的加载或过渡视觉反馈。
3. 一致性要求
并发渲染可能在 Commit 前多次计算或放弃某次计算,因此 Render 阶段必须保持纯净:
function Price({ amount }: { amount: number }) {
// 适合:根据输入计算结果
const formatted = amount.toFixed(2);
return <span>{formatted}</span>;
}
不要在 Render 阶段执行不可逆副作用:
let requestCount = 0;
function BadComponent() {
requestCount += 1; // Render 可能重复或被放弃,不应依赖这里发生一次
return null;
}
网络请求、订阅、DOM 操作等副作用应放在合适的 Effect、事件处理器或框架的数据加载机制中。否则并发 Render、StrictMode 检查或错误重试都可能造成重复副作用。
六、长列表:减少“同时存在”的工作,而不是只优化每一行
1. 为什么 10000 个简单节点也会变慢
假设列表有 条数据,每一行平均产生 个 React 元素和 个 DOM 节点,那么初始工作量可以近似理解为:
当 时,即使每行很简单,总工作仍然线性增长。滚动时还可能出现:
- 大量 DOM 节点参与布局;
- 内存占用增加;
- 样式匹配成本增加;
- 更新时需要比较更多 Fiber;
- 屏幕阅读器或查找功能面对非常大的 DOM 树。
给每一行加 memo 只能减少部分更新成本,不能减少首次创建 10000 行的成本。
2. 虚拟化的核心思想
虚拟列表只渲染视口附近的窗口:
总数据: [0 ... 99999]
可视窗口: [320 ... 345]
额外缓冲区: [300 ... 365]
实际 DOM: 只创建约 66 行
可见范围通常根据滚动位置 、视口高度 、行高 计算:
其中:
- :滚动容器的垂直滚动距离;
- :容器可视高度;
- :固定行高;
- :数据总数。
实际实现通常还会增加 overscan,避免快速滚动时出现空白。
3. 一个固定高度的可运行示例
下面的示例不依赖第三方库,展示虚拟列表的基本机制:
import {
useEffect,
useMemo,
useState,
} from 'react';
type Item = {
id: string;
name: string;
};
type VirtualListProps = {
items: Item[];
height: number;
rowHeight: number;
overscan?: number;
};
export function VirtualList({
items,
height,
rowHeight,
overscan = 4,
}: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);
const range = useMemo(() => {
const firstVisible = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(height / rowHeight);
const start = Math.max(0, firstVisible - overscan);
const end = Math.min(
items.length,
firstVisible + visibleCount + overscan,
);
return { start, end };
}, [height, items.length, overscan, rowHeight, scrollTop]);
const visibleItems = items.slice(range.start, range.end);
return (
<div
style={{
height,
overflowY: 'auto',
position: 'relative',
}}
onScroll={(event) => {
setScrollTop(event.currentTarget.scrollTop);
}}
>
<div
style={{
height: items.length * rowHeight,
position: 'relative',
}}
>
{visibleItems.map((item, offset) => {
const index = range.start + offset;
return (
<div
key={item.id}
style={{
height: rowHeight,
left: 0,
position: 'absolute',
right: 0,
top: index * rowHeight,
}}
>
{item.name}
</div>
);
})}
</div>
</div>
);
}
使用方式:
const items: Item[] = Array.from(
{ length: 100_000 },
(_, index) => ({
id: `item-${index}`,
name: `Item ${index}`,
}),
);
export function Demo() {
return (
<VirtualList
items={items}
height={480}
rowHeight={36}
overscan={6}
/>
);
}
每一步成立的原因是:
- 外层容器固定可视高度并允许滚动;
- 内层容器使用总高度,保持滚动条比例正确;
- 可见行使用绝对定位,位置由全量索引乘以行高决定;
- React 只为
[start, end)范围创建元素; key使用实体 ID,使数据与 Fiber 的对应关系稳定;- 滚动时只更新
scrollTop,重新计算窗口。
这个示例的前置条件是每行高度固定。如果行高不固定,index * rowHeight 不成立,需要测量实际高度并维护前缀高度或使用成熟的动态虚拟化方案。
4. 虚拟化的真实边界
虚拟化并非无条件适用:
- 浏览器查找只能找到当前挂载的节点;
- 需要完整 DOM 的导出、打印和无障碍场景需要额外处理;
- 锚定滚动位置可能在动态高度变化时跳动;
- 行内输入框、焦点和本地状态需要稳定 Key;
- 服务端渲染只能预渲染一个初始窗口,客户端滚动后再补充;
- 容器高度为
auto时,计算可见范围更复杂; - 快速滚动时 overscan 太小会出现白屏,太大则失去虚拟化收益。
可访问性方面,虚拟列表应正确表达列表语义和位置,例如必要时使用 aria-setsize、aria-posinset,并确保键盘焦点不会因为节点被回收而丢失。具体属性需要结合组件角色和屏幕阅读器测试,不能只添加属性而不验证实际交互。
5. 分页、无限滚动和虚拟化不是同一件事
这三者解决不同问题:
- 分页:减少一次从服务端获取的数据量和客户端状态量;
- 无限滚动:按需继续获取数据;
- 虚拟化:即使客户端已有大量数据,也只挂载可见窗口。
一个生产列表可能同时使用三者:
服务端分页获取 100 条
↓
客户端累计 5000 条
↓
虚拟列表只挂载当前约 40 条
如果数据仍在服务端,虚拟化不能减少网络传输;如果一次已经把百万条数据放进内存,虚拟化也不能解决 JSON 解析和内存占用问题。
七、更新边界与长列表结合:避免滚动和选择状态互相传播
列表性能通常同时受三类更新影响:
- 滚动位置变化;
- 查询条件变化;
- 单行选中、编辑或悬停变化。
如果所有状态都放在列表顶层:
function ListPage() {
const [scrollTop, setScrollTop] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
// 所有状态变化都可能让整个列表父组件重新执行
}
可以按照职责拆分:
- 滚动状态只由虚拟列表内部管理;
- 查询状态由搜索区域管理;
- 选中状态由列表容器管理;
- 行组件使用
memo,只让受影响行更新。
type RowProps = {
item: Item;
selected: boolean;
onSelect: (id: string) => void;
};
const Row = memo(function Row({
item,
selected,
onSelect,
}: RowProps) {
return (
<button
aria-pressed={selected}
onClick={() => onSelect(item.id)}
>
{item.name}
</button>
);
});
如果选中一个新 ID,通常只有旧选中行和新选中行的 selected 值发生变化,其他行的 Props 可以保持稳定。这个收益依赖于:
- 行使用稳定的实体 Key;
items中未变化的对象保持引用;onSelect引用稳定;- 没有一个每次都变化的整体对象作为 Row Props。
八、Bundle:性能不只发生在 Render 之后
1. Bundle 成本由哪些阶段组成
客户端 JavaScript 的实际成本可以分解为:
其中:
download:网络下载;parse:解析 JavaScript;compile:引擎编译;execute:模块初始化和业务代码执行;hydrate:将服务端 HTML 与客户端 React 逻辑连接起来。
压缩后体积只影响下载的一部分。一个压缩文件较小但包含大量初始化代码,仍可能造成执行和 hydration 延迟。
2. Tree Shaking 的成立条件
Tree Shaking 依赖静态模块依赖关系和构建工具的分析。例如:
// math.ts
export function add(a: number, b: number) {
return a + b;
}
export function expensiveChartRuntime() {
// ...
}
import { add } from './math';
console.log(add(1, 2));
在模块是 ESM、构建工具能分析副作用的前提下,未使用的导出通常有机会被删除。
但以下写法可能增加分析难度:
const moduleName = './math';
const module = require(moduleName);
或者包声明了不准确的副作用信息。不能仅凭“使用了 ESM”就保证最终文件一定最小,必须通过构建产物分析验证。
常见影响 Bundle 的因素包括:
- 从大包入口导入单个功能,但包没有良好拆分;
- 引入仅在一个页面使用的编辑器、图表或日期库;
- Polyfill 和国际化数据全部打入主包;
- CSS、字体和 WebAssembly 被忽略;
- CommonJS 依赖阻碍 Tree Shaking;
sideEffects配置错误导致必要副作用被误删。
3. Code Splitting 与 React.lazy
不应把只在某个低频页面使用的代码放入首屏主 Bundle:
import { lazy, Suspense } from 'react';
const AnalyticsChart = lazy(
() => import('./AnalyticsChart'),
);
export function AnalyticsPage() {
return (
<Suspense fallback={<p>图表加载中…</p>}>
<AnalyticsChart />
</Suspense>
);
}
这里的过程是:
- 首屏 Bundle 不必同步包含
AnalyticsChart的完整实现; - 第一次渲染到
AnalyticsChart时,动态 import 返回 Promise; - Lazy 组件在模块加载期间暂停;
- 最近的
Suspense显示fallback; - 模块加载完成后,React 继续渲染并提交真实图表。
代码分割并不等于“免费变快”。它会产生新的网络请求和加载边界。如果用户几乎必然会访问图表,过度拆分可能导致点击后才开始下载,反而增加等待。应根据路由、用户行为和网络情况决定分割粒度。
错误边界也必须与懒加载配合:
<ErrorBoundary fallback={<p>图表加载失败,请重试。</p>}>
<Suspense fallback={<p>图表加载中…</p>}>
<AnalyticsChart />
</Suspense>
</ErrorBoundary>
Suspense 负责“还在等待”,Error Boundary 负责“加载或渲染失败”。网络失败、模块执行错误和组件内部异常不能用同一个 Loading 状态掩盖。
4. Suspense 不是通用数据缓存
Suspense 可以协调懒加载、框架提供的数据加载能力以及其他符合 Suspense 协议的资源,但单独把普通 Promise 放进组件 Render 中并不能自动形成正确的生产级缓存:
function Bad() {
const data = fetch('/api/data').then((response) => response.json());
// 每次 Render 都可能创建新的 Promise
return null;
}
如果 Promise 没有稳定缓存,重新 Render 可能重复发起请求,甚至造成无限暂停。数据获取应使用框架的数据加载机制或明确实现资源缓存,并处理取消、错误和重试。
5. 路由级拆分通常是第一层边界
一个典型应用可以按照路由拆包:
主 Bundle
├── App Shell
├── 路由基础设施
└── 登录页必要代码
按需 Chunk
├── Dashboard
├── Editor
├── AnalyticsChart
└── Admin
路由级拆分的优势在于边界天然清晰。用户访问 /login 时,不必下载管理后台的编辑器和图表运行时。
组件级拆分适合大型、低频或条件性功能,例如:
- 富文本编辑器;
- 图表库;
- 地图 SDK;
- PDF 预览;
- 管理员专属面板。
但首屏关键组件不应为了追求更小的初始 Bundle 而被拆到点击后才加载。性能目标是降低用户完成任务的总成本,而不是单独追求某个 JS 文件的最小体积。
九、客户端与服务端边界:减少 Bundle 的最有效方式是“不发送”
1. Server Component 与 Client Component 的区别
在支持 React Server Components 的框架中,Server Component 在服务端执行,其代码通常不需要发送到浏览器。Client Component 则需要发送到客户端,以支持:
useState;useEffect;- 浏览器事件;
- 访问浏览器 API;
- 交互式 UI。
因此,服务端边界不仅影响 Render 位置,也影响 Bundle 组成。
例如,产品详情页可以让服务端负责:
- 获取产品数据;
- 生成静态展示内容;
- 执行数据库查询或服务端格式化。
只有购买按钮、数量选择器等交互部分放入 Client Component。
概念结构如下:
Server ProductPage
├── Server ProductDetails
└── Client AddToCartButton
如果在顶层 Client Component 中导入整个页面树,即使其中一些组件没有交互,它们的依赖也可能进入客户端依赖图。边界应尽可能放在真正需要浏览器能力的位置,而不是把整个页面标记为客户端组件。
2. 服务端执行不等于客户端零成本
即使展示内容由服务端生成,客户端仍可能需要:
- 下载 Client Component 及其依赖;
- 对服务端输出进行 hydration;
- 建立事件处理;
- 加载交互所需的懒加载模块。
因此需要同时观察:
- HTML 首字节和完整响应时间;
- 初始 JavaScript 体积;
- hydration 任务时长;
- 首次输入延迟;
- 交互组件是否在可用时及时加载。
3. Hydration 不匹配的性能和正确性风险
服务端和客户端第一次渲染应生成一致的结构。以下写法容易产生不一致:
function Time() {
return <span>{new Date().toLocaleString()}</span>;
}
服务端和客户端的时区、时间点或语言环境可能不同,导致 hydration mismatch。随机数、依赖 window 的初始输出也有类似问题。
更稳妥的方式是:
- 服务端生成确定值并传给客户端;
- 将仅客户端可知的内容延迟到合适的客户端阶段;
- 使用框架提供的 hydration 安全方案;
- 不把 mismatch 警告当作普通日志忽略,因为它可能导致额外修复工作或交互异常。
十、常见失败方案:看似优化,实际扩大成本
1. 给所有组件加 memo
失败原因:
- 很多组件 Props 每次都变化;
- 组件本身很便宜;
- Context 或自身状态仍然会触发更新;
- 比较函数增加了额外成本;
- 代码可读性和依赖管理复杂度上升。
正确做法是先通过 Profiler 找出频繁更新且计算昂贵的稳定子树,再建立有证据的边界。
2. 用 useMemo 包裹所有计算
const value = useMemo(() => a + b, [a, b]);
如果 a + b 的成本远小于 Hook 依赖比较和缓存维护成本,这种写法没有实际收益。useMemo 更适合:
- 昂贵且纯的计算;
- 计算结果作为稳定引用传给
memo子组件; - 计算结果作为依赖,且稳定引用能避免下游大量工作。
它不是语义正确性工具。代码必须在没有缓存时也正确。
3. 通过随机 Key 强制刷新
<Component key={Date.now()} />
这会让 React 将其视为新实体,触发卸载和重新挂载。它可能暂时“清空状态”或绕过旧状态,但会增加 DOM、Effect 和资源成本,并隐藏真正的状态设计问题。
如果确实需要重置组件状态,应使用表达业务实体变化的稳定 Key,并明确知道重挂载会触发什么生命周期行为。
4. 虚拟化后仍然把全部数据同步加工
const rows = hugeData
.sort(expensiveComparator)
.map(expensiveTransform);
return <VirtualList items={rows} />;
虚拟化只减少渲染窗口,不会自动减少 sort 和 map 的成本。若每次输入都重新处理百万条数据,主要瓶颈仍在数据计算阶段。
应考虑:
- 服务端筛选和排序;
- 对纯计算结果做有证据的缓存;
- 增量计算;
- Web Worker;
- 分页后再虚拟化;
- 避免在输入的高优先级更新中同步执行大计算。
5. 只看 Bundle 大小,不看执行和交互
某个 Bundle 从 500 KB 降到 400 KB,不一定意味着用户体验按比例改善。需要验证:
- 主线程解析和执行时间;
- 首次输入延迟;
- hydration 时间;
- 用户真正访问的路由是否更快;
- 动态 Chunk 是否在关键交互时才开始下载;
- 缓存命中和网络失败表现。
十一、一个完整的诊断路径
面对“输入卡顿、列表慢、首屏慢”时,可以按因果路径排查。
场景一:输入框卡顿
- 浏览器 Performance 查看一次输入事件是否形成 Long Task;
- React Profiler 查看输入引起的哪些子树更新;
- 判断是:
- 大量组件 Render;
- 单个组件计算昂贵;
- 大量 DOM Commit;
- 浏览器布局或绘制;
- 如果结果区域不是紧急反馈,使用
useTransition或useDeferredValue; - 如果列表节点数量很大,使用虚拟化;
- 如果查询计算本身很重,考虑服务端查询或 Worker;
- 再用同样的数据规模重复测量。
场景二:列表首屏慢
- 统计列表数据量和最终 DOM 节点数;
- 用 Profiler 测量初始
mount的 React 成本; - 用 Performance 查看脚本执行、布局和绘制;
- 若所有节点同时挂载,分页、按需加载或虚拟化;
- 若单行计算昂贵,优化行组件和数据预处理;
- 若代码只有这个页面需要,考虑路由级或组件级拆包;
- 检查服务端渲染和 hydration 是否重复承担大量工作。
场景三:首屏代码很大
- 生成生产构建;
- 查看 Bundle 分析报告,按模块和路由确认体积来源;
- 检查是否把低频依赖放入主入口;
- 检查导入方式和包的 ESM、side effects 配置;
- 使用动态 import 拆分低频功能;
- 在支持 Server Components 的框架中,将纯展示和服务端数据访问留在服务端;
- 验证动态 Chunk 的 Loading、错误和重试路径。
十二、生产取舍:优化目标必须和用户任务一致
性能优化不是单一数字竞赛,而是不同成本之间的取舍:
memo减少计算,但增加 Props 稳定性要求;useMemo减少重复计算,但增加缓存和依赖复杂度;- 虚拟化减少 DOM,但增加焦点、测量、打印和无障碍复杂度;
- Code Splitting 减少首屏代码,但增加请求和加载边界;
- Server Component 减少客户端 Bundle,但要求明确客户端能力边界;
- Transition 改善交互响应,但不减少底层计算总量;
- 分页减少网络和内存,但改变用户浏览和搜索体验。
一个可靠的优化结论应至少包含:
问题:哪些操作慢
证据:Profiler / Performance / Bundle 分析结果
原因:具体是哪类 Render、Commit、脚本或网络成本
方案:改变了哪个更新边界或加载边界
验证:相同输入、相同数据规模下是否改善
风险:状态、焦点、错误、SSR、缓存或可访问性是否受影响
最终可以用一个简化判断来选择工具:
| 观测到的问题 | 优先考虑 |
|---|---|
| 子树频繁更新且 Props 稳定 | memo、稳定引用、拆分状态 |
| 状态变化传播范围太大 | 状态下移、拆分组件、拆分 Context |
| 单次计算本身很重 | 算法、缓存、Worker、服务端计算 |
| 同时存在的列表节点太多 | 分页、无限加载、虚拟化 |
| 首屏 JavaScript 太大 | Tree Shaking、路由拆包、动态 import |
| 纯展示代码被发送到浏览器 | Server/Client 边界调整 |
| 输入被结果更新阻塞 | Transition、Deferred Value、减少同步工作 |
| DOM 少但页面仍然卡 | 浏览器布局、绘制、第三方脚本或主线程任务 |
Profiler 告诉你 React 做了什么,更新边界决定 React 应该影响多大范围,Memo 决定哪些稳定子树可以跳过,虚拟化决定页面同时保留多少节点,Bundle 和服务端边界决定浏览器一开始必须下载和执行多少代码。只有把这些层次分开测量,优化才不会从“减少工作”变成“增加复杂度”。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 测试体系:Testing Library、Vitest、用户行为和端到端测试
- 下一篇:React 并发渲染:Transition、Deferred Value、Suspense 和一致性
- 延伸:React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论