React 性能优化实战

一、React 性能优化的整体思路

面试题 1:React 性能优化应该从哪些方面入手?

核心思路(一句话)

先测量定位性能瓶颈,再针对"渲染、计算、列表、网络加载"分别优化,最后通过指标验证优化是否真正有效。

主要矛盾

主要矛盾不是"用了哪些 React API",而是"到底哪里浪费了时间"。

性能优化最忌讳:

text 复制代码
感觉这里可能慢
    ↓
直接加 React.memo
    ↓
再加 useMemo
    ↓
再加 useCallback
    ↓
代码越来越复杂
    ↓
性能不一定变好

正确方式应该是:

text 复制代码
用户感知到卡顿 / 首屏慢 / 交互延迟
              ↓
        建立性能指标
              ↓
          定位瓶颈
              ↓
 ┌────────────┼─────────────┐
 ↓            ↓             ↓
渲染问题     计算问题       加载问题
 ↓            ↓             ↓
React.memo   useMemo       代码分割
useCallback  计算拆分      React.lazy
组件拆分     Web Worker    预加载
 ↓            ↓             ↓
长列表 → 虚拟化
              ↓
        优化后重新测量
              ↓
        对比优化前后指标

性能问题可以分成四类

类型 常见问题 典型方案
组件渲染 父组件更新导致无关子组件更新 React.memo
引用变化 函数、对象、数组每次重新创建 useCallbackuseMemo
计算耗时 大量数据计算、复杂排序过滤 useMemo、Web Worker
DOM 数量 几千、几万条列表 虚拟列表
首屏加载 JavaScript 包过大 代码分割、懒加载
交互优先级 大量低优先级更新阻塞输入 startTransitionuseDeferredValue
网络 接口串行、资源过多 并行请求、缓存、预加载
DOM/浏览器 Layout、Paint、Composite 开销 减少 DOM、避免强制同步布局

二、React.memo

面试题 2:React.memo 是什么?它为什么可以减少组件重新渲染?

核心思路(一句话)

React.memo 会比较函数组件前后两次接收到的 props,如果 props 没有变化,就可以跳过这次由父组件更新导致的子组件重新渲染。

解决方案流程图

text 复制代码
父组件发生更新
      ↓
React 准备处理子组件
      ↓
子组件是否使用 React.memo?
      ↓
    是
      ↓
比较旧 props 和新 props
      ↓
   props 是否变化?
   ↙           ↘
否             是
↓               ↓
跳过本次        重新执行组件
组件渲染        ↓
                生成新的 React Element
                ↓
                后续协调与提交

底层实现原理

假设:

jsx 复制代码
const UserInfo = React.memo(function UserInfo({ name, age }) {
  console.log('UserInfo render');

  return (
    <div>
      {name} - {age}
    </div>
  );
});

父组件:

jsx 复制代码
function App() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        count: {count}
      </button>

      <UserInfo name="张三" age={30} />
    </div>
  );
}

点击按钮:

text 复制代码
App state 更新
    ↓
App 重新执行
    ↓
生成新的 <UserInfo name="张三" age={30} />
    ↓
React.memo 比较 props
    ↓
name:张三 === 张三
age:30 === 30
    ↓
props 没有变化
    ↓
跳过 UserInfo 本次重新渲染

React.memo 默认怎么比较?

默认使用的是浅层比较

可以近似理解成:

js 复制代码
Object.is(prevProps.name, nextProps.name);
Object.is(prevProps.age, nextProps.age);

注意:

jsx 复制代码
<UserInfo user={{ name: '张三', age: 30 }} />

即使内容一样:

js 复制代码
{ name: '张三', age: 30 } !== { name: '张三', age: 30 }

因为这是两个不同的对象引用。


完整示例代码

jsx 复制代码
import React from 'react';

/**
 * React.memo 包装函数组件。
 *
 * 只有当 props 发生变化时,React 才需要重新执行这个组件。
 */
const UserInfo = React.memo(function UserInfo({ name, age }) {
  console.log('UserInfo render');

  return (
    <div>
      <p>姓名:{name}</p>
      <p>年龄:{age}</p>
    </div>
  );
});

export default function App() {
  // count 和 UserInfo 的 props 没有任何关系。
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCount((value) => value + 1)}>
        count:{count}
      </button>

      {/*
        点击按钮后 App 会重新执行。

        但是:
        name 始终是 "张三"
        age 始终是 30

        React.memo 会发现 UserInfo 的 props 没有发生变化,
        因此可以跳过 UserInfo 的这次重新渲染。
      */}
      <UserInfo name="张三" age={30} />
    </div>
  );
}

自定义比较函数

如果默认浅比较不能满足需求,可以:

jsx 复制代码
const UserInfo = React.memo(
  function UserInfo({ user }) {
    console.log('UserInfo render');

    return (
      <div>
        {user.name} - {user.age}
      </div>
    );
  },

  /**
   * 返回 true:
   * 表示新旧 props 可以认为相同,
   * React 可以跳过本次重新渲染。
   *
   * 返回 false:
   * 表示 props 发生变化,需要重新渲染。
   */
  (prevProps, nextProps) => {
    return (
      prevProps.user.name === nextProps.user.name &&
      prevProps.user.age === nextProps.user.age
    );
  }
);

