Skip to content

第 22 章 · 性能优化

一句话开篇:性能优化就是「让网站在用户的网络、用户的设备上,更快地变得可用、可看、可点」——而不是只在你的 MacBook + 千兆光纤上"很快"。


0. 生活类比(先建立直觉)

把一个网页想象成一家奶茶店上新品

阶段奶茶店网页
用户进店推门、看菜单输入 URL → 看到页面
第一杯到手等多久喝到第一口?LCP(最大内容绘制)
点单流畅度你点了"加珍珠",店员要 5 秒才反应?INP(点击响应)
菜单稳定性店员突然把"招牌奶茶"挪到最下面,你点错了 ❌CLS(布局偏移)
装修店面装修花了 8 小时打底,但顾客只看到成品构建优化(Tree Shaking、压缩)
配送总店做好饮料用快递送到你手里CDN 把静态资源送到离你最近的服务器
备货把热卖款提前备到柜台旁边,省得现做缓存(HTTP 缓存、浏览器缓存)

性能优化的核心就是三件事

  1. 少传——能不传就不传(按需加载、Tree Shaking、压缩)
  2. 快传——传也得就近、就快传(CDN、HTTP/2、preload)
  3. 少算——浏览器拿到资源后少做无效功(避免回流、虚拟列表、防抖节流)

优化不是"把代码写漂亮",而是对真实用户体验的负责


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.htmlCache-Control: no-cache必须实时拿到最新版,让协商缓存兜底
带 hash 的 JS/CSS(如 app.a3f9c.jsCache-Control: max-age=31536000, immutable文件名变 = 内容变,可以缓存"一年"
图片Cache-Control: max-age=259200030 天,平衡更新和性能
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/opacityGPU 完成,最便宜

避免回流的常见技巧:

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 containwill-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

按需切代码块。三种切法:

  1. 入口切分:多页应用每个页面一个入口
  2. 动态导入import('./xx.js') 自动切
  3. 公共代码提取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. ⚠️ 易踩的坑

  1. will-change 全局加:会让浏览器为很多元素提前创建合成层,反而吃光显存导致更卡。只在动画前临时加,动画结束就移除

  2. 图片不写宽高:会引起 CLS。哪怕 100x100 的图也要写 width="100" height="100",让浏览器提前占位。

  3. 首屏图片懒加载loading="lazy" 加到首屏图上,反而拖慢 LCP。首屏 LCP 图建议加 fetchpriority="high"

  4. CDN 缓存了带 hash 的旧文件:构建产物名变 = 新文件,但 index.html 里引用了旧名字 = 资源 404。部署顺序:先传静态资源,再传 HTML

  5. 防抖节流"看起来"实现了,结果丢了 this:原因是没用 apply(this, args) 转发参数。

  6. Tree Shaking 没生效:常见原因是用了 require、用了 import *、或包的 package.json 没标 "sideEffects": false

  7. 协商缓存返回 304 但还是慢:因为 304 还要发请求 + 等响应。能强缓存就别走协商。

  8. CLS 优化只盯着图片,忘了广告/字体:第三方广告和 webfont 切换(FOIT/FOUT)也是 CLS 大户。字体用 font-display: optional/swap,广告位预留高度。

  9. Source Map 直接传到 CDN:黑客可以直接还原你的源码 + 业务逻辑。生产环境务必移除或私有化上传。

  10. 盯着 Lighthouse 分数刷分:刷到 100 ≠ 用户体验好。永远以真实用户监控(RUM)为准


7. 一句话总结

性能优化 = 少传 + 快传 + 少算:缓存与 CDN 让资源就近、按需加载与压缩让体积变小、虚拟列表与防抖节流让浏览器少干活;最后用 Core Web Vitals + RUM 闭环验证你的优化真的"被用户感受到了"。


8. 延伸阅读