This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/5. 工程实践篇/15-性能优化.md
T
2026-04-29 23:36:32 +08:00

11 KiB
Raw Blame History

tags, create time
tags create time
React
Performance
Optimization
Frontend
2026-04-29 22:14

性能优化

概述

React 性能优化的核心原则是 "减少不必要的渲染"。本文档从诊断工具到具体手段,提供完整的调优方法论。

[!question] 思考:为什么 React 会慢? 大多数情况下,应用变慢不是因为 React 本身慢,而是因为 它重新渲染了不该渲染的组件。 一个常见的陷阱:父组件 state 变化 → 所有子组件重新执行 render(即使它们的 props 没变)。 我们的目标:让 React 只渲染真正需要更新的部分。

优化决策流程图

flowchart TD
    A[页面卡顿?] --> B{使用 Profiler 定位}
    B --> C[频繁重渲染?]
    B --> D[首屏加载慢?]
    B --> E[滚动掉帧?]
    
    C --> F[props 不变却渲染?]
    C --> G[状态更新频率过高?]
    F --> H["React.memo / useMemo"]
    G --> I["useCallback / 防抖节流"]
    
    D --> J[打包体积过大?]
    D --> K[某些功能很少用?]
    J --> L["Code Splitting / Tree Shaking"]
    K --> M["Dynamic Import"]
    
    E --> N[列表项很多?]
    N --> O["虚拟列表 Virtual Scroll"]
    
    style H fill:#61DAFB,color:#000
    style L fill:#61DAFB,color:#000
    style O fill:#61DAFB,color:#000

[!warning] 第一条原则:先测量,再优化 没有 Profiler 数据的优化都是猜。先用 React DevTools 或 Chrome Performance 确认瓶颈在哪, 然后针对性地解决,而不是盲目地在每个组件上加 memo。

性能诊断工具箱

graph TB
    A[发现性能问题] --> B["选择诊断工具"]
    
    B --> C["React DevTools Profiler"]
    B --> D["Chrome Performance Tab"]
    B --> E["Lighthouse"]
    B --> F["Web Vitals 监控"]
    
    C --> G["定位重渲染的组件和原因"]
    D --> H["分析主线程阻塞时段"]
    E --> I["整体 LCP/INP/CLS 评分"]
    F --> J["生产环境真实用户数据"]
    
    style C fill:#61DAFB,color:#000
    style H fill:#F5A87D,color:#000

React DevTools Profiler 使用要点

  1. 录制期间进行关键交互(点击、输入)
  2. 观察 Commit 颜色 —— 红色越深表示重渲染越多
  3. 展开组件树,关注 "why did this render" 原因
  4. 对比优化前后的 Commit 时间变化

React.memo —— 阻止子组件重渲染

const ExpensiveList = React.memo(({ items }: { items: Item[] }) => {
  // props.items 引用不变时,跳过整个子树的 re-render
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}, (prevProps, nextProps) => prevProps.items === nextProps.items);  // 自定义比较函数

[!warning] React.memo 的适用边界

  • 只对纯组件有用——相同 props 必须产生相同的输出
  • 父组件每次传新对象/函数作 prop → memo 无效
  • 简单列表(几十项以内)不需要 memo

缓存计算与回调 — useMemo / useCallback

React.memo 解决的是"组件不重新渲染",而 useMemo 和 useCallback 解决的是**"值不重新生成"**。

useMemo —— 缓存昂贵的计算结果

function SearchPage({ items, keyword }: { items: string[]; keyword: string }) {
  // 过滤逻辑只在 items 或 keyword 变化时重新执行
  const filtered = useMemo(() => {
    console.log("filtering...");  // 仅依赖变化时才打印
    return items.filter(item => item.includes(keyword));
  }, [items, keyword]);

  return <List data={filtered} />;
}

[!tip] useMemo 的本质 它不是"性能捷径",而是跳过不必要的计算。对于 O(n) 以下的轻量操作,useMemo 反而增加内存开销。 适用场景:大数据量处理、深度嵌套对象的派生计算、API 请求参数校验。

useCallback —— 缓存函数引用

function Parent() {
  const [count, setCount] = useState(0);
  
  // count 变化时才创建新函数,Child 用 React.memo 包裹时不会被重渲染
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child onAction={handleClick} />  {/* 不受 count 变化影响 */}
    </>
  );
}

[!important] 什么时候需要 useCallback? 仅在以下两种场景中使用:

  1. 函数传给 React.memo 包裹的子组件,避免父状态变化导致子组件重渲染
  2. 作为其他 Hook(如 useEffect)的依赖项

否则——不加更简单,也更容易维护。


[!question] 思考:三个 memo 的关系

  • React.memo: 阻止组件树 re-render(组件级别)
  • useMemo: 缓存返回值(值级别)
  • useCallback: 缓存函数引用(等价于 useMemo(() => fn, deps))

它们共同目标是:缩小变更传播范围。当 state 更新时,让影响尽可能少地扩散到子树。

虚拟列表(Virtual Scrolling)

当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用:

// 方案1:tanstack/virtual(推荐)
import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,  // 预估每项高度
    overscan: 5,            // 视口上下各多渲染 5 项
  });
  
  return (
    <div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

[!tip] 何时需要虚拟列表?

  • 列表项 > ~50 且每帧渲染耗时可感知
  • 固定高度的项目比可变高度更容易实现
  • React Window / React Virtualized 是老牌的成熟方案

Code Splitting

Code Splitting 的核心理念:用户不需要一次性下载所有代码。按需加载可以显著降低首屏时间。

路由级拆分

import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

// 每个路由对应一个 chunk,用户只下载当前页面对应的代码
const Home = lazy(() => import("./pages/Home"));
const Admin = lazy(() => import("./pages/Admin"));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

[!note] Next.js 的特殊之处 Next.js 基于文件系统自动实现路由级 Code Splitting,无需手动 lazy()。 在 /app 目录中,每个页面文件就是一个独立的 route segment → 自动拆包。

组件级拆分

对体积大、使用频率低的第三方库进行动态导入:

import dynamic from "next/dynamic";

// SSR 关闭——图表库依赖 window,服务端没有
const Chart = dynamic(() => import("recharts"), { ssr: false });

// 带 loading fallback——提升感知体验
const HeavyEditor = dynamic(
  () => import("@monaco-editor/react"),
  { loading: () => <p>正在加载编辑器...</p>, ssr: false }
);

// React 原生写法——配合 Suspense
const MapComponent = lazy(() => import("./MapComponent"));
// 可指定 preloadStrategy——预加载策略
const PrefetchModal = lazy(
  () => import("./HeavyModal")
);

Bundle 分析与优化

# Next.js 项目
npx next-bundle-analyzer

# Vite / Webpack 通用方案
npm install --save-dev rollup-plugin-visualizer
npm run build && npx visualizer

[!tip] Bundle 大小目标

层级 目标大小(gzipped)
首屏 chunk < 150KB
单个 chunk < 300KB
JS Total < 500KB(SPA)/ < 200KB(PWA)

[!tip] 减小 bundle 的三个高效手段

  1. 替换大库——lodash → lodash-es(tree-shaking 友好),或直接用原生 API
  2. 检查重复依赖——npm ls react 看有没有多份 React 实例
  3. 静态资源不进 bundle——图片、视频等上传到 CDN,URL 写在代码里

Lighthouse 关键指标调优

指标 含义 优化方向
FCP(First Contentful Paint) 首次内容绘制 减小首屏 HTML/JS 体积、使用 CDN
LCP(Largest Contentful Paint) 最大内容绘制 图片懒加载、预加载关键资源、优先渲染
INP(Interaction to Next Paint) 交互响应延迟(取代 FID) useTransition、删除同步 heavy work、Web Worker 分流
CLS(Cumulative Layout Shift) 布局偏移 预留图片宽高、避免字体闪烁、占位广告容器

React 并发优化 —— startTransition / useDeferredValue

React 18 引入的并发特性是解决 "交互卡顿" 最直接的手段。它们让 React 能够优先级调度。

import { useState, startTransition } from "react";

function SearchApp() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  // ❌ 普通 setState:阻塞 UI,用户输入时感知到延迟
  // setSearchResults(heavySearch(query));

  // ✅ startTransition:标记为低优先级,不阻塞当前输入
  function handleChange(e: string) {
    setQuery(e);  // 高优先级——立即更新
    startTransition(() => {
      setResults(heavySearch(e));  // 低优先级——可以中断
    });
  }

  return <input value={query} onChange={handleChange} />;
}

与 Suspense 配合的最佳实践

// 路由级懒加载 + 过渡状态
<Suspense fallback={<Spinner />}>
  <Routes>
    <Route path="/dashboard" element={
      <Dashboard />
    } />
  </Routes>
</Suspense>

[!tip] startTransition vs useDeferredValue 的选择

场景 推荐方案
表单输入 → 搜索结果 startTransition
长列表滚动 → 非关键区域降级 useDeferredValue
异步数据加载(路由级) Suspense + lazy
复杂计算(不在渲染中) Web Worker / TanStack Query

[!summary] 性能优化 checklist

  • 用 Profiler 确认瓶颈位置
  • 高频重渲染组件加 React.memo
  • 派生计算用 useMemo,回调用 useCallback
  • 大数据列表用虚拟滚动
  • 大库和低频页面做 Code Splitting
  • 耗时操作放进 startTransition
  • Lighthouse 评分达标?Web Vitals 监控上线?

关联笔记

  • 06-性能优化 Hooks — useMemo、useCallback、useTransition 等 Hook 的详细用法
  • 13-并发特性 — React 18 并发渲染机制深入解析
  • 04-State 与不可变性 — State 更新模式对性能的影响
  • 09-状态管理 — 全局状态管理的性能考量(Zustand / Redux / Jotai)
  • 14-Serverside Rendering — SSR / RSC 的首屏性能优势
  • 16-测试 — 性能回归测试:React Testing Library + Performance Assertions