注意:自定义比较函数不是越复杂越好

例如:

js 复制代码
JSON.stringify(prevProps) === JSON.stringify(nextProps)

通常不是好方案。

因为:

text 复制代码
比较本身也需要成本
       ↓
props 越复杂
       ↓
比较时间越长
       ↓
可能比较的成本 > 重新渲染成本

所以 React.memo 的核心不是:

"所有组件都包起来。"

而是:

组件重新渲染成本较高 + props 经常保持稳定 + 父组件经常更新。


三、React.memo 的边界和常见误区

面试题 3:React.memo 是不是可以阻止组件所有重新渲染?

核心思路(一句话)

不能,React.memo 主要阻止的是"父组件重新渲染导致的、props 没变化的子组件重新渲染"。

这是面试非常容易追问的一点

text 复制代码
React.memo
   │
   ├── 父组件重新渲染 + props 没变化
   │        → 通常可以跳过
   │
   ├── props 变化
   │        → 重新渲染
   │
   ├── 自己的 state 变化
   │        → 仍然重新渲染
   │
   └── 读取的 Context 变化
            → 仍然可能重新渲染

所以:

jsx 复制代码
const Child = React.memo(() => {
  const [count, setCount] = React.useState(0);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
});

点击按钮:

text 复制代码
Child 自己的 state 改变
        ↓
Child 必须重新渲染

React.memo 并不会阻止它。


四、useCallback

面试题 4:useCallback 是什么?为什么它经常和 React.memo 一起使用?

核心思路(一句话)

useCallback 缓存的是函数引用,在依赖不变时返回同一个函数引用,从而避免函数引用变化导致 React.memo 子组件失去优化效果。

关键矛盾

看这个代码:

jsx 复制代码
const increment = () => {
  setCount((count) => count + 1);
};

每次 App 执行:

text 复制代码
第一次 App 执行
increment → 函数对象 A

第二次 App 执行
increment → 函数对象 B

第三次 App 执行
increment → 函数对象 C

虽然:

js 复制代码
increment()

逻辑完全一样。

但是:

js 复制代码
A !== B
B !== C

所以:

text 复制代码
React.memo
    ↓
比较 onClick
    ↓
旧函数引用 !== 新函数引用
    ↓
认为 props 发生变化
    ↓
子组件重新渲染

正确组合

text 复制代码
React.memo
    +
useCallback
    ↓
稳定函数引用
    ↓
React.memo 可以真正发挥作用

完整示例代码

jsx 复制代码
import React from 'react';

/**
 * 子组件使用 React.memo。
 *
 * 只有当 props 发生变化时才重新渲染。
 */
const MyButton = React.memo(function MyButton({ onClick }) {
  console.log('MyButton render');

  return (
    <button onClick={onClick}>
      增加 count
    </button>
  );
});

export default function App() {
  const [count, setCount] = React.useState(0);
  const [other, setOther] = React.useState(0);

  /**
   * 使用函数式更新:
   *
   * setCount((currentCount) => currentCount + 1)
   *
   * 这样 increment 不需要读取外部的 count。
   *
   * 因此 useCallback 的依赖数组可以为空,
   * 函数引用可以在整个组件生命周期中保持稳定。
   */
  const increment = React.useCallback(() => {
    setCount((currentCount) => currentCount + 1);
  }, []);

  return (
    <div>
      <p>count:{count}</p>
      <p>other:{other}</p>

      {/*
        increment 的函数引用保持稳定。

        因此即使 App 因为 other 改变而重新渲染,
        MyButton 收到的 onClick 引用仍然相同。

        React.memo 就可以跳过 MyButton 的重新渲染。
      */}
      <MyButton onClick={increment} />

      <button onClick={() => setOther((value) => value + 1)}>
        修改 other
      </button>
    </div>
  );
}

useCallback 的依赖数组为什么非常重要?

例如:

jsx 复制代码
const handleClick = React.useCallback(() => {
  console.log(count);
}, [count]);

这里:

text 复制代码
count 不变
    ↓
函数引用保持不变

count 改变
    ↓
重新创建函数
    ↓
得到新的函数引用

这并不是 useCallback 失效,而是正确行为 。因为函数依赖了新的 count


一个更重要的优化技巧

很多时候可以通过函数式更新减少依赖:

不推荐

jsx 复制代码
const increment = React.useCallback(() => {
  setCount(count + 1);
}, [count]);

更推荐

jsx 复制代码
const increment = React.useCallback(() => {
  setCount((currentCount) => currentCount + 1);
}, []);

原因:

text 复制代码
直接读取 count
     ↓
依赖 count
     ↓
count 变化
     ↓
函数重新创建

而:

text 复制代码
函数式更新
     ↓
不需要读取外部 count
     ↓
依赖数组可以为空
     ↓
函数引用长期稳定

五、useMemo

面试题 5:useMemo 和 useCallback 有什么区别?

核心思路(一句话)

