
入行这些年我前后经历过好几轮前端模块化的认知刷新。刚带项目的时候每天最头疼的就是一个 index.html 里挂二三十个 script 标签顺序错了页面直接白屏后来把 RequireJS 换成 webpack又被配置折腾到凌晨再后来 Vite 出来、Module Federation 出来、微前端火起来我发现每一个新方案都号称解决了旧方案的问题但真正落地时坑一个不少。这篇文章想做的不是复述文档而是把前端模块化架构这十几年的演进脉络捋清楚讲明白每一轮变化背后的真实驱动力最后把我在选型和落地过程中攒下的经验一次性摆出来。1. 从 script 标签到 ES Module模块化到底解决了什么问题1.1 全局变量时代的混乱2008 年前后做前端哪有什么模块化概念。页面要放交互逻辑无非是几个 script 标签按顺序往下排。表面上代码是按顺序执行的实际上依赖关系完全靠人肉维护。我印象最深的是有一次接手一个后台系统页面里引了十来个 JS 文件我把其中一个看起来没人用的公共函数文件删掉结果三个页面同时报错——那个文件里挂了一个全局变量别的文件启动时悄悄读它。那个年代最主流的规避方式有两个。一是前缀命名空间var App window.App || {}; App.User App.User || {}; App.User.getName function () { return 张三; };二是立即执行函数配合全局挂载(function (global) { var Module { init: function () {}, render: function () {} }; global.Module Module; })(window);这两种写法在小型页面里够用但项目一大就露馅。命名空间只能说降低冲突概率解决不了加载顺序问题IIFE 虽然把变量收进了函数作用域可模块之间的通信还是要通过 window 上挂的全局对象。更致命的是所有代码都在同一个全局作用域里跑第三方库之间互相覆盖 window 上的同名对象是家常便饭排错排到怀疑人生。1.2 规范混战CommonJS、AMD、CMD、UMD到了 2009 年Node.js 把 CommonJS 带火了。require/module.exports 这种写法看起来非常自然一个文件就是一个模块模块间靠 require 显式声明依赖。但 CommonJS 的加载是同步的这在服务端没问题——本地文件系统读文件再快不过了浏览器端就不行了没有文件系统同步加载一个远程 JS 文件等于把页面卡死。浏览器端需要的是异步加载于是 AMD 出现了。RequireJS 是 AMD 规范的代表实现它把一个模块定义成依赖数组加工厂函数define([jquery, utils], function ($, utils) { function init() { utils.log(module started); } return { init: init }; });RequireJS 在当年确实是救命稻草它解决了两个真实痛点声明式依赖和异步按需加载。但用久了你会发现它的体验很拧巴——所有依赖都要预先写在数组里代码被 define 包了一层又一层写起来啰嗦读起来费劲。紧接着国内玉伯搞了 Sea.js推广 CMD 规范主张就近依赖、延迟执行代码写起来跟 CommonJS 几乎一样只是用 define 包一层就能在浏览器异步加载。在当时AMD 和 CMD 的争论几乎是前端圈的年度大戏。回头客观评价两者解决的是同一个问题差别主要在代码组织风格上AMD 偏依赖前置CMD 偏依赖就近。谁也没真正统一江湖。再后来就是 UMD。它的本质是一段环境探测代码让同一个模块能在 CommonJS、AMD、全局变量三种环境下运行(function (root, factory) { if (typeof module object typeof module.exports object) { module.exports factory(require(jquery)); } else if (typeof define function define.amd) { define([jquery], factory); } else { root.MyModule factory(root.jQuery); } })(this, function ($) { return {}; });UMD 兼容性强几乎所有 npm 包在那个时期都会打一份 UMD 产物这让各种构建工具铁了心搞统一。但说白了这个兼容层也是历史包袱本质上是规范分裂期的临时产物。1.3 ESM 的胜利不是偶然2015 年 ES6 正式把模块化写进语言标准import/export 成为一等公民。让我真正服气的不是语法好看而是它的静态结构——import 和 export 必须写在顶层模块的依赖关系在解析阶段就能确定编译器拿到代码不用执行就知道这个模块依赖谁、导出了谁。这意味着很多以前想都不敢想的事变成了可能Tree Shaking编译期就能识别哪些导出没被引用打包时直接摇掉。循环依赖检测模块图在解析期就能发现环不用等运行时炸掉。静态类型友好TypeScript 的类型擦除和模块解析能更准确地工作。确定性排序模块的执行顺序由依赖图决定不再依赖 script 标签的手写顺序。浏览器原生支持 script typemodule 之后连构建工具都不需要了直接就能在浏览器里跑 ESM。同时 import maps 也补了缩略名解析的坑页面里可以这么写script typeimportmap { imports: { vue: https://cdn.example.com/vue3.4.0/dist/vue.esm-browser.prod.js } } /script script typemodule import { createApp } from vue; /script我为什么要把这段历史讲得这么细因为现在很多同事的模块化认知是直接建立在 ESM 和 webpack 上的他们不知道在此之前模块加载是运行时的事。不理解运行时加载和编译期静态分析的差异就永远不会懂为什么 Vite 快、为什么 tree shaking 有极限、为什么模块联邦能做成运行时共享依赖。历史的每一步都在给下一代方案埋种子。2. 构建工具迭代史webpack、Rollup、Vite 的取舍逻辑2.1 webpack什么都管但管得太累webpack 是 2015 年之后前端工程化的绝对主角。它的核心思想是把一切资源都当作模块——JS 是模块CSS 是模块图片是模块字体也是模块然后用 loader 把它们处理成浏览器能认的产物。这个万物皆模块的设计在当时是降维打击恰好接住了单页应用爆发期的需求。但用过的都知道webpack 的学习曲线相当陡。一个老项目的 webpack.config.js 上千行是常态entry、output、resolve、module、plugins、optimization 六大部分每个部分都有二十多个可配置项。我见过刚入行的同事对着 webpack 配置一脸茫然最后干脆复制老项目配置改改路径就上线出了错也不知道去哪查。更折磨人的是开发期的编译速度。2018 年我做一个后台中台项目业务代码接近一百个路由页面webpack-dev-server 冷启动要一分半改一行代码热更新等五六秒是常事。那种改一行字等一杯咖啡的体验直接拉低了整个团队的迭代效率。后来 webpack 自己也意识到这个问题。webpack 5 加了持久化缓存把编译结果缓存到 node_modules/.cache 里二次编译快了不少又内置了 Module Federation让跨应用共享模块有了官方方案。但底层的 JavaScript 执行模型决定了它的性能上限——同样是打包用 Go 写的 esbuild 比 webpack 快几十倍这不是堆优化能追回来的差距。2.2 Rollup库开发场景的正确答案Rollup 跟 webpack 几乎是同一时期立项但它走了另一条路用 ESM 的静态分析做极致的 tree shaking打包出来的产物干净、体积小特别适合发布 npm 库。我用 Rollup 做过一个 UI 组件库对比特别直观。同样一份源码webpack 打出来的 UMD 包因为要处理各种兼容逻辑体积大了差不多 15%Rollup 打出来的产物就是一个个 ESM 文件副作用标记清晰使用者可以按需引入配合 terser 优化之后效果好得多。Rollup 的弱项在应用级场景——它对代码分割、动态 import、开发服务器这些日常需求支持得没那么顺手。所以很长一段时间的业界常规操作是应用层用 webpack库层用 Rollup各干各的。2.3 esbuild Vite体验优先的破局者Vite 出现之前大家已经被 webpack 的慢折磨了很多年。Vite 的思路跟 webpack 完全相反开发环境不打包直接用浏览器原生的 ESM 按需加载。浏览器请求哪个模块Vite 就把那个模块依赖的裸导入bare import比如 import Vue from vue预打包一下剩下的业务代码原样输出给浏览器。因为每一个 import 都是浏览器发起的真实 HTTP 请求改一个文件只需要让浏览器重新请求那一个文件全量重打包这步直接被跳过了所以 Vite 冷启动能做到秒开热更新是毫秒级的。这里有个经常被问到的点Vite 开发环境快是因为 esbuild 快但生产构建却默认用 Rollup为什么不直接用 esbuild 打包这个问题我也犹豫过。esbuild 打包确实也快但它的 tree shaking 和代码分割能力不如 Rollup 成熟而且生态里大量插件都是为 Rollup 写的。Vite 的做法是取长补短开发期用 esbuild 做依赖预构建生产期用 Rollup 保证产物质量。这套组合拳目前来看是体验和质量的平衡点。用 Vite 也不是完全没坑我实际踩过的有几个依赖预构建缓存某个依赖安装后升级版本Vite 可能还在用旧缓存导致莫名其妙的报错。解决方法是改依赖后手动删掉 node_modules/.vite。老插件兼容性有些 webpack 时代的 loader 和 plugin 在 Vite 里找不到对应替代品尤其是复杂场景下的自定义 code splitting。后端模板集成如果把前端构建产物嵌入后端模板比如 PHP、Java 的视图层Vite 的 base 路径和动态资源地址要额外处理。2.4 三套工具的定位总结下表是我基于多个项目体感总结的选型参考维度webpackRollupVite核心优势生态最全、配置灵活产物干净、tree shaking 极致开发体验快、上手简单短板配置复杂、编译慢应用级能力弱老插件生态缺口适用场景大型 SPA、复杂工程组件库、工具库发布中后台、新项目快速迭代上手成本高中低我现在的选型逻辑很简单新项目默认 Vite除非遇到特殊需求做库或组件库用 Rollup遗留的老项目如果还趴在 webpack 上没有强需求就不要轻易动它先保证业务稳定。3. 应用内部的模块边界工程化拆分的实战经验3.1 按功能域划分模块别再按技术类型堆目录很多团队做模块化架构第一步就是把目录结构定为 views/components/store/api/utils这是典型的按技术类型划分。小项目还好项目一大就乱套——一个业务功能要跨四五个目录改代码改个用户列表要同时动 views/user、store/user、api/user、components/user-table关联关系全靠人脑记。真正好用的做法是按功能域划分业内一般叫 feature-based 或 domain-driven 结构src/ features/ auth/ components/ hooks/ api/ store/ dashboard/ components/ hooks/ api/ store/ shared/ components/ utils/ hooks/这样划分之后一个功能的所有代码收在一个目录里开发时心智负担小得多。跨功能复用时就往上提到 shared——但提的时候要想清楚这个组件到底是通用能力还是恰好在别处也用得上。3.2 依赖方向必须单向循环依赖是项目腐烂的开始模块化架构里最容易失控的是依赖关系。我见过最极端的例子是一个项目里出现了 A 依赖 B、B 依赖 C、C 又依赖 A 的循环依赖结果初始化时互相拿不到对方的导出页面白屏排查了一整个下午。循环依赖的根因通常是双向通信——两个模块互相需要对方的函数或状态。正确解法是把公共依赖往下沉或引入事件总线、依赖注入来解耦通信。我自己定的团队规范是三条上层可以依赖下层下层绝不能依赖上层。feature 可以依赖 sharedshared 不能反向依赖任何 feature。同层的模块之间不直接互相引用。要协作就通过父级协调或事件机制。新增依赖必须过 code review。每次 PR 里新增 import 路径都要说明理由防止依赖悄然变混乱。3.3 共享代码的收敛utils 不是垃圾桶模块化架构落地到日常开发最容易犯的错就是把什么都往 shared/utils 里塞。今天封装一个时间格式化明天封装一个金额转换后天把几个页面共用的函数也丢进去最后 utils 变成一个没有内在逻辑的大杂烩谁都不敢改因为不知道会被哪里用到。收敛共享代码我有几个判断标准看通用度只有三个以上不同领域的模块会用到才进 shared。看变化频率如果这个函数几乎每个月都会因业务需求改一次说明它本身边界不清放哪都会出事。看语义完整性与其放一个散装函数不如收敛成一个自洽的类或模块比如 DateUtils、PriceUtils对外暴露的 API 语义清晰。组件库的边界更关键。业务组件和通用组件混在一个目录里是很多项目后期维护的噩梦。我个人的习惯是业务组件就近放在 feature 目录下只有完全去业务化的通用组件才进 shared/components并且用文档把每个组件的使用场景、props、插槽约定写清楚。没有文档约束的共享组件本质上就是在埋雷。4. 跨应用共享微前端与 Module Federation 的真实体感4.1 微前端到底解决什么问题别为了架构而架构微前端是这几年最热的前端架构话题之一但说实话我在很多团队看到的使用场景并不成立。两三人的团队维护一个后台系统非要把应用拆成五个微应用光做部署和联调的成本就比单体高出好几倍这就是典型的为了架构而架构。微前端真正适用的场景我认为只有两个大型遗留系统的渐进式迁移。老系统技术栈老旧不能整体重构但新增的业务模块想用新框架新构建链这时候用微前端把新老模块隔离共存。多团队独立开发独立发布的超大型应用。比如一个平台汇聚了几十个业务系统每个系统由独立团队维护直接打包在一起会导致发布互相牵制拆开才能对齐团队的自治边界。4.2 qiankun 的沙箱与样式隔离机制qiankun 是基于 single-spa 封装的微前端框架核心能力是 JS 沙箱和样式隔离。JS 沙箱的原理是在子应用加载时快照全局对象卸载时还原避免子应用污染宿主应用的 window。样式隔离则通过给子应用根节点加属性前缀把 CSS 作用域限制住。听上去很美好实际用起来要注意几个细节。一是沙箱对 ES Module 的支持一直不算顺子应用最好打包成 UMD 或特定格式二是样式隔离对动态插入的 style 标签需要额外处理否则子应用里有些第三方组件的样式还是会穿透三是主应用和子应用之间的路由状态同步要自己搭建官方给的例子比较理想化真实业务里各种边界情况很多。4.3 Module Federation 的运行时共享思路webpack 5 的 Module Federation 提供了一个更轻量的跨应用共享方案。它的思路是把应用拆成容器和远程模块——主应用声明自己需要哪些远程模块远程模块由其他应用在运行时提供加载后如同本地模块一样使用。实际体验下来Module Federation 的最大价值是依赖共享。比如多个应用都用同一个组件库和状态库传统打包方式是每个应用都打一份白白浪费流量和内存用 MF 可以让主应用加载一份公共依赖子应用运行时复用。我做过的两个微应用集成场景公共依赖的加载量直接减了一半。但 MF 也有明显的坑版本管理和部署复杂度上升。远程模块的版本变了宿主应用可能没感知上线顺序一乱就出现线上故障。必须建立先发布提供方、再发布消费方的部署规范同时做好运行时版本检测和灰度。这方面 pnpm workspace monorepo 是一个较好的兜底方案——源码层面统一版本构建后再由 MF 共享产物。4.4 微前端与模块联邦的选型对比方案隔离粒度复用方式适用体量主要成本qiankun应用级隔离JS/CSS应用聚合大团队多应用沙箱、路由、联调成本高Module Federation模块级共享运行时依赖中大型应用组合版本管理、部署顺序import maps ESM语言原生浏览器原生模块中小型应用依赖管理繁琐4.5 微前端的潜在坑与避坑建议还有几个微前端的常见问题我在实际项目中碰到过值得单独拎出来说。子应用间的状态共享两套独立的 store 之间做同步方案五花八门事件总线、消息广播用得多了整个系统跟蜘蛛网一样。我比较推荐的是抽象出一个独立的 shared-state 模块用发布订阅模式统一管理子应用只跟这个模块通信。样式隔离过头引发的问题我们曾经把子应用样式严格隔离结果发现某些通用弹窗在不同子应用里长得完全不一样用户反馈体验不一致。后来调整方案基础样式全局共享业务样式各自隔离。登录态与鉴权的透传主应用登录后子应用怎么拿到 token、怎么刷新、跨域 cookie 怎么处理这些都要提前画好时序图否则联调期天天互相甩锅。5. 演进背后模块化架构的未来判断5.1 标准优先于工具浏览器原生能力的持续吞噬回看整个演进历程一个清晰的趋势是标准化逐步替代私有方案。ES Modules 替代了 AMD/CMDimport maps 替代了裸导入解析浏览器的原生能力在一点点压缩构建工具的存在必要。这并不意味着构建工具会消失而是它们会向两个方向分化一是进一步做好性能优化如代码分割、压缩、缓存策略二是专注于标准化能力覆盖不到的领域。作为开发者我的建议是优先写标准化的模块代码少依赖特定构建器的私有语法这样无论未来工具怎么换代码资产都不会贬值。5.2 RSC、Islands 与模块边界的重构React Server Components、Astro 的 Islands 架构、各种 SSR 框架的崛起这些新的渲染架构正在重塑模块化的边界。以前模块化主要关心客户端代码怎么拆分现在关心的是哪些代码跑在服务端、哪些跑在客户端、哪些两端共享——模块的边界从文件之间延伸到了运行时环境之间。这类架构对团队提出了更高的要求每个组件都要显式声明自己的运行环境构建工具要能正确地拆分服务端与客户端产物。模块化从代码组织问题变成了运行时架构问题。我在试水 RSC 之后最大的感受是以往的打包优化思路几乎全部要重学但同时应用的整体性能上限也明显提高了。5.3 我的模块化选型框架最后分享一个我实际带团队时用的选型框架。接到一个新项目我会依次问五个问题团队规模和分工超过五个开发且长期并行迭代优先考虑功能域拆分 尽量少的微前端。技术栈统一程度技术栈统一就选 Vite 单应用模块化技术栈割裂再考虑微前端兼容。业务变更频率核心业务每月都有大改模块边界要按 feature 切细低频保守业务可以按层切。部署发布自由度团队希望独立发布考虑微前端或 MF集中发版单体模块化收益更高。团队对工具的熟悉度再好的架构如果团队没人能维护就是负债。这套框架不是什么高深理论就是我在十来个项目里摔打出来的实操准则。架构没有标准答案只有适合当前团队和业务的选择。很多团队的问题不在选错了架构而在选了一个远超当前需求复杂度的架构让团队花大量精力去维护架构本身业务反而被拖慢了。模块化架构演进了十几年本质奔着一个方向让代码的边界更清晰、让团队的协作更顺畅、让应用的性能更可控。我个人体会最深的一点是任何模块化方案都是一把双刃剑——它帮你约束了复杂度也给你带来了新的编排成本。判断一个方案值不值得上永远要回到它解决的是不是我们现在最痛的问题这个基本问题上而不是追逐热度。与其把架构设计得无比宏大不如把模块边界定得清晰朴素让团队能持续快速交付这才是模块化真正该有的样子。