上周接了个老项目的需求,一个数据看板页面,几十个图表组件加上实时数据刷新。产品跟我说"就加个筛选功能",我打开页面一看——输入框打个字都要卡半秒,切换 Tab 直接白屏一秒多。Chrome DevTools 的 Performance 面板录了一下,一次筛选触发了 200+ 次组件 re-render,我当场就坐不住了。
花了差不多两天把这个页面从"卡成 PPT"优化到能用的状态,踩了不少坑,记录一下整个过程。
先搞清楚:到底卡在哪
很多人一提 React 性能优化就开始无脑加 React.memo、useMemo,这是最容易踩坑的做法。优化的第一步永远是定位瓶颈,不是猜。
我的排查流程:
graph TD
A[页面卡顿] --> B[Chrome Performance 录制]
B --> C{主线程长任务在哪?}
C -->|渲染| D[React DevTools Profiler]
C -->|计算| E[检查数据处理逻辑]
C -->|网络| F[检查请求瀑布流]
D --> G[找到不必要的 re-render]
E --> H[找到重复计算]
G --> I[针对性优化]
H --> I
React DevTools Profiler
装上 React DevTools,切到 Profiler 标签页,勾选「Record why each component rendered」,录制一次用户操作。
在这个项目里,录制结果让我傻眼:
- 改一个筛选条件,所有图表组件都重新渲染了
- 每个图表组件内部的工具栏、图例、标题也跟着渲染
- 一个隐藏的 Tab 里的组件竟然也在渲染
根因很清楚:状态提升过高 + 缺少渲染隔离。
第一刀:拆分 Context
老代码是这样的——一个巨大的 DashboardContext,里面塞了筛选条件、用户信息、主题配置、图表数据。改任何一个值,消费这个 Context 的所有组件全部 re-render。
// ❌ 反面教材:一个 Context 装所有东西
const DashboardContext = createContext();
function DashboardProvider({ children }) {
const [filters, setFilters] = useState({});
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
const [chartsData, setChartsData] = useState([]);
return (
<DashboardContext.Provider value={{
filters, setFilters,
theme, setTheme,
user, setUser,
chartsData, setChartsData,
}}>
{children}
</DashboardContext.Provider>
);
}
改筛选条件 → filters 变了 → Provider 的 value 是新对象 → 所有 useContext(DashboardContext) 的组件重新渲染。哪怕某个组件只用了 theme,也逃不掉。
拆开:
// ✅ 按更新频率拆分 Context
const FilterContext = createContext();
const ThemeContext = createContext();
const UserContext = createContext();
function FilterProvider({ children }) {
const [filters, setFilters] = useState({});
return (
<FilterContext.Provider value={{ filters, setFilters }}>
{children}
</FilterContext.Provider>
);
}
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 组合使用
function AppProviders({ children }) {
return (
<UserProvider>
<ThemeProvider>
<FilterProvider>
{children}
</FilterProvider>
</ThemeProvider>
</UserProvider>
);
}
这一刀下去,改筛选条件只有消费 FilterContext 的组件 re-render,图表的工具栏、主题相关组件纹丝不动。Profiler 里的渲染次数直接从 200+ 降到 40 多。
第二刀:React.memo + useMemo,但别乱用
Context 拆完之后,还有一批组件是通过 props 传数据的,父组件 re-render 照样带着它们一起渲染。这时候才轮到 React.memo 上场。
这里有个坑:props 里有引用类型(对象、数组、函数),React.memo 的浅比较根本拦不住。
// ❌ 这样写 React.memo 等于没写
function Dashboard() {
const [filters, setFilters] = useState({});
// 每次 render 都是新的对象引用
const chartConfig = {
showLegend: true,
animate: true,
};
// 每次 render 都是新的函数引用
const handleClick = (item) => {
console.log(item);
};
return <Chart config={chartConfig} onClick={handleClick} />;
}
const Chart = React.memo(({ config, onClick }) => {
// 每次 Dashboard re-render,这里照样 re-render
// 因为 config 和 onClick 每次都是新引用
return <div>...</div>;
});
正确做法:
// ✅ 配合 useMemo 和 useCallback
function Dashboard() {
const [filters, setFilters] = useState({});
const chartConfig = useMemo(() => ({
showLegend: true,
animate: true,
}), []); // 依赖为空,只创建一次
const handleClick = useCallback((item) => {
console.log(item);
}, []); // 依赖为空,只创建一次
return <Chart config={chartConfig} onClick={handleClick} />;
}
const Chart = React.memo(({ config, onClick }) => {
// 现在只有 config 或 onClick 真正变化时才 re-render
return <div>...</div>;
});
什么时候该用 useMemo/useCallback:
| 场景 | 是否需要 | 原因 |
|---|---|---|
| 传给 memo 子组件的对象/函数 | ✅ 需要 | 保证引用稳定,memo 才生效 |
| 计算量大的派生数据(排序、过滤、聚合) | ✅ 需要 | 避免每次 render 重复计算 |
| 组件内部的简单变量 | ❌ 不需要 | useMemo 本身有开销,不值得 |
| 不传给子组件的函数 | ❌ 不需要 | 没人比较它的引用 |
第三刀:列表虚拟化
优化完 re-render 之后,有一个组件还是卡——一个数据表格,2000 多行。就算不 re-render,首次挂载也慢,因为一口气创建了 2000 个 DOM 节点。
用 react-window 做虚拟滚动,只渲染可视区域内的几十行:
import { FixedSizeList as List } from 'react-window';
function VirtualTable({ data, columns }) {
const Row = ({ index, style }) => {
const item = data[index];
return (
<div style={style} className="table-row">
{columns.map(col => (
<span key={col.key} className="table-cell">
{item[col.key]}
</span>
))}
</div>
);
};
return (
<List
height={600} // 可视区域高度
itemCount={data.length}
itemSize={48} // 每行高度
width="100%"
>
{Row}
</List>
);
}
效果:2000 行表格,首次渲染从 800ms 降到 30ms。滚动时也丝滑,因为始终只有 15-20 个 DOM 节点在页面上。
行高不固定就用 VariableSizeList,传一个 itemSize 函数。我这里行高固定,FixedSizeList 够了。
第四刀:懒加载隐藏的 Tab 内容
隐藏 Tab 里的组件也在渲染,因为老代码用 CSS display: none 来"隐藏" Tab,组件其实一直挂载着。
// ❌ CSS 隐藏,组件还活着,数据更新照样 re-render
<div style={{ display: activeTab === 'chart' ? 'block' : 'none' }}>
<HeavyChartPanel />
</div>
<div style={{ display: activeTab === 'table' ? 'block' : 'none' }}>
<HeavyTablePanel />
</div>
改成条件渲染,未激活的 Tab 直接不挂载:
// ✅ 条件渲染,未激活的 Tab 不挂载
{activeTab === 'chart' && <HeavyChartPanel />}
{activeTab === 'table' && <HeavyTablePanel />}
但这样切换 Tab 时每次都要重新挂载和请求数据,切换频繁的话体验反而更差。
折中方案——首次访问时才挂载,之后保持不卸载:
function LazyTab({ active, children }) {
const [hasBeenActive, setHasBeenActive] = useState(false);
useEffect(() => {
if (active && !hasBeenActive) {
setHasBeenActive(true);
}
}, [active, hasBeenActive]);
if (!hasBeenActive) return null;
return (
<div style={{ display: active ? 'block' : 'none' }}>
{children}
</div>
);
}
// 使用
<LazyTab active={activeTab === 'chart'}>
<HeavyChartPanel />
</LazyTab>
<LazyTab active={activeTab === 'table'}>
<HeavyTablePanel />
</LazyTab>
首次没点过的 Tab 不渲染,点过之后用 CSS 隐藏保留状态。
第五刀:防抖输入
筛选输入框每敲一个字就触发状态更新 → 整个筛选链路重新计算 → 图表重新渲染。打字快的话一秒能触发 5-6 次。
// ✅ 用 useDeferredValue 或手动防抖
import { useDeferredValue, useState, useMemo } from 'react';
function FilterInput({ data }) {
const [input, setInput] = useState('');
const deferredInput = useDeferredValue(input);
// 用 deferredInput 做计算,不会阻塞输入
const filteredData = useMemo(() => {
return data.filter(item =>
item.name.toLowerCase().includes(deferredInput.toLowerCase())
);
}, [data, deferredInput]);
return (
<>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入关键词筛选..."
/>
<DataTable data={filteredData} />
</>
);
}
useDeferredValue 是 React 18+ 自带的,比手写 debounce 更优雅——浏览器空闲时才更新 deferred 值,输入框响应完全不受影响。
项目还在 React 17 的话,用 lodash 的 debounce:
import { debounce } from 'lodash-es';
const debouncedSearch = useMemo(
() => debounce((value) => setSearchTerm(value), 300),
[]
);
优化效果
全部做完跑了一遍 Profiler,对比数据:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 筛选操作 re-render 次数 | 200+ | 12 | 94% ↓ |
| 输入框交互延迟 | 500ms+ | <16ms | 丝滑 |
| 表格首次渲染 | 800ms | 30ms | 96% ↓ |
| Tab 切换白屏 | 1.2s | <100ms | 92% ↓ |
| Lighthouse Performance 分数 | 42 | 89 | +47 |
踩坑记录
坑 1:React.memo 的比较函数别写太复杂
我一开始给一个组件写了自定义比较函数,里面做了深比较。结果深比较本身比 re-render 还慢,适得其反。如果 props 层级超过两层,与其深比较不如在上层把数据 flatten 好再传下来。
坑 2:useMemo 里不要有副作用
有个同事在 useMemo 里塞了个埋点上报。这东西在 Strict Mode 下会执行两次,线上偶尔也会因为 React 的并发特性出问题。useMemo 只做纯计算,副作用一律走 useEffect。
坑 3:react-window 和 CSS-in-JS 的冲突
项目用了 styled-components,虚拟列表的行组件如果用 styled 包裹,每次滚动都会生成新的 className 注入 <style> 标签,反而更卡。改成 CSS Modules 就好了。虚拟列表这种高频渲染的场景,CSS-in-JS 的运行时开销真的吃不消。
小结
核心就三步:先测量再优化(Profiler 和 Performance 面板是起点)、减少不必要的 re-render(拆 Context、memo、稳定引用)、减少单次 render 的工作量(虚拟化、懒加载、防抖)。
这套组合拳能解决 90% 的 React 性能问题。剩下 10% 的极端场景,可能需要上 zustand 替换 Context,或者用 react-virtuoso 替换 react-window,甚至考虑 Web Worker 做重计算。
但对大部分项目来说,做好这五刀就够了。能跑到 60fps 就收手,多出来的时间不如去写单测。