前端性能与可观测性

一、导读

定义: 先回答「**对谁而言什么叫慢**」,再给「**怎么量、怎么改、怎么验收**」——拒绝只有鸡汤没有刻度的「优化」。
代码块 // 语法要点//正确示例//错误示例;性能话题必须与指标绑定。
啥时候用 每条技巧都写「**适用条件**」——避免把预加载抄进每个项目。
姊妹篇 《异步与主线程》交代卡顿的调度原因;《构建与运行》交代产物的体积与分割;本篇负责用户体感与外场证据

目标:能解释 LCP / INP / CLS 三者各自惩罚什么;会在 Chrome DevTools Performance / Lighthouse / field data(CrUX) 之间切换视角;知道 preload / prefetch / preconnect语义差异滥用代价
自勉:优化若不由指标起,多半是在感动自己;指标若脱离业务场景,只是在刷实验室分数

阅读建议:本文聚焦「用户体感与外场证据」。若对事件循环、调度机制不熟悉,建议先读《异步与主线程》;若对构建产物、代码分割不熟悉,建议先读《构建与运行》。

本文不覆盖:后端数据库查询优化、Native App 性能、服务器硬件扩容——这些虽影响 TTFB,但属于后端/运维主场,前端只能协作而非直接动手。


二、从 RAIL 到 Web Vitals:先定义「快」

1 RAIL 的用户节奏(Still useful)

定义:RAIL(Response, Animate, Idle, Load)强调交互响应、动画帧预算、空闲时做低优先级活、首屏可见路径。经验值常在教学中出现:100ms 内感知为即时动画 60fps(≈16ms/帧)主线程长块 >50ms 要警惕。它不是新标准,但仍是团队沟通的带宽方言

// 语法要点:性能优化 = 在预算里做事

//正确示例——把非关键脚本 defer / type=module
// <script type="module" src="/app.js"></script>
// 注意:type="module" 默认 defer(延迟执行),但会开启严格模式 + CORS 校验,不是 defer 的完美平替
// <script src="/app.js" defer></script>

//错误示例——把 2MB 解析和执行堆在 head 里阻塞首个字节后的一切
// <script src="/ huge-app.js"></script>

啥时候用

  • 制定内部 SLA — 用 RAIL 与产品一起谈「可接受的慢」。

2 Core Web Vitals:field 指标与「门槛」

定义:Core Web Vitals 是 Google 推广的一组 以用户为中心外场(field)指标;门槛会随生态演化——上线验收前请直接查阅 web.dev/vitals 当期数字。下文给出写作时的常见参考档位(便于团队对齐口径,永久法条):

指标 惩罚对象 常见「好 / 需改进 / 差」分档(75 百分位思路)
LCP 首屏最大可见内容出现太晚 ≤2.5s / 2.5–4s / >4s(单位:秒)
INP 交互到下一次绘制反馈太久(综合 Pointer Events) ≤200ms / 200–500ms / >500ms(单位:毫秒)
CLS 意外版面位移累积 ≤0.1 / 0.1–0.25 / >0.25(无单位分数)

精确表述

  • 指标判的是真实用户分布,不是你笔记本上一次 Lighthouse。
  • FID(First Input Delay)已被 INP 正式取代(2024 年 3 月起成为 Core Web Vitals 正式指标)——INP 覆盖更广的交互延迟,别再用「FID 好就一定跟手」忽悠自己。
  • Lighthouse 跑出来的是 lab;与 CrUX 可能严重不一致——对外承诺体验 SLA 时以外场或自有 RUM 为准
RAIL 维度 对应 CWV 指标 一句话关系
Response(响应) INP 点了多久有反应
Load(加载) LCP 最大内容多久出来
视觉稳定性 CLS 页面有没有乱跳
// 语法要点:优化要针对指标,不是针对「感觉还行」

//正确示例——上线后用 web-vitals 上报 LCP/INP/CLS 的 name/value/id 到自家数仓
// 注意:如果页面加载完成后才注册回调,需要加 buffered: true,否则可能漏掉已发生的指标
import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToAnalytics({ name, value, id }) {
  navigator.sendBeacon('/analytics', JSON.stringify({ name, value, id }));
}

onLCP(sendToAnalytics, { reportAllChanges: false }); // 默认只报最终值
onINP(sendToAnalytics, { reportAllChanges: false });
onCLS(sendToAnalytics, { reportAllChanges: false });

//错误示例——本地电脑飞快,但低端安卓场外用户全挂
// 只在 MBP + 千兆 Wi-Fi 上跑 Lighthouse 就宣布「性能没问题」

