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

243次阅读  |  发布于3年以前

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

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

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

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

所以,我们要什么?

如下这是一个典型的微前端结构例子,在一个 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[1] (基于 single-spa) OneX (阿里内部-蚂蚁金服)
MicroX (阿里内部) MicroX + CSKit (阿里内部-阿里云智能)
icestark[2] Iceworks (阿里内部-飞冰)
alfajs[3] Alfa (阿里内部-阿里云控制台)

开发配套

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

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

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

微物料

「微物料」这一块画虚线是因为它比较偏体系建设后期(也离我们(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);
复制代码;

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

运行时容器

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

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

沙箱隔离

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

JS 隔离

Snapshot

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

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

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

Sandbox

Wasm VM

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

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

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

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

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[5],stage 2,可以创建完全独立的全局对象和全局作用域,用来实现沙箱正合适 (也有部分关于逃逸的讨论[6]),

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

CSS 隔离

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

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

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

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

切换应用时卸载

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

Shadow DOM

Shadow DOM[7] 听起来才是真正有效用于 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 的做法可能会有无法同步、遗漏,等问题;

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8