阅读 8010

从场景倒推我们要什么样的微前端体系

这篇文章大致是为了回答几个问题:

  1. 迁移到微前端,我们到底要什么?
  2. 业界的「微前端」体系通常包括哪些部分?
  3. 贴近研发同学侧的「微前端框架」通常用什么样的技术?
  4. 我们团队的项目现在能上「微前端」吗?需要做什么改造、到什么地步?

(以下全文读完大约 20 min)


微前端已经不是一个新概念了,大家或多或少都听说过接触过,这里不再去做一堆定义,只是对目前业界做法的调研总结 / 概览,这篇文章面向的是还没有在业务中使用过微前端的同学或团队,通过这篇概览,可以简单的建立对 「微前端」的整体认知;

总的来说「微前端」这个概念从造出来到发展如今,还处于一个百花齐放(各做各的)的发展中,没有形成统一的(市场占有高的)共识 / 标准;各个大厂 / 社区对这个概念以及背后的技术都有不同定义,各自为战,造的轮子也一堆。


所以,我们要什么?

如下这是一个典型的微前端结构例子,在一个 URL 访问的页面中,有一个主应用(基座),多个共存的子应用 A/B,子应用 B 内还有嵌套的子应用 C;它们可由不同团队独立开发,各个应用独立上线、互不干扰


「独立上线」「互不干扰」

对我们来说重要的是 「独立上线」「互不干扰」,在上线发布层面互不干扰,在应用共存层面互不干扰;

这是我们目前最直观需要微前端给我们带来的能力,因为我们面临的直接问题就是 “内部有模块要拆到不同团队开发了,怎么发布上线” (即 “把内部一些大模块独立成子应用”)。

先从 「上线发布层面互不干扰」 说起,比如 Main App 是 v1.0.1 的版本, Sub App A 已经是 v2.1.0 的版本的,不同团队的人各自上线自己的应用,各自发版节奏之间没有影响;

当 Sub App A 升级到 v2.1.1 的时候,Main App 和 Sub App B 应该完全不做任何改动 / 发布,线上页面就是 Sub App A 这个区域就是新的。

再把这个 case 简化成最小情况,就一个主应用、一个子应用,来看看 「独立上线」 这件事。

在此之前,我们先聊聊在非微前端时,页面加载是怎么操作的:

通常前端页面应用打包结果的入口就是一段 <script> 标签加载 js 文件,执行后往某一个 dom 节点下挂载内容,类似如下

<html>
  <head>
    ...
  </head>


  <body>
    <div id="root"></div>
    <script src="//cdn/entry/main-app.js@1.0.2"></script>
  </body>
</html>
复制代码

当在页面访问不同路由 (url) 时,原本打包的 js 内部会去异步加载对应路由、组件的 chunk js,拿到代码后再去渲染这个路由下的内容 / 组件;

以 webpack 为例,是通过插入 <script> 标签来获取其他 chunk js,每个 chunk js 中通过 jsonp 的方式来加载 (入口文件则是 IIFE)。

<html>
  <head>
    ...
    <script src="//cdn/chunk/0.f3c200e0.async.js"></script>
    <script src="//cdn/chunk/1.5bb06b78.async.js"></script>
  </head>

  <body>
    <div id="root"></div>
    <script src="//cdn/entry/main-app.js@1.0.2"></script>
  </body>
</html>
复制代码
// 0.f3c200e0.async.js

(window.webpackJsonp=window.webpackJsonp||[]).push([chunkId], xxxChunk)
复制代码

那么换到微前端框架上,这个加载会有一点区别,具体来说,是在渲染某些区域的内容时,从「加载自身 chunk」变成 「加载应用入口」,加载器从 webpack 换成「微前端容器」;

以访问 https://xxx-domiain/main-app/sub-route/xxxx 为例,简化流程为:

  1. 主应用匹配到 /main-app/sub-soute 路由,渲染当前路由内容
  2. 当前路由内容中有子应用,则异步加载子应用入口
  3. 子应用匹配到 /sub-route/xxxx 路由,在自己的区域内渲染对应路由内容