啥时候用

  • 和老板谈「为什么要修性能」 — 甩官方门槛 + 外场曲线,比甩自我感觉有效。

三、LCP:让「最大那块」早点出现

1 LCP 的拆解:不是「一张图」而是一串环节

定义:Chrome 在文档里将 LCP 粗分为四段(名称随版本微调,认语义即可):

  1. TTFB(Time to First Byte) — 首字节到达前:DNS、TCP/TLS、服务器与边缘耗时。
  2. 资源加载延迟 — HTML 解析到发现 LCP 资源之间的空隙(排得太后、loading=lazy 误伤、CSS 遮挡等)。
  3. 资源加载时长 — 字节下载 + 解密(HTTPS)。
  4. 渲染延迟 — 字体阻塞、同步 JS 长时间占主线程导致「字节已到但不能画」。

精确心法:优化 LCP 时先在 Performance → TimingsLCP 条目详情 里看主导段网络 还是 主线程,别一上来只压图片 KB 数。

DevTools 实操路径

  1. 打开 Chrome DevTools → Performance 面板
  2. 点击「录制」按钮(圆形红点),刷新页面
  3. 等页面稳定后停止录制
  4. 找到顶部 Timings 轨道的 LCP 标记(绿色菱形)
  5. 点击 LCP 标记 → 下方 Summary 面板会显示 LCP Breakdown
  6. 看四段(TTFB / 资源加载延迟 / 资源加载时长 / 渲染延迟)哪段最长——最长的那段就是主攻方向

2 常见罪魁祸首

定义:

  • 慢 TTFB / 慢 CDN
  • 大图未优化未规定宽高导致解码与布局往返。
  • 客户端渲染壳子白屏过久,主体内容迟迟不进 DOM。
  • 阻止渲染的同步 CSS/JS 在关键路径前面排队。

客户端渲染(CSR)vs 服务端渲染(SSR/SSG)的 LCP 差异

  • CSR:HTML 只有一个 <div id="app"></div>,JS 执行完才有内容 → LCP 通常很晚
  • SSR/SSG:HTML 直接包含内容 → LCP 通常快得多
  • 折中方案:流式 SSR(Streaming SSR)或渐进式激活(Progressive Hydration)

常见误区:JS 放 </body> 前就万事大吉?不对。如果 <head> 里有阻塞渲染的 CSS,浏览器仍会等到 CSS 下载并解析完后,才继续解析后面的 HTML 并发现 LCP 元素。

<!-- 语法要点:hero 图若是 LCP 候选,优先保证它的发现与下载 -->

<!-- 正确示例——现代图片三件套(视项目支持度)-->
<!--
  <img
    src="hero.avif"
    width="1200"
    height="630"
    alt="..."
    fetchpriority="high"
    decoding="async"
  />
-->

<!-- 错误示例——巨大 PNG 未压缩、无尺寸,CLS 与 LCP 双杀 -->

啥时候用

  • 落地页 / 文章详情 — 首屏图几乎总是 LCP 元素。

3 fetchpriority="high" 不是全局加速器

定义:fetchpriority(高/低)提示浏览器资源调度优先级——只对少数关键资源使用;满屏都是 high,等于没说话

<!-- 正确示例——页面上只有一两处 high -->
<img src="/hero.jpg" fetchpriority="high" alt="" />

<!-- 正确示例——非关键图片主动降优先级,让带宽留给 hero 图 -->
<img src="/footer-logo.jpg" fetchpriority="low" alt="" />

<!-- 错误示例——几十个图片全写 high -->
<!-- 浏览器会忽略你的提示,或者更糟糕:关键资源反而被挤占 -->

啥时候用

  • 确认 LCP 元素(Performance/Lighthouse 会提示)后,再给它「开绿灯」。

四、INP:交互别被长任务饿死

1 输入到绘制:INP 粗略拆三段(理解用)

定义:一次交互从指针按下到屏幕有可见反馈,中间可粗分为:

含义 常见拖慢原因
输入延迟 主线程正忙,轮到处理事件晚了 前排长任务、密集微任务
处理时长 你的 事件回调 + 框架逻辑 + 提交渲染 本身太长 setState 触达过重 DOM、巨大 diff
呈现延迟 逻辑已完,但还没下一次 paint 布局/绘制太重、被别的高优先级工作挤占

精确提醒:这是教学模型,不是 Performance 面板里逐行可对号的三个 API 字段;排障时仍以 Main thread 火焰图 + Long Task + Interactions 为准。

2 为什么「只优化 LCP」救不了跟手

