路由懒加载与代码分割:根治首屏性能问题的工程实践
前阵子接手了一个后台系统的性能优化首屏加载慢到同事直接用上了 Loading 遮罩鼠标转圈转得人发慌。查了一圈问题最后发现最痛的点就出在路由上所有页面的代码全被打进同一个大包里哪怕用户只是登录进去看一眼首页也得先把几十个页面的代码全部下载完。路由懒加载就是针对这个场景最直接、收益也最明显的解法——把每个路由对应的组件单独切分用户走到哪个页面才加载哪个页面的代码。这篇文章我会把这套思路的完整链路拆开讲清楚从为什么要做、底层是怎么生效的到打包层面如何优化、实际操作中又会踩哪些坑全程按我实际改过一轮项目的经验来写应该能帮你少趟几次坑。1. 为什么要做路由懒加载——先从首屏性能说起1.1 一个常见但容易被忽视的问题很多单页应用SPA刚搭起来的时候业务简单、路由也少全量打包的方式根本感觉不到问题。可是业务一旦膨胀起来比如后台系统里塞进了十几个一级模块、几十个二级页面再配上图表库、复杂表单、富文本编辑器这类大体积的依赖Webpack/Vite 打包出来的主包体积就会以肉眼可见的速度增长。我不止一次见过这种项目生产环境主包达到了 8MB 甚至 10MB 以上首屏加载时浏览器要干的事情是下载一个巨大的 JavaScript 文件 → 解析它 → 编译它 → 执行它 → 再渲染出页面。用户在弱网环境下打开系统最先看到的往往就是一片白屏性能面板里那个加载时间直接飞到七八秒。这个场景其实可以类比成逛实体书店——如果你只是想买一本烹饪书却要求你把整家书店的所有书全部买下来搬回家再从中找出那一本显然浪费到了极点。路由懒加载的核心就是改变这种全量搬运的模式变成“你拿哪本就给你哪本”。1.2 为什么动态加载能显著改善首屏路由懒加载背后的执行逻辑并不复杂它把路由组件从普通的静态 import 改成动态 import打包工具会识别出这些动态引入点将每个路由组件单独分离成独立的 chunk 文件。用户访问 /dashboard 时浏览器只加载 entry 主包和 dashboard 对应的 chunk访问 /setting 时再按需拉取 setting 的 chunk。这个改动带来的改善是多维度的传输体积下降主包大概率只包含框架核心代码和公共依赖业务代码被拆分到多个按需加载的小包中。首屏不再需要下载全部代码这块体量通常能砍掉 60%-80%。解析执行成本降低浏览器下载完成后还要做 JS 解析和编译。代码拆散后首屏只需要解析首屏真正会执行的这部分引擎的编译压力自然小很多。缓存利用率提高路由级别的 chunk 通常不会频繁变动配合 hash 指纹可以设置长期缓存。下一次访问时甚至可以从本地缓存直接取 chunk连网络请求都省了。我实际做的一轮优化里一个后台管理系统的首屏耗时从 6.8 秒降到 2.1 秒主 bundle 从 7.5MB 降到约 800KB其余部分全部以路由级 chunk 的形式按需加载。这个收益基本不需要改动任何业务逻辑只需要调整路由注册方式。1.3 到底什么情况下才需要做路由懒加载听起来很好但也别无脑跟风。如果你的项目体量很小——一共就三四个页面每个页面也没有复杂依赖——强行做路由懒加载反而会制造一堆零碎的小请求性能不一定更好维护还更麻烦。我一般用几个标准来判断项目路由数量超过 10 个且页面之间的业务相对独立构建后主包体积超过 500KB未压缩状态或者 gzip 后超过 150KB存在体积较大的第三方依赖且它们只在特定页面使用首屏加载体验已经出现可感知延迟满足上面任一条件路由懒加载就值得做。如果项目还在原型阶段页面少得可怜先保持静态引入反而更清爽。性能优化这件事永远要有数据基础而不是凭感觉。2. 核心实现动态 import 与路由级代码分割2.1 静态引入和动态引入到底差在哪理解路由懒加载之前先说清两种引入方式的区别。静态引入就是我们平常写的import Dashboard from /views/Dashboard这段代码在编译时就会被处理打包工具知道它依赖哪些模块会把所有相关内容打进同一个 bundle。这种方式的优点是简单直接、依赖关系明确缺点是“全部提前”。动态引入则长这样const Dashboard () import(/views/Dashboard)每调用一次 import()运行时就会发出一个 JSONP 请求去加载对应的模块代码。在 ES2020 标准中动态 import 已经是原生支持的能力它返回的是一个 Promise加载完成后会 resolve 出模块的导出内容。Webpack 和 RollupVite 底层都会把动态 import 的模块单独切包。从实现原理上看Webpack 遇到动态 import 时会为这个模块生成一个独立的 chunk并在运行时注册一个加载函数。当代码执行到 import() 这一步时加载函数会动态创建 script 标签或发送 JSONP 请求拿到代码后执行并缓存。加载过一次的 chunk 会被缓存到运行时内存中不会重复下载。2.2 React Router 中的 lazy Suspense 写法React 生态中路由懒加载通常配合 React.lazy 和 Suspense 使用。React.lazy 接受一个返回 Promise 的函数这个 Promise 最终要 resolve 出一个默认导出的组件。import { Suspense, lazy } from react import { BrowserRouter, Routes, Route } from react-router-dom const Dashboard lazy(() import(/views/Dashboard)) const Setting lazy(() import(/views/Setting)) const UserCenter lazy(() import(/views/UserCenter)) // 公共的加载占位 function PageLoading() { return div style{{ padding: 24px }}页面加载中.../div } function App() { return ( BrowserRouter Routes Route path/dashboard element{ Suspense fallback{PageLoading /} Dashboard / /Suspense } / Route path/setting element{ Suspense fallback{PageLoading /} Setting / /Suspense } / Route path/user element{ Suspense fallback{PageLoading /} UserCenter / /Suspense } / /Routes /BrowserRouter ) }这里有两个细节值得注意Suspense 的 fallback 会在这个组件等待加载时显示。加载完成前会短暂地切换为 fallback所以 fallback 本身要足够轻量不要在里面塞复杂的结构。如果项目里有多个路由都用了 Suspense其实可以把 Suspense 提升到 Routes 外层只保留一个 fallback。这样代码更简洁加载任一路由时都会触发同一个占位组件。React Router v6 还提供了一种更内聚的方式——用 useRoutes 配合懒加载子组件来组织原理是一样的只是写法上更灵活。2.3 Vue Router 中的动态导入Vue 生态里做这件事更简单。Vue Router 4 直接支持在路由配置里传一个动态 import 函数import { createRouter, createWebHistory } from vue-router const routes [ { path: /dashboard, name: Dashboard, component: () import(/views/Dashboard) }, { path: /setting, name: Setting, component: () import(/views/Setting) } ] const router createRouter({ history: createWebHistory(), routes })当用户导航到 /dashboard 时Vue Router 会请求对应的 chunk。加载完成后由 Vue 渲染组件。你不需要额外写任何 Loading 状态——当然如果你想要更精致的过渡效果可以在 RouterView 外层包一层 Transition再配合 Suspense 使用。Vue 3 里组件本身也支持script setup的动态加载不过核心还是这个 import() 函数。Vue Router 4 相比 Vue Router 3 在懒加载解析上有一些改进它内置了对动态引入组件的解析处理并且会在组件加载失败时给出更清晰的错误提示。另外如果你用了 Nuxt路由懒加载基本是默认行为Nuxt 会自动按页面目录结构生成异步 chunk。2.4 打包工具拆包逻辑和 chunk 生成机制动态 import 写完之后真正的拆包是打包工具来完成的。Webpack 在编译时会把动态 import 里的模块作为一个新的 chunk然后在运行时用一个模块注册表来管理保证同一 chunk 不会重复请求。这里有一个容易忽视的点同样的代码打包结果可能完全不同。Webpack 的 optimization.splitChunks 配置会影响 chunk 的合并策略。默认情况下Webpack 会尽量复用已有 chunk如果你在多个路由中都 import 了同一个公共组件Webpack 可能把它提取到公共 chunk 中而不是每个页面重复打包。Vite 基于 Rollup 的 output.manualChunks 可以做更细粒度的手动分包。比如把 react 全家桶、antd 这类固定不改的依赖单独拆出来方便长效缓存。但注意别过度手动配置否则容易把依赖关系拆乱反而让公共包变得难以维护。3. 配套的优化手段——拆完不是终点3.1 给 chunk 起一个可读性强的名字默认情况下打包生成的 chunk 文件名是一个无意义的数字 ID比如 723.9f3c2a.js。这在开发调试时非常痛苦——Network 面板里一坨数字根本不知道加载的是哪个页面。实际项目中我习惯在动态 import 里加上 Webpack 的魔法注释指定 chunk 名称const Dashboard () import(/* webpackChunkName: dashboard */ /views/Dashboard)这样打包后对应的 chunk 就会是dashboard.f3c2a1.js这种可读形式。如果是 Vite 环境动态 import 的解析规则不太一样但 Vite 在 dev 模式和 build 模式都会尽量根据源码路径生成有意义的文件名你也可以配合 build.rollupOptions.output.chunkFileNames 配置模板字符串来规范一点。这个改动对线上性能没有直接影响但对排查问题非常重要。线上异常上报里如果出现某个 chunk 的加载错误看到名字你就能秒懂是哪个页面出了问题否则还得去 source map 里翻半天。3.2 加载中占位、加载失败要怎么处理路由懒加载引入了一个新的不确定因素异步加载永远可能失败。用户网络不稳定时chunk 请求可能超时或直接失败。如果处理不好界面上会一直白屏没有任何反馈。React 中的 Suspense 只覆盖加载中状态它不能捕获加载失败的错误。所以实际项目中我需要建立一层边界错误处理机制。一般做法是在懒加载组件外再包一层 ErrorBoundaryclass RouteErrorBoundary extends React.Component { state { hasError: false } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return div页面加载失败请刷新重试/div } return this.props.children } }Vue 3 里直接用 onErrorCaptured 或者在路由层面捕获错误即可。Vue Router 提供了 router.onError专门用来捕获路由懒加载过程中的异常router.onError((error) { // 可以在这里做上报或者提示 message.error(页面加载失败请刷新重试) })加载中的占位也需要设计得当。很多项目直接用全局 loading 遮罩但懒加载通常非常快如果每次切换路由都闪一个全屏遮罩体验反而更差。更合适的做法是只给真正懒加载的部分一个局部占位或者设置一个 200ms 左右的短延时再显示 loading避免快速切换路由时出现闪烁。3.3 预加载策略prefetch 和 preload 什么时候用懒加载虽然省了首屏流量但用户点击下一个页面时还是需要等待 chunk 下载。如果希望“点击即打开”的效果可以用预加载策略把某些大概率会访问的页面提前拉下来。Webpack 的魔法注释支持 webpackPrefetch 和 webpackPreloadconst Dashboard () import(/* webpackPrefetch: true */ /views/Dashboard)webpackPrefetch会在浏览器空闲时下载这个 chunk下载完成后放入缓存但不会执行。用户真正跳到这个页面时直接从缓存取代码几乎零等待。webpackPreload则会在当前页面加载时并行下载适合那种“当前页面一定会立刻跳转”的场景比如登录页跳转首页。登录页加载时就提前把首页 chunk 下载好用户登录后跳转瞬间就完成了。需要克制的地方在于prefetch 也是请求如果一口气 prefetch 了几十个页面那懒加载的优化效果就打了折扣。我一般只对用户“下一步最可能访问”的两三个页面做 prefetch比如首页对详情页、列表页对编辑页而不是把全站页面都 prefetch 掉。3.4 缓存策略拆分后为什么还是老刷新如果你已经把路由拆成 chunk 了但线上每次发版本用户重新打开时还是会请求一大堆 chunk那八成是缓存策略没配好。前端缓存的核心在于文件名指纹。Webpack 的 output.filename 和 chunkFilename 设为[name].[contenthash].jsVite 的默认行为也差不多生成的文件名包含内容 hash。只要某个 chunk 的内容没变文件名就不会变浏览器就能继续使用缓存。但这里有一个隐藏问题如果主包拆得不干净公共依赖每次构建后 hash 都变了哪怕业务代码没变所有 chunk 也会跟着刷新缓存。解决办法是把变化频率极低的第三方依赖单独打包比如 react、react-dom、框架运行时等它们几乎不会变文件名 hash 就会长期稳定。实操经验是可以在 Webpack 的 splitChunks 中配置一个专门处理第三方库的分组。Vite 里用 build.rollupOptions.output.manualChunks 做类似的事。注意规则别写太细——拆分粒度太碎hash 稳定但请求过多粒度太粗公共包又变成第二个大 bundle。4. 实战中容易踩的坑与排查记录4.1 页面切换时出现白屏闪烁如果你发现切路由时总有一瞬间白屏或 Layout 跳动大概率是 Suspense fallback 和真实页面交替渲染造成的。特别是 fallback 组件和路由页面样式差距大时视觉跳动尤其明显。我的解法是设置一个延迟显示的 loading。定义一个带延迟的懒加载包装函数function lazyWithDelay(loader, delay 200) { return lazy(() { return Promise.all([ loader(), new Promise(resolve setTimeout(resolve, delay)) ]).then(([moduleExports]) moduleExports) }) } const Dashboard lazyWithDelay(() import(/views/Dashboard))这样当页面加载足够快时loading 甚至还没来得及显示用户看到的就是一个流畅的切换过程。延迟只影响 fallback 的显示时机不影响 chunk 加载本身。4.2 动态 import 路径写错导致加载失败动态 import 的路径在 Webpack 4 中要求不能是纯变量表达式。下面这种写法会导致构建异常const name Dashboard const Component lazy(() import(/views/${name}))打包工具在编译时无法分析出动态部分包含哪些模块要么直接构建失败要么产物里把所有可能的页面全打进来得不偿失。Webpack 是依赖静态分析来识别模块依赖的import() 的参数必须能静态解析。比如写成完整的字符串路径或者使用带明确前缀的对象映射import(./views/ pageName) // Webpack 可以解析出 views 目录下所有模块但即便如此它也很可能把 views 下所有模块都打成一个动态待加载集合体积优势全没了。所以我的建议很简单每个路由单独写清楚完整路径不要通过变量拼接。代码多一点但可维护性和打包结果都可控。4.3 拆包拆过头请求碎片化有一次做优化时我把每个组件都做成了单独的 chunk结果首屏请求数从 12 个暴增到 80 多个。HTTP/2 虽然能并行传输多个文件但浏览器处理大量小请求同样有开销服务端的压力也会变大。这种情况通常是 splitChunks 的配置太激进或者动态 import 拆分粒度过细。合理的策略是在“请求数”和“单个 chunk 体积”之间找平衡。我习惯把 chunk 数量控制在 20 到 30 个以内单 chunk 体积尽量保持在 50KB 到 200KBgzip 后之间。过小的 chunk 应该合并过大的 chunk 应该再拆分。实际上有些页面本身并不复杂没必要每个都单独等一个完整包。可以考虑把几个业务关联度高、访问路径连续的页面合并到一个 chunk 中比如一个列表页和它的新增、编辑、详情三个子页面打包成同一个 module。4.4 懒加载时机不对导致的性能回报低懒加载做完了不代表性能就一定好。如果一个页面内部还有巨大的同步依赖比如页面本身不大却 import 了体积超大的图表库那这个页面的 chunk 必然很大用户进入该页面时还是会卡。路由懒加载解决的是“路由间”的性能问题页面内部的优化还需要配合组件级别的懒加载。比如图表组件只在弹窗打开时才动态加载富文本编辑器点击编辑时才引入这种组件级的代码分割能进一步压小主 chunk。另一个常见问题是懒加载已经做了但接口请求没有做任何处理。用户进入页面后页面代码是加载好了但在等待一堆串行接口返回页面还是空白的。这种时候要把注意力放在接口并行、缓存和骨架屏上而不是继续抠包体积。4.5 常见问题速查表现象可能原因解决方案路由 chunk 加载失败白屏无反馈未绑定懒加载错误处理用 React ErrorBoundary 或 Vue router.onError 捕获并展示错误提示切换路由常闪一下 loadingSuspense fallback 渲染太快导致闪烁给懒加载包装函数增加短延迟或提升 Suspense 层级减少重复触发打包后 chunk 文件名全是数字缺少魔法注释添加/* webpackChunkName: dashboard */Vite 项目配置 chunkFileNames主包 hash 频繁变化缓存失效公共依赖未独立分包用 splitChunks 或 manualChunks 把 react、antd 等固定依赖单独拆包首屏请求数过多拆包粒度过细chunk 碎片化适当合并小 chunk控制数量在 20-30 个动态 import 变量路径构建失败打包工具无法静态分析依赖路由配置里使用完整字符串路径禁止动态拼接懒加载后个别页面首屏变慢页面内部还引用大体积同步依赖对图图表库、编辑器组件做组件级懒加载4.6 用性能指标验证优化效果改造完成后我通常不会只看一眼“感觉快了”就收工。用性能面板和浏览器 DevTools 记录两组数据做对比会更有说服力加载时间以 First Contentful Paint 为主记录优化前后路由跳转的时间资源体积构建时输出的 chunk 体积清单主包体积和总包体积的对比请求数量优化前后首屏请求数确保没有因拆包导致请求数过度增长Chrome DevTools 的 Network 面板里有一项可以设置网速模拟比如 Fast 3G。用它测更接近弱网环境的真实体验。我再额外会关注那个 Largest Contentful Paint——它比页面 load 事件更能代表用户体验。5. 路由懒加载之外的一些延伸思路路由懒加载不是性能优化的终点但它是一个极好的切入点。做完整套拆包之后你会开始注意到更多和加载相关的东西比如字体文件、CSS、图片的加载策略。类似的问题其实可以用同样一套思路去审视。对我来说路由懒加载最大价值不只是省了那几 MB 流量而是强制我们思考另一个问题代码到底是什么时候真正被需要的资源加载、组件渲染、接口请求本质上都是这个问题。带着这个视角去重构项目能发现很多隐藏的低效点。如果项目开发周期允许建议在一开始就规划好路由结构并为页面建立清晰的目录规范懒加载改造时就不需要大规模重构路由文件。同时给团队定下一条规则任何新页面的路由注册一律使用动态 import禁止新增静态引入的路由组件。这样路由懒加载才会一直延续下去而不是上线结束后又慢慢退回原样。我个人这几轮优化做下来的体会是懒加载给体验带来的提升很明显但要防止它变成一种形式主义——不是所有页面都必须一口气全拆完更不是拆得越碎越好。每个项目的业务特征不同最优解只能在实测数据里慢慢逼近。先做一次完整的首屏性能分析再动手拆包最后用数据验证收益这才是稳健的顺序。