Vue 3 后台系统首屏优化实战:LCP 从 6.2s 降到 1.4s

Vue 3 后台首屏 LCP 6.2 秒,3G 下打不开。本文实录两周优化全过程:路由懒加载 + CDN 化 + 按需引入 + WebP/AVIF + 字体子集 + Critical CSS + preload/preconnect + 骨架屏 + SWR + Brotli 双压缩。LCP 降到 1.4s,Lighthouse 96 分。

接手一个 Vue 3 后台管理系统,首屏 LCP 6.2 秒,FCP 4.5 秒,3G 网络下基本打不开。复盘和优化用了两周时间,把首屏 LCP 压到 1.4 秒。本文实录优化过程,包含 webpack/vite 配置、按需加载、CDN、HTTP/2、骨架屏、字体优化等十几项实战。

初始基线

Lighthouse 报告(快速 3G + 中端设备):
- First Contentful Paint (FCP): 4.5s
- Largest Contentful Paint (LCP): 6.2s
- Total Blocking Time (TBT): 850ms
- Cumulative Layout Shift (CLS): 0.18
- Speed Index: 5800ms

Network 面板:
- HTML: 1.2KB
- vendor.js: 2.8MB(gzip 850KB)         ← 罪魁
- main.js: 1.5MB(gzip 480KB)
- main.css: 320KB(gzip 75KB)
- 图标字体: 180KB
- 总下载: 6.4MB

优化 1:路由懒加载

// 错:全部 import 进 main bundle
import UserList from '@/views/UserList.vue'
import UserDetail from '@/views/UserDetail.vue'
import OrderList from '@/views/OrderList.vue'
// ... 100 个 view 都在 main bundle 里

const routes = [
  { path: '/users', component: UserList },
  ...
]

// 对:用动态 import,webpack 自动切 chunk
const routes = [
  {
    path: '/users',
    component: () => import(/* webpackChunkName: "user" */ '@/views/UserList.vue')
  },
  {
    path: '/users/:id',
    component: () => import(/* webpackChunkName: "user" */ '@/views/UserDetail.vue')
  },
  {
    path: '/orders',
    component: () => import(/* webpackChunkName: "order" */ '@/views/OrderList.vue')
  }
]
// 同 webpackChunkName 的合并到一个 chunk,按业务模块切割

优化 2:第三方库 CDN 化

// vite.config.ts:把 vue / element-plus / echarts 等大库设 external
import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia', 'element-plus', 'echarts'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia',
          'element-plus': 'ElementPlus',
          echarts: 'echarts'
        }
      }
    }
  },
  plugins: [
    createHtmlPlugin({
      inject: {
        data: {
          cdnLinks: `
            <link rel="dns-prefetch" href="https://cdn.example.com">
            <link rel="preconnect" href="https://cdn.example.com">
            <link rel="stylesheet" href="https://cdn.example.com/element-plus@2.6.0/dist/index.css">
          `,
          cdnScripts: `
            <script src="https://cdn.example.com/vue@3.4.0/dist/vue.global.prod.js"></script>
            <script src="https://cdn.example.com/vue-router@4.2.0/dist/vue-router.global.prod.js"></script>
            <script src="https://cdn.example.com/pinia@2.1.0/dist/pinia.iife.prod.js"></script>
            <script src="https://cdn.example.com/element-plus@2.6.0/dist/index.full.min.js"></script>
          `
        }
      }
    })
  ]
})

// index.html 模板
<!DOCTYPE html>
<html>
<head>
  <%- cdnLinks %>
</head>
<body>
  <div id="app"></div>
  <%- cdnScripts %>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

效果:vendor.js 从 850KB 砍到 50KB(只剩业务依赖),CDN 资源有 cache 直接命中。

优化 3:按需引入 + Tree Shaking

// 错:全量引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)

// 对:按需引入(unplugin-auto-import)
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    AutoImport({
      resolvers: [ElementPlusResolver()]
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ]
})

// 业务代码直接用,不用 import
<template>
  <el-button @click="onClick">Click</el-button>
</template>
// 编译时只打包 el-button 的代码和 css
// echarts 也要按需
// 错
import echarts from 'echarts'    // 全量 ~800KB

