很多时候“React 很慢”其实是“某几个组件在不停做没必要的事”:重复计算、反复渲染、列表一次性渲染太多、Context 一更新全家跟着跑……这篇文章不聊 Web 性能的全套(资源、网络、缓存那些),只聚焦在 React 使用层面:我平时会优先排查什么、哪些招数用起来确实顺手、以及常见的反作用。
一、组件渲染优化 先说一个我自己的习惯:别一上来就“优化”,先确认“慢”到底慢在哪。多数情况下你会发现问题不是渲染次数多,而是某次渲染里做了太多工作(比如列表的排序/过滤、复杂的图表计算、或者一堆组件因为 props 引用不稳定被带着刷新)。
1. React.memo - 避免不必要的重渲染 React.memo 的核心作用不是“让组件变快”,而是“当 props 没变时,别再渲染一遍”。它适合那种渲染成本比较高、同时 props 又比较稳定的子组件(尤其是列表 item / 图表 / 大块 UI)。
但也别把它当银弹:如果你每次都传进来一个新对象/新数组/新函数(比如 style={{...}}、onClick={() => ...}),那 memo 基本等于没用;更糟的是,全站乱加 memo 还会让调试和心智成本变高。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import React from 'react' ;const ExpensiveComponent = React .memo (({ data, onUpdate } ) => { console .log ('ExpensiveComponent rendered' ); return ( <div > {data.map(item => ( <div key ={item.id} > {item.name}</div > ))} <button onClick ={onUpdate} > 更新</button > </div > ); }); const CustomMemoComponent = React .memo ( ({ data, onUpdate } ) => { return <div > {/* 组件内容 */}</div > ; }, (prevProps, nextProps ) => { return prevProps.data .length === nextProps.data .length ; } );
2. useMemo - 缓存计算结果 useMemo 更像是“把一次昂贵计算的结果缓存起来”。典型场景是:过滤、排序、分组、派生数据这些计算本身就挺费,且依赖项变化频率不高。
我一般不会为了“看起来专业”到处加 useMemo:它也有开销(依赖比较、缓存占用、可读性下降)。如果计算很轻,或者依赖几乎每次都变,那加了也不一定赚。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import React , { useMemo } from 'react' ;function DataTable ({ data, filterText } ) { const filteredData = useMemo (() => { console .log ('重新计算过滤数据' ); return data.filter (item => item.name .toLowerCase ().includes (filterText.toLowerCase ()) ); }, [data, filterText]); const sortedData = useMemo (() => { return [...filteredData].sort ((a, b ) => a.name .localeCompare (b.name )); }, [filteredData]); return ( <table > {sortedData.map(item => ( <tr key ={item.id} > <td > {item.name}</td > <td > {item.value}</td > </tr > ))} </table > ); }
3. useCallback - 缓存函数引用 useCallback 解决的是“函数引用不稳定”这个很常见的问题:父组件一 re-render,就会创建新的函数对象,传给子组件后会让 React.memo 失效,或者触发依赖它的 useEffect 重新执行。
它同样不建议滥用:如果函数不会被下游当作依赖、也不会传给做了 memo 的子组件,那你加 useCallback 多半只是增加复杂度。另外依赖数组也别为了“图省事”随手写空——空依赖不是不行,但要确认函数体里没有读到会变化的外部值(除了 setState 这类稳定引用),否则很容易踩到闭包旧值的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import React , { useCallback, useState } from 'react' ;function ParentComponent ( ) { const [count, setCount] = useState (0 ); const [data, setData] = useState ([]); const handleAddItem = useCallback ((newItem ) => { setData (prev => [...prev, newItem]); }, []); const handleUpdateItem = useCallback ((id, updates ) => { setData (prev => prev.map (item => item.id === id ? { ...item, ...updates } : item )); }, []); return ( <div > <p > Count: {count}</p > <button onClick ={() => setCount(c => c + 1)}>增加计数</button > <ChildComponent data ={data} onAdd ={handleAddItem} onUpdate ={handleUpdateItem} /> </div > ); } const ChildComponent = React .memo (({ data, onAdd, onUpdate } ) => { return ( <div > {data.map(item => ( <div key ={item.id} > {item.name} <button onClick ={() => onUpdate(item.id, { name: 'Updated' })}> 更新 </button > </div > ))} </div > ); });
二、状态管理优化 状态这块很多性能问题的根源其实是“谁在订阅变化”。你希望改 A 只影响 A 的消费者,而不是把整棵子树都带着刷新一遍。
1. 状态分割 - 避免不必要的重渲染 把所有东西塞进一个巨大的 state 对象里,看起来“统一管理”很爽,但更新任何一个字段都会让依赖这个对象的地方一起动。如果组件树又深一点,很容易牵一发而动全身。
更实用的做法是:按“变化频率”和“消费范围”去拆 state。更新频繁的、只影响局部 UI 的状态尽量就地放;跨组件共享的再往上提,或者交给专门的 store。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import React , { useState } from 'react' ;function BadExample ( ) { const [state, setState] = useState ({ user : { name : 'John' , email : 'john@example.com' }, settings : { theme : 'dark' , language : 'en' }, notifications : { count : 5 , list : [] } }); const updateUser = (userData ) => { setState (prev => ({ ...prev, user : { ...prev.user , ...userData } })); }; return <div > {/* 组件内容 */}</div > ; } function GoodExample ( ) { const [user, setUser] = useState ({ name : 'John' , email : 'john@example.com' }); const [settings, setSettings] = useState ({ theme : 'dark' , language : 'en' }); const [notifications, setNotifications] = useState ({ count : 5 , list : [] }); const updateUser = (userData ) => { setUser (prev => ({ ...prev, ...userData })); }; return <div > {/* 组件内容 */}</div > ; }
2. 使用 useReducer 管理复杂状态 当状态更新逻辑开始“带条件、带流程、带多分支”时(比如 loading/error/items/filter 一起联动),我会更倾向用 useReducer:不是为了性能,而是为了把更新规则写得更清楚、更不容易漏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import React , { useReducer } from 'react' ;const initialState = { items : [], loading : false , error : null , filter : 'all' }; function reducer (state, action ) { switch (action.type ) { case 'SET_LOADING' : return { ...state, loading : action.payload }; case 'SET_ITEMS' : return { ...state, items : action.payload , loading : false }; case 'ADD_ITEM' : return { ...state, items : [...state.items , action.payload ] }; case 'REMOVE_ITEM' : return { ...state, items : state.items .filter (item => item.id !== action.payload ) }; case 'SET_FILTER' : return { ...state, filter : action.payload }; case 'SET_ERROR' : return { ...state, error : action.payload , loading : false }; default : return state; } } function ItemList ( ) { const [state, dispatch] = useReducer (reducer, initialState); const addItem = (item ) => { dispatch ({ type : 'ADD_ITEM' , payload : item }); }; const removeItem = (id ) => { dispatch ({ type : 'REMOVE_ITEM' , payload : id }); }; return ( <div > {state.loading && <div > 加载中...</div > } {state.error && <div > 错误: {state.error}</div > } {state.items.map(item => ( <div key ={item.id} > {item.name} <button onClick ={() => removeItem(item.id)}>删除</button > </div > ))} </div > ); }
三、列表渲染优化 1. 虚拟列表 - 处理大量数据 列表卡顿是最常见的性能问题之一:一次性渲染几千个节点,浏览器和 React 都很难受。虚拟列表的思路很朴素——只渲染可视区域附近那一小段,其它的用占位高度“假装存在”。
实际项目里我通常会直接用成熟库(比如 react-window / react-virtualized),自己手写当然也行,但要注意动态高度、滚动容器、滚动同步这些边角会很磨人。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import React , { useState, useMemo } from 'react' ;function VirtualList ({ items, itemHeight = 50 , containerHeight = 400 } ) { const [scrollTop, setScrollTop] = useState (0 ); const visibleRange = useMemo (() => { const startIndex = Math .floor (scrollTop / itemHeight); const endIndex = Math .min ( startIndex + Math .ceil (containerHeight / itemHeight) + 1 , items.length ); return { startIndex, endIndex }; }, [scrollTop, itemHeight, containerHeight, items.length ]); const visibleItems = useMemo (() => { return items.slice (visibleRange.startIndex , visibleRange.endIndex ); }, [items, visibleRange]); const handleScroll = (e ) => { setScrollTop (e.target .scrollTop ); }; return ( <div style ={{ height: containerHeight , overflow: 'auto ' }} onScroll ={handleScroll} > <div style ={{ height: items.length * itemHeight }}> <div style ={{ transform: `translateY (${visibleRange.startIndex * itemHeight }px )` }}> {visibleItems.map((item, index) => ( <div key ={item.id} style ={{ height: itemHeight , borderBottom: '1px solid #eee ' }} > {item.name} </div > ))} </div > </div > </div > ); }
2. 使用稳定的 key key 的问题经常不是“性能”而是“错乱”:用索引当 key,一旦插入/删除/排序,React 可能复用错节点,导致输入框光标跳、展开状态串行、动画怪异。稳定且唯一的 key 基本是写列表的底线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function BadList ({ items } ) { return ( <ul > {items.map((item, index) => ( <li key ={index} > {item.name}</li > // 索引会变化 ))} </ul > ); } function GoodList ({ items } ) { return ( <ul > {items.map(item => ( <li key ={item.id} > {item.name}</li > // 使用稳定的 ID ))} </ul > ); }
四、事件处理优化 1. 事件委托 如果你渲染了很多相似元素(比如一长串按钮/菜单项),每个都挂一个 handler 并不是“绝对不行”,但在某些场景下(尤其是你还在做复杂计算)确实会让内存和更新成本上来。
事件委托的做法是:把事件绑在父节点上,通过 event.target/closest 判断点的是谁。React 的合成事件本身就做了一层封装,这个思路依旧可用,只是要注意目标元素可能是子节点(比如按钮里的 icon)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import React , { useCallback } from 'react' ;function EventDelegationExample ({ items } ) { const handleClick = useCallback ((e ) => { const target = e.target ; if (target.matches ('.item-button' )) { const itemId = target.dataset .id ; console .log ('点击了项目:' , itemId); } }, []); return ( <div onClick ={handleClick} > {items.map(item => ( <div key ={item.id} className ="item" > {item.name} <button className ="item-button" data-id ={item.id} > 操作 </button > </div > ))} </div > ); }
2. 防抖和节流 输入框联想、窗口 resize、滚动监听这类事件很容易“刷屏”。防抖/节流本质是降低触发频率,别让主线程一直在跑回调。
另外一个小坑:在 Hook 里用 useState 存 timer id/时间戳,会引入额外的 state 更新和重渲染;这里用 useRef 更合适。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 import React , { useState, useCallback, useEffect, useRef } from 'react' ;function useDebounce (callback, delay ) { const timeoutRef = useRef (null ); const latestCallbackRef = useRef (callback); useEffect (() => { latestCallbackRef.current = callback; }, [callback]); return useCallback ((...args ) => { if (timeoutRef.current ) clearTimeout (timeoutRef.current ); timeoutRef.current = setTimeout (() => { latestCallbackRef.current (...args); }, delay); }, [delay]); } function useThrottle (callback, delay ) { const lastCallRef = useRef (0 ); const latestCallbackRef = useRef (callback); useEffect (() => { latestCallbackRef.current = callback; }, [callback]); return useCallback ((...args ) => { const now = Date .now (); if (now - lastCallRef.current >= delay) { lastCallRef.current = now; latestCallbackRef.current (...args); } }, [delay]); } function SearchComponent ( ) { const [searchTerm, setSearchTerm] = useState ('' ); const debouncedSearch = useDebounce ((term ) => { console .log ('搜索:' , term); }, 300 ); const throttledScroll = useThrottle (() => { console .log ('滚动事件' ); }, 100 ); const handleInputChange = (e ) => { const value = e.target .value ; setSearchTerm (value); debouncedSearch (value); }; return ( <div onScroll ={throttledScroll} > <input value ={searchTerm} onChange ={handleInputChange} placeholder ="搜索..." /> </div > ); }
五、代码分割与懒加载 1. React.lazy 和 Suspense 懒加载的目标很简单:首屏先把“必须的”交付出去,后面的页面/模块等用户真的要用再下载。对于后台系统、组件库页面特别多的项目,这个收益往往很直观。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import React , { Suspense , lazy, useState } from 'react' ;const LazyComponent = lazy (() => import ('./LazyComponent' ));const AnotherLazyComponent = lazy (() => import ('./AnotherLazyComponent' ));function App ( ) { const [showLazy, setShowLazy] = useState (false ); return ( <div > <button onClick ={() => setShowLazy(!showLazy)}> 切换懒加载组件 </button > {showLazy && ( <Suspense fallback ={ <div > 加载中...</div > }> <LazyComponent /> </Suspense > )} </div > ); }
2. 路由级别的代码分割 路由级别拆包是最常见的落点:不同页面通常天然是独立 chunk。注意点也很现实:拆得太碎会带来更多请求和瀑布流(尤其弱网);拆得太粗首屏又大。一般先把“非首屏页面”拆出来,就能解决大部分问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import React , { Suspense , lazy } from 'react' ;import { BrowserRouter , Routes , Route } from 'react-router-dom' ;const Home = lazy (() => import ('./pages/Home' ));const About = lazy (() => import ('./pages/About' ));const Contact = lazy (() => import ('./pages/Contact' ));function App ( ) { return ( <BrowserRouter > <Suspense fallback ={ <div > 页面加载中...</div > }> <Routes > <Route path ="/" element ={ <Home /> } /> <Route path ="/about" element ={ <About /> } /> <Route path ="/contact" element ={ <Contact /> } /> </Routes > </Suspense > </BrowserRouter > ); }
六、Context 优化 1. 分割 Context Context 的痛点是:Provider 的 value 一变,所有消费它的组件都会重新渲染。把“用户信息/主题/通知”这种完全不同的东西塞进同一个 Context,等于人为扩大了受影响范围。
拆分 Context 的收益很朴素:谁关心谁更新。它不是为了“优雅”,而是为了“别误伤”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import React , { createContext, useContext, useState } from 'react' ;const UserContext = createContext ();const ThemeContext = createContext ();const NotificationContext = createContext ();function UserProvider ({ children } ) { const [user, setUser] = useState (null ); return ( <UserContext.Provider value ={{ user , setUser }}> {children} </UserContext.Provider > ); } function ThemeProvider ({ children } ) { const [theme, setTheme] = useState ('light' ); return ( <ThemeContext.Provider value ={{ theme , setTheme }}> {children} </ThemeContext.Provider > ); } function NotificationProvider ({ children } ) { const [notifications, setNotifications] = useState ([]); return ( <NotificationContext.Provider value ={{ notifications , setNotifications }}> {children} </NotificationContext.Provider > ); } function useUser ( ) { const context = useContext (UserContext ); if (!context) { throw new Error ('useUser must be used within UserProvider' ); } return context; } function useTheme ( ) { const context = useContext (ThemeContext ); if (!context) { throw new Error ('useTheme must be used within ThemeProvider' ); } return context; } function App ( ) { return ( <UserProvider > <ThemeProvider > <NotificationProvider > <MainApp /> </NotificationProvider > </ThemeProvider > </UserProvider > ); }
2. 使用 useMemo 优化 Context 值 如果你在 Provider 里直接写 value={{ user, setUser }},那每次渲染都会创建新对象,即使 user 没变也会让消费者跟着刷新。用 useMemo 把 value 稳定下来,通常能少掉很多“无意义更新”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import React , { createContext, useContext, useState, useMemo } from 'react' ;const AppContext = createContext ();function AppProvider ({ children } ) { const [user, setUser] = useState (null ); const [theme, setTheme] = useState ('light' ); const contextValue = useMemo (() => ({ user, setUser, theme, setTheme, isLoggedIn : !!user, isDarkTheme : theme === 'dark' }), [user, theme]); return ( <AppContext.Provider value ={contextValue} > {children} </AppContext.Provider > ); }
七、Ref 优化 1. 使用 useRef 避免不必要的重渲染 有些数据只是“存一下,后面拿来用”,并不需要驱动 UI(比如 timer id、第三方实例、上一次的值)。这类东西放在 useState 里只会白白触发重渲染,用 useRef 更合适。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import React , { useRef, useEffect } from 'react' ;function TimerComponent ( ) { const intervalRef = useRef (null ); const countRef = useRef (0 ); useEffect (() => { intervalRef.current = setInterval (() => { countRef.current += 1 ; console .log ('计数:' , countRef.current ); }, 1000 ); return () => { if (intervalRef.current ) { clearInterval (intervalRef.current ); } }; }, []); return <div > 计时器运行中...</div > ; }
2. 使用 useImperativeHandle 暴露方法 useImperativeHandle 属于“必要时再用”的工具:当你确实需要让父组件调用子组件内部方法(比如控制一个输入框的 focus、暴露某个 reset),它很好用;但如果只是为了传递数据/状态,优先考虑受控组件或 props 回调,通常更直观。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import React , { forwardRef, useImperativeHandle, useRef, useState } from 'react' ;const ChildComponent = forwardRef ((props, ref ) => { const [count, setCount] = useState (0 ); useImperativeHandle (ref, () => ({ increment : () => setCount (c => c + 1 ), decrement : () => setCount (c => c - 1 ), reset : () => setCount (0 ), getCount : () => count })); return <div > 计数: {count}</div > ; }); function ParentComponent ( ) { const childRef = useRef (); const handleIncrement = ( ) => { childRef.current ?.increment (); }; const handleDecrement = ( ) => { childRef.current ?.decrement (); }; return ( <div > <ChildComponent ref ={childRef} /> <button onClick ={handleIncrement} > 增加</button > <button onClick ={handleDecrement} > 减少</button > </div > ); }
八、开发工具与调试 Profiler 是我排查 React 性能问题的首选:先定位“到底是谁在渲染、渲染花了多久”,再决定要不要上 memo/useMemo/useCallback 这些手段。不要靠猜。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import React , { Profiler } from 'react' ;function onRenderCallback ( id, phase, actualDuration, baseDuration, startTime, commitTime, interactions ) { console .log ('Profiler:' , { id, phase, actualDuration, baseDuration, startTime, commitTime }); } function App ( ) { return ( <Profiler id ="App" onRender ={onRenderCallback} > <MainComponent /> </Profiler > ); }
2. 使用 why-did-you-render 当你怀疑“我明明没改这个组件,为啥它老在渲染”时,why-did-you-render 很适合用来抓“触发源”(props 引用变化、hook 依赖变化等等)。我一般只在本地/开发环境开它,问题定位完就关掉。
1 2 3 4 5 6 7 8 9 10 if (process.env .NODE_ENV === 'development' ) { const whyDidYouRender = require ('@welldone-software/why-did-you-render' ); whyDidYouRender (React , { trackAllPureComponents : true , }); } MyComponent .whyDidYouRender = true ;
九、性能优化对比总结
优化手段
适用场景
常见收益(看场景)
实现复杂度
更像“坑”的部分
React.memo
子组件渲染重、props 稳定
有时很赚
低
props 引用不稳会直接失效;到处加会变难维护
useMemo
过滤/排序/派生数据很费
有时很赚
中
过度使用收益不明显;依赖写错会出 bug
useCallback
传给 memo 子组件/作为依赖
小到中
中
空依赖导致闭包旧值;依赖项管理麻烦
状态分割
大对象 state 被到处消费
经常有效
中
拆太碎也会让逻辑分散;要按消费范围拆
useReducer
多分支状态更新
更多是“好维护”
中
reducer 写得太大也会变成另一种“巨石”
虚拟列表
成百上千条数据渲染
往往立竿见影
高
动态高度/滚动同步/吸顶等边角成本高
事件委托
大量相似交互元素
看情况
中
target/closest 判断要写严谨,别点错人
防抖/节流
输入/滚动/resize 等高频事件
经常有效
中
延迟不合适会影响体验;注意清理 timer
代码分割
页面多、首包大
常见有效
中
拆得太碎会造成请求瀑布;注意加载态
Context 分割
Context 更新误伤一大片
经常有效
高
Provider 太多会让结构变深;别为拆而拆
useRef
存非 UI 状态/缓存实例
主要是减少渲染
低
ref 不触发渲染,别拿它当 state 用
十、最佳实践总结
先定位,再动手 :Profiler 看清楚“谁慢、慢在哪”,别凭感觉到处 memo。
优先解决大头 :首屏大就拆包;列表卡就上虚拟列表;计算重就缓存派生数据。
把引用稳定下来 :对象/数组/函数如果要跨组件传递,先想想怎么让它别每次都变。
别用优化把代码写烂 :可维护性本身也是性能(人维护得动才会持续优化)。
改完要复测 :有些“优化”只是把问题挪走,甚至会引入更隐蔽的 bug。
结语 性能优化其实没那么玄学:把浪费的渲染次数砍掉、把不必要的计算挪走、把一次性渲染太多的列表“缩小到视口”,再配合工具把问题定位清楚,绝大多数卡顿都能解决。
如果你愿意再往前一步,我建议给页面设一个“性能预算”(比如交互响应时间、首屏可用时间、列表滚动帧率),这样优化目标会更明确,也更容易持续。
Happy Coding!