
我做了几年前端构建优化发现一个挺有意思的现象不少项目开发阶段跑得飞快一打包到生产环境首屏加载却慢得让人着急。多数问题并不是业务代码写得差而是在构建环节没有做好代码分割Code Splitting和懒加载Lazy Loading。Vite 现在是很多团队的主流选择开发环境基于原生 ESModule 和 esbuild确实快得不像话但生产构建默认走 Rollup如果完全依赖默认配置很容易出现一个几 MB 的依赖包或首屏要加载几十个小模块的情况。这篇文章想认真聊一聊 Vite 生产环境的代码分割与懒加载优化什么场景该拆、怎么拆、拆完怎么验证。适合已经用 Vite 写过一两个项目、想系统优化线上性能的同学。我不会只贴配置还会把背后的为什么讲清楚这样你在自己的项目里才好做取舍。1. 生产环境代码分割的底层逻辑与收益1.1 Vite 开发与生产的构建差异先理解一个容易被忽略的前提Vite 开发模式和生产构建“不是同一台机器”。开发模式下Vite 会把模块直接转成浏览器能跑的 ESModule按需编译、按需加载浏览器只请求当前页面用到的文件所以你在本地几乎感知不到依赖包的大小切路由也是即时响应的。到了生产构建Vite 会把代码交给 Rollup 做打包压缩把一个项目里成百上千个模块重新组织成适合线上传输的静态文件集合。这两种机制的差异带来了一个反差开发环境里你觉得很流畅不代表生产环境也流畅。尤其当项目里引入了大体积三方库图表库、地图库、日期处理库、富文本编辑器如果构建时没有做合理的拆分这些代码会全部进入首屏资源用户在打开首页时就不得不下载一整包业务代码加库代码。理解了这一点后续所有优化动作就有了方向做代码分割本质上是在“加载体积”和“请求数量”之间找平衡。1.2 代码分割正在解决哪些具体问题代码分割解决的第一类问题是首屏体积过大。假设一个后台管理系统首屏只用到登录页和框架壳但你却把 20 个业务模块全打进了初始包用户未登录就要下载几十 MB 资源这在移动端网络下几乎不可接受。拆分之后首屏只加载登录页依赖的代码其他模块等用户真正进入再加载。第二类问题是缓存利用率低。如果不做拆分三个月的迭代都可能让同一个大包内容不断变化hash 一变用户每次发版都要重新下载所有资源如果能按第三方依赖单独分包业务代码频繁更新时依赖包 hash 相对稳定浏览器缓存就能真正发挥作用。第三类问题是渲染阻塞。浏览器遇到大型脚本文件时下载和解析都会阻塞首屏。把代码拆成多个小 chunk 并配合异步加载能显著降低单次脚本的解析成本。需要注意的是拆得越细请求数越多HTTP 请求本身也有开销所以不能盲目追求“分割得越多越好”这也是下面会反复强调的取舍点。1.3 什么情况下需要手动介入并不是每个 Vite 项目都需要肉眼盯着分包配置。判断信号有三个首次构建后某个 chunk 超过 200KBgzip 前且核心业务代码都在里面。项目没有做任何路由懒加载所有页面一次性打进主包。第三方依赖占比极高动辄几百 KB但都混在业务包中。如果只是一个小型活动页体积本身就不大手动拆包反而会引入新的维护成本。这时候你要做的是先观测数据而不是一上来就抄配置。我见过不少开发者把网上的 manualChunks 配置直接贴进项目结果发现分包没更多解决还产生了新的循环依赖警告。优化这件事最怕“不知道问题在哪就先动手”。2. 默认行为分析与拆包切入点2.1 Vite 默认拆包规则在实际项目里的表现Vite 生产构建不是完全不分包。默认情况下Rollup 会做一些基础处理把所有异步 chunk 中共同依赖的模块提取成共享 chunk同时根据 ESModule 的静态依赖关系生成初始 chunk。你还会发现构建后有个assets/index-xxxx.js那就是入口 bundle。问题在于这个入口 bundle 通常会“吸收”绝大部分直接 import 的第三方代码和业务代码。如果页面组件全在入口文件里静态引入且没有使用动态 import那 Rollup 就认为它们首屏都需要统统塞进同一个输出文件中。构建日志里的“chunk size”警告是 Rollup 在提醒你体积偏大很多团队直接把那段警告忽略了实在可惜。这里我特意提一句构建工具给的警告不是摆设它是判断分包是否需要调整的免费信号。出现大包警告时你应该先看看这个包里面是什么。可以用构建插件输出依赖占比报告也可以直接看打包产物的文件大小比例先定位大头再考虑怎么拆。2.2 dynamic import 是懒加载的“开关”懒加载能不能成立核心是语法上有没有使用动态 import。静态import xxx from ...写出来Rollup 就能在构建期分析依赖把所有东西放到同一个可预测的依赖图里。只有写成import(./xxx)或await import(./xxx)Rollup 才知道这是“运行时才需要”的模块路径才会为它单独生成一个 chunk。所以在项目里第一步永远是把路由级页面改成动态 import。这一步不需要调整任何构建配置收益却非常直接每一个路由页面会变成一个独立的异步 chunk首屏只加载当前路由的代码。可以这样理解静态 import 是在点菜时把整本菜单都背熟动态 import 则是翻到哪一页才看哪一页。需要注意动态 import 的路径最好使用相对路径或通过路径别名引入不要拼接动态变量。如果写成import(url)这种变量形式构建工具无法静态分析出具体有哪些模块最终可能打包出几十个无法预测的 chunk甚至把动态加载的功能完全破坏。2.3 依赖预构建对代码拆分的双层影响Vite 的依赖预构建平时主要发生在开发模式它会用 esbuild 把 node_modules 里的 CommonJS 依赖转换成 ESM并合并成少量文件目的就是避免开发时请求过多。这个行为不影响生产构建的最终产物但它容易误导开发者的判断开发模式加载快很多时候是预构建帮你做了优化让你忽略了生产模式下可能存在的大包问题。到了生产构建optimizeDeps并非完全不起作用但真正决定产物的是 Rollup 的分包策略。如果你在某些配置里看到别人把optimizeDeps.include写得很长那多半是为开发模式的稳定性服务的对生产包的大小没有直接决定权。这个点容易混淆我在排查问题时经常看到有人把生产构建速度慢、产物大的问题怪到预构建头上其实方向不对。3. 手动拆包的具体方案与配置3.1 按路由维度拆分是最稳的第一步如果你还没有做过任何懒加载那么按路由拆分是优先级最高的动作因为它的收益最确定、风险最低。在 React 项目中使用React.lazy配合Suspense包装路由组件在 Vue 项目中可以使用defineAsyncComponent或直接让路由组件使用动态 import。路由层面拆分后构建产物里会出现与页面数量基本一致的 chunk首屏包会明显变小。这里有个容易被忽略的细节路由懒加载不要只拆页面组件还要拆组合起来匹配的功能模块。比如一个详情页依赖一个很大的富文本编辑器编辑器应该和详情页放进同一个路由 chunk而不是被静态 import 到公共模块中。判断标准很简单某个功能只被少数页面使用就把它放到那些页面自己的模块体系里被许多页面共用的才考虑提取公共 chunk。3.2 按第三方依赖拆分manualChunks 的常用写法第三方依赖拆分通常是第二步。Vite 的构建配置中有一个build.rollupOptions.output.manualChunks它可以传对象也可以传函数。对象形式最直观把某个包名映射到某个 chunk 名字。// vite.config.ts export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vendor: [vue, vue-router, pinia], echarts: [echarts], utils: [lodash-es, dayjs] } } } } })需要注意对象形式要求列出的模块确实是项目依赖。如果某个包没有被直接 import而是被其他包间接依赖写进对象并不一定能达成预期效果。另一种更灵活的方式是函数形式根据模块 id 匹配特征做分组。但它也有坑函数形式里如果逻辑过于宽泛很容易把所有 node_modules 里的依赖都丢进同一个 vendor导致 vendor 包巨大。我自己常用的思路是先看构建报告找出体积靠前的 3 到 5 个依赖单独分组剩余 node_modules 依赖可以合并成一个 shared 包。共享依赖的 chunk 体积要控制在合理范围如果某几个依赖单独拆出来不足 20KB就别单独成包合并进 shared 反而更合适。3.3 自定义函数分组需要注意的细节函数形式 manualChunks 的签名大致是这样manualChunks(id) { if (id.includes(node_modules)) { if (id.includes(echarts) || id.includes(zrender)) { return charts } if (id.includes(lodash) || id.includes(moment)) { return utils } return vendor } }这种写法在常见场景下可读性很好。它的问题在于返回字符串相同的模块会合并到同一个 chunk如果两个 chunk 之间存在模块引用关系Rollup 会按规则提取公共模块。还有一点很重要函数里不要写死绝对路径因为不同的部署环境、不同的 npm 目录深度都会导致匹配失败。建议用id.includes()去匹配包名而不是匹配用户目录名。另外千万不要为了“看起来干净”强行把所有三方包打成一个 vendor。只有一个几百 KB 的图表库或地图库单独分包才有意义而把 vue、vue-router、pinia 这些框架运行库打在一起其实是很合理的因为它们几乎总是同时被使用分离反而增加请求数。我提到的这些配置都可以用vite build后输出的产物名称来验证。如果你看到vendor-xxxx.js或者charts-xxxx.js说明分组逻辑生效了如果产物列表里没有这些文件名要么是分组条件没匹配到要么是这些依赖在项目中并不存在。4. 懒加载模式的完整落地指导4.1 路由级懒加载的代码示例与细节先看 React 路由懒加载的标准写法import { lazy, Suspense } from react import { createBrowserRouter } from react-router-dom const Dashboard lazy(() import(/pages/dashboard)) const Settings lazy(() import(/pages/settings)) const router createBrowserRouter([ { path: /dashboard, element: ( Suspense fallback{PageLoading /} Dashboard / /Suspense ) } // ... ])Vue 项目里的写法类似const Dashboard defineAsyncComponent(() import(/pages/Dashboard.vue))路由懒加载看似只有几行代码真正要注意的是 Suspense 放置的位置。如果每个路由组件都包一层 Suspense导航切换时会有明显的空白闪烁如果只在整站根部包一层又可能让所有路由都共享同一个加载状态。实践中我会在路由出口处统一加一个 Suspense同时让每个页面组件内部自己处理局部 loading兼顾首屏速度和交互体验。还有一个细节动态 import 出来的 chunk 大小取决于页面本身。如果某个页面体积仍然过大单纯切路由还不够需要把页面内部的大组件进一步拆分。也就是说路由懒加载只是第一层页面内部的组件懒加载是第二层。4.2 组件级懒加载的取舍与禁忌组件级懒加载适用于体积大、非首屏必需、出现条件不固定的组件。典型场景包括图表库、富文本编辑器、代码高亮组件、地图组件、PDF 预览。这些组件一旦被静态 import即使页面默认不展示代码也会被计入首屏包用动态 import 改成可见时才加载后初始体积能明显下降。但组件级懒加载有一个很常见的坑很多开发者对常用的弹窗组件也做懒加载导致用户第一次点击弹窗要等组件加载体验反而变差。我的取舍标准是弹窗、抽屉、折叠区里的大组件可以懒加载按钮、表单、导航这类基础交互组件绝不懒加载。另外如果项目本身是一个只有几个页面的小型应用组件懒加载带来的收益有限反而增加了请求延迟没必要硬上。还有一种特殊场景是图标库。把几百个图标全部放进首屏是非常典型的浪费。实践中可以把图标组件改造成按名称动态加载模块让用户用哪个图标就加载哪个图标的代码收益非常明显。4.3 加载状态与错误兜底懒加载一定会带来加载状态和异常处理的问题。正常的处理方式是提供 fallback UI至少保证用户在等待时有所反馈。React 是 Suspense 的 fallbackVue 是 asyncComponent 的 loadingComponent 和 delay 配置。关键的是错误兜底。动态 import 在弱网环境下可能加载失败或者某个 chunk 因为版本更新而被服务器删掉了。常见的处理方案是在错误边界里捕获 chunk 加载错误然后提示用户刷新或自动重试。我见过不少项目只写了 lazy没写错误边界一上线就出现白屏排查半天才发现是某个异步 chunk 加载 404。class ChunkErrorBoundary extends Component { state { hasError: false } static getDerivedStateFromError() { return { hasError: true } } componentDidCatch(error) { // 记录失败的 chunk 信息并上报 } render() { return this.state.hasError ? ReloadView / : this.props.children } }这个错误边界应该包住所有使用 lazy 组件的区域尤其是路由出口。另外加载失败后不要直接让用户硬刷新页面可以先尝试重新加载 chunk如果重试仍失败再提示刷新。把这些兜底逻辑写好懒加载在线上才算是真正“可用”的优化而不是给自己埋雷。5. 生产优化进阶预加载、缓存与验证5.1 资源预加载的优先级让浏览器更聪明分割和懒加载是一对组合拆完之后还需要告诉浏览器哪些资源值得提前下载。Rollup 在生成代码时会自动在入口 HTML 或 chunk 中注入一些 preload 指令但对于我们手动切割的业务 chunk默认行为可能不够精准。常见的做法是在路由懒加载场景下给大概率被访问的页面加 preload。例如一个后台系统用户登录后大概率进入首页如果首页被单独拆成一个 chunk可以在登录页加载完成后预取首页 chunk。这样既不会拖累登录页首屏又能让用户跳转首页时秒开。另一方面prefetch 适合滞后性较强的资源比如用户可能在后续操作中用到但不保证会用到的模块比如设置页、帮助文档。需要克制的是preload 并不一定越多越好。把所有异步 chunk 全部 preload等于把懒加载省下来的流量又花掉了一部分。所以要针对高频访问路径做预加载而不是无脑全配。5.2 配置输出文件名与缓存策略Vite 默认会给生成的静态文件加上 hash这是生产环境缓存的关键。没有 hash 的文件名很危险即使内容更新浏览器缓存里还是旧版本用户要频繁强刷才能看到新内容。默认 Vite 使用的是内容 hash只要文件内容一致文件名就不变CDN 和浏览器缓存都能长期生效。如果项目有自定义需求可以在 build.rollupOptions.output 里调整 chunkFileNames、entryFileNames。但有一点要提醒不要为了文件名好看把 hash 去掉。哈希名的代价是可读性差一点换取的是缓存准确性这个交易非常划算。缓存优化要配合网络响应头才能完整生效。如果你在部署后发现资源请求返回 200 而不是 304 或 from cache建议检查一下 CDN 的缓存配置。构建产物上正确的 hash 文件名再配合immutable缓存参数才能让静态资源长期缓存。业务入口 HTML 则不缓存否则发版后用户拿到的还是旧壳子。5.3 上线后如何确认真实收益配置做完不是结束要回到数据里验证。我常用的方式分三步第一步对比优化前后的构建产物总体积和 chunk 数量确认分包确实生效第二步用浏览器开发工具检查网络面板看首屏加载的资源列表、每个资源的大小和耗时第三步观察真实用户侧的指标比如首屏时间、交互可用时间、包加载失败率。这里我特别强调一个反直觉的点总构建体积在代码分割后通常会上升因为分包本身会产生额外的首包开销和跨 chunk 引用代码。所以你判断优化是否有效不能只看构建报告里的总大小而是看首屏加载的资源体积和耗时。如果拆分让用户后续操作中的加载更轻盈那即便是总量略有增长方向就是对的。6. 实操经验、常见问题与复盘6.1 常见问题与排查速查我把平时被问得最多的问题整理成了一张速查表现象可能原因处理建议构建后有超大 vendor 包所有三方依赖被塞进一个 chunk根据构建报告拆分大依赖单独分出图表库、地图库等动态 import 的 chunk 仍出现在首屏页面组件被静态 import 引用检查路由配置与父组件移除静态引入构建后出现循环依赖警告manualChunks 分组跨越了原有依赖边界调整 manualChunks 函数逻辑避免把相互依赖的模块强制拆开异步 chunk 加载 404代码版本更新导致旧 chunk 被清理加强错误边界与重试机制避免白屏优化后请求数量暴增拆分过细合并体积小的 chunk设置合理的分组策略这几类问题我都实际遇到或排查到过。尤其是循环依赖警告它不是构建失败但可能带来运行时变量未定义的诡异 bug。遇到这类警告最直接的处理就是检查最近改动再看 manualChunks 是否拆错了边界。6.2 拆分后体积不降反升的实战复盘之前有次优化我把一个后台管理系统按“页面 每页内所有组件”都拆开结果首屏脚本请求数从 8 个涨到了 30 多个总下载字节数反而涨了 10% 左右。复盘时发现原因有两个一是跨 chunk 的重复引用被 Rollup 提取到公共 chunk 后公共 chunk 又变大二是过多的模块被拆开模块之间的调用需要额外的加载等待。这次教训让我把拆包逻辑重新拉回到两个原则第一拆分的最小粒度为“路由页”和“独立大依赖”不追求把每个小组件都拆出去第二任何一次拆包后都要用构建产物大小和首屏请求数做前后对比用数据说服自己而不是凭感觉。之后项目上线稳定也再没出现过类似的请求数膨胀问题。其实代码分割和懒加载优化的本质是让用户更快看到他真正需要的内容并把可能用到的东西放到离“现在”更远的位置。从这个角度看最低效的做法是“把所有资源都提前加载”而最优解永远是做取舍。6.3 后续可以继续尝试的优化动作如果你已经做到路由懒加载和依赖分包可以继续尝试几个动作把首屏必需的骨架、关键样式提取出来单独内联对高频页面做预加载利用服务端渲染把首屏标记提前产出对分包后的资源做加载监控发现某个 chunk 在真实环境加载过慢时再回到构建配置里继续调整。另一个值得考虑的方向是更细化的拆包策略。有人为了极致压缩首屏会按照业务模块动态生成打包函数比如把图表相关依赖拆成若干更小的子块。这种做的收益要看具体项目而定但它对维护成本和构建时间都有影响不建议小型团队一开始就追求极致拆分。我个人在实际操作中的体验是Vite 的默认推荐已经覆盖了大部分常规场景你需要花心思介入的其实是剩下那些关键部分。先把路由懒加载做扎实再根据数据决定要不要拆某个大依赖比一开始就堆一堆构建配置要稳得多。