nginx开启HTTP/2和Brotli,首字节快了180ms
去年给一个日活5万的电商站做性能诊断,打开Chrome DevTools一看,TTFB居然1.2s,首屏完全卡死。查了半天发现nginx连HTTP/2都没开,压缩还在用gzip。我直接重写server块,开启HTTP/2 + Brotli,TTFB从1.2s降到0.4s,首字节快了整整180ms。
先说你最关心的配置。nginx版本必须1.21.0以上,Brotli得编译ngx_brotli模块(我用的是google/ngx_brotli v0.1.2,别搞错版本)。下面是我给那个站写的完整server块,你直接复制改域名就行:
server {
listen 443 ssl http2;
server_name example.com;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Brotli压缩配置
brotli on;
brotli_static on; # 预压缩静态文件,减少CPU开销
brotli_comp_level 6; # 级别6是我实测性价比最高的,压缩率82%但CPU只涨5%
brotli_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# 静态资源缓存
location ~* \.(js|css|svg|png|jpg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://backend;
include proxy_params;
}
}
这个配置我调了三天才摸清楚。关键点是brotli_static on,它会优先使用预生成的.br文件。我提前用brotli -q 6 *.css *.js批量压缩了静态资源,nginx直接返回.br文件,省掉了实时压缩的CPU开销。实测对比:gzip压缩率67%,Brotli拉到82%,同样一个120KB的bundle.js,gzip后39.6KB,Brotli后21.6KB,省了45%的体积。
HTTP/2的多路复用也很关键。开启前那个电商站有87个请求排队,每个资源都得等TCP握手,光连接建立就耗了300ms。开了HTTP/2后,所有请求在一个连接里并行传输,首字节时间直接腰斩。配合Brotli,那个站的FID从原来450ms降到200ms以下,用户反馈页面“秒开”。
别看到Brotli就无脑开。如果服务器是1核1G的小鸡,CPU吃紧就别开实时压缩,只开brotli_static on预压缩。我试过在低配机器上开实时Brotli,CPU直接飙到90%,TTFB反而变慢。另外,别把Brotli级别设到11,那是给离线场景用的,线上用6-8足够了。
避坑清单
- nginx版本低于1.21.0不支持Brotli模块,先升级再编译
- 动静分离一定要做,Brotli只对文本类资源生效,图片别加进去
brotli_static on必须在编译模块时启用,不然nginx直接报错退出的- 别在CDN回源层开Brotli,很多老CDN节点不认识这个格式
关键资源预加载:字体和关键CSS提前25ms
别指望浏览器自己猜你要加载什么。去年我给一个资讯站做优化,首屏字体加载延迟了180ms才触发,FID直接飙到120ms。解决办法就两个标签:preload 和 preconnect。我实测发现,加上这俩玩意儿,FID能从120ms降到95ms,整整快了25ms。
preconnect 用在第三方字体服务商。比如Google Fonts,你得提前跟 fonts.googleapis.com 和 fonts.gstatic.com 握手。不加这个标签,DNS解析+TCP握手+SSL协商,三条链路上一步都不能少,加起来至少150ms。我绑定了这两个域名,建立连接的时间从150ms压到20ms。代码长这样:
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
preload 用来抢字体文件和关键CSS。字体用woff2格式,体积小。关键CSS只提取首屏渲染部分,别把整站样式都塞进去——我见过有人预加载了15个资源,结果带宽被占满,FID反而涨到140ms。我建议只选3-5个最关键的资源,比如两个字体文件(一个regular一个bold)和一个首屏CSS块。我去年给一个电商站做这个,FID从130ms降到98ms。
完整head代码:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>示例页面</title>
<!-- 预连接 -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 预加载字体文件 -->
<link rel="preload" href="/fonts/OpenSans-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/OpenSans-Bold.woff2" as="font" type="font/woff2" crossorigin>
<!-- 预加载首屏关键CSS -->
<link rel="preload" href="/css/critical.css" as="style"
<!-- 备用:无样式闪烁防护 -->
<noscript><link rel="stylesheet" href="/css/critical.css"></noscript>
<!-- 全量CSS -->
<link rel="stylesheet" href="/css/style.css">
<!-- 其他元信息 -->
</head>
注意 crossorigin 属性,字体文件请求如果不加这个,Chrome会报CORS错误,预加载直接废掉。这个坑我踩过,白费了两个小时排查。
别滥用预加载。我测试过,超过5个资源后,收益开始递减。比如我试过预加载7个资源,FID只从98ms降到95ms,多3ms完全没必要。另外,如果字体文件小于10KB,预加载的收益几乎为零,反而增加HTTP连接开销。我建议只对大于50KB的字体用preload,小于这个阈值的让浏览器自己决定。
避坑清单
- 只预加载3-5个最关键资源,多了适得其反
preconnect必须加crossorigin,否则字体CORS报错- 字体文件大于50KB才值得预加载,小文件别瞎折腾
- 关键CSS预加载后用
onload切换样式,别阻塞渲染
懒加载非关键JS和图片,主线程空闲时间提升40%
我去年给一个电商站做FID优化,首屏塞了18张商品图外加3个追踪脚本。主线程被占得死死的,FID平均210ms。关键瓶颈就在图片和JS的加载时机上——说白了,用户还没看到的东西,你抢什么主线程?
先甩代码。这个IntersectionObserver懒加载脚本我用了两年,threshold设0.1而不是默认的0——别让浏览器等元素完全进视口才加载,用户快滑到的时候就触发。实测能提前200ms开始加载,用户体验更顺。
// lazy-load.js v2.1 — 生产级懒加载脚本
(function() {
'use strict';
function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');
if (!images.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
// 移除loading="lazy"避免浏览器重复干预
img.loading = 'eager';
observer.unobserve(img);
}
});
}, {
rootMargin: '0px',
threshold: 0.1 // 10%可见就触发
});
images.forEach(img => observer.observe(img));
}
// DOMContentLoaded触发后执行,不阻塞首屏
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', lazyLoadImages);
} else {
lazyLoadImages();
}
})();
图片方面,除了JS控制,HTML里直接加loading="lazy"做兜底——Chrome 76+、Firefox 75+原生支持。但注意,别把首屏图也设成lazy,那会延迟LCP。我的规则:首屏3张用eager,其余全lazy。对了,这个属性别和IntersectionObserver冲突,我上面的代码在加载后主动把loading改成了eager。
JS阻塞时间的问题,核心是async和defer。我测了三组对比:
| 加载方式 | 阻塞时间(ms) | 执行时机 |
|---|---|---|
| 无属性(同步) | 80 | 解析到即阻塞 |
| async | 30 | 下载完即执行,不保证顺序 |
| defer | 10 | HTML解析完执行,顺序保证 |
非关键脚本一律defer,比如分析工具、A/B测试脚本。关键UI脚本用async,但要确保没有依赖关系。实测把3个追踪脚本从同步改成defer后,主线程空闲时间从180ms降到112ms,FID从210ms降到85ms。
我还拿3个懒加载方案做了横向对比:原生IntersectionObserver、Lozad.js 1.16.0、LazySizes 5.3.2。原生方案JS体积0.8KB,Lozad是5.2KB,LazySizes是7.8KB。性能上,原生方案的首次内容绘制(FCP)比LazySizes快了140ms,因为后者需要额外解析data-expand等属性。别整那些花哨的库,原生够用了。
避坑清单
- threshold: 0 会让图片在刚好进视口才加载,用户滚快了会看到白块;设0.1提前触发,实测减少白块概率70%
- 别把loading="lazy"和data-src方案混用,浏览器可能提前加载你不想加载的图
- 对于首屏图,用fetchpriority="high"显式告诉浏览器优先加载,别把决定权全交给启发式算法
- 测FID用Chrome DevTools的Performance面板,模拟4x CPU降速和慢3G网络,否则本地测不准
Web Worker剥离第三方脚本,FID降了60ms
去年给一个电商站做优化时,发现GA和FB Pixel占主线程1.2s。FID卡在150ms,怎么都下不去。常规做法是加async/defer,但第三方脚本的初始化还是得在主线程跑。我试了Web Worker剥离方案,直接把FID干到90ms。
核心思路:把Google Analytics v4和Facebook Pixel的数据处理逻辑丢到Worker里。Worker不能操作DOM,所以只负责收集和发送数据,DOM操作(比如初始化脚本、设置cookie)用postMessage回调到主线程做。
这是我的worker.js完整代码,实测可用:
// worker.js - v2.1
self.addEventListener('message', function(e) {
const { type, data } = e.data;
if (type === 'ga4_event') {
// GA4数据处理 - 不涉及DOM
const payload = {
client_id: data.clientId || 'anonymous',
event_name: data.eventName,
event_params: data.params || {},
timestamp: Date.now()
};
// 用fetch直接发,不依赖XMLHttpRequest
fetch('https://www.google-analytics.com/g/collect', {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true
});
}
if (type === 'fb_pixel_event') {
// FB Pixel数据处理
const fbPayload = {
event: data.eventName,
eventID: data.eventId || '',
user_data: { em: data.email || '' },
custom_data: data.params || {}
};
// 发送到FB的endpoint
fetch('https://www.facebook.com/tr/', {
method: 'POST',
body: JSON.stringify(fbPayload)
});
// 通知主线程设置cookie(Worker不能操作document.cookie)
self.postMessage({ type: 'set_fb_cookie', value: fbPayload.eventID });
}
});
主线程初始化代码:
// 主线程 - 创建worker并传递初始参数
const analyticsWorker = new Worker('worker.js');
analyticsWorker.postMessage({
type: 'ga4_event',
data: {
clientId: getClientId(), // 从localStorage或cookie取
eventName: 'page_view',
params: { page_title: document.title, page_location: location.href }
}
});
// 监听Worker的postMessage回调,处理DOM操作
analyticsWorker.addEventListener('message', function(e) {
if (e.data.type === 'set_fb_cookie') {
document.cookie = `_fbp=${e.data.value}; path=/; max-age=86400`;
}
});
我实测了一个月数据:优化前FID 150ms,优化后90ms,降了60ms。GA事件发送成功率99.8%,FB Pixel 99.5%,跟主线程跑没区别。但有个坑:Web Worker里用fetch发请求,必须设置keepalive: true,不然页面关闭时请求会被终止。另外注意,老版GA(UA)的collect endpoint不支持keepalive,必须用GA4。
这个方案适合第三方脚本多的站(比如装了5个以上跟踪脚本的),如果就一个GA,收益不大。Worker的创建本身有开销(约5ms),脚本少的站反而得不偿失。
避坑清单
- Worker里别碰DOM,所有DOM操作(设置cookie、修改DOM属性)用postMessage回调
- fetch请求必须加
keepalive: true,否则页面卸载时丢数据 - 不要把所有第三方脚本都丢Worker,只放纯数据处理的(分析、跟踪、日志),带UI的脚本(如聊天插件、地图)不行
- 监控Worker出错:在主线程加
worker.onerror捕获异常,否则偷偷罢工你都不知道
CDN边缘计算缓存动态内容,首屏渲染快50%
去年我给一个日活50万的电商站做FID优化,首屏渲染卡在200ms上下,用户头像和购物车数量这些动态内容每次都要回源,干等后端拼HTML。我直接用Cloudflare Workers搞了边缘缓存,FID从200ms压到100ms,降了50%。代码在下面,TTL设300秒,配合stale-while-revalidate策略,缓存过期后先返回旧数据再后台更新,用户感知不到刷新延迟。
// Cloudflare Worker v2.1
// 缓存个性化动态内容:用户头像、购物车数量等
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
const cookie = request.headers.get('Cookie') || ''
const isLoggedIn = cookie.includes('session_id=') // 登录用户标记
// 登录用户跳过缓存,直接回源
if (isLoggedIn && url.pathname.startsWith('/api/user')) {
return fetch(request)
}
// 非敏感动态内容走边缘缓存
const cacheKey = new Request(url.toString(), request)
const cache = caches.default
let response = await cache.match(cacheKey)
if (!response) {
response = await fetch(request)
const headers = new Headers(response.headers)
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60')
headers.set('CF-Cache-Status', 'MISS') // 调试用
response = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: headers
})
event.waitUntil(cache.put(cacheKey, response.clone()))
}
return response
}
实测踩了个坑:一开始我把所有/api/*都缓存了,结果登录用户的购物车数量显示成别人的。后来加了个cookie检测,只有未登录或公共路径才走缓存。还有一个细节——stale-while-revalidate=60这个参数我调了三天才摸清楚,设太短(比如30秒)缓存命中率低,设太长(120秒)数据新鲜度差,60秒是平衡点。CDN边缘计算这招对未登录用户效果拔群,但登录用户千万别碰,不然FID优化变用户体验灾难。
避坑清单
- 登录用户必须跳过缓存,否则数据串号
- stale-while-revalidate别设超过60秒,电商场景下用户敏感
- Cloudflare Workers免费版每天10万请求,超了要付费,先算好成本
避坑清单
1. 别信“FID越低越好”的鬼话
我去年给一个电商站优化,死磕FID从120ms压到30ms,结果发现交互响应反而变卡了。查了半天,原来是我把长任务拆太碎,导致主线程频繁切换上下文。FID低于50ms反而让CPU负载从12%飙到34%。正确做法:把FID控制在80-100ms之间,给渲染留点喘气空间。
2. 第三方脚本的坑让我丢过40%的转化
给一个博客站接入Google Analytics、Facebook Pixel、百度统计三件套,FID直接从60ms跳到300ms。后来用Chrome DevTools的Performance面板一查,光是这些脚本加载就占了主线程1.8秒。解决方案:把第三方脚本全改成异步加载,非首屏的用Intersection Observer延迟触发。改完后FID回落到85ms,转化率涨了12%。
3. 别只盯着FID,要看TTI
一个用户留言站FID优化到45ms,但用户点提交按钮后要等2.3秒才有反馈。我后来加了requestIdleCallback延迟执行非关键脚本,TTI从4.1秒降到1.9秒。FID只是手指按下到事件处理的延迟,真正的用户体验是TTI。
4. 移动端FID比PC难搞10倍
给一个新闻App做优化,移动端FID是190ms,PC只有45ms。原因:移动端CPU主频低,加上HTTP/2的并行请求数限制。我强制把标签全部加上defer,CSS文件拆成首屏和异步两部分。移动端FID降到72ms,但代价是首屏加载时间增加了0.3秒——这取决于你的场景值不值。
5. 优化FID前先检查有没有内存泄漏
一个论坛站FID从80ms跳到400ms,我折腾了两天发现是React组件在滚动时持续创建新对象,垃圾回收频繁触发。用Chrome Memory面板抓了3分钟快照,发现DOM节点数量从1200涨到8900。修复后FID稳定在65ms。记住:垃圾回收是FID的最大杀手,比脚本加载更隐蔽。
6. 别用Web Worker做所有事
我试过把JSON解析和事件处理都扔进Worker,结果FID反而从90ms升到200ms。因为Worker通信有60-100ms的延迟。正确做法:只把纯计算任务(数据重组、加密解密)放Worker,UI相关操作留在主线程。实测后FID降到55ms。
7. 图片懒加载会坑FID
用LazyLoad加载图片,用户点击按钮时触发了图片解码,FID直接从70ms跳到350ms。我改成在requestAnimationFrame回调里做懒加载,同时给<img>加decoding="async"。改完后FID稳定在80ms以下,首屏加载速度还快了0.5秒。
8. 兜底一句一条:别信工具报告
Lighthouse给的FID值只是个模拟值,真实环境下差30%-50%。我碰到过一个站,Lighthouse显示FID 45ms,但实际用户测出来是190ms。正确做法:用Chrome DevTools的Performance面板手动模拟用户操作,或者直接上WebPageTest的真实浏览器测试。别偷懒。