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

上周接了个老项目的需求,一个数据看板页面,几十个图表组件加上实时数据刷新。产品跟我说"就加个筛选功能",我打开页面一看------输入框打个字都要卡半秒,切换 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。

jsx 复制代码
// ❌ 反面教材:一个 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,也逃不掉。

拆开:

jsx 复制代码
// ✅ 按更新频率拆分 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 的浅比较根本拦不住。

jsx 复制代码
// ❌ 这样写 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>;
});

正确做法:

jsx 复制代码
// ✅ 配合 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 做虚拟滚动,只渲染可视区域内的几十行:

jsx 复制代码
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,组件其实一直挂载着。

jsx 复制代码
// ❌ CSS 隐藏,组件还活着,数据更新照样 re-render
<div style={{ display: activeTab === 'chart' ? 'block' : 'none' }}>
 <HeavyChartPanel />
</div>
<div style={{ display: activeTab === 'table' ? 'block' : 'none' }}>
 <HeavyTablePanel />
</div>

改成条件渲染,未激活的 Tab 直接不挂载:

jsx 复制代码
// ✅ 条件渲染,未激活的 Tab 不挂载
{activeTab === 'chart' && <HeavyChartPanel />}
{activeTab === 'table' && <HeavyTablePanel />}

但这样切换 Tab 时每次都要重新挂载和请求数据,切换频繁的话体验反而更差。

折中方案------首次访问时才挂载,之后保持不卸载:

jsx 复制代码
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 次。

jsx 复制代码
// ✅ 用 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

jsx 复制代码
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 就收手,多出来的时间不如去写单测。

相关推荐
TrisighT13 小时前
Agent 并行工具调用快了 0.8 秒——200 组实测发现,多花的那 3100 token 全打了水漂
aigc·agent·ai编程
Databend13 小时前
AST Visitor API 迁移,怎么证明 AI 没改坏 SQL 语义?
数据库·ai编程
欢喜躲在眉梢里14 小时前
用飞算Java做了一套新能源充电运营管理系统:把站点、设备、订单拆成可运营的微服务
java·开发语言·微服务·ai编程·技术分享·java开发·飞算javaai炫技赛
无责任此方_修行中14 小时前
面对 300+ 仓库的重复劳动,我用 Guideline 优化了 Vibe Coding 工作流
后端·ai编程·vibecoding
孟无岐15 小时前
LayaAir-MCP 插件上手指南:让 AI 直接操控你的 LayaAir IDE
ai编程·游戏开发·mcp·layaair·workbuddy·layaair-mcp
CS_Zero15 小时前
Claude 大模型新手入门与实战指南
ai编程
zzz海羊15 小时前
用飞算JavaAI完成一个超市商品进销系统:从生成工程到进销台账的实录测评
ai编程·技术分享·java开发·开发者工具·飞算javaai炫技赛·程序员日常
Ai_easygo15 小时前
2026 AI编程工具年终横评——Cursor vs Claude Code vs Copilot vs Windsurf,到底选哪个?
copilot·ai编程
AI大模型-小雄1 天前
2026 年 7 月国内怎么开通 ChatGPT Pro?5x / 20x 区别、适合人群与避坑指南
人工智能·gpt·chatgpt·ai编程·开发工具·codex·chatgpt pro
kyriewen1 天前
看了微软几万人用AI编程的数据——效率涨24%的代价没人提
前端·ai编程·claude