qiankun最完美的样式隔离方案

3,468 阅读6分钟

随着微前端话题的热度逐渐下降,微前端方案的逐渐稳定。

image.png 图中可以看出qiankun依旧占国内微前端方案下载量榜首,但是其中部分功能还尚未完善(资源共享、多应用保活、插件机制、元素隔离等等),其中手动加载模式的CSS样式隔离方案有着无法规避的缺陷;

前言

  • 产品线: 应用微前端架构的产品,一般都是有多个垂直业务线,各自负责前端项目的排期、维护、部署;
  • 角色: 我负责维护微前端中的基座项目,因此如果代码的变更涉应用加载逻辑业务方配合改动,往往带来倍数级的影响范围(bug)和沟通成本(影响摸鱼)。

因此,涉及到业务方配合的改动,必须要推动业务方,赶在同一排期内上线,给双方都带了额外的成本;

背景

秉承又不是不能用,尽量不改动核心代码的理念,与产品经理周旋了几个迭代,最终还是给我扔了个炸弹(将页面升级为多tab模式)。

如果需要满足多Tab交互(即多个应用间独立),我总结了三个关键技术点:【路由响应隔离、样式隔离、元素隔离】;

问题

路由响应隔离

  • 背景:子应用本身是带有路由功能的,因此需要控制为手动加载模式,且保证Tab间路由跳转互不干扰;
  • 实现:手动模式加载子应用,一个Tab加载一个子应用,再控制子应用路由跳转逻辑(这里暂时先不讨论,感兴趣可以给我评论留言)

接下来通过是什么为什么怎么办来描述问题,及给出最终解决方案。

样式隔离

样式文件丢失

2023-02-17 15.09.54.gif

  • 是什么: demo中可以看出,同时加载两个相同的子应用时,关闭掉首次加载的应用,第二个应用的样式会丢失;
  • 为什么: 异步加载样式文件动态插入,项目中我们按需引入组件,经webpack打包分块处理后,link文件会按需加载; image.png 如上图所示,为了不浪费性能,webpack做了一层判断(检查documenty中是否存在相同的节点,有则无执行插入)。

image.png

结论:因此如上图所示,“同时”加载两个子应用时只插入一份样式文件,而关闭第一个应用时,link文件就document节点中被销毁,从而导致第二个应用只有节点,没有样式;

  • 怎么办:(使判断webpakc的判断失效)
  1. 开启ShadowDom,独立Document上下问题(strictStyleIsolation模式);
  2. 将link文件转化为style文件(experimentalStyleIsolation模式)

样式名冲突

  • 是什么: 加载多个子应用,导致存在相同的样式名称
  • 为什么: 同一个Document实例 加载多个子应用,同一个Document实例中存在相同的样式名称
  • 怎么办
  1. 方案一:CSS Rem方案(通过命名规避,或者css-loader提供的prefix功能)
  2. 方案二:开启样式隔离方案

Document隔离

2023-02-17 15.57.51.gif

  • 是什么: 通过ID选择(document.getElementById)操作Dom节点,可以看出右边子应用拿到了左边的节点
  • 为什么::同时加载多个相同子应用存在多个相同的节点信息(id,className,...),这时候通过ID选择器查找节点只能命中Document中上第一个节点,而不是当前子应用下的ID(即选中其他Tab下的ID)
  • 怎么办
  1. 方案一:开启ShadowDom(完全隔离Doc上下文)
  2. 方案二:改用Ref获取精准的节点(需要所有子应用都配合改动,影响范围大,涉及改造的内容多)

方案分析

从以上案例的解决方案,看出无论是样式问题(开启任意一种模式),还是Document隔离(ShadowDom模式),分析一下这两种方案好坏:

strictStyleIsolation模式(ShadowDom)
  • 优点:提供原生的Dom隔离
  • 缺点:ShadowDom本身有兼容性问题,且所有子应用需配合改动,再者在v3版本已经被弃用。 image.png
  • 结论:~~不敢用
experimentalStyleIsolation模式
  • 优点:将link文件转化成style文件,并补上样式前缀实现样式内容隔离
  • 缺点:
  1. 有一部分css功能无法使用(可以接受)
  2. Modal样式丢失(github.com/umijs/qiank…) 2023-02-17 16.31.24.gif
  • 原因:modal获取document.body节点再执行的append方法 image.png
  • 解决:ant-design通过ConfigProvider全局化配置收归到子应用节点内部(!!需要通知所有业务线的开发修改代码)

问题汇总

思考:

  1. ShadowDom模式,第一没踩过坑,第二影响还是兼容性改造(“推动业务”)
  2. experimentalStyleIsolation模式可以用,但是Modal样式会丢失(“推动业务”)
  3. 选择器干扰问题,替换成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

目标方法
DocumentcreateElement
DocumentquerySelector
DocumentquerySelectorAll
DocumentgetElementById
DocumentgetElementsByName
DocumentgetElementsByClassName
DocumentcreateElement
ElementquerySelector
ElementquerySelectorAll
Elementappend
Elementprepend
ElementinsertBefore
ElementremoveChild
ElementcloneNode

当初除此之外还有一些细节的改动,有感兴趣的同学可以通过github或评论一起交流 魔改(bushi。

该模式依赖于qiankun内部的getCurrentRunningApp方法来获取运行中的沙箱,因此只能使用proxy沙箱模式即默认模式;

最终效果

2023-02-21 08.34.58.gif

总结

通过patch了Document,Element原型属性上的部分方法,“experimentalStyleIsolation”模式,我们实现了在“手动加载模式”下多个子应用间,Document隔离,样式隔离;

项目地址