
摘要
在现代 Web 应用的开发版图中,系统复杂度的指数级增长已成为常态。随着业务边界的不断扩张、团队规模的持续膨胀,曾经“小而美”的单体应用(Monolith)逐渐演变为盘根错节、难以维护的“巨石”系统。每一次微小的需求变更都可能牵一发而动全身,独立开发、测试和部署成为奢望,技术栈的迭代升级更是步履维艰。
Garfish 与 Vmok 微前端方案实现原理与选型分析
前言:在复杂中寻求秩序
在现代 Web 应用的开发版图中,系统复杂度的指数级增长已成为常态。随着业务边界的不断扩张、团队规模的持续膨胀,曾经“小而美”的单体应用(Monolith)逐渐演变为盘根错节、难以维护的“巨石”系统。每一次微小的需求变更都可能牵一发而动全身,独立开发、测试和部署成为奢望,技术栈的迭代升级更是步履维艰。
正是在这样的背景下,“微前端”(Micro-Frontends)架构应运而生。它借鉴了后端“微服务”的核心思想,倡导将一个庞大的前端应用,按业务领域或功能模块,拆解成一系列更小、更自治、技术栈无关的“微应用”。这些微应用可以被独立开发、独立部署,并最终在用户浏览器中被无缝地聚合为一个完整的产品。其核心价值在于,通过“分而治之”,在工程层面实现团队解耦、提升迭代效率,并在架构层面赋予系统更高的灵活性与可扩展性。
在字节跳动内部,对微前端的探索与实践同样走在行业前列。面对海量用户、高速迭代的业务场景,前端体系催生了多种解决方案。其中,开源的 Garfish 与内部广泛应用的 Vmok,分别代表了两种不同颗粒度、不同侧重点的微前端实现路径。
本报告将从一线研发的视角,深入拆解 Garfish 与 Vmok 的核心实现原理,系统性地对比两者在隔离机制、依赖共享、开发体验(DX)及性能表现上的关键差异。我们不仅会探讨“它们是什么”,更会聚焦于“它们如何工作”,并最终落脚于“在何种场景下该如何选择”,旨在为研发工程师与架构评审者提供一份清晰、易懂且具备实践指导意义的技术参考。
微前端的核心理念与实现路径
在深入对比 Garfish 与 Vmok 之前,我们有必要简要回顾微前端的核心理念以及业界主流的实现路径。这有助于我们更好地理解这两种框架的设计抉择及其背后的技术权衡。
微前端的核心在于“技术栈无关”、“独立开发”与“独立部署”。为了实现这一目标,业界探索出了多种技术路径,每种路径都在隔离、通信、性能之间做出了不同的取舍。
1. Iframe:与生俱来的“硬隔离”
Iframe 是最古老、最直观的页面嵌入方案。它天然提供了完美的“硬隔离”,包括 JS 沙箱(独立的 window 对象)、CSS 隔离(独立的样式作用域),几乎无需任何额外处理就能将一个独立的页面嵌入到另一个页面中。
然而,Iframe 的优点也正是其局限所在:
- 通信成本高:父子应用通信依赖 postMessage,流程相对繁琐。
- 体验不佳:浏览器刷新会丢失子应用状态,前进后退按钮的管理复杂;UI 层的弹窗、遮罩等元素会被 Iframe 边界限制,难以实现全局覆盖。
- 性能开销大:每次加载 Iframe 都相当于打开一个全新的网页,资源和上下文的重复加载导致性能开销较大。
由于这些固有的体验与性能问题,纯 Iframe 方案通常不被视为现代微前端架构的首选。
2. Single-SPA / Qiankun:路由驱动的“软隔离”
以 single-spa 为基石,并由 qiankun 发扬光大的方案,是当前社区最主流的应用级微前端实现。其核心思想是:主应用通过劫持路由,根据路由规则(activeWhen)动态地加载并挂载不同的子应用。
这种模式通常被称为“软隔离”,它通过一系列技术手段模拟出隔离环境:
- JS 沙箱:通过 Proxy 代理 window 对象,实现全局变量的隔离。当子应用运行时,对其 window 的读写操作会被捕获并重定向到一个“伪 window”对象上,从而避免污染全局 window。当子应用卸载时,只需恢复 window 对象的快照即可。
- CSS 隔离:通常采用动态添加/移除
<style>标签的方式。一些框架还会结合 Shadow DOM 或 CSS in JS 方案来确保样式不会相互影响。 - 生命周期管理:约定一套 bootstrap, mount, unmount 的生命周期协议,由主应用统一调度。
Qiankun 在 single-spa 基础上,进一步提供了 HTML Entry、沙箱、样式隔离等开箱即用的能力,极大地降低了接入成本,是目前应用级微前端的标杆。
3. Module Federation:构建时的模块共享
Webpack 5 引入的模块联邦(Module Federation, MF)从一个全新的维度解决了微前端问题。它并非一个运行时框架,而是一种构建时的模块共享机制。
MF 允许一个应用(Remote)在构建时暴露自己的模块,而另一个应用(Host)可以在运行时动态地从 Remote 加载这些模块,就如同加载本地模块一样。其核心优势在于:
- 原生共享依赖:MF 具备强大的依赖分析能力。如果 Host 和 Remote 共享了同一个依赖(如 React),该依赖只会被加载一次,实现了真正的运行时依赖共享,极大优化了性能。
- 代码拆分与动态加载:共享的模块可以按需加载,粒度非常灵活,可以是单个组件、函数,乃至整个应用。
- 去中心化:任何应用都可以既是 Host 又是 Remote,形成一个灵活的网状结构。
MF 为模块级(而非应用级)的代码复用和共享提供了极其优雅的方案,但它本身不提供 JS 沙箱和 CSS 隔离,需要与上层框架结合使用。
4. 应用级与模块级拆分
上述路径也引出了微前端的两种主要拆分粒度:
- 应用级拆分:将整个前端应用按业务板块拆分成多个独立的、可独立运行的“应用”。每个子应用通常有自己的路由、状态管理和完整的技术栈。Garfish 正是这一模式的典型代表。
- 模块级拆分:将应用中的公共能力、业务组件等拆分成可独立版本化和发布的“模块”。这些模块本身无法独立运行,而是作为“插件”或“零件”被动态注入到宿主应用中。Vmok 则是这一模式的深度实践者。
理解了这些背景,我们便可以开始深入探索 Garfish 和 Vmok 的内部世界。
Garfish:稳健的应用级微前端解决方案
Garfish 是字节跳动开源的一款成熟的应用级微前端框架。它遵循了以 qiankun 为代表的“路由驱动”模式,并在此基础上进行了诸多优化与扩展,旨在提供一套稳定、可靠、功能丰富的生产级解决方案。其核心设计可以概括为四大模块:Router(路由)、Loader(加载器)、Sandbox(沙箱)和 Store(通信)。
Image
1. 路由系统:劫持与调度中心
Garfish 的路由系统是整个微前端框架的“交通枢纽”。当用户在浏览器中进行导航时,Garfish Router 会立即介入,完成以下核心任务:
- 路由劫持:与 vue-router 或 react-router 的原理类似,Garfish 会重写
history.pushState和history.replaceState方法,并监听 popstate 和 hashchange 事件。这使得主应用能够捕获所有的路由变化,从而获得控制权。 - 子应用匹配与激活:主应用在初始化时,会注册一个包含所有子应用信息的列表。每个子应用都配置了一个 activeWhen 规则,这通常是一个路径字符串(如
/app1)或一个返回布尔值的函数。当路由发生变化时,Garfish 会遍历这个列表,找到与当前 URL 匹配的 activeWhen 规则,进而确定需要加载或卸载哪个子应用。 - basename 自动计算:为了防止不同子应用之间的路由冲突,Garfish 会根据 activeWhen 的值自动为每个子应用计算出一个唯一的 basename。例如,activeWhen: '/app1' 的子应用,其 basename 就是 /app1。这个 basename 会被传递给子应用,子应用内部的路由库(如 react-router)需要将此 basename 作为根路径,确保其所有路由都基于这个前缀,从而实现主子路由的隔离。
2. Loader:资源加载与协议解析
一旦 Router 确定需要加载某个子应用,Loader 模块便开始工作。它负责获取子应用的资源,并为其渲染做好准备。Garfish 支持两种主流的入口模式:HTML Entry 和 JS Entry。
HTML Entry
这是 Garfish 推荐的方式。子应用的入口是一个 HTML 文件。Loader 的处理流程如下:
- 资源获取:通过 fetch 获取 HTML 文件的文本内容。
- DOM 解析:将 HTML 文本解析成一个 DOM 树。这一步并不会直接渲染,而是在内存中进行,以便对资源进行处理。
- 资源分类与处理:遍历 DOM 树,提取出所有的
<script>、<style>、<link rel="stylesheet">等资源标签。- 对于外部脚本(
<script src="...">)和样式(<link href="...">),会记录其 URL,后续由沙箱负责加载和执行。 - 对于内联脚本(
<script>...</script>)和样式(<style>...</style>),会提取其内容,同样交由沙箱处理。
- 对于外部脚本(
- 通过这种方式,Garfish 将子应用的所有静态资源都纳入了自己的管控范围,为沙箱隔离和性能优化奠定了基础。
JS Entry & Provider 协议
当入口是单个 JS 文件时,Garfish 需要一种方式来获取子应用的生命周期函数。为此,它约定了一套名为 provider 的渲染协议。
子应用需要从其入口文件导出一个名为 provider 的函数,该函数返回一个包含 render 和 destroy 方法的对象:
javascript// 子应用入口: a-sub-app/src/index.js export const provider = () => { return { render({ dom, basename, props }) { // 使用 React 渲染子应用 ReactDOM.render( <App basename={basename} {...props} />, dom.querySelector('#root') ); }, destroy({ dom }) { // 卸载 React 应用 ReactDOM.unmountComponentAtNode(dom.querySelector('#root')); }, }; }; // 用于独立运行时 if (!window.__GARFISH__) { ReactDOM.render(<App />, document.querySelector('#root')); }
那么,Loader 是如何在加载 JS 文件后,准确找到这个 provider 导出的呢?答案在于构建配置与运行时模拟。
- UMD/CommonJS 规范:Garfish 要求子应用打包成 UMD(Universal Module Definition)格式。UMD 规范的代码会检测当前的模块环境(如 CommonJS, AMD, 或 global)。
- 模拟 CommonJS 环境:在执行子应用的 JS 代码之前,Garfish 会在沙箱中注入一个伪造的 module 和 exports 对象。当 UMD 代码执行时,它会命中 CommonJS 的判断分支(
typeof exports === 'object' && typeof module === 'object'),然后将导出内容赋值给module.exports。 - 获取 Provider:执行完毕后,Garfish 就能从
module.exports.provider中安全地拿到子应用的生命周期实现。
这种机制非常巧妙,它利用了模块化规范的特性,在运行时实现了对子应用导出的“截获”,而无需子应用做过多的侵入式改造。
3. Sandbox:隔离的艺术
沙箱是微前端框架的灵魂,其核心目标是隔离副作用,确保多个子应用能在同一个 window 环境下和平共处。Garfish 提供了两种沙箱实现:SnapshotSandbox(快照沙箱)和 VMSandbox(虚拟机沙箱)。
SnapshotSandbox
快照沙箱的原理相对简单,适用于不支持 Proxy 的旧版浏览器。
- 激活 (Activate):在子应用挂载前,遍历 window 对象,将当前所有属性复制一份,形成一个“快照”。
- 运行:子应用在全局 window 上自由运行。
- 卸载 (Deactivate):子应用卸载时,再次遍历 window 对象,与之前保存的快照进行对比,找出被修改和新增的属性。将被修改的属性恢复原状,将被新增的属性删除。
快照沙箱的缺点是无法支持多实例(即同时运行多个子应用),因为所有应用都共享同一个全局 window。它更像是一种“分时复用”的策略。
VMSandbox
这是 Garfish 在现代浏览器中的默认选择,基于 ES6 的 Proxy 实现,提供了更彻底的隔离,并支持多实例。
- 伪 window 对象:为每个子应用创建一个代理对象 fakeWindow,它通过 Proxy 包装了真实的 window。
- 读操作 (Get):当子应用尝试读取
window.someVar时,Proxy 的 get 陷阱会捕获这个操作。它会优先从沙箱内部的私有变量(一个普通 Object)中查找 someVar。如果找不到,再向上“穿透”到真实的 window 对象中去查找。 - 写操作 (Set):当子应用尝试写入
window.someVar = 'value'时,Proxy 的 set 陷阱会拦截这个操作,并将 someVar 和它的值 'value' 始终保存在沙箱内部的私有变量中,而不会污染真实的 window。 - 副作用收集与还原:除了 window 变量,沙箱还需要处理其他副作用,如 document 上的事件监听(addEventListener)、定时器(setTimeout/setInterval)、动态添加的样式和脚本等。Garfish 会重写这些原生 API,将子应用注册的事件、定时器 ID 等信息记录下来。当子应用卸载时,再根据记录一一清除,确保“雁过无痕”。
VMSandbox 为每个子应用都创建了独立的“模拟环境”,使得它们可以同时运行而互不干扰。
4. 依赖共享与扩展能力
- externals 机制:Garfish 推荐使用 Webpack 的 externals 配置来实现主子应用间的依赖共享。主应用将 React、Vue 等公共库通过 CDN 引入并暴露到全局,子应用则将这些库配置为 externals,在构建时不再打包它们,而是在运行时从主应用的 window 对象上获取。这种方式简单直接,但缺点是主子应用强耦合,公共库版本需要统一管理,升级较为困难。
- 多实例:得益于 VMSandbox,Garfish 支持在同一个页面同时激活和渲染多个子应用,这在一些复杂的仪表盘或门户页面中非常有用。
- 预加载:为了提升应用切换速度,Garfish 提供了预加载能力。它可以在浏览器空闲时,提前下载下一个可能被访问的子应用的资源,当用户真正点击时,直接从缓存中读取,大大缩短了等待时间。
- 与 Modern.js 集成:作为字节跳动内部工程体系的一部分,Garfish 与 Modern.js 框架深度集成。通过 @modern-js/plugin-garfish 插件,可以一键开启微前端能力,简化了大量的配置工作。
总的来说,Garfish 提供了一套非常全面和稳健的应用级微前端解决方案。它通过路由劫持、资源加载、沙箱隔离和依赖共享,有效地解决了大型应用拆分后的集成问题,并在工程效率和稳定性上做了大量优化。
Vmok:精细化的微模块共享与动态注入引擎
与 Garfish 应用级的定位不同,Vmok 是一套微模块(Micro-Module)解决方案。它深入到了 Webpack 构建的腹地,基于 Module Federation(MF)的思想,并结合字节内部强大的工程化基建,打造了一套覆盖从开发、构建、部署到运行时的全链路微模块共享体系。Vmok 的核心理念是“应用分治 + 动态注入”,旨在实现比应用级更细粒度的代码复用和更灵活的依赖共享策略。
Image
1. 核心定位:从“应用”到“模块”的下沉
Vmok 的关注点不在于“拼装应用”,而在于“共享模块”。这里的模块可以是任何 JS 模块,比如一个 React 组件、一个工具函数库,或者一组业务逻辑。这种下沉带来了几个核心变化:
- 生产者/消费者模型:在 Vmok 的世界里,应用被划分为两种角色。生产者(Producer/Remote)负责构建并暴露(exposes)一个或多个模块。消费者(Consumer/Host)则通过配置(remotes)来声明它希望使用哪些远程模块。
- NPM 式的开发体验:对于消费者而言,使用一个远程模块就像使用一个本地 npm 包一样简单。通过构建插件的魔法,开发者可以直接使用 import 语法来引入远程模块,Vmok 会在背后处理好加载和依赖的一切。
- 动态注入:模块的加载是完全动态的。消费者可以在运行时决定加载哪个生产者的哪个版本的哪个模块,这为 A/B 实验、动态功能下发、低代码与 Pro-code 混合等场景打开了想象空间。
2. 实现机制:构建、运行时与平台的协同
Vmok 的强大之处在于它并非一个孤立的运行时框架,而是一个深度整合了构建工具、运行时 SDK 和部署平台的完整体系。
构建层:Webpack 插件的魔力
Vmok 的核心能力始于构建阶段,通过一个 Webpack 插件(如 @edenx/plugin-vmok)深度介入。
- 生产者(Exposes):当一个项目被定义为生产者时,Vmok 插件会将其通过 exposes 配置指定的模块,打包成一种特殊的格式,并生成一份 manifest.json 文件。这份清单文件描述了该生产者暴露了哪些模块、每个模块的入口文件是什么,以及它自身依赖了哪些共享库(shared)。
- 消费者(Remotes):当消费者通过 remotes 配置声明了对远程模块的依赖时,插件会做一件关键的事情:将这些
import('remote/module')语句在编译时转换为对 Vmok 运行时 API 的调用。这意味着,开发者写的看似同步的导入,实际上在构建后变成了异步加载远程模块的逻辑。 - 依赖共享(Shared):这是 Vmok(及 MF)最精妙的设计之一。生产者和消费者都可以通过 shared 配置声明自己项目中的共享依赖及其版本要求(如
react: { singleton: true, requiredVersion: '^17.0.0' })。singleton: true 确保了该依赖在整个应用生命周期中只有一个实例。
运行时:动态加载与智能共享
当消费者应用在浏览器中运行时,Vmok Runtime 开始接管。
- 远程模块加载(loadRemote):当代码执行到被构建插件改写过的 import 语句时,会触发
loadRemote('remote/module')的调用。 - 模块中心与 Snapshot:loadRemote 首先会查询模块中心。模块中心是一个服务端组件,它存储了所有生产者发布的所有版本信息。它会根据消费者的请求,返回一个快照(Snapshot)。这份快照包含了目标模块的资源 URL、依赖树,以及所有共享依赖的版本信息。
- 智能依赖共享:拿到快照后,Vmok Runtime 会进行一次精密的“依赖协商”。它会检查快照中生产者所需的共享依赖(如 React 17.0.2),与消费者自身已经加载的共享依赖(如 React 17.0.1)进行版本对比。
- 如果版本兼容(根据 semver 规则),则直接复用消费者环境中的依赖,避免重复加载。
- 如果版本不兼容,Vmok 会根据预设策略决定是加载生产者自己的版本,还是报错。这种灵活的运行时共享策略,彻底解决了 Garfish externals 方案的强耦合和版本锁定问题。
- 模块实例化:依赖问题解决后,Vmok 会下载远程模块的 JS 文件,并在一个隔离的上下文中执行,最终返回模块的导出内容,完成 import 的过程。



