主题
第 22 章 · 性能优化
一句话开篇:性能优化就是「让网站在用户的网络、用户的设备上,更快地变得可用、可看、可点」——而不是只在你的 MacBook + 千兆光纤上"很快"。
0. 生活类比(先建立直觉)
把一个网页想象成一家奶茶店上新品:
| 阶段 | 奶茶店 | 网页 |
|---|---|---|
| 用户进店 | 推门、看菜单 | 输入 URL → 看到页面 |
| 第一杯到手 | 等多久喝到第一口? | LCP(最大内容绘制) |
| 点单流畅度 | 你点了"加珍珠",店员要 5 秒才反应? | INP(点击响应) |
| 菜单稳定性 | 店员突然把"招牌奶茶"挪到最下面,你点错了 ❌ | CLS(布局偏移) |
| 装修 | 店面装修花了 8 小时打底,但顾客只看到成品 | 构建优化(Tree Shaking、压缩) |
| 配送 | 总店做好饮料用快递送到你手里 | CDN 把静态资源送到离你最近的服务器 |
| 备货 | 把热卖款提前备到柜台旁边,省得现做 | 缓存(HTTP 缓存、浏览器缓存) |
性能优化的核心就是三件事:
- 少传——能不传就不传(按需加载、Tree Shaking、压缩)
- 快传——传也得就近、就快传(CDN、HTTP/2、preload)
- 少算——浏览器拿到资源后少做无效功(避免回流、虚拟列表、防抖节流)
优化不是"把代码写漂亮",而是对真实用户体验的负责。
1. 概念是什么
前端性能(Web Performance) 指的是网页从开始加载到完全可交互这一过程中,用户感知到的速度和流畅度。
衡量它有两个维度:
- 客观指标:LCP / INP / CLS / TTFB / FCP / TTI(数字、可被工具测量)
- 主观感受:「我点了按钮,是不是马上有反馈」「滚动是不是不卡」
业界事实标准是 Google 的 Core Web Vitals(核心网页指标):
┌──────────────────────────────────────────────────────────┐
│ Core Web Vitals (核心三指标) │
├────────┬─────────────────────┬───────────────────────────┤
│ LCP │ Largest Contentful │ 最大内容绘制时间 │
│ │ Paint │ 衡量"加载快不快" │
│ │ │ 优秀 ≤ 2.5s │
├────────┼─────────────────────┼───────────────────────────┤
│ INP │ Interaction to Next │ 交互到下一帧绘制时间 │
│ │ Paint │ 衡量"操作跟手不跟手" │
│ │ (替代了旧的 FID) │ 优秀 ≤ 200ms │
├────────┼─────────────────────┼───────────────────────────┤
│ CLS │ Cumulative Layout │ 累计布局偏移 │
│ │ Shift │ 衡量"页面稳不稳" │
│ │ │ 优秀 ≤ 0.1 │
└────────┴─────────────────────┴───────────────────────────┘记忆口诀:LCP 看「快」,INP 看「跟手」,CLS 看「稳」。
2. 为什么需要它
不优化会怎样?看一组真实数据(来自 Google / Akamai 公开报告):
| 加载时间 | 后果 |
|---|---|
| 0 → 1 秒 | 跳出率基线 |
| 1 → 3 秒 | 跳出率上升 32% |
| 1 → 5 秒 | 跳出率上升 90% |
| 1 → 6 秒 | 跳出率上升 106% |
| 1 → 10 秒 | 跳出率上升 123% |
简单说:慢 1 秒,可能损失 7% 的转化率。对电商而言这就是真金白银。
更现实的:你的简历项目如果首屏 6 秒,面试官评价大概率是"前端基本功不扎实"。
3. 怎么用(最小示例)
最简单的三招优化,写一行代码加 1 行 HTML 即可:
html
<!-- 1. 给图片懒加载(关键内容上方除外) -->
<img src="big.jpg" loading="lazy" alt="..." />
<!-- 2. 提前解析关键域名 -->
<link rel="dns-prefetch" href="//cdn.example.com" />
<!-- 3. 把首屏关键 CSS 内联到 <head>,避免阻塞 -->
<style>
/* critical CSS 放这里 */
body { margin: 0; font-family: system-ui; }
</style>就这 3 行,可能让你的 LCP 直接从 4s → 2s。
4. 进阶要点
性能优化按"用户视角"分成 4 大块:
┌─────────────────────────────────────────────────────────────┐
│ 性能优化全景图 │
├─────────────────────────────────────────────────────────────┤
│ 1. 加载性能 → 资源「快到」 │
│ ├── HTTP 缓存(强缓存 + 协商缓存) │
│ ├── CDN(就近分发) │
│ ├── 资源提示(preload / prefetch / dns-prefetch) │
│ ├── 按需加载(路由懒加载、组件懒加载) │
│ └── 图片优化(WebP/AVIF、lazy、srcset、占位) │
├─────────────────────────────────────────────────────────────┤
│ 2. 渲染性能 → 浏览器「画得快」 │
│ ├── 减少回流(Reflow)/ 重绘(Repaint) │
│ ├── CSS contain / will-change │
│ ├── 虚拟列表(长列表只渲染可视区) │
│ └── 防抖(debounce)/ 节流(throttle) │
├─────────────────────────────────────────────────────────────┤
│ 3. 构建性能 → 产物「更小更精」 │
│ ├── Tree Shaking(摇掉死代码) │
│ ├── Code Splitting(按需切块) │
│ ├── 压缩(gzip / brotli) │
│ └── Source Map 策略(生产环境别上传完整 Source Map) │
├─────────────────────────────────────────────────────────────┤
│ 4. 监控运营 → 知道「线上有多快」 │
│ ├── Performance API │
│ ├── Lighthouse(实验室数据) │
│ └── web-vitals 库(真实用户数据 RUM) │
└─────────────────────────────────────────────────────────────┘下面逐块拆解。
4.1 加载性能
① HTTP 缓存(重点中的重点)
浏览器缓存分两类:强缓存和协商缓存。
请求一个资源:
│
▼
┌──────────────────┐
│ 1. 强缓存命中? │ Cache-Control: max-age=31536000
│ (没过期) │ Expires: ...(已不推荐)
└─────────┬────────┘
│ 命中:直接用本地缓存(200 from disk/memory cache)
│ 不命中 / 过期 ↓
┌──────────────────┐
│ 2. 协商缓存? │ 请求带上 If-None-Match / If-Modified-Since
│ │
│ 服务器看 ETag / │
│ Last-Modified │
└─────────┬────────┘
│ 资源没变:返回 304 Not Modified(无 body,省流量)
│ 资源变了 ↓
┌──────────────────┐
│ 3. 返回新资源 │ 200 OK + 新内容 + 新 ETag
└──────────────────┘实战经验法则:
| 资源类型 | 缓存策略 | 为什么 |
|---|---|---|
index.html | Cache-Control: no-cache | 必须实时拿到最新版,让协商缓存兜底 |
带 hash 的 JS/CSS(如 app.a3f9c.js) | Cache-Control: max-age=31536000, immutable | 文件名变 = 内容变,可以缓存"一年" |
| 图片 | Cache-Control: max-age=2592000 | 30 天,平衡更新和性能 |
| API(动态数据) | Cache-Control: no-store | 别缓存任何东西 |
② CDN(Content Delivery Network)
类比:奶茶店在全国 100 个城市开了分店。用户从最近的分店拿货,而不是每次都跑到上海总店。
技术上:把静态资源(JS/CSS/图片)放到分布在全球的边缘节点,用户访问时从最近节点返回。
关键词:边缘节点、回源、刷新、预热——后面 25 章会讲。
③ 资源提示(Resource Hints)
这是一组让浏览器提前做事的指令:
| 指令 | 作用 | 典型场景 |
|---|---|---|
dns-prefetch | 提前 DNS 解析 | 即将用到的第三方域名(API、CDN) |
preconnect | 提前建立 TCP+TLS 连接(更进一步) | 关键的第三方域名 |
preload | 当前页面马上就要用的关键资源 | 首屏字体、关键 JS |
prefetch | 下一个页面可能用到的资源 | 路由级预取 |
modulepreload | 预加载 ES 模块 | 现代项目的 JS 模块 |
html
<!-- DNS 预解析(最便宜,建议无脑加)-->
<link rel="dns-prefetch" href="//api.example.com" />
<!-- 关键字体预加载(一定要加 as 和 type)-->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<!-- 下一页预取 -->
<link rel="prefetch" href="/next-page.html" />④ 按需加载
路由级(最常见):
js
// React
const Profile = lazy(() => import('./pages/Profile'));
// Vue Router
{ path: '/profile', component: () => import('./pages/Profile.vue') }构建工具(Webpack/Vite)会自动把 Profile 拆成单独 chunk,访问 /profile 才加载。
组件级:模态框、富文本编辑器这类"用户大概率不会用"的组件,懒加载。
⑤ 图片优化(性价比最高)
图片通常占网页体积 50%+,优化空间巨大:
| 手段 | 节省效果 | 兼容性 |
|---|---|---|
| WebP 替代 JPG/PNG | -25% ~ -35% 体积 | 主流浏览器都支持 |
| AVIF 替代 WebP | 再 -20% | Safari 16+,需降级 |
loading="lazy" | 首屏外的图不加载 | 现代浏览器原生支持 |
srcset | 高 DPR 屏幕给 2x 图,普通屏 1x | 全支持 |
| 占位图(LQIP) | 首屏不抖(避免 CLS) | 自己实现 |
<picture> + 渐进降级写法:
html
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="..." width="800" height="450" loading="lazy" />
</picture>⚠️ 一定要给
width/height!否则图片加载时撑开会引起 CLS。
4.2 渲染性能
浏览器渲染流水线:
HTML → DOM Tree
╲
CSS → CSSOM → Render Tree → Layout → Paint → Composite
↑ ↑ ↑
【回流】 【重绘】 【合成】
最贵 中 最便宜① 回流 vs 重绘(必背)
| 类型 | 触发 | 代价 |
|---|---|---|
| 回流 | 修改几何属性(宽高、位置、display) | 重新计算布局,很贵 |
| 重绘 | 修改外观(颜色、background) | 仅重新画,便宜 |
| 合成 | 仅修改 transform/opacity | GPU 完成,最便宜 |
避免回流的常见技巧:
js
// ❌ 反例:每次都触发回流
for (let i = 0; i < 100; i++) {
list.style.height = `${i}px`;
}
// ✅ 正例 1:批量修改
list.style.cssText = 'height: 100px; width: 200px; color: red;';
// ✅ 正例 2:脱离文档流
list.style.display = 'none';
// ...大量修改
list.style.display = 'block';
// ✅ 正例 3:使用 DocumentFragment 批量插入
const frag = document.createDocumentFragment();
items.forEach(it => frag.appendChild(createItem(it)));
container.appendChild(frag); // 只触发 1 次回流
// ✅ 正例 4:动画用 transform 而不是 left/top
// transform 走合成层,不触发回流和重绘
element.style.transform = 'translateX(100px)';② CSS contain 与 will-change
css
/* contain:告诉浏览器"这个元素的影响范围被'封住'了" */
.card {
contain: layout paint; /* 内部变化不会影响外部布局 */
}
/* will-change:提前告知浏览器"我即将动画这个属性" */
.modal {
will-change: transform;
}⚠️
will-change不要滥用!它会提前创建合成层,吃显存。只在真正动画前临时加。
③ 虚拟列表
10 万条数据全渲染 = 10 万 DOM = 卡到崩。
虚拟列表:只渲染可视区 + 上下缓冲区的几十条 DOM,滚动时动态替换内容。
┌─────────────┐ ← 实际只有 20 个 DOM
│ item 31 │
│ item 32 │
│ item 33 │
│ item 34 │ ← 用户能看到这片
│ item 35 │
│ item 36 │
│ item 37 │
└─────────────┘
底层数据:
[item 1, item 2, ..., item 100000]详见 examples/virtual-list.html。
④ 防抖(debounce) vs 节流(throttle)
| 概念 | 作用 | 类比 |
|---|---|---|
| 防抖 | 触发后等 N 毫秒再执行;期间再触发就重新计时 | 电梯门:有人按就再等 5 秒才关 |
| 节流 | N 毫秒内只执行一次,多余的扔掉 | 自动售货机:1 秒只能投一次币 |
js
// 防抖:搜索框输入
input.addEventListener('input', debounce(search, 300));
// 节流:滚动监听
window.addEventListener('scroll', throttle(onScroll, 100));完整实现见 examples/debounce-throttle.js。
4.3 构建性能
① Tree Shaking
打包时摇掉没用到的代码。前提是 ES Module(import/export),CommonJS(require)摇不动。
js
// utils.js
export const a = () => 'a';
export const b = () => 'b';
// main.js
import { a } from './utils.js'; // ✅ b 会被摇掉
// 反例(会失败)
import * as utils from './utils.js';
utils.a(); // ❌ 用了星号导入,b 也会被打包② Code Splitting
按需切代码块。三种切法:
- 入口切分:多页应用每个页面一个入口
- 动态导入:
import('./xx.js')自动切 - 公共代码提取:
vendor.js(react、lodash 等不变的库单独打包,长缓存)
③ 压缩
| 压缩算法 | 节省 | 兼容性 |
|---|---|---|
| gzip | ~70% | 所有浏览器 |
| brotli | ~80% | 现代浏览器(HTTPS) |
服务器配置(Nginx):
nginx
gzip on;
gzip_types text/plain text/css application/json application/javascript;
brotli on;
brotli_types text/plain text/css application/json application/javascript;④ Source Map 策略
| 模式 | 适用场景 | 安全性 |
|---|---|---|
eval | 开发 | 快但不安全 |
source-map | 生产 + 私有上传 | 完整,别公开 |
hidden-source-map | 生产 + 上报错误监控 | 不暴露给浏览器,配合 Sentry |
nosources-source-map | 生产 + 简单调试 | 没源码内容,只有映射 |
最佳实践:构建完把
.map上传到 Sentry,然后从 dist 目录删掉或不部署到 CDN。
4.4 性能监控
① Performance API(浏览器原生)
js
// 获取页面加载关键时间点
const nav = performance.getEntriesByType('navigation')[0];
console.log('TTFB:', nav.responseStart - nav.requestStart);
console.log('DOM Ready:', nav.domContentLoadedEventEnd - nav.startTime);
console.log('Load:', nav.loadEventEnd - nav.startTime);
// 自定义打点
performance.mark('myStart');
// ...做事...
performance.mark('myEnd');
performance.measure('myWork', 'myStart', 'myEnd');
console.log(performance.getEntriesByName('myWork')[0].duration);② web-vitals 库(推荐!)
js
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(metric => report('LCP', metric.value));
onINP(metric => report('INP', metric.value));
onCLS(metric => report('CLS', metric.value));
function report(name, value) {
// 上报到自家监控平台
navigator.sendBeacon('/perf', JSON.stringify({ name, value }));
}
sendBeacon是性能监控的"亲儿子"——它能在页面unload时也保证发出去,不阻塞页面。
③ Lighthouse
Chrome DevTools 自带的"实验室体检"。注意:它跑出来的分数 ≠ 真实用户感受,只是参考。
5. 实战案例
详见 examples/ 目录:
debounce-throttle.js——防抖节流的两种工业级实现lazy-load.html——三种图片懒加载方案对比(IntersectionObserver / loading=lazy / 滚动监听)virtual-list.html——10 万条数据的虚拟列表,零依赖原生实现preload.html——资源提示对比演示
demo/index.html 演示了「未优化 vs 优化后」图片加载的实时对比。
6. ⚠️ 易踩的坑
will-change全局加:会让浏览器为很多元素提前创建合成层,反而吃光显存导致更卡。只在动画前临时加,动画结束就移除。图片不写宽高:会引起 CLS。哪怕 100x100 的图也要写
width="100" height="100",让浏览器提前占位。首屏图片懒加载:
loading="lazy"加到首屏图上,反而拖慢 LCP。首屏 LCP 图建议加fetchpriority="high"。CDN 缓存了带 hash 的旧文件:构建产物名变 = 新文件,但
index.html里引用了旧名字 = 资源 404。部署顺序:先传静态资源,再传 HTML。防抖节流"看起来"实现了,结果丢了
this:原因是没用apply(this, args)转发参数。Tree Shaking 没生效:常见原因是用了
require、用了import *、或包的package.json没标"sideEffects": false。协商缓存返回 304 但还是慢:因为 304 还要发请求 + 等响应。能强缓存就别走协商。
CLS 优化只盯着图片,忘了广告/字体:第三方广告和 webfont 切换(FOIT/FOUT)也是 CLS 大户。字体用
font-display: optional/swap,广告位预留高度。Source Map 直接传到 CDN:黑客可以直接还原你的源码 + 业务逻辑。生产环境务必移除或私有化上传。
盯着 Lighthouse 分数刷分:刷到 100 ≠ 用户体验好。永远以真实用户监控(RUM)为准。
7. 一句话总结
性能优化 = 少传 + 快传 + 少算:缓存与 CDN 让资源就近、按需加载与压缩让体积变小、虚拟列表与防抖节流让浏览器少干活;最后用 Core Web Vitals + RUM 闭环验证你的优化真的"被用户感受到了"。
8. 延伸阅读
- web.dev/learn-performance ——Google 官方性能学习路径
- web-vitals npm
- Chrome DevTools Performance 面板入门
- HTTP Archive Almanac
- 《Web 性能权威指南》(Ilya Grigorik)——HTTP/CDN/TCP 角度的圣经