页面慢这件事最难受的地方,不是不知道怎么优化,是不知道该优化哪儿。有人上来就压图片,有人先去拆包,忙活一通首屏时间纹丝不动。我自己也走过这段弯路,后来才想明白,性能优化的第一步从来不是动手改代码,是先把「慢在哪一毫秒」量出来。
这篇按这个顺序展开:先把测量工具和 Web API 讲清楚,让你能拿到数字;再按加载、渲染、缓存三条线逐条给可落地的手段,压缩、拆包、骨架屏、虚拟列表、预加载懒加载都在里面。每一条都说清它在解决哪个指标,以及什么场景下用了也白用。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Network、Lighthouse、Performance、WebPageTest 各自适合看什么
- 用 Performance API 自己算白屏、TTI、DNS 这些指标
- 雅虎军规里今天还真正管用的那几条
- Gzip、服务端压缩、JS/CSS/HTML 压缩、HTTP/2 首部压缩的实际收益
- webpack 侧的 DllPlugin 与 splitChunks 拆包
- 骨架屏、虚拟列表、白屏 loading 这类体验优化的原理
- HTTP 缓存四件套和 Service Worker
- preload、prefetch 和图片、路由懒加载的正确用法
# 一、先把问题量出来,四类调试工具
工欲善其事必先利其器。这一节的工具不用全学,但至少要知道各自看什么。
# 1.1 Network

Network 面板能看到资源加载详情,用来初步评估影响页面性能的因素。鼠标右键可以自定义列,页面底部是当前加载资源的一个概览。里面有两个数字要先认准:DOMContentLoaded 是 DOM 渲染完成的时间,Load 是当前页面所有资源加载完成的时间。
先想一个问题,怎么判断哪些资源对当前页面加载是无用的?
答案不在这个面板里。按 shift + cmd + P 调出命令面板,输入 Coverage 打开覆盖率工具,它会告诉你每个 JS 和 CSS 文件里有多少字节是本次访问根本没执行到的:

同一个命令面板里还能调出 Performance monitor,实时看 DOM 节点数、JS 堆大小、每秒布局次数的变化:

DOM 节点数持续上涨而不回落,基本可以断定有泄漏,这个指标比内存曲线更直观。
# 瀑布流 waterfall
单条请求点开的这个时间条,是排查慢请求最重要的信息:

Queueing浏览器将资源放入队列的时间Stalled因放入队列时间而发生的停滞时间DNS LookupDNS 解析时间Initial connection建立 HTTP 连接的时间SSL浏览器与服务器建立安全性连接的时间TTFB等待服务端返回数据的时间Content Download浏览器下载资源的时间
这七段的意义在于它能直接告诉你锅在哪边。TTFB 长是后端慢或者网络链路远,Content Download 长是资源太大,Queueing 和 Stalled 长通常是同域名并发请求数打满了。三种情况的解法完全不同,光看一个总耗时是分不出来的。
# 1.2 Lighthouse

Lighthouse 会根据 Chrome 的一套策略自动对网站做质量评估,并给出优化建议。几个核心指标:
First Contentful Paint首屏渲染时间,1s 以内是绿色Speed Index速度指数,4s 以内是绿色Time to Interactive到页面可交互的时间
它最大的价值是把「感觉有点慢」变成一个可以拿去开会的分数,而且给的建议基本都能直接照着做。缺点是本地跑分受你自己的机器和网络影响很大,同一个页面开着一堆标签页跑和干净环境跑能差二十分,看趋势别看绝对值。
# 1.3 Performance

Performance 是对网站最专业的分析面板,能看到每一帧里 JS 执行、样式计算、布局、绘制各花了多久。前面两个工具告诉你有问题,这个告诉你问题具体在哪一行代码。
学会读火焰图之后,很多玄学问题会立刻变得具体。渲染链路这块我在 浏览器渲染路径与性能优化 里单独展开过,可以配着看。
# 1.4 WebPageTest
WebPageTest 可以模拟不同场景下的访问情况,比如不同浏览器、不同国家、不同网速,在线测试地址是 webPageTest。


