Vue3 SPA 首屏优化记录:5 步把 LCP 从 3.2s 压到 1.2s
sotool.top 是一个 PDF 工具箱,首页包含 17 个工具入口、FAQ、对比表格、推荐位等,首屏 HTML 超过 30KB。Vite 打包后,index.html 加载 app.js(约 180KB gzipped),其中引入了 vue、vue-router、pdf-lib、html2pdf.js、mammoth、xlsx 等库。首页虽然用不到 pdf-lib,但它被打进 vendor chunk,移动端首屏 LCP 经常在 3s 以上。
做了一轮首屏优化,LCP 从 3.2s 降到 1.2s,记录一下每一步的做法和踩到的坑。
优化 1:手动拆分 chunk
Vite 默认把所有第三方依赖打进同一个 chunk,首页不得不加载用不到的库。通过 manualChunks 按库拆分:
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'lucide-vue-next'],
'pdf-lib': ['pdf-lib'],
'pdfjs-dist': ['pdfjs-dist'],
mammoth: ['mammoth'],
xlsx: ['xlsx'],
'html2pdf-jspdf': ['html2pdf.js', 'jspdf'],
},
},
},
}
效果:首页只加载 vendor chunk(约 60KB gzipped),pdf-lib 等库按需加载。首页首屏 JS 从 180KB 降到 60KB。
坑点:chunk 拆得太细会增加 HTTP 请求数。虽然 Vite 6 支持 HTTP/2,但移动端 HTTP/2 多路复用不一定生效,chunk 数量控制在 5-8 个更稳妥。
优化 2:过滤 modulePreload
Vite 默认会给每个 chunk 生成 `` 标签,导致首页 HTML 里 preload 了 pdf-lib、html2pdf 这些当前页面根本不会用到的 chunk。通过 modulePreload 钩子过滤掉:
// vite.config.ts
build: {
modulePreload: {
resolveDependencies(filename, deps, { hostId, hostType }) {
if ((hostType === 'html' && hostId.includes('index')) || hostId.endsWith('index.html')) {
return deps.filter(dep => !dep.includes('pdf-lib') && !dep.includes('html2pdf'))
}
return deps
},
},
}
效果:首页 preload 请求从 12 个降到 4 个,减少网络竞争。
优化 3:路由懒加载
Vue Router 的路由组件全部改为 import() 动态加载,首页不加载任何工具页面:
{ path: '/merge', name: 'Merge', component: () => import('@/views/Merge.vue') }
坑点:首次点击工具入口时,chunk 加载会有 200-500ms 延迟。加了 loading 状态过渡后,用户基本感知不到。
优化 4:字体加载优化
项目用了 4 个字体包(Plus Jakarta Sans、JetBrains Mono、Instrument Serif),通过 @fontsource 引入,每个包 50-200KB。给字体加上 font-display: swap:
@font-face {
font-family: 'PlusJakartaSans';
src: url('/fonts/plus-jakarta-sans-variable.woff2') format('woff2');
font-display: swap;
}
swap 让文字先用系统字体渲染,自定义字体加载完后再替换,比 block 对首屏的影响小得多。
坑点:字体替换时可能出现 FOIT 闪烁。可以用 document.fonts.ready 做全局监听,字体加载完成后移除 font-loading class,避免在字体切换过程中闪烁:
document.fonts.ready.then(() => { document.documentElement.classList.remove('font-loading') })
优化 5:预渲染首屏内容
SPA 首屏 HTML 是空的,全靠 JS 客户端渲染,对 LCP 和 SEO 都不友好。做法是在 vite build 后用 Playwright 脚本预渲染首页和几个核心工具页,把渲染后的静态 HTML 写回:
// scripts/prerender.mjs
import { chromium } from 'playwright'
const browser = await chromium.launch()
const page = await browser.newPage()
const urls = ['/', '/merge', '/compress', '/split', '/word-to-pdf']
for (const url of urls) {
await page.goto(`http://localhost:4173${url}`, { waitUntil: 'networkidle' })
const html = await page.content()
// 写入静态 HTML 文件
}
await browser.close()
效果:首页直接返回带内容的 HTML,LCP 从 3s 降到 1.2s。
坑点:预渲染会增加构建时间(每页约 2-3s)。如果项目规模上去,SSR 框架(如 Nuxt)会更合适;小项目用 Playwright 预渲染性价比更高。
优化前后对比
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| LCP | 3.2s | 1.2s | -62% |
| 首屏 JS | 180KB | 60KB | -67% |
| preload 请求数 | 12 | 4 | -67% |
| 首屏 HTML | 空 | 30KB 内容 | — |
总结
5 个优化里,手动 chunk 拆分 + modulePreload 过滤性价比最高,改动量小效果明显;font-display: swap 是个容易忽略的细节;预渲染对 LCP 和 SEO 帮助最大,但后续维护成本也最高。做类似 SPA 工具站,建议从 chunk 拆分入手,再逐步往下优化。