Web 性能优化实战:从 Core Web Vitals 到工程化性能治理
Web 性能优化并不是简单地压缩几张图片、删除几个 JavaScript 文件,而是围绕加载速度、交互响应、视觉稳定性和资源成本建立一套可测量、可分析、可持续的工程体系。
目录
- 为什么需要进行 Web 性能优化
- Web 页面从请求到展示经历了什么
- Web 性能核心指标
- 实验室数据与真实用户数据
- 建立性能基线与性能预算
- 优化服务器响应时间与 TTFB
- 优化网络请求和资源加载链路
- LCP 优化实战
- 图片性能优化
- CSS 性能优化
- Web 字体优化
- JavaScript 加载与体积优化
- INP 与主线程优化
- 渲染性能优化
- CLS 与页面稳定性优化
- SPA、SSR、SSG 与水合性能
- 浏览器缓存与 CDN 优化
- 第三方脚本优化
- 前端性能监控与 RUM
- 使用 Lighthouse CI 建立性能门禁
- Web 性能优化实战案例
- 常见性能问题排查方法
- Web 性能上线检查清单
- 常见问题
- 总结
1. 为什么需要进行 Web 性能优化
Web 性能直接影响用户体验、业务转化率、搜索引擎排名和服务器成本。
当页面加载缓慢时,用户可能会遇到以下问题:
- 页面长时间白屏;
- 首屏图片迟迟不能显示;
- 点击按钮后没有反馈;
- 输入文字时页面卡顿;
- 页面内容突然跳动;
- 手机端耗电、发热明显;
- 弱网环境下页面无法正常使用。
从业务角度看,性能问题通常会进一步导致:
- 跳出率升高;
- 页面停留时间下降;
- 注册、下单、支付转化率下降;
- 搜索引擎自然流量减少;
- CDN 和服务器带宽成本增加;
- 客服投诉和线上故障增多。
因此,Web 性能优化的目标不应该只是"让 Lighthouse 跑到 100 分",而应该是:
- 让用户更快看到主要内容;
- 让用户操作后更快得到反馈;
- 保证页面展示过程稳定;
- 控制资源体积和请求数量;
- 在真实用户环境中持续监控性能;
- 防止新版本引入性能回退。
2. Web 页面从请求到展示经历了什么
要进行性能优化,首先要理解页面加载链路。
用户在浏览器中输入 URL 后,大致会经历以下过程:
text
输入 URL
↓
解析 URL
↓
检查浏览器缓存
↓
DNS 查询
↓
建立 TCP 或 QUIC 连接
↓
建立 TLS 安全连接
↓
发送 HTTP 请求
↓
服务器处理请求
↓
返回 HTML
↓
浏览器解析 HTML
↓
下载 CSS、JavaScript、图片、字体
↓
构建 DOM 和 CSSOM
↓
生成渲染树
↓
布局 Layout
↓
绘制 Paint
↓
栅格化 Raster
↓
合成 Composite
↓
页面显示
页面性能问题可能出现在任何一个环节。
例如:
| 性能现象 | 可能原因 |
|---|---|
| 页面长时间白屏 | TTFB 高、CSS 阻塞、客户端渲染依赖大包 JavaScript |
| 首屏大图加载慢 | 图片过大、发现时间晚、请求优先级低 |
| 点击按钮卡顿 | 主线程存在长任务、事件回调执行时间过长 |
| 页面不断跳动 | 图片没有尺寸、广告位没有预留空间、字体切换 |
| 二次访问仍然很慢 | 缓存策略不合理、静态资源没有内容哈希 |
| 低端手机卡顿 | JavaScript 解析执行量过大、DOM 规模过大 |
| 海外访问缓慢 | CDN 节点不足、跨区域回源、连接链路过长 |
性能优化的关键,就是先找到时间究竟消耗在什么地方,然后针对瓶颈进行优化。
3. Web 性能核心指标
3.1 Core Web Vitals
Core Web Vitals 是衡量用户体验的三个核心指标:
| 指标 | 含义 | 良好 | 需要改进 | 较差 |
|---|---|---|---|---|
| LCP | 最大内容绘制,衡量主要内容加载速度 | ≤ 2.5 秒 | 2.5~4 秒 | > 4 秒 |
| INP | 与下一次绘制的交互,衡量整体交互响应性 | ≤ 200 毫秒 | 200~500 毫秒 | > 500 毫秒 |
| CLS | 累积布局偏移,衡量视觉稳定性 | ≤ 0.1 | 0.1~0.25 | > 0.25 |
这些指标应当按照真实用户访问数据的第 75 百分位进行评估。
第 75 百分位可以简单理解为:至少 75% 的用户访问体验都需要达到目标,而不是只保证平均值看起来正常。
3.2 LCP
LCP 的全称是 Largest Contentful Paint,即最大内容绘制。
它关注首屏中最大图片、视频封面或文本块完成绘制的时间。
常见的 LCP 元素包括:
- 首页头图;
- 商品主图;
- Banner 图片;
- 文章标题区域;
- 大段首屏文本;
- 视频封面。
LCP 可以拆成四部分:
text
LCP =
TTFB
+ 资源加载延迟
+ 资源加载耗时
+ 元素渲染延迟
这意味着图片压缩并不是 LCP 优化的全部。
如果图片已经下载完成,但页面必须等待 JavaScript 执行后才能显示,那么瓶颈实际上是元素渲染延迟。
3.3 INP
INP 的全称是 Interaction to Next Paint。
它衡量用户进行点击、触摸或键盘输入后,页面完成下一次视觉更新需要多长时间。
一次交互可以拆成三个阶段:
text
输入延迟
+
事件处理时间
+
展示延迟
常见 INP 问题包括:
- 用户点击时主线程正在执行其他任务;
- 事件回调中进行了大量计算;
- 更新了规模过大的 DOM;
- 框架一次性重新渲染大量组件;
- 页面布局和绘制开销过大;
- 第三方脚本长期占用主线程。
3.4 CLS
CLS 的全称是 Cumulative Layout Shift,即累积布局偏移。
常见问题包括:
- 图片没有声明宽高;
- 广告或推荐位没有预留空间;
- 接口返回后在页面顶部插入新内容;
- Web 字体加载后文字尺寸发生变化;
- 动画修改了
width、height、top或left; - 骨架屏和真实内容尺寸不一致。
3.5 其他诊断指标
除了 Core Web Vitals,还应关注以下指标:
| 指标 | 作用 |
|---|---|
| TTFB | 从发起导航到收到 HTML 第一个字节的时间 |
| FCP | 页面首次绘制文字、图片或其他内容的时间 |
| TBT | 实验室环境中主线程被长任务阻塞的总时间 |
| Speed Index | 页面可视内容完成展示的速度 |
| Resource Timing | 每个资源的 DNS、连接、等待、下载耗时 |
| Long Task | 主线程中执行时间超过 50 毫秒的任务 |
| JS Heap | JavaScript 堆内存使用量 |
| DOM Size | 页面 DOM 节点数量和层级 |
需要注意,Lighthouse 无法在一次短暂的页面加载测试中完整模拟用户整个访问周期的 INP,通常会使用 TBT 等指标辅助诊断主线程阻塞问题。
4. 实验室数据与真实用户数据
Web 性能数据主要分为实验室数据和真实用户数据。
4.1 实验室数据
实验室数据是在可控环境下运行测试得到的数据。
常用工具包括:
- Chrome DevTools;
- Lighthouse;
- PageSpeed Insights 的实验室部分;
- WebPageTest;
- Lighthouse CI。
实验室数据适合:
- 在开发阶段复现问题;
- 分析网络瀑布图;
- 查看主线程任务;
- 对比优化前后结果;
- 在 CI 中检测性能回退。
它的局限是无法完整代表真实用户。
开发者电脑通常性能较强、网络较快,与低端手机和弱网用户差别很大。
4.2 真实用户数据
真实用户监控通常称为 RUM,也就是 Real User Monitoring。
RUM 数据来自真实用户的:
- 实际设备;
- 实际网络;
- 实际地理位置;
- 实际登录状态;
- 实际页面内容;
- 实际操作路径。
真实用户数据适合回答:
- 哪些页面的 LCP 最差?
- 哪些设备的 INP 较高?
- 某次发布是否导致性能回退?
- 低端手机是否明显比高端手机慢?
- 某个地区是否存在 CDN 或回源问题?
4.3 推荐的性能工作流
text
真实用户数据发现问题
↓
按页面、设备、网络、版本进行分组
↓
在实验室环境中复现
↓
使用 Network 和 Performance 定位原因
↓
实施优化
↓
本地和 CI 验证
↓
灰度发布
↓
继续观察真实用户数据
不要只根据单次 Lighthouse 分数做结论。
性能测试存在波动,应当多次运行,并结合资源体积、请求数量、CPU 时间和真实用户数据综合判断。
5. 建立性能基线与性能预算
5.1 建立性能基线
在优化之前,先记录当前数据。
建议至少记录:
| 维度 | 示例 |
|---|---|
| 页面 | 首页、列表页、详情页、登录页、结算页 |
| 设备 | 高端手机、低端手机、桌面设备 |
| 网络 | Wi-Fi、4G、弱网 |
| 用户状态 | 未登录、已登录、新用户、老用户 |
| 缓存状态 | 首次访问、二次访问 |
| 页面指标 | TTFB、FCP、LCP、INP、CLS |
| 资源指标 | JS、CSS、图片、字体体积 |
| 运行指标 | 长任务、DOM 节点、内存 |
5.2 设置性能预算
性能预算是项目允许消耗的最大性能资源。
下面是一份示例预算,实际项目应根据用户群体和现有基线调整:
json
{
"page": "product-detail",
"budgets": {
"lcpMs": 2500,
"inpMs": 200,
"cls": 0.1,
"initialJavaScriptKb": 300,
"initialCssKb": 100,
"initialImageKb": 500,
"fontKb": 150,
"requestCount": 60
}
}
性能预算不要只设置 Lighthouse 总分。
总分可能随着工具权重变化而变化,而以下指标更加直接:
- JavaScript 体积;
- 图片体积;
- 请求数量;
- LCP 资源开始加载的时间;
- 主线程执行时间;
- 长任务数量;
- TTFB;
- LCP;
- CLS。
5.3 区分冷启动和热缓存
测试时需要分别测量:
- 首次访问,没有可用缓存;
- 再次访问,静态资源命中缓存;
- 从其他页面进行站内跳转;
- 浏览器前进后退缓存恢复。
如果只测试热缓存,可能会掩盖新用户首访性能问题。
6. 优化服务器响应时间与 TTFB
TTFB 是整个页面加载链路的起点。
如果 HTML 返回就花了 3 秒,那么页面很难达到 2.5 秒以内的 LCP。
6.1 TTFB 较高的常见原因
- DNS 解析慢;
- 网络距离过远;
- 多次重定向;
- TLS 建连时间过长;
- 应用启动慢;
- 数据库查询慢;
- 串行调用多个下游服务;
- 模板渲染耗时;
- 缓存未命中;
- CDN 回源慢;
- 服务端资源不足;
- 服务端进行过多同步计算。
6.2 减少重定向
不推荐:
text
http://example.com
→ https://example.com
→ https://www.example.com
→ https://www.example.com/home
如果业务允许,应尽量直接跳转到最终地址。
6.3 优化服务端处理
可以从以下方面入手:
- 为高频查询建立合适的数据库索引;
- 避免循环查询导致的 N+1 问题;
- 将互不依赖的下游请求并行执行;
- 缓存公共页面和公共接口;
- 对热点数据进行预热;
- 避免在请求链路执行不必要的同步任务;
- 为服务端调用设置合理的超时和降级策略;
- 通过
Server-Timing返回服务端阶段耗时。
示例:
http
Server-Timing: db;dur=42, cache;dur=3, app;dur=68
浏览器可以在开发者工具中展示这些数据,从而帮助判断时间究竟消耗在数据库、缓存还是应用逻辑中。
6.4 使用流式响应
对于服务端渲染页面,可以在条件允许时逐步输出 HTML。
浏览器收到部分 HTML 后就可以开始:
- 解析文档;
- 发现 CSS;
- 发现首屏图片;
- 预加载关键资源。
但是流式输出不能弥补严重的后端性能问题。首字节仍然应该尽早返回。
7. 优化网络请求和资源加载链路
7.1 分析 Network 瀑布图
重点检查:
- 哪个请求最早开始;
- 哪个请求阻塞时间最长;
- LCP 资源什么时候被发现;
- 是否存在重复资源;
- 是否出现长重定向链;
- 请求是否等待连接;
- 是否存在体积过大的 JavaScript;
- 是否有接口串行依赖;
- 静态资源是否命中缓存。
一个典型的低效链路可能是:
text
HTML
↓
main.js
↓
执行 JavaScript
↓
请求页面数据
↓
拼接图片地址
↓
请求首屏图片
↓
显示 LCP 元素
更好的链路是:
text
HTML ──────────────→ 页面结构
├───────────────→ 关键 CSS
├───────────────→ 首屏图片
└───────────────→ JavaScript
首屏关键资源应尽早出现在初始 HTML 中,让浏览器的预加载扫描器能够及时发现。
7.2 减少不必要的串行依赖
不推荐:
javascript
const user = await fetchUser();
const products = await fetchProducts();
const recommendations = await fetchRecommendations();
如果这些请求互不依赖,可以并行执行:
javascript
const [user, products, recommendations] = await Promise.all([
fetchUser(),
fetchProducts(),
fetchRecommendations()
]);
如果推荐内容不是首屏关键内容,可以延后加载:
javascript
const [user, products] = await Promise.all([
fetchUser(),
fetchProducts()
]);
renderMainContent({ user, products });
requestIdleCallback(() => {
fetchRecommendations().then(renderRecommendations);
});
requestIdleCallback 并不是所有环境都具有一致支持,生产项目应进行兼容性检查,或者使用框架调度机制和可控的延迟任务。
7.3 合理使用资源提示
preconnect
提前建立到关键跨域服务器的连接:
html
<link rel="preconnect" href="https://static.example.com" crossorigin>
适用于:
- 核心静态资源域名;
- 首屏字体域名;
- 首屏关键 API 域名。
不要对大量域名使用 preconnect,因为 DNS、TCP、TLS 都会消耗资源。
dns-prefetch
只提前进行 DNS 查询:
html
<link rel="dns-prefetch" href="//analytics.example.com">
preload
提前加载当前页面一定会使用的关键资源:
html
<link
rel="preload"
href="/assets/hero.avif"
as="image"
type="image/avif"
fetchpriority="high"
>
错误使用 preload 可能会抢占其他关键资源带宽,因此只能用于少量、确定需要的资源。
prefetch
低优先级获取未来可能使用的资源:
html
<link rel="prefetch" href="/assets/next-page.js">
浏览器可能根据网络状况忽略预取提示,因此业务逻辑不能依赖资源一定被预取。
7.4 不要机械地合并所有资源
HTTP/2 和 HTTP/3 支持多路复用,不再需要像 HTTP/1.1 时代那样无条件把所有文件合并成一个超大文件。
过度合并可能导致:
- 首屏下载不需要的代码;
- 修改一行代码导致整个大文件缓存失效;
- 无法按路由拆分;
- 解析和执行成本升高。
正确方向是:
- 首屏只加载必要资源;
- 公共依赖合理拆包;
- 页面功能按需加载;
- 避免产生大量细碎且无意义的小请求;
- 通过瀑布图验证真实效果。
8. LCP 优化实战
LCP 优化应按照以下顺序分析:
text
TTFB
↓
LCP 资源发现时间
↓
LCP 资源下载时间
↓
LCP 元素渲染时间
8.1 让 LCP 资源尽早被发现
不推荐通过 JavaScript 动态创建首屏图片:
javascript
const image = new Image();
image.src = '/images/hero.avif';
document.querySelector('.hero').appendChild(image);
浏览器必须先下载并执行 JavaScript,之后才能发现图片。
更好的方式是直接写入 HTML:
html
<img
src="/images/hero.avif"
width="1440"
height="720"
fetchpriority="high"
alt="产品展示"
>
8.2 不要懒加载 LCP 图片
错误示例:
html
<img
src="/images/hero.avif"
loading="lazy"
alt="首屏主图"
>
首屏图片尤其是 LCP 图片,应使用默认的立即加载行为,并可以根据测量结果设置:
html
<img
src="/images/hero.avif"
width="1440"
height="720"
fetchpriority="high"
alt="首屏主图"
>
不要给大量图片设置 fetchpriority="high",否则所有资源都变成高优先级,也就失去了优先级提示的意义。
8.3 减少 LCP 元素渲染延迟
即使 LCP 图片已经下载完成,也可能因为以下原因无法显示:
- 主线程正在执行大型 JavaScript;
- 元素默认被设置为
display: none; - 页面等待接口完成后才整体显示;
- 客户端渲染尚未完成;
- Web 字体阻塞文本绘制;
- CSS 动画延迟元素出现。
不推荐:
css
.page {
opacity: 0;
}
.page.ready {
opacity: 1;
}
如果 .ready 必须等待整个应用初始化完成才添加,那么首屏内容也会被一起延迟。
应当优先展示已经可以展示的页面结构和主要内容,再逐步增强交互功能。
9. 图片性能优化
图片往往是页面中占用带宽最多的资源。
9.1 选择合适的图片格式
| 格式 | 适用场景 |
|---|---|
| JPEG | 普通照片,兼容性好 |
| PNG | 需要透明背景或无损细节的图片 |
| WebP | 照片、透明图、动图,压缩效率较好 |
| AVIF | 对体积要求较高的照片和复杂图片 |
| SVG | 图标、Logo、简单矢量图形 |
格式选择不能只看扩展名,还要比较:
- 文件体积;
- 解码开销;
- 清晰度;
- 浏览器兼容性;
- 是否需要透明或动画。
9.2 使用响应式图片
不要让宽度只有 390 像素的手机直接下载一张 3000 像素宽的图片。
html
<img
src="/images/product-800.webp"
srcset="
/images/product-480.webp 480w,
/images/product-800.webp 800w,
/images/product-1200.webp 1200w
"
sizes="
(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
600px
"
width="1200"
height="800"
alt="商品图片"
>
srcset 描述候选图片,sizes 告诉浏览器图片在不同视口中的预计显示宽度。
9.3 使用 picture 提供多种格式
html
<picture>
<source
srcset="/images/banner.avif"
type="image/avif"
>
<source
srcset="/images/banner.webp"
type="image/webp"
>
<img
src="/images/banner.jpg"
width="1440"
height="720"
fetchpriority="high"
alt="活动 Banner"
>
</picture>
9.4 懒加载首屏外图片
html
<img
src="/images/recommendation.webp"
width="600"
height="400"
loading="lazy"
decoding="async"
alt="推荐内容"
>
适合懒加载的内容包括:
- 首屏之外的图片;
- 长列表中的商品图;
- 评论区头像;
- 页脚图片;
- 非首屏 iframe。
不要懒加载:
- LCP 图片;
- 首屏关键 Logo;
- 首屏立即可见的商品主图。
9.5 始终声明图片尺寸
html
<img
src="/images/product.webp"
width="800"
height="600"
alt="商品图片"
>
响应式布局可以继续使用:
css
img {
max-width: 100%;
height: auto;
}
HTML 中的 width 和 height 可以帮助浏览器提前计算宽高比、预留空间,从而减少 CLS。
10. CSS 性能优化
10.1 CSS 为什么会阻塞渲染
浏览器通常需要完成以下过程:
text
解析 HTML
+
解析 CSS
↓
构建 DOM 和 CSSOM
↓
生成渲染树
↓
布局和绘制
如果关键 CSS 下载很慢,页面可能长时间无法正常绘制。
10.2 删除无用 CSS
大型组件库、历史样式和重复样式可能导致 CSS 文件不断膨胀。
可以通过构建工具分析:
- 哪些 CSS 没有被页面使用;
- 哪些组件样式被全量引入;
- 是否存在重复规则;
- 是否可以按页面拆分;
- 是否存在仅后台页面使用但首页也加载的样式。
删除 CSS 时要注意动态类名,例如:
javascript
const className = `status-${status}`;
静态扫描工具可能无法识别这类动态生成的类名,需要配置保留列表。
10.3 谨慎内联关键 CSS
将少量首屏关键 CSS 内联到 HTML 中,可以减少关键请求链。
html
<style>
.page-header {
min-height: 64px;
display: flex;
align-items: center;
}
.hero {
aspect-ratio: 2 / 1;
background: #f5f7fa;
}
</style>
但不要把整个 CSS 文件都内联,否则会:
- 增大 HTML;
- 影响 CSS 缓存复用;
- 增加服务端输出体积;
- 增加内容安全策略管理难度。
10.4 优化动画属性
优先使用:
css
.card {
transition:
transform 200ms ease,
opacity 200ms ease;
}
.card:hover {
transform: translateY(-4px);
opacity: 0.95;
}
谨慎使用会频繁触发布局的属性:
css
.card:hover {
top: -4px;
width: 420px;
}
transform 和 opacity 通常更适合动画,但不代表所有此类动画都没有成本。
will-change 也不能全局滥用:
css
.animating-element {
will-change: transform;
}
长期给大量元素设置 will-change 会增加内存和合成层成本。应只在确实需要时使用,并在动画结束后移除。
10.5 延迟渲染屏幕外内容
对于很长的页面,可以尝试:
css
.article-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
content-visibility: auto 可以让浏览器暂时跳过屏幕外内容的布局和绘制。
使用前需要验证:
- 滚动体验;
- 页面内搜索;
- 锚点跳转;
- 辅助技术表现;
- 目标浏览器兼容性。
11. Web 字体优化
字体文件可能同时影响 LCP 和 CLS。
11.1 只加载实际需要的字体
不要一次加载:
- 多个字体家族;
- 所有字重;
- 所有字符集;
- 页面根本没有使用的斜体字体。
例如页面只使用 400 和 600 字重,就不应加载 100~900 的所有独立字体文件。
11.2 使用 WOFF2
WOFF2 通常是现代 Web 字体的优先选择。
css
@font-face {
font-family: "AppSans";
src: url("/fonts/app-sans-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
11.3 合理设置 font-display
常见取值包括:
swap:先显示后备字体,再替换为 Web 字体;optional:网络条件不好时可能继续使用后备字体;fallback:在短时间等待后使用后备字体。
font-display: swap 可以减少文字长时间不可见的问题,但字体切换时可能产生布局变化。
因此还需要:
- 选择尺寸接近的后备字体;
- 使用字体度量覆盖属性;
- 只预加载首屏真正需要的字体;
- 对字体进行子集化。
11.4 预加载关键字体
html
<link
rel="preload"
href="/fonts/app-sans-regular.woff2"
as="font"
type="font/woff2"
crossorigin
>
即使字体和页面同源,字体预加载通常也需要正确处理 crossorigin,否则可能出现重复请求。
不要预加载所有字体,只预加载首屏确定需要的字体。
12. JavaScript 加载与体积优化
JavaScript 的性能成本不只是下载。
浏览器还需要:
text
下载
↓
解压
↓
解析
↓
编译
↓
执行
↓
创建对象和事件
↓
触发框架渲染
相同体积的 JavaScript 和图片,对低端设备造成的影响完全不同。
图片主要消耗网络和解码资源,而 JavaScript 还会占用主线程。
12.1 正确使用 async、defer 和 module
普通脚本:
html
<script src="/js/app.js"></script>
可能阻塞 HTML 解析。
使用 defer:
html
<script src="/js/app.js" defer></script>
特点:
- 与 HTML 并行下载;
- 等 HTML 解析完成后执行;
- 多个
defer脚本通常按文档顺序执行。
使用 async:
html
<script src="/js/analytics.js" async></script>
特点:
- 与 HTML 并行下载;
- 下载完成后尽快执行;
- 多个脚本执行顺序不确定。
适合独立统计脚本,不适合存在执行顺序依赖的业务脚本。
ES Module:
html
<script type="module" src="/js/app.js"></script>
模块脚本默认具有类似 defer 的行为。
12.2 按路由拆分代码
javascript
const routes = [
{
path: '/dashboard',
load: () => import('./pages/dashboard.js')
},
{
path: '/settings',
load: () => import('./pages/settings.js')
}
];
用户访问首页时,不应该下载后台管理页面的全部代码。
12.3 按功能懒加载
javascript
document
.querySelector('#open-editor')
.addEventListener('click', async () => {
const { createEditor } = await import('./editor.js');
createEditor();
});
适合懒加载:
- 富文本编辑器;
- 图表库;
- 地图 SDK;
- PDF 预览器;
- 视频编辑器;
- 不常使用的弹窗功能。
12.4 Tree Shaking
推荐使用 ES Module 的具名导入:
javascript
import { debounce } from './utils.js';
避免无条件引入整个大型工具库:
javascript
import utils from './all-utils.js';
Tree Shaking 能否生效还取决于:
- 依赖是否使用 ES Module;
- 构建工具配置;
- 包是否声明副作用;
- 代码是否存在难以静态分析的动态行为。
不能只看到"开启 Tree Shaking"就认为无用代码已经被删除,应检查最终产物。
12.5 避免重复依赖
常见问题包括:
- 主项目和子包加载不同版本的相同库;
- 多个组件库分别携带相同工具库;
- 同一个 polyfill 被重复注入;
- 服务端渲染包和客户端包边界不清;
- monorepo 依赖解析导致重复打包。
应使用构建产物分析工具查看依赖构成,而不是只看源码目录大小。
12.6 将大型计算移出主线程
Web Worker 适合:
- 大量数据计算;
- 文件解析;
- 压缩和解压;
- 图像处理;
- 加密计算;
- 大型 JSON 转换。
主线程:
javascript
const worker = new Worker(
new URL('./calculate.worker.js', import.meta.url),
{ type: 'module' }
);
worker.postMessage({ records });
worker.addEventListener('message', event => {
renderResult(event.data);
});
Worker:
javascript
self.addEventListener('message', event => {
const { records } = event.data;
const result = calculateStatistics(records);
self.postMessage(result);
});
Web Worker 无法直接操作 DOM,数据传输也有成本,因此不适合所有任务。
13. INP 与主线程优化
13.1 什么是长任务
浏览器主线程除了执行 JavaScript,还需要处理:
- 用户输入;
- 样式计算;
- 页面布局;
- 绘制;
- 垃圾回收;
- 框架更新。
当一个任务执行时间超过 50 毫秒时,通常会被视为长任务。
如果用户恰好在长任务执行期间点击按钮,交互就必须等待。
13.2 拆分长任务
不推荐一次处理全部数据:
javascript
function processAll(items) {
for (const item of items) {
performExpensiveWork(item);
}
}
可以分批处理并主动让出主线程:
javascript
async function yieldToMain() {
if (
'scheduler' in window &&
typeof window.scheduler.yield === 'function'
) {
await window.scheduler.yield();
return;
}
await new Promise(resolve => {
setTimeout(resolve, 0);
});
}
async function processInChunks(items, chunkSize = 100) {
for (let index = 0; index < items.length; index += chunkSize) {
const chunk = items.slice(index, index + chunkSize);
for (const item of chunk) {
performExpensiveWork(item);
}
await yieldToMain();
}
}
分片大小需要通过真实设备测试确定。
分片太大会继续阻塞主线程,分片太小则会增加调度开销。
13.3 尽早提供视觉反馈
用户点击"提交"后,应尽快显示状态:
javascript
submitButton.addEventListener('click', async () => {
submitButton.disabled = true;
submitButton.textContent = '提交中...';
await yieldToMain();
try {
await submitOrder();
showSuccessMessage();
} finally {
submitButton.disabled = false;
submitButton.textContent = '提交订单';
}
});
先更新按钮状态,可以让用户更快知道操作已经生效。
13.4 减少单次渲染规模
不推荐一次渲染一万个列表节点:
javascript
for (const item of items) {
list.appendChild(createItemElement(item));
}
可以使用:
- 分页;
- 虚拟列表;
- 分批渲染;
- 服务端筛选;
- 增量加载;
DocumentFragment;- 框架提供的窗口化组件。
13.5 避免频繁强制同步布局
不推荐交替读取和修改布局:
javascript
for (const element of elements) {
element.style.width = `${container.offsetWidth / 2}px`;
}
读取 offsetWidth 后立即修改样式,可能反复触发布局计算。
可以先统一读取,再统一写入:
javascript
const containerWidth = container.offsetWidth;
const targetWidth = `${containerWidth / 2}px`;
for (const element of elements) {
element.style.width = targetWidth;
}
13.6 谨慎处理高频事件
滚动、拖动、鼠标移动和输入事件可能高频触发。
javascript
let scheduled = false;
window.addEventListener(
'scroll',
() => {
if (scheduled) {
return;
}
scheduled = true;
requestAnimationFrame(() => {
updateStickyHeader();
scheduled = false;
});
},
{ passive: true }
);
passive: true 适合不需要调用 preventDefault() 的触摸和滚动监听器。
防抖和节流也不能无条件使用。输入框搜索可以防抖,但点击反馈不能因为防抖而被延迟。
14. 渲染性能优化
浏览器渲染大致包括:
text
JavaScript
↓
Style
↓
Layout
↓
Paint
↓
Composite
14.1 减少 DOM 规模
DOM 过大会增加:
- HTML 解析时间;
- 样式匹配时间;
- 布局计算时间;
- 内存占用;
- 框架更新成本。
常见问题:
- 无意义的多层包装元素;
- 被隐藏但仍然完整渲染的大型弹窗;
- 一次性渲染全部列表;
- 重复保留已经不用的 DOM;
- 组件层级映射成过深的真实 DOM。
14.2 限制布局影响范围
可以通过 CSS Containment 限制部分计算影响:
css
.product-card {
contain: layout paint;
}
或者:
css
.virtual-list-item {
contain: layout style paint;
}
使用 contain 可能改变定位、尺寸计算和绘制行为,需要逐个组件验证。
14.3 使用 requestAnimationFrame 更新视觉状态
javascript
function updatePosition(element, x, y) {
requestAnimationFrame(() => {
element.style.transform = `translate(${x}px, ${y}px)`;
});
}
requestAnimationFrame 会在下一帧绘制前执行回调,更适合视觉更新。
它不是通用的后台任务队列,也不会自动让复杂计算变快。
14.4 控制绘制区域
需要重点关注:
- 大面积阴影;
- 大范围模糊滤镜;
- 半透明层叠;
- 固定背景;
- 大面积渐变动画;
- 频繁变化的复杂 SVG;
- 覆盖整个页面的动画图层。
这些效果不一定禁止使用,但应该通过 Performance 面板和低端设备测试其真实成本。
15. CLS 与页面稳定性优化
15.1 为媒体元素声明尺寸
html
<img
src="/images/article-cover.webp"
width="1200"
height="630"
alt="文章封面"
>
视频和 iframe 也应该预留比例:
css
.video-container {
aspect-ratio: 16 / 9;
background: #111;
}
.video-container iframe {
width: 100%;
height: 100%;
border: 0;
}
15.2 为动态内容预留空间
css
.ad-container {
min-height: 250px;
background: #f5f5f5;
}
即使广告最终没有返回,也要谨慎处理预留空间。
突然删除占位区域同样可能造成布局偏移。
15.3 骨架屏尺寸要接近真实内容
错误做法:
text
骨架屏高度:200px
真实内容高度:700px
数据加载完成时,后续内容会整体下移。
正确做法是让骨架屏尽可能模拟:
- 图片比例;
- 标题行数;
- 正文高度;
- 按钮位置;
- 卡片间距。
15.4 不要在页面顶部突然插入内容
例如页面加载完成后突然在顶部插入公告条,会把整个页面向下推。
可以:
- 首次渲染时预留公告位置;
- 将公告设计为覆盖层;
- 在用户主动操作后插入;
- 将非关键动态内容放在首屏以下。
15.5 动画优先使用 transform
不推荐:
css
.notice {
transition: top 200ms ease;
}
.notice.open {
top: 0;
}
推荐:
css
.notice {
transform: translateY(-100%);
transition: transform 200ms ease;
}
.notice.open {
transform: translateY(0);
}
16. SPA、SSR、SSG 与水合性能
16.1 纯客户端渲染的性能问题
典型客户端渲染链路:
text
下载 HTML 空壳
↓
下载 JavaScript
↓
解析和执行 JavaScript
↓
请求接口
↓
生成页面 DOM
↓
显示主要内容
如果 JavaScript 包很大,弱网和低端设备可能长时间白屏。
16.2 SSR
服务端渲染可以更早返回页面 HTML,使浏览器更早发现:
- 文本内容;
- CSS;
- LCP 图片;
- 页面链接。
但 SSR 并不一定自动更快。
SSR 仍然可能存在:
- 服务端 TTFB 过高;
- HTML 体积过大;
- 客户端水合 JavaScript 过多;
- 水合期间主线程阻塞;
- 服务端和客户端内容不一致;
- 页面看起来可用,但交互尚未完成。
16.3 SSG
静态站点生成在构建阶段产生 HTML,适合:
- 博客;
- 文档;
- 新闻;
- 产品介绍页;
- 更新频率不高的公共页面。
它通常能够减少运行时服务端计算,但需要处理:
- 内容更新;
- 增量构建;
- 缓存刷新;
- 动态个性化;
- 大量页面的构建时间。
16.4 部分水合与岛屿架构
不是所有页面内容都需要立即水合。
例如一篇文章中:
- 标题和正文只需要静态 HTML;
- 评论区可以进入视口后再加载;
- 分享按钮可以在空闲阶段增强;
- 在线编辑器可以在用户点击后加载。
理想目标是:
text
默认发送可展示的 HTML
+
只为真正需要交互的区域加载 JavaScript
16.5 避免重复请求数据
服务端已经获取并渲染的数据,不应该在客户端水合时无条件再次请求。
可以将必要的初始数据安全地序列化到页面中:
html
<script type="application/json" id="initial-data">
{
"productId": "P10001",
"name": "示例商品"
}
</script>
读取时仍然应当进行类型和字段校验,不能因为数据来自服务端模板就完全信任。
17. 浏览器缓存与 CDN 优化
17.1 带内容哈希的静态资源
构建产物推荐使用内容哈希:
text
app.8fd31a2c.js
style.170f20d1.css
logo.31a87ed2.svg
资源内容变化后,文件名也会变化,因此旧文件可以设置长期缓存:
http
Cache-Control: public, max-age=31536000, immutable
17.2 HTML 缓存策略
HTML 文件通常不能像带哈希的静态资源一样缓存一年。
可以使用:
http
Cache-Control: no-cache
no-cache 并不表示完全不存储,而是表示再次使用前需要向服务器验证。
如果页面包含高度敏感或用户专属内容,可以根据业务使用:
http
Cache-Control: private, no-store
具体策略应根据内容是否公共、是否个性化以及是否允许中间缓存决定。
17.3 使用 ETag 或 Last-Modified
浏览器可以进行条件请求:
http
If-None-Match: "asset-version-123"
资源未变化时,服务器返回:
http
HTTP/1.1 304 Not Modified
这样可以避免重新传输完整响应体。
17.4 stale-while-revalidate
对于允许短时间使用旧内容的公共资源,可以考虑:
http
Cache-Control: public, max-age=300, stale-while-revalidate=60
含义是:
- 资源在 300 秒内保持新鲜;
- 过期后的 60 秒内可以先返回旧内容;
- 同时在后台重新验证。
是否适用取决于业务对数据一致性的要求。
余额、库存、支付状态等敏感数据不能机械套用该策略。
17.5 文本资源压缩
适合压缩:
- HTML;
- CSS;
- JavaScript;
- JSON;
- SVG;
- XML。
常见算法:
- Brotli;
- Gzip。
已经经过压缩的 JPEG、WebP、AVIF、MP4、ZIP 等文件,再进行 Gzip 通常收益很小。
17.6 CDN 优化
CDN 可以:
- 缩短资源与用户之间的物理距离;
- 缓存静态资源;
- 减少源站压力;
- 提高并发能力;
- 降低跨区域网络抖动。
CDN 优化时需要检查:
- 缓存命中率;
- 回源比例;
- 缓存键是否包含无意义参数;
- Cookie 是否导致公共资源无法缓存;
Vary是否造成缓存碎片;- 节点是否覆盖主要用户区域;
- 缓存刷新机制是否可靠;
- HTML 和静态资源是否使用了不同策略。
18. 第三方脚本优化
第三方脚本通常包括:
- 数据统计;
- 广告;
- 在线客服;
- 地图;
- 支付 SDK;
- A/B 测试;
- 用户行为录制;
- 社交分享;
- 推荐系统。
第三方脚本可能带来:
- DNS、TCP、TLS 连接;
- JavaScript 下载;
- 主线程解析和执行;
- 长任务;
- 隐私和安全风险;
- 单点故障;
- 页面布局偏移。
18.1 建立第三方脚本清单
| 脚本 | 负责人 | 是否首屏需要 | 体积 | 主线程耗时 | 故障影响 |
|---|---|---|---|---|---|
| 统计 SDK | 数据团队 | 否 | 80 KB | 60 ms | 低 |
| 在线客服 | 客服团队 | 否 | 350 KB | 220 ms | 中 |
| 支付 SDK | 支付团队 | 结算页需要 | 180 KB | 100 ms | 高 |
如果没有负责人,也无法说明业务价值,这个第三方脚本就应该被重新评估。
18.2 延迟非关键脚本
javascript
async function loadCustomerService() {
const module = await import('./customer-service.js');
module.initialize();
}
document
.querySelector('#customer-service-button')
.addEventListener('click', loadCustomerService, { once: true });
在线客服不一定需要在用户打开首页的第一毫秒加载。
18.3 设置超时和降级
第三方接口不能无限阻塞页面核心流程。
核心内容和主按钮应尽量不依赖第三方统计、推荐或客服脚本是否成功。
19. 前端性能监控与 RUM
19.1 使用 web-vitals 采集指标
安装:
bash
npm install web-vitals
采集:
javascript
import {
onCLS,
onINP,
onLCP
} from 'web-vitals/attribution';
function sendMetric(metric) {
const payload = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
route: getRouteTemplate(),
release: window.APP_RELEASE
});
const sent = navigator.sendBeacon('/api/rum', payload);
if (!sent) {
fetch('/api/rum', {
method: 'POST',
body: payload,
keepalive: true,
headers: {
'Content-Type': 'application/json'
}
}).catch(() => {
// 性能上报失败不能影响用户正常使用
});
}
}
onLCP(sendMetric);
onINP(sendMetric);
onCLS(sendMetric);
19.2 不要直接上报完整 URL
以下 URL 可能包含敏感信息或造成高基数:
text
/order/100001?token=secret
/user/987654/profile
建议转换成路由模板:
text
/order/:orderId
/user/:userId/profile
同时不要上报:
- 用户输入内容;
- Access Token;
- Cookie;
- 身份证号;
- 手机号;
- 完整接口响应;
- 未脱敏的用户 ID。
19.3 性能数据需要分组
建议按照以下维度分析:
- 页面路由;
- 应用版本;
- 设备类型;
- 操作系统;
- 浏览器;
- 网络类型;
- 地理区域;
- 新用户和老用户;
- 登录和未登录;
- 首次访问和站内跳转。
平均值可能掩盖长尾问题。
例如:
text
平均 LCP:2.1 秒
第 75 百分位:3.8 秒
第 95 百分位:8.2 秒
平均值看起来正常,但大量用户的体验实际上很差。
19.4 将发布版本与性能数据关联
javascript
window.APP_RELEASE = 'web-2026.08.27.1';
有了版本号,就可以判断:
- 哪次发布后 LCP 变慢;
- 某个 JavaScript 包升级是否导致 INP 回退;
- 某个页面重构是否降低 CLS;
- 回滚后指标是否恢复。
19.5 采样与数据量控制
高流量网站不一定要上报所有访问。
可以进行稳定采样:
javascript
const shouldReport = Math.random() < 0.1;
if (shouldReport) {
onLCP(sendMetric);
onINP(sendMetric);
onCLS(sendMetric);
}
采样比例应记录在分析系统中,并保证重要页面和异常场景仍有足够样本。
20. 使用 Lighthouse CI 建立性能门禁
性能优化不能只依赖人工检查。
可以使用 Lighthouse CI 在每次构建或合并请求中执行测试。
20.1 安装 Lighthouse CI
bash
npm install --save-dev @lhci/cli
20.2 配置 lighthouserc.json
json
{
"ci": {
"collect": {
"startServerCommand": "npm run preview",
"startServerReadyPattern": "Local",
"url": [
"http://localhost:4173/",
"http://localhost:4173/products"
],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": [
"warn",
{
"minScore": 0.9
}
],
"largest-contentful-paint": [
"error",
{
"maxNumericValue": 2500
}
],
"total-blocking-time": [
"error",
{
"maxNumericValue": 200
}
],
"cumulative-layout-shift": [
"error",
{
"maxNumericValue": 0.1
}
],
"resource-summary:script:size": [
"error",
{
"maxNumericValue": 307200
}
]
}
}
}
}
这里 resource-summary:script:size 使用字节作为单位,307200 约等于 300 KB。
20.3 配置执行命令
json
{
"scripts": {
"build": "vite build",
"preview": "vite preview",
"perf": "npm run build && lhci autorun"
}
}
运行:
bash
npm run perf
20.4 为什么要运行多次
Lighthouse 会受到以下因素影响:
- CI 机器负载;
- CPU 调度;
- 网络波动;
- 浏览器后台任务;
- 页面随机内容;
- 接口响应变化。
建议:
- 多次运行;
- 固定测试环境;
- 尽量使用稳定测试数据;
- 对容易波动的分数设置警告;
- 对资源体积、请求数量等确定性指标设置错误门禁。
性能门禁的目标不是让构建频繁误报,而是及时阻止明显的性能回退。
21. Web 性能优化实战案例
下面以一个电商首页为例。
以下数据仅用于展示优化过程,不代表所有项目都能获得相同结果。
21.1 优化前
| 指标 | 优化前 |
|---|---|
| TTFB | 1.2 秒 |
| LCP | 4.8 秒 |
| INP | 380 毫秒 |
| CLS | 0.24 |
| 首屏 JavaScript | 820 KB |
| 首屏图片 | 1.9 MB |
| 请求数量 | 126 |
| 长任务数量 | 11 |
21.2 问题分析
通过 Network 和 Performance 面板发现:
- 首页接口串行调用三个下游服务;
- 首屏 Banner 使用 2600 像素宽的 PNG;
- Banner 地址需要执行 JavaScript 后才能获得;
- 首屏 Banner 被错误设置为
loading="lazy"; - 首页加载了后台编辑器和图表库;
- 在线客服 SDK 在页面初始化阶段执行;
- 商品列表一次渲染 500 个节点;
- 图片没有声明宽高;
- Web 字体加载了六个字重;
- 静态资源没有长期缓存。
21.3 第一阶段:优化后端和首屏链路
实施:
- 并行调用互不依赖的服务;
- 缓存公共推荐数据;
- 删除一次不必要的重定向;
- 在初始 HTML 中直接输出 Banner;
- 为 Banner 设置
fetchpriority="high"; - 移除 Banner 的懒加载。
结果:
text
TTFB:1.2 秒 → 0.45 秒
LCP:4.8 秒 → 3.1 秒
21.4 第二阶段:优化图片
实施:
- PNG 转换为 AVIF 和 WebP;
- 使用
picture提供回退; - 为移动端生成较小尺寸;
- 使用
srcset和sizes; - 为图片声明宽高;
- 首屏外图片使用原生懒加载。
结果:
text
首屏图片:1.9 MB → 620 KB
LCP:3.1 秒 → 2.3 秒
CLS:0.24 → 0.08
21.5 第三阶段:优化 JavaScript
实施:
- 编辑器改为点击后动态导入;
- 图表库只在数据分析页面加载;
- 在线客服改为用户点击后加载;
- 删除重复工具库;
- 将首页脚本按页面拆分;
- 商品列表改为分页和虚拟滚动。
结果:
text
首屏 JavaScript:820 KB → 310 KB
长任务:11 个 → 3 个
INP:380 毫秒 → 165 毫秒
21.6 第四阶段:优化缓存和字体
实施:
- 带哈希静态资源设置一年缓存;
- HTML 使用协商缓存;
- 字体从六个字重减少到两个;
- 字体改为 WOFF2;
- 只预加载首屏字体;
- CDN 缓存键移除无意义查询参数。
结果:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| TTFB | 1.2 秒 | 0.45 秒 |
| LCP | 4.8 秒 | 1.9 秒 |
| INP | 380 毫秒 | 150 毫秒 |
| CLS | 0.24 | 0.05 |
| 首屏 JavaScript | 820 KB | 310 KB |
| 首屏图片 | 1.9 MB | 620 KB |
| 请求数量 | 126 | 61 |
| 长任务数量 | 11 | 2~3 |
21.7 案例中的关键经验
优化收益并不是来自某一个"性能技巧",而是来自完整链路:
text
后端响应
+
资源发现
+
资源优先级
+
资源体积
+
JavaScript 执行
+
DOM 渲染
+
缓存策略
如果只压缩图片,而 LCP 图片仍然必须等待 JavaScript 执行后才被发现,收益可能非常有限。
22. 常见性能问题排查方法
| 现象 | 优先检查 | 常见解决方向 |
|---|---|---|
| 页面长时间白屏 | TTFB、关键 CSS、主包 JS | 服务端缓存、减少阻塞资源、SSR 或预渲染 |
| LCP 图片很晚才请求 | Network Initiator、HTML 源码 | 直接写入 HTML、preload、fetchpriority |
| 图片下载完仍不显示 | Performance 主线程、元素样式 | 拆分长任务、取消整体隐藏、减少客户端渲染依赖 |
| 点击按钮没有反馈 | INP 分阶段、Long Task | 尽早更新 UI、拆分任务、减少事件回调工作 |
| 输入框明显卡顿 | 输入处理、列表渲染 | 延迟非关键计算、虚拟列表、Web Worker |
| 页面不断跳动 | Layout Shift 轨迹 | 图片尺寸、广告占位、字体度量、骨架屏 |
| 首次访问慢 | 冷缓存 Network | 减少首屏资源、压缩、CDN、关键资源优先级 |
| 二次访问仍然慢 | Size、Cache-Control | 长期缓存、内容哈希、协商缓存 |
| 手机慢但电脑正常 | CPU 节流、真实设备 | 减少 JS、长任务和 DOM,延迟第三方脚本 |
| 某地区访问慢 | RUM 地域数据、CDN | 调整节点、减少跨区回源、检查 DNS |
| 发布后突然变慢 | 版本维度、构建产物 | 对比资源体积、依赖版本和长任务 |
| Lighthouse 波动较大 | 多次运行结果 | 固定环境、多次运行、关注确定性预算 |
排查时建议遵循:
text
先确认问题
↓
找到具体指标
↓
定位指标的组成部分
↓
确定具体资源或任务
↓
一次只修改一个关键变量
↓
重新测量
23. Web 性能上线检查清单
23.1 HTML
- 页面不存在无意义的重定向链;
- 首屏关键内容存在于初始 HTML;
- LCP 图片能够从 HTML 中直接发现;
- 页面声明正确的字符编码;
- 没有重复加载相同资源。
23.2 图片
- 图片选择了合适格式;
- 使用响应式图片;
- 图片声明宽高;
- 首屏外图片使用懒加载;
- LCP 图片没有使用懒加载;
- 只给少量关键图片设置高优先级;
- 移动端没有下载明显过大的桌面图片。
23.3 CSS
- 删除无用样式;
- 没有全量引入不需要的组件样式;
- 关键 CSS 体积可控;
- 动画优先使用
transform和opacity; - 没有滥用
will-change; - 长页面已评估延迟渲染方案。
23.4 JavaScript
- 主包体积符合预算;
- 页面按路由拆包;
- 大型功能按需加载;
- 没有重复依赖;
- 没有明显长任务;
- 大型计算已评估 Web Worker;
- 第三方脚本不会阻塞核心内容;
- 低端移动设备完成过测试。
23.5 字体
- 使用 WOFF2;
- 只加载实际使用的字体和字重;
- 字体进行了合理子集化;
-
font-display策略合理; - 只预加载关键字体;
- 字体替换不会造成明显布局偏移。
23.6 缓存和 CDN
- 静态资源文件名包含内容哈希;
- 带哈希资源配置长期缓存;
- HTML 使用适合业务的缓存策略;
- 文本资源启用 Brotli 或 Gzip;
- CDN 缓存命中率正常;
- 不必要的 Cookie 和查询参数不会破坏缓存;
- CDN 回源和缓存刷新经过验证。
23.7 性能监控
- 已采集 LCP、INP 和 CLS;
- 指标包含页面和发布版本;
- 不采集敏感信息;
- 使用第 75 百分位分析;
- 能按设备、网络、浏览器和地域分组;
- 发布后具有性能告警;
- CI 中配置性能预算。
24. 常见问题
24.1 Lighthouse 100 分是否代表性能没有问题?
不代表。
Lighthouse 是实验室测试,结果受到测试环境、页面状态和工具规则影响。
真实用户可能使用:
- 更慢的手机;
- 更差的网络;
- 不同浏览器;
- 更复杂的数据;
- 不同登录状态;
- 更长的交互流程。
应该同时观察 Lighthouse、Chrome DevTools、资源预算和 RUM 数据。
24.2 请求越少越好吗?
不一定。
减少无意义请求通常有帮助,但不能为了减少请求,把所有代码合并成一个超大文件。
需要平衡:
- 首屏资源数量;
- 单个资源体积;
- 缓存复用;
- 按需加载;
- 请求优先级;
- HTTP/2 或 HTTP/3 多路复用。
24.3 使用 CDN 后为什么仍然很慢?
可能原因包括:
- HTML TTFB 仍然很高;
- CDN 缓存没有命中;
- 缓存键配置错误;
- 资源携带 Cookie;
- 首屏 JavaScript 过大;
- LCP 资源发现时间太晚;
- 用户设备执行 JavaScript 很慢;
- CDN 节点与主要用户区域不匹配。
CDN 主要优化网络分发,不能自动解决主线程和渲染问题。
24.4 图片已经很小,为什么 LCP 仍然很高?
可能是:
- TTFB 高;
- 图片请求开始得太晚;
- 图片被懒加载;
- 图片优先级低;
- 图片需要等待 JavaScript 才能发现;
- 主线程长任务阻塞绘制;
- 元素被 CSS 隐藏;
- 客户端渲染完成得太晚。
应该分析完整的 LCP 四阶段,而不是只看文件体积。
24.5 SSR 是否一定比 SPA 快?
不一定。
SSR 可以更早提供 HTML,但也可能存在:
- 服务端响应慢;
- 水合成本高;
- JavaScript 体积仍然很大;
- 页面可见但不可交互;
- 服务端和客户端重复请求;
- 水合不一致。
需要根据内容类型、用户访问路径和真实测量结果选择架构。
24.6 防抖是否能够解决所有 INP 问题?
不能。
防抖适合减少高频搜索、校验或接口请求,但会主动延迟回调执行。
如果按钮点击本来就应该立即反馈,使用防抖反而可能让体验更差。
INP 优化的核心是:
- 减少输入延迟;
- 缩短事件回调;
- 减少布局和绘制成本;
- 尽快产生下一次视觉反馈。
24.7 是否应该预加载所有重要资源?
不应该。
preload 会提升资源调度优先级。预加载过多资源会互相竞争,并挤占真正关键资源的带宽。
通常只预加载:
- LCP 图片;
- 首屏关键字体;
- 首屏必须使用但发现较晚的资源。
任何预加载都应该通过 Network 瀑布图验证。
25. 总结
Web 性能优化不是一次性工作,而是一套持续治理流程。
完整的性能优化闭环应该是:
text
建立指标
↓
采集真实用户数据
↓
识别性能最差的页面和用户群体
↓
使用实验室工具复现
↓
分析网络、主线程和渲染瓶颈
↓
实施小范围优化
↓
验证优化效果
↓
设置性能预算和 CI 门禁
↓
发布后持续监控
在实际项目中,可以优先处理以下问题:
- 降低 TTFB;
- 让 LCP 资源尽早被浏览器发现;
- 优化首屏图片格式、尺寸和优先级;
- 减少首屏 JavaScript;
- 拆分主线程长任务;
- 为图片、广告和异步内容预留空间;
- 延迟加载非关键功能和第三方脚本;
- 配置正确的浏览器缓存和 CDN;
- 建立真实用户性能监控;
- 使用性能预算防止版本回退。
性能优化最重要的原则是:
不凭感觉优化,不只看单次跑分,不把技巧当目标。先测量真实问题,再优化关键瓶颈,最后用持续监控证明效果。