回到 「独立上线」 这个事情上,首先大家已经知道了微前端框架实际上就是 「父应用加载子应用入口」,再简单预设这个「入口」也就是一段 js (或 html),就如下图结构,

那么我们还是有那么一堆问题;

  • 怎么注入加载入口脚本,从哪儿加载,怎么控制版本?
  • 在哪儿上线,怎么上线?
  • 子应用上线升级版本,怎么不让主应用重新打包?
  • 如何选择不同版本 上线 / 回滚 / 灰度?
  • 如何查看现在所有子应用的列表?
  • 多个版本之间切换如何集成联调?
  • ...

微前端体系

这实际反映出的是,我们对「微前端」的需求,不只是一个「微前端框架」,更是需要一整个配套的「微前端体系」;

这是体系大致包含的内容,上一节最后的几个问题,可以由 「治理体系」「开发配套」 来回答,而通常大家在聊的 「微前端框架」 只是这个体系里面的 「运行时容器」 这一部分;


治理体系

「治理体系」简单看可以视为一个 上线管理平台 + 上线发布流程;

在目前调研结果来看,微前端的落地使用一定需要配套这么个管理平台,虽然说的这么绝对,它也就比非微前端时候的上线平台多两个功能:

  • 应用管理 - 能上线各种主应用、子应用不同版本,列出上线应用不同版本的入口地址
  • 依赖管理 - 明确管理父子应用依赖关系,将子应用入口地址注入父应用

根据上一节「入口加载」提到的,子应用的入口加载,就是是父应用去加载一段 js url 地址 ,如:https://cdn/.../entry@v1.js ,那么子应用的 上线 也就是更新这个 url 地址 (版本),每次上线是一个新的入口地址 (版本);

并且要做到 “子应用上线升级版本,不让主应用重新打包” 还需要让这个入口 url 是通过上线平台 注入 到父应用,而不是 hardcode 写到父应用的代码中;这个注入的过程、注入哪些子应用,都是在这个上线管理平台中做的。

其他如「版本发布」「灰度方案」「私有化」部分都和一般前端上线部署平台一致,不再赘述;

但凡有实际上线需求,这个治理体系就没法缺,缺了就表示这个团队又得再做一个出来。

附:一些调研结果

加载容器管理平台
Qiankun (基于 single-spa)OneX (阿里内部-蚂蚁金服)
MicroX (阿里内部)MicroX + CSKit (阿里内部-阿里云智能)
icestarkIceworks (阿里内部-飞冰)
alfajsAlfa (阿里内部-阿里云控制台)

开发配套

文档这部分特别重要,也不只是对微前端框架的介绍文档,包括开发本身的文档,以及刚才说的「上线发布流程」,什么流程和怎么操作都该有串联起来的文档。

构建 / 发布 这些略有区别,表现为打包的入口可能变了,甚至变多个了;子应用产物的格式也多了一些限制,比如需要多一些跟「加载入口」相关的文件。

多个父子应用间集成联调涉及到:

  • 本地开发子应用可脱离父应用 独立启动开发调试
  • 调试本地子应用和父应用接入,两者都用本地启动
  • 线上 bug 复现,需要调试子应用和父应用接入,其中一个本地启动,另一个加载线上

微物料

「微物料」这一块画虚线是因为它比较偏体系建设后期(也离我们(Aeolus)目前需求比较远),在前期微前端运作刚起步阶段,是不需要实现的;

并且「微物料」的出现本质上是对微前端形态的一种转换,把微前端从 「不同页面级别应用组合,no-bundle」 转换到 「根据接口协议,可以直接加载远程的组件、函数,no-bundle」

