找回密码
 会员注册
查看: 19|回复: 0

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

[复制链接]

7

主题

0

回帖

22

积分

新手上路

积分
22
发表于 2024-9-30 14:20:30 | 显示全部楼层 |阅读模式
这篇文章大致是为了回答几个问题:迁移到微前端,我们到底要什么?业界的「微前端」体系通常包括哪些部分?贴近研发同学侧的「微前端框架」通常用什么样的技术?我们团队的项目现在能上「微前端」吗?需要做什么改造、到什么地步?(以下全文读完大约 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 简化成最小情况,就一个主应用、一个子应用,来看看 「独立上线」 这件事。在此之前,我们先聊聊在非微前端时,页面加载是怎么操作的:通常前端页面应用打包结果的入口就是一段 当在页面访问不同路由 (url) 时,原本打包的 js 内部会去异步加载对应路由、组件的 chunk js,拿到代码后再去渲染这个路由下的内容 / 组件;以 webpack 为例,是通过插入 //0.f3c200e0.async.js(window.webpackJsonp=window.webpackJsonp||[]).push([chunkId],xxxChunk)那么换到微前端框架上,这个加载会有一点区别,具体来说,是在渲染某些区域的内容时,从「加载自身 chunk」变成 「加载应用入口」,加载器从 webpack 换成「微前端容器」;以访问 https://xxx-domiain/main-app/sub-route/xxxx 为例,简化流程为:主应用匹配到 /main-app/sub-soute 路由,渲染当前路由内容当前路由内容中有子应用,则异步加载子应用入口子应用匹配到 /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) 的感觉),直接拉低组件复用的门槛、远程加载的门槛,于是物料市场这种东西也顺势就会有;//伪代码示例,加载函数级别的微组件并执行importfoofrom'https://cdn/.../foo.js'constfoo=import('https://cdn/.../foo.js')constfoo=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 等都会有问题现在已经没有这么干的了,都是用沙箱的思路。SandboxWasm VM重新编译一个 Wasm 的 JS 解释器放在浏览器中,把子应用直接放进这个 VM 中执行;隔离非常严格,看到过很多技术文章讲解,但目前没有调研到有实际微前端框架这么干的,原因和大家不用 Web Worker / iframe 一样,隔离太严格了,通信非常麻烦,通信开销非常大;(但在除微前端之外有一些用,比如 StackBlitz[4] )with() + new Function(code) + Proxywith 语法用于改变作用域链,这里用来拦截写访问全局变量时对 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=newProxy(pick(window,whiteListProperties),{...})document=newProxy(document,{...})...sandbox=newFunction(`returnfunction({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-scriptsallow-same-originallow-popupsallow-presentationallow-top-navigation",style:'display:none;',}))window=newProxy(frame.contentWindow,{...})document=newProxy(document,{...})...sandbox=newFunction(`returnfunction({window,location,history,document},code){with(window){${code}}}`)sandbox().call(window,{window,location,history,document},code)Realmstc39 还在提案中的新规范 Realms[5],stage 2,可以创建完全独立的全局对象和全局作用域,用来实现沙箱正合适 (也有部分关于逃逸的讨论[6]),目前没有调研到任何微前端框架在用,仅 Figma 提到用于自身插件方案(上文提到「微物料」化之后,插件也能纳入「微前端」范畴);CSS 隔离不同于 JS 隔离的相对成熟, CSS 隔离在业界完全不成熟,目前对于大部分微前端框架都是有点问题;大部分都会告诉你,用工程化的方式加前缀来防止冲突,比如 BEM / css modules / css in js / 自定义前缀 等;但这没法解决不同应用依赖了同一个 UI 库不同版本的情况;并且大部分历史项目里面也有很多硬编码的 className 很难彻底改造;切换应用时卸载与上文提到的 JS 隔离用的 Snapshot 在应用切换时的[挂载 / 卸载原理]相同,问题也相同,不再赘述;Shadow DOMShadow DOM[7] 听起来才是真正有效用于 CSS 隔离的沙箱,有着和 iframe 一样严格的 DOM 隔离,Shadow DOM 内部的元素始终不会影响到它外部的元素;并且不管是 ...▼...但实际上,除了兼容性、浏览器 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 的做法可能会有无法同步、遗漏,等问题;比如对
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-15 07:41 , Processed in 0.555290 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表