[vue-router]04-懒加载与打包优化

6 阅读17分钟

【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.js2.3MB  (包含所有页面组件 + 业务代码 + 第三方库)
vendor.js800KB  (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.js200KB  (只包含路由逻辑 + 核心代码)
chunk-Home.js50KB  (首页组件)
chunk-About.js30KB  (关于页组件)
chunk-Dashboard.js80KB  (仪表盘组件)
chunk-Settings.js60KB  (设置页组件)
...每个页面一个独立文件

用户访问首页:
→ 下载 index.html + app.js (200KB) ✓ 快!
→ 用户点击跳转到 /about
→ 此时才下载 chunk-About.js (30KB) ✓ 按需加载!

效果对比

指标不用懒加载使用懒加载
首屏 JS 大小2.3MB250KB
首屏加载时间~5s(4G)~0.8s
总传输量(浏览全站)3.1MB3.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 中的 PromisePromise.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
更精细的控制:使用 webpackPrefetchwebpackPreload
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

特性prefetchpreload
加载时机浏览器空闲时(低优先级)与当前资源并行(高优先级)
适用场景可能下一步访问的页面(如"关于"页、"设置"页)当前页面立即需要的资源(如路由视图内的重型子组件、字体文件)
资源优先级最低LowestHigh
全局支持率~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.1HTTP/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:缓存命中率
  → 页面 AB 共享同一个 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:webpackPrefetchwebpackPreload 有什么区别?

prefetch 是在浏览器空闲时以最低优先级预加载资源,适合用户下一步可能访问的页面(如从列表页点进详情页);preload 是与当前页面资源并行以高优先级加载,适合当前页面渲染就需要的资源。对于路由组件来说,通常对非首屏的关键路径页面使用 prefetch: true 就够了,慎用 preload 因为它可能与首屏资源争抢带宽。


七、本篇小结

概念一句话记忆
路由懒加载() => import() 把组件变成异步加载,减小首屏体积
Webpack runtime 加载原理__webpack_require__.e(chunkId) → 检查 installedChunks 缓存 → 动态注入 <script> 标签 → chunk 执行 window["webpackChunk"].push() → Promise resolve
installedChunksWebpack 运行时的全局注册表,负责 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 官方文档