直接模糊了 App / Page / Component (widget) / Function / Plugin 的边界;在写法上形如原生浏览器 esm import 以及 deno import (调用方式上也颇有一种后端 「远程过程调用」(RPC) 的感觉),直接拉低组件复用的门槛、远程加载的门槛,于是物料市场这种东西也顺势就会有;

// 伪代码示例,加载函数级别的微组件并执行
import foo from 'https://cdn/.../foo.js'
const foo = import('https://cdn/.../foo.js')
const foo = load('https://cdn/.../foo.js')
foo(xxx)
复制代码

在这个场景下,简单区分下目前这几个称呼的边界

  • App - 一整个微前端应用,内部也能有很多模块、多个页面 (Page)
  • Page - 一个稍大一点有路由的微前端组件可以称为页面,如一个数据查询页面
  • Widget - 没有路由的小组件(挂件),如一个样式很独特的按钮
  • Function - 被远程加载执行的一个功能函数,如试想一下用 UMD 加载 lodash 一个 func (接口格式定义在应用外)
  • Plugin - 接口格式、执行上下文定义的比较严的函数 (接口格式由应用定义)

运行时容器

这部分就是通常狭义上的「微前端框架」做的事;


它主要是要干什么呢,大概这些事:

  • 应用加载 - 根据注册的子应用,通过给定的 url,加载约定格式的子应用入口,并挂载到给定位置
    • 业界目前常用两种入口格式, HTMLJS
      • JS 做入口更纯粹,用 HTML 做入口更易于旧项目改造
    • 父子入口组合(即确定依赖关系)也有两种模式,构建时组合运行时组合
      • 部分框架是根据类似 manifest 的数据,来获取子应用注册情况以及入口地址
      • 部分框架支持和管理平台配合,运行时接受平台动态注入的入口地址 (也有框架宣称运行时注入和管理平台解耦,但实际是如果不用,就得自己实现注入逻辑)
  • 生命周期 - 加载 / 挂载 / 更新 / 卸载 等
    • 加载 / 挂载时做的初始化、权限守卫、i18n 语言等
    • 卸载时做清理,如卸载 script 标签、style 标签、子应用 dom 等
    • 以及路由、父子通信时做双向更新的桥梁
  • 路由同步 - 子应用的路由切换时,同步更新 url; url 跳转 / 更新时,同步更新子应用
    • 也就是对子应用做到路由等同于 url
  • 应用通信 - 是说支持父子应用之间便捷地相互通信,不像 postMessage 那样难用 (指字符串)
    • 什么,你问兄弟应用相互通信?当然大家都是用父应用作 EventHub
  • 沙箱隔离 - 为了各个应用「互补干扰」,需要把各个应用在“隔离”的环境中执行
    • 缺少隔离的话,CSS 全局样式可能 冲突混乱,JS 全局变量可能被 污染 / 篡改 / 替换
    • 这一部分业界的方案和演进比较多,下一章会展开讲讲
  • 异常处理 - 以上所有东西在报错时的统一处理,比如加载失败、或者路由匹配失败

沙箱隔离

通常在多个应用间,需要做隔离的就两个部分, JS & CSS;

JS 隔离

Snapshot

子应用挂载时,先对全局 window 变量打个快照放闭包里,再把全局 window 丢给子应用,并在子应用卸载时通过快照恢复全局 window 变量;


这是早期部分框架的做法,实际上这也并没有形成“隔离”,只是防止多个子应用互相“污染”;限制也非常多:

  • 父子子应用不能共存,一个 url 路由下整个页面都是某一子应用
  • 多个子应用之间不能共存,因为全局 window 只有一个,快照只有一个
  • 快照的方式安全性不够严格,深拷贝遇到 document / history 等都会有问题

现在已经没有这么干的了,都是用沙箱的思路。


Sandbox


Wasm VM

重新编译一个 Wasm 的 JS 解释器放在浏览器中,把子应用直接放进这个 VM 中执行;

隔离非常严格,看到过很多技术文章讲解,但目前没有调研到有实际微前端框架这么干的,