它比本地 Lighthouse 强的地方是节点在全球各地,能真实反映海外用户或者弱网用户看到的样子。做出海业务的话这个工具比什么都实在。
# 1.5 打包体积分析
前面几个看的是运行时,这个看的是产物。
webpack-bundle-analyzer 会把打包结果画成一张矩形树图,哪个包占地大一目了然:

安装和配置:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js 文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports={
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'server',
analyzerHost: '127.0.0.1',
analyzerPort: 8889,
reportFilename: 'report.html',
defaultSizes: 'parsed',
openAnalyzer: true,
generateStatsFile: false,
statsFilename: 'stats.json',
statsOptions: null,
logLevel: 'info'
}),
]
}
再在 package.json 里加个入口:
"analyz": "NODE_ENV=production npm_config_report=true npm run build"
另一条路是开 source-map 配合 source-map-explorer,它的粒度更细,能看到某个第三方库里具体是哪几个文件占了体积。先在 webpack.config.js 里开 source-map:
module.exports = {
mode: 'production',
devtool: 'hidden-source-map',
}
hidden-source-map 会生成 map 文件但不在 bundle 末尾写引用注释,这样浏览器 DevTools 不会自动加载它,源码不至于泄漏出去,同时本地分析工具还能用。
然后配脚本:
"analyze": "source-map-explorer 'build/*.js'",
跑 npm run analyze 出来的结果长这样:

我一般是先用 webpack-bundle-analyzer 找大块,锁定某个库之后再用 source-map-explorer 看内部构成,判断是能 tree-shaking 掉还是得整个换掉。
# 二、用 Web API 自己采数据
工具面板只能看当下这一次访问。要做长期监控,得靠浏览器提供的这几个 API 自己上报。
# 2.1 监听视窗激活状态
用户切走标签页之后,页面里的定时器、轮询、动画其实都还在跑,白白烧电和流量。visibilitychange 就是用来管这件事的:
// 窗口激活状态监听
let vEvent = 'visibilitychange';
if (document.webkitHidden != undefined) {
vEvent = 'webkitvisibilitychange';
}
function visibilityChanged() {
if (document.hidden || document.webkitHidden) {
document.title = '客官,别走啊~'
console.log("Web page is hidden.")
} else {
document.title = '客官,你又回来了呢~'
console.log("Web page is visible.")
}
}
document.addEventListener(vEvent, visibilityChanged, false);
这段里的 webkitHidden 是老 Safari 和老 Android 浏览器时代的前缀写法,现在主流浏览器全都支持无前缀的 document.hidden 和 visibilitychange,新项目直接用标准写法就够了,不用再做这层降级判断。
真正有价值的用法不是改标题逗用户,是在 hidden 时暂停轮询、停掉 requestAnimationFrame、断开 WebSocket,回到 visible 时再恢复。做直播或者行情类页面,这一下能省掉相当可观的资源。
# 2.2 观察长任务
主线程上超过 50ms 的任务就算长任务,它是页面卡顿、点击没反应的直接原因。PerformanceObserver 能把它们全捞出来:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry)
}
})
observer.observe({entryTypes: ['longtask']})
拿到 entry 之后上报到监控平台,就能知道线上用户实际卡在哪些操作上,比在本地反复复现靠谱得多。
# 2.3 监听网络变化
网络变化时给用户一个反馈,看直播的时候网络卡了平台会提醒你或者自动降清晰度,就是这套东西:
var connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
var type = connection.effectiveType;
function updateConnectionStatus() {
console.log("Connection type changed from " + type + " to " + connection.effectiveType);
type = connection.effectiveType;
}
connection.addEventListener('change', updateConnectionStatus);
这个我踩过一个坑,navigator.connection 的兼容性比看起来差,Safari 到现在都不支持,直接取 connection.effectiveType 会抛错。上生产一定要判空,别学上面这段直接往下写。
# 2.4 计算 DOMContentLoaded 时间
window.addEventListener('DOMContentLoaded', (event) => {
let timing = performance.getEntriesByType('navigation')[0];
console.log(timing.domInteractive);
console.log(timing.fetchStart);
let diff = timing.domInteractive - timing.fetchStart;
console.log("TTI: " + diff);
})