最近给 sotool.top 做了首屏性能优化,LCP 从 3s+ 降到 1.2s。这篇文章记录 5 个实际有效的优化手段,以及踩过的坑。
背景
sotool 是一个 PDF 工具箱,首页包含 17 个工具入口、FAQ、对比表格、推荐位等,首屏 HTML 超过 30KB。
Vite 打包后:
index.html加载app.js(约 180KB gzipped)app.js里引入了vue、vue-router、pdf-lib、html2pdf.js、mammoth、xlsx等- 首页虽然不直接用到 pdf-lib,但它在
vendorchunk 里 - 移动端首屏 LCP 经常 3s+
优化 1:手动 chunk 拆分
Vite 默认把所有第三方库打包到一个 chunk 里,首页加载了不需要的代码。
我们配置了 manualChunks:
css
// 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 等库按需加载。
坑点: 拆得太细会导致 HTTP 请求过多。Vite 6 支持 HTTP/2,但移动端 HTTP/2 多路复用不一定生效,chunk 数量控制在 5-8 个比较安全。
优化 2:modulePreload 过滤
Vite 默认会为每个 chunk 生成 <link rel="modulepreload"> 标签。首页的 HTML 里preload了 pdf-lib、html2pdf 等不需要的 chunk。
javascript
// vite.config.ts
build: {
modulePreload: {
resolveDependencies(filename, deps, { hostId, hostType }) {
// 首页不 preload 大型 PDF 工具 chunk
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() 懒加载,首页不加载任何工具页面。
javascript
// router/index.ts
{
path: '/merge',
name: 'Merge',
component: () => import('@/views/Merge.vue'),
}
坑点: 首次点击工具时会有 200-500ms 的 chunk 加载延迟。我们加了 loading 状态过渡,用户感知不到。
优化 4:字体加载优化
项目用了 4 个字体包:Plus Jakarta Sans、JetBrains Mono、Instrument Serif。全部通过 @fontsource 引入,每个包 50-200KB。
arduino
// main.ts
import '@fontsource-variable/plus-jakarta-sans'
import '@fontsource-variable/jetbrains-mono'
import '@fontsource/instrument-serif/400.css'
import '@fontsource/instrument-serif/400-italic.css'
优化方案: 在 CSS 里用 font-display: swap:
css
@font-face {
font-family: 'PlusJakartaSans';
src: url('/fonts/plus-jakarta-sans-variable.woff2') format('woff2');
font-display: swap;
}
swap 策略让文字先用系统字体显示,等自定义字体加载完再替换。比 block(等字体加载完再渲染)快得多。
坑点: 字体替换时可能有闪烁。我们用 document.fonts.ready 做全局字体加载监听,在字体加载完后移除 font-loading class。
javascript
document.fonts.ready.then(() => {
document.documentElement.classList.remove('font-loading')
})
优化 5:首屏内容直出
SPA 的首屏 HTML 是空的,全靠 JS 渲染。这对 LCP 和 SEO 都不好。
我们的做法是在 vite build 后用脚本预渲染首页和核心工具页:
javascript
// 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 是细节但很容易被忽略。预渲染对 SEO 和 LCP 帮助最大,但维护成本也最高。
如果你也在做类似的 SPA 工具站,建议先从 chunk 拆分开始,然后逐步优化。
工具地址:sotool.top
如果对你有帮助,欢迎点赞收藏,有问题评论区见。