原因和大家不用 Web Worker / iframe 一样,隔离太严格了,通信非常麻烦,通信开销非常大;

(但在除微前端之外有一些用,比如 StackBlitz )


with() + new Function(code) + Proxy

with 语法用于改变作用域链,这里用来拦截写访问全局变量时对 window 的查找,如直接访问 Array.from 而不是 window.Array.from 写法时;


new Function 执行 code 作用等同于 eval,但 eval 能访问到当前局部作用域变量,new Function 返回函数不管哪里执行,都只能访问全局作用域,正是我们想要的。


Proxy 提供的是 with 和 new Function 闭包中用到的充当 window 作用域的对象,通过白名单属性限制能访问真正 window 上的部分元素,通过 Proxy 让删除 / 添加全局变量 / api 时不会对真正全局 window 产生影响;


同时对 document / history / localtion 上各类操作做劫持,比如把 document.body 上插入元素乾坤大挪移、把 history.push 改写再同步到 url、把 localtion path 拦截让子应用只获取内部路由, 等等,这些种种限制组成沙箱环境;


// 简化伪代码示例
window = new Proxy(pick(window, whiteListProperties), { ... })
document = new Proxy(document, { ... })
...

sandbox = new Function(`
  return function ({ window, location, history, document }, code){
    with(window) {
      ${code}
    }
}`)


sandbox().call(window, { window, location, history, document }, code)
复制代码

但这里对 window 拦截的程度是有限的,甚至可以简单理解为「浅拷贝」而非「深拷贝」,通过全局通用 API 很容易做到逃逸而实现污染,比如直接改掉 Array.prototype.push 的行为;


with() + new Function(code) + Proxy + iframe contex

为了更安全的解决上面的 Proxy window 全局 API 逃逸问题,可以取一个 iframe 的 window 作为沙箱环境上下文的 window;


这里的 iframe 并不是直接作为沙箱来执行子应用代码,子应用依然执行在 with + new Function 中,这个 iframe 只是个创建出来的空的 same-origin iframe,唯一用途是取它的 iframe.contentWindow 对象传给子应用做 window;


因为 iframe 的严格隔离性,一切全局对象跟外层均没有任何关系(除了 parent),因此内外两个 Array Array.prototype 都不相同,等同于把上一个方案的 window 拦截做到了 「深拷贝」,是一种目前比较完善优雅的沙箱方案;


(对 document / history / localtion 的代理拦截与上一个方案无异)

// 简化伪代码示例
frame = document.body.appendChild(document.createElement('iframe',{
  src: 'about:blank',
  sandbox: "allow-scripts allow-same-origin allow-popups allow-presentation allow-top-navigation",
  style: 'display: none;',
}))

window = new Proxy(frame.contentWindow, { ... })
document = new Proxy(document, { ... })
...


sandbox = new Function(`
  return function ({ window, location, history, document }, code){
    with(window) {
      ${code}
    }
}`)


sandbox().call(window, { window, location, history, document }, code)
复制代码

Realms

tc39 还在提案中的新规范 Realms,stage 2,可以创建完全独立的全局对象和全局作用域,用来实现沙箱正合适 (也有部分关于逃逸的讨论),

目前没有调研到任何微前端框架在用,仅 Figma 提到用于自身插件方案(上文提到「微物料」化之后,插件也能纳入「微前端」范畴)


CSS 隔离

不同于 JS 隔离的相对成熟, CSS 隔离在业界完全不成熟,目前对于大部分微前端框架都是有点问题;


大部分都会告诉你,用工程化的方式加前缀来防止冲突,比如 BEM / css modules / css in js / 自定义前缀 等;

但这没法解决不同应用依赖了同一个 UI 库不同版本的情况;

并且大部分历史项目里面也有很多硬编码的 className 很难彻底改造;


切换应用时卸载

与上文提到的 JS 隔离用的 Snapshot 在应用切换时的[挂载 / 卸载原理]相同,问题也相同,不再赘述;