定义:首屏极致快、滑一下或点一下卡死——典型是 INP 差:根因往往在 长任务主线程上的事件处理。与《异步与主线程》中的 调度、本篇 Main 轨 对照阅读。

// 语法要点:把主线程重活切片或搬 Worker

//错误示例——click 里同步排序 10 万行再 setState
function handleClick() {
  const sorted = hugeArray.sort((a, b) => /* 复杂比较 */); // 阻塞主线程 500ms+
  setState(sorted); // 用户点了没反应,半天后才跳
}

//正确示例 1——Web Worker 排序(推荐)
// worker.js
self.onmessage = (e) => {
  const sorted = e.data.sort((a, b) => /* 复杂比较 */);
  self.postMessage(sorted);
};

// 主线程
const worker = new Worker('./worker.js');
function handleClick() {
  worker.postMessage(hugeArray);
  worker.onmessage = (e) => setState(e.data); // 排序在后台,点击立即有反馈
}

//正确示例 2——分片排序 + 让出主线程(兼容性更好)
async function handleClick() {
  const chunkSize = 1000;
  for (let i = 0; i < hugeArray.length; i += chunkSize) {
    // 每处理 1000 条让出一次主线程,让浏览器有机会响应点击/滚动
    if (window.scheduler?.yield) {
      await scheduler.yield(); // Chrome 115+,其他浏览器需 polyfill
    } else {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
    processChunk(hugeArray, i, i + chunkSize);
  }
}

兼容性提醒scheduler.yield() 目前 Chrome 115+ 支持,Safari/Firefox 尚未普及。生产环境建议用 setTimeout(0) 做兜底,或引入 scheduler-polyfill

啥时候用

  • 巨表、重筛选 — 第一反应不是「虚拟滚动就好」,而是算力别堵点击回调

3 事件的「被动」与滚动流畅

定义:{ passive: true } 告诉浏览器监听器不会 preventDefault(),从而滚动不必等待回调——改善滚动与 INP。仅当你真的需要取消默认滚动时才省略 passive。

//正确示例
el.addEventListener('touchstart', onTouch, { passive: true });

//错误示例——全程非 passive + 回调很重 —— 滚动像坐船
el.addEventListener('touchstart', onTouch); // 浏览器必须等你的回调执行完,才敢滚动

//框架提醒:Vue 3 和 React 17+ 已默认将 touchstart/touchmove/wheel 设为 passive
//所以你在 Vue/React 里写 @scroll="handleScroll" 时,通常不需要手动加 passive
//但如果你在原生 JS 里监听,或者需要 preventDefault(),就要显式声明 { passive: false }

啥时候用

  • 移动端列表、抽屉、地图层

五、CLS:稳定,是一种礼貌

1 给图片 / iframe / 广告位「占位」

定义:未设尺寸的媒体在加载完成后把下面的内容推开 —— CLS 飙升
固定 width / height 或使用 aspect-ratio CSS,让布局预留孔洞。

<!-- 正确示例 -->
<img src="/a.jpg" width="640" height="360" alt="" style="aspect-ratio: 16/9; width: 100%; height: auto;" />

<!-- 错误示例 -->
<img src="/a.jpg" alt="" /> <!-- 无尺寸 → 下方文字先排版后猛跳 -->

啥时候用

  • CMS 文章、第三方广告 —— 和业务约定「最小占位高度」。

2 Web 字体(FOIT/FOUT)与字体匹配策略

定义:字体文件晚到,文本会用回退字体占位再替换,宽度变化 → CLS。策略:子集化font-display: optional/swap预加载关键字体(谨慎)、或设计层面减少字体家族数量。

/* 语法要点 */
@font-face {
  font-family: 'Brand';
  src: url('/brand.woff2') format('woff2');
  font-display: swap; /* 或 optional:更稳但更可能短暂用回退 */
  /* size-adjust: 107%; */ /* 让回退字体的 x-height 更接近 Web 字体,减少切换时的视觉跳动 */
}

font-display 怎么选

行为 适用场景
swap 立即用回退字体,Web 字体到了再换 正文内容,不能等
optional 给 100ms 机会加载,不到就用回退字体,不换 大标题,宁可稳定也不要跳动
block 白等(FOIT),最多等 3s 不推荐,用户盯着空白
fallback 先隐藏 100ms,然后 swap 折中方案,但控制感弱

预加载字体的权衡

  • 好处:字体更早开始下载,减少 FOUT 时间
  • 风险:字体文件通常几百 KB,预加载会挤占关键带宽,可能反而拖慢 LCP
  • 建议:只预加载「首屏大标题且字体 < 50KB」的情况,否则用 font-display: optional 更稳

啥时候用

  • 品牌站点首屏大标题 — 与设计师吵一架很值得(礼貌地)。

六、资源提示:preload / prefetch / preconnect / dns-prefetch

1 各干各的活,别混成一锅粥

定义:

  • preconnect:尽早完成DNS + TCP + TLS —— 适合马上要请求的第三方 API 域。
  • dns-prefetch:只做 DNS —— 更轻,含金量更低
  • preload本导航关键路径上马上要用的资源(字体、关键 CSS、英雄图)。滥用会抢走带宽
  • prefetch下一导航可能用的资源 —— 低优先级浏览器空闲时才拿。
<!-- 正确示例——字体 preload + 跨域要带 crossorigin -->
<link rel="preconnect" href="https://api.example.com" />
<link
  rel="preload"
  href="/fonts/body.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

<!-- 正确示例——SPA 下一页 chunk 可用 prefetch(构建器可自动插入) -->
<link rel="prefetch" href="/assets/AboutView-xxxxx.js" />

<!-- 正确示例——preconnect + dns-prefetch 组合,兼容性更好 -->
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
<!-- 解释:老浏览器不支持 preconnect 时,dns-prefetch 仍能提前解析域名 -->

<!-- 正确示例——MPA 里预加载下一页的关键资源 -->
<link rel="prefetch" href="/checkout.css" />
<link rel="prefetch" href="/checkout.js" />

<!-- 错误示例——preload 半站图片 —— LCP 没提,带宽先炸 -->
<!-- <link rel="preload" href="/gallery/01.jpg" as="image" /> -->
<!-- <link rel="preload" href="/gallery/02.jpg" as="image" /> -->
<!-- ... preload 了 20 张图,hero 图反而被挤到后面 -->

啥时候用

  • 第三方身份 SDK、支付网关preconnect 常常是低成本高收益。

七、JavaScript 成本:解析、编译、执行

1 分割与缓存

定义:现代打包器(Vite/webpack)会做 code splitting路由级懒加载减少首包。
长期缓存文件名 content hash(见《构建与运行》)。

//正确示例——路由组件动态 import(Vite / webpack 都支持)
const About = () => import('./AboutView.vue');

//正确示例——预加载动态导入的 chunk(webpack 魔法注释 / Vite 内置)
const About = () => import(/* webpackPrefetch: true */ './AboutView.vue');
// 解释:webpack 会自动插入 <link rel="prefetch">,在浏览器空闲时预加载

//正确示例——Vite 的 glob 导入 + 预加载
const modules = import.meta.glob('./views/*.vue', { prefetch: true });

//错误示例——单文件 entry 引入所有业务页组件
import About from './AboutView.vue';
import Dashboard from './DashboardView.vue';
import Settings from './SettingsView.vue';
// 用户只访问首页,却下载了全部页面的代码

啥时候用

  • 首屏只需仪表盘 — 别把「年终报表」大块也塞首包。

2 第三方库「买一送一」

定义:moment 全量、lodash 全引入 —— 树摇失败时体积惊人。
选型前看 bundle 体积维护状态

//正确示例——按需导入,树摇有效
import { debounce } from 'lodash-es';

//正确示例——分析 bundle 体积的工具链
// 1. webpack: npx webpack-bundle-analyzer dist/stats.json
// 2. Vite: npx vite-bundle-visualizer
// 3. VSCode 插件: import-cost(写代码时就能看到导入成本)

//错误示例——全量导入,即使只用了一个函数
import _ from 'lodash'; // 可能打包进 70KB+,即使只用了 _.debounce

//错误示例——子路径导入也可能树摇失败
import { debounce } from 'lodash'; // lodash 的 sideEffects 配置不当,可能仍全量打包
// 解决:确认包的 package.json 里有 "sideEffects": false,或用 lodash-es

啥时候用

  • 新依赖 PR — 把包大小差分贴评论里。

八、网络层与缓存(衔接《网络层》《存储》)

1 减少往返与体积

定义:HTTP/2 / HTTP/3(多路复用、更低握手成本)、响应压缩(Brotli/gzip)、图片现代格式(AVIF/WebP)、TLS 会话恢复——多数是 CDN + 后端 + 运维 的主场;前端的抓手是:少一个阻塞资源别把关键请求排在长链末端

前端能推动的网络优化

  • 图片 CDN 自动格式转换:Cloudflare Polish、Imgix(?fm=avif)、阿里云 OSS 自动 WebP——让 CDN 根据浏览器支持度自动返回最优格式,减少前端 <picture> 标签维护成本
  • HTTP/3 现状:Cloudflare、Fastly 等主流 CDN 已广泛支持(2025 年),但企业内网/自建网关可能仍卡在 HTTP/2——推动升级通常是运维/后端的工作,前端负责确认「我们的 CDN 是否已开 HTTP/3」

2 关键请求链(Critical Request Chains)到底卡谁

定义:Lighthouse 里的 critical-request-chains 把「HTML → 阻塞 CSS → 阻塞字体 → 阻塞 JS」的依赖树画出来。长链 = 串行等待多段 RTT,在移动网络上会被放大。

精确排查

  • 首屏 CSS 是否在 head 尽早发现、能否内联极少量关键 CSS + 异步其余?
    • 内联多少算合理:gzip 后约 14KB 以内(一个 TCP 初始拥塞窗口),超过这个值反而拖慢 TTFB
    • 工具:Critical(npm 包)、Penthouse 可以自动提取首屏关键 CSS
  • 是否有一个「巨无霸」同步 JS 在 head 拦住解析
  • preconnect / preload 是否用在刀刃(见第六章),而不是全屋贴满?

啥时候用

  • LCP 里 TTFB 已经绿了,但 LCP 仍红 —— 多半是链与主线程,而不是单一图片 KB。

3 别把「缓存头」当前端一拍脑门的私货

定义:HTTP 缓存策略服务端响应头主导(Cache-ControlETag 等,见《网络层》《存储》HTTP 缓存章)。前端能在 fetch 里做的是「这次请求要不要尽量绕缓存」,无权替全站静态资源改 TTL。
精确分工:CDN / 网关 / BFF 定策略;打包器 content hash 文件名可激进长缓存的前提。

三个容易搞混的缓存头

指令 含义 使用场景
no-store 完全不存,每次都要重新请求 敏感数据、实时性极强的 API
no-cache 可以存,但用之前必须问服务器是否过期 HTML 入口文件(需要及时更新)
max-age=31536000, immutable 一年内别问,直接本地取 带 content hash 的 JS/CSS(内容变了文件名也变)

常见扯皮:「用户看到旧 JS」——先分清三层:Service Worker 缓存、HTTP 缓存(Cache-Control)、浏览器进程内存缓存,再找对应的人。

啥时候用

  • 扯皮「为什么用户看到旧 JS」 —— 先分 SW、HTTP 缓存、浏览器进程缓存三层,再找人。

4 Service Worker 命中时的「另一套时钟」

定义:若页面被 Service Worker 接管,fetch 的时延分布会混入 Cache Storage 查找与策略分支——Performance 里会看到 Service Worker 相关条目。优化 SW 策略与优化首包网络不是同一拨人也能做,但要一起对数


九、可观测性:实验室 vs 外场

1 Lab:Lighthouse、WebPageTest

定义:实验条件可控,适合迭代对比(同机同网络)。短板:不等价真实众包设备

// 正确姿势
// Lighthouse CI:每次 PR 对比性能回归
// 把 Lighthouse 分数当门禁之一,不是唯一真理

// 错误姿势
// 仅在 MBP + 千兆 Wi-Fi 上「验收」

Lighthouse 的局限性(知道它不能测什么,才不会被误导)

  • ❌ 不测量 INP(用 TBT 近似代替,两者不完全等价)
  • ❌ 不测量登录后的页面(只测无登录态的首页)
  • ❌ 不测量 SPA 软导航(路由切换不触发新的 Lighthouse 跑分)
  • ❌ 实验室网络条件固定,不代表真实用户的 3G/弱网环境

WebPageTest 的补充价值

  • 支持选择真实设备(低端安卓)和网络条件(3G Fast / 3G Slow)
  • 「视觉进度条」和「胶片条」能直观看到「用户实际看到了什么」
  • 适合跨国业务测试不同地区的 CDN 表现

啥时候用

  • 防回归 — 把 Lighthouse 分数当门禁之一,不是唯一真理。

2 Field:CrUX、自家 RUM

定义:Chrome User Experience Report域级外场分布;自家 RUM 能把它细化到路由 / 实验 / 设备等级
采样与隐私要合规——不要上报可直接识别个人的字段

//正确示例——web-vitals 上报(生产环境建议加 fallback)
import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToAnalytics({ name, value, id, attribution }) {
  const data = JSON.stringify({
    name,      // 'LCP' | 'INP' | 'CLS'
    value,     // 指标值
    id,        // 唯一标识,用于去重
    // 归因信息(需要 web-vitals 的 attribution 版本)
    // 能告诉你是哪个 DOM 元素、哪个脚本导致的性能问题
    ...(attribution && {
      eventTarget: attribution.eventTarget,     // 触发 INP 的元素
      eventType: attribution.eventType,         // 事件类型
      eventTime: attribution.eventTime,         // 事件发生时间
    }),
  });

  // sendBeacon 可能失败(队列满、页面崩溃),加 fallback
  if (!navigator.sendBeacon('/analytics', data)) {
    fetch('/analytics', { method: 'POST', body: data, keepalive: true });
  }
}

// 使用 attribution 版本,排障效率提升 10 倍
onLCP(sendToAnalytics, { reportAllChanges: false, attribution: true });
onINP(sendToAnalytics, { reportAllChanges: false, attribution: true });
onCLS(sendToAnalytics, { reportAllChanges: false, attribution: true });

啥时候用

  • 上线后 — 实验室全绿但外场红:优先信外场

十、Performance 面板:不止是截图

1 火焰图里看什么

定义:Main 轨宽条 = 长任务;黄色 Scripting紫色 Layout绿色 Paint —— 谁宽就优先怀疑谁。
Frames 轨掉到 30fps 以下要找原因。
NetworkMain 对齐看:下载慢还是主线程算不过来

// 正确示例——录屏一次交互(点击),只看 Main + Network 重叠区

// 错误示例——只看 Summary 圆饼就宣布胜利

DevTools 火焰图实操指南

  1. 打开 Performance 面板 → 点击「录制」→ 执行卡顿的交互 → 停止录制
  2. 看 Main 轨道
    • 黄色宽条 = JavaScript 执行太久
    • 紫色宽条 = Layout(回流)太重
    • 绿色宽条 = Paint(绘制)太重
    • 红色小三角 = 强制同步布局(Forced Synchronous Layout),典型的「先读 offsetHeight,再改 style,再读 offsetHeight」
  3. 看 Frames 轨道
    • 绿色 = 60fps(16.6ms/帧)
    • 黄色 = 30-60fps(16.6-33.3ms/帧)
    • 红色 = <30fps(>33.3ms/帧)
  4. Network 与 Main 对齐看:如果 Network 里资源已经下载完,但 Main 里还有长任务 → 问题在主线程,不是网络

啥时候用

  • 单点难复现的卡顿 — 让用户开 remote debugging(若可)更快。

2 React/Vue 性能分析:框架 Profiler + 浏览器 Performance 对照

定义:框架批量更新能减少 paint,但逻辑重仍在主线程——需要框架 Profiler(看哪个组件重渲染)+ 浏览器 Performance(看主线程具体在算什么)对照阅读

React DevTools Profiler 使用路径

  1. 安装 React DevTools 浏览器扩展
  2. Components 标签旁切换到 Profiler 标签
  3. 点击「录制」→ 执行卡顿操作 → 停止
  4. 看火焰图里最长的条形 → 点击它 → 看「Why did this render?」(为什么重渲染)
  5. 常见结论:state 变了但 props 没变 → 考虑 React.memocontext 变了导致大面积重渲染 → 考虑拆分 Context 或用状态管理库

Vue DevTools 使用路径

  1. 安装 Vue DevTools 扩展(v6 支持 Vue 3)
  2. 切换到 TimelineComponent 标签
  3. 点击「录制」→ 执行操作 → 看哪个组件的渲染时间最长
  4. Vue 3 的 <script setup> 组件默认没有 name,Profiler 里可能显示匿名 → 建议给组件加 defineOptions({ name: 'MyComponent' })

生产环境 Profiling

  • React:使用 react-dom/profiling 构建版本,线上也能抓性能数据
  • Vue:设置 __VUE_PROD_DEVTOOLS__ = true(仅调试时),线上性能问题需要这个能力

啥时候用

  • 大列表 + 重渲染 — 先量,再谈 memo、虚拟列表。

3 PerformanceObserver:在代码里抓 Long TaskLoAF

定义:主线程 duration > 50ms 的任务块可被 PerformanceObserverentryTypes: ['longtask'])观察到——实现与策略因浏览器而异,部署前务必能力探测。longtask 不直接给出业务函数名,需配合 performance.mark/User Timings 或 Source Map 深挖。

