前端知库

qiankun、无界和 Module Federation 怎么选?JS 沙箱、样式隔离和通信有什么不同?

qiankun 用 fetch 拉取子应用的 HTML,用 Proxy 代理 window 做 JS 沙箱,样式隔离可选 Shadow DOM 或选择器前缀,通信靠 props 和 initGlobalState。无界把子应用的 JS 放进同域 iframe 运行,DOM 渲染在 Web Component 里,隔离更彻底,子应用基本不用改造,通信靠 props、window.parent 和 EventBus。Module Federation 是构建层面的模块共享,没有沙箱和样式隔离,适合技术栈统一、想细粒度共享代码的团队。

参考答案

简答

三个方案解决的问题不完全一样。qiankun 和无界是“把一个完整的子应用嵌进主应用”,Module Federation 是“在构建时声明我要用别人的哪个模块”,运行时把模块加载进来,粒度可以小到一个组件。

隔离方式是最大的区别。qiankun 用 Proxy 代理 window 做 JS 沙箱,子应用写全局变量写进代理对象,读不到时再去真实的 window 上找;样式隔离默认不开,可以选 Shadow DOM 或给选择器加前缀。无界把子应用的 JS 放进一个和主应用同域的 iframe 里运行,iframe 有自己原生的 window,DOM 则渲染在一个 Web Component 里,样式天然隔开。Module Federation 没有沙箱,也不做样式隔离,远程模块和主应用跑在同一个环境里。

通信方面,qiankun 用注册时传的 props 和 initGlobalState 返回的全局状态;无界有 props 注入、同域下直接访问 window.parent、以及 EventBus 三种;Module Federation 的远程模块就是普通模块,直接调用函数、传 props 就行。

所以我会这样选:技术栈统一、想共享组件和依赖,用 Module Federation;要接入技术栈不同的老系统,看重隔离,就在 qiankun 和无界里选。无界的子应用基本不用改造,还支持保活;qiankun 要求子应用导出生命周期、打包成 umd。两者都要求子应用的资源支持跨域访问。

先说清楚它们在哪一层工作

微前端要解决什么组织问题、iframe 和 Web Component 等集成方式怎么比,见《微前端适合解决什么组织和部署问题?》。Module Federation 的配置和 shared 版本协商,见《怎样用 Module Federation 做微前端?和 qiankun 有什么区别?》。这里只比三者在隔离和通信上的不同。

qiankun 和无界都是运行时方案:主应用运行时拿到子应用的地址,把它的 HTML、JS、CSS 拉下来执行。子应用可以用任何框架、任何时间单独部署。Module Federation 是构建时方案:双方在构建配置里声明暴露和使用的模块,共享依赖也在构建时协商,所以通常要求团队使用支持它的构建工具。

JS 沙箱:qiankun 的 Proxy 和无界的 iframe

qiankun 文档的 FAQ 写明,start() 传 { sandbox: true } 时,qiankun 用 Proxy 给子应用隔离全局的 window 对象。子应用读 window.Vue 时,先在代理对象上找,找不到再去全局的 window 上找。下面用几行代码模拟这种行为,这只是原理示意,不是 qiankun 的源码:

JavaScript
const realWindow = { appName: '主应用', Vue: 'Vue 2' };

function createSandbox(global) {
  const local = {};
  return new Proxy(local, {
    get(target, key) {
      return key in target ? target[key] : global[key];
    },
    set(target, key, value) {
      target[key] = value; // 写入只落在沙箱自己的对象上
      return true;
    },
  });
}

const subWindow = createSandbox(realWindow);
subWindow.appName = '子应用';
subWindow.token = 'abc';

console.log(subWindow.appName); // 子应用
console.log(realWindow.appName); // 主应用
console.log('token' in realWindow ? '泄漏' : '没有泄漏'); // 没有泄漏
console.log(subWindow.Vue); // Vue 2

子应用改了 appName、加了 token,真实的 window 都没变,卸载子应用时把代理对象扔掉就行。可是最后一行说明了这种沙箱的边界:子应用能读到主应用挂在 window 上的东西。qiankun 的 FAQ 就举了这样一个例子:vue-router 加载时会检查 window.Vue,子应用在沙箱里读到的是主应用的 Vue,结果报 Cannot redefine property: $router。

无界换了一条路。它的文档说,子应用的 JS 注入到一个和主应用同域的 iframe 中运行,iframe 是原生的 window 沙箱,修改只发生在 iframe 内部,主应用的 window 不受影响。iframe 也有自己的 history 和 location,所以子应用的路由和主应用天然分开。无界还特别说明,它不用 with(proxyWindow){code} 这种方式执行代码,子应用的代码以原生速度运行,代价是要创建 iframe,这一步可以用预加载提前做。

样式隔离:前缀、Shadow DOM 和 Web Component

qiankun 的 sandbox 默认值是 true,只开 JS 沙箱,样式隔离要显式配置。API 文档给了两种:{ strictStyleIsolation: true } 会把每个子应用的容器变成 Shadow DOM,子应用的样式不会泄漏到全局;{ experimentalStyleIsolation: true } 是实验性的方案,给子应用的选择器加上 div[data-qiankun-react16] 这样的前缀来限制作用范围,文档注明 @keyframes、@font-face、@import、@page 不会被改写。