Shadow DOM

Shadow DOM 听起来才是真正有效用于 CSS 隔离的沙箱,有着和 iframe 一样严格的 DOM 隔离,Shadow DOM 内部的元素始终不会影响到它外部的元素;

并且不管是 <style><link rel="stylesheet"> 产生的 css 在内外之间都是互不影响;

(图源 MDN)

听起来很美好,只需要给每个子应用外面套一个 Shadow DOM 就万事大吉,子应用往 head 里插入的 style / css link 都拦截到这个 Shadow DOM 内;

<html>
  ▶︎ <head>...</head><body><div id="main-app" >
      ...

      <!-- 子应用 A 对应的 shadow dom 容器 --><div id="sub-app-a-container">
        ▼ #shadow-root (open)
          ▶︎ <style>...</style>
          ▶︎ <link rel="stylesheet">...</link><div id="sub-app-a">
             ...
            </div>
        </div>
      </div>

    </body>
</html>
复制代码

但实际上,除了兼容性、浏览器 Shadow DOM 有一堆 BUG、react-dom 低版本对 Shadow DOM 事件不支持外,还有一个问题:


弹窗遮罩

准确的说是:子应用那些通过 JS 往 document.body 上插的元素,如 Tooltip / Popover / Modal 怎么办?

他们要是真插入到 document.body 上了,就跳过了 Shadow DOM,也就没有了子应用的 CSS,样式就没了啊;

要是被 JS 沙箱的 document 劫持到了插入操作,那这些 Tooltip / Popover / Modal 元素应该插入到哪里?

如果是插入到子应用 Shadow DOM 内跟挂载 DOM 同级的位置,可能因为 DOM 结构(顺序)改变导致子应用某些样式出问题,也可能因为子应用所在区域的 位置、大小、margin/padding 跟 body 不一致,导致这个插入的元素(如 Tooltip)的定位出现偏差,毕竟不是所有插入元素都用 fixed 定位;

一种 hack 的解决办法是,在 document.body 末尾给每个子应用对应再放一个 Shadow DOM 的 div,这个 div 和 document.body 的定位、大小、margin/padding 属性都完全一样,等同于覆盖在 body 之上,并且内部完全同步了对应子应用插入的 style / css link 标签,

这个 Shadow DOM 的 div 用来承载子应用插入到 document.body 上的元素(需要 JS 沙箱配合),这样,不管是 Tooltip / Popover / Modal 还是没有 fixed 定位的元素,获取到的 css 都和子应用内部一致,并且所在位置又和 body 对齐,基本解决问题;

<html>
  ▶︎ <head>...</head><body><div id="main-app" >
      ...

      <!-- 子应用 A 对应的 shadow dom 容器 --><div id="sub-app-a-container">
        ▼ #shadow-root (open)
          ▶︎ <style>...</style>
          ▶︎ <link rel="stylesheet">...</link><div id="sub-app-a">
             ...
            </div>
        </div>
      </div>

      <!-- 子应用 A 同步所有样式的 shadow dom 容器 --><div id="sub-app-a-global-shadow">
        ▼ #shadow-root (open)
          ▶︎ <style>...</style>
          ▶︎ <link rel="stylesheet">...</link><div id="modal">
             ...
            </div>
        </div>
    </body>
</html>
复制代码

但 hack 并不是完美的,但这里基于同步 css 的做法可能会有无法同步、遗漏,等问题;

  • 比如对 <style> 标签内部的同步需要一直监听、两个 Shadow DOM 之间需要来回同步,因为任何一个内都可能插入新的 <style> 标签,也能在原有的某个 <style>标签内修改;
  • 再比如 css in js 方案为了性能通常会使用 CSSStyleSheet.insertRule() API 来创建样式,这样元素虽然能受到 css 样式影响,但对应 <style> 标签内容是完全空的,基于标签内容的手动无法同步,需要 JS 沙箱配合劫持 insertRule API 来做同步;
  • 以及如果子应用通过 JS 插入 dom 的位置不是 document.body ,而是其他任何一个一有 dom 的位置,这里也很难做劫持;