// 传统方式:监听 Long Task(所有现代 Chrome 都支持)
try {
  const po = new PerformanceObserver((list) => {
    for (const e of list.getEntries()) {
      console.warn('longtask', e.duration, e.startTime);
      // e.attribution 可以告诉你是哪个脚本容器导致的(Chrome 83+)
      if (e.attribution?.[0]) {
        console.warn('归因:', e.attribution[0].containerName, e.attribution[0].containerSrc);
      }
    }
  });
  po.observe({ entryTypes: ['longtask'] });
} catch {
  /* 当前环境不支持 */
}

新一代:Long Animation Frames(LoAF)——比 Long Task 更精确

定义:Chrome 正在用 LoAF(entryTypes: ['long-animation-frame'])取代 Long Task。区别在于:

  • Long Task:只告诉你「主线程忙了 50ms+」,但不知道具体在忙什么
  • LoAF:告诉你「这一帧为什么花了这么久」,包括具体的脚本、DOM 操作、样式计算
// 实验性 API,目前 Chrome 支持较好
try {
  const po = new PerformanceObserver((list) => {
    for (const e of list.getEntries()) {
      console.warn('LoAF', e.duration, e.startTime);
      // e.scripts 数组里包含导致长帧的具体脚本信息
      e.scripts?.forEach((script) => {
        console.warn('  脚本:', script.name, '耗时:', script.duration);
      });
    }
  });
  po.observe({ entryTypes: ['long-animation-frame'] });
} catch {
  /* 当前环境不支持,fallback 到 longtask */
}

