随着微前端话题的热度逐渐下降,微前端方案的逐渐稳定。
图中可以看出qiankun依旧占国内微前端方案下载量榜首,但是其中部分功能还尚未完善(资源共享、多应用保活、插件机制、元素隔离等等),其中手动加载模式的CSS样式隔离方案有着无法规避的缺陷;
前言
- 产品线: 应用微前端架构的产品,一般都是有多个垂直业务线,各自负责前端项目的排期、维护、部署;
- 角色: 我负责维护微前端中的基座项目,因此如果代码的变更涉应用加载逻辑、业务方配合改动,往往带来倍数级的影响范围(bug)和沟通成本(影响摸鱼)。
因此,涉及到业务方配合的改动,必须要推动业务方,赶在同一排期内上线,给双方都带了额外的成本;
背景
秉承又不是不能用,尽量不改动核心代码的理念,与产品经理周旋了几个迭代,最终还是给我扔了个炸弹(将页面升级为多tab模式)。
如果需要满足多Tab交互(即多个应用间独立),我总结了三个关键技术点:【路由响应隔离、样式隔离、元素隔离】;
问题
路由响应隔离
- 背景:子应用本身是带有路由功能的,因此需要控制为手动加载模式,且保证Tab间路由跳转互不干扰;
- 实现:手动模式加载子应用,一个Tab加载一个子应用,再控制子应用路由跳转逻辑(这里暂时先不讨论,感兴趣可以给我评论留言)
接下来通过是什么、为什么、怎么办来描述问题,及给出最终解决方案。
样式隔离
样式文件丢失
- 是什么: demo中可以看出,同时加载两个相同的子应用时,关闭掉首次加载的应用,第二个应用的样式会丢失;
- 为什么: 异步加载样式文件动态插入,项目中我们按需引入组件,经webpack打包分块处理后,link文件会按需加载;
如上图所示,为了不浪费性能,webpack做了一层判断(检查documenty中是否存在相同的节点,有则无执行插入)。
结论:因此如上图所示,“同时”加载两个子应用时只插入一份样式文件,而关闭第一个应用时,link文件就document节点中被销毁,从而导致第二个应用只有节点,没有样式;
- 怎么办:(使判断webpakc的判断失效)
- 开启ShadowDom,独立Document上下问题(strictStyleIsolation模式);
- 将link文件转化为style文件(experimentalStyleIsolation模式)
样式名冲突
- 是什么: 加载多个子应用,导致存在相同的样式名称
- 为什么: 同一个Document实例 加载多个子应用,同一个Document实例中存在相同的样式名称
- 怎么办
- 方案一:CSS Rem方案(通过命名规避,或者css-loader提供的prefix功能)
- 方案二:开启样式隔离方案
Document隔离
- 是什么: 通过ID选择(document.getElementById)操作Dom节点,可以看出右边子应用拿到了左边的节点
- 为什么::同时加载多个相同子应用存在多个相同的节点信息(id,className,...),这时候通过ID选择器查找节点只能命中Document中上第一个节点,而不是当前子应用下的ID(即选中其他Tab下的ID)
- 怎么办:
- 方案一:开启ShadowDom(完全隔离Doc上下文)
- 方案二:改用Ref获取精准的节点(需要所有子应用都配合改动,影响范围大,涉及改造的内容多)
方案分析
从以上案例的解决方案,看出无论是样式问题(开启任意一种模式),还是Document隔离(ShadowDom模式),分析一下这两种方案好坏:
strictStyleIsolation模式(ShadowDom)
- 优点:提供原生的Dom隔离
- 缺点:ShadowDom本身有兼容性问题,且所有子应用需配合改动,再者在v3版本已经被弃用。
- 结论:~~不敢用
experimentalStyleIsolation模式
- 优点:将link文件转化成style文件,并补上样式前缀实现样式内容隔离
- 缺点:
- 有一部分css功能无法使用(可以接受)
- Modal样式丢失(github.com/umijs/qiank…)
- 原因:modal获取document.body节点再执行的append方法
- 解决:ant-design通过ConfigProvider全局化配置收归到子应用节点内部(!!需要通知所有业务线的开发修改代码)
问题汇总
思考:
ShadowDom模式,第一没踩过坑,第二影响还是兼容性改造(“推动业务”)- experimentalStyleIsolation模式可以用,但是Modal样式会丢失(“推动业务”)
- 选择器干扰问题,替换成ref(”推动业务“)
上面遇到的问题,无论兼容性(ShadowDom)或者改动范围(所有业务线配合改动),都是一个让人头疼的问题;因此果断放弃“ShadowDom”,没得选只能“投靠”experimentalStyleIsolation。
思路:
最终我们从隔壁框架 MircoApp得到了灵感,通过代理Document,Element原型链上的方法来实现把选择器的范围控制到当前子应用内, 看看getElementById怎么实现代理;
关键函数:querySelector
Document.prototype.getElementById = function getElementById (key: string): HTMLElement | null {
判断当前有没有执行中的子应用
if (!getCurrentAppName() || isInvalidQuerySelectorKey(key)) {
return globalEnv.rawGetElementById.call(this, key)
}
try {
实际执行这一句,key为id选择器的值,看看querySelector实现
👇👇👇👇👇👇
return querySelector.call(this, `#${key}`)
} catch {
return globalEnv.rawGetElementById.call(this, key)
}
}
关键函数:getCurrentAppName、appInstanceMap.get(appName)
function querySelector (this: Document, selectors: string): any {
👇👇👇👇👇👇👇👇👇 获取运行中的应用
const appName = getCurrentAppName()
if (
!appName ||
!selectors ||
isUniqueElement(selectors) ||
// see https://github.com/micro-zoe/micro-app/issues/56
rawDocument !== this
) {
return globalEnv.rawQuerySelector.call(this, selectors)
}
👇👇👇👇👇👇👇👇👇 获取运行中的应用的容器节点
return appInstanceMap.get(appName)?.container?.querySelector(selectors) ?? null
}
而实际中qiankun也做了类似的操作,不过只时代理部分append方法,用来控制文件动态插入的范围;
👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawBodyAppendChild,
containerConfigGetter,
isInvokedByMicroApp,
target: 'body', ⬅️⬅️⬅️⬅️
}) as typeof rawBodyAppendChild;
Key: mountDOM -> rawDOMAppendOrInsertBefore.call(mountDOM)
function getOverwrittenAppendChildOrInsertBefore(opts: {
rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
isInvokedByMicroApp: (element: HTMLElement) => boolean;
containerConfigGetter: (element: HTMLElement) => ContainerConfig;
target: DynamicDomMutationTarget;
}) {
....
const appWrapper = appWrapperGetter();//⬅️获取最外层节点
👇👇👇👇
const mountDOM = target === 'head' ? getAppWrapperHeadElement(appWrapper) :
appWrapper;
....
👇👇👇👇👇👇👇👇👇👇👇👇
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
参考mircoapp的理念和qiankun的patch写法 因此,我对以下方法做了patch
| 目标 | 方法 |
|---|---|
| Document | createElement |
| Document | querySelector |
| Document | querySelectorAll |
| Document | getElementById |
| Document | getElementsByName |
| Document | getElementsByClassName |
| Document | createElement |
| Element | querySelector |
| Element | querySelectorAll |
| Element | append |
| Element | prepend |
| Element | insertBefore |
| Element | removeChild |
| Element | cloneNode |
当初除此之外还有一些细节的改动,有感兴趣的同学可以通过github或评论一起交流 魔改(bushi。
该模式依赖于qiankun内部的
getCurrentRunningApp方法来获取运行中的沙箱,因此只能使用proxy沙箱模式即默认模式;
最终效果
总结
通过patch了Document,Element原型属性上的部分方法,“experimentalStyleIsolation”模式,我们实现了在“手动加载模式”下多个子应用间,Document隔离,样式隔离;
项目地址
- Npm: qiankun-rewrite
- Github: github.com/Rahim-Chan/…