微前端架构剖析

221 阅读11分钟

前言

本文大部分内容来自官方文档、开源项目维护者的博客、社区博客。另外阅读本文可能需要你已经对微前端有个大致的了解。

微前端的核心价值

微前端(Micro Frontends)是一种前端架构模式,它将前端应用程序拆分成一个基座应用和多个小型子应用,子应用可以独立开发、测试、部署和升级,并且不限制子应用的技术栈。

What-Are-Micro-Frontends--scaled.jpg 它是一种微服务架构,对于子应用的拆分理念类似微服务的服务拆分,被拆分的子应用之间应该是独立的。

我们这里不过多介绍微前端到底是什么,相信看这篇文章的同学都已经对微前端有个初步的了解了。

对于微前端的核心价值,Qiankun 官方文档中提供的链接 微前端的核心价值 讲的很好,这里对文章内容简单做一个总结:

  1. 微前端的核心价值在于 "技术栈无关",这才是它诞生的理由。
  2. 确保遗留的屎山代码能平滑的迁移,以及确保在若干年后还能用上时下热门的技术栈。
  3. 微前端首先解决的,是如何解构巨石应用。
  4. 微前端的使命,文章的作者认为是:

image.png

推荐大家去看一下原文,文章下面的评论争论的比较激烈,很多都是针对第一点技术栈无关

image.png

我个人觉得,对于中小型公司来讲,技术栈统一是非常重要的事,特别是小公司,受限于规模不太可能有很多开发同学在同一个项目上进行开发。使用过多不同的技术栈反而会导致开发周期变长,且代码变得难以维护。

试想一下,一个中小型公司,只有一个前端团队,但是同时使用了React, Angular, Vue 三种框架进行开发,在面对人员离职,休假,排期冲突等等问题时,会导致开发同学需要修改他不熟悉的技术栈的业务代码,不同的组件库,不同的语法,不同的api,必然要花费更多的时间去实现。

而统一技术栈,不仅有利于提升开发速度和代码质量,也有利于组内的开发同学进行技术沉淀。

我一直觉得如果一个人说他自己同时精通三种不同框架写法用法,那他一定是在装B。你可能理解三种框架的实现,因为框架的设计理念总有些共通之处,但是各种写法,和各种不同的api你在写的时候,必然要花时间去翻官方文档,或是问谷歌问chatgpt。

不过由于作者是阿里的,大公司在应对多个不同的团队在同一个巨石应用上进行开发时,技术栈无关就显得特别重要了。

综上,我还是觉得 拆解巨石应用 才是大部分公司对于微前端的核心诉求。

微前端的几种实现方案

推荐阅读无界官方文档 微前端是什么

想要实现一个微前端框架,就必须要考虑到下面几点:

  1. 怎么去加载子应用。
  2. 怎么实现子应用之间的隔离,子应用间的隔离包括:
    • 隔离声明的全局变量或者挂载在window上的属性和方法
    • 样式隔离
    • 元素隔离

Iframe 方案

image.png

Single SPA 、 Qiankun 的方案

主要实现思路:

  • 预先注册子应用(激活路由、子应用资源、生命周期函数)
  • 在监听路由的变化后,根据子应用提供的注册信息,匹配到对应的子应用,然后加载子应用资源,顺序调用生命周期函数并最终渲染到容器。

Qiankun 则进一步对single-spa方案进行完善:

  • 子应用资源由 js 列表修改进为一个url,通过 html entry 的方案大大减轻注册子应用的复杂度。
  • 实现应用间隔离,支持js隔离(自己实现的沙箱) 和css隔离  (类似于vuescoped,严格模式下基于 webcomponent shadow dom)。
  • 增加资源预加载能力,预先加载子应用htmljscss资源,然后缓存下来,加快子应用的打开速度。

image.png

Wujie

image.png

微前端实现浅析

如何加载子应用

Qiankun

推荐阅读 可能是你见过最完善的微前端解决方案

主应用劫持到 url change 事件, 根据要访问的 url 和子应用注册时的配置, 匹配要加载的子应用入口, 然后通过 import-html-entry plugin 插件,通过 fetch 获取到 html 入口文件(这就是为什么需要子应用资源允许跨域),拿到 html 字符串后,会调用方法通过一大堆正则去匹配获取 html 中包括内联和外联的js、css、注释等等,然后把

  • 其中的 js 放到沙箱中执行 (with(proxyWindow){code})
  • css 全部以内联形式嵌入 html 模板中
  • 渲染出来的 html 插入到注册子应用时配置的 container中。

如下图所示 :

image.png

无界

推荐阅读:

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

让iframe焕发新生

无界主要基于 iframewebcomponent实现,

  1. 创建 iframe,插入到 document 中。
  2. 根据配置的子应用 url,使用基于乾坤的 import-html-entry plugin 二次封装的插件,加载入口 html 内容,后面的操作跟 Qiankun 基本一样,分别加载并解析出 html css js 代码,跟 Qiankun 唯一的区别是, Wujie 支持 ESM 的代码。
  3. 创建 webcomponent,并把处理后的 html css 挂载上去。
  4. 通过代理建立 iframewebcomponent 的连接,然后将子应用的js注入主应用同域的iframe中运行, 渲染页面。

image.png

沙箱

Qiankun

参考

# Qiankun原理——JS沙箱是怎么做隔离的

从零到一实现企业级微前端框架,保姆级教学

Qiankun 有三种沙箱, SnapshotSandboxLegacySandbox , ProxySandbox

SnapshotSandbox就是对 window 做个快照(浅拷贝),子应用在 UnMount 后拿的 window 跟快照进行对比,记录被修改的 Props,在子应用下次加载时,重新应用修改。

LegacySandbox 优化了上面的过程,不再每次都进行比较(太费时间),而是通过代理 window 对象,监听对 window 的修改,直接记录新增和修改的属性,在子应用下次加载时,重新应用修改。

前两种沙箱都是直接对 window 直接进行操作,所以全局只能有一个子应用被加载,而 ProxySandbox 为了避免直接操作真实的 window, 它基于 Window 对象做了一个假的 fakeWindow,然后给每个子应用都分配一个 fakeWindow。通过 Proxy 进行代理,当子应用想要修改 window 对象时,就修改自己的 fakeWindow, 在访问的时候, 优先从 fakeWindow上拿,子应用卸载时就把自己的 fakeWindow 存起来,在重新加载时再还回去。子应用之间互不影响。

前两种沙箱属于最后一种的降级方案。

无界

由于 iframe 天然拥有独立的上下文环境,所以无界把子应用的 js 代码放在 iframe 内执行,借助iframe 完美实现了沙箱。

这里简单解释下 Wujie iframe 是怎么跟 webcomponent 连接起来的:

渲染页面以 Vue 为例,需要给 Vue 指定一个 DOM 作为挂载点,Vue 会将组件挂载到该 DOM 上,而把 JS 代码放在 iframe 中执行后,使用 document.querySelector 查找到的 DOMiframe 内的 DOM,但实际我们是想让它挂载到 webcomponent 内,此时可以我们通过代理,直接劫持 iframe 内的 document.querySelector方法,让它返回 webscomponent 内的 DOM, 这样 iframewebcomponent 就连接起来了。

除了 document.querySelector 之外,Wujie 还劫持了其它很多类似的方法,保证在 iframe 中操作 DOM 时,实际上操作的是 webcomponent

IE环境下webcomponentproxy 都无法支持,Wujie 会自动降级,采用另一个 iframe替换webcomponent,用Object.defineProperty替换proxy来做代理。

样式隔离

webcomponent 一个重要的特性就是 shadow domshadow dom 是一项 web 标准,用于封装组件并实现样式隔离。 它允许将组件的 HTML 结构、样式和行为封装在一个独立的 DOM 树中,从而与主文档的 DOM 树相互隔离。也就是说外界的样式影响不了它,它内部的样式也无法影响到外部。

Qiankun

Qiankun 有两种样式隔离的方案可供选择

一种就是上面说的 shadow dom,这种方案会导致像弹框这种挂载到 body上的元素,样式丢失。其实也不是样式丢失,是因为弹框的样式在子应用 shadow dom 内, dom 元素挂载在外面,而 shadow dom 内的样式无法对外部的元素生效。

另一种是借鉴了 scoped css 的思路。 对所有样式加了一层 data-qiankun=“应用名” 的选择器来隔离。

image.png

无界

无界也是借助的 shadow dom ,对于弹框问题 由于无界劫持了 document 的属性和方法,document.bodyappendChild 或者 insertBefore 被劫持了, 所以弹框在挂载时会直接插入到 webcomponent,所以不存在这种问题。

元素隔离

元素隔离的意思是子应用只能对自身的元素进行增,删,改,查的操作。

京东 Micro App 对于元素隔离的解释: image.png

Micro App

Micro App 通过代理 Document, Element 原型链上的方法来实现把选择器的范围控制到当前子应用内。简单来说就是子应用内调用的 document.getElementByxxx 方法已经被重写了,会根据对应子应用的标识做查询范围限制。

Qiankun 后来也采用此方案做了优化。

无界

在上文我们提到过无界子应用是代码实例运行在 iframe 中,DOM 渲染在 webcomponent 中,并且通过代理document,建立了 iframe 与 webcomponent 的关系。

此时 iframe 中子应用实例执行 document.getElement 时,获取到的 DOM 元素其实就是子应用渲染在对应 webcomponent 中的元素。

微前端框架选型

可以参考无界的官方文档,对当前的几种微前端方案做了对比:

将微前端做到极致-无界微前端方案

目前 Qiankun2.0 不能很好的支持 Vite,虽然可以通过社区插件解决一部分问题,但还是有各种缺陷,Qiankun3.0 快要发布了,希望发布后能够解决。

为什么 qiankun 不能和 vite 一起使用?

在微前端qiankun中使用Vite你踩坑了吗?

想问一下,未来是否考虑支持 vite #1257

如何让 vite 完美接入 qiankun

挑一部分解释下:

在开发环境下,如果我们使用 vite 来构建 vue3 子应用,

基于 vite 的构建机制,vite 以 原生 ESM 方式提供源码,然后在子应的 html 的入口文件的 script 标签上携带 type=module

qiankun父应用引入子应用,本质上是将 index.html 作为入口文件,并通过 import-html-entry这个库去加载子应用所需要的资源列表 js、css,然后通过 eval("xxxxxcode") 直接执行。

这时候 qiankun 去加载子应用获取 main.js 的代码时就出问题了, eval() 它不支持诸如 export 和 import 的模块语法,所以就直接报错了:

<script type="module" src="/src/main.js"></script>

function () {
    // main.js 中的代码
    import xx from 'xx'
    import xx1 from 'xx1'
    // 省略...
}
Uncaught SyntaxError: Cannot use import statement outside a module

这里顺便提一下,在最开始的时候,import-html-entry甚至会过滤掉 type=module的 script,后面才支持的 type=module,详见这个PR # feat: support of module scripts

而在生产模式下

  1. vite2 不支持 runtime publicPath,这项能力在 webpack 中由内置变量__webpack_public_path__提供,runtime publicPath是 qiankun 加载子应用的核心 (由 import-html-entry 模块提供) ,用于预加载及引入异步脚本和加载静态资源。

  2. esm 会使 qiankun 的 js 沙箱失效,qiankun 内部的 js 沙箱将 window 对象进行了代理,以防止全局作用域被污染,但 esm 模块始终具有自己独立的 顶级作用域,也就是说它访问到的 window 是全局作用域下的,而不是 qiankun 沙箱中提供的代理 window,虽然可以通过在生产环境打包为 umd 格式的方式来避免使用 esm,但有些本末倒置了。

所以当前如果需要为项目接入微前端,而且想要用 Vite 的话,Wujie是目前的最优选,而且 Wujie 的接入成本也是最低的。但是 Wujie 在社区里没有 Qiankun 那么有知名度,用的人也没那么多,可能有坑还没被发现,希望 Wujie 能够一直维护下去,不会成为一个 KPI项目吧。

image.png