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足够了。

避坑清单

  1. nginx版本低于1.21.0不支持Brotli模块,先升级再编译
  2. 动静分离一定要做,Brotli只对文本类资源生效,图片别加进去
  3. brotli_static on必须在编译模块时启用,不然nginx直接报错退出的
  4. 别在CDN回源层开Brotli,很多老CDN节点不认识这个格式

关键资源预加载:字体和关键CSS提前25ms

别指望浏览器自己猜你要加载什么。去年我给一个资讯站做优化,首屏字体加载延迟了180ms才触发,FID直接飙到120ms。解决办法就两个标签:preloadpreconnect。我实测发现,加上这俩玩意儿,FID能从120ms降到95ms,整整快了25ms。

preconnect 用在第三方字体服务商。比如Google Fonts,你得提前跟 fonts.googleapis.comfonts.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的真实浏览器测试。别偷懒。