// 对
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([
  LineChart, BarChart,
  GridComponent, TooltipComponent, LegendComponent,
  CanvasRenderer
])
// 只用到的图表类型和组件,~150KB

优化 4:图片优化

<!-- 错:大 PNG 直接放 -->
<img src="/banner.png" />     <!-- 1.5MB -->

<!-- 对:WebP / AVIF + 响应式 -->
<picture>
  <source type="image/avif" srcset="
    /banner.avif 1x,
    /banner@2x.avif 2x
  ">
  <source type="image/webp" srcset="
    /banner.webp 1x,
    /banner@2x.webp 2x
  ">
  <img src="/banner.jpg" alt="" loading="lazy" decoding="async" />
</picture>

<!-- LCP 候选图片需要 priority hint -->
<img src="/hero.jpg" fetchpriority="high" />

<!-- 非首屏图片 lazy -->
<img src="/product1.jpg" loading="lazy" />
# 自动转 WebP / AVIF
$ npm install vite-plugin-imagemin -D

# vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'

plugins: [
  viteImagemin({
    gifsicle: { optimizationLevel: 7 },
    optipng: { optimizationLevel: 7 },
    mozjpeg: { quality: 75 },
    pngquant: { quality: [0.65, 0.9] },
    webp: { quality: 75 },
    svgo: { plugins: [{ name: 'removeViewBox', active: false }] }
  })
]

# 效果:banner.png 1.5MB → banner.webp 280KB

优化 5:字体优化

/* 错:全字体 + 阻塞渲染 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-full.woff2') format('woff2');
  /* 全套字体 800KB,渲染被阻塞 */
}

/* 对:font-display: swap + 子集化 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-cn-2500.woff2') format('woff2');
  font-display: swap;       /* 先用降级字体,字体加载完再换 */
  unicode-range: U+4E00-9FFF;   /* 只匹配中文范围 */
}

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-en.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0020-007F;
}

/* preload 关键字体 */
<link rel="preload" href="/fonts/custom-cn-2500.woff2" as="font" type="font/woff2" crossorigin>
# 字体子集化:fonttools
$ pip install fonttools
$ pyftsubset custom-full.woff2 \
    --unicodes=U+4E00-9FFF \
    --output-file=custom-cn-2500.woff2 \
    --flavor=woff2

# 字体大小:800KB → 200KB(中文常用 2500 字)

优化 6:Critical CSS 内联

// 把首屏需要的 CSS 内联到 HTML,其余异步加载
// vite-plugin-critical
import Critters from 'critters'

const critters = new Critters({
  preload: 'swap',
  inlineFonts: true
})
const html = await critters.process(rawHtml)

// 效果:
// 首屏 CSS 内联进 HTML(8KB),其余 CSS lazy load
// FCP 提前 500-800ms

优化 7:HTTP/2 推送 + Resource Hints

<head>
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="https://api.example.com">
  <link rel="dns-prefetch" href="https://cdn.example.com">

  <!-- 提前建立连接 -->
  <link rel="preconnect" href="https://api.example.com" crossorigin>
  <link rel="preconnect" href="https://cdn.example.com" crossorigin>

  <!-- 关键资源 preload -->
  <link rel="preload" href="/main.css" as="style">
  <link rel="preload" href="/main.js" as="script">
  <link rel="preload" href="/hero.webp" as="image">

  <!-- 可能用到的资源 prefetch -->
  <link rel="prefetch" href="/views/users.chunk.js">

  <!-- 提前发起 API 请求,等 main.js 来用 -->
  <link rel="preload" href="/api/user/profile" as="fetch" crossorigin>
</head>

优化 8:骨架屏 + Loading 状态

<!-- App.vue 里直接渲染骨架,等 Vue mount 时替换 -->
<template>
  <div id="app">
    <router-view v-slot="{ Component }">
      <Suspense>
        <component :is="Component" />
        <template #fallback>
          <SkeletonLoader />
        </template>
      </Suspense>
    </router-view>
  </div>
</template>

<!-- 在 index.html 写死最初的骨架,JS 加载前用户就看到内容 -->
<div id="app">
  <div class="initial-skeleton">
    <div class="skeleton-header"></div>
    <div class="skeleton-sidebar"></div>
    <div class="skeleton-content">
      <div class="skeleton-row" v-for="i in 5"></div>
    </div>
  </div>
