首页 / React 学习笔记 / 41

REACT · Vol.VI · LESSON 41 · 性能优化与部署

性能监控与 React Profiler

进阶#性能#监控

两套指标,两把尺子

性能卷走到这里,你手里攒了一整套手段:第 37 篇的 memo、第 38 篇的虚拟列表、第 39 篇的拆包、第 40 篇的构建。但后端老兵都懂:不会测量就等于不会优化——没看监控就改代码,那叫玄学调优。

动手之前先分清:前端性能其实是两套完全不同的指标,量的是两件事。

指标体系测什么用户的抱怨后端类比
运行时渲染性能交互之后界面多快更新:点击、输入、滚动「点了没反应」「打字掉帧」接口 RT(P99 尾延迟最要命)
页面加载性能从输入 URL 到内容可用的全过程「白屏好几秒」「一直转圈」冷启动到可对外服务的时间

两套指标用不同工具测:运行时找 React DevTools 的 Profiler,加载找 Lighthouse 和 Web Vitals。搞混的代价是真金白银:首屏慢的人拼命加 memo(方向错了),列表卡的人忙着拆包(白忙一场)。

后端类比:可观测性思维的平移

后端你不会「感觉接口慢」就动手——先看 APM 的 P95/P99、慢调用榜单,再 profile 到方法。前端同款:Profiler 是火焰图,Lighthouse 是压测报告,Web Vitals 是 SLA 指标。本篇把这套流程装进肌肉记忆。

顺便立个规矩:优化必须「一次只改一处,改完复测」。攒一堆优化一起上线,好了不知谢谁、坏了不知赖谁——和你不会把十个重构混进一个提交,是同一个道理。

Profiler:前端版火焰图

装好 React Developer Tools 浏览器扩展后,DevTools 会多出 Profiler 标签。使用流程固定四步:

  1. 录制:点 Profiler 左上角的圆点开始记录;
  2. 操作:在页面上做一遍真实用户动作——输入搜索、勾选待办;
  3. 停止:得到火焰图(Flamegraph);
  4. 归因:找两类异常——渲染次数多(不该重渲染的也渲染了)和单次耗时长(self time 高的组件)。

用过 JProfiler 或 async-profiler 的话,这张图不用教:横向是时间,条越宽越耗时,嵌套对应组件树。差别只有一个维度:Profiler 还统计每个组件渲染了几次——「次数」是 React 特有的病灶。

修复前 App ListPage —— keyword 状态在这里,重渲染是必要的 ×300 TodoRow × 300:每敲一个字,全部重渲染 修复后(memo + 稳定引用) App ListPage —— 自身状态变化,重渲染仍是必要的 TodoRow:memo 命中,props 引用没变,全部跳过 整次 commit 耗时:220ms → 12ms(示意)
图 1Profiler 火焰图(示意):条越宽越耗时。修复前 300 个 TodoRow 全量重渲染,修复后 memo 命中全部跳过。

准备一个「看起来人畜无害」的列表页当靶子:

ListPage.tsx · 待会儿进 Profiler 的靶子
import { useState } from "react";

type Todo = { id: number; title: string; done: boolean };

function buildSeed(): Todo[] {
  return [];  // 造 300 条测试数据,从略
}

export default function ListPage() {
  const [todos, setTodos] = useState<Todo[]>(buildSeed);
  const [keyword, setKeyword] = useState("");

  const toggle = (id: number) =>
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );

  const visible = todos.filter((t) => t.title.includes(keyword));

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ul>
        {visible.map((t) => (
          <TodoRow key={t.id} todo={t} onToggle={() => toggle(t.id)} />
        ))}
      </ul>
    </>
  );
}

function TodoRow({ todo, onToggle }: { todo: Todo; onToggle: () => void }) {
  console.log("render:", todo.id);  // 面板里刷屏的那行日志就是它
  return (
    <li onClick={onToggle}>
      {todo.title} {todo.done ? "✓" : ""}
    </li>
  );
}

