前端性能与可观测性
前端性能与可观测性
一、导读
| 定义: | 先回答「**对谁而言什么叫慢**」,再给「**怎么量、怎么改、怎么验收**」——拒绝只有鸡汤没有刻度的「优化」。 |
| 代码块 | 含 // 语法要点、//正确示例、//错误示例;性能话题必须与指标绑定。 |
| 啥时候用 | 每条技巧都写「**适用条件**」——避免把预加载抄进每个项目。 |
| 姊妹篇 | 《异步与主线程》交代卡顿的调度原因;《构建与运行》交代产物的体积与分割;本篇负责用户体感与外场证据。 |
目标:能解释 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 粗分为四段(名称随版本微调,认语义即可):
- TTFB(Time to First Byte) — 首字节到达前:DNS、TCP/TLS、服务器与边缘耗时。
- 资源加载延迟 — HTML 解析到发现 LCP 资源之间的空隙(排得太后、
loading=lazy误伤、CSS 遮挡等)。 - 资源加载时长 — 字节下载 + 解密(HTTPS)。
- 渲染延迟 — 字体阻塞、同步 JS 长时间占主线程导致「字节已到但不能画」。
精确心法:优化 LCP 时先在 Performance → Timings 与 LCP 条目详情 里看主导段是 网络 还是 主线程,别一上来只压图片 KB 数。
DevTools 实操路径:
- 打开 Chrome DevTools → Performance 面板
- 点击「录制」按钮(圆形红点),刷新页面
- 等页面稳定后停止录制
- 找到顶部 Timings 轨道的 LCP 标记(绿色菱形)
- 点击 LCP 标记 → 下方 Summary 面板会显示 LCP Breakdown
- 看四段(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-Control、ETag 等,见《网络层》《存储》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 以下要找原因。
Network 与 Main 对齐看:下载慢还是主线程算不过来。
// 正确示例——录屏一次交互(点击),只看 Main + Network 重叠区
// 错误示例——只看 Summary 圆饼就宣布胜利
DevTools 火焰图实操指南:
- 打开 Performance 面板 → 点击「录制」→ 执行卡顿的交互 → 停止录制
- 看 Main 轨道:
- 黄色宽条 = JavaScript 执行太久
- 紫色宽条 = Layout(回流)太重
- 绿色宽条 = Paint(绘制)太重
- 红色小三角 = 强制同步布局(Forced Synchronous Layout),典型的「先读 offsetHeight,再改 style,再读 offsetHeight」
- 看 Frames 轨道:
- 绿色 = 60fps(16.6ms/帧)
- 黄色 = 30-60fps(16.6-33.3ms/帧)
- 红色 = <30fps(>33.3ms/帧)
- Network 与 Main 对齐看:如果 Network 里资源已经下载完,但 Main 里还有长任务 → 问题在主线程,不是网络
啥时候用
- 单点难复现的卡顿 — 让用户开 remote debugging(若可)更快。
2 React/Vue 性能分析:框架 Profiler + 浏览器 Performance 对照
定义:框架批量更新能减少 paint,但逻辑重仍在主线程——需要框架 Profiler(看哪个组件重渲染)+ 浏览器 Performance(看主线程具体在算什么)对照阅读。
React DevTools Profiler 使用路径:
- 安装 React DevTools 浏览器扩展
- Components 标签旁切换到 Profiler 标签
- 点击「录制」→ 执行卡顿操作 → 停止
- 看火焰图里最长的条形 → 点击它 → 看「Why did this render?」(为什么重渲染)
- 常见结论:
state变了但 props 没变 → 考虑React.memo;context变了导致大面积重渲染 → 考虑拆分 Context 或用状态管理库
Vue DevTools 使用路径:
- 安装 Vue DevTools 扩展(v6 支持 Vue 3)
- 切换到 Timeline 或 Component 标签
- 点击「录制」→ 执行操作 → 看哪个组件的渲染时间最长
- Vue 3 的
<script setup>组件默认没有name,Profiler 里可能显示匿名 → 建议给组件加defineOptions({ name: 'MyComponent' })
生产环境 Profiling:
- React:使用
react-dom/profiling构建版本,线上也能抓性能数据 - Vue:设置
__VUE_PROD_DEVTOOLS__ = true(仅调试时),线上性能问题需要这个能力
啥时候用
- 大列表 + 重渲染 — 先量,再谈 memo、虚拟列表。
3 PerformanceObserver:在代码里抓 Long Task 与 LoAF
定义:主线程 duration > 50ms 的任务块可被 PerformanceObserver(entryTypes: ['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+):
- 打开 Performance 面板 → 点击「录制」→ 执行卡顿的点击/输入 → 停止
- 找到底部 Interactions 轨道(如果没有,点击轨道标题栏的「⋮」→ 勾选 Interactions)
- 看到红色或黄色的交互条 → 点击它
- DevTools 会自动在 Main 轨道高亮对应的代码执行区间
- 看高亮区间里的最宽函数调用 → 那就是导致卡顿的罪魁祸首
- 点击函数名 → 跳转到 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 面板实操:
- 打开 Memory 面板 → 选择「Heap snapshot」→ 点击「Take snapshot」
- 执行 suspected 导致泄漏的操作(比如路由切换 10 次)
- 再拍一个快照 → 点击上方「Comparison」视图
- 看「Delta」列正增长的对象 → 找是否有 Detached DOM Tree(已卸载组件的 DOM 残留)
- 点击对象 → 看 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) |
十二、结语
性能与安全的共同点是:不讲证据只靠态度,就一定翻车。当你把《异步》《安全》《网络》《构建》串起来,你手里握的是节拍、门锁、水管与工厂的图纸——本篇只是温度计:量对了,才知该开哪扇窗。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)