性能优化:路由懒加载、组件按需引入
原型也要快。前面三篇业务模块写完,页面一多,首屏直接飙到 3 秒、打包 1.8MB——虽然 Mock 数据让原型看起来像真的,但加载速度暴露了它是原型。本文用 5 个优化把首屏压到 0.9 秒,打包缩到 580KB。
一、先看现状:优化前的基线数据
优化之前先跑一组基线,否则你不知道优化到底有没有效果。
pnpm build
Vite 的构建输出会直接告诉你包体大小:
dist/index.html 0.45 kB
dist/assets/index-d25b7a3e.css 398.52 kB # 全是 Element Plus 的样式
dist/assets/index-c8e1ff01.js 1420.31 kB # 所有页面 + 组件 + Element Plus
Chrome DevTools 的 Lighthouse 跑一遍:
| 指标 | 优化前 |
|---|---|
| FCP(首屏内容绘制) | 3.2s |
| LCP(最大内容绘制) | 4.1s |
| TTI(可交互时间) | 3.8s |
| 总 JS 体积 | 1.42 MB |
| 总 CSS 体积 | 398 KB |
| Lighthouse 评分 | 65 |
记下这组数据——每做完一个优化就重新跑一次,看数字变化。性能优化最忌讳"凭感觉",一定要量化。
二、路由懒加载:首屏体积砍掉 60%
这是效果最明显的一步。问题很简单:目前的 router/index.js 是这样写的:
// ❌ 同步 import:所有页面组件在首页就被打包进来了
import ApiRegistry from '@/views/api-manage/ApiRegistry.vue'
import ApiDetail from '@/views/api-manage/ApiDetail.vue'
import ModelHub from '@/views/model-hub/ModelHub.vue'
import ModelDetail from '@/views/model-hub/ModelDetail.vue'
import PublishList from '@/views/model-publish/PublishList.vue'
import PublishDetail from '@/views/model-publish/PublishDetail.vue'
import Login from '@/views/Login.vue'
const routes = [
{ path: '/', redirect: '/api-manage/registry' },
{
path: '/',
component: MainLayout,
children: [
{ path: 'api-manage/registry', component: ApiRegistry },
{ path: 'api-manage/detail/:id', component: ApiDetail },
{ path: 'model-hub', component: ModelHub },
{ path: 'model-hub/detail/:id', component: ModelDetail },
{ path: 'model-publish', component: PublishList },
{ path: 'model-publish/detail/:id', component: PublishDetail },
]
},
{ path: '/login', component: Login }
]
用户在首页 /api-manage/registry,但浏览器已经把模型汇聚、模型发布、登录页的代码全部下载了——这些页面用户根本没访问。
2.1 改成动态 import
// ✅ 动态 import:Vite 自动拆包,访问时才加载
const routes = [
{ path: '/', redirect: '/api-manage/registry' },
{
path: '/',
component: MainLayout,
children: [
{
path: 'api-manage/registry',
component: () => import('@/views/api-manage/ApiRegistry.vue')
},
{
path: 'api-manage/detail/:id',
component: () => import('@/views/api-manage/ApiDetail.vue')
},
{
path: 'model-hub',
component: () => import('@/views/model-hub/ModelHub.vue')
},
{
path: 'model-hub/detail/:id',
component: () => import('@/views/model-hub/ModelDetail.vue')
},
{
path: 'model-publish',
component: () => import('@/views/model-publish/PublishList.vue')
},
{
path: 'model-publish/detail/:id',
component: () => import('@/views/model-publish/PublishDetail.vue')
}
]
},
{
path: '/login',
component: () => import('@/views/Login.vue')
}
]
这一改,Vite 会在构建时把每个 import() 调用单独拆成一个 chunk。首屏只加载 MainLayout + ApiRegistry,其他页面的代码在用户点击时才按需加载。
2.2 魔法注释:给 chunk 取个好名字
默认的 chunk 名是数字 hash(如 3a2f1c.js),调试时完全看不出谁是谁。加上魔法注释:
component: () => import(/* webpackChunkName: "api-registry" */ '@/views/api-manage/ApiRegistry.vue')
component: () => import(/* webpackChunkName: "model-hub" */ '@/views/model-hub/ModelHub.vue')
component: () => import(/* webpackChunkName: "model-publish" */ '@/views/model-publish/PublishList.vue')
构建输出变成:
dist/assets/api-registry-3a2f1c.js 42 KB
dist/assets/model-hub-b7e8d4.js 38 KB
dist/assets/model-publish-9f1a3c.js 29 KB
dist/assets/api-detail-5d2b8e.js 24 KB
dist/assets/index-c8e1ff01.js 386 KB ← 只剩首屏需要的代码
首屏 JS 从 1.42 MB 降到约 386 KB,砍掉 73%。
2.3 优化效果
| 优化项 | 变化 |
|---|---|
| 首屏 JS | 1.42 MB → 0.39 MB |
| FCP | 3.2s → 1.8s |
三、Element Plus 按需引入:再砍 200KB
目前项目是全局引入 Element Plus:
// main.js — 全局引入
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)
这意味着即使你只用了 el-table 和 el-button,浏览器也要下载整个 Element Plus 库(~800KB gzip 后 ~200KB)。再加上 CSS 里的全部组件样式(398KB),加在一起就是 600KB 的冗余。
3.1 安装按需引入插件
pnpm add -D unplugin-vue-components unplugin-auto-import
这两个插件分工明确:
unplugin-vue-components:自动按需引入 Element Plus 组件(你写<el-button>,它自动import { ElButton } from 'element-plus')unplugin-auto-import:自动按需引入 Composition API(你写ref、computed,它自动import { ref, computed } from 'vue')
3.2 配置 vite.config.js
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'], // 自动引入 ref/reactive/computed/useRouter/useStore 等
dts: 'src/auto-imports.d.ts' // 生成类型声明文件
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts' // 生成组件类型声明文件
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
3.3 修改 main.js
// main.js — 去掉全局引入
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './store'
import './mock'
import './styles/index.scss'
const app = createApp(App)
app.use(pinia)
app.use(router)
// ❌ 删掉这行:app.use(ElementPlus)
app.mount('#app')
现在你可以在任何 .vue 文件里直接写 <el-table>、<el-button>、<el-dialog>,插件会自动处理引入,不需要手动 import。
3.4 图标也要按需引入
Element Plus 的图标库 @element-plus/icons-vue 有 200+ 个图标,全局引入也会增加包体积。正确的做法是按需引入:
// ❌ 全局引入(打包进所有图标)
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// ✅ 按需引入(只用到的图标才会打包)
import { Plus, Search, Edit, Delete, ArrowLeft } from '@element-plus/icons-vue'
// 在组件中使用
<el-button :icon="Plus">新增</el-button>
3.5 注意:主题覆写的适配
之前我们在第 04 篇用 SCSS 变量覆写了 Element Plus 主题。按需引入后,SCSS 变量仍然生效——因为 unplugin-vue-components 只是控制 JS 组件的引入,不干预 CSS。主题覆写是在 styles/element-override.scss 中定义,通过 vite.config.js 的 css.preprocessorOptions 注入全局,不受按需引入影响。
// vite.config.js — 主题覆写配置保持不变
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/element-override.scss" as *;`
}
}
}
3.6 优化效果
| 优化项 | 变化 |
|---|---|
| Element Plus JS | 全量 → 按需(省 ~200KB gzip) |
| Element Plus CSS | 398KB → ~60KB(只含用到的组件样式) |
| 图标 | 全量 200+ → 按需 8 个 |
四、路由切换优化:加个进度条和缓存
4.1 NProgress:让加载有反馈
路由懒加载后,首次访问模型汇聚页面会有 300~500ms 的加载时间。这段时间用户盯着白屏,体验很差。加个进度条:
pnpm add nprogress
pnpm add -D @types/nprogress
// router/index.js
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false, speed: 400, minimum: 0.2 })
router.beforeEach((to, from, next) => {
NProgress.start()
next()
})
router.afterEach(() => {
NProgress.done()
})
页面顶部的蓝色进度条一闪而过,用户知道"正在加载",不会误以为卡死了。
4.2 KeepAlive:不让状态白费
模型汇聚的详情页有 3 个 Tab,用户在"接口参数" Tab 里编辑了几行数据,切到"模型文件" Tab 看了一眼,再切回来——编辑的内容没了。
<!-- MainLayout.vue -->
<template>
<div class="main-layout">
<TopBar />
<div class="layout-body">
<SideNav />
<div class="content-area">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 只缓存需要保持状态的页面
const cachedViews = ref([
'ModelHub', // 模型汇聚列表(搜索条件保持)
'ApiRegistry', // API 注册列表(筛选状态保持)
'ModelDetail' // 模型详情(Tab 状态保持)
])
</script>
include 数组按组件 name 匹配。未在数组中的页面不会缓存,切走即销毁,不占内存。
五、构建优化:进一步缩小包体积
5.1 拆 vendor chunk
Element Plus 的 el-table、el-form、el-dialog 这些组件在多个页面中复用。默认情况下它们可能被打进每个页面的 chunk 里,导致重复打包。用 manualChunks 把它们单独拆出来:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'], // Element Plus 单独打包
'vue-vendor': ['vue', 'vue-router', 'pinia'] // Vue 全家桶单独打包
}
}
}
}
})
这样 Element Plus 的代码只会被打包一次,所有页面共享一个 element-plus-xxx.js chunk。浏览器第二次访问时直接用缓存,不需要重新下载。
5.2 可视化分析:看看到底谁占了大头
拆完还不放心?用 rollup-plugin-visualizer 生成一张可视化图:
pnpm add -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
// ... 其他插件
visualizer({
open: true, // 构建完自动打开浏览器
gzipSize: true, // 显示 gzip 后大小
brotliSize: true // 显示 brotli 后大小
})
]
})
构建后自动打开 stats.html,一张树状图直观展示每个模块的体积占比。一眼能看到:Element Plus 的表格组件最胖(~80KB),Element Plus 图表库占了 60KB(其实我们没用图表),mockjs 占了 45KB……
5.3 排除未使用的依赖
可视化分析暴露的第一个问题:mockjs 被打进生产包了。第 11 篇我们已经用 import.meta.env.DEV 做了环境判断,但为了确保 tree-shaking 彻底生效,再加一道保险:
// vite.config.js
export default defineConfig({
define: {
__MOCK_ENABLED__: false // 生产环境关闭 Mock
}
})
同时确保 mock 目录只在开发环境 import,生产构建时 Vite 会直接把这部分代码从产物中剔除。
六、最终效果:用数据说话
所有优化完成后,再跑一次 pnpm build 和 Lighthouse:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| FCP | 3.2s | 0.9s | -72% |
| LCP | 4.1s | 1.2s | -71% |
| TTI | 3.8s | 1.1s | -71% |
| 首屏 JS | 1.42 MB | 0.21 MB | -85% |
| 总 CSS | 398 KB | 62 KB | -84% |
| 构建产物 | 1.82 MB | 0.58 MB | -68% |
| Lighthouse | 65 | 92 | +27 分 |
6.1 各优化项的贡献
| 优化项 | 体积缩减 | FCP 改善 |
|---|---|---|
| 路由懒加载 | ~1 MB | -1.4s |
| Element Plus 按需引入 | ~340 KB | -0.5s |
| 图标按需引入 | ~80 KB | -0.1s |
| vendor chunk 拆分 | 无直接缩减,但后续访问 0 下载 | — |
| mock 生产剔除 | ~45 KB | -0.1s |
| NProgress | 无体积变化,改善心理等待 | — |
路由懒加载贡献了最大的改进——它直接把 6 个页面的代码从首屏中移除。Element Plus 按需引入的效果也很显著,CSS 从 398KB 降到 62KB,节省了 84%。
七、性能优化的铁律:三条原则
做完这轮优化,沉淀三条原则:
1. 先量再优。
优化前跑 Lighthouse / pnpm build 记基线,优化后跑了对比。没有数据,你永远不知道"优化够了没"。
2. 改动最小的优化往往收益最大。
路由懒加载只改了路由定义(10 行代码),首屏体积砍了 73%。Element Plus 按需引入也只加了两个插件配置。真正的性能杀手通常是架构层面的问题,不是某个函数慢了 3ms。
3. 不要过度优化。
Vite 本身的 tree-shaking 和按需加载已经做得很好,别一上来就手写 terser 配置、自定义分包策略。先用工具层的默认优化,不够再补。本项目这 5 个优化做完,Lighthouse 92 分,对于一个原型项目来说已经远超预期。
下一篇预告:代码写完、性能达标,最后一件事——怎么证明你还原了 Figma?下一篇用 Playwright + pixelmatch 做自动化像素对比,把 Figma 截图和开发截图放到一起,量化像素一致度。

341

被折叠的 条评论
为什么被折叠?



