引言:性能优化的本质
React 性能优化的核心只有一条:减少不必要的渲染。
所有优化手段——memo、useMemo、useCallback、key、虚拟列表——都是围绕这个目标展开的。但”减少渲染”不是简单地少调用几次 setState,而是理解 React 何时、以何种方式触发渲染,并在每个环节针对性地消除无效工作。
这篇文章会从渲染的本质出发,逐步覆盖 React 性能优化各个维度,结合源码解析和面试要点,帮你构建完整的性能优化知识体系。
一、渲染的本质:什么时候会触发重新渲染?
理解性能优化,首先要理解什么行为会触发 React 的重新渲染。
触发渲染的四种情况
function App() { // 1. 状态变化 const [count, setCount] = useState(0);
// 2. Props 变化 return <ChildComponent value={count} />;
// 3. Context 变化 const theme = useContext(ThemeContext);
// 4. 父组件渲染 return <ParentComponent />;}1. 状态变化(setState / useState)
当组件内部调用 setState 时,React 会标记该 Fiber 为”脏节点”,并调度一次重新渲染。这是我们最熟悉的情况。
2. Props 变化
父组件传递给子组件的 props 发生变化时,子组件会进入重新渲染流程。注意:这里比较的是 props 的引用,而不是值本身。
3. Context 变化
当 Context Provider 的 value 发生变化时,所有消费该 Context 的组件都会重新渲染。这是一个很容易被忽视的性能陷阱。
4. 父组件渲染
默认情况下,当父组件重新渲染时,所有子组件也会重新渲染。这意味着即使子组件的 props 没变,只要父组件重新渲染,子组件就无法避免地要执行一遍。
Fiber 架构下的渲染流程
React 16 引入 Fiber 架构,将渲染工作拆分成两个阶段:
Reconciler(协调阶段)- 可中断 ↓render() 计算 Virtual DOM diff ↓ Commit(提交阶段)- 同步执行 ↓ DOM 更新**协调阶段(Reconciler)**会遍历 Fiber 树,找出需要更新的内容。这个阶段可以被 React 自己中断和恢复。
**提交阶段(Commit)**是同步的,必须一次性完成,因为它涉及真实的 DOM 操作。
二、Fiber 架构与渲染调度
可中断的渲染
在 React Fiber 出现之前,React 的渲染过程是递归的,会在单个渲染周期内一直执行直到完成。这种方式在处理大型组件或复杂动画时会造成主线程阻塞,导致页面卡顿。
Fiber 架构将任务切分成多个小单元(work unit),每个单元执行完毕后可以主动中断,让出主线程去处理更高优先级的任务(如用户输入),然后再继续渲染。
// 伪代码:Fiber 的可中断执行function workLoop() { while (nextWorkUnit) { // 执行一个工作单元 performWork(nextWorkUnit);
// 检查是否需要让出主线程 if (shouldYield()) { // 中断,调度其他任务 requestIdleCallback(workLoop); return; } }}Lane 优先级机制
React 通过 Lane(车道)来管理更新优先级。不同的任务来源有不同的优先级:
| 优先级 | 来源 | 场景 |
|---|---|---|
| ImmediatePriority | 同步 | 阻塞交互 |
| UserBlockingPriority | 高 | 点击、输入 |
| NormalPriority | 正常 | setState、useEffect |
| LowPriority | 低 | 日志、预加载 |
| IdlePriority | 空闲 | 后台任务 |
Scheduler 调度机制
React 使用 MessageChannel + 宏任务实现时间分片,通过 shouldYield() 判断是否需要让出主线程:
// Scheduler 简化逻辑function shouldYield() { const currentTime = getCurrentTime(); return currentTime >= deadline;}当 shouldYield() 返回 true 时,当前的渲染任务会被挂起,等待下一个宏任务再继续执行。
三、批量更新与批处理机制
React 18 前的批处理
在 React 18 之前,批处理是不完全的。只有在 React 管理的事件处理函数中,setState 才会被批处理。在原生事件(addEventListener)、setTimeout、setInterval 中,setState 是同步执行的。
// React 18 之前button.addEventListener('click', () => { setCount(1); // 直接触发重新渲染 setFlag(true); // 再触发一次重新渲染});// 如果检测不到 React 上下文,会触发两次渲染React 18 后的自动批处理
React 18 引入了自动批处理(Automatic Batching),所有 setState 都会被批量处理,无论在什么环境中:
// React 18+button.addEventListener('click', () => { setCount(1); // 被批处理 setFlag(true); // 被批处理 setName('test'); // 被批处理});// 只会触发一次渲染时间分片与 shouldYield
React 18 还引入了时间分片机制,当更新涉及大量组件时,React 会将渲染工作分散到多个帧中执行:
function App() { const [list, setList] = useState([]);
const handleLoadMore = () => { // 大列表更新会分片执行 const newList = generateLargeList(); setList(newList); };}通过 shouldYield() 判断,如果当前帧的时间用完了,React 会暂停渲染,把主线程让给其他任务。
四、setState 同步与异步
React 18 后的变化
React 18 后,setState 是异步的——更准确地说,它总是异步的。React 会自动合并多次 setState 调用,只触发一次重新渲染。
同步 vs 异步的场景
// 在 React 检测到的环境中(事件处理函数内)- 异步function handleClick() { setCount(c => c + 1); // 异步,批量处理 console.log(count); // 打印的是旧值}
// 在 React 检测不到的环境中(原生事件、setTimeout)- 同步useEffect(() => { const timer = setInterval(() => { setCount(c => c + 1); // 同步更新 }, 1000); return () => clearInterval(timer);}, []);状态合并机制
如果多次调用 setState 传入相同的值,React 不会触发重新渲染:
setCount(1); // 触发更新setCount(1); // 被合并,不触发更新setCount(2); // 触发更新这种机制通过 Object.is() 比较新旧状态来实现。
五、key:列表渲染的性能关键
为什么 key 这么重要?
当使用 map 渲染列表时,React 依靠 key 来判断哪些节点可以复用:
function List({ items }) { return items.map(item => ( <div key={item.id}>{item.name}</div> ));}React 的 Diff 算法会通过 key 来匹配新旧树中的节点:
- key 相同 → 节点可复用,只更新属性
- key 不同 → 旧节点销毁,新节点创建
为什么不用 index 作为 key?
// ❌ 错误:index 作为 keyitems.map((item, index) => ( <div key={index}>{item.name}</div>));
// ✅ 正确:唯一 id 作为 keyitems.map(item => ( <div key={item.id}>{item.name}</div>));用 index 作为 key 在列表顺序发生变化时会导致严重的性能问题:
场景:列表逆序插入
// 初始渲染['A', 'B', 'C'].map((item, index) => <div key={index}>{item}</div>);// 结果:A B C (key: 0, 1, 2)
// 插入 D 到最前面['D', 'A', 'B', 'C'].map((item, index) => <div key={index}>{item}</div>);// 结果:D A B C (key: 0, 1, 2)// 问题:所有节点 key 都变了,React 认为是全量更新!正确的 key 带来的复用效果:
// 初始渲染['A', 'B', 'C'].map(item => <div key={item.id}>{item.name}</div>);
// 插入 D 到最前面[{id: 'd', name: 'D'}, ...].map(item => <div key={item.id}>{item.name}</div>);// 结果:D A B C// 优化:只有 D 是新节点,A B C 被复用key 的最佳实践
- 使用唯一且稳定的 id:数据库 ID、UUID 等
- 避免使用随机数:每次渲染 key 都不同,完全失去复用意义
- key 不要用在 render 的 UI 上:key 只用于 React 内部,不应该影响渲染结果
六、React.memo:阻止不必要的子组件渲染
默认行为与性能问题
默认情况下,当父组件重新渲染时,所有子组件都会重新渲染:
function Parent() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> <HeavyChild /> {/* 每次 count 变化都会重新渲染 */} </div> );}React.memo 的作用
React.memo 是一个高阶组件,它会对组件的 props 进行浅比较,只有当 props 实际发生变化时才重新渲染:
const Child = React.memo(({ name, age }) => { console.log('Child rendered'); return <div>{name} - {age}</div>;});
// 使用<Child name="Alice" age={25} />;浅比较的原理
React.memo 使用 Object.is() 进行浅比较:
// 简化的 memo 原理function memo(Component) { return function MemoizedComponent(props) { const renderCount = useRef(0);
if (!is(props, prevProps)) { // Object.is() 比较 renderCount.current++; prevProps = props; }
return <Component {...props} />; };}Object.is() 的比较规则:
- 基本类型:值比较
- 引用类型:引用比较(只比较地址,不深比较内容)
// ❌ 即使值相同,props 仍被认为变化<Child value={{ name: 'Alice' }} /> // 新对象,引用不同
// ✅ 可以避免重新渲染const value = useMemo(() => ({ name: 'Alice' }), []);<Child value={value} />适用场景 vs 不适用场景
适用场景:
- 子组件渲染成本高
- 组件接收的 props 相对稳定
- 组件经常接收到引用类型的 props(需要配合 useMemo)
不适用场景:
- props 变化频繁的组件
- 受 Context 影响的组件(Context 变化会直接触发重渲染)
- 纯展示组件,渲染本身很快(memo 的比较成本可能高于重新渲染)
React.memo vs useMemo
| 特性 | React.memo | useMemo |
|---|---|---|
| 作用对象 | 整个组件 | 单个计算值 |
| 比较内容 | props | 依赖项 |
| 使用场景 | 防止重渲染 | 缓存计算结果 |
七、useMemo + useCallback:值与函数的缓存
useMemo:缓存计算结果
useMemo 会在依赖项未变化时返回缓存的值,避免重复计算:
function ExpensiveList({ items, filter }) { const filteredItems = useMemo(() => { console.log('Filtering...'); // 依赖未变化时不执行 return items.filter(item => item.name.includes(filter)); }, [items, filter]);
return filteredItems.map(item => <div key={item.id}>{item.name}</div>);}useMemo 源码解析
// React 源码简化版function mountMemo(nextCreate, deps) { const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = nextCreate(); hook.memoizedState = [hook.memoizedState, nextDeps];
return hook.memoizedState;}
function updateMemo(nextCreate, deps) { const hook = updateWorkInProgressHook(); const prevDeps = hook.memoizedState[1];
if (objectIs(prevDeps, deps)) { return hook.memoizedState[0]; // 依赖未变,返回缓存 }
const nextValue = nextCreate(); // 依赖变化,重新计算 hook.memoizedState = [nextValue, deps];
return nextValue;}关键在于 objectIs(React 版的 Object.is):
function objectIs(x, y) { return (x === y) || (x !== x && y !== y); // 处理 NaN}useCallback:缓存函数引用
useCallback 本质上是 useMemo 的语法糖:
const handleClick = useCallback((event) => { console.log('Clicked', event);}, []);
// 等价于const handleClick = useMemo(() => (event) => { console.log('Clicked', event);}, []);useCallback 的实际价值
常见误区:以为使用 useCallback 就能避免子组件重渲染。
真相:只有当子组件用 React.memo 包裹,且 props 的引用确实保持不变时,useCallback 才有意义。
// ❌ 假优化:memo 的子组件接收到的 props 虽然引用没变,// 但 Parent 重新渲染时 Child 仍会被比较const Child = React.memo(({ onClick }) => <button onClick={onClick}>Click</button>);
function Parent() { const [count, setCount] = useState(0);
// handleClick 引用每次渲染都变化 const handleClick = useCallback(() => { console.log('clicked'); }, []);
return ( <div> <button onClick={() => setCount(c => c + 1)}>{count}</button> <Child onClick={handleClick} /> </div> );}这个例子中,即使使用了 useCallback,Child 仍然会在 Parent 每次渲染时被 memo 拿来比较 props(因为比较的是 onClick 引用,而 useCallback 保证了这个引用不变,所以 Child 实际上不会重渲染)。
但真正的问题在于:如果 Parent 有其他状态变化导致重渲染,即使 Child 完全不受影响,memo 的浅比较仍然会执行。所以 useCallback + memo 的组合确实能阻止 Child 的重渲染,但前提是两者都要正确使用。
对于 handleClick 这样没有任何依赖项的回调函数,useCallback 是必要的——否则每次渲染都会生成新的函数引用。
八、React.lazy + Suspense:代码分割
动态导入
React.lazy 允许你用动态 import() 的方式加载组件:
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() { return ( <div> <React.Suspense fallback={<Loading />}> <HeavyComponent /> </React.Suspense> </div> );}Suspense 的工作机制
当 React.lazy 加载的组件还在请求中时,React 会”暂停”渲染,并找到最近的 <Suspense> 边界来显示 loading 状态:
<React.Suspense fallback={<Spinner />}> <Dashboard /> {/* 可以立即渲染 */} <HeavyChart /> {/* 动态加载,显示 Spinner */} <RecentActivity /> {/* 可以立即渲染 */}</React.Suspense>这里 Dashboard 和 RecentActivity 不需要等 HeavyChart 加载完成就可以显示,只有 HeavyChart 的位置会显示 fallback。
异常捕获机制
React 16 引入的 Error Boundaries 可以捕获 Suspense 中抛出的异常:
class ErrorBoundary extends React.Component { state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() { if (this.state.hasError) { return <FallbackUI />; } return this.props.children; }}
<ErrorBoundary> <React.Suspense fallback={<Loading />}> <SlowComponent /> </React.Suspense></ErrorBoundary>源码解析
// 简化版的 Lazy 组件实现function lazy(ctor) { return { $$typeof: REACT_LAZY_TYPE, _payload: { status: 'pending', result: null }, _init: function() { // 实际加载组件 const promise = ctor(); promise.then( (module) => { const factory = module.default; this._payload.status = 'resolved'; this._payload.result = factory; }, (error) => { this._payload.status = 'rejected'; this._payload.result = error; } ); return promise; } };}当 Suspense 检测到子组件是 lazy 类型且还在 pending 状态时,会将自己设置为 fallback 状态,直到 lazy 组件加载完成。
九、虚拟列表:大列表渲染优化
核心原理
虚拟列表的核心思想是:只渲染可视区域内的内容。
假设有一个 10000 条数据的列表,屏幕只能显示 20 条。虚拟列表只创建 20+缓冲区 的 DOM 节点,当滚动时更新显示的内容:
┌────────────────────────┐│ 可视区域 ││ ┌──────────────────┐ ││ │ visible items │ │ ← 只渲染这 20 条│ └──────────────────┘ ││ ││ ┌──────────────┐ ││ │ Buffer items │ │ ← 额外渲染少量 buffer│ └──────────────┘ │└────────────────────────┘ ↓ 滚动┌────────────────────────┐│ 滚动后位置 ││ ┌──────────────────┐ ││ │ new visible │ │ ← 复用之前的 DOM 节点│ └──────────────────┘ │└────────────────────────┘react-window 原理
react-window 是最常用的虚拟列表库:
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) { return ( <FixedSizeList height={400} itemCount={items.length} itemSize={50}> {({ index, style }) => ( <div style={style}> {items[index].name} </div> )} </FixedSizeList> );}关键实现思路:
- 计算可见区域:根据 scrollTop 和容器高度计算 startIndex 和 endIndex
- 只渲染可见项:只创建可见范围 + buffer 的 DOM 节点
- 滚动时更新:滚动事件触发,重新计算可见范围,复用已有节点
面试高频问题
Q:虚拟列表如何计算可见区域?
function calculateVisibleRange(scrollTop, containerHeight, itemHeight) { const startIndex = Math.floor(scrollTop / itemHeight); const visibleCount = Math.ceil(containerHeight / itemHeight);
return { startIndex, endIndex: startIndex + visibleCount };}Q:虚拟列表的 DOM 节点数量是多少?
假设屏幕高度 400px,每个 item 高度 50px,buffer 为 2:
- 可见数量:400 / 50 = 8
- 总 DOM 节点:8 + 2 × 2 = 12 个(而不是 10000 个)
十、useTransition + useDeferredValue:并发渲染
useTransition:标记非紧急更新
useTransition 允许你将某些更新标记为”过渡任务”,使 UI 能优先响应用户交互:
function TabSwitch() { const [activeTab, setActiveTab] = useState('list'); const [isPending, startTransition] = useTransition(); const [content, setContent] = useState([]);
const handleTabChange = (tab) => { setActiveTab(tab); // 立即更新 Tab 高亮 startTransition(() => { setContent(generateLargeContent(tab)); // 内容渲染低优先级 }); };
return ( <div> <TabBar active={activeTab} onChange={handleTabChange} /> {isPending ? <Spinner /> : <Content data={content} />} </div> );}Lane 优先级的体现
React 内部使用 Lane 标记更新优先级:
const SyncLane = 0b0001;const InputContinuousLane = 0b0100;const DefaultLane = 0b1000;const TransitionLane = 0b10000000;setActiveTab 获得较高优先级(立即更新),setContent 获得较低优先级(可以被打断)。
useDeferredValue 的区别
| 特性 | useTransition | useDeferredValue |
|---|---|---|
| 作用对象 | 一段逻辑 | 一个状态值 |
| 使用方式 | startTransition(() => { setX(...) }) | const x = useDeferredValue(value) |
| 典型场景 | 数据加载、复杂计算 | 搜索输入、防抖 |
// useDeferredValue 示例function SearchResults({ query }) { const deferredQuery = useDeferredValue(query);
// deferredQuery 会滞后更新,允许 UI 先响应输入 const results = useMemo(() => searchDatabase(deferredQuery), [deferredQuery]);
return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> {results.map(r => <ResultItem key={r.id} {...r} />)} </div> );}十一、useLayoutEffect vs useEffect:性能影响
执行时机的差异
function Component() { useLayoutEffect(() => { // 1. DOM 更新后 // 2. 浏览器绘制前 // 3. 同步执行,会阻塞绘制 }, []);
useEffect(() => { // 1. DOM 更新后 // 2. 浏览器绘制后 // 3. 异步执行,不阻塞绘制 }, []);
return <div>Content</div>;}时序图
DOM 更新 → useLayoutEffect 同步执行 → 浏览器绘制 → useEffect 异步执行 ↑ 这里会阻塞 ↑ 这里不会阻塞性能陷阱
如果把 DOM 布局修改逻辑放在 useEffect 中:
function Component() { useEffect(() => { // ❌ 问题:先绘制,再修改 DOM element.style.opacity = '0.5'; // 触发回流重绘 }, []);
useLayoutEffect(() => { // ✅ 正确:在绘制前修改 element.style.opacity = '0.5'; }, []);}useEffect 会导致浏览器先绘制一次,然后发现布局变化再重新绘制,造成视觉闪烁。
适用场景
useLayoutEffect:
- 需要立即读取/修改 DOM 布局
- 对视觉一致性要求高(避免闪烁)
- 任何修改后会影响后续渲染的逻辑
useEffect(默认选择):
- 数据获取
- 事件监听
- 大部分副作用
十二、useSyncExternalStore:安全读取外部数据
并发模式下的挑战
在 React 18 的并发模式下,组件渲染可能被中断和恢复。如果在渲染过程中外部数据发生了变化,会导致数据不一致。
useSyncExternalStore 的作用
useSyncExternalStore 确保组件在任何渲染阶段都能读取到一致的数据:
const state = useSyncExternalStore( subscribe, // 订阅数据变化 getSnapshot, // 获取当前数据快照 getServerSnapshot // 服务端渲染时的快照);实际使用示例
function useStore(store) { const state = useSyncExternalStore( store.subscribe, () => store.getSnapshot().data, () => INITIAL_SERVER_STATE // SSR 时使用 );
return state;}
// Redux 集成const useReduxSelector = (selector) => { return useSyncExternalStore( store.subscribe, () => selector(store.getState()), );};十三、React Compiler:自动优化
简介
React Compiler(React 19+)是一个自动 memoization 编译器,它能在编译时自动识别可优化的代码:
// 编译前function Component({ items, filter }) { const filtered = items.filter(item => item.id === filter); return filtered.map(item => <div key={item.id}>{item.name}</div>);}
// 编译器自动添加 memoizationfunction Component({ items, filter }) { const filtered = $useMemo(items, filter, () => items.filter(item => item.id === filter) ); return filtered.map(item => <div key={item.id}>{item.name}</div>);}工作原理
React Compiler 分析代码中的纯函数调用,自动插入 useMemo 和 useCallback。它基于 React 的 Rules of Hooks 进行静态分析。
现状
React Compiler 目前仍处于实验阶段,需要在项目中显式启用。它不会替代手动的性能优化,而是减少样板代码。
十四、Context 性能陷阱
Context 变化会触发所有消费者重渲染
const ThemeContext = createContext('light');
// Providerfunction App() { const [theme, setTheme] = useState('light');
return ( <ThemeContext.Provider value={theme}> <Toolbar /> </ThemeContext.Provider> );}
// 消费者function Toolbar() { const theme = useContext(ThemeContext); // theme 变化时,这里会重渲染}问题:即使只有一个组件需要新主题,所有消费 Context 的组件都会重渲染
解决方案
1. 拆分 Context
const UserContext = createContext(null);const ThemeContext = createContext(null);
// 只消费需要的 Contextfunction Toolbar() { const theme = useContext(ThemeContext); // 只在这个变化时重渲染}2. 使用状态提升 + props 传递
将变化频繁的状态保留在父组件,通过 props 传递。
3. 使用 useMemo 缓存 Provider value
function App() { const [user, setUser] = useState(null); const [theme, setTheme] = useState('light');
const userValue = useMemo(() => ({ user, setUser }), [user]); const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
return ( <UserContext.Provider value={userValue}> <ThemeContext.Provider value={themeValue}> <Dashboard /> </ThemeContext.Provider> </UserContext.Provider> );}十五、State 拆分原则
为什么要拆分 State?
State 的组织方式直接影响重渲染的范围。合理的拆分可以让更新粒度更细:
// ❌ 粗糙的 State 组织const [state, setState] = useState({ user: null, posts: [], comments: [], loading: false,});
// ✅ 合理的拆分const [user, setUser] = useState(null);const [posts, setPosts] = useState([]);const [comments, setComments] = useState([]);const [loading, setLoading] = useState(false);拆分 vs 合并的原则
应该拆分:
- 独立变化的多个数据
- 不同更新频率的数据
- 属于不同子组件的数据
可以合并:
- 相关且同时更新的数据
- 更新频率一致的数据
- 数据量很小(3-4 个字段)
示例
function ChatRoom() { // ✅ 消息列表单独管理,滚动位置独立管理 const [messages, setMessages] = useState([]); const [scrollPosition, setScrollPosition] = useState(0);
// ❌ 不需要拆分的例子 const [position, setPosition] = useState({ x: 0, y: 0 }); // position.x 和 position.y 总是一起使用,合并更合理}十六、组件设计粒度
性能从设计开始
好的组件设计本身就是性能优化。过度的组件拆分会导致过度抽象,保持组件拆分合理能减少不必要的重渲染。
原则
1. 隔离重渲染区域
// ✅ 好:重渲染逻辑隔离function App() { const [count, setCount] = useState(0);
return ( <div> <ExpensiveChart data={someData} /> <Counter count={count} onIncrement={() => setCount(c => c + 1)} /> </div> );}
// ❌ 差:Counter 重渲染会导致 App 重新渲染,进而导致 Chart 重渲染function App() { const [count, setCount] = useState(0); return <Counter count={count} onIncrement={() => setCount(c => c + 1)} />;}2. Container/Presentational 模式
将数据逻辑和 UI 分离,Presentational 组件更容易被 memo 优化:
// Presentational Component - 容易被 memo 优化const UserAvatar = React.memo(({ user }) => ( <img src={user.avatar} alt={user.name} />));
// Container Component - 管理状态function UserProfile({ userId }) { const user = useUser(userId); return <UserAvatar user={user} />;}3. 避免滥用 Context
Context 适合存储全局不变化或变化频率一致的数据,不适合存储频繁变化的局部状态。
十七、性能指标:Core Web Vitals
关键指标
| 指标 | 含义 | 目标 |
|---|---|---|
| LCP | Largest Contentful Paint | < 2.5s |
| FID | First Input Delay | < 100ms |
| CLS | Cumulative Layout Shift | < 0.1 |
| INP | Interaction to Next Paint | < 200ms |
React 性能优化与指标的关系
1. LCP 优化
- 代码分割:减少首屏加载资源
- SSR/SSG:加速首屏渲染
- 资源优化:图片压缩、懒加载
2. FID/INP 优化
- 减少主线程阻塞
- useTransition 标记非紧急更新
- 虚拟列表避免长列表渲染
3. CLS 优化
- 给图片预留空间
- 避免动态内容插入导致的布局偏移
测量工具
- Chrome DevTools Performance 面板
- Lighthouse
- Web Vitals 扩展
十八、面试问题汇总
Q1:React.memo 和 useMemo 的区别?
A:React.memo 是一个高阶组件,用于包装整个组件;useMemo 是一个 Hook,用于缓存计算结果。两者都用于避免不必要的计算/渲染,但作用对象不同。
// React.memo:防止整个组件重渲染const Child = React.memo(({ name }) => <div>{name}</div>);
// useMemo:防止计算结果重复计算const expensive = useMemo(() => computeExpensive(items), [items]);Q2:为什么不能用 index 作为 key?
A:当列表发生顺序变化(如插入、删除、排序)时,使用 index 作为 key 会导致所有节点的 key 都发生变化,React 只能销毁所有旧节点并创建新节点,造成性能问题。正确做法是使用唯一且稳定的 id。
Q3:useCallback 什么时候真正需要?
A:只有当传递给用 React.memo 包裹的子组件时才有意义。对于普通组件,useCallback 没有意义。另外,当 useCallback 的依赖数组很长或包含对象/函数时,维护成本可能超过收益。
Q4:useTransition 和 useDeferredValue 的区别?
A:useTransition 将一段逻辑标记为低优先级过渡任务;useDeferredValue 将一个状态值延迟产生。useTransition 处理逻辑,useDeferredValue 处理状态。
Q5:虚拟列表的原理是什么?
A:只渲染可视区域内的列表项,通过监听滚动事件计算可见范围,复用已有 DOM 节点实现平滑滚动。关键在于计算 startIndex = scrollTop / itemHeight。
Q6:React.memo 的比较是深比较还是浅比较?
A:浅比较。Object.is() 只比较基本类型值和引用地址,不递归比较对象内部属性。
Q7:useLayoutEffect 会在什么时候造成性能问题?
A:当 useLayoutEffect 中的逻辑耗时较长时,会阻塞浏览器绘制,造成白屏或卡顿。应该将不涉及布局的逻辑移到 useEffect 中。
Q8:什么情况下 memo 无法阻止重渲染?
A:
- 组件消费了 Context,Context 变化时仍会重渲染
- 父组件重新渲染导致 props 引用变化
- 组件内部使用了不稳定的 useCallback 依赖
总结
React 性能优化是一个系统工程,需要从多个层面入手:
- 理解渲染机制:知道什么会触发渲染,才知道如何避免无效渲染
- 合理使用缓存:memo、useMemo、useCallback 各有其适用场景
- 优化渲染内容:虚拟列表、懒加载减少实际渲染的节点数
- 利用并发特性:useTransition、useDeferredValue 提升用户体验
- 注意常见陷阱:Context 陷阱、State 拆分原则
性能优化没有银弹,关键在于理解原理后,根据实际场景选择合适的手段。
如果你觉得这篇文章有帮助,欢迎关注和转发。有什么问题或建议,可以在评论区留言。