为什么不默认开 Shadow DOM?因为外部样式进不来,内部样式也出不去,弹窗、下拉菜单这类挂到 document.body 上的组件,会跑到 Shadow DOM 外面,样式就丢了。

无界的做法是创建一个 wujie 自定义元素,把子应用的完整 DOM 结构渲染在里面,JS 仍然在 iframe 里运行。为了让两边接起来,无界把 iframe 里 document 的 querySelector、getElementById、head、body 等查询都代理到这个 Web Component 上,子应用往 document.body 上 appendChild 的弹窗也会被放进去,所以弹窗不用改造也能正常显示。

有的浏览器不支持这些能力时,无界会自动降级:用 iframe 替换 Web Component,用 Object.defineProperty 替换 Proxy。startApp 的 degrade 参数可以主动开启降级。

Module Federation 不提供样式隔离,远程组件的样式和主应用在同一个文档里,要靠 CSS Modules、BEM 或统一前缀之类的约定避免冲突。

通信:三者各用什么

qiankun 注册子应用时可以传 props,子应用在 mount 生命周期里拿到。需要全局共享状态时,主应用调用 initGlobalState(state) 得到一组方法:onGlobalStateChange 监听变化,setGlobalState 修改状态,文档说明它只能修改已经定义过的第一层属性,offGlobalStateChange 取消监听,子应用卸载时会默认调用。官方建议在主应用里初始化,子应用通过 props 拿到这些方法。

无界的通信文档列了三种。第一种是 props,主应用在组件上传 props,子应用通过 window.$wujie?.props 读到。第二种是 window 直接通信:因为 iframe 和主应用同域,子应用可以直接读 window.parent.xxx,主应用也能拿到子应用 iframe 的 contentWindow。第三种是 EventBus,主应用用 bus.$on、bus.$emit,子应用用 window.$wujie?.bus,这是一套去中心化的方案,子应用之间也能互相通信。文档提醒,子应用里订阅的事件要在 unmount 生命周期里手动 $off。

Module Federation 没有专门的通信机制。远程模块被加载以后就是一个普通的 ES 模块,主应用直接调用它导出的函数,或者像用本地组件一样传 props。共享状态库时,把它声明成 shared 的单例,双方用的就是同一份实例。

子应用要做哪些改造

qiankun 的入门文档要求子应用在入口文件导出 bootstrap、mount、unmount 三个生命周期,并在 webpack 里把输出改成 libraryTarget: 'umd'。FAQ 还说明,qiankun 用 fetch 获取子应用的静态资源,所以这些资源必须支持 CORS。

无界的文档说,满足跨域条件时子应用可以不用改造。它的子应用资源和接口请求都从主域名发起,所以子应用同样要配置 CORS。运行模式有三种:alive 设为 true 是保活模式,切走以后 Web Component 留在内存里,回来时重新挂上,状态不丢;alive 为 false 且做了生命周期改造,也就是在 window.__WUJIE_MOUNT 和 window.__WUJIE_UNMOUNT 上挂载和销毁实例,是单例模式;两者都没有,就是重建模式,每次切换都销毁 iframe 和 Web Component。

Module Federation 要求双方都用支持它的构建工具和插件,在配置里写 exposes、remotes 和 shared。

放在一起比

维度qiankun无界Module Federation
接入单位子应用子应用模块
JS 隔离Proxy 代理 window,读不到时回落到全局同域 iframe 原生 window不隔离
样式隔离默认不开,可选 Shadow DOM 或选择器前缀Web Component 承载 DOM不提供,靠约定
通信props、initGlobalStateprops、window.parent、EventBus直接调用模块、shared 单例
子应用改造导出生命周期、umd 输出、资源开 CORS资源开 CORS,单例模式才需改造生命周期构建配置加插件

常见追问

无界的 iframe 方案和直接用 iframe 嵌页面有什么区别?

直接用 iframe,DOM 被关在 iframe 的矩形区域里,弹窗没法覆盖整个页面,URL 刷新后也会丢。无界只把 JS 放在 iframe 里,DOM 渲染到主文档的 Web Component 中,弹窗能正常显示;它还会劫持 iframe 的 pushState、replaceState,开启 sync 后把子应用的路径同步到主应用 URL 的查询参数上,刷新和分享链接时能还原。

qiankun 的沙箱为什么挡不住所有全局污染?

它代理的是子应用对 window 的读写,读不到的属性会回落到真实的 window,所以子应用能读到主应用的全局变量。官方 FAQ 里 vue-router 读到主应用 window.Vue 的报错就是这个原因,给出的解决办法是子应用把 Vue 打包进自己的代码,或者主应用把全局的 window.Vue 改个名字。

同一个页面要同时显示多个子应用怎么办?

qiankun 的 start() 有 singular 选项,默认 true,同一时间只渲染一个子应用;要同时显示多个,可以把 singular 设为 false,或者用 loadMicroApp 手动加载,它的 singular 默认是 false。无界把子应用当组件用,文档说明一个页面可以同时激活多个子应用。