Vue 3 SPA 首屏优化:从 3s 到 1.2s 的 5 个实践

3 阅读3分钟

最近给 sotool.top 做了首屏性能优化,LCP 从 3s+ 降到 1.2s。这篇文章记录 5 个实际有效的优化手段,以及踩过的坑。

背景

sotool 是一个 PDF 工具箱,首页包含 17 个工具入口、FAQ、对比表格、推荐位等,首屏 HTML 超过 30KB。

Vite 打包后:

  • index.html 加载 app.js(约 180KB gzipped)
  • app.js 里引入了 vuevue-routerpdf-libhtml2pdf.jsmammothxlsx
  • 首页虽然不直接用到 pdf-lib,但它在 vendor chunk 里
  • 移动端首屏 LCP 经常 3s+

优化 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 等库按需加载。

坑点: 拆得太细会导致 HTTP 请求过多。Vite 6 支持 HTTP/2,但移动端 HTTP/2 多路复用不一定生效,chunk 数量控制在 5-8 个比较安全。

优化 2:modulePreload 过滤

Vite 默认会为每个 chunk 生成 <link rel="modulepreload"> 标签。首页的 HTML 里preload了 pdf-lib、html2pdf 等不需要的 chunk。

// 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() 懒加载,首页不加载任何工具页面。

// 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。

// 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

@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。

document.fonts.ready.then(() => {
  document.documentElement.classList.remove('font-loading')
})

优化 5:首屏内容直出

SPA 的首屏 HTML 是空的,全靠 JS 渲染。这对 LCP 和 SEO 都不好。

我们的做法是在 vite build 后用脚本预渲染首页和核心工具页:

// 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 方案够用。

优化前后对比

指标优化前优化后改善
LCP3.2s1.2s-62%
首屏 JS180KB60KB-67%
preload 请求124-67%
首屏 HTML30KB 内容-

总结

这 5 个优化里,手动 chunk 拆分 + modulePreload 过滤 性价比最高,改动小、效果大。字体 font-display: swap 是细节但很容易被忽略。预渲染对 SEO 和 LCP 帮助最大,但维护成本也最高。

如果你也在做类似的 SPA 工具站,建议先从 chunk 拆分开始,然后逐步优化。


工具地址:sotool.top

如果对你有帮助,欢迎点赞收藏,有问题评论区见。