useMemo 缓存计算结果,useCallback 缓存函数引用;本质上都是为了减少不必要的重复工作,但优化对象不同。

对比

API 缓存什么 常见用途
useMemo 计算结果 大量计算、派生数据
useCallback 函数引用 React.memo 子组件传函数
React.memo 组件渲染结果是否需要重新执行 避免无意义的子组件渲染

完整示例代码

jsx 复制代码
import React from 'react';

/**
 * 模拟一个比较昂贵的计算。
 *
 * 实际项目中可能是:
 * - 大数组排序
 * - 大数组过滤
 * - 复杂数据转换
 * - 图表数据加工
 * - 权限树计算
 */
function expensiveCalculation(number) {
  console.log('执行昂贵计算');

  let result = 0;

  // 这里只是为了模拟 CPU 密集型计算。
  for (let i = 0; i < 10000000; i += 1) {
    result += (number * i) % 10;
  }

  return result;
}

export default function App() {
  const [number, setNumber] = React.useState(1);
  const [other, setOther] = React.useState(0);

  /**
   * 只有 number 发生变化时,
   * expensiveCalculation 才会重新执行。
   *
   * 如果 App 因为 other 改变而重新渲染,
   * 由于 number 没变化,
   * React 会直接使用上一次缓存的计算结果。
   */
  const result = React.useMemo(() => {
    return expensiveCalculation(number);
  }, [number]);

  return (
    <div>
      <p>计算结果:{result}</p>

      <button onClick={() => setNumber((value) => value + 1)}>
        修改 number
      </button>

      <button onClick={() => setOther((value) => value + 1)}>
        修改 other
      </button>

      <p>other:{other}</p>
    </div>
  );
}

六、useMemo 为什么不能滥用?

面试题 6:是不是所有计算都应该使用 useMemo?

核心思路(一句话)

不是,只有计算成本明显、重复执行频繁、依赖相对稳定且缓存收益高时,useMemo 才值得使用。

例如:

jsx 复制代码
const result = useMemo(() => {
  return a + b;
}, [a, b]);

通常没有必要。

因为:

text 复制代码
a + b 本身极其便宜
        ↓
useMemo 需要维护依赖
        ↓
还需要缓存和检查
        ↓
收益可能小于成本

应该使用 useMemo 的场景

text 复制代码
10000 条数据
    ↓
复杂 filter
    ↓
复杂 sort
    ↓
数据转换
    ↓
图表数据计算

比较适合。


七、虚拟列表

面试题 7:什么是虚拟列表?为什么它能够优化长列表性能?

核心思路(一句话)

虚拟列表通过窗口化技术,只为当前可视区域及少量缓冲区创建 DOM,而不是一次性渲染整个超长列表,从根源上减少 DOM 数量、布局和渲染开销。

几百、几千条数据一次性渲染会造成页面卡顿,虚拟列表的核心是只渲染视口内以及少量缓冲项。

核心架构图

假设总数据:

text 复制代码
10000 条数据

普通列表:

text 复制代码
DOM
│
├── item 1
├── item 2
├── item 3
├── ...
├── item 10000

虚拟列表:

text 复制代码
10000 条数据
      ↓
当前滚动位置
      ↓
计算可见区
      ↓
计算 overscan 缓冲区
      ↓
只渲染例如 30 条
      ↓
┌─────────────────┐
│      可视区域    │
│  item 500       │
│  item 501       │
│  item 502       │
│  ...            │
│  item 520       │
└─────────────────┘

滚动:

text 复制代码
scrollTop
   ↓
计算 startIndex
   ↓
计算 endIndex
   ↓
更新可见数据
   ↓
复用 / 更新有限 DOM

底层实现原理

假设:

text 复制代码
每一项高度 = 50px

当前:

text 复制代码
scrollTop = 5000px

那么:

text 复制代码
startIndex = floor(5000 / 50)
           = 100

如果容器高度:

text 复制代码
600px

那么大约可以显示:

text 复制代码
600 / 50 = 12 项

再增加 overscan:

text 复制代码
前面额外 5 项
后面额外 5 项

实际可能渲染:

text 复制代码
95 ~ 116

而不是:

text 复制代码
0 ~ 9999

一个完整的固定高度虚拟列表示例

jsx 复制代码
import React from 'react';

const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 400;
const OVERSCAN_COUNT = 5;

/**
 * 一个简单的虚拟列表。
 *
 * 这个示例假设:
 * 1. 每一项高度固定;
 * 2. 数据数量非常大;
 * 3. 通过 scrollTop 计算当前应该渲染哪些数据。
 */
