Nuxt3 优化 Lighthouse 得分
Lighthouse 的 Performance 分数主要由 FCP、LCP、TBT、CLS 和 SI 五项实验室指标计算得出。优化时不要只盯着最终分数,而要先定位耗时发生在服务器、网络、资源下载,还是浏览器主线程。
对于同时使用 SSR、Vuetify、大量图片、服务端接口和第三方广告脚本的 Nuxt 应用,优化重点通常是缩短 TTFB、尽早暴露 LCP 图片、减少首屏资源竞争,以及为异步内容预留空间。
指标与目标
| 指标 | 含义 | 建议目标 | 常见瓶颈 |
|---|---|---|---|
| FCP | First Contentful Paint,首次内容绘制 | 不超过 1.8 秒 | TTFB 高、CSS 阻塞、首屏资源过多 |
| LCP | Largest Contentful Paint,最大内容绘制 | 不超过 2.5 秒 | LCP 资源发现晚、图片过大、主线程阻塞 |
| TBT | Total Blocking Time,总阻塞时间 | 不超过 200 毫秒 | JavaScript 体积大、长任务、第三方脚本 |
| CLS | Cumulative Layout Shift,累积布局偏移 | 不超过 0.1 | 图片无尺寸、广告插入、异步内容撑开布局 |
| SI | Speed Index,速度指数 | 越低越好 | 首屏各区域绘制缓慢 |
LCP 和 CLS 同时也是 Core Web Vitals;真实用户的交互指标是 INP。Lighthouse 中的 TBT 是实验室指标,通常用来提前发现可能影响 INP 的主线程长任务。最终应同时查看:
- Lighthouse 的本地实验室数据,用于复现和定位问题。
- PageSpeed Insights 或 CrUX 的真实用户数据,用于判断线上用户是否真的变快。
- Chrome DevTools 的 Performance、Network 和 Coverage 面板,用于找到具体资源或长任务。
建立可比较的基线
先构建生产版本,再通过 Preview 服务测试,不要用开发模式的结果做前后对比。开发模式包含调试代码、热更新和未压缩资源,得分没有参考意义。
在相同页面、设备类型和网络条件下连续测试至少三次,取中位数。同时记录下面的信息:
- Lighthouse 标出的 LCP 元素是否每次相同。
- HTML 文档的 TTFB。
- LCP 图片的请求开始时间、下载时间和渲染延迟。
- 主线程中超过 50 毫秒的 Long Task。
- Layout Shifts 轨道中发生偏移的元素。
不要一次改动所有配置。每完成一类优化就重新构建和测试,否则很难判断哪项修改有效,哪项修改只是让单次跑分产生波动。
FCP:尽快返回并绘制第一屏
FCP 包含重定向、连接建立、TTFB、资源下载和首次渲染等阶段。Nuxt SSR 页面如果 TTFB 已经很高,只优化浏览器端代码通常不会有明显效果。
降低 TTFB
缓存重复读取的数据
可以将文章、分类、轮播和公共布局数据写入 Nitro Storage。请求命中缓存后,不再重复访问 GraphQL 或数据库。
多实例部署时,应把 Storage 挂载到 Redis 等共享存储,避免每个实例各自缓存一份。TTL 可以增加少量随机值,避免大量热门缓存同时失效,造成缓存击穿。
并行执行互不依赖的请求
首页轮播、文章和分类数据互不依赖,可以使用 Promise.all 并行请求,避免串行瀑布。
只把首屏必需的数据放进 SSR 的关键路径。Header、Footer、相关推荐等非关键数据可以使用 useLazyRequest,并通过 server: false 延后到客户端请求,从而减少 SSR 等待时间。
这种做法不是无条件收益。Header 如果包含首屏主内容或 SEO 导航,改成纯客户端渲染可能导致空白、CLS 和可访问性问题。是否移出 SSR 应以页面职责和实际测量为准,并为异步区域预留尺寸。
监控服务器耗时
开启 Nitro Server Timing 后,可以直接在浏览器 Network 面板查看服务端耗时:
export default defineNuxtConfig({
nitro: {
timing: true,
},
})还可以在 Nitro 的 request 和 afterResponse Hook 中记录请求耗时,用于排查慢接口。线上应控制日志量,避免同步日志反过来拖慢响应。
对于可静态化的页面,优先预渲染或使用 CDN 边缘缓存。动态 SSR 页面也应配置合适的 Cache-Control、SWR 或 ISR 策略,避免每次请求都回到源站完整渲染。
减少渲染阻塞 CSS
浏览器在关键 CSS 可用前不会绘制页面。可以使用下面的组合,将组件样式内联到 SSR HTML,并移除已经内联的重复样式链接:
export default defineNuxtConfig({
modules: ["nuxt-vitalizer"],
features: {
inlineStyles: true,
},
vite: {
build: {
cssCodeSplit: true,
},
},
vitalizer: {
disablePrefetchLinks: "dynamicImports",
disablePreloadLinks: false,
disableStylesheets: "entry",
},
})nuxt-vitalizer 是什么
nuxt-vitalizer 是一个构建期优化模块。它不会在浏览器中加入新的运行时代码,而是在 Nuxt 构建时调整 Client Manifest,减少最终 HTML 中不必要或重复的资源链接。它主要处理三类资源:动态导入的 Prefetch、当前页面依赖的 Preload,以及已经内联过的样式表。
它不是图片压缩、代码压缩或性能监控工具。它解决的是资源调度问题:浏览器打开页面时,应该优先下载真正影响首屏的资源,避免非关键资源与 LCP 图片、关键 CSS 和 JavaScript 争抢带宽。
几个配置项的作用分别是:
| 配置 | 作用 | 对性能的影响 |
|---|---|---|
disablePrefetchLinks | 移除可能在以后使用的 Prefetch | 减少首屏无关请求和带宽竞争 |
disablePreloadLinks | 移除当前页面资源的 Preload 和 Modulepreload | 可能减少请求突发,也可能让模块依赖形成瀑布 |
disableStylesheets | 移除已经内联到 HTML 的重复样式链接 | 减少渲染阻塞请求和重复 CSS 下载 |
Prefetch 和 Preload 的用途不同。Prefetch 表示浏览器空闲时可以提前获取“以后可能需要”的资源;Preload 或 Modulepreload 表示当前页面需要尽早获取的资源。因此这里只关闭动态导入产生的 Prefetch,同时保留 Preload。
disableStylesheets 必须与 features.inlineStyles 配合理解。Nuxt 先把组件样式写进 SSR HTML,nuxt-vitalizer 再移除指向同一份样式的外部链接。如果样式并没有成功内联就删除链接,页面会直接丢失样式,所以不能脱离构建产物单独开启。
这个模块只在正式构建时修改产物,在开发模式下通常看不到效果。启用后需要检查生成的 HTML 和 Network 请求,确认重复样式链接已经消失、页面没有闪烁或样式缺失,并比较 LCP 资源是否更早开始下载。
这里有几个容易忽略的取舍:
- 内联 CSS 可以少一次渲染阻塞请求,但会增大 HTML,且内联部分不能单独利用浏览器缓存。
cssCodeSplit: false曾用于把 CSS 合并成一个文件以缓解加载顺序造成的 CLS,但它会让每个页面下载更多无关 CSS。disablePreloadLinks保持为false。删除当前页面真正需要的modulepreload可能形成新的模块请求瀑布,使 LCP 变差。
CSS 较大时,优先使用 Coverage 检查 Vuetify 或全局样式中的未使用规则。内联整个大型样式表通常只会把下载成本转移到 HTML。
压缩传输资源
Nitro 可以在构建时为 Public Assets 生成 Gzip 和 Brotli 文件:
export default defineNuxtConfig({
nitro: {
compressPublicAssets: {
gzip: true,
brotli: true,
},
},
})还需要确认 Nginx、CDN 或托管平台会根据 Accept-Encoding 返回对应文件,并设置正确的 Content-Encoding 和 Vary 响应头。只生成 .gz、.br 文件但服务器不使用它们,不会改善得分。
LCP:优先加载首屏最大元素
LCP 可以拆为四段:TTFB、资源加载延迟、资源下载时间和元素渲染延迟。
先在 Lighthouse 的 Largest Contentful Paint element 或 DevTools Performance 中确认 LCP 到底是轮播图、文章封面还是标题,再处理对应阶段。
让浏览器尽早发现 LCP 资源
LCP 图片应直接出现在 SSR 返回的 HTML 中,并保留真实的 src 或 srcset。不要等 onMounted 后再插入图片,也不要把首屏图片藏在只有执行 JavaScript 后才能确定的组件中。
首页轮播应让第一张图片立即加载,后续图片才设置 loading="lazy"。还可以为首张图片设置 loading="eager" 和 fetchpriority="high",并明确提供 width 和 height。这样既提高加载优先级,也让浏览器提前计算布局。
其中最重要的规则是:不要给 LCP 图片设置 loading="lazy"。可以给最可能成为 LCP 的一张图片设置 fetchpriority="high",但不要给大量图片都设置高优先级,否则它们仍会互相竞争。
如果 LCP 是 CSS 背景图,浏览器必须先下载并解析 CSS 才能发现它。更好的选择是改用 <img>;无法修改时,可以在 Head 中预加载:
useHead({
link: [
{
rel: "preload",
as: "image",
href: "/images/hero.webp",
type: "image/webp",
fetchpriority: "high",
},
],
})预加载 URL 必须与页面最终请求的 URL 完全一致,否则浏览器可能下载两次。
懒加载非首屏图片
文章正文由富文本 HTML 生成时,可以在服务端遍历图片,为它们补全宽高,并给第一张以外的图片增加懒加载属性。这样 SSR 输出就已经包含优化信息,不需要等客户端 JavaScript 执行。
不能简单假设“第一张图片一定是 LCP”。如果正文之前还有封面或广告,应根据页面模板识别真实的首屏图片。响应式图片还应使用 srcset 和 sizes,让移动端不要下载桌面尺寸原图。
图片处理建议按下面的优先级执行:
- CDN 返回接近展示尺寸的图片,而不是只用 CSS 缩小原图。
- 优先使用 AVIF 或 WebP,并保留兼容格式。
- 压缩图片,移除不需要的元数据。
- 为非首屏图片设置
loading="lazy"。 - 为图片设置
width、height或aspect-ratio,同时降低 CLS。
预连接真正关键的跨域源
图片位于多个 CDN 域名时,可以在 app.vue 中加入连接提示:
useHead({
link: [
{ rel: "preconnect", href: "https://cdn.example.com" },
{ rel: "preconnect", href: "https://image.example.com" },
{ rel: "dns-prefetch", href: "https://img.example.com" },
],
})preconnect 会提前完成 DNS、TCP 和 TLS,因此只应用于首屏必然使用的少数域名。域名太多会浪费连接和 CPU。如果资源允许代理到与 HTML 相同的域名,同源复用连接通常更好。
避免无关 Prefetch 抢占带宽
Nuxt 可能为动态导入生成 Prefetch。页面很大时,这些“以后也许会用到”的资源可能与 LCP 图片竞争。可以通过 nuxt-vitalizer 只关闭动态导入的 Prefetch,同时保留当前页面依赖的 Preload。
修改前后应比较 Network 瀑布:如果 LCP 资源开始得更早且没有产生新的 JavaScript 请求瀑布,才说明配置有效。
CLS:为异步内容预留稳定空间
CLS 的核心不是禁止异步内容,而是让加载前后的几何尺寸保持一致。
固定媒体尺寸
图片、视频和 iframe 应提供 width、height 或 aspect-ratio。例如,将 Logo 从仅设置 CSS 高度改为明确的 HTML 宽高。
浏览器可以在图片下载前计算宽高比,因此后面的导航不会等图片出现后再移动。
固定异步模块和轮播区域
可以为首页选项卡、轮播和文章区域设置固定高度或最小高度。例如,桌面端选项卡内容预留 240 像素,移动端轮播预留 200 像素,首页主体预留 900 像素。
固定高度适合内容规格确定的卡片;正文长度不确定时,应使用 min-height、aspect-ratio 或与最终布局相同的骨架屏,避免内容溢出和响应式断点下的大面积空白。
为广告和第三方组件预留插槽
广告通常在页面绘制后才返回,是 CLS 的高发来源。广告容器在请求前就要设置稳定的最小高度。
如果不同断点使用不同广告规格,用媒体查询为每个断点保留对应高度。没有填充广告时,也不要立即折叠位于当前视口上方的插槽。
小心 ClientOnly
ClientOnly 适合依赖浏览器 API、无法 SSR 或非首屏的交互组件,但服务端占位与客户端内容尺寸不同会直接产生 CLS。应提供与最终组件尺寸一致的 fallback,或者在外层容器预留最小高度。
字体也可能引发布局偏移。自托管字体应做子集化并合理设置 font-display,同时选择字宽接近的系统回退字体。
TBT:减少 JavaScript 解析和长任务
TBT 统计 FCP 到可交互阶段之间,超过 50 毫秒的任务所产生的阻塞时间。网络下载变快不等于 TBT 会下降,JavaScript 的解析、编译和执行仍然发生在主线程。
延后第三方脚本
Google Ads 等第三方脚本可以使用 defer,让脚本在 HTML 解析完成后按顺序执行:
useHead({
script: [
{
defer: true,
crossorigin: "anonymous",
src: "https://example.com/third-party.js",
},
],
})defer 只避免阻塞 HTML 解析,不会消除脚本执行成本。广告、统计、客服和推送 SDK 如果并非首屏必需,更有效的方法是在用户同意、首次交互或浏览器空闲后再加载。
Firebase 初始化即使放在 onMounted 中,静态导入仍可能让 SDK 进入首屏客户端包。可以进一步改成动态导入,在用户允许通知后再加载 Firebase App 和 Messaging 模块。
不要在页面一打开就调用通知授权。除了体验较差,它还会让与首屏无关的工作进入加载关键阶段。
缩小组件和图标客户端包
可以将 @nuxt/icon 的服务端图标包设为本地,让模块扫描源码中实际使用的图标,并显式加入动态使用、无法被扫描发现的图标:
export default defineNuxtConfig({
icon: {
serverBundle: "local",
clientBundle: {
scan: true,
icons: ["mdi:circle", "mdi:chevron-up", "mdi:chevron-down"],
},
},
})对于首屏不需要的复杂组件,可使用 Nuxt 的 Lazy 组件或动态导入进行代码分割。组件名称加上 Lazy 只解决按需加载问题;如果组件仍在首屏立即渲染,对 TBT 的帮助有限。
把非关键接口移出首屏关键路径
相关推荐、侧栏和页脚等内容可以使用 useLazyFetch,并在不影响 SEO 和首屏结构时配置 { server: false }。但首屏 LCP 内容仍应 SSR 输出,不能为了减少服务端等待而全部改成客户端请求,否则常常只是把 TTFB 问题变成更严重的 LCP 和 CLS 问题。
根据 Long Task 定位代码
在 DevTools Performance 中录制一次页面加载,展开 Main 线程的 Long Task,查看 Bottom-up 和 Call tree:
- 大量
Evaluate Script:减少依赖、按路由拆包或延迟第三方 SDK。 - 大量
Recalculate Style、Layout:缩小 DOM,避免循环读写布局属性。 - 水合耗时高:减少首屏组件数量和响应式状态,避免服务端与客户端输出不一致。
- 一次性处理大量富文本:尽可能移动到服务端处理并缓存结果。
不建议使用“延迟整个 Nuxt 水合”来欺骗 Lighthouse。它可能让分数变高,但用户看到的按钮和链接暂时不可交互,真实 INP 甚至可能更差。延迟水合只适合明确的非交互组件,并且必须以真实用户数据验证。
SI:提高首屏整体绘制进度
SI 衡量首屏内容逐渐变得完整的速度,通常没有独立的优化开关。改善下面几项后,SI 会一起下降:
- 降低 TTFB 和 FCP,让页面更早开始绘制。
- 优先加载 LCP 图片和关键 CSS。
- 为图片、轮播、广告和异步内容预留空间。
- 延迟首屏无关的 JavaScript、图片和接口请求。
- 使用骨架屏时,让骨架结构接近最终内容,避免先画一整块空白再整体替换。
优化顺序
当页面同时存在多个告警时,可以按下面的顺序处理:
- 确认 LCP 元素和 Network 请求瀑布,避免优化错对象。
- 处理 TTFB:缓存、并发请求、CDN、减少 SSR 阻塞接口。
- 处理 LCP:SSR 可发现、禁止懒加载、提高优先级、压缩和调整尺寸。
- 处理 CLS:补全媒体尺寸,为广告与异步组件预留空间。
- 处理 TBT:延迟第三方脚本、拆包、精简依赖、消除 Long Task。
- 最后再处理零散的 Prefetch、字体和缓存策略,并用真实用户数据复核。
通常 TTFB 和 LCP 的收益最大;CLS 与 TBT 则更容易影响真实使用体验。把 99 分追到 100 分的收益,往往不如先修复一个线上用户持续遇到的布局偏移或交互卡顿。