录制后在输入框里敲一个字:ListPage 重渲染是必要的(keyword 变了),但 300 个 TodoRow 的条全部亮起,console 刷屏 300 行 render 日志——输入一个字母,300 行重算,这就是 Profiler 给你的实锤。录制时一次只测一个场景,先关掉无关的轮询和动画,变量不隔离,火焰图就是一锅粥。

定位到病灶:重渲染三大来源

火焰图只负责指认「谁在多余的渲染」,开药方要回到第 37 篇总结的三大来源,逐一比对靶子:

  1. 父级连带:ListPage 因 keyword 重渲染,子组件默认跟随——React 不知道你会不会用到新的 props;
  2. props 引用不稳定() => toggle(t.id) 每次渲染都生成新函数,对 TodoRow 来说 props 「变了」;
  3. Context 整包变化:本例没有,但 Provider 的 value 是大对象时,改一个字段全家遭殃(拆 Context 第 37 篇讲过)。

对症下药,两个改动:

ListPage.tsx · memo + 稳定引用,双剑合璧
import { useState, useCallback, memo } from "react";

// memo:props 浅比较全相等就跳过重渲染(第 37 篇)
interface RowProps { todo: Todo; onToggle: (id: number) => void; }

const TodoRow = memo(function TodoRow({ todo, onToggle }: RowProps) {
  return (
    <li onClick={() => onToggle(todo.id)}>
      {todo.title} {todo.done ? "✓" : ""}
    </li>
  );
});

export default function ListPage() {
  const [todos, setTodos] = useState<Todo[]>(buildSeed);
  const [keyword, setKeyword] = useState("");

  // useCallback:把回调引用钉住,memo 才有得比
  const toggle = useCallback((id: number) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }, []);

  const visible = todos.filter((t) => t.title.includes(keyword));

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ul>
        {visible.map((t) => (
          <TodoRow key={t.id} todo={t} onToggle={toggle} />
        ))}
      </ul>
    </>
  );
}

注意 onToggle 变了:不再是每行一个新闭包,而是把 id 当参数传出去,引用全局稳定。复测:再敲一个字,TodoRow 一条不亮——todo 引用没变(filter 返回原对象)、toggle 被 useCallback 钉住,memo 全部命中;ListPage 自身照旧重渲染,那是必要成本。两个补刀:

  • memo 不是免费的:浅比较也花时间,十几行的小组件不值得包。顺序永远是 Profiler 实锤 → 再 memo,反过来就是第 37 篇批评过的「无脑全面 memo」;
  • 行数上千还卡:memo 挡得住「不该渲染的」,挡不住「必须渲染的 500 行 DOM」。这时换武器——第 38 篇的虚拟列表,只渲染可视区那 20 行。
后端类比:缓存命中条件

memo 跳过重渲染的前提是 props 逐项「相等」。你每次传新 lambda 过去,等于每次都用新 key 查缓存——永远 miss。useCallback / useMemo(第 11 篇)的意义就是把「缓存 key」钉稳,函数组件优化的硬前提。

加载性能:Web Vitals 与 Lighthouse

运行时解决「卡」,加载要解决「白屏久」。测量必须对着生产包——第 40 篇说过 dev/build 是两条链路,dev 没有压缩分包,指标全偏悲观。正确姿势:npm run buildvite preview 起本地服务,Lighthouse 对产物测。

加载性能的行业标准是 Google 的 Web Vitals 三件套:

指标达标线测什么后端可观测性类比
LCP(最大内容绘制)≤ 2.5s首屏最大元素渲染完成,≈「页面可看了」冷启动到首响
INP(交互到下一帧)≤ 200ms全程所有交互延迟的近最差值P99 RT
CLS(累积布局偏移)≤ 0.1元素突然跳动、「骗走」用户点击的程度错误率 / 事故率

三个数字都有官方达标线,过线即「良好」。它们来自真实用户:上线后用 web-vitals 库上报到监控后台,就是前端的埋点。顺带纠正:2024 年 3 月起 INP 已正式取代 FID,还在教 FID 的教程可以合上了。

