最近在排查一个 Nuxt 3 项目的首屏加载耗时问题,页面本身不复杂,但响应体将近 200KB,传输时间占了大部分。我第一反应是去看后端响应头里有没有 Content-Encoding,结果发现 Nitro 默认没有启用压缩。这篇文章写一下我当时排查和配置的完整过程,以及需要注意的几个边界条件。
先确认现象:是真的需要压缩,还是别的瓶颈
拿到一个慢页面,我会先用浏览器的 Network 面板看实际传输大小和耗时。如果响应体超过几十 KB 且 Content-Encoding 不是 gzip 或 brotli,那压缩肯定是有效的优化方向。但有一种情况容易被忽略:如果页面用了大量内联样式或脚本,压缩收益有限,更值得先检查是否不该内联。我之前见过一个项目,首屏慢是因为服务端渲染返回了完整的状态树 JSON,而不是只返回必要部分。所以不要一上来就配压缩,先确认压缩能解决传输问题,而不是代码设计问题。
容易误判的地方:中间件顺序和 CDN 覆盖
Nuxt 3 的 Nitro 引擎支持使用 h3 中间件来添加压缩,但很多人会在自定义中间件里手动实现 gzip,结果发现不起作用。常见原因是自定义中间件挂载顺序不对,或者被其他中间件拦截了响应。另一个容易踩的坑是,如果你在 CDN 层开启了压缩,而 Nitro 也开启了,响应头可能被 CDN 改写,导致本地调试看到的是 CDN 的压缩结果,但源站实际没有压缩。我习惯先用 curl 直接请求源站 IP 并带上 --compressed 参数,看返回的头是否包含 Content-Encoding: gzip,这样能绕开 CDN 干扰。
还有一种情况是,你配置了压缩但文件太小,压缩算法可能因为开销而不生效。比如小于 1KB 的文件,有些压缩库会直接跳过。这不是配置问题,而是算法行为,需要了解你使用的压缩库的默认阈值。
建议的处理顺序:从检查默认设置到按需调整
1. 检查当前 Nitro 版本和默认压缩行为
Nuxt 3 的 Nitro 内置了压缩能力,但默认只对静态资源(public 目录下的文件)开启压缩,不对动态生成的 HTML 响应压缩。你可以在 nuxt.config.ts 中确认:
export default defineNuxtConfig({
nitro: {
compressPublicAssets: true
}
})
这个配置默认就是 true,所以静态资源通常已经被压缩了。但动态页面的压缩需要手动添加中间件。
2. 添加 h3 压缩中间件
对于动态响应的压缩,我推荐使用 h3 官方提供的 compress 中间件。在 server/middleware 目录下新建文件,比如 01-compress.ts:
import { defineEventHandler } from 'h3'
import { createGzip } from 'node:zlib'
import { promisify } from 'node:util'
const gzip = promisify(createGzip)
export default defineEventHandler(async (event) => {
const response = await event.node.res
if (!response.headersSent) {
// 检查客户端是否支持 gzip
const acceptEncoding = event.node.req.headers['accept-encoding']
if (acceptEncoding && acceptEncoding.includes('gzip')) {
response.setHeader('Content-Encoding', 'gzip')
response.setHeader('Vary', 'Accept-Encoding')
// 注意:这里不能直接用 response.end 压缩,需要配合 stream 或完整响应重写
}
}
})
但上面这段只演示了思路,实际生产环境不建议自己手写压缩逻辑。h3 提供 defineCompressMiddleware 或者直接用 createServer 时传 compress 选项。不过 Nuxt 3 默认封装的 Nitro 没有暴露这个选项,更稳妥的做法是使用社区中间件库,例如 h3-compression。
3. 使用社区中间件简化
我后来用了 h3-compression 包,安装后直接在 middleware 中注册:
import { defineEventHandler } from 'h3'
import { useCompression } from 'h3-compression'
export default defineEventHandler(async (event) => {
await useCompression(event, { brotli: true })
})
这个中间件会自动处理 gzip 和 brotli,并且会跳过已经压缩过的响应。注意 brotli 支持需要 Node.js 版本 >= 12.0,但 Nitro 通常运行在 Node 18+,所以问题不大。
验证方法:从响应头到实际效果
配置完成后,先重启开发服务器,随便请求一个页面,用 curl 检查:
curl -I --compressed http://localhost:3000
如果看到 Content-Encoding: gzip 或 br,说明压缩生效了。更精确的方法是查看传输大小对比:先请求不带 --compressed 的原始大小,再请求带压缩的,看 body 字节数是否明显减少。我在实际项目中从 180KB 降到了 45KB 左右,但这只是举例,具体数值取决于内容重复度,不要当作基准。
另外,检查 Vary 头是否正确设置。如果缺少 Vary: Accept-Encoding,缓存服务器可能给不支持压缩的客户端也返回压缩内容,导致解压失败。这个头通常在中间件里自动加上,但需要确认。
回滚和风险:CPU 开销与缓存策略
动态压缩会增加服务端 CPU 消耗,尤其是对高频 api 或大型响应。如果你发现 CPU 飙升,可以回到默认不压缩,或者只对特定路由开启压缩。我见过一个案例,所有 API 响应都压缩,结果 TTFB 反而增加了,因为压缩本身花了时间,而网络传输时间节省不多(API 响应通常很小)。建议先评估平均响应体大小,小于 1KB 的响应可以跳过压缩。部分中间件有阈值配置,比如 threshold: 1024。
回滚很简单:删除中间件文件或注释掉配置即可。注意如果之前已经缓存了压缩后的响应,回滚后需要清除 CDN 或浏览器的缓存,否则客户端可能拿到旧的压缩内容。最好在发布前用版本号或文件名哈希避免缓存冲突。
后续维护:定期检查压缩率和异常
压缩配置不是一次性的。随着项目迭代,页面内容结构变化,压缩率可能会下降。我习惯在日志里加一条响应大小和压缩后的对比,或者用工具定期扫描。另外,如果以后迁移到边缘函数或 serverless,压缩可能由平台自动处理,那就需要去掉自定义中间件,避免重复压缩。保持关注 Nitro 的新版本,有时候框架会内置压缩支持,比如 Nuxt 4 据说会默认开启动态压缩,届时配置方式会变。
总结一下我这次排查的思路:先看实际传输大小,再确认是否真的需要压缩,然后按顺序检查静态资源默认配置、添加动态压缩中间件、验证响应头和大小,最后评估 CPU 影响并做好回滚预案。整个过程不需要复杂的分析,但每一步都要确认当前环境的行为。配置完成后,我会在浏览器里对比开启和关闭压缩的加载时间,确保真的有提升,而不是心理作用。