https://zhuanlan.zhihu.com/p/657544258
背景
什么是微前端
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略
玉伯:今天看各 BU 的业务问题,微前端的前提,还是得有主体应用,然后才有微组件或微应用,解决的是可控体系下的前端协同开发问题(含空间分离带来的协作和时间延续带来的升级维护)
应用场景
大多是 toB 应用或者中后台系统
- 应用聚合:聚合多个应用,提供统一入口。在保障用户体验的同时,赋能业务。
- 新老系统并存上线:在老系统中新增迭代时,以微前端的形式单独创建一个子应用,无关技术栈。
- 多团队协同问题:摆脱技术栈的约束及部署冲突,高效协作
核心:无关技术栈、独立运行/开发/部署、渐近升级
通过引入微应用,除了解决了工程上的问题,同时让产品拥有可自由重组的能力,让产品价值最大化
为什么还要造新的微前端框架?
目前已经有不少较成熟的微前端方案,像 qiankun、micro-app 等方案,为什么还要造新的微前端框架?
qiankun 不足:
- 适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;
- css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;
- 无法同时激活多个子应用,也不支持子应用保活;
- 无法支持 vite 等 esm 脚本运行;
micro app 不足:
- css 沙箱依然无法绝对的隔离
- 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
- 对于不支持 webcompnent 的浏览器没有做降级处理;
结论:
qiankun 方案对 single-spa 微前端方案做了较大的提升同时也遗留下来了不少问题长时间没有解决;
micro app 方案对 qiankun 方案做了较多提升但基于 qiankun 的沙箱也相应会继承其存在的问题;
目前的微前端方案在用户的核心诉求上都没有很好的满足,有很大的优化提升空间。
无界方案
无界利用 iframe 和 webcomponent 来搭建天然的 js 沙箱和 css 沙箱
iframe 特点
优点
- 非常简单,使用没有任何心智负担
- web应用隔离的非常完美,无论是 js、css、dom 都完全隔离开来
缺点
- 路由状态丢失,刷新一下,iframe 的 url 状态就丢失了
- dom 割裂严重,弹窗只能在 iframe 内部展示,无法覆盖全局
- web 应用之间通信非常困难
- 每次打开白屏时间太长,对于 SPA 应用来说无法接受
接下来看一下无界微前端框架是如何通过继承 iframe 的优点,一步一步解决 iframe 缺点的。
设计原理
渲染子应用步骤:
- 创建和主应用同源的 iframe,路径携带了子路由的路由信息
同源是为了方便应用间的通信,子应用需要能支持跨域;iframe 实例化完成后需立即中断加载 html,防止进入主应用的路由逻辑污染子应用 - 解析子应用的入口 html
识别出 html 部分,分离 style 和 js;处理 css 重新注入html ;创建 webComponent 并挂载 html - 创建 script 标签,并插入到 iframe 的 head 中
- 在 iframe 中拦截 document 对象,统一将 dom 指向 shadowRoot
这样弹窗或者冒泡组件就可以正常覆盖主应用
接下来的三步分别解决iframe的三个缺点:
✅ 通信非常困难的问题:iframe 和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
✅ dom 割裂严重的问题:主应用提供一个容器给到 shadowRoot 插拔,shadowRoot 内部的弹窗也就可以覆盖主应用
✅ 路由状态丢失的问题:浏览器的前进后退可以天然的作用到 iframe 上,此时监听 iframe 的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由
将这套机制封装进无界框架:
我们可以发现:
✅ 首次白屏的问题:wujie 实例可以提前实例化,包括 shadowRoot、iframe 的创建、js 的执行,以此来加快子应用首次打开的时间
✅ 切换白屏的问题:一旦 wujie 实例可以缓存下来,子应用的切换成本变的极低,如果采用保活模式,那么相当于 shadowRoot 的插拔
会带来一定对内存开销:未激活子应用的 shadowRoot 和 iframe 内存常驻,保活模式每个页面都需要独占一个 wujie 实例
应用加载机制和 js 沙箱机制
将子应用的 js 注入主应用同域的 iframe 中运行,iframe 是一个原生的 window 沙箱,内部有完整的 history 和 location 接口,子应用实例 instance 运行在 iframe 中,路由也彻底和主应用解耦。
/*** iframe插入脚本* @param scriptResult script请求结果* @param iframeWindow* @param rawElement 原始的脚本*/
export function insertScriptToIframe(scriptResult: ScriptObject | ScriptObjectLoader,iframeWindow: Window,rawElement ? : HTMLScriptElement) {const { src, module, content, crossorigin, crossoriginType, async, attrs, callback, onload } =scriptResult as ScriptObjectLoader;// ...if (!iframeWindow.__WUJIE.degrade && !module) {code = `(function(window, self, global, location) {${code}}).bind(window.__WUJIE.proxy)(window.__WUJIE.proxy,window.__WUJIE.proxy,window.__WUJIE.proxy,window.__WUJIE.proxyLocation,);`;}// ...
}
iframe 连接机制和 css 沙箱机制
css 沙箱机制: 采用 webcomponent 创建一个 wujie 自定义元素来实现页面的样式隔离,将子应用的完整结构渲染在内部
iframe 连接机制: 子应用的实例 instance 在 iframe 内运行,dom 在主应用容器下的 webcomponent 内,通过代理 iframe 的 document 到 webcomponent,可以实现两者的互联;将 document 的查询类接口全部代理到 webcomponent,这样 instance 和 webcomponent 就精准的链接起来
shadowRoot的插拔: 当子应用发生切换,iframe保留下来,子应用的容器可能销毁,但 webcomponent依然可以选择保留,这样等应用切换回来将 webcomponent 再挂载回容器上,子应用可以获得类似 vue 的 keep-alive 的能力
路由同步机制
在 iframe 内部进行 history.pushState,浏览器会自动的在 joint session history 中添加 iframe 的 session-history,浏览器的前进、后退在不做任何处理的情况就可以直接作用于子应用(所有 iframe 页面会共享 top 页面的 history)
劫持 iframe 的 history.pushState 和 history.replaceState,就可以将子应用的 url 同步到主应用的 query 参数上,当刷新浏览器初始化 iframe 时,读回子应用的 url 并使用 iframe 的 history.replaceState 进行同步
/*** 对iframe的history的pushState和replaceState进行修改* 将从location劫持后的数据修改回来,防止跨域错误* 同步路由到主应用* @param iframeWindow* @param appHostPath 子应用的 host path* @param mainHostPath 主应用的 host path*/
function patchIframeHistory(iframeWindow: Window, appHostPath: string, mainHostPath: string): void {const history = iframeWindow.history;const rawHistoryPushState = history.pushState;const rawHistoryReplaceState = history.replaceState;history.pushState = function(data: any, title: string, url ? : string): void {const baseUrl =mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);const ignoreFlag = url === undefined;rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl);if (ignoreFlag) return;updateBase(iframeWindow, appHostPath, mainHostPath);syncUrlToWindow(iframeWindow);};history.replaceState = function(data: any, title: string, url ? : string): void {const baseUrl =mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);const ignoreFlag = url === undefined;rawHistoryReplaceState.call(history, data, title, ignoreFlag ? undefined : mainUrl);if (ignoreFlag) return;updateBase(iframeWindow, appHostPath, mainHostPath);syncUrlToWindow(iframeWindow);};
}
我们还可以在页面上同时激活多个子应用,由于 iframe 和主应用处于同一个top-level browsing context,因此浏览器前进、后退都可以作用到到子应用:
通信机制
props 注入机制
子应用通过 $wujie.props 可以轻松拿到主应用注入的数据
window.parent 通信机制
子应用 iframe 沙箱和主应用同源,子应用可以直接通过 window.parent 和主应用通信
去中心化的通信机制
无界提供了 EventBus 实例,注入到主应用和子应用,所有的应用可以去中心化的进行通信
插件系统
无界提供强大的插件系统,方便用户在运行时去修改子应用代码从而避免将适配代码硬编码到仓库中。
- html-loader 可以对子应用 template 进行处理
- js-excludes 和 css-excludes 可以排除子应用特定的 js 和 css 加载
- js-before-loaders、js-loader、js-after-loaders 可以方便的对子应用 js 进行自定义
- css-before-loaders、css-loader、css-after-loaders 可以方便的对子应用 css 进行自定义
生命周期
无界提供完善的生命周期钩子供主应用调用:
beforeLoad:子应用开始加载静态资源前触发
beforeMount:子应用渲染前触发 (生命周期改造专用)
afterMount:子应用渲染后触发(生命周期改造专用)
beforeUnmount:子应用卸载前触发(生命周期改造专用)
afterUnmount:子应用卸载后触发(生命周期改造专用)
activated:子应用进入后触发(保活模式专用)
deactivated:子应用离开后触发(保活模式专用)
vite 框架支持
无界子应用运行在 iframe 中原生支持 esm 的脚本,而且不用担心子应用运行的上下文问题
兼容 IE9
由于无界采用了 webcomponent + shadowdom + proxy 的方案,在某些低版本浏览器上无法运行时,无界微前端会自动降级。
降级方案采用:
- webcomponent + shadowdom ⇒ iframe(dom-iframe)
- proxy + Object.defineproperty ⇒ Object.defineproperty
- 子应用运行的方式是 dom-iframe + js-iframe + Object.defineproperty,IE9+ 都可以兼容
自动降级后无界依然可以保证子应用的 css 和 js 原生隔离,但是由于 dom-iframe 的限制,弹窗将只能在子应用内部打开
由于无法使用proxy,无法劫持子应用的location,导致访问 window.location.host 的时候拿到的是主应用的 host,子应用可以从 $wujie.location
中拿到子应用正确的 host
设计上的不足
- 引入了一个空白的 iframe 带来额外内存开销:初始化子应用的时候需要准备一个空白的 iframe 沙箱,然后将子应用的js注入进去执行
- 还有一个不是很完美的点,防止进入主应用的路由逻辑污染子应用停止时机:iframe 沙箱的 src 设置了主应用的 host,初始化 iframe 的时候需要等待 iframe 的 location.orign 从 'about:blank' 初始化为主应用的 host(这样子应用路由就可以正常的调用 window.history.push 等 api),这个等待是采用setTimeout 轮询来查看 location.orign 是否已经 ready,在等待过程中iframe有可能已经开始加载主应用的 js 文件从而导致污染,这个问题框架做了一定处理但是目前还没有特别好的办法
/*** 防止运行主应用的js代码,给子应用带来很多副作用*/
// TODO: 更加准确抓取停止时机
function stopIframeLoading(iframeWindow: Window) {const oldDoc = iframeWindow.document;return new Promise<void>((resolve) => {function loop() {setTimeout(() => {let newDoc = null;try {newDoc = iframeWindow.document;} catch (err) {newDoc = null;}// wait for document readyif (!newDoc || newDoc == oldDoc) {loop();} else {iframeWindow.stop ? iframeWindow.stop() : iframeWindow.document.execCommand("Stop");resolve();}}, 1);}loop();});
}
技术选型建议
对比 | qiankun | micro-app | wujie |
---|---|---|---|
首个版本 | 4年 (2019-08-01) | 2年 (2021-07-09) | 1年 (2022-07-05) |
最近更新 | v2.10.8 (2023-05-17) | v1.0.0-beta.4 (2023-04-27) | 1.0.16 (2023-05-17) |
包体积 | 94kb | 30kb | 11kb |
接入成本 | 中 | 低 | 较低 |
ie | √ | × | √ |
数据通信机制 | props | addDataListener | props、window、eventBus |
js沙箱 | √ | √ | √ |
样式隔离 | √ | √ | √ |
元素隔离 | × | √ | √ |
静态资源地址补全 | × | √ | √ |
预加载 | √ | √ | √ |
keep-alive | × | √ | √ |
应用共享同一个资源 | √ | √ | √ |
应用嵌套 | √ | √ | √ |
插件系统 | × | √ | √ |
子应用不改造接入 | × | √ | √ |
- 考虑系统需要兼容 ie 浏览器场景
wujie > qiankun - 接入便捷度考虑
wujie > micro-app > qiankun - 框架稳定性 (框架成熟度)
qiankun > micro-app > wujie
展望
更低的接入成本: 目前的微前端框架使用中对于子应用的接入还是存在一些改造成本,相对于注册机制来说组件式的引用方式更灵活改造成本也更低
标准化的沙箱能力: 可以看到现阶段大部分的微前端框架都有自己的沙箱处理机制且各不相同,在这类问题上应该可以期待 W3C 提供一些标准化的沙箱能力,来处理这些同质化问题
更加灵活: 目前部分微前端方案中还是存在一些技术约束,如 EMP 依赖 webpack、乾坤也依赖 umd 的打包方式、对 vite 的支持。后续应该会更灵活更少约束