啥时候用

  • RUM 采样长任务密度 — 与 INP 差高度相关。
  • 排障具体卡顿点 — LoAF 能精确到「哪个点击处理函数卡了」,比 Long Task 好用得多。

4 DevTools 里对齐 慢交互Main 火焰图

定义:Chrome DevTools 持续演进 Interactions / INP 相关视图——用于把一次「点了没反应」对齐到 Main 线程具体函数。排障时升级浏览器往往比读半年前的教程截图管用。

DevTools 实操路径(Chrome 120+)

  1. 打开 Performance 面板 → 点击「录制」→ 执行卡顿的点击/输入 → 停止
  2. 找到底部 Interactions 轨道(如果没有,点击轨道标题栏的「⋮」→ 勾选 Interactions)
  3. 看到红色或黄色的交互条 → 点击它
  4. DevTools 会自动在 Main 轨道高亮对应的代码执行区间
  5. 看高亮区间里的最宽函数调用 → 那就是导致卡顿的罪魁祸首
  6. 点击函数名 → 跳转到 Sources 面板看具体代码

如果 Interactions 轨道没有显示

  • 确保你执行的是「真实用户交互」(点击、输入、滚动),不是 JS 自动触发的
  • 尝试在 Performance 面板的设置里勾选「Enable advanced paint instrumentation」

