
昨天一个同事来问我说他们的后台系统首屏加载要七八秒Network 面板里密密麻麻全是请求。我看了一下打包配置3MB 左右的单文件 bundle几乎没有做代码分割。我告诉他先把路由的动态导入做起来用 Vue Router 的() import(...)按需加载这个改动一小时就能完成首屏能立竿见影地瘦下去。Vue Router 的动态导入与懒加载就是把“组件加载”这件事从构建期推迟到运行期。原本所有页面组件挤在一个大 bundle 里现在拆成一个主包和多个页面包用户访问哪个路由才去拉取对应的组件代码。这篇文章我会把核心原理、实操写法、打包细节、常见坑一次讲清楚。适合正在写 Vue 项目、觉得首屏太慢的新人也适合想系统梳理前端路由按需加载方案的开发者。1. 从路由懒加载说起为什么需要动态导入1.1 前端路由与组件加载的默认行为先说一个很多刚入门的朋友容易忽略的点在 Vue Router 4 里如下写法——import HomePage from ./pages/HomePage.vue然后在routes数组里使用component: HomePage这在功能上完全没问题。关键在于这样写属于静态导入Vue 项目在使用 Webpack 或 Vite 打包时会把所有被静态导入的组件统统合并到主 bundle 中或者至少合并到同一个较大的 chunk 里。拿一个典型的运营后台举例登录页、Dashboard、订单列表、商品管理、数据分析、用户报表、系统设置……十几个页面可能有几十个组件。如果全部静态导入那么用户首次打开只访问登录页浏览器却要把所有页面组件代码都下载下来。就像你进饭店点一碗面老板却把招牌菜、甜品、饮品全部端上桌你吃不了这么多但该付的时间、注意力、荷包一个都没少。模块加载的本质是一种静态依赖关系。普通import在编译期就会被解析成确定的依赖边Webpack 或 Vite 沿着依赖图遍历最终把整棵依赖树打包进产物。项目里的路由越多这棵依赖树越庞大产物体积越大用户的浏览器需要在首屏阶段一次性下载、解析、执行完毕。这就是未做懒加载时首屏缓慢的根本原因。1.2 动态导入到底解决了什么问题动态导入的核心思路是把“加载”这个动作从构建期推迟到运行期。ES2020 正式引入了动态import()语法它返回一个 Promise浏览器会在运行时才真正去请求并执行对应模块的代码。Vue Router 的懒加载写法component: () import(./pages/HomePage.vue)实际上就是利用这个机制让打包工具把对应这一个路由的组件单独切成一个 chunk。这样做的收益有三个非常直观第一首屏体积缩小。原来所有页面组件挤在一个包里现在拆成“主包 N 个页面包”主包通常只有框架代码和公共依赖。骨架小加载快。第二按需加载。用户进入某个路由时才去拉取对应页面的 chunk。不访问的页面代码永远不加载对低频功能尤其划算。第三缓存命中率提高。主包和业务包分开后公共依赖和页面代码可以各自独立更新。发版时如果只是改了某个页面的逻辑其他 chunk 的 hash 不变浏览器缓存依然有效回访用户不用重新下载全部代码。这三个收益对应到线上就是秒开和白屏时长的差距。我见过同样的业务页面做懒加载之前首屏要两秒多做之后能压到八百毫秒以内而代码层面的改动其实很小。1.3 什么时候真正需要用上懒加载不过我也得泼一盆冷水懒加载不是万能的也不是所有项目都必须上。如果你的项目就两三个页面总代码量不到一百 KB硬上动态导入反而会增加额外的请求数和复杂性加载一个十几 KB 的 chunk 和加载一个几十 KB 的完整包体验差别不大。真正需要懒加载的场景我总结了几类路由数量多、页面组件重的业务系统比如后台管理、SAAS 控制台、数据分析类页面存在大型三方库的页面比如某些页面独用 ECharts、Excel 导出、PDF 预览等重依赖低频访问的功能例如运营后台里的“系统设置”“操作日志”一个月打开不了几次需要做性能指标监控的项目首屏加载时间有明确的业务要求。反过来如果项目属于快速原型、轻量活动页、组件本身很小那就没必要为了“性能优化”而优化。真正到项目后期根据实际情况拆也不迟。我做性能优化最忌讳的是“感觉慢了就堆方案”而是先量化问题——用 Lighthouse、Chrome Performance 看看瓶颈到底在 JS 体积、资源请求数量还是图片再决定要不要用懒加载。动态导入是手段不是目的。2. 动态导入的核心原理与代码分割机制2.1 import() 与普通 import 的区别很多同学知道import()能懒加载但说不清它区别于import的地方。普通import xxx from xxx是静态导入必须在模块顶部书写路径在编译期就要确定。静态导入的好处是容易做静态分析、循环依赖检测、tree-shaking坏处是它无法决定“我此时此刻不想加载这个模块”。import()则是动态导入它是一种类似函数调用的表达式可以在代码任何位置书写参数可以是一个符合规范的字符串。它返回一个 Promiseresolve 之后得到一个模块命名空间对象里面包含该模块的所有导出。对比起来直观一点整理成一张表维度静态 import动态 import()书写位置必须在模块顶层可在代码任意位置执行时机模块加载时执行且必须先于依赖它的代码调用时才发起加载返回值无绑定在模块作用域Promise打包产物合并进当前 chunk 或主 bundle通常拆成独立 chunk配合路由组件加载到 route 配置中组件作为工厂函数返回 import() 结果也就是说Vue Router 里把component写成函数本质上是给路由加了一层“延迟到活跃时再解析”的壳。当这个路由被匹配时Vue Router 才去调用这个工厂函数触发动态 import拉取组件代码。这个函数就是异步组件的雏形Vue 内部会处理它返回的 Promise并在组件 resolve 之后完成渲染。2.2 打包工具如何处理动态导入代码分割与 chunk刚才说的拆 chunk其实是 Webpack 和 Vite 背后默默做的代码分割Code Splitting。Webpack 在编译时遇到import()就会把它当作一个分割点为这个动态导入的模块生成一个独立的 chunk。多个路由如果使用的import()路径相同或者通过魔法注释归入同一个分组也可以打包到同一文件里。Vite 底层用 Rollup 做打包对动态 import 的处理逻辑类似同样会生成独立的异步 chunk。为了让生成的 chunk 名字可读Webpack 支持魔法注释component: () import(/* webpackChunkName: home-page */ ./pages/HomePage.vue)这句话里的webpackChunkName只是 Webpack 认识的注释Vue 和浏览器都看不见它但构建后产物文件的命名就会变成home-page.[hash].js。有几个相关页面时还可以让它们共享一个 chunk 名const routes [ { path: /user/profile, component: () import(/* webpackChunkName: user */ ./pages/UserProfile.vue) }, { path: /user/settings, component: () import(/* webpackChunkName: user */ ./pages/UserSettings.vue) } ]这两个页面会被打包进同一个 chunk用户访问其中任何一个时另一个的代码也一起加载了。听起来像是偏离了“按需”但有时候这反而是刻意的——用户互相跳转频繁的页面放一起能减少后续切页的请求算是一种取舍。如果你用的是 Vite上面这些 Webpack 魔法注释就不需要了。Vite 底层用 Rollup 打包import()同样会触发代码分割产物会生成独立的异步 chunk。Vite 默认生成的 chunk 名往往是数字编号不太直观但功能性没问题。想自定义命名可以通过构建配置里的output.chunkFileNames或output.manualChunks来调整。日常开发中我更推荐先关注 chunk 体积而不是名字。2.3 加载时机与网络请求的微观过程理解懒加载最重要的是在脑子里建立一个“加载时机”的模型。未做懒加载时流程是这样浏览器加载 index.html → 加载主 JS bundle → Vue 启动 → 路由解析 → 渲染页面组件。整个过程里所有页面组件代码在主 bundle 抵达的那一刻就已经下载并解析完了。也就是说用户虽然站在登录页整个后台的代码已经全部进入了他的浏览器。做了懒加载后流程变成了浏览器加载 index.html → 加载主 JS bundle明显变小了→ Vue 启动 → 路由解析解析到懒加载组件时Vue Router 发现component是一个工厂函数于是调用它 → 动态 import() 发起异步请求加载对应的 chunk 文件 → chunk 下载并执行完毕Promise resolve → Vue 渲染组件。从 Network 面板看懒加载前后的差异非常明显未懒加载时首屏一个巨大的 JS 文件懒加载后首屏是一个小的主 bundle随后当你点击进入某个路由时才会新增对应 chunk 的请求。如果做了 Loading 反馈用户会看到进入页面时有一个极短的等待之后页面出现。这里有个细节很多人会忽略懒加载不代表“这段代码会提前预取”。默认情况下Webpack 也不会主动去拉取未访问的 chunk。一旦用户首次进入某个页面这个 chunk 就直接成为该页面的首屏阻塞资源。所以如果某个页面很大它的首次进入体验可能不如静态导入的平缓——这是懒加载的必然代价。要缓解这个代价可以结合路由守卫做预加载后面我会专门讲。3. 实操在 Vue Router 中落地动态导入3.1 最简写法() import(组件路径)先说最常用的写法。Vue Router 4 的 routes 配置里component字段可以直接传一个函数import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /, name: home, component: () import(../views/HomeView.vue) }, { path: /about, name: about, component: () import(../views/AboutView.vue) } ] }) export default router你不需要额外引入 Vue 的defineAsyncComponent也不需要手动then。Vue Router 内部已经处理了 Promise 的 resolve 过程它在匹配路由时会解析这个组件工厂在组件没有加载完成前进入 undefined 状态加载完成后渲染真正的组件。这里有几个细节值得注意。第一component里的() import()路径必须是一个完整的静态字符串不能这样写// 这样在 Webpack 的静态分析阶段是无法解析的 const view HomeView.vue component: () import(../views/${view})原因我在前面提过Webpack 需要对 import() 里的模块路径做静态分析如果路径变量化它无法在构建时确定该切哪个 chunk。这种写法要么打包报错要么把整个目录打成一个巨大的 chunk。正确做法是在 routes 配置里直接写完整路径() import(../views/HomeView.vue)。第二如果某个页面组件需要多个导出或者需要额外封装可以这样写component: () import(../views/HomeView.vue).then(module module.HomeView)不过 Vue 单文件组件默认是 default 导出所以平时不需要写 then。只有在组件库或者自定义模块里用了命名导出时才会用上。第三Vue Router 3 和 Vue Router 4 在这块写法上是一致的老的 Vue 2 项目同样支持component: () import(...)只是底层打包处理略有差异。如果你维护的是老项目可以放心改造不需要重写路由。3.2 带命名的 webpackChunkName 魔法注释与 Vite 方案如果你直接写() import(../views/HomeView.vue)Webpack 生成的 chunk 名字通常是数字或者src_views_HomeView_vue这种自动名。这在排查构建产物时很不直观所以我的习惯是加上魔法注释。component: () import(/* webpackChunkName: home */ ../views/HomeView.vue)加了webpackChunkName之后生成的文件名类似home.[contenthash].js。在构建输出的dist/assets或者dist/js目录里一眼就能看出每个文件对应哪个页面。对于大型项目这种可读性在排查问题时价值很大。除了 chunk 名Webpack 还有webpackPrefetch和webpackPreload两个魔注component: () import( /* webpackChunkName: dashboard */ /* webpackPrefetch: true */ ../views/DashboardView.vue )webpackPrefetch: true会在主 bundle 加载完后用空闲时间下载这个 chunk但不立即执行。适合“用户大概率马上要访问的页面”webpackPreload: true则会提前加载当前路由需要的资源适合首屏关键路径上的组件。注意区别prefetch 是空闲时预取未来的资源preload 是提前加载当前必需的资源。用错了反而会增加带宽消耗不是越多越好。Vite 项目不需要 webpackChunkName如果想要更可读的 chunk 名字可以通过构建配置控制。例如在 vite.config.ts 里设置build.rollupOptions.output.chunkFileNames按 chunk 信息重新命名。不过实际项目里我更建议先看产物体积分布名字好不好看是其次。用rollup-plugin-visualizer或 Webpack Bundle Analyzer 分析产物比纠结文件名更有价值。3.3 路由级别的懒加载与组件级别的懒加载如何取舍路由懒加载是异步组件在路由层面的应用。除了路由Vue 3 还提供了defineAsyncComponent让你在组件内部也能实现局部懒加载。这两者使用场景不同别混为一谈。路由懒加载解决的是“整个页面组件的加载”。大多数业务系统的粒度就是页面所以用路由懒加载就足够了。但有时候一个页面里只有某个模块特别重比如一个详情页里某个区域要渲染一个超大的表格、地图或富文本编辑器其他部分都很轻。这时如果把整个页面都做成异步用户进入页面的体验会因为等待整个区域而变差更好的方式是拆出子组件用defineAsyncComponent局部异步加载那个重的模块script setup import { defineAsyncComponent } from vue const HeavyEditor defineAsyncComponent(() import(./components/HeavyEditor.vue)) /script template div h1文章详情/h1 HeavyEditor / /div /template这样做的优势是页面的其他部分可以先渲染重的编辑器在加载完成后单独插入用户可以一边看标题和其他内容一边等待编辑器就绪体感上比整个页面白屏等待要好得多。还有一点要提醒不要在defineAsyncComponent内部又去 import 一个路由然后指望它按需加载。路由级别和组件级别是两个层面的异步机制互相嵌套容易造成“先加载路由 chunk再去加载组件 chunk”的多级串行请求反而加长了加载时间。如果页面本身就重优先在路由层面做懒加载如果页面里有可独立的重模块再用组件级异步。3.4 加载状态与错误处理如何给用户更好的反馈懒加载虽然能减小首屏但它也有个现实问题异步加载期间用户体验会有短暂的空白。如果不做任何处理用户点击一个路由后可能会看到页面白屏几百毫秒甚至更久在弱网环境下尤其明显。所以生产环境里我建议至少做两件事加载提示和错误处理。Vue Router 本身没有暴露“组件加载中”的状态但可以通过路由守卫配合一个全局 loading 状态来实现。最常见的做法是在router.beforeEach里开启进度条路由完成后再关掉。项目里通常用 NProgress 这种体积很小的库import NProgress from nprogress import nprogress/nprogress.css router.beforeEach((to, from) { NProgress.start() }) router.afterEach((to, from) { NProgress.done() })如果你不想引入额外依赖也可以在路由配置里用 Vue 的defineAsyncComponent包一层通过loadingComponent传一个转圈组件import { defineAsyncComponent } from vue import LoadingComponent from ./components/RouteLoading.vue const routes [ { path: /home, component: defineAsyncComponent({ loader: () import(../views/HomeView.vue), loadingComponent: LoadingComponent, delay: 200, timeout: 10000, onError(error, retry, fail, attempts) { if (attempts 3) { retry() } else { fail() } } }) } ]这里delay: 200的含义加载超过 200 毫秒才显示 loading避免在组件快速加载时闪烁一个无用的转圈。timeout是加载超时时间超时会触发 onError。onError里可以做重试也可以做错误上报。错误处理是容易被忽略的一点。弱网环境或者 CDN 出问题时动态 import 可能失败。上面例子里的onError就是 Vue 提供的重试和降级机制。你可以结合自己的错误监控系统在onError中记录错误信息并引导用户刷新或返回。我建议把“加载提示”当成懒加载的标配以前我见过一个后台做了懒加载但忘了做 loading用户点菜单后经常怀疑是不是自己没点到。4. 常见问题与排查技巧实录4.1 动态导入路径写错导致无法构建或运行时加载失败我见过的第一个高频坑是动态导入的路径问题。分两种情况一种是构建时报错通常是 Webpack 无法解析动态路径另一种是构建不报错但运行时某个路由点击后加载失败页面一直停留在空白或者报 chunk 404。先说构建时报错。当你写出import(../views/${view}.vue)这种模板字符串路径时Webpack 可能不会立刻报错而是把变量当成一个上下文扫描整个 views 目录然后打包出一个包含所有文件的大 chunk。表面上看构建成功了实际效果和没做懒加载一样甚至更差。这种情况建议直接在路由配置文件里写成静态字符串不要拼接。运行时 chunk 加载失败常见原因是部署产出时路径配置不对。懒加载生成的 chunk 往往放在子目录里如果 Webpack 的 publicPath 配置不对浏览器请求/js/home.[hash].js时会得到 404。排查方式很简单打开控制台找到失败的 chunk 请求看它的 URL 是否符合预期。如果 URL 前缀不对检查publicPath的配置。还有一种隐蔽的情况同一个项目中有些路由静态导入、有些路由动态导入如果它们的代码之间有循环依赖会导致 chunk 加载时出现怪异报错或者某些模块被重复打包。遇到这种报错先从依赖关系的角度排查看有没有页面之间互相 import。路由配置写多了以后保持依赖方向的单向性很重要页面组件不要反向去 import 路由配置文件。4.2 分包过多与重复打包splitChunks 的取舍懒加载拆的 chunk 太多也会带来问题。比如一个项目有五十个路由如果每个路由都被切成独立 chunk用户每跳转一次路由就要发一个新请求。HTTP/2 时代多请求的代价变低了但移动端网络的握手和排队时间依旧存在。所以分包粒度不是越细越好。Webpack 里默认的 splitChunks 会把 node_modules 里的公共依赖单独拆出来比如 Vue、Vue Router、Axios 这些会放到一个公共 chunk 中。这样不同页面 chunk 之间不会重复打包框架代码。但如果你在多个页面组件里各自 import 了同一个大型业务组件SplitChunks 不一定能自动帮你去重到理想状态表现就是几个页面 chunk 中各携带一份同样的业务代码。解决办法有两个方向一是配置 splitChunks 的缓存组把某些跨页面复用度高的模块提取到公共 chunk二是调整路由分组的粒度用webpackChunkName把关联页面合并到一个 chunk减少请求数量。这两个方向没有绝对的对错跟业务特性强相关——如果页面间跳转频繁、后续加载时间敏感可以并包如果某些页面几个月都访问不到保持独立则更有价值。我建议的做法是先用 Webpack Bundle AnalyzerVite 可以使用 rollup-plugin-visualizer看看产物里到底哪些 chunk 体积大、哪些重复多再决定要不要调整。空谈配置没有意义先看数据。4.3 懒加载页面的白屏与闪烁问题另一种常见体验问题懒加载页面在切换瞬间会出现白屏或者闪烁。刚才在 3.4 里提到了用 loading 组件这里我再补充一些实操细节。如果页面切换时有明显闪烁先检查是不是没有设置 transition。Vue Router 的页面切换加上过渡动画后会在新页面组件进入时保持旧页面的存在视觉上自然很多。不要小看这个细节很多用户对“咻的一下白屏”非常敏感。闪烁的另一个可能来源是路由配置里同时使用了defineAsyncComponent和loadingComponent而 loading 组件本身没有设置定位导致它显示时把页面顶部的内容盖住或者挤压到下边。我遇到过一次莫名其妙的“页面内容往下跳”问题最后发现是 loading 组件渲染成了一个块级占位把页面撑大了。解决思路有两种一是 loading 组件使用绝对定位渲染成一个浮层二是设置合理的 loading 延迟比如 delay 200ms让快速的加载不显示 loading只有慢加载才显示。后者的代码在 3.4 已经给过。另外还有个容易被忽略的点手写懒加载时如果组件本身有异步依赖比如子组件也用了import()那么页面的 loading 时间会是多层嵌套的累计值。这种情况下最好禁用某些不必要的子组件懒加载否则用户会感觉页面一直在转圈。4.4 利用路由守卫实现智能预加载最后分享一个我自己项目里的实践路由守卫结合动态导入做预加载。前面讲过了动态导入是“用到才加载”首次进入某个路由时总会有等待。为了让常用页面的二次跳转更快也为了让高频路由的首次进入体验更好可以在路由守卫里主动触发预加载。思路很简单在router.beforeEach里判断用户即将前往的路由如果该路由的组件是懒加载组件就提前调用它的import()让它开始加载。这样页面可能仍停留在当前路由上一小段时间但 chunk 已经在后台下载。等用户真正跳转时组件已经 resolve体验接近静态导入的秒开效果。具体实现上可以在路由配置里给路由元信息加一个标识比如meta: { prefetch: true }然后在守卫里判断router.beforeEach(async (to) { if (!to.meta.prefetch) return for (const record of to.matched) { const comp record.components?.default if (typeof comp function) { try { await comp() } catch (e) { console.error(预加载失败, e) } } } })注意这里的record.components?.default就是我前面说的工厂函数直接调用它不会立刻渲染组件只会触发 chunk 加载正好适合预加载。也可以做得更激进一点用webpackPrefetch魔注让浏览器空闲时自动预取。这两种方式可以组合高频路由用 route meta 手动预加载低频大路由用 webpackPrefetch 交给浏览器调度。不过预加载本质上是“用流量换体验”要克制。别把所有路由都预取否则又回到了把所有代码都加载一遍的处境懒加载就没有意义了。写到最后我想结合个人的实操经验说几句。路由懒加载是我做过的最快见效的性能优化手段之一但它不是“配置一下就好了”的银弹。你需要关注分包粒度、首屏体验、错误处理、预加载策略还要在业务和技术之间做取舍。我现在的习惯是新项目从第一天就使用动态导入少写静态 import页面组件如果带了两个明显吃重的依赖就拆成一个独立 chunk同时把 loading 和错误处理加到位。如果你正在被首屏加载慢的问题困扰不妨先看看自己的路由配置里有多少个静态 import 的页面组件。把它们改成() import()再看一眼 Network 面板大概率会看到明显的改善。就这一点点改动可能就是线上体验从“勉强能接受”到“还挺快”的区别。