外观
首屏 LCP 4.2s 到 1.8s:懒加载与预渲染实测
问题背景:LCP 为什么卡在 4.2s
这次优化对象是一个 Vue3 + Vite 构建、部分页面复用 VitePress 内容源的文档站点。上线后移动端 Lighthouse 持续报出 LCP 4.2s,Core Web Vitals 字段数据也显示 75 分位超过 3.5s。抓包后发现三个典型问题:首屏路由和后台管理组件被打进同一个 chunk;页面 HTML 只有空挂载节点,内容必须等待 JS 下载、解析、执行后才出现;样式表虽然只有 80KB,但作为外部 CSS 阻塞渲染,弱网下显著推迟了文字和图片的首次绘制。
LCP 的关键不是单纯减小总包体积,而是缩短「请求文档 → 浏览器拿到可渲染 HTML/CSS → 最大元素绘制」的链路。只做路由懒加载能减少 JS 体积,却无法解决空 HTML 问题;只做预渲染但不处理 CSS,仍会被样式请求卡住;只内联 CSS 却保留巨大入口包,又会让 hydration 和主线程执行变慢。因此本次方案把三者组合起来:路由级代码分割、构建期预渲染、首屏关键 CSS 内联。
方案对比:懒加载、SSR、预渲染怎么选
常见首屏方案可以按渲染时机分成三类。客户端渲染改造成本最低,但 HTML 内容为空,LCP 强依赖 JS;SSR 能输出实时内容,但需要 Node 服务、缓存、降级和运维能力;静态预渲染适合文档、营销页、帮助中心等 URL 固定、内容构建时可知的页面,可直接生成带内容的 HTML,CDN 命中率高。
| 方案 | 适用场景 | 优点 | 主要代价 |
|---|---|---|---|
| CSR + 懒加载 | 强交互后台、登录后页面 | 部署简单、前后端分离 | 首屏依赖 JS,SEO 弱 |
| SSR | 内容实时变化、个性化页面 | HTML 完整,可动态渲染 | 服务器成本和复杂度高 |
| 预渲染 | 文档站、博客、落地页 | 静态托管、TTFB 稳定、SEO 友好 | 构建变慢,动态内容受限 |
| 关键 CSS 内联 | 所有首屏性能敏感页 | 消除 CSS 请求往返 | 需控制内联体积,避免重复 |
本站 90% 流量集中在文档详情页,内容在发布后才变化,因此选择「预渲染 + 懒加载 + 关键 CSS 内联」。VitePress 本身默认 SSG,这里重点演示普通 Vue3/Vite 应用如何接入同一思路,并说明 VitePress 页面的配合方式。
实操一:路由懒加载与 Vite 分包
第一步先消灭入口大包。Vue Router 中不要在顶部静态导入所有页面,而要使用动态 import()。Vite 遇到动态导入会自动生成独立 chunk,浏览器只在访问对应路由时下载。
typescript
// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'\n
// 首屏立即需要的布局可保留同步导入
import AppLayout from '../layouts/AppLayout.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: AppLayout,
children: [
{
path: '',
name: 'home',
// 首页是 LCP 页面,独立 chunk 便于预加载
component: () => import('../views/HomeView.vue'),
},
{
path: 'docs/:slug',
name: 'doc-detail',
component: () => import('../views/DocDetail.vue'),
},
{
path: 'dashboard',
name: 'dashboard',
// 低优先级页面访问时再加载
component: () => import('../views/DashboardView.vue'),
},
],
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
},
})大型第三方库也要从首屏路径中剥离。例如图表、代码高亮、编辑器不应进入默认入口,可在具体组件内动态加载,或通过 manualChunks 稳定拆分。
typescript
// vite.config.ts
import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n
export default defineConfig({
plugins: [vue()],
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router'],
editor: ['monaco-editor'],
},
},
},
},
})分包后不要盲目追求每个依赖单独成块。弱网下过多小 chunk 会带来请求调度和连接竞争,文档站建议控制在「框架公共包、当前页面包、低频重型组件包」三类。实测中,首页 JS 从 428KB 降到 176KB,解析编译时间从 820ms 降到 310ms。
实操二:构建期预渲染让 LCP 元素直出
第二步是把文档 URL 在构建阶段渲染成静态 HTML。下面用一个轻量插件演示核心流程:构建后创建 Vue 应用,根据路由抓取页面数据,渲染为字符串,再写入 dist/**/index.html。真实项目也可直接使用 vite-plugin-ssr、vite-ssg 等成熟方案。
typescript
// scripts/prerender.ts
import { renderToString } from 'vue/server-renderer'\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'\nimport { createApp } from './server-app'
import { docs } from './docs-data'
async function prerender() {
const paths = ['/', ...docs.map((doc) => `/docs/${doc.slug}`)]
for (const path of paths) {
const { app } = createApp(path)
const html = await renderToString(app)
const file = path === '/'
? join('dist', 'index.html')
: join('dist', path, 'index.html')
const dir = join(file, '..')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
// 真实项目应读取构建产物模板并替换挂载点内容
writeFileSync(file, html, 'utf8')
console.log('[prerender]', path)
}
}
prerender().catch((error) => {
console.error(error)
process.exitCode = 1
})文档详情页的数据访问必须兼容服务端,不能在 setup 顶层直接访问 window 或 document。
vue
<!-- src/views/DocDetail.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'\nimport { useRoute } from 'vue-router'
import { getDocBySlug } from '../api/docs'
const route = useRoute()
const doc = ref(getDocBySlug(String(route.params.slug)))
const canUseClipboard = ref(false)
onMounted(() => {
// 浏览器专属 API 放到挂载后执行,避免预渲染报错
canUseClipboard.value = 'clipboard' in navigator
})
</script>
<template>
<article class="doc-page">
<h1 class="doc-title">{{ doc?.title }}</h1>
<p class="doc-summary">{{ doc?.summary }}</p>
<main class="doc-content" v-html="doc?.contentHtml" />
</article>
</template>如果使用 VitePress,默认构建已经会生成静态 HTML。需要额外检查的是主题布局和自定义 Vue 组件是否把主要内容放进了客户端渲染的空容器;否则看似 SSG,实际 LCP 文本仍要等 hydration。VitePress 中可优先使用 Markdown 原生内容、构建期数据加载和静态组件,把评论框、演示编辑器等放到客户端组件中懒加载。
实操三:关键 CSS 内联,消除渲染阻塞
第三步处理首屏样式。完整 CSS 内联会让 HTML 膨胀并丢失缓存收益,因此只提取导航、标题、正文宽度、LCP 图片尺寸等首屏必要规则,其余样式继续异步加载。
typescript
// vite.config.ts 中增加 HTML 转换插件
import { readFileSync } from 'node:fs'
import type { Plugin } from 'vite'
function inlineCriticalCss(): Plugin {
return {
name: 'inline-critical-css',
apply: 'build',
transformIndexHtml(html, context) {
if (context.path !== '/') return html
const critical = readFileSync('src/styles/critical.css', 'utf8')
return html.replace(
'</head>',
`<style id="critical-css">\n${critical}\n</style>\n</head>`,
)
},
}
}
export default defineConfig({
plugins: [vue(), inlineCriticalCss()],
})css
/* src/styles/critical.css:只保留首屏可见区域样式 */
:root {
--text: #1f2937;
--muted: #6b7280;
--primary: #4f46e5;
}
body {
margin: 0;
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.site-header,
.doc-page {
width: min(920px, calc(100% - 32px));
margin: 0 auto;
}
.doc-title {
margin: 32px 0 12px;
font-size: clamp(28px, 5vw, 48px);
line-height: 1.15;
}
.doc-summary {
color: var(--muted);
font-size: 18px;
}
/* 固定图片尺寸,避免加载后推动布局导致 LCP 元素变化 */
.doc-hero {
width: 100%;
height: auto;
aspect-ratio: 16 / 7;
display: block;
}非关键 CSS 可以使用媒体技巧先不阻塞渲染,再在加载完成后恢复:
html
<link
rel="stylesheet"
href="/assets/rest.css"
media="print"
onload="this.media='all'"
/>生产环境建议配合 Critters、Penthouse 等工具按路由自动提取,而不是长期维护手写清单。内联样式建议控制在 10KB 到 15KB gzip 内,超过后应重新审视设计系统和首屏复杂度。
实测数据:4.2s 是如何降到 1.8s 的
测试条件为移动端模拟、4 倍 CPU 降速、1.6Mbps 网络、冷缓存,每组跑 9 次取中位数。优化前文档 HTML 2.1KB,首屏 JS 428KB,外部 CSS 86KB,LCP 4.2s;仅懒加载后 JS 降到 176KB,LCP 3.1s;加入预渲染后 HTML 包含标题和正文,LCP 2.3s;再内联关键 CSS 后,外部样式不再阻塞首文本绘制,LCP 最终为 1.8s。
| 阶段 | 首屏 JS | 阻塞 CSS | HTML 内容 | LCP |
|---|---|---|---|---|
| 优化前 | 428KB | 86KB | 空节点 | 4.2s |
| 路由懒加载 | 176KB | 86KB | 空节点 | 3.1s |
| 加预渲染 | 176KB | 86KB | 完整内容 | 2.3s |
| 关键 CSS 内联 | 176KB | 0 阻塞 | 完整内容 | 1.8s |
同时,CLS 从 0.21 降到 0.03,主要来自图片宽高、字体 font-display: swap 和骨架屏预留空间;TTI 从 5.6s 降到 3.2s。需要强调,LCP 下降并不只靠某一个配置,预渲染负责让最大元素尽早存在,关键 CSS 负责让它能被绘制,懒加载负责减少主线程争抢和网络竞争。
避坑总结:这些错误会让优化失效
动态导入被错误地静态提前引用。 现象是打包分析里页面组件仍出现在入口 chunk,路由切换也看不到新请求。常见原因是在顶部写了
import DocDetail from ...后又传给路由,或工具函数把所有页面收集到数组中同步执行。修正方法是路由配置只保留() => import('../views/DocDetail.vue'),并用rollup-plugin-visualizer确认 chunk 归属。预渲染阶段访问浏览器 API 导致构建失败或内容为空。 典型报错是
window is not defined、document is not defined,有时不报错但生成的 HTML 缺少正文。应把localStorage、navigator、DOM 测量放进onMounted,第三方组件用<ClientOnly>包裹;数据请求改为构建期可执行的函数,不依赖 XHR 上下文。关键 CSS 提取过多或重复内联。 现象是 HTML 从十几 KB 膨胀到上百 KB,首字节后文档下载变慢,样式还在每个页面重复传输。不要把全站 CSS 全部内联;只提取首屏视口内选择器,公共小样式可内联,正文以下和组件库样式走外部缓存。构建后用覆盖率面板检查未使用规则,并设置体积阈值。
LCP 图片没有预加载,文字直出后仍被图片拖慢。 如果最大元素是首屏主图,仅预渲染
<img>还不够,浏览器可能要解析 HTML、等待 CSS 和 JS 后才发现它。对首屏主图可使用fetchpriority="high"、preload、WebP/AVIF 与响应式srcset,但不要预加载折叠线以下图片,否则会抢占文档和 CSS 带宽。** hydration 名称不一致造成节点重建。** 服务端输出的 HTML 和客户端首次渲染如果因时间、随机数、用户地区不同而不一致,Vue 会丢弃并重新生成 DOM,LCP 看似直出却被二次覆盖。时间格式化、广告位、实验分流应在挂载后执行,保证首次客户端渲染与预渲染结果一致。
最终方案已经接入 CI:Lighthouse 性能分低于 90、路由入口 chunk 超过 200KB、关键 CSS 超过 15KB 时构建失败。性能优化不是一次性调参,而是把资源加载顺序、渲染产物和监控阈值一起工程化;当文档继续增长时,LCP 才不会重新回到 4s 区间。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。