远程调试真机卡顿

  • Android:USB 连接 → Chrome 打开 chrome://inspect → 选择设备 → 录制 Performance
  • iOS:Mac 上 Safari → 开发菜单 → 选择 iPhone → 使用 Safari 的 Timelines 面板

十点五、图片与媒体:原生懒加载、decoding<picture>

1 loading="lazy" 不是 LCP 元素的选项

定义:原生 loading="lazy" 让浏览器在接近视口时才拉图片——首屏英雄图若懒加载,会直接拖垮 LCP。实践:首屏候选 LCP 图用 eager 或不写(默认);列表里远景再懒。

<!-- 正确示例——列表缩略图 -->
<img src="/thumb/1.jpg" alt="" width="160" height="90" loading="lazy" decoding="async" />

<!-- 错误示例——首屏 banner 仍 lazy -->
<img src="/hero.jpg" alt="" loading="lazy" fetchpriority="high" />

啥时候用

  • 文章瀑布流、搜索结果 — 懒加载省带宽立竿见影。

2 decoding="async":解码别挡主线程太久

定义:async 让图片解码异步化的机会增加(实现相关),sync 更偏立即解码——大量图同时进屏时,async 常与 不阻塞交互 的诉求一致(仍要实测)。

啥时候用

  • 同屏几十张图 — 与《异步与主线程》的「别在输入处理里堆活」一起看。

