首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >无界微前端是如何渲染子应用的?

无界微前端是如何渲染子应用的?

原创
作者头像
CandyTong
发布2023-03-29 22:21:29
4.9K0
发布2023-03-29 22:21:29
举报

经过我们团队的调研,我们选择了无界作为微前端的技术栈。目前的使用效果非常好,不仅性能表现出色,而且使用体验也不错。

尽管在使用的过程中,我们也遇到了一些问题,但这些问题往往源于我们对框架实现的不熟悉。我们深入研究了无界技术的源码,并将在本文中与大家分享。本文将重点探讨无界微前端如何渲染子应用的

无界渲染子应用的步骤

无界与其他微前端框架(例如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 主要有以下流程:

  1. 创建 iframe 的 DOM,并设置属性
  2. 将 iframe 插入到 document 中(此时 iframe 会立即访问 src)
  3. 停止 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,可以拿到外部 scriptstyle 的 url,fetch 发起 ajax 就可以获取到 scriptstyle 的内容

但是 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 内,样式也不会影响到外部,也不会受外部样式影响。

JS 的执行细节

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 渲染到 shadowRoot

我们先来看看现代的前端框架,是如何渲染 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

因此这里必须要对 iframedocument.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 就已经能够渲染出来了。

挟持 document 的属性/方法

上一小节,通过 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,还有 proxyWindowproxyLocation

很可惜的是,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 有非常多的副作用需要修正处理,文章不会一一列举,这里会说一下大概,让大家对这个有点概念。

DOM 相关的副作用处理

下面是几个例子

修正相对 URl

<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 head、body

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,因此也要处理

iframe 的副作用处理

History API

history API 在 SPA 应用中非常常见,例如 vue-router 就会使用到 history.pushStatehistory.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/document 属性/事件

有些属性,应该是使用主应用 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 上。

location 对象

当我们在子应用 iframe 中获取 location.hreflocation.host 等属性的时候,需要获取的是子应用的 hrefhost(iframe 的 location href 并不是子应用的 url),因此这里也是需要进行改造。

const proxyLocation = new Proxy(
  {},
  {
    get: function (_, propKey) {
      if (propKey === "href") {
        return // 获取子应用真正的 url
      }
	  // 省略其他属性的挟持
    },
  }
);

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

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

总结

本文介绍了无界渲染子应用的步骤:

  • 创建子应用 iframe
  • 解析入口 HTML
  • 创建 webComponent,并挂载 HTML
  • 运行 JS 渲染 UI

最后介绍了无界是处理副作用的一些细节。

目前主流的微前端框架多多少少多会有些问题,目前还没有一种非常完美的方法实现微前端。即使是经历过长时间迭代的 qiankun,其设计上也有问题,因此还配有一个常见问题的页面,给开发者提供帮助去避免问题。

在本文中也介绍到,虽然无界的设计思想更为优秀,但其设计也是有局限性的,例如必须要允许跨域、location 对象无法挟持等,这些都是开发中会遇到的问题,只有理解了无界的设计,才能更好的理解这些问题的本质原因,以及知道如何去避免它们。

如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 无界渲染子应用的步骤
  • 创建子应用 iframe
  • 解析入口 HTML
    • 处理 CSS 并重新嵌入 HTML
    • 创建 webComponent 并挂载 HTML
    • JS 的执行细节
      • 简单的实现
        • 将 UI 渲染到 shadowRoot
          • 挟持 document 的属性/方法
          • 副作用的处理
            • DOM 相关的副作用处理
              • 修正相对 URl
              • 修正 shadowRoot head、body
            • iframe 的副作用处理
              • History API
              • window/document 属性/事件
              • location 对象
          • 总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档