经过我们团队的调研,我们选择了无界作为微前端的技术栈。目前的使用效果非常好,不仅性能表现出色,而且使用体验也不错。
尽管在使用的过程中,我们也遇到了一些问题,但这些问题往往源于我们对框架实现的不熟悉。我们深入研究了无界技术的源码,并将在本文中与大家分享。本文将重点探讨无界微前端如何渲染子应用的。
无界渲染子应用的步骤
无界与其他微前端框架(例如qiankun)的主要区别在于其独特的 JS 沙箱机制。无界使用 iframe 来实现 JS 沙箱,由于这个设计,无界在以下方面表现得更加出色:
- 应用切换没有清理成本
- 允许一个页面同时激活多个子应用
- 性能相对更优
无界渲染子应用,主要分为以下几个步骤:
- 创建子应用 iframe
- 解析入口 HTML
- 创建 webComponent,并挂载 HTML
- 运行 JS 渲染 UI
创建子应用 iframe
要在 iframe 中运行 JS,首先得有一个 iframe。
export function iframeGenerator( sandbox: WuJie, attrs: { [key: string]: any }, mainHostPath: string, appHostPath: string, appRoutePath: string ): HTMLIFrameElement { // 创建 iframe 的 DOM const iframe = window.document.createElement("iframe"); // 设置 iframe 的 attr setAttrsToElement(iframe, { // iframe 的 url 设置为主应用的域名 src: mainHostPath, style: "display: none", ...attrs, name: sandbox.id, [WUJIE_DATA_FLAG]: "" }); // 将 iframe 插入到 document 中 window.document.body.appendChild(iframe); const iframeWindow = iframe.contentWindow; // 停止 iframe 的加载 sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => { // 省略其他内容 } // 注入无界的变量到 iframeWindow,例如 __WUJIE patchIframeVariable(iframeWindow, sandbox, appHostPath); // 省略其他内容 return iframe; }
创建 iframe 主要有以下流程:
- 创建 iframe 的 DOM,并设置属性
- 将 iframe 插入到 document 中(此时 iframe 会立即访问 src)
- 停止 iframe 的加载(stopIframeLoading)
为什么要停止 iframe 的加载?
因为要创建一个纯净的 iframe,防止 iframe 被污染,假如该 url 的 JS 代码,声明了一些全局变量、函数,就可能影响到子应用的运行(假如子应用也有同名的变量、函数)
为什么 iframe 的 src 要设置为主应用的域名
为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域)
- 主应用域名为
a.com
- 子应用域名为
b.com
,但它对应的 iframe 域名为a.com
,所以要设置b.com
的资源能够允许跨域访问
因此 iframe 的 location.href
并不是子应用的 url。
解析入口 HTML
iframe 中运行 js,首先要知道要运行哪些 js
我们可以通过解析入口 HTML 来确定需要运行的 JS 内容
假设有以下HTML
<!DOCTYPE html> <html lang="en"> <head> <script defer="defer" src="./static/js/main.4000cadb.js"></script> <link href="./static/css/main.7d8ad73e.css" rel="stylesheet"> </head> <body> <div id="root"></div> </body> </html>
经过 importHTML
处理后,结果如下:
- template 模板部分,去掉了所有的 script 和 style
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> </head> </head> <body> <div id="root"></div> </body> </html>
- getExternalScripts,获取所有内联和外部的 script
[ { async: false, defer: true, src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js', module: false, crossorigin: false, crossoriginType: '', ignore: false, contentPromise: // 获取 script 内容字符串的 Promise } ]
- getExternalStyleSheets,获取所有内联和外部的 style
[ { src: "https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css", ignore: false, contentPromise: // 获取 style 内容字符串的 Promise } ]
为什么要将 script 和 style 从 HTML 中分离?
- HTML 要作为 webComponent 的内容,挂载到微前端挂载点上
- 因为无界有插件机制,需要单独对 js/style 进行处理,再插入到 webComponent 中
- script 除了需要经过插件处理外,还需要放到 iframe 沙箱中执行,因此也要单独分离出来
external 是外部的意思,为什么 getExternalScripts 拿到的却是所有的 script,而不是外部的非内联 script?
external 是相对于解析后的 HTML 模板来说的,由于解析后的 HTML 不带有任何的 js 和 css,所以这里的 external,就是指模板外的所有 JS
无界与 qiankun 的在解析 HTML 上区别?
无界和 qiankun 都是以 HTML 为入口的微前端框架。qiankun 基于 import-html-entry
解析 HTML,而无界则是借鉴 import-html-entry
代码,实现了自己的 HTML 的解析,因此两者在解析 HTML 上的不同,主要是在importHTML
的实现上。
由于无界支持执行 esModule script,需要在分析的结果中,保留更多的信息
[ { async: false, defer: true, src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js', module: false, crossorigin: false, crossoriginType: '', ignore: false, contentPromise: // 获取 script 内容字符串的 Promise } ]
而 import-html-entry
的分析结果中,只有 script 的 js 内容字符串。
无界是如何获取 HTML 的外部的 script、style 内容的?
分析 HTML,可以拿到外部 script
、style
的 url,用 fetch
发起 ajax 就可以获取到 script
、style
的内容。
但是 fetch
相对于原来 HTML script
标签,有一个坏处,就是 ajax 不能跨域,因此在使用无界的时候必须要给请求的资源设置允许跨域
处理 CSS 并重新嵌入 HTML
单独将 CSS 分离出来,是为了让无界插件能够对 对 CSS 代码进行修改,下面是一个 CSS loader 插件:
const plugins = [ { // 对 css 脚本动态的进行替换 // code 为样式代码、url为样式的地址(内联样式为'')、base为子应用当前的地址 cssLoader: (code, url, base) => { console.log("css-loader", url, code.slice(0, 50) + "..."); // do something return code; }, }, ];
无界会用以下代码遍历插件修改 CSS
// 将所有 plugin 的 CSSLoader 函数,合成一个 css-loader 处理函数 const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader)); const processedCssList: StyleResultList = getExternalStyleSheets().map(({ src, contentPromise }) => { return { src, // 传入 CSS 文本处理处理函数 contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)), }; });
修改后的 CSS,会存储在 processedCssList
数组中,需要遍历该数组的内容,将 CSS 重新嵌入到 HTML 中。
举个例子,这是我们之前的 HTML
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> </head> </head> <body> <div id="root"></div> </body> </html>
嵌入 CSS 之后的 HTML 是这样子的
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> - <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> + <style> + /* https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css */. + 省略内容 + <style/> </head> </head> <body> <div id="root"></div> </body> </html>
将原来的 Link 标签替换成 style 标签,并写入 CSS 。
创建 webComponent 并挂载 HTML
在执行 JS 前,需要先把 HTML
的内容渲染出来。
无界子应用是挂载在 webComponent
中的,其定义如下:
class WujieApp extends HTMLElement { // 首次被插入文档 DOM 时调用 connectedCallback(): void { if (this.shadowRoot) return; // 创建 shadowDOM const shadowRoot = this.attachShadow({ mode: "open" }); // 通过 webComponent 的标签 WUJIE_DATA_ID,拿到子应用 id,再通过 id 拿到无界实例对象 const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID)); // 保存 shadowDOM sandbox.shadowRoot = shadowRoot; } // 从文档 DOM 中删除时,被调用 disconnectedCallback(): void { const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID)); sandbox?.unmount(); } }
customElements?.define("wujie-app", WujieApp);
于是就可以这样创建 webComponent
export function createWujieWebComponent(id: string): HTMLElement { const contentElement = window.document.createElement("wujie-app"); // 设置 WUJIE_DATA_ID 标签,为子应用的 id‘ contentElement.setAttribute(WUJIE_DATA_ID, id); return contentElement; }
然后为 HTML
创建 DOM
,这个非常简单
let html = document.createElement("html"); html.innerHTML = template; // template 为解析处理后的 HTML
直接用 innerHTML
设置 html
的内容即可
然后再插入 CSS
(上一小节的内容)
// processCssLoaderForTemplate 返回注入 CSS 的 html DOM 对象 const processedHtml = await processCssLoaderForTemplate(iframeWindow.__WUJIE, html)
最后挂载到 shadowDOM
中
shadowRoot.appendChild(processedHtml);
这样就完成了 HTML
和 CSS 的挂载了,CSS 由于在 shadowDOM
内,样式也不会影响到外部,也不会受外部样式影响。