Lighthouse 用法:DevTools 的 Lighthouse 面板选 Performance、Mobile、模拟限速,一键出报告——分数、指标实测值,还会把「首屏加载 2.1 MB JS」这类问题列成改进项清单。最常见的就是包体积过大,用 bundle 分析定位:

vite.config.ts · 构建时生成体积矩形树图
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    react(),
    visualizer({ filename: "stats.html", gzipSize: true }),
  ],
});

build 后打开 stats.html:每个方块是一个模块,面积即体积占比。一眼看到 echarts 占 40%?第 39 篇的 lazy 和 manualChunks 就知道该切谁了。

后端类比:jar 体积分析

后端拿到 200 MB 的 fat jar,你会看依赖树找最重的传递依赖、查重复引入。stats.html 就是前端的「依赖体积树」——rollup-plugin-visualizer 之于 bundle,约等于 mvn dependency:tree 之于 jar,都是把「黑盒的胖」拆成「可归因的胖」。

Lighthouse 分数是体检报告不是 KPI:同一份产物换台机器、换个网络就漂移。盯「指标是否过线」(LCP ≤ 2.5s)比盯「92 还是 95 分」有意义得多。

案例走查与 SOP

把本卷工具串成两条案例主线,从症状到药方走一遍:

案例症状测量结果归因药方
列表页卡输入搜索词时掉帧Profiler:每次按键 300 个 TodoRow 全重渲染父级连带 + 内联回调引用不稳memo + useCallback;上千行上虚拟列表(第 38 篇)
首屏慢Lighthouse 报 LCP 4.8sNetwork:首屏拉 2.1 MB JS,其中 echarts 占 1 MB单 bundle 全量加载路由级 lazy + 重库级拆分 + hover 预取(第 39 篇)

两条案例走的是同一条路,值得固化成 SOP——先测量、再归因、后优化

  1. 先测量:拿数字。Profiler 录制、Lighthouse 报告、Network 面板截图,没有数字不动手;
  2. 再归因:把数字落到具体对象——哪个组件渲染多了、哪个模块体积大了。归因不到组件 / 模块级别的优化,都是赌博;
  3. 后优化:一处改动,复测确认收益,再进行下一处。药方永远从测量结果里开,不从经验直觉里开。
后端类比:排查慢接口的 SOP 逐字对应

先看监控锁定慢接口,再用 arthas / JProfiler 定位到方法行,改完回归压测——和「先测量、再归因、后优化」一模一样。跳过前两步直接「我觉得加个缓存就好了」,在哪个技术栈里都是事故来源。

核心要点
  • 前端性能两套指标:运行时渲染(≈接口 RT)用 Profiler 测,页面加载(≈冷启动)用 Lighthouse + Web Vitals 测,先分清再动手。
  • Profiler 流程:录制 → 复现场景 → 火焰图 → 找「渲染次数多 / self time 高」的组件 → 修复 → 复测,前端版 JProfiler。
  • 不必要重渲染三大来源:父级连带、props 引用不稳定、Context 整包变化;武器是 memo、useCallback / useMemo、拆 Context(第 37 篇)。
  • Web Vitals 达标线:LCP ≤ 2.5s、INP ≤ 200ms(已取代 FID)、CLS ≤ 0.1;Lighthouse 必须测生产包,stats.html 归因 bundle 体积。
  • 固化 SOP:先测量、再归因、后优化,一次一处、改完复测。

本章回顾

本篇为性能卷收口:memo、虚拟列表、拆包,全部装进「先测量、再归因、后优化」的流程里,像排查慢接口一样排查慢页面。至此开发、构建、优化都齐了,只差最后一公里——怎么把 dist 交到用户手上:Nginx 怎么配、CDN 怎么挂、CI 怎么跑。下一篇第 42 篇「部署」,为第六卷收官。

Comments · 评论