export default function VirtualList() {
  // 模拟 10000 条数据。
  const items = React.useMemo(() => {
    return Array.from({ length: 10000 }, (_, index) => ({
      id: index,
      name: `用户 ${index + 1}`,
    }));
  }, []);

  const [scrollTop, setScrollTop] = React.useState(0);

  /**
   * 计算当前可视区域最开始的位置。
   *
   * 例如:
   * scrollTop = 500
   * ITEM_HEIGHT = 50
   *
   * startIndex = 10
   */
  const startIndex = Math.max(
    0,
    Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN_COUNT
  );

  /**
   * 根据容器高度计算理论上可以显示多少条。
   */
  const visibleCount = Math.ceil(
    CONTAINER_HEIGHT / ITEM_HEIGHT
  );

  /**
   * 结束位置增加 overscan。
   *
   * 多渲染一些上下缓冲项,
   * 可以避免快速滚动时出现白屏。
   */
  const endIndex = Math.min(
    items.length,
    startIndex + visibleCount + OVERSCAN_COUNT * 2
  );

  /**
   * 只截取真正需要渲染的数据。
   */
  const visibleItems = items.slice(startIndex, endIndex);

  return (
    <div
      style={{
        height: CONTAINER_HEIGHT,
        overflow: 'auto',
        border: '1px solid #ccc',
      }}
      onScroll={(event) => {
        /**
         * 每次滚动时记录 scrollTop。
         *
         * scrollTop 发生变化后,
         * React 会重新计算可视数据范围。
         */
        setScrollTop(event.currentTarget.scrollTop);
      }}
    >
      {/*
        这个外层元素的高度仍然等于整个列表的总高度。

        这样浏览器的滚动条才能表现得像有 10000 条数据。
      */}
      <div
        style={{
          height: items.length * ITEM_HEIGHT,
          position: 'relative',
        }}
      >
        {visibleItems.map((item, index) => {
          /**
           * 真实索引:
           *
           * startIndex + index
           */
          const realIndex = startIndex + index;

          return (
            <div
              key={item.id}
              style={{
                position: 'absolute',
                top: realIndex * ITEM_HEIGHT,
                left: 0,
                right: 0,
                height: ITEM_HEIGHT,
                display: 'flex',
                alignItems: 'center',
                padding: '0 12px',
                boxSizing: 'border-box',
                borderBottom: '1px solid #eee',
              }}
            >
              {item.name}
            </div>
          );
        })}
      </div>
    </div>
  );
}

实际项目更推荐

实际项目中,如果需求复杂,通常不建议自己实现完整虚拟列表,因为还需要处理:

text 复制代码
固定高度
动态高度
滚动定位
overscan
快速滚动
键盘导航
滚动锚定
数据追加
容器尺寸变化

所以,可以使用 react-window 等成熟方案:

text 复制代码
简单固定高度列表
    → 可以自己实现

复杂业务长列表
    → 优先使用成熟虚拟化方案

八、虚拟列表的边界场景

面试题 8:虚拟列表是不是任何长列表都适合?

核心思路(一句话)

虚拟列表适合"大量 DOM 元素同时存在"是主要瓶颈的场景,但对于分页、服务端搜索、数据量本身不大或动态高度特别复杂的列表,需要综合考虑。

不适合直接使用的情况

例如:

text 复制代码
总共只有 30 条数据

没必要虚拟化。

又比如:

text 复制代码
数据量 10000 条
但是用户每次只需要分页查看 20 条

更适合:

text 复制代码
分页
+
服务端查询

而不是单纯把 10000 条数据全部加载到浏览器再虚拟化。

一个非常重要的面试加分点

虚拟列表解决的是"渲染成本",不一定解决"数据获取成本"。

例如:

text 复制代码
数据库 1000000 条数据
        ↓
接口一次返回 1000000 条
        ↓
浏览器拿到 1000000 条
        ↓
即使使用虚拟列表
        ↓
网络、JSON 解析、内存仍然可能成为瓶颈

所以更合理:

text 复制代码
服务端分页 / 游标分页
        ↓
只获取当前需要的数据
        ↓
浏览器虚拟化
        ↓
只渲染当前需要的 DOM

这属于:

数据层优化 + 渲染层优化。


九、代码分割与 React.lazy

面试题 9:React 如何通过代码分割优化首屏加载?

核心思路(一句话)

把非首屏必需的 JavaScript 拆成独立代码块,在真正需要时再加载,从而降低首屏需要下载、解析和执行的 JavaScript 体积。

解决方案流程图

text 复制代码
原始应用
   ↓
所有页面代码打进一个 JavaScript Bundle
   ↓
首屏下载整个 Bundle
   ↓
下载慢 + 解析慢 + 执行慢

优化:

text 复制代码
应用代码
   ↓
代码分割
   ↓
┌───────────────┐
│ 首页 Bundle    │ ← 首屏加载
├───────────────┤
│ 用户中心 Bundle │ ← 进入用户中心再加载
├───────────────┤
│ 管理后台 Bundle │ ← 进入后台再加载
└───────────────┘

完整示例

jsx 复制代码
import React from 'react';

/**
 * React.lazy 接收一个动态 import 函数。
 *
 * import('./UserCenter') 会被构建工具识别为一个独立代码块。
 *
 * 只有真正需要 UserCenter 时,
 * 浏览器才会请求这个 JavaScript 文件。
 */
const UserCenter = React.lazy(() => import('./UserCenter'));