3 <picture> 与响应式格式

定义:用 <source type="image/avif"> 先于 WebP、再回退 img,让支持 AVIF 的浏览器拿更小体积累积在 LCP 上省字节——省字节就是省时间与钱

<!-- 响应式图片:不同视口宽度用不同分辨率 -->
<picture>
  <source
    srcset="/a-400.avif 400w, /a-800.avif 800w, /a-1200.avif 1200w"
    sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
    type="image/avif"
  />
  <source
    srcset="/a-400.webp 400w, /a-800.webp 800w, /a-1200.webp 1200w"
    sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
    type="image/webp"
  />
  <img
    src="/a-800.jpg"
    srcset="/a-400.jpg 400w, /a-800.jpg 800w, /a-1200.jpg 1200w"
    sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
    width="800"
    height="450"
    alt=""
  />
</picture>

sizes 属性为什么重要

  • 没有 sizes,浏览器不知道「这张图片在页面上会显示多大」
  • 结果:浏览器可能选了一个过大的分辨率,浪费带宽;或者选了过小的,模糊
  • sizes 告诉浏览器:「在 600px 宽以下的屏幕上,这图只显示 400px 宽,你选对应的 srcset 即可」

啥时候用

  • 大图落地页 — CDN 配合格式协商也可,但 <picture> 最直观。

