您好,登录后才能下订单哦!
这篇文章主要介绍“无界微前端是怎么渲染子应用的demo解析”的相关知识,小编通过实际案例向大家展示操作过程,操作方法简单快捷,实用性强,希望这篇“无界微前端是怎么渲染子应用的demo解析”文章能帮助大家解决问题。
无界与其他微前端框架(例如qiankun)的主要区别在于其独特的 JS 沙箱机制。无界使用 iframe 来实现 JS 沙箱,由于这个设计,无界在以下方面表现得更加出色:
应用切换没有清理成本
允许一个页面同时激活多个子应用
性能相对更优
无界渲染子应用,主要分为以下几个步骤:
创建子应用 iframe
解析入口 HTML
创建 webComponent,并挂载 HTML
运行 JS 渲染 UI
要在 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。
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="external nofollow" 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 分离出来,是为了让无界插件能够对 对 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 。
在执行 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
内,样式也不会影响到外部,也不会受外部样式影响。
把 HTML
渲染到 webComponent
之后,我们就可以执行 JS 了
export function insertScriptToIframe( scriptResult: ScriptObject | ScriptObjectLoader, iframeWindow: Window, ) { const { content, // js 的代码字符串 } = scriptResult; const scriptElement = iframeWindow.document.createElement("script"); scriptElement.textContent = content || ""; // 获取 head 标签 const container = rawDocumentQuerySelector.call(iframeWindow.document, "head"); // 在 head 中插入 script 标签,就会运行 js container.appendChild(scriptElement); }
创建 script
标签,并插入到 iframe 的 head 中,就在 iframe 中能运行对应的 JS 代码。
这样虽然能运行 JS,但是产生的副作用(例如渲染的 UI),也会留在 iframe 中。
如何理解这句话?
当我们在 iframe
中,使用 document.querySelector
查找 #app
的 DOM 时,它只能在 iframe
中查找(副作用留在 iframe
中),但 UI 是渲染到 webComponent 中的,webComponent
不在 iframe
中,且 iframe
不可见。
因此在 iframe
中就会找不到 DOM
。
那要怎么办呢?
我们先来看看现代的前端框架,是如何渲染 UI 的
以 Vue 为例,需要给 Vue 指定一个 DOM 作为挂载点,Vue 会将组件,挂载到该 DOM 上
import Comp from './comp.vue' // 传入根组件 const app = createApp(Comp) // 指定挂载点 app.mount('#app')
挂载到 #app
,实际上使用 document.querySelector
查找 DOM,然后挂载到 DOM 里面
但是正如上一小节说的,在无界微前端会有问题:
如果在 iframe
中运行 document.querySelector
,就会在 iframe
中查找就会查找不到,因为子应用的 HTML
是渲染到外部的 shadowRoot
的
因此这里必须要对 iframe
的 document.querySelector
进行改造,改为从 shadowRoot
里面查找,才能使 Vue 组件能够正确找到挂载点,伪代码如下:
const proxyDocument = new Proxy( {}, { get: function (_, propKey) { if (propKey === "querySelector" || propKey === "querySelectorAll") { // 代理 shadowRoot 的 querySelector/querySelectorAll 方法 return new Proxy(shadowRoot[propKey], { apply(target, ctx, args) { // 相当于调用 shadowRoot.querySelector return target.apply(shadowRoot, args); }, }); } }, } );
这样修改之后,调用 proxyDocument.querySelector
就会从 shadowRoot
中查找元素,就能挂载到 shadowRoot
中的 DOM
中了。
Vue 的根组件,就能成功挂载上去,其他子组件,因为是挂载到根节点或它的子节点上,不需要修改挂载位置,就能够正确挂载。
到此为止,如果不考虑其他 js 非视图相关的 js 代码,整个DOM 树就已经挂载成功,UI 就已经能够渲染出来了。
上一小节,通过 proxyDocument.querySelector
,就能从 shadowRoot 查找元素
但这样有一个坏处,就是要将 document
改成 proxyDocument
,代码才能正确运行。但这是有方法解决的。
假如我们要运行的是以下代码:
const app = document.querySelector('#app') // do something
我们可以包一层函数:
(function (document){ const app = document.querySelector('#app') // do something })(proxyDocument)
这样就不需要修改子应用的源码,直接使用 document.querySelector
但是,这样做又会有新的问题:
esModule 的 import 必须要在函数最外层
var 声明的变量,原本是全局变量,包一层函数后,变量会被留在函数内
于是就有了下面的方案:
// 挟持 iframeWindow.Document.prototype 的 querySelector // 从 proxyDocument 中获取 Object.defineProperty(iframeWindow.Document.prototype, 'querySelector', { enumerable: true, configurable: true, get: () => sandbox.proxyDocument['querySelector'], set: undefined, });
只要我们在 iframe 创建时(子应用 JS),先通过 Object.defineProperty
重写 querySelector
,挟持 document 的属性/方法,然后从 proxyDocument
中取值,
这样,就能直接执行子应用的 JS 代码,不需要另外包一层函数执行 JS
在无界微前端中,有非常多像 querySelector
的属性/方法,需要对每个属性方法的副作用进行修正。因此除了 proxyDocument
,还有 proxyWindow
、proxyLocation
很可惜的是,location 对象不能使用 Object.defineProperty
进行挟持,因此实际上,运行非 esModule 代码时,仍然需要用函数包一层运行,传入 proxyLocation 代替 location 对象。
但 esModule 由于不能在函数中运行,因此 esModule 代码中获取的 location 对象是错误的,这个无界的常见问题文档也有提到。
接下来稍微介绍一下无界对 DOM 和 iframe 副作用的一些处理
无界通过创建代理对象、覆盖属性和函数等方式对原有的JavaScript对象进行挟持。需要注意的是,所有这些处理都必须在子应用 JS 运行之前,也就是在 iframe 创建时执行:
const iframe = window.document.createElement("iframe"); // 将 iframe 插入到 document 中 window.document.body.appendChild(iframe); const iframeWindow = iframe.contentWindow; // 停止 iframe 的加载 sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => { // 对副作用进行处理修正 }
在 stopIframeLoading
后,即停止 iframe 加载,获得纯净的 iframe
后,再对副作用进行处理
无界微前端 JS 有非常多的副作用需要修正处理,文章不会一一列举,这里会说一下大概,让大家对这个有点概念。
下面是几个例子
<img src = "./images/test.png" alt = "Test Image" />
当我们在 DOM 中使用相对 url 时,会用 DOM 节点的 baseURI
作为基准,其默认值为 document.location.href
。
但我们知道,子应用的 UI 是挂载在 shadowRoot,跟主应用是同一个 document 上下文,因此它的 baseURI
默认是主应用的 url
,但实际上应该为子应用的 url
才对,因此需要修正。
下面是部分修正的伪代码:
// 重写 Node 原型的 appendChild,在新增 DOM 时修正 iframeWindow.Node.prototype.appendChild = function(node) { const res = rawAppendChild.call(this, node); // 修正 DOM 的 baseURI patchElementEffect(node, iframeWindow); return res; };
事实上,除了 appendChild
,还有其他的函数需要修正,在每个能够创建 DOM 的位置,都需要进行修正,例如 insertBefore
shadowRoot 可以视为子应用的 document
在前端项目中,经常会在 JS 中引入 CSS,实际上 CSS 文本会以 style 标签的形式注入到 docuement.head
中,伪代码如下:
export default function styleInject(css) { const head = document.head const style = document.createElement('style') style.type = 'text/css' style.styleSheet.cssText = css head.appendChild(style) }
在 iframe 中使用 document.head
,需要用 Object.defineProperty
挟持 document 的 head 属性,将其重定向到 shadowRoot 的 head
标签
Object.defineProperty(iframeWindow.document, 'head', { enumerable: true, configurable: true, // 改为从 proxyDocument 中取值 get: () => sandbox.proxyDocument['head'], set: undefined, });
proxyDocument
的 head 实际上为 shadowRoot
的 head
shadowRoot.head = shadowRoot.querySelector("head"); shadowRoot.body = shadowRoot.querySelector("body");
同样的,很多组件库的弹窗,都会往 document.body
插入弹窗的 DOM,因此也要处理
history
API 在 SPA 应用中非常常见,例如 vue-router 就会使用到 history.pushState
、 history.replaceState
等 API。
当前 url 改变时
需要改变 document.baseURI
,而它是个只读的值,需要修改 document.head
中的 base
标签
需要将子应用的 url,同步到父应用的地址栏中
history.pushState = function (data: any, title: string, url?: string): void { // 当前的 url const baseUrl = mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash; // 根据当前 url,计算出即将跳转的 url 的绝对路径 const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl); // 调用原生的 history.pushState rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl); // 更新 head 中的 base 标签 updateBase(iframeWindow, appHostPath, mainHostPath); // 同步 url 到主应用地址栏 syncUrlToWindow(iframeWindow); };
有些属性,应该是使用主应用 window 的属性,例如:getComputedStyle
有些事件,需要挂载到主应用,有些需要挂载到 iframe 中。这里直接举个例子:
onunload 事件,需要挂载到 iframe 中
onkeyup 事件,需要挂载到主应用的 window 下(iframe 中没有 UI,UI 挂载到主应用 document 的 shadowRoot 下)
因此要挟持 onXXX
事件和 addEventListener
,对每一个事件进行分发,将事件挂载到 window
/ iframeWindow
中
将事件挂载到window
的代码实现如下:
// 挟持 onXXX 函数 Object.defineProperty(iframeWindow, 'onXXX', { enumerable: true, configurable: true, // 从 window 取 get: () => window['onXXX'], set: (handler) => { // 设置到 window window['onXXX'] = typeof handler === "function" ? handler.bind(iframeWindow) // 将函数的 this 设置为 iframeWindow : handler; } });
通过 Object.defineProperty
挟持 onXXX
,将事件设置到 window
上。
当我们在子应用 iframe 中获取 location.href
, location.host
等属性的时候,需要获取的是子应用的 href
和 host
(iframe 的 location href 并不是子应用的 url),因此这里也是需要进行改造。
const proxyLocation = new Proxy( {}, { get: function (_, propKey) { if (propKey === "href") { return // 获取子应用真正的 url } // 省略其他属性的挟持 }, } );
为什么 iframe 的 location href 不是子应用的 url?
为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域)
关于“无界微前端是怎么渲染子应用的demo解析”的内容就介绍到这里了,感谢大家的阅读。如果想了解更多行业相关的知识,可以关注亿速云行业资讯频道,小编每天都会为大家更新不同的知识点。
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。