跳到主内容

首屏 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 页面的配合方式。

请求 HTML空挂载节点下载大 JS解析并执行请求 CSS阻塞渲染LCP 4.2s预渲染 HTML内容直出关键 CSS 内联首屏立即可绘路由懒加载非首屏延迟LCP 1.8s

实操一:路由懒加载与 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阻塞 CSSHTML 内容LCP
优化前428KB86KB空节点4.2s
路由懒加载176KB86KB空节点3.1s
加预渲染176KB86KB完整内容2.3s
关键 CSS 内联176KB0 阻塞完整内容1.8s

同时,CLS 从 0.21 降到 0.03,主要来自图片宽高、字体 font-display: swap 和骨架屏预留空间;TTI 从 5.6s 降到 3.2s。需要强调,LCP 下降并不只靠某一个配置,预渲染负责让最大元素尽早存在,关键 CSS 负责让它能被绘制,懒加载负责减少主线程争抢和网络竞争。

避坑总结:这些错误会让优化失效 ​

  1. 动态导入被错误地静态提前引用。 现象是打包分析里页面组件仍出现在入口 chunk,路由切换也看不到新请求。常见原因是在顶部写了 import DocDetail from ... 后又传给路由,或工具函数把所有页面收集到数组中同步执行。修正方法是路由配置只保留 () => import('../views/DocDetail.vue'),并用 rollup-plugin-visualizer 确认 chunk 归属。

  2. 预渲染阶段访问浏览器 API 导致构建失败或内容为空。 典型报错是 window is not defined、document is not defined,有时不报错但生成的 HTML 缺少正文。应把 localStorage、navigator、DOM 测量放进 onMounted,第三方组件用 <ClientOnly> 包裹;数据请求改为构建期可执行的函数,不依赖 XHR 上下文。

  3. 关键 CSS 提取过多或重复内联。 现象是 HTML 从十几 KB 膨胀到上百 KB,首字节后文档下载变慢,样式还在每个页面重复传输。不要把全站 CSS 全部内联;只提取首屏视口内选择器,公共小样式可内联,正文以下和组件库样式走外部缓存。构建后用覆盖率面板检查未使用规则,并设置体积阈值。

  4. LCP 图片没有预加载,文字直出后仍被图片拖慢。 如果最大元素是首屏主图,仅预渲染 <img> 还不够,浏览器可能要解析 HTML、等待 CSS 和 JS 后才发现它。对首屏主图可使用 fetchpriority="high"、preload、WebP/AVIF 与响应式 srcset,但不要预加载折叠线以下图片,否则会抢占文档和 CSS 带宽。

  5. ** hydration 名称不一致造成节点重建。** 服务端输出的 HTML 和客户端首次渲染如果因时间、随机数、用户地区不同而不一致,Vue 会丢弃并重新生成 DOM,LCP 看似直出却被二次覆盖。时间格式化、广告位、实验分流应在挂载后执行,保证首次客户端渲染与预渲染结果一致。

最终方案已经接入 CI:Lighthouse 性能分低于 90、路由入口 chunk 超过 200KB、关键 CSS 超过 15KB 时构建失败。性能优化不是一次性调参,而是把资源加载顺序、渲染产物和监控阈值一起工程化;当文档继续增长时,LCP 才不会重新回到 4s 区间。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。