从场景倒推看字节微前端

// 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 (阿里内部-阿里云控制台) |

开发配套

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

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

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

  • 本地开发子应用可脱离父应用 独立启动开发调试

  • 调试本地子应用和父应用接入,两者都用本地启动

  • 线上 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,加载约定格式的子应用入口,并挂载到给定位置

    • 部分框架是根据类似 manifest 的数据,来获取子应用注册情况以及入口地址
  • 部分框架支持和管理平台配合,运行时接受平台动态注入的入口地址 (也有框架宣称运行时注入和管理平台解耦,但实际是如果不用,就得自己实现注入逻辑)

  • JS 做入口更纯粹,用 HTML 做入口更易于旧项目改造

  • 业界目前常用两种入口格式, HTML 和 JS

  • 父子入口组合(即确定依赖关系)也有两种模式,构建时组合 和 运行时组合

  • 生命周期 - 加载 / 挂载 / 更新 / 卸载 等

    • 加载 / 挂载时做的初始化、权限守卫、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[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 内;

▶︎ …

▼ 

▼ 

▼ 

▼ #shadow-root (open)

▶︎ 

▶︎ …

▼ 

但实际上,除了兼容性、浏览器 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 对齐,基本解决问题;

▶︎ …

▼ 

▼ 

▼ 

▼ #shadow-root (open)

▶︎ 

▶︎ …

▼ 

▼ 

▼ #shadow-root (open)

▶︎ 

▶︎ …

▼ 

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

  • 比如对 <style> 标签内部的同步需要一直监听、两个 Shadow DOM 之间需要来回同步,因为任何一个内都可能插入新的 <style> 标签,也能在原有的某个 <style>标签内修改;

  • 再比如 css in js 方案为了性能通常会使用 CSSStyleSheet.insertRule\(\) API[8] 来创建样式,这样元素虽然能受到 css 样式影响,但对应 <style> 标签内容是完全空的,基于标签内容的手动无法同步,需要 JS 沙箱配合劫持 insertRule API 来做同步;

  • 以及如果子应用通过 JS 插入 dom 的位置不是 document.body ,而是其他任何一个一有 dom 的位置,这里也很难做劫持;

技术债 !!


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

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

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

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

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

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

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

========

由于篇幅过长,就不展示所有面试题了,想要完整面试题目的朋友(另有小编自己整理的2024大厂高频面试题及答案附赠)


  • 24
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值