export default function App() {
  const [showUserCenter, setShowUserCenter] =
    React.useState(false);

  return (
    <div>
      <h1>首页</h1>

      <button
        onClick={() => setShowUserCenter(true)}
      >
        打开用户中心
      </button>

      {/*
        Suspense 用于处理 lazy 组件还没有加载完成的状态。

        fallback 会在 UserCenter 的代码加载期间显示。
      */}
      <React.Suspense fallback={<div>用户中心加载中...</div>}>
        {showUserCenter && <UserCenter />}
      </React.Suspense>
    </div>
  );
}

底层原理

text 复制代码
React.lazy
    ↓
动态 import()
    ↓
构建工具拆分 Chunk
    ↓
浏览器首次不下载该 Chunk
    ↓
组件真正需要
    ↓
执行 import()
    ↓
发送网络请求
    ↓
Chunk 下载完成
    ↓
Promise resolve
    ↓
React 渲染组件

十、代码分割不是越细越好

面试题 10:代码分割是不是越多越好?

核心思路(一句话)

代码分割的目标是降低关键路径上的资源成本,而不是无限拆包;拆得过细可能增加网络请求、调度和缓存管理成本。

例如:

text 复制代码
错误思路:

每一个 Button
每一个 Icon
每一个小组件
都拆成一个 Chunk

可能导致:

text 复制代码
Chunk 数量暴增
      ↓
请求数量增加
      ↓
加载调度复杂
      ↓
反而影响性能

比较合理的是按照:

text 复制代码
路由
+
业务模块
+
首屏 / 非首屏
+
访问频率

进行拆分。


十一、React DevTools Profiler

面试题 11:如何定位 React 应用中的性能问题?

核心思路(一句话)

不要凭感觉优化,应该使用 React DevTools Profiler 录制真实交互,分析哪些组件重新渲染、渲染耗时多少,再针对具体瓶颈优化。

建议在优化前通过 Profiler 分析组件渲染情况,并通过优化前后的 Profile 进行验证。

诊断流程

text 复制代码
发现页面卡顿
      ↓
React DevTools Profiler
      ↓
开始录制
      ↓
执行真实操作
      ↓
停止录制
      ↓
观察:
├── 哪些组件渲染
├── 渲染次数
├── 渲染耗时
├── 组件之间的关系
└── 哪次交互最慢
      ↓
定位瓶颈
      ↓
选择优化方案
      ↓
再次录制
      ↓
比较优化前后

Profiler 重点关注什么?

不要只是看:

"哪个组件渲染了?"

更应该看:

text 复制代码
为什么渲染?
      ↓
props 变了吗?
state 变了吗?
Context 变了吗?
父组件导致的吗?
      ↓
渲染本身耗时多少?
      ↓
真正值得优化吗?

十二、React 性能优化完整排查方法

面试题 12:如果面试官问"你在实际项目中是怎么做 React 性能优化的",应该怎么回答?

核心思路(一句话)

我的性能优化流程是"监控与复现 → 定位瓶颈 → 分类处理 → 小步优化 → 指标验证",而不是一上来就堆 React.memo、useMemo 和 useCallback。

把优化方法归纳为发现问题、分析瓶颈、制定策略、实时优化并验证效果,并强调避免过早优化和过度优化。

完整架构图

text 复制代码
                 React 性能问题
                       │
                       ↓
                ① 发现 / 复现
                       │
          ┌────────────┼────────────┐
          ↓            ↓            ↓
       首屏慢         交互卡       滚动卡
          │            │            │
          ↓            ↓            ↓
      加载分析       渲染分析      列表分析
          │            │            │
          ↓            ↓            ↓
     Bundle 分析    Profiler      DOM 数量
          │            │            │
          ↓            ↓            ↓
     代码分割       memo 系列      虚拟列表
     懒加载         组件拆分       分页
     预加载         状态下沉       服务端分页
                       │
                       ↓
                 ② 性能优化
                       │
                       ↓
                 ③ 再次测量
                       │
                       ↓
                 ④ 指标对比
                       │
              ┌────────┴────────┐
              ↓                 ↓
            有收益             无收益
              ↓                 ↓
           保留优化        回滚 / 换方案

十三、React 性能优化的"主要矛盾"和"次要矛盾"

主要矛盾

1. 不必要的工作

text 复制代码
没有必要的组件重新渲染
没有必要的计算
没有必要的 DOM
没有必要的 JavaScript

所以核心目标是:

减少不必要的工作


次要矛盾

具体采用哪个工具:

text 复制代码
React.memo
useCallback
useMemo
虚拟列表
React.lazy
Suspense
startTransition
useDeferredValue

这些都只是解决具体问题的工具

所以面试时不要回答成:

"React 性能优化就是 React.memo + useMemo + useCallback。"

这是层次比较低的答案。

应该回答:

text 复制代码
先确定瓶颈
      ↓
再确定瓶颈类型
      ↓
最后选择工具

十四、补充:React 18 的并发相关优化

面试题 13:React 中如何处理高优先级交互和低优先级更新之间的冲突?

核心思路(一句话)

对于不需要立即完成的更新,可以降低它的更新优先级,让用户输入等高优先级交互优先得到响应。

典型 API:

jsx 复制代码
startTransition

以及:

jsx 复制代码
useDeferredValue

使用场景

例如搜索:

text 复制代码
用户输入关键字
       ↓