</div>
<style>
.initial-skeleton { /* CSS 直接写 */ }
.skeleton-row { background: linear-gradient(...); animation: shimmer 1.5s infinite; }
</style>

优化 9:接口并行 + 缓存

// 错:串行调用接口
async function loadDashboard() {
  const user = await api.getUser()
  const stats = await api.getStats()
  const recent = await api.getRecent()
  // 3 个串行 = 3 × 200ms = 600ms
}

// 对:Promise.all 并行
async function loadDashboard() {
  const [user, stats, recent] = await Promise.all([
    api.getUser(),
    api.getStats(),
    api.getRecent()
  ])
  // 并行 200ms
}

// 进阶:SWR / vue-query 缓存
import { useQuery } from '@tanstack/vue-query'

const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: api.getUser,
  staleTime: 5 * 60 * 1000,      // 5 分钟内不重新请求
  gcTime: 30 * 60 * 1000         // 30 分钟后从缓存清除
})

优化 10:打包配置

// vite.config.ts
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
import compression from 'vite-plugin-compression'

export default defineConfig({
  build: {
    target: 'es2020',                    // 现代浏览器,产物小
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
        pure_funcs: ['console.log']
      }
    },
    rollupOptions: {
      output: {
        manualChunks: {
          // 把固定的工具库单独切 chunk,缓存友好
          'lodash': ['lodash-es'],
          'date': ['dayjs'],
          'utils': ['axios', 'qs', 'js-cookie']
        },
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js',
        assetFileNames: ({ name }) => {
          if (/\.(png|jpe?g|gif|svg|webp|avif)$/.test(name)) return 'images/[name]-[hash][extname]'
          if (/\.(woff2?|eot|ttf|otf)$/.test(name)) return 'fonts/[name]-[hash][extname]'
          if (/\.css$/.test(name)) return 'css/[name]-[hash][extname]'
          return 'assets/[name]-[hash][extname]'
        }
      }
    },
    cssCodeSplit: true,                  // CSS 跟 chunk 一起 split
    sourcemap: false,                    // 生产关闭(或上传 sentry 后删除)
    reportCompressedSize: false          // 加快构建
  },
  plugins: [
    compression({
      algorithm: 'gzip',
      ext: '.gz'
    }),
    compression({
      algorithm: 'brotliCompress',       // brotli 更好,但需 nginx 支持
      ext: '.br'
    }),
    visualizer({ open: true, gzipSize: true })
  ]
})

优化前后对比

指标                       优化前        优化后
==========================================
FCP                       4.5s          1.0s     (-78%)
LCP                       6.2s          1.4s     (-77%)
TBT                       850ms         180ms    (-79%)
CLS                       0.18          0.02
Speed Index               5800          1500     (-74%)
首屏请求数                 42            8        (-81%)
首屏字节数(gzip)          1.4MB         95KB    (-93%)
Lighthouse 总分            42 → 96

核对清单

  1. 路由懒加载 + 业务模块切 chunk
  2. 大库 CDN 化(vue / element / echarts)
  3. 按需引入 + Tree Shaking
  4. 图片格式 WebP / AVIF + lazy loading
  5. 字体子集化 + font-display: swap
  6. Critical CSS 内联
  7. preconnect / preload / prefetch 用好
  8. 骨架屏写到 index.html
  9. 接口并行 + SWR 缓存
  10. Brotli + gzip 双压缩
  11. HTTP/2 (尽量 HTTP/3)
  12. 关键 LCP 图加 fetchpriority="high"

首屏优化是个堆细节的活,没有银弹。这次两周的优化涉及十几项配合,LCP 从 6.2 秒降到 1.4 秒。最大的几个杠杆是:CDN 化大库(省 800KB)+ 路由懒加载(主 bundle 砍 70%)+ 关键 CSS 内联(FCP 提前 500ms)。后端 API 也跟着配 HTTP/2 + Brotli,效果叠加更明显。这套优化跑了一年,日 PV 100w 没出过性能问题。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Elasticsearch 8000 主分片治理实战:p99 从 5s 降到 200ms

2026-5-19 11:45:56

技术教程

从 Istio 撤下:14 个月 Service Mesh 实践复盘 + 替代方案对比

2026-5-19 11:50:07

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索