Overview
React 性能优化全解析:从源码到面试

React 性能优化全解析:从源码到面试

May 28, 2026
12 min read

引言:性能优化的本质

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 作为 key
items.map((item, index) => (
<div key={index}>{item.name}</div>
));
// ✅ 正确:唯一 id 作为 key
items.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 的最佳实践

  1. 使用唯一且稳定的 id:数据库 ID、UUID 等
  2. 避免使用随机数:每次渲染 key 都不同,完全失去复用意义
  3. 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.memouseMemo
作用对象整个组件单个计算值
比较内容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>
);
}

关键实现思路:

  1. 计算可见区域:根据 scrollTop 和容器高度计算 startIndex 和 endIndex
  2. 只渲染可见项:只创建可见范围 + buffer 的 DOM 节点
  3. 滚动时更新:滚动事件触发,重新计算可见范围,复用已有节点

面试高频问题

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 的区别

特性useTransitionuseDeferredValue
作用对象一段逻辑一个状态值
使用方式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>);
}
// 编译器自动添加 memoization
function 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 分析代码中的纯函数调用,自动插入 useMemouseCallback。它基于 React 的 Rules of Hooks 进行静态分析。

现状

React Compiler 目前仍处于实验阶段,需要在项目中显式启用。它不会替代手动的性能优化,而是减少样板代码。

十四、Context 性能陷阱

Context 变化会触发所有消费者重渲染

const ThemeContext = createContext('light');
// Provider
function 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);
// 只消费需要的 Context
function 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

关键指标

指标含义目标
LCPLargest Contentful Paint< 2.5s
FIDFirst Input Delay< 100ms
CLSCumulative Layout Shift< 0.1
INPInteraction 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

  1. 组件消费了 Context,Context 变化时仍会重渲染
  2. 父组件重新渲染导致 props 引用变化
  3. 组件内部使用了不稳定的 useCallback 依赖

总结

React 性能优化是一个系统工程,需要从多个层面入手:

  1. 理解渲染机制:知道什么会触发渲染,才知道如何避免无效渲染
  2. 合理使用缓存:memo、useMemo、useCallback 各有其适用场景
  3. 优化渲染内容:虚拟列表、懒加载减少实际渲染的节点数
  4. 利用并发特性:useTransition、useDeferredValue 提升用户体验
  5. 注意常见陷阱:Context 陷阱、State 拆分原则

性能优化没有银弹,关键在于理解原理后,根据实际场景选择合适的手段。


如果你觉得这篇文章有帮助,欢迎关注和转发。有什么问题或建议,可以在评论区留言。