React 性能优化实战:我把一个卡成 PPT 的页面优化到丝滑的全过程

18 阅读6分钟

上周接了个老项目的需求,一个数据看板页面,几十个图表组件加上实时数据刷新。产品跟我说"就加个筛选功能",我打开页面一看——输入框打个字都要卡半秒,切换 Tab 直接白屏一秒多。Chrome DevTools 的 Performance 面板录了一下,一次筛选触发了 200+ 次组件 re-render,我当场就坐不住了。

花了差不多两天把这个页面从"卡成 PPT"优化到能用的状态,踩了不少坑,记录一下整个过程。

先搞清楚:到底卡在哪

很多人一提 React 性能优化就开始无脑加 React.memouseMemo,这是最容易踩坑的做法。优化的第一步永远是定位瓶颈,不是猜。

我的排查流程:

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+1294% ↓
输入框交互延迟500ms+<16ms丝滑
表格首次渲染800ms30ms96% ↓
Tab 切换白屏1.2s<100ms92% ↓
Lighthouse Performance 分数4289+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 就收手,多出来的时间不如去写单测。