【Vue 路由系列 04】路由懒加载与打包优化:从碎片化到精细化控制
"我知道 HTTP/1.1 并发上限是 6 个,也知道 Vite 有
manualChunks,但说不出具体方案。" —— 这说明方向对了,缺的是落地经验。在前两篇中,我们搞清楚了路由怎么配(03篇:嵌套/动态路由/编程式导航)和谁能进来(02篇:守卫系统/权限控制)。这一篇关注性能——当你的应用有几十个路由时,如何组织打包产物让首屏加载最快? 路由懒加载不只是写个
() => import()就完事了(03 篇的 0.6 节提到过但没展开),Webpack/Vite 的运行时到底怎么加载 chunk?分组策略怎么设计?HTTP/2 下还要不要分组?这些才是面试官真正想考察的深度。
一、为什么需要路由懒加载
1.1 不使用懒加载的问题
// ❌ 全部静态导入
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Dashboard from '@/views/Dashboard.vue'
import UserList from '@/views/UserList.vue'
import Settings from '@/views/Settings.vue'
// ... 还有 30 个页面
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/dashboard', component: Dashboard },
{ path: '/users', component: UserList },
{ path: '/settings', component: Settings },
// ...
]
打包结果:
app.js → 2.3MB (包含所有页面组件 + 业务代码 + 第三方库)
vendor.js → 800KB (vue, vue-router, axios 等)
index.html
用户访问首页:
→ 浏览器下载 index.html
→ 解析发现需要 app.js (2.3MB) + vendor.js (800KB)
→ 总计 3.1MB 需要下载和解析
→ 首屏白屏时间长到用户想关掉网页 💀
问题核心:用户只需要看首页,但你让他下载了所有页面的代码(包括可能永远不看的后台管理页面)。
1.2 使用懒加载后
// ✅ 动态导入(懒加载)
const routes = [
{
path: '/',
component: () => import('@/views/Home.vue') // 注意这里!
},
{
path: '/about',
component: () => import('@/views/About.vue')
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue')
}
]
打包结果:
index.html
app.js → 200KB (只包含路由逻辑 + 核心代码)
chunk-Home.js → 50KB (首页组件)
chunk-About.js → 30KB (关于页组件)
chunk-Dashboard.js → 80KB (仪表盘组件)
chunk-Settings.js → 60KB (设置页组件)
...每个页面一个独立文件
用户访问首页:
→ 下载 index.html + app.js (200KB) ✓ 快!
→ 用户点击跳转到 /about
→ 此时才下载 chunk-About.js (30KB) ✓ 按需加载!
效果对比:
| 指标 | 不用懒加载 | 使用懒加载 |
|---|---|---|
| 首屏 JS 大小 | 2.3MB | 250KB |
| 首屏加载时间 | ~5s(4G) | ~0.8s |
| 总传输量(浏览全站) | 3.1MB | 3.1MB(一样,但分散了) |
| 用户感知速度 | 慢(一次性等很久) | 快(先看到首页,后续按需) |
二、懒加载的工作原理
2.1 动态 import() 的魔法
() => import('@/views/Home.vue') 看起来很简单,但它背后发生了很多事情:
// 你写的:
component: () => import('@/views/Home.vue')
// Webpack/Vite 编译后的结果(简化版):
component: () =>
import(/* webpackChunkName: "views-Home" */ '@/views/Home.vue')
// 或者 Vite 生成:
// () => import('/src/views/Home.vue?t=1741234567890')
执行流程:
用户导航到 /home
↓
Vue Router 执行路由匹配
↓
发现 Home 组件是懒加载的(是一个返回 Promise 的函数)
↓
调用该函数 → 触发动态 import()
↓
Webpack/Vite 运行时:
1. 在 DOM 中创建 <script src="chunk-Home.js"> 标签
2. 浏览器开始下载这个 JS 文件
3. 下载完成后执行脚本
4. 脚本内部 resolve(HomeComponent)
↓
Promise resolved → 组件可用
↓
渲染 Home 组件
2.2 ⚠️ 进阶:Webpack runtime 到底怎么加载 chunk?
面试如果被追问"懒加载原理",上面的流程能拿 70 分。下面是 100 分答案——Webpack 打包后的运行时到底做了什么:
第一步:打包时的代码分割
当你写 import('@/views/Home.vue') 时,Webpack 做了这些事:
原始代码:
component: () => import('@/views/Home.vue')
Webpack 编译后(伪代码):
// 主 chunk (app.js) 中生成一个"加载函数":
var chunkLoadingGlobal = "webpackChunk";
// ...
// component 变成了这样:
component: function() {
return __webpack_require__.e("views-Home") // "e" = ensure(确保 chunk 已加载)
.then(__webpack_require__.bind(__webpack_require__, 1234)) // 1234 是模块 ID
}
第二步:运行时的 chunk 加载
当 Vue Router 首次调用 component() 函数时:
// __webpack_require__.e(chunkId) 的内部逻辑(极度简化版):
__webpack_require__.e = function ensure(chunkId) {
// 1. 检查缓存:这个 chunk 已经加载过了吗?
var promises = installedChunks[chunkId]; // installedChunks 是全局注册表
if (promises !== undefined) {
// 已加载或正在加载中 → 直接返回已有的 Promise(去重!)
// 这意味着快速连续导航不会重复下载同一个 chunk
return Promise.all(promises);
}
// 2. 创建一个 Promise 数组(支持多个依赖)
var promise = new Promise(function(resolve, reject) {
installedChunks[chunkId] = [resolve, reject];
});
// 3. 创建 <script> 标签动态插入 DOM
var script = document.createElement('script');
script.charset = 'utf-8';
script.timeout = 120; // 120 秒超时
// 关键!src 指向 chunk 文件的 URL
// 如果配置了 publicPath + hash,类似:
// src="/assets/js/views-Home-a1b2c3d4.js"
script.src = jsonpScriptSrc(chunkId);
// 4. 处理加载成功
var onScriptComplete = function(prevEvent) {
script.onerror = script.onload = null; // 清除事件监听(防泄漏)
var chunk = installedChunks[chunkId];
if (chunk !== undefined) {
// chunk 内容已执行,resolve 掉 Promise
var callbacks = chunk[0];
callbacks(); // 触发 then 链
installedChunks[chunkId] = 0; // 标记为已加载
}
};
script.onerror = script.onload = onScriptComplete;
// 5. 插入 DOM 开始下载
document.head.appendChild(script);
return promise;
};
第三步:chunk 文件执行后通知主程序
被动态加载的 chunk 文件长这样:
// views-Home-a1b2c3d4.js(Webpack 生成的 chunk 文件内容)
// chunk 内部格式:
(window["webpackChunk"] = window["webpackChunk"] || []).push([
["views-Home"], // chunk ID 列表
{ // 模块定义表
1234: function(module, exports, __webpack_require__) {
// Home.vue 编译后的 render 函数 + setup 代码
eval("const _hoisted_1 = ..."); // 实际是压缩编译后代码
}
},
[[1235]] // 这个 chunk 依赖的其他 chunk(如共享的 vendor 模块)
]);
关键动作:window["webpackChunk"].push() 会触发 Webpack runtime 内部的回调,runtime 收到模块定义后将其注册到模块系统中,然后 ensure() 函数中的 Promise 被 resolve。
第四步:完整时序图
Vue Router: "需要渲染 /home 页面"
↓
调用 component()
→ 即 __webpack_require__.e("views-Home")
↓
Runtime 检查: installedChunks["views-Home"] ?
├─ 有值且 === 0 → 已加载,直接返回 resolved Promise ✅
├─ 有值且是数组 → 正在加载中,复用已有 Promise(去重!)✅
└─ undefined → 首次加载,开始以下流程 👇
↓
创建 Promise → 存入 installedChunks["views-Home"]
↓
创建 <script src="/assets/js/views-Home-hash.js">
↓
document.head.appendChild(script)
↓
浏览器开始 HTTP 请求下载 JS 文件
↓ (网络传输中...)
下载完成 → 浏览器解析+执行 JS
↓
chunk 内部: window["webpackChunk"].push([moduleDefs])
↓
Runtime push 回调触发:
- 将模块注册到 __webpack_require__.m 缓存
- resolve installedChunks 中的 Promise
↓
Promise.then → __webpack_require__(moduleId) 执行组件模块
↓
返回组件对象 → Vue Router 渲染 🎉
面试加分点:
如果能说出上面这套流程,说明你不只是"用过懒加载",而是理解了底层机制。核心关键词:
__webpack_require__.e(ensure)、installedChunks去重、动态<script>标签注入、window["webpackChunk"].push回调机制。
2.2 加载状态的处理
动态导入意味着网络请求,而网络请求可能会慢或失败。你需要给用户反馈:
方式一:简单的 loading
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
// Vue Router 支持在导航过程中显示 Loading 组件
// 但更推荐用下面方式二
}
]
方式二:配合 Suspense(Vue 3 推荐)
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<Suspense>
<!-- 实际内容 -->
<component :is="Component" :key="route.path" />
<!-- 加载中的 fallback -->
<template #fallback>
<div class="loading-container">
<LoadingSpinner />
<p>页面加载中...</p>
</div>
</template>
</Suspense>
</transition>
</router-view>
</template>
方式三:自定义加载策略
// 带超时处理的懒加载函数
function lazyLoad(importFn, { timeout = 10000, LoadingComp = DefaultLoading } = {}) {
return async () => {
const AsyncComp = defineAsyncComponent({
loader: importFn,
loadingComponent: LoadingComp,
delay: 200, // 延迟 200ms 再显示 loading(避免闪烁)
timeout, // 超时时间
errorComponent: ErrorComponent, // 加载失败时的组件
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
return retry() // 自动重试最多 3 次
}
fail() // 重试次数耗尽,显示错误组件
}
})
return AsyncComp
}
}
// 使用
const routes = [
{
path: '/heavy-page',
component: lazyLoad(
() => import('@/views/HeavyPage.vue'),
{ timeout: 15000 }
)
}
]
三、路由分组打包 —— 解决碎片化问题
3.1 什么是碎片化问题
当你有 40 个路由,每个都用懒加载,默认情况下会生成 40 个独立的 JS 文件:
chunk-Home.js → 45KB
chunk-About.js → 28KB
chunk-Dashboard.js → 82KB
chunk-UserList.js → 56KB
chunk-UserDetail.js → 63KB
chunk-UserCreate.js → 71KB
chunk-RoleList.js → 48KB
chunk-RoleDetail.js → 52KB
chunk-Settings.js → 67KB
...还有 31 个小文件
HTTP/1.1 时代:浏览器对同一域名的并发请求上限是 6 个。40 个文件意味着至少 7 轮请求才能完成,每轮都有 RTT(往返延迟)开销。
即使 HTTP/2 多路复用解决了并发限制,过多的 chunk 也会带来其他问题:
- 每个 chunk 都有自己的头部开销
- 模块间有共享依赖时会产生冗余代码
- 构建变慢(更多文件需要处理)
3.2 Webpack 方案:webpackChunkName 魔法注释
// ===== 基础用法:指定 chunk 名称 =====
const routes = [
{
path: '/user/list',
component: () => import(
/* webpackChunkName: "user" */
'@/views/user/UserList.vue'
)
},
{
path: '/user/detail/:id',
component: () => import(
/* webpackChunkName: "user" */
'@/views/user/UserDetail.vue'
)
},
{
path: '/role/list',
component: () => import(
/* webpackChunkName: "role" */
'@/views/role/RoleList.vue'
)
},
{
path: '/role/detail/:id',
component: () => import(
/* webpackChunkName: "role" */
'@/views/role/RoleDetail.vue'
)
}
]
// 相同 webpackChunkName 的路由会被打包到同一个文件中:
// user.[hash].js → 包含 UserList + UserDetail
// role.[hash].js → 包含 RoleList + RoleDetail
更精细的控制:使用 webpackPrefetch 和 webpackPreload
const routes = [
// prefetch:浏览器空闲时预加载(适合可能即将访问的页面)
{
path: '/about',
component: () => import(
/* webpackChunkName: "about" */
/* webpackPrefetch: true */
'@/views/About.vue'
)
},
// preload:与当前资源并行加载(适合当前页面立即需要的资源)
// 通常用于路由视图内的重型子组件,而不是路由本身
]
prefetch vs preload 对比:
📊 数据来源:Can I Use (2026年2月) / Can I Use - preload
| 特性 | prefetch | preload |
|---|---|---|
| 加载时机 | 浏览器空闲时(低优先级) | 与当前资源并行(高优先级) |
| 适用场景 | 可能下一步访问的页面(如"关于"页、"设置"页) | 当前页面立即需要的资源(如路由视图内的重型子组件、字体文件) |
| 资源优先级 | 最低(Lowest) | 高(High) |
| 全局支持率 | ~81% | ~97% |
| Chrome | ✅ v8+ 全面支持 | ✅ v50+ |
| Firefox | ✅ v2+ 全面支持 | ✅ v85+ |
| Edge | ✅ 全版本支持 | ✅ v79+ |
| Safari / iOS Safari | ❌ 默认禁用(所有版本) 技术上存在但需手动开启 | ✅ Safari 11.1+ / iOS 11.3+ |
| 影响 | 不影响当前页面性能 ⚠️ 但 Safari 用户无效 | 可能抢占当前页面资源 需谨慎使用 |
⚠️ 关键提醒:如果你的用户群体包含大量 iOS/Safari 用户(如移动端应用),
webpackPrefetch可能完全不起作用。此时应考虑其他策略(如提前加载关键路由 chunk 或使用preload)。
3.3 Vite 方案:manualChunks 配置
Vite 生产环境使用 Rollup 打包,配置方式不同但思路一致:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
// ===== 方式一:按目录自动分组 =====
manualChunks(id) {
// 将 node_modules 下的第三方库单独打包
if (id.includes('node_modules')) {
// 进一步拆分大库
if (id.includes('vue') || id.includes('vue-router')) {
return 'vue-vendor'
}
if (id.includes('echarts')) {
return 'echarts' // ECharts 特别大(~1MB),必须单独拆
}
if (id.includes('element-plus')) {
return 'element-plus'
}
return 'vendor' // 其他第三方库
}
// 将 views 下的页面按一级目录分组
if (id.includes('/views/user/')) {
return 'pages-user'
}
if (id.includes('/views/admin/')) {
return 'pages-admin'
}
if (id.includes('/views/report/')) {
return 'pages-report'
}
// 其余保持默认行为
},
// ===== 方式二:显式声明分组(更可控)=====
// manualChunks: {
// 'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 'ui-library': ['element-plus'],
// 'charts': ['echarts'],
// 'utils': ['lodash-es', 'dayjs', 'axios'],
// },
// chunk 文件名模板
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]'
}
}
}
})
分组策略的设计原则:
原则 1:变更频率相近的放一起
- vue + vue-router + pinia → 很少变动,打成一个 vendor
- 业务代码 → 经常变动,单独打包(利用长期缓存)
原则 2:体积特别大的单独拆
- echarts (~1MB) → 单独一个 chunk
- element-plus (~800KB) → 单独一个 chunk
- monaco-editor (~2MB) → 单独一个 chunk
原则 3:按业务模块分组
- user 相关的所有页面 → pages-user
- admin 相关的所有页面 → pages-admin
- report 报表相关 → pages-report
原则 4:同步访问的页面放一起
- UserList 和 UserCreate 通常连续访问 → 同一个 chunk
- Dashboard 和它的子面板 → 同一个 chunk
3.4 分组前后的对比
分组前(40 个独立 chunk):
static/js/Home-[hash].js (45 KB)
static/js/About-[hash].js (28 KB)
static/js/Dashboard-[hash].js (82 KB)
static/js/UserList-[hash].js (56 KB)
static/js/UserDetail-[hash].js (63 KB)
static/js/UserCreate-[hash].js (71 KB)
static/js/RoleList-[hash].js (48 KB)
static/js/RoleDetail-[hash].js (52 KB)
static/js/Settings-[hash].js (67 KB)
... 共 42 个 chunk 文件
总请求数(访问首页):7 次(app + vendor + Home + 各依赖)
分组后(按模块合并):
static/js/app-[hash].js (120 KB) ← 入口
static/js/vue-vendor-[hash].js (150 KB) ← Vue 核心
static/js/element-plus-[hash].js (350 KB) ← UI 库
static/js/echarts-[hash].js (980 KB) ← 图表库(按需引入可缩小)
static/js/pages-user-[hash].js (190 KB) ← 用户模块(4个页面合并)
static/js/pages-admin-[hash].js (230 KB) ← 管理模块(6个页面合并)
static/js/pages-report-[hash].js (310 KB) ← 报表模块(5个页面合并)
static/js/pages-other-[hash].js (280 KB) ← 其他杂项页面
共 8 个 chunk 文件
总请求数(访问首页):3 次(app + vue-vendor + element-plus)
四、HTTP/2 时代的思考
4.1 分组还有必要吗?
| 维度 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 并发限制 | 同域名 6 个 TCP 连接 | 多路复用,单连接无限并发 |
| 请求开销 | 每次 TCP 握手 + 头部压缩弱 | HPACK 头部压缩,复用连接 |
| 推送能力 | 无 | Server Push(虽然实际部署少) |
结论:HTTP/2 解决了多小文件的并发问题,但不代表分组没有意义了。
4.2 HTTP/2 下仍然需要分组的理由
理由 1:压缩效率
→ gzip/brotli 在大文件上的压缩率更高
→ 40 个小文件 × 重复的 import 语句 vs 合并后的去重
→ 实测:分组后总体积减少 15-25%
理由 2:解析成本
→ JavaScript 引擎解析 100 个小文件比 10 个大文件慢
→ 每个文件都需要 parse + compile + execute 的初始化过程
理由 3:缓存命中率
→ 页面 A 和 B 共享同一个 chunk
→ 访问 A 后缓存了 pages-user chunk
→ 访问 B 时直接命中缓存,无需重新下载
→ 如果拆成细粒度,缓存粒度过小反而降低命中率
理由 4:构建和部署管理
-> 几十个 chunk 比 100+ 个好管理
-> CI/CD 构建产物检查更快
4.3 现代 SPA 的推荐策略
┌─────────────────────────────────────────────┐
│ 推荐的打包策略 │
├─────────────────────────────────────────────┤
│ │
│ ① 所有路由都使用懒加载 │
│ component: () => import(...) │
│ │
│ ② 第三方库按"稳定性"分组 │
│ - vue-vendor (vue + vue-router + pinia) │
│ - ui-vendor (element-plus / ant-design) │
│ - charts (echarts / chart.js) │
│ - utils (lodash / dayjs / axios) │
│ │
│ ③ 业务路由按"功能模块"分组 │
│ - 同一功能模块的路由合并为一个 chunk │
│ - 用 manualChunks 或 webpackChunkName │
│ │
│ ④ 超大依赖按需引入 │
│ - echarts 按图表类型引入 │
│ - moment.js 改用 dayjs(tree-shaking 友好)│
│ - lodash 改用 lodash-es(ESM 版本) │
│ │
│ ⑤ 对关键路径使用 preload/prefetch │
│ - 首屏之后大概率访问的页面加 prefetch │
│ - L 型路径的用户旅程优化 │
│ │
└─────────────────────────────────────────────┘
五、实战案例:从零搭建一个大型项目的路由打包方案
5.1 项目结构
src/
├── views/
│ ├── dashboard/ # 仪表盘模块
│ │ ├── Index.vue
│ │ └── components/
│ ├── user/ # 用户管理模块
│ │ ├── List.vue
│ │ ├── Detail.vue
│ │ └── Create.vue
│ ├── order/ # 订单模块
│ │ ├── List.vue
│ │ └── Detail.vue
│ ├── finance/ # 财务模块(重度使用 echarts)
│ │ ├── Report.vue
│ │ └── Analysis.vue
│ └── system/ # 系统设置模块
│ ├── Role.vue
│ ├── Menu.vue
│ └── Config.vue
├── router/
│ └── index.js
└── main.js
5.2 路由配置
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 使用统一的懒加载包装器
function lazyPage(path) {
return () => import(`@/views/${path}.vue`)
}
const routes = [
{
path: '/',
name: 'layout',
component: () => import('@/components/Layout.vue'),
children: [
{
path: '',
name: 'dashboard',
component: lazyPage('dashboard/Index'),
meta: { title: '仪表盘' }
},
// ---- 用户管理(会合并为同一个 chunk)----
{
path: 'user/list',
name: 'user-list',
component: lazyPage('user/List'),
meta: { title: '用户列表', group: 'user' }
},
{
path: 'user/create',
name: 'user-create',
component: lazyPage('user/Create'),
meta: { title: '创建用户', group: 'user' }
},
{
path: 'user/:id',
name: 'user-detail',
component: lazyPage('user/Detail'),
meta: { title: '用户详情', group: 'user' }
},
// ---- 订单模块 ----
{
path: 'order/list',
name: 'order-list',
component: lazyPage('order/List'),
meta: { title: '订单列表', group: 'order' }
},
{
path: 'order/:id',
name: 'order-detail',
component: lazyPage('order/Detail'),
meta: { title: '订单详情', group: 'order' }
},
// ---- 财务报表(重度 echarts)----
{
path: 'finance/report',
name: 'finance-report',
component: lazyPage('finance/Report'),
meta: { title: '财务报表', group: 'finance' }
},
{
path: 'finance/analysis',
name: 'finance-analysis',
component: lazyPage('finance/Analysis'),
meta: { title: '数据分析', group: 'finance' }
},
// ---- 系统设置 ----
{
path: 'system/role',
name: 'system-role',
component: lazyPage('system/Role'),
meta: { title: '角色管理', group: 'system' }
},
{
path: 'system/menu',
name: 'system-menu',
component: lazyPage('system/Menu'),
meta: { title: '菜单管理', group: 'system' }
}
]
}
]
export default routes
5.3 Vite 配置
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// 启用 brotli 压缩(比 gzip 压缩率高 15-20%)
// brotliSize: true, // 需要 vite-plugin-brotli
// 单个 chunk 上限警告阈值(帮助发现异常大的包)
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
// 核心分组策略
manualChunks(id) {
// ===== 1. 第三方库分组 =====
if (id.includes('node_modules')) {
// Vue 生态(稳定,很少更新)
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
return 'vue-core'
}
// UI 组件库(较大且独立)
if (id.includes('element-plus') || id.includes('ant-design-vue')) {
return 'ui-lib'
}
// 图表库(超大!必须单独拆分)
if (id.includes('echarts') || id.includes('chart.js')) {
return 'chart-lib'
}
// 工具库(轻量级工具集合)
if (id.includes('lodash') || id.includes('dayjs') ||
id.includes('axios') || id.includes('qs')) {
return 'util-lib'
}
// 其他第三方库
return 'vendor'
}
// ===== 2. 业务页面按模块分组 =====
// 利用路由 meta.group 字段来分组
// 但 Rollup 不知道 meta 信息,所以通过文件路径判断
if (id.includes('/views/user/')) {
return 'pages-user'
}
if (id.includes('/views/order/')) {
return 'pages-order'
}
if (id.includes('/views/finance/')) {
return 'pages-finance'
}
if (id.includes('/views/system/')) {
return 'pages-system'
}
if (id.includes('/views/dashboard/')) {
return 'pages-dashboard'
}
},
// 文件命名规则
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
}
}
}
})
5.4 最终打包产出
dist/
├── assets/
│ ├── js/
│ │ ├── app-[hash].js (85 KB) ← 应用入口
│ │ ├── vue-core-[hash].js (145 KB) ← Vue + Pinia + VueRouter
│ │ ├── ui-lib-[hash].js (320 KB) ← Element Plus
│ │ ├── util-lib-[hash].js (35 KB) ← Axios + Dayjs + Qs
│ │ ├── chart-lib-[hash].js (850 KB) ← ECharts(可按需引入优化)
│ │ ├── pages-dashboard-[hash].js (65 KB) ← 仪表盘
│ │ ├── pages-user-[hash].js (155 KB) ← 用户管理 3 页面合并
│ │ ├── pages-order-[hash].js (95 KB) ← 订单 2 页面合并
│ │ ├── pages-finance-[hash].js (380 KB) ← 财务 2 页面(含 echarts)
│ │ ├── pages-system-[hash].js (120 KB) ← 系统 2 页面合并
│ │ └── vendor-[hash].js (25 KB) ← 其他零散依赖
│ └── css/
│ ├── ui-lib-[hash].css (280 KB) ← UI 样式
│ └── app-[hash].css (15 KB) ← 自定义样式
├── index.html
└── favicon.ico
六、面试高频问题速查
Q1:什么是路由懒加载?为什么要用?
路由懒加载是将各路由对应的组件通过动态
import()进行代码分割,使得只有在访问该路由时才加载对应组件的代码。主要目的是减小首屏加载体积——用户不需要一次性下载所有页面的代码,只需加载当前页面所需的资源,其余按需加载。实现方式是将component: import(Component)改为component: () => import(Component)。
Q2:懒加载会导致大量的小 JS 文件,怎么解决?
这是路由碎片化问题。解决方案是路由分组打包:在 Webpack 中使用
/* webpackChunkName: "group-name" */魔法注释将相关路由合并;在 Vite 中使用build.rollupOptions.output.manualChunks按功能模块或第三方库进行聚合。分组原则是:变更频率相同的放一起、特别大的库单独拆、同业务模块的路由合并且、同步访问概率高的页面放同一组。这样既能享受懒加载的按需优势,又能避免过多 HTTP 请求的开销。
Q3:webpackPrefetch 和 webpackPreload 有什么区别?
prefetch是在浏览器空闲时以最低优先级预加载资源,适合用户下一步可能访问的页面(如从列表页点进详情页);preload是与当前页面资源并行以高优先级加载,适合当前页面渲染就需要的资源。对于路由组件来说,通常对非首屏的关键路径页面使用prefetch: true就够了,慎用preload因为它可能与首屏资源争抢带宽。
七、本篇小结
| 概念 | 一句话记忆 |
|---|---|
| 路由懒加载 | () => import() 把组件变成异步加载,减小首屏体积 |
| Webpack runtime 加载原理 | __webpack_require__.e(chunkId) → 检查 installedChunks 缓存 → 动态注入 <script> 标签 → chunk 执行 window["webpackChunk"].push() → Promise resolve |
| installedChunks | Webpack 运行时的全局注册表,负责 chunk 加载去重和缓存 |
| chunk 文件格式 | (window["webpackChunk"] ||= []).push([[chunkId], {moduleId: fn}, [deps]]) |
| 碎片化问题 | 太多路由各自独立打包 → 大量小文件 → HTTP 开销大 |
| Webpack 分组 | /* webpackChunkName: "group" */ 魔法注释,同名合并 |
| Vite 分组 | manualChunks 配置函数,按路径/库名灵活分组 |
| 分组原则 | 稳定性相同放一起、超大库单独拆、同模块合并、同步访问合并 |
| prefetch | 空闲时预加载(低优先级),适合下一步可能访问的页面 |
| preload | 并行加载(高优先级),适合当前页面立即需要的资源 |
| HTTP/2 还要不要分组 | 要!压缩效率更高、解析更快、缓存命中率更好 |
| Suspense + 异步组件 | Vue 3 的标准方式处理加载态,配合 defineAsyncComponent |
下一篇预告:搞定了路由怎么配、谁能进来、怎么打包优化之后,下一篇我们回到用户体验层面——SPA 状态保持与滚动还原。用户后退时滚动位置怎么恢复?keep-alive 怎么正确使用?history.state 能存什么?这些直接影响用户对 SPA 的"流畅度"感知。
👉 Vue 路由系列 05:状态保持与滚动还原 —— scrollBehavior + keep-alive + history.state
🔗 回顾前篇:Vue 路由系列 03:动态路由与权限控制 —— 路由体系全貌 + addRoute + 404 问题
参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、Webpack 官方文档、Vite 官方文档