上周接了个老项目的需求一个数据看板页面几十个图表组件加上实时数据刷新。产品跟我说就加个筛选功能我打开页面一看——输入框打个字都要卡半秒切换 Tab 直接白屏一秒多。Chrome DevTools 的 Performance 面板录了一下一次筛选触发了 200 次组件 re-render我当场就坐不住了。花了差不多两天把这个页面从卡成 PPT优化到能用的状态踩了不少坑记录一下整个过程。先搞清楚到底卡在哪很多人一提 React 性能优化就开始无脑加React.memo、useMemo这是最容易踩坑的做法。优化的第一步永远是定位瓶颈不是猜。我的排查流程渲染计算网络页面卡顿Chrome Performance 录制主线程长任务在哪?React DevTools Profiler检查数据处理逻辑检查请求瀑布流找到不必要的 re-render找到重复计算针对性优化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} classNametable-row {columns.map(col ( span key{col.key} classNametable-cell {item[col.key]} /span ))} /div ); }; return ( List height{600} // 可视区域高度 itemCount{data.length} itemSize{48} // 每行高度 width100% {Row} /List ); }效果2000 行表格首次渲染从 800ms 降到 30ms。滚动时也丝滑因为始终只有 15-20 个 DOM 节点在页面上。行高不固定就用VariableSizeList传一个itemSize函数。我这里行高固定FixedSizeList够了。第四刀懒加载隐藏的 Tab 内容隐藏 Tab 里的组件也在渲染因为老代码用 CSSdisplay: 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 的debounceimport { debounce } from lodash-es; const debouncedSearch useMemo( () debounce((value) setSearchTerm(value), 300), [] );优化效果全部做完跑了一遍 Profiler对比数据指标优化前优化后提升筛选操作 re-render 次数2001294% ↓输入框交互延迟500ms16ms丝滑表格首次渲染800ms30ms96% ↓Tab 切换白屏1.2s100ms92% ↓Lighthouse Performance 分数428947踩坑记录坑 1React.memo 的比较函数别写太复杂我一开始给一个组件写了自定义比较函数里面做了深比较。结果深比较本身比 re-render 还慢适得其反。如果 props 层级超过两层与其深比较不如在上层把数据 flatten 好再传下来。坑 2useMemo 里不要有副作用有个同事在useMemo里塞了个埋点上报。这东西在 Strict Mode 下会执行两次线上偶尔也会因为 React 的并发特性出问题。useMemo只做纯计算副作用一律走useEffect。坑 3react-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 就收手多出来的时间不如去写单测。