input 更新
       ↓
高优先级
       ↓
必须立即响应

搜索结果过滤
       ↓
可能有几千条数据
       ↓
低优先级

可以把两者区分开。


完整示例

jsx 复制代码
import React from 'react';

const DATA = Array.from(
  { length: 10000 },
  (_, index) => `商品 ${index + 1}`
);

export default function SearchPage() {
  const [keyword, setKeyword] = React.useState('');
  const [filteredData, setFilteredData] =
    React.useState(DATA);

  const [isPending, startTransition] =
    React.useTransition();

  function handleChange(event) {
    const value = event.target.value;

    /**
     * 输入框本身属于高优先级更新。
     *
     * 用户输入什么,应该立即显示什么。
     */
    setKeyword(value);

    /**
     * 搜索结果属于可以延后的更新。
     *
     * 使用 startTransition 后,
     * React 可以把这部分更新作为非紧急更新处理。
     */
    startTransition(() => {
      const result = DATA.filter((item) =>
        item.includes(value)
      );

      setFilteredData(result);
    });
  }

  return (
    <div>
      <input
        value={keyword}
        onChange={handleChange}
        placeholder="搜索商品"
      />

      {/*
        isPending 可以告诉用户:
        低优先级更新还在处理。
      */}
      {isPending && <p>搜索中...</p>}

      <ul>
        {filteredData.slice(0, 100).map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

注意

startTransition 不是:

"让 JavaScript 在后台线程执行。"

JavaScript 仍然主要运行在主线程。

它解决的是:

React 更新优先级和可中断渲染的问题。


十五、useDeferredValue 与 useMemo 的区别

这是一个很容易被面试官追问的点。

text 复制代码
useMemo
    ↓
减少重复计算

useDeferredValue
    ↓
允许某个值的使用滞后一些
    ↓
优化交互响应

例如:

text 复制代码
输入框 value
     ↓
立即更新
     ↓
useDeferredValue
     ↓
得到稍微滞后的 value
     ↓
昂贵列表使用 deferredValue

所以:

text 复制代码
useMemo = "少算几次"
useDeferredValue = "可以晚一点处理"

两者解决的问题不同。


十六、性能优化中的一个高级误区:React.memo 不等于性能一定提升

假设:

jsx 复制代码
const Child = React.memo(function Child({ value }) {
  return <div>{value}</div>;
});

如果:

text 复制代码
Child 每次渲染只花 0.01ms

但是:

text 复制代码
props 比较 + memo 管理成本

可能已经比重新渲染更复杂。

因此真正的原则是:

text 复制代码
优化收益
=
被减少的工作成本
-
优化机制本身的成本

所以性能优化应该基于测量。


十七、首屏性能不能只看 React

这是面试中非常容易拉开差距的一点。

完整的首屏性能链路应该是:

text 复制代码
DNS
 ↓
TCP
 ↓
TLS
 ↓
HTML
 ↓
JavaScript 下载
 ↓
JavaScript 解析
 ↓
JavaScript 执行
 ↓
React 初始化
 ↓
组件渲染
 ↓
DOM
 ↓
Style/Layout
 ↓
Paint
 ↓
用户看到内容

因此:

React 性能优化只是前端性能优化的一部分。

如果首屏慢是:

text 复制代码
JavaScript 包 5MB

你应该考虑:

text 复制代码
代码分割
Tree Shaking
依赖分析
懒加载
资源压缩
缓存

而不是:

text 复制代码
给所有组件加 React.memo

十八、性能优化的完整决策表

发现的问题 第一考虑方案 第二考虑方案
父组件更新导致子组件无意义更新 React.memo 状态下沉、组件拆分
函数 props 引用频繁变化 useCallback 调整组件结构
对象/数组 props 引用频繁变化 useMemo 状态结构调整
昂贵计算重复执行 useMemo Web Worker、算法优化
数千条列表 虚拟列表 分页、服务端分页
首屏 JavaScript 太大 代码分割 懒加载、依赖优化
非首屏页面 React.lazy 路由级代码分割
输入导致复杂列表卡顿 useDeferredValue startTransition
非紧急状态更新阻塞交互 startTransition 状态结构调整
不知道哪里慢 React DevTools Profiler 浏览器 Performance
DOM 太多 虚拟列表 减少 DOM
接口数据太大 分页 / 游标分页 服务端过滤
计算阻塞主线程 算法优化 Web Worker

十九、性能优化面试中最容易犯的错误

错误 1:所有组件都加 React.memo

错误思想:

text 复制代码
React.memo 越多
=
性能越好

正确:

text 复制代码
React.memo
=
有明确收益时才使用

错误 2:所有函数都 useCallback

错误:

jsx 复制代码
const add = useCallback(() => {
  return 1 + 1;
}, []);

如果:

text 复制代码
函数非常简单
+
没有作为稳定 props 传递

通常没有意义。


错误 3:所有计算都 useMemo

错误:

jsx 复制代码
const total = useMemo(() => price * count, [
  price,
  count
]);

如果计算非常简单,缓存成本可能没有意义。


错误 4:把 useCallback 当成"阻止组件渲染"的 API

错误:

useCallback 可以防止组件重新渲染。

正确:

useCallback 主要是稳定函数引用;如果这个函数作为 props 传递给 React.memo 子组件,那么可以帮助子组件避免因函数引用变化而产生的无意义重新渲染。


错误 5:虚拟列表只解决 DOM 问题

不完整。

应该说:

text 复制代码
虚拟列表
    ↓
减少 DOM 数量
    ↓
减少渲染 / 布局 / 绘制压力

但是:

text 复制代码
数据获取
JSON 解析
内存
网络

仍然可能成为瓶颈。


错误 6:看到性能问题就优化

正确:

text 复制代码
发现问题
 ↓
测量
 ↓
定位
 ↓
优化
 ↓
再次测量

而不是:

text 复制代码
感觉慢
 ↓
开始乱加 memo

应该小步优化,并通过优化前后的 Profile 对比验证效果


二十、最终整理:React 性能优化满分答案

下面这段可以直接作为面试时的最终口述答案

面试题:React 项目中你是如何进行性能优化的?

核心思路(一句话)

React 性能优化的核心不是盲目使用某个 API,而是先通过性能工具定位瓶颈,再从减少不必要的渲染、减少不必要的计算、减少 DOM 数量和减少首屏资源几个方向针对性优化,最后通过指标验证优化效果。


一、我通常会按照这条链路进行优化

text 复制代码
发现性能问题
      ↓
复现问题并建立指标
      ↓
React DevTools Profiler / 浏览器 Performance 定位
      ↓
判断属于哪一类问题
      ↓
┌──────────┬──────────┬──────────┬──────────┐
│ 渲染问题 │ 计算问题 │ 列表问题 │ 加载问题 │
└────┬─────┴────┬─────┴────┬─────┴────┬─────┘
     ↓           ↓          ↓          ↓
React.memo    useMemo     虚拟列表   代码分割
useCallback   算法优化    分页       React.lazy
组件拆分      Web Worker  服务端分页  懒加载
状态下沉
     ↓
小步优化
     ↓
重新 Profile
     ↓
比较优化前后指标

二、第一类:减少不必要的组件渲染

如果发现父组件更新导致某些子组件频繁重新渲染,而这些子组件实际上没有任何变化,我会考虑使用 React.memo

jsx 复制代码
const UserInfo = React.memo(function UserInfo({ name, age }) {
  return (
    <div>
      <p>{name}</p>
      <p>{age}</p>
    </div>
  );
});

React.memo 默认会对 props 做浅层比较。

如果:

text 复制代码
旧 props === 新 props

React 就可以跳过这次因为父组件更新而导致的子组件重新渲染。

但是需要注意,React.memo 并不能阻止组件自己的 state 更新,也不能阻止所有 Context 变化导致的更新。


三、第二类:解决函数引用变化

如果子组件使用了 React.memo,但是父组件每次重新渲染都会重新创建一个回调函数,那么:

jsx 复制代码
const handleClick = () => {};

每次都会产生新的函数引用。

于是:

text 复制代码
旧 handleClick !== 新 handleClick

即使函数逻辑完全一样,React.memo 的浅比较仍然会认为 props 发生变化。

这种情况下可以使用 useCallback

jsx 复制代码
const handleClick = React.useCallback(() => {
  setCount((currentCount) => currentCount + 1);
}, []);

所以我会强调:

useCallback 本身不是为了直接阻止组件重新渲染,而是为了稳定函数引用,尤其是在函数作为 props 传给 React.memo 子组件时非常有价值。


四、第三类:减少昂贵计算

如果组件每次重新渲染都会执行大量排序、过滤、数据转换或者图表数据计算,可以使用 useMemo

jsx 复制代码
const result = React.useMemo(() => {
  return expensiveCalculation(data);
}, [data]);

这样只有 data 发生变化时才重新计算。

但是我不会滥用 useMemo

如果只是:

jsx 复制代码
const total = price * count;

这种非常廉价的计算,一般没有必要增加记忆化逻辑。

所以使用 useMemo 的前提是:

text 复制代码
计算成本较高
+
重复执行频繁
+
依赖相对稳定
+
缓存收益明显

五、第四类:解决超长列表

如果页面需要展示几千甚至几万条数据,一次性创建所有 DOM 会带来明显的渲染、布局和内存压力。

这时候我会使用虚拟列表。

虚拟列表的核心思想是:

text 复制代码
10000 条数据
      ↓
计算当前 scrollTop
      ↓
计算可视区域
      ↓
增加少量 overscan
      ↓
只渲染几十个列表项

用户滚动时动态更新可见范围。

这样可以把:

text 复制代码
一次渲染 10000 个 DOM

变成:

text 复制代码
只维护几十个 DOM

实际项目中,如果涉及动态高度、滚动定位、overscan 等复杂逻辑,我通常优先使用成熟的虚拟化方案,而不是自己重新实现。

同时还要注意:

虚拟列表解决的是渲染层问题,并不意味着可以把几十万条数据全部一次性从服务器传到浏览器。

如果数据本身很大,还应该配合:

text 复制代码
服务端分页
+
游标分页
+
服务端过滤
+
虚拟列表

六、第五类:优化首屏加载

如果发现首屏慢是因为 JavaScript Bundle 太大,我会分析 Bundle 构成,然后使用代码分割。

例如:

jsx 复制代码
const UserCenter = React.lazy(
  () => import('./UserCenter')
);

然后:

jsx 复制代码
<React.Suspense fallback={<div>加载中...</div>}>
  <UserCenter />
</React.Suspense>

底层原理是:

text 复制代码
动态 import()
      ↓
构建工具拆分代码块
      ↓
非首屏代码不会进入首屏关键资源
      ↓
真正访问时才请求
      ↓
下载完成
      ↓
React 渲染组件

因此可以明显减少首屏需要下载、解析和执行的 JavaScript。

但代码分割也不是越细越好,需要根据路由、业务模块和访问频率合理拆分。


七、第六类:处理交互响应问题

如果是搜索框、筛选器等场景:

text 复制代码
用户输入
      ↓
立即更新输入框
      ↓
同时触发昂贵列表计算
      ↓
页面卡顿

可以考虑 React 的并发更新能力。

例如使用 startTransition 把非紧急更新标记出来:

jsx 复制代码
const [isPending, startTransition] =
  React.useTransition();

function handleChange(event) {
  const value = event.target.value;

  // 输入框更新必须及时响应。
  setKeyword(value);

  // 搜索结果可以稍后处理。
  startTransition(() => {
    setResults(search(value));
  });
}

这里需要特别说明:

startTransition 不是让 JavaScript 在另一个线程执行,而是让 React 能够区分更新优先级,让高优先级交互更及时。


八、我会使用 React DevTools Profiler 定位问题

性能优化不能靠猜。

我通常会:

text 复制代码
打开 React DevTools Profiler
      ↓
开始录制
      ↓
执行真实用户操作
      ↓
停止录制
      ↓
分析组件渲染
      ↓
查看渲染次数和耗时
      ↓
确定真正的性能瓶颈

例如发现:

text 复制代码
点击搜索框
 ↓
App 更新
 ↓
100 个子组件全部重新渲染
 ↓
其中 90 个组件实际上不需要更新

那么我才会进一步考虑:

text 复制代码
React.memo
状态下沉
组件拆分
useCallback

而不是直接给整个项目所有组件都加 React.memo


九、性能优化最重要的是验证

优化之后,我会再次使用相同的操作进行测试。

例如比较:

text 复制代码
优化前:
组件渲染次数:100
渲染耗时:80ms

优化后:
组件渲染次数:20
渲染耗时:25ms

如果优化没有实际收益,甚至增加了复杂度,就应该考虑回滚或者换方案。


十、最终原则

所以我理解的 React 性能优化,本质上是**减少不必要**:

text 复制代码
减少不必要的工作
        ↓
减少不必要的组件渲染
        ↓
减少不必要的计算
        ↓
减少不必要的 DOM
        ↓
减少首屏需要加载的资源
        ↓
提高高优先级交互的响应速度

具体工具只是手段:

text 复制代码
React.memo
    → 减少不必要的组件重新渲染

useCallback
    → 稳定函数引用

useMemo
    → 缓存昂贵计算结果

虚拟列表
    → 减少长列表 DOM 数量

React.lazy + Suspense
    → 代码分割和按需加载

startTransition / useDeferredValue
    → 优化非紧急更新对交互的影响

React DevTools Profiler
    → 定位和验证 React 渲染性能问题

最重要的是:

先测量、后优化;先定位、再选择工具;小步优化、持续验证,避免过早优化和过度优化。

相关推荐
mmsx2 小时前
Android GIS系列 内存加锁、SQLite 开事务:矢量数据集双层并发设计
android·性能优化·app
FungLeo2 小时前
成为全栈·React 管理后台篇·按钮级权限:能力映射、菜单过滤与自锁保护
react·rbac·路由守卫·权限控制·前端权限·成为全栈
FungLeo4 小时前
成为全栈·React 管理后台篇·评论审核工作流:把状态下拉改成可理解的动作
react·内容安全·状态机·交互设计·成为全栈·评论审核
liangshanbo121517 小时前
React 状态持久化面试题——原理深挖版
react·状态持久化
liangshanbo121517 小时前
Redux 的中间件(Middleware)的工作机制
中间件·react·redux
Pioneer000011 天前
我用 Redis + 网关做多模型 API 路由:缓存命中率 95%+ 的工程实践
人工智能·redis·后端·缓存·性能优化·架构
ZFJ_张福杰1 天前
【Flutter】Flutter 中有哪些耗时操作?从 UI 卡顿到 Isolate 性能优化
flutter·性能优化·卡顿·isolate
政企项目老覃1 天前
金融转账“幽灵失败“排查实录:从本地消息表到 Seata TCC 的选型与权衡
数据库·程序人生·性能优化·数据分析·系统架构
爱喝水的鱼丶1 天前
SAP-ABAP:MM 模块供应商主数据开发:字段扩展、分级管控与同步接口开发
开发语言·性能优化·接口·sap·abap·增强·经验交流