十点六、Service Worker:能加速也能「缓存太聪明」

定义:SW 若策略写坏了,会比 HTTP 缓存更顽固地返回旧 HTML/旧 JS——表现为「我明明发布了用户还是旧应用」。性能优化里要把 activate / cache key / skipWaiting 当成发布流程的一部分,而不仅是前端技巧。(细节见《浏览器存储与缓存策略》Service Worker 章。)

啥时候用

  • PWA、离线包 — 性能与安全、发布三者一起评审。

十点七、内存:另一类「越来越慢」

定义:Detached DOM 树、未清定时器、闭包持有大对象、全局 cache Map 无限长 —— 不会让你首页分数当场红,但会让页面越久越卡(GC 压力)。DevTools Memory 拍堆快照对比;业务上组件卸载时 abort 请求、清监听

//正确示例——React 组件卸载时清理
useEffect(() => {
  const id = setInterval(fetchData, 5000);
  const handler = () => console.log('resize');
  window.addEventListener('resize', handler);

  return () => {
    clearInterval(id);              // 清定时器
    window.removeEventListener('resize', handler); // 清事件监听
    controller.abort();             // 取消未完成的请求
  };
}, []);

//正确示例——Vue 3 组件卸载时清理
import { onMounted, onUnmounted } from 'vue';

onMounted(() => {
  const id = setInterval(fetchData, 5000);
  const handler = () => console.log('resize');
  window.addEventListener('resize', handler);

  onUnmounted(() => {
    clearInterval(id);
    window.removeEventListener('resize', handler);
    controller.abort();
  });
});

//正确示例——用 WeakMap/WeakRef 避免全局缓存无限增长
const cache = new WeakMap(); // 当 key 对象被 GC 时,缓存项自动消失
// 对比:new Map() 会强引用 key,即使组件销毁了,缓存还在

//错误示例——单页应用路由来回切,每次都 addEventListener 不 remove
// 结果:内存泄漏,页面越久越卡

DevTools Memory 面板实操

  1. 打开 Memory 面板 → 选择「Heap snapshot」→ 点击「Take snapshot」
  2. 执行 suspected 导致泄漏的操作(比如路由切换 10 次)
  3. 再拍一个快照 → 点击上方「Comparison」视图
  4. 看「Delta」列正增长的对象 → 找是否有 Detached DOM Tree(已卸载组件的 DOM 残留)
  5. 点击对象 → 看 Retainers(谁持有它)→ 追溯到未清理的闭包或全局 Map

啥时候用

  • 长会话后台(SaaS 仪表盘) — INP 偶发卡顿有时是 GC,不是算法。

十一、小结:一张行动的优先级表

信号 先做 警惕 验证方式
LCP 红 英雄资源、关键 CSS、图片尺寸与格式 盲 preload 一切 Performance → LCP Breakdown,看哪段最长
INP 红 长任务切片、Worker、减少主线程同步 只顾减包不改交互链路 Performance → Interactions 轨道,对齐 Main 火焰图
CLS 红 尺寸占位、字体策略 无限插入广告 DevTools → Rendering → Layout Shift Regions(可视化跳动)
网络红 preconnect、HTTP 缓存头、CDN 前端造假缓存 Network 面板看 TTFB + 资源瀑布图
内存泄漏 卸载时清理定时器/监听/请求 全局 Map 无限增长 Memory 面板拍 Heap Snapshot → Comparison
证据分歧 以外场为准 拿本机分数怼用户手机 对比 Lighthouse(lab)vs CrUX/RUM(field)

十二、结语

性能与安全的共同点是:不讲证据只靠态度,就一定翻车。当你把《异步》《安全》《网络》《构建》串起来,你手里握的是节拍、门锁、水管与工厂的图纸——本篇只是温度计量对了,才知该开哪扇窗

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