技术债 !!

下面列出的是典型的技术债,不过它们清理的过程,也可以看做微前端改造过程的一部分。

  • 模块之间组件的交叉耦合

    模块内引入了其他模块的内部组件 / 方法,

    这些被引用项应该拆分出去成公共组件 / 方法;

    (如数据准备用到标签的表达式树组件、可视化筛选器组件等)


  • 公共依赖组件/方法还没完整拆分打包

    Common 公共组件 / Service 公共方法等,需要后续重构拆分发包


  • URL 路由模式改造还没做 (hash history => browser history)

    之前 Aeolus 一直有需求和计划要从 hash history 改到 browser history,但还没做,

    如果计划要改造,但在此之前做了微前端改造,那么之后路由改造的兼容可能更难做;

    不同微前端容器对路由模式的支持程度不一样,并且对父子应用能否使用不同模式的支持也不一样;


  • React v17 升级以修复 Shadow DOM 问题

    主流框架 CSS 隔离都有带 Shadow DOM 支持,而 React 需要升级到 v17 才有对 Shadow DOM 各种问题的修复;

    又因为目前用的 umi, react 运行版本是由 umi 内部包控制的,所以实际上这个升级是连带着 umi 一起升级的,新版 umi 的路由懒编译等特性也需要处理。


  • 硬编码的写在代码中的 jsx className

    大部分这类 className 都没有前缀,命名也很简单 (right, left, first, last ...),极易造成冲突,这部分也需要改造或重构;代码里这部分有不少,还很难统计;


  • Dev / CI / CD 流程还在改造中 (monorepo / dev preformance / ci tasks)

    而本身微前端的改造也对 Dev / CI / CD 流程有影响,因此两者最好不要同时做;


公共依赖复用

微前端不解决复用问题,「依赖复用」本身不是微前端框架该做的事 ,有些依赖是不能也不应该复用的(如代码的执行会对依赖本身的内部变量/context 产生副作用)(部分框架做了 npm lib 级别的复用抽取,但也会导致 bundle chunk 有问题);


用哪个框架?

技术上,用什么框架都可以,各个框架的设计基本都是宣称几行轻量级无侵入的接入方式,因此接入成本和替换成本都很小;重点是风神自己需要做完模块拆分;

实际上,我们只能选有配套治理体系服务的、研发者离我们近的;否则我们需要自己根据框架造配套治理体系以及自行排查接入源码级问题。

因此我们同时需要在改造时做好不耦合、甚至能随时替换微前端框架的准备。


要改造到什么程度?

  • 内部公共依赖该拆分的拆分,该发包的发包
  • 对应模块完整移动到其他仓库(或 monorepo 目录,如 apps/ ),并能独立启动开发 (因为能开发就能做部署了)
  • 剩下就是按照对应框架文档做打包接入
  • 具体实际改造过程和过渡阶段工程化有什么指导性方案?



Refs

把整个过程中,看的比较有关联有价值的文章列出来了

起源

Techniques, strategies and recipes for building a modern web app with multiple teams using different


综述

【推荐】是对微前端的一些总体概览,包括设计演进、技术演进等


Webpack5 Module Federation

没有沙箱,只有代码打包复用


Qiankun

qiankun 是运行时容器、加载器,但没有解答的工程与平台问题


Magic microservices

理念是 web components 做隔离,是纯框架容器的一层,不包含管理平台

Puzzle

Bit.dev

Alfa


沙盒隔离

Figma

Figma 博客详细讲了它们插件系统的沙箱隔离是怎么一步步演进的;Figma 使用 Realms 和 same-origin iframe + null-origin iframe 为沙箱中代码创建上下文环境。


欢迎关注「 字节前端 ByteFE 」 简历投递联系邮箱「tech@bytedance.com

文章分类
前端
文章标签