无界微前端是怎么渲染子应用的demo解析

其他教程   发布日期:2025年03月08日   浏览次数:140

这篇文章主要介绍“无界微前端是怎么渲染子应用的demo解析”的相关知识,小编通过实际案例向大家展示操作过程,操作方法简单快捷,实用性强,希望这篇“无界微前端是怎么渲染子应用的demo解析”文章能帮助大家解决问题。

    无界渲染子应用的步骤

    无界与其他微前端框架(例如qiankun)的主要区别在于其独特的 JS 沙箱机制。无界使用 iframe 来实现 JS 沙箱,由于这个设计,无界在以下方面表现得更加出色:

    • 应用切换没有清理成本

    • 允许一个页面同时激活多个子应用

    • 性能相对更优

    无界渲染子应用,主要分为以下几个步骤:

    • 创建子应用 iframe

    • 解析入口 HTML

    • 创建 webComponent,并挂载 HTML

    • 运行 JS 渲染 UI

    创建子应用 iframe

    要在 iframe 中运行 JS,首先得有一个 iframe。

    1. export function iframeGenerator(
    2. sandbox: WuJie,
    3. attrs: { [key: string]: any },
    4. mainHostPath: string,
    5. appHostPath: string,
    6. appRoutePath: string
    7. ): HTMLIFrameElement {
    8. // 创建 iframe 的 DOM
    9. const iframe = window.document.createElement("iframe");
    10. // 设置 iframe 的 attr
    11. setAttrsToElement(iframe, {
    12. // iframe 的 url 设置为主应用的域名
    13. src: mainHostPath,
    14. style: "display: none",
    15. ...attrs,
    16. name: sandbox.id,
    17. [WUJIE_DATA_FLAG]: ""
    18. });
    19. // 将 iframe 插入到 document 中
    20. window.document.body.appendChild(iframe);
    21. const iframeWindow = iframe.contentWindow;
    22. // 停止 iframe 的加载
    23. sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => {
    24. // 省略其他内容
    25. }
    26. // 注入无界的变量到 iframeWindow,例如 __WUJIE
    27. patchIframeVariable(iframeWindow, sandbox, appHostPath);
    28. // 省略其他内容
    29. return iframe;
    30. }

    创建 iframe 主要有以下流程:

    • 创建 iframe 的 DOM,并设置属性

    • 将 iframe 插入到 document 中(此时 iframe 会立即访问 src)

    • 停止 iframe 的加载(stopIframeLoading)

    为什么要停止 iframe 的加载?

    因为要创建一个纯净的 iframe,防止 iframe 被污染,假如该 url 的 JS 代码,声明了一些全局变量、函数,就可能影响到子应用的运行(假如子应用也有同名的变量、函数)

    为什么 iframe 的 src 要设置为主应用的域名

    为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域)

    • 主应用域名为

      1. a.com
    • 子应用域名为

      1. b.com
      ,但它对应的 iframe 域名为
      1. a.com
      ,所以要设置
      1. b.com
      的资源能够允许跨域访问

    因此 iframe 的

    1. location.href
    并不是子应用的 url。

    解析入口 HTML

    iframe 中运行 js,首先要知道要运行哪些 js

    我们可以通过解析入口 HTML 来确定需要运行的 JS 内容

    假设有以下HTML

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <script defer="defer" src="./static/js/main.4000cadb.js"></script>
    5. <link href="https://www.19jp.com">

    经过

    1. importHTML
    处理后,结果如下:
    • template 模板部分,去掉了所有的 script 和 style

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie -->
    5. <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie -->
    6. </head>
    7. </head>
    8. <body>
    9. <div id="root"></div>
    10. </body>
    11. </html>
    • getExternalScripts,获取所有内联和外部的 script

    1. [
    2. {
    3. async: false,
    4. defer: true,
    5. src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js',
    6. module: false,
    7. crossorigin: false,
    8. crossoriginType: '',
    9. ignore: false,
    10. contentPromise: // 获取 script 内容字符串的 Promise
    11. }
    12. ]
    • getExternalStyleSheets,获取所有内联和外部的 style

    1. [
    2. {
    3. src: "https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css",
    4. ignore: false,
    5. contentPromise: // 获取 style 内容字符串的 Promise
    6. }
    7. ]

    为什么要将 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 基于

    1. import-html-entry
    解析 HTML,而无界则是借鉴
    1. import-html-entry
    代码,实现了自己的 HTML 的解析,因此两者在解析 HTML 上的不同,主要是在
    1. importHTML
    的实现上。

    由于无界支持执行 esModule script,需要在分析的结果中,保留更多的信息

    1. [
    2. {
    3. async: false,
    4. defer: true,
    5. src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js',
    6. module: false,
    7. crossorigin: false,
    8. crossoriginType: '',
    9. ignore: false,
    10. contentPromise: // 获取 script 内容字符串的 Promise
    11. }
    12. ]

    1. import-html-entry
    的分析结果中,只有 script 的 js 内容字符串。

    无界是如何获取 HTML 的外部的 script、style 内容的?

    分析 HTML,可以拿到外部

    1. script
    1. style
    的 url,用
    1. fetch
    发起 ajax 就可以获取到
    1. script
    1. style
    的内容。

    但是

    1. fetch
    相对于原来 HTML
    1. script
    标签,有一个坏处,就是 ajax 不能跨域,因此在使用无界的时候必须要给请求的资源设置允许跨域

    处理 CSS 并重新嵌入 HTML

    单独将 CSS 分离出来,是为了让无界插件能够对 对 CSS 代码进行修改,下面是一个 CSS loader 插件:

    1. const plugins = [
    2. {
    3. // 对 css 脚本动态的进行替换
    4. // code 为样式代码、url为样式的地址(内联样式为'')、base为子应用当前的地址
    5. cssLoader: (code, url, base) => {
    6. console.log("css-loader", url, code.slice(0, 50) + "...");
    7. // do something
    8. return code;
    9. },
    10. },
    11. ];

    无界会用以下代码遍历插件修改 CSS

    1. // 将所有 plugin 的 CSSLoader 函数,合成一个 css-loader 处理函数
    2. const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader));
    3. const processedCssList: StyleResultList = getExternalStyleSheets().map(({
    4. src,
    5. contentPromise
    6. }) => {
    7. return {
    8. src,
    9. // 传入 CSS 文本处理处理函数
    10. contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)),
    11. };
    12. });

    修改后的 CSS,会存储在

    1. processedCssList
    数组中,需要遍历该数组的内容,将 CSS 重新嵌入到 HTML 中。

    举个例子,这是我们之前的 HTML

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie -->
    5. <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie -->
    6. </head>
    7. </head>
    8. <body>
    9. <div id="root"></div>
    10. </body>
    11. </html>

    嵌入 CSS 之后的 HTML 是这样子的

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie -->
    5. - <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie -->
    6. + <style>
    7. + /* https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css */.
    8. + 省略内容
    9. + <style/>
    10. </head>
    11. </head>
    12. <body>
    13. <div id="root"></div>
    14. </body>
    15. </html>

    将原来的 Link 标签替换成 style 标签,并写入 CSS 。

    创建 webComponent 并挂载 HTML

    在执行 JS 前,需要先把

    1. HTML
    的内容渲染出来。

    无界子应用是挂载在

    1. webComponent
    中的,其定义如下:
    1. class WujieApp extends HTMLElement {
    2. // 首次被插入文档 DOM 时调用
    3. connectedCallback(): void {
    4. if (this.shadowRoot) return;
    5. // 创建 shadowDOM
    6. const shadowRoot = this.attachShadow({ mode: "open" });
    7. // 通过 webComponent 的标签 WUJIE_DATA_ID,拿到子应用 id,再通过 id 拿到无界实例对象
    8. const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID));
    9. // 保存 shadowDOM
    10. sandbox.shadowRoot = shadowRoot;
    11. }
    12. // 从文档 DOM 中删除时,被调用
    13. disconnectedCallback(): void {
    14. const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID));
    15. sandbox?.unmount();
    16. }
    17. }
    18. customElements?.define("wujie-app", WujieApp);

    于是就可以这样创建

    1. webComponent
    1. export function createWujieWebComponent(id: string): HTMLElement {
    2. const contentElement = window.document.createElement("wujie-app");
    3. // 设置 WUJIE_DATA_ID 标签,为子应用的 id‘
    4. contentElement.setAttribute(WUJIE_DATA_ID, id);
    5. return contentElement;
    6. }

    然后为

    1. HTML
    创建
    1. DOM
    ,这个非常简单
    1. let html = document.createElement("html");
    2. html.innerHTML = template; // template 为解析处理后的 HTML

    直接用

    1. innerHTML
    设置
    1. html
    的内容即可

    然后再插入

    1. CSS
    (上一小节的内容)
    1. // processCssLoaderForTemplate 返回注入 CSS 的 html DOM 对象
    2. const processedHtml = await processCssLoaderForTemplate(iframeWindow.__WUJIE, html)

    最后挂载到

    1. shadowDOM
    1. shadowRoot.appendChild(processedHtml);

    这样就完成了

    1. HTML
    和 CSS 的挂载了,CSS 由于在
    1. shadowDOM
    内,样式也不会影响到外部,也不会受外部样式影响。

    JS 的执行细节

    1. HTML
    渲染到
    1. webComponent
    之后,我们就可以执行 JS 了

    简单的实现

    1. export function insertScriptToIframe(
    2. scriptResult: ScriptObject | ScriptObjectLoader,
    3. iframeWindow: Window,
    4. ) {
    5. const {
    6. content, // js 的代码字符串
    7. } = scriptResult;
    8. const scriptElement = iframeWindow.document.createElement("script");
    9. scriptElement.textContent = content || "";
    10. // 获取 head 标签
    11. const container = rawDocumentQuerySelector.call(iframeWindow.document, "head");
    12. // 在 head 中插入 script 标签,就会运行 js
    13. container.appendChild(scriptElement);
    14. }

    创建

    1. script
    标签,并插入到 iframe 的 head 中,就在 iframe 中能运行对应的 JS 代码。

    这样虽然能运行 JS,但是产生的副作用(例如渲染的 UI),也会留在 iframe 中。

    如何理解这句话?

    当我们在

    1. iframe
    中,使用
    1. document.querySelector
    查找
    1. #app
    的 DOM 时,它只能在
    1. iframe
    中查找(副作用留在
    1. iframe
    中),但 UI 是渲染到 webComponent 中的,
    1. webComponent
    不在
    1. iframe
    中,且
    1. iframe
    不可见。

    因此在

    1. iframe
    中就会找不到
    1. DOM

    那要怎么办呢?

    将 UI 渲染到 shadowRoot

    我们先来看看现代的前端框架,是如何渲染 UI 的

    以 Vue 为例,需要给 Vue 指定一个 DOM 作为挂载点,Vue 会将组件,挂载到该 DOM 上

    1. import Comp from './comp.vue'
    2. // 传入根组件
    3. const app = createApp(Comp)
    4. // 指定挂载点
    5. app.mount('#app')

    挂载到

    1. #app
    ,实际上使用
    1. document.querySelector
    查找 DOM,然后挂载到 DOM 里面

    但是正如上一小节说的,在无界微前端会有问题:

    • 如果在

      1. iframe
      中运行
      1. document.querySelector
      ,就会在
      1. iframe
      中查找就会查找不到,因为子应用的
      1. HTML
      是渲染到外部的
      1. shadowRoot

    因此这里必须要对

    1. iframe
    1. document.querySelector
    进行改造,改为从
    1. shadowRoot
    里面查找,才能使 Vue 组件能够正确找到挂载点,伪代码如下:
    1. const proxyDocument = new Proxy(
    2. {},
    3. {
    4. get: function (_, propKey) {
    5. if (propKey === "querySelector" || propKey === "querySelectorAll") {
    6. // 代理 shadowRoot 的 querySelector/querySelectorAll 方法
    7. return new Proxy(shadowRoot[propKey], {
    8. apply(target, ctx, args) {
    9. // 相当于调用 shadowRoot.querySelector
    10. return target.apply(shadowRoot, args);
    11. },
    12. });
    13. }
    14. },
    15. }
    16. );

    这样修改之后,调用

    1. proxyDocument.querySelector
    就会从
    1. shadowRoot
    中查找元素,就能挂载到
    1. shadowRoot
    中的
    1. DOM
    中了。

    Vue 的根组件,就能成功挂载上去,其他子组件,因为是挂载到根节点或它的子节点上,不需要修改挂载位置,就能够正确挂载。

    到此为止,如果不考虑其他 js 非视图相关的 js 代码,整个DOM 树就已经挂载成功,UI 就已经能够渲染出来了。

    挟持 document 的属性/方法

    上一小节,通过

    1. proxyDocument.querySelector
    ,就能从 shadowRoot 查找元素

    但这样有一个坏处,就是要将

    1. document
    改成
    1. proxyDocument
    ,代码才能正确运行。但这是有方法解决的。

    假如我们要运行的是以下代码:

    1. const app = document.querySelector('#app')
    2. // do something

    我们可以包一层函数:

    1. (function (document){
    2. const app = document.querySelector('#app')
    3. // do something
    4. })(proxyDocument)

    这样就不需要修改子应用的源码,直接使用

    1. document.querySelector

    但是,这样做又会有新的问题:

    • esModule 的 import 必须要在函数最外层

    • var 声明的变量,原本是全局变量,包一层函数后,变量会被留在函数内

    于是就有了下面的方案:

    1. // 挟持 iframeWindow.Document.prototype 的 querySelector
    2. // 从 proxyDocument 中获取
    3. Object.defineProperty(iframeWindow.Document.prototype, 'querySelector', {
    4. enumerable: true,
    5. configurable: true,
    6. get: () =&gt; sandbox.proxyDocument['querySelector'],
    7. set: undefined,
    8. });

    只要我们在 iframe 创建时(子应用 JS),先通过

    1. Object.defineProperty
    重写
    1. querySelector
    ,挟持 document 的属性/方法,然后从
    1. proxyDocument
    中取值,

    这样,就能直接执行子应用的 JS 代码,不需要另外包一层函数执行 JS

    在无界微前端中,有非常多像

    1. querySelector
    的属性/方法,需要对每个属性方法的副作用进行修正。因此除了
    1. proxyDocument
    ,还有
    1. proxyWindow
    1. proxyLocation

    很可惜的是,location 对象不能使用

    1. Object.defineProperty
    进行挟持,因此实际上,运行非 esModule 代码时,仍然需要用函数包一层运行,传入 proxyLocation 代替 location 对象。

    但 esModule 由于不能在函数中运行,因此 esModule 代码中获取的 location 对象是错误的,这个无界的常见问题文档也有提到。

    接下来稍微介绍一下无界对 DOM 和 iframe 副作用的一些处理

    副作用的处理

    无界通过创建代理对象、覆盖属性和函数等方式对原有的JavaScript对象进行挟持。需要注意的是,所有这些处理都必须在子应用 JS 运行之前,也就是在 iframe 创建时执行:

    1. const iframe = window.document.createElement("iframe");
    2. // 将 iframe 插入到 document 中
    3. window.document.body.appendChild(iframe);
    4. const iframeWindow = iframe.contentWindow;
    5. // 停止 iframe 的加载
    6. sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() =&gt; {
    7. // 对副作用进行处理修正
    8. }

    1. stopIframeLoading
    后,即停止 iframe 加载,获得纯净的
    1. iframe
    后,再对副作用进行处理

    无界微前端 JS 有非常多的副作用需要修正处理,文章不会一一列举,这里会说一下大概,让大家对这个有点概念。

    DOM 相关的副作用处理

    下面是几个例子

    修正相对 URl

    1. <img src = "./images/test.png" alt = "Test Image" />

    当我们在 DOM 中使用相对 url 时,会用 DOM 节点的

    1. baseURI
    作为基准,其默认值为
    1. document.location.href

    但我们知道,子应用的 UI 是挂载在 shadowRoot,跟主应用是同一个 document 上下文,因此它的

    1. baseURI
    默认是主应用的
    1. url
    ,但实际上应该为子应用的
    1. url
    才对,因此需要修正。

    下面是部分修正的伪代码:

    1. // 重写 Node 原型的 appendChild,在新增 DOM 时修正
    2. iframeWindow.Node.prototype.appendChild = function(node) {
    3. const res = rawAppendChild.call(this, node);
    4. // 修正 DOM 的 baseURI
    5. patchElementEffect(node, iframeWindow);
    6. return res;
    7. };

    事实上,除了

    1. appendChild
    ,还有其他的函数需要修正,在每个能够创建 DOM 的位置,都需要进行修正,例如
    1. insertBefore

    修正 shadowRoot head、body

    shadowRoot 可以视为子应用的

    1. document

    在前端项目中,经常会在 JS 中引入 CSS,实际上 CSS 文本会以 style 标签的形式注入到

    1. docuement.head
    中,伪代码如下:
    1. export default function styleInject(css) {
    2. const head = document.head
    3. const style = document.createElement('style')
    4. style.type = 'text/css'
    5. style.styleSheet.cssText = css
    6. head.appendChild(style)
    7. }

    在 iframe 中使用

    1. document.head
    ,需要用
    1. Object.defineProperty
    挟持 document 的 head 属性,将其重定向到 shadowRoot 的
    1. head
    标签
    1. Object.defineProperty(iframeWindow.document, 'head', {
    2. enumerable: true,
    3. configurable: true,
    4. // 改为从 proxyDocument 中取值
    5. get: () => sandbox.proxyDocument['head'],
    6. set: undefined,
    7. });

    1. proxyDocument
    的 head 实际上为
    1. shadowRoot
    的 head
    1. shadowRoot.head = shadowRoot.querySelector("head");
    2. shadowRoot.body = shadowRoot.querySelector("body");

    同样的,很多组件库的弹窗,都会往

    1. document.body
    插入弹窗的 DOM,因此也要处理

    iframe 的副作用处理

    History API

    1. history
    API 在 SPA 应用中非常常见,例如 vue-router 就会使用到
    1. history.pushState
    1. history.replaceState
    等 API。

    当前 url 改变时

    • 需要改变

      1. document.baseURI
      ,而它是个只读的值,需要修改
      1. document.head
      中的
      1. base
      标签
    • 需要将子应用的 url,同步到父应用的地址栏中

    1. history.pushState = function (data: any, title: string, url?: string): void {
    2. // 当前的 url
    3. const baseUrl = mainHostPath
    4. + iframeWindow.location.pathname
    5. + iframeWindow.location.search
    6. + iframeWindow.location.hash;
    7. // 根据当前 url,计算出即将跳转的 url 的绝对路径
    8. const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);
    9. // 调用原生的 history.pushState
    10. rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl);
    11. // 更新 head 中的 base 标签
    12. updateBase(iframeWindow, appHostPath, mainHostPath);
    13. // 同步 url 到主应用地址栏
    14. syncUrlToWindow(iframeWindow);
    15. };

    window/document 属性/事件

    有些属性,应该是使用主应用 window 的属性,例如:

    1. getComputedStyle

    有些事件,需要挂载到主应用,有些需要挂载到 iframe 中。这里直接举个例子:

    • onunload 事件,需要挂载到 iframe 中

    • onkeyup 事件,需要挂载到主应用的 window 下(iframe 中没有 UI,UI 挂载到主应用 document 的 shadowRoot 下)

    因此要挟持

    1. onXXX
    事件和
    1. addEventListener
    ,对每一个事件进行分发,将事件挂载到
    1. window
    /
    1. iframeWindow

    将事件挂载到

    1. window
    的代码实现如下:
    1. // 挟持 onXXX 函数
    2. Object.defineProperty(iframeWindow, 'onXXX', {
    3. enumerable: true,
    4. configurable: true,
    5. // 从 window 取
    6. get: () => window['onXXX'],
    7. set: (handler) => {
    8. // 设置到 window
    9. window['onXXX'] = typeof handler === "function"
    10. ? handler.bind(iframeWindow) // 将函数的 this 设置为 iframeWindow
    11. : handler;
    12. }
    13. });

    通过

    1. Object.defineProperty
    挟持
    1. onXXX
    ,将事件设置到
    1. window
    上。

    location 对象

    当我们在子应用 iframe 中获取

    1. location.href
    1. location.host
    等属性的时候,需要获取的是子应用的
    1. href
    1. host
    (iframe 的 location href 并不是子应用的 url),因此这里也是需要进行改造。
    1. const proxyLocation = new Proxy(
    2. {},
    3. {
    4. get: function (_, propKey) {
    5. if (propKey === "href") {
    6. return // 获取子应用真正的 url
    7. }
    8. // 省略其他属性的挟持
    9. },
    10. }
    11. );

    为什么 iframe 的 location href 不是子应用的 url?

    为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域)

    以上就是无界微前端是怎么渲染子应用的demo解析的详细内容,更多关于无界微前端是怎么渲染子应用的demo解析的资料请关注九品源码其它相关文章!