理解 React 渲染机制

React 的核心是声明式 UI 更新。当 state 或 props 变化时,React 会重新执行组件函数,生成新的虚拟 DOM 树,与旧树 diff 后更新真实 DOM。

问题在于:如果组件的 props 没变但父组件重新渲染了,子组件也会不必要地重新渲染。React 默认的策略是“子组件无条件跟随父组件渲染”,这是它简单可靠的代价。

需要注意区分两个概念:

  • 渲染(Render):调用组件函数生成虚拟 DOM,发生在内存中,大多数渲染不会产生真实 DOM 操作
  • 提交(Commit):diff 后把变化同步到真实 DOM,只有这一步才有直接的性能开销

所以“不必要的渲染”并非总是性能问题——组件函数执行很快、diff 结果为空时影响可忽略。只有当组件函数本身开销大(大量计算、深递归子树)时,跳过渲染才有明显收益。

memo 与 useMemo

对于函数组件,React.memo 可以做浅比较来避免不必要的渲染。

const ExpensiveList = React.memo(({ items }) => {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

配合 useMemo 缓存计算结果,useCallback 缓存回调函数,可以避免因为引用变化导致的无效渲染。

function ProductPage({ productId }) {
  const [query, setQuery] = useState('');

  // 缓存重计算:只有 query 变化时才重新过滤
  const results = useMemo(() => {
    return heavyFilter(allProducts, query);
  }, [allProducts, query]);

  // 缓存回调:子组件用 memo 包裹时,引用稳定才能跳过渲染
  const handleSelect = useCallback((id) => {
    setSelected(id);
  }, []);
}

但不要无脑加 memo 和 useMemo,它们自身也有比较和内存成本。经验法则:

  • 只对“确实慢”的组件用 memo,先用 Profiler 确认
  • 传递给 memo 子组件的 props 必须全部引用稳定,否则 memo 形同虚设——这是最常见的翻车点
  • 自定义比较函数 memo(Component, areEqual) 在 props 结构复杂时比浅比较更精准

React 19 引入的 React Compiler 正是为了解决这类问题:编译期自动插入记忆化,让开发者不用手写 memo/useMemo/useCallback。新项目可以直接受益,老项目可以逐页面试点。

状态设计:从源头减少渲染

很多渲染问题不是优化工具能救的,根源在状态放错了位置。

状态下放(state colocation)

// 反例:input 状态放在顶层,每敲一个字整棵树都渲染
function App() {
  const [keyword, setKeyword] = useState('');
  return (
    <>
      <SearchInput value={keyword} onChange={setKeyword} />
      <HugeTable />   {/* 与 keyword 无关,却被拖着一起渲染 */}
    </>
  );
}

// 优化:把状态下放到真正消费它的组件里
function App() {
  return (
    <>
      <SearchSection />
      <HugeTable />
    </>
  );
}

function SearchSection() {
  const [keyword, setKeyword] = useState('');
  return <SearchInput value={keyword} onChange={setKeyword} />;
}

拆分状态与组件组合

高频变化的状态(如鼠标位置、输入框内容)和低频更新的大组件树隔离开,是性价比最高的优化手段——不写一行 memo 代码,渲染范围直接缩小一个数量级。同理,Modal、Tooltip 这类高频出现的组件,用 Portal 渲染到独立的 DOM 节点,也能避免影响父树的 reconciliation。

虚拟列表

当列表数据超过 1000 条时,全部渲染会导致严重卡顿。使用虚拟列表只渲染可视区域内的元素。

原理不复杂:容器监听滚动事件,根据 scrollTop 和行高计算当前可视窗口对应的索引区间,只渲染这几十条,并用 transformpadding 撑起总高度让滚动条表现正常。

function VirtualList({ items, rowHeight = 40, viewportHeight = 600 }) {
  const [scrollTop, setScrollTop] = useState(0);

  const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + 10;
  const visibleItems = items.slice(start, start + visibleCount);

  return (
    <div
      style={{ height: viewportHeight, overflowY: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: items.length * rowHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${start * rowHeight}px)` }}>
          {visibleItems.map(item => (
            <div key={item.id} style={{ height: rowHeight }}>{item.name}</div>
          ))}
        </div>
      </div>
    </div>
  );
}

生产环境建议直接用成熟的库:@tanstack/react-virtual(轻量、支持动态行高)、react-window(经典稳定)。注意动态行高、图片懒加载占位是虚拟列表的两大难点,选库时要确认支持。

网络层与加载体验

渲染优化之外,网络往往才是真正的瓶颈:

  • 代码分割:路由级用 React.lazy + Suspense,重组件(编辑器、图表库)按需动态 import,首屏包体积能砍掉一半以上
  • 数据预取:在用户 hover 导航链接时就开始 prefetch 目标页面的 chunk 和数据,点击时秒开
  • 列表分页与无限滚动:一次拉全量数据既慢又占内存,服务端分页配合 SWR/React Query 的缓存和乐观更新是更现代的做法
  • 图片优化:使用 loading="lazy"、现代格式(WebP/AVIF)、根据容器宽度请求合适尺寸(srcset)
const Chart = React.lazy(() => import('./HeavyChart'));

<Suspense fallback={<Skeleton />}>
  <Chart data={data} />
</Suspense>

用 Profiler 度量

优化前先度量,React DevTools 的 Profiler 是第一工具:

  1. 点击录制按钮,操作页面,停止录制
  2. 火焰图中颜色越深表示渲染耗时越长
  3. 点击具体组件,右侧 “Why did this render?” 会告诉你原因:props 变了、state 变了、还是父组件渲染了
  4. 勾选 “Highlight updates when components render” 可以直观看到哪些组件在跟着闪

Chrome DevTools 的 Performance 面板则适合看全局:长任务(Long Task)、布局抖动(Layout Thrashing)、脚本执行与渲染的占比,帮你在“JS 太慢”和“渲染太慢”之间定位方向。

总结

性能优化的核心思路:减少不必要的渲染、减少不必要的计算、减少不必要的网络请求。先度量再优化,用 Profiler 找到瓶颈,避免盲目优化。落地优先级建议:先做状态设计(下放、拆分)和代码分割这类结构性优化,再用 memo/useMemo 做点状修补,最后才考虑虚拟列表这类专项方案。始终盯住两个用户可感知的指标:INP(交互响应)和 LCP(内容加载),其他的都是手段。