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

简介: 无界微前端是如何渲染子应用的?(下)

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

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


挟持 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 的修仙秘籍(点击可跳转)

另外,想要米哈游/腾讯内推的,也可以关注公众号找我内推

目录
相关文章
|
6天前
|
前端开发 JavaScript 安全
前端性能调优:HTTP/2与HTTPS在Web加速中的应用
【10月更文挑战第27天】本文介绍了HTTP/2和HTTPS在前端性能调优中的应用。通过多路复用、服务器推送和头部压缩等特性,HTTP/2显著提升了Web性能。同时,HTTPS确保了数据传输的安全性。文章提供了示例代码,展示了如何使用Node.js创建一个HTTP/2服务器。
16 2
|
1天前
|
编解码 前端开发 UED
探索无界:前端开发中的响应式设计深度解析与实践####
【10月更文挑战第29天】 本文深入探讨了响应式设计的核心理念,即通过灵活的布局、媒体查询及弹性图片等技术手段,使网站能够在不同设备上提供一致且优质的用户体验。不同于传统摘要概述,本文将以一次具体项目实践为引,逐步剖析响应式设计的关键技术点,分享实战经验与避坑指南,旨在为前端开发者提供一套实用的响应式设计方法论。 ####
20 4
|
7天前
|
Rust 前端开发 JavaScript
前端性能革命:WebAssembly在高性能计算中的应用探索
【10月更文挑战第26天】随着Web应用功能的日益复杂,传统JavaScript解释执行模式逐渐成为性能瓶颈。WebAssembly(Wasm)应运而生,作为一种二进制代码格式,支持C/C++、Rust等语言编写的代码在浏览器中高效运行。Wasm不仅提升了应用的执行速度,还具备跨平台兼容性和安全性,显著改善了Web应用的响应速度和用户体验。
23 4
|
6天前
|
前端开发 数据管理 测试技术
前端自动化测试:Jest与Cypress的实战应用与最佳实践
【10月更文挑战第27天】本文介绍了前端自动化测试中Jest和Cypress的实战应用与最佳实践。Jest适合React应用的单元测试和快照测试,Cypress则擅长端到端测试,模拟用户交互。通过结合使用这两种工具,可以有效提升代码质量和开发效率。最佳实践包括单元测试与集成测试结合、快照测试、并行执行、代码覆盖率分析、测试环境管理和测试数据管理。
18 2
|
7天前
|
前端开发 安全 应用服务中间件
前端性能调优:HTTP/2与HTTPS在Web加速中的应用
【10月更文挑战第26天】随着互联网的快速发展,前端性能调优成为开发者的重要任务。本文探讨了HTTP/2与HTTPS在前端性能优化中的应用,介绍了二进制分帧、多路复用和服务器推送等特性,并通过Nginx配置示例展示了如何启用HTTP/2和HTTPS,以提升Web应用的性能和安全性。
14 3
|
7天前
|
前端开发 JavaScript 数据可视化
前端自动化测试:Jest与Cypress的实战应用与最佳实践
【10月更文挑战第26天】前端自动化测试在现代软件开发中至关重要,Jest和Cypress分别是单元测试和端到端测试的流行工具。本文通过解答一系列问题,介绍Jest与Cypress的实战应用与最佳实践,帮助开发者提高测试效率和代码质量。
21 2
|
7天前
|
前端开发 JavaScript API
前端框架新探索:Svelte在构建高性能Web应用中的优势
【10月更文挑战第26天】近年来,前端技术飞速发展,Svelte凭借独特的编译时优化和简洁的API设计,成为构建高性能Web应用的优选。本文介绍Svelte的特点和优势,包括编译而非虚拟DOM、组件化开发、状态管理及响应式更新机制,并通过示例代码展示其使用方法。
21 2
|
8天前
|
前端开发 JavaScript 开发者
“揭秘React Hooks的神秘面纱:如何掌握这些改变游戏规则的超能力以打造无敌前端应用”
【10月更文挑战第25天】React Hooks 自 2018 年推出以来,已成为 React 功能组件的重要组成部分。本文全面解析了 React Hooks 的核心概念,包括 `useState` 和 `useEffect` 的使用方法,并提供了最佳实践,如避免过度使用 Hooks、保持 Hooks 调用顺序一致、使用 `useReducer` 管理复杂状态逻辑、自定义 Hooks 封装复用逻辑等,帮助开发者更高效地使用 Hooks,构建健壮且易于维护的 React 应用。
21 2
|
14天前
|
JavaScript 前端开发 测试技术
前端全栈之路Deno篇(五):如何快速创建 WebSocket 服务端应用 + 客户端应用 - 可能是2025最佳的Websocket全栈实时应用框架
本文介绍了如何使用Deno 2.0快速构建WebSocket全栈应用,包括服务端和客户端的创建。通过一个简单的代码示例,展示了Deno在WebSocket实现中的便捷与强大,无需额外依赖,即可轻松搭建具备基本功能的WebSocket应用。Deno 2.0被认为是最佳的WebSocket全栈应用JS运行时,适合全栈开发者学习和使用。
|
9天前
|
前端开发 API UED
深入理解微前端架构:构建灵活、高效的前端应用
【10月更文挑战第23天】微前端架构是一种将前端应用分解为多个小型、独立、可复用的服务的方法。每个服务独立开发和部署,但共同提供一致的用户体验。本文探讨了微前端架构的核心概念、优势及实施方法,包括定义服务边界、建立通信机制、共享UI组件库和版本控制等。通过实际案例和职业心得,帮助读者更好地理解和应用微前端架构。