为什么需要CSS沙箱
在 qiankun 微前端框架中,由于每个子应用的开发和部署都是独立的,将主/子应用的资源整合到一起时,容易出现样式冲突的问题
因此,需要 CSS 沙箱来解决样式冲突问题,实现主应用以及各子应用之间的样式隔离,确保各自的样式独立运行,互不干扰
工程化手段
既然 CSS 沙箱是用来解决样式冲突的问题,那如果我通过工程化手段确保每个样式选择器名称都是唯一的,这样是不是就不需要 CSS 沙箱了?
使用工程化手段来生成唯一的 CSS 类名,常见解决方案有:
- BEM :不同项目用不同的前缀或命名规则来确保类名唯一性,避免样式冲突,详见 BEM命名规范
- CSS Module :通过构建工具配置(详见 webpack 启用 css-loader)在构建过程中自动生成唯一的类名。对了,vue3 中
<style module>
标签也会被编译为 CSS Module,详见 Vue.js - 单文件组件|CSS功能 - CSS-in-JS : 在 JS 中定义 CSS 样式块,注入到 DOM 中,详见 CSS-in-JS 指南
但是这些方案都存在一些问题:
- 历史包袱:对于老旧项目,尤其是那些未采用现代工程化手段的项目,修改现有代码以支持新的样式管理方案(如 BEM 或 CSS-in-JS)需要大量的重构工作
- 第三方库:即使你确保了自己的样式选择器唯一,第三方库的样式仍可能会导致冲突
显然,工程化手段只能解决一部分问题,在实际应用中,可能需要结合使用工程化手段和 CSS 沙箱,以应对不同的样式管理需求
乾坤沙箱
乾坤目前存在三种 CSS 隔离机制,分别是动态样式隔离、影子DOM沙箱和作用域沙箱
- 动态样式隔离:qiankun 默认开启,可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
- 影子DOM沙箱(Shadow DOM) :手动开启 ,qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
- 作用域沙箱(Scope CSS):手动开启 ,qiankun 会改写子应用所添加的样式,为所有样式规则增加一个特殊的选择器规则来限定其影响范围
你可能想问,开关在呢❓如何手动开启我想要的沙箱机制❓❓❓
在这个 乾坤API - start({ }) 中,有一个可选参数
sandbox
,用于控制是否开启沙箱以及开启哪种沙箱
- true:默认值,开启动态样式隔离
- { strictStyleIsolation: true }:开启影子DOM沙箱
- { experimentalStyleIsolation: true }:开启作用域沙箱
动态样式隔离
乾坤会默认开启此沙箱
可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景子应用之间的样式隔离
实现原理是当子应用被加载时,其对应的样式会被注入到页面中;当子应用被卸载时,qiankun 会自动移除其样式,确保页面的样式环境保持干净
动态样式隔离虽然可以提供很好的隔离效果,但往往存在一些限制条件,所以在现实的使用中基本无法单独满足用户的需求
对于新的子应用,使用动态样式隔离 + 工程化手段两种方案结合的方式,基本能够解决样式冲突的问题
Shadow DOM 沙箱
手动开启,开启代码如下
javascript
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([...]) // 注册子应用
start({
sandbox: { strictStyleIsolation: true } // 开启 Shadow DOM 沙箱
})
这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
Shadow DOM是什么?
Shadow DOM 是 Web Components 技术的一部分,它允许开发者创建一个封闭的 DOM 树,这个 DOM 树的样式和脚本与页面的主 DOM 树是隔离的。通过 Shadow DOM,可以确保子应用的样式和脚本不会影响到主应用或其他子应用,从而避免冲突和干扰
Shadow DOM,可以理解为是存在于 DOM 中的 DOM
记住!影子DOM 是独立存在的 DOM,有自己的作用域集,外部的配置不会影响到内部,内部的配置也不会影响外部
影子 DOM 允许将隐藏的 DOM 树附加到常规 DOM 树中的元素上------这个影子 DOM 始于一个影子根,在其之下你可以用与普通 DOM 相同的方式附加任何元素
这里有一些影子 DOM 术语:
- 影子宿主(Shadow host): 影子 DOM 附加到的常规 DOM 节点
- 影子树(Shadow tree): 影子 DOM 内部的 DOM 树
- 影子边界(Shadow boundary): 影子 DOM 终止,常规 DOM 开始的地方
- 影子根(Shadow root): 影子树的根节点
说了这么多,那如何创建创建影子 DOM ?
我们可以调用宿主上的 attachShadow() 来创建影子 DOM
我们结合 乾坤小demo 实际演示一下,影子DOM到底有什么作用?
ok!我们创建了一个 qiankun 项目,现在主应用和子应用根节点类名相同,都是 .App
,主应用根节点背景色设置为黑色,子应用根节点背景色设置为红色
由于 qiankun 默认的动态样式隔离机制存在缺陷,无法确保主应用和子应用之间的样式隔离,我们发现,子应用污染了主应用的背景色样式
启用 Shadow DOM沙箱隔离机制,Later~,一切正常
实现原理
这里我们实现一下 Shadow DOM 沙箱机制的核心逻辑,对应乾坤的源代码在createElement
方法,可以看这里 - Shadow DOM沙箱源代码
其原理也很简单,就是将子应用模板包裹在 Shadow DOM 中,使其形成一个独立的样式作用域,确保其样式隔离
html
<body>
<div id="root">qiankun 是一个基于 single-spa 的微前端实现库</div>
<script>
// 子应用的模版字符串
const template = `<div id="qiankun-xxx">
<div id="app">Shadow DOM 沙箱</div>
<style>div{color:red}</style>
</div>`
function createElement(appContent) {
const containerElement = document.createElement('div')
containerElement.innerHTML = appContent
const appElement = containerElement.firstChild // 影子宿主(template模版字符串转换成了真实的dom)
const shadow = appElement.attachShadow({ // 影子DOM(调用宿主上的 attachShadow() 来创建影子 DOM)
mode: 'open',
})
shadow.innerHTML = appElement.innerHTML // 给Shadow DOM附加宿主节点下的内容
appElement.innerHTML = ''
return appElement
}
document.body.appendChild(createElement(template))
</script>
</body>
虽然 Shadow DOM 是一个强大的技术 ,但在某些场景下,它并不是一个完美的解决方案
比如,越界的 DOM 操作 ,在实际应用中,子应用可能会有操作主文档 DOM 的需求,比如动态地向主文档document
添加全局组件、弹窗等。这些操作会创建 Shadow DOM 之外的元素,Shadow DOM 的内部样式也就无法对这些元素生效
基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来(比如 react 场景下需要解决这些 问题,使用者需要清楚开启了
strictStyleIsolation
意味着什么 - 摘抄自 qiankun 文档
Scope CSS (Scoped CSS)
手动开启,开启代码如下
javascript
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([...]) // 注册子应用
start({
sandbox: { experimentalStyleIsolation: true } // 开启作用域沙箱
})
这是 qiankun 一个实验性的样式隔离特性,它的核心思想是通过给子应用中的所有样式选择器添加一个唯一的前缀选择 div[data-qiankun="xxx"]
,来限制这些样式的作用范围
对于一个选择器,如果需要限制它的作用范围,可以使用组合选择器的方式。在当前选择器A前面加一个选择器B,使得选择器A只作用在选择器B内部的节点
改写后的代码会表达为如下结构
javascript
// 假设 registerMicroApps 方法注册的子应用 name 是 react16
.app-main {
font-size: 14px;
}
// 改写后
div[data-qiankun="react16"] .app-main {
font-size: 14px;
}
实现原理
提取和解析样式 :当一个子应用被加载时,qiankun 会提取子应用中的所有 <style>
标签内嵌样式和 <link>
标签引入的外部样式,并对其进行解析,获取所有的 CSS 规则
重写样式规则 :qiankun 给每个子应用的包裹容器新增唯一标识符 data-qiankun
属性,值为通过 registerMicroApps
API 注册子应用的 name
;然后修改子应用的样式选择器,添加前缀选择器 div[data-qiankun="xxx"]
,重写选择器
由于作用域沙箱不能直接修改
link
标签引入的外部样式,所以会把link
外部样式转化为style
内嵌样式,再给其添加前缀
对应乾坤源代码的入口是createElement
方法,可以看这里 - Scope CSS沙箱源代码
javascript
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appName: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild as HTMLElement;
/**
* CSS样式冲突的处理方式
* 1. shadowDOM
* 2. scoped CSS
*/
if (strictStyleIsolation) {
// ... shadowDOM 沙箱逻辑
}
if (scopedCSS) {
// 常量 css.QiankunCSSRewriteAttr = 'data-qiankun'
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
// 给子应用的包裹容器新增 data-qiankun 属性,值为通过 registerMicroApps 注册子应用的 name
appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
}
// 遍历子应用的所有样式,修改其样式选择器,添加前缀选择器 div[data-qiankun="xxx"]
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appName);
});
}
return appElement;
}
不足的话,应该是解析子应用的 style 样式,并为每个选择器添加前缀。这一过程在子应用的加载和渲染时会增加额外的计算开销,尤其是在样式表很大或者包含大量选择器的情况下,可能会影响页面的初始加载性能
沙箱方案
实际的工作中,选择合适的沙箱方案需要根据具体的场景和需求来决定。 以下是一些常见的场景及其对应的沙箱选择
单实例模式
单实例模式指的是一次仅加载一个子应用的场景,这种模式下子应用之间不会并发运行,避免了同时多个应用运行导致的冲突
在这种模式下,**动态样式隔离+ 工程化手段(如 BEM 命名规范、CSS Modules)**通常就能满足大部分需求。因为在单实例模式中,不需要担心子应用之间的样式和脚本冲突问题。
多实例模式
在多实例模式下,多个子应用可能同时加载和运行,子应用之间的样式和脚本容易产生冲突
在这种模式下,需要更强的隔离性。可以使用 作用域沙箱(Scoped CSS Sandbox)+ Shadow DOM 沙箱 的组合
参考文档
GitHub - careyke/frontend_knowledge_structure: qiankun中CSS沙箱的实现