Qiankun实践——实现一个CSS沙箱
前言
哈喽,大家好,我是海怪。
上篇文章讲了如何实现一个 Qiankun 的 JS 沙箱(实际应该是 3 个,哈哈),那这篇文章就带大家来实现一下 CSS 的沙箱。
Qiankun 的 CSS 沙箱原理上并不难,但与 JS 沙箱不同的是,它的源码比较分散,阅读起来要跳转好几个地方,有点麻烦。因此,这篇文章同样也会精简整个实现过程,尽量让读者读起来不费劲。
文章中的源码都放在我的 这个仓库 mini-css-sandbox 里,需要的自行提取即可。废话不多说,那现在就让我们开始吧~
准备工作
首先,我们来做一些准备工作,分别添加以下文件:
index.html
:入口 HTMLshadowDOMIsolation.js
:Shadow DOM 沙箱实现scopedCSSIsolation.js
:Scoped CSS 沙箱实现
因为样式是否成功隔离可以通过肉眼去看,这里就不用 TDD 方式来做测试了,文章也会更精简一些。
在 index.html
里添加:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>样式隔离沙箱</title>
<style>
p {
color: red;
}
</style>
</head>
<body>
<h1>Shadow DOM 隔离</h1>
<div id="shadow-dom">
<p>Shadow DOM 隔离</p>
</div>
<h1>Scoped CSS 隔离</h1>
<div id="scoped-css">
<p>Scoped CSS 隔离</p>
</div>
<p>外部文本</p>
<script src="scopedCSSIsolation.js"></script>
<script src="shadowDOMIsolation.js"></script>
</body>
</html>
这个 HTML 里有一个全局的 <style>
,里面有全局样式,会将 <p>
的颜色变成红色,剩下的都是一些测试要用的 HTML 结构。
Shadow DOM 沙箱
我们先来实现 Shadow DOM 沙箱,它对应 Qiankun 样式隔离的严格模式。
原理
在开始写代码前,我们来简单了解一下 Shadow DOM 原理。
Shadow DOM 可以将一个隐藏的、独立的 DOM 附加到一个元素上,一般来说是微应用的容器 <div>
上。
其中:
- Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
- Shadow tree:Shadow DOM 内部的 DOM 树。
- Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。
- Shadow root: Shadow tree 的根节点。
这并不是什么新技术,我们常见的 <video>
和 <audio>
用的就是 Shadow DOM,浏览器把一些相关逻辑封装和内部结构封装在里面。外部看来就是一个 <video>
但里面包含着对应的按钮、轨道、滚动条等结构。
实现
假如我们把微应用的内容用 Shadow DOM 封装起来,比如把 <style>
挂截到 Shadow Tree 上,那么就可以实现样式的硬隔离了。
假如有下面的微应用,里面有一个 <style>
会把 <p>
字体颜色改成紫色:
const shadowDOMSection = document.querySelector('#shadow-dom');
const appElement = shadowDOMIsolation(`
<div class="wrapper">
<style>p { color: purple }</style>
<p>内部文本</p>
</div>
`);
shadowDOMSection.appendChild(appElement);
我们现在要做的就是把 div.wrapper
与外部隔离,不要让里面的 <style>
影响到外部的 <p>
。按照刚刚对 Shadow DOM 的理解,我们来实现一下:
function shadowDOMIsolation(contentHtmlString) {
// 清理 HTML
contentHtmlString = contentHtmlString.trim();
// 创建一个容器 div
const containerElement = document.createElement('div');
// 生成内容 HTML 结构
containerElement.innerHTML = contentHtmlString; // content 最高层级必需只有一个 div 元素
// 获取根 div 元素
const appElement = containerElement.firstChild;
const { innerHTML } = appElement;
// 清空内容,以便后面绑定 shadow DOM
appElement.innerHTML = '';
let shadow;
if (appElement.attachShadow) {
// 兼容性更广的写法
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 旧写法
shadow = appElement.createShadowRoot();
}
// 生成 shadow DOM 的内容
shadow.innerHTML = innerHTML;
return appElement;
}
可以看到 Shadow DOM 沙箱实现还是比较简单的,主要做了几件事:
- 把当前元素的内容拿出来
- 生成
shadowDOM
- 再刚刚的内容放入这个 shadow DOM
- 清除这个元素,并追加 shadow DOM 即可
最终效果如下:
会发现外部文本依然是红色,不会受微应用的样式影响。
Scoped CSS 沙箱
接下来讲讲 Scoped CSS 沙箱,它对应的是 Qiankun 样式隔离的实验性模式。
原理
Scoped CSS 沙箱的原理更简单:将微应用里的 <style>
的文本提取出来,将所有的选择器进行转换:
普通选择器 -> 微应用容器选择器 普通选择器
例如:
span -> div[data-app-name=我的微应用] span
这样 span
的样式只会作用在 div[data-app-name=我的微应用]
元素,而不会跑到外面了。
实现
原理很简单,但实现起来还是有些复杂的。其中比较绕的一个点就是获取 CSS 文本:
function processCSS(appElement, stylesheetElement, appName) {
// 生成 CSS 选择器:div[data-app-name=微应用名字]
const prefix = `${appElement.tagName.toLowerCase()}[data-app-name="${appName}"]`;
// 生成临时 <style> 节点
const tempNode = document.createElement('style');
document.body.appendChild(tempNode);
tempNode.sheet.disabled = true
if (stylesheetElement.textContent !== '') {
// 将原来的 CSS 文本复制一份到临时 <style> 上
const textNode = document.createTextNode(stylesheetElement.textContent || '');
tempNode.appendChild(textNode);
// 获取 CSS 规则
const sheet = tempNode.sheet;
const rules = [...sheet?.cssRules ?? []];
// 生成新的 CSS 文本
stylesheetElement.textContent = this.rewrite(rules, prefix);
// 清理
tempNode.removeChild(textNode);
}
}
function scopedCSSIsolation(appName, contentHtmlString) {
// 清理 HTML
contentHtmlString = contentHtmlString.trim();
// 创建一个容器 div
const containerElement = document.createElement('div');
// 生成内容 HTML 结构
containerElement.innerHTML = contentHtmlString; // content 最高层级必需只有一个 div 元素
// 获取根 div 元素
const appElement = containerElement.firstChild;
// 打上 data-app-name=appName 的标记
appElement.setAttribute('data-app-name', appName);
// 获取所有 <style></style> 元素内容,并将它们做替换
const styleNodes = appElement.querySelectorAll('style') || [];
[...styleNodes].forEach((stylesheetElement) => {
processCSS(appElement, stylesheetElement, appName);
})
return appElement;
}
由于我们要改成 div[data-app-name=我的微应用] span
,所以第一个参数为应用名,以此作区分。接下来是对 CSS 文进行替换了,这部分 Qiankun 用了好几 replace
,属实有点绕,我这里就精简成对最常见的情况 p { color: blue }
进行替换:
// 多种规则
const RuleType = {
STYLE: 1,
MEDIA: 4,
SUPPORTS: 12,
}
function ruleStyle(rule, prefix) {
//匹配 p {..., a { ..., span {... 这类字符串
return rule.cssText.replace(/^[\s\S]+{/, (selectors) => {
// 匹配 div,body,span {... 这类字符串
return selectors.replace(/(^|,\n?)([^,]+)/g, (selector, _, matchedString) => {
// 将 p { => div[data-app-name=微应用名] p {
return `${prefix} ${matchedString.replace(/^ */, '')}`;
})
});
}
function rewrite(rules, prefix) {
let css = '';
rules.forEach((rule) => {
switch (rule.type) {
case RuleType.STYLE:
css += ruleStyle(rule, prefix);
break;
// case RuleType.MEDIA:
// css += this.ruleMedia(rule, prefix);
// break;
// case RuleType.SUPPORTS:
// css += this.ruleSupport(rule, prefix);
// break;
default:
css += `${rule.cssText}`;
break;
}
});
return css;
}
可以看到除了 STYLE
规则,还有媒体以及兼容性的规则,这些 Qiankun 都有对于的正则匹配去改写 CSS,这里只关注 STYLE 就可以了。当然,这部分基本就是正则的替换,我认为不需要花太多时间纠结正则表达式是怎么写的,只要理解整体思路就好。
同样写一个用例测试一下:
const scopedCSSSection = document.querySelector('#scoped-css');
const wrappedScopedCSSAppElement = scopedCSSIsolation('MyApp', `
<div class="wrapper">
<style>p { color: blue }</style>
<p>Scoped CSS Isolation</p>
</div>
`);
scopedCSSSection.appendChild(wrappedScopedCSSAppElement);
效果如下:
可以看到,外部文本依然为红色,而内部文本为蓝色。打开控制台也可以到对应的 CSS 选择器已做了改写:
变成 Web Component
问题
可以发现上面的代码有一些重复:每次都要获取 container 元素,写好 HTML,最后再 appendChild
追加 appElement
。有重复,我们就应该用一个函数去封装好它,这才是良好的写代码习惯。
通常来说写个函数包装一下就好了,Qiankun 也是如此。不过,这里我想跳脱 Qiankun 微前端的范畴,我希望不要自己手写 htmlString
,而是可以这样去使用:
<h1>Shadow DOM 隔离</h1>
<isolation-content data-app-name="Sub1" data-isolation-mode="shadowDOM">
<style>p { color: purple }</style>
<p>Shadow DOM Isolation</p>
</isolation-content>
<h1>Scoped CSS 隔离</h1>
<isolation-content data-app-name="Sub2" data-isolation-mode="scopedCSS">
<style>p { color: blue }</style>
<p>Scoped CSS Isolation</p>
</isolation-content>
<p>外部文本</p>
这其实就是 Web Component 了,其它微应用框架 single-spa 周边库 和京东的 MicroApp 也用到同样的技术。
实现
Web Componet 不多介绍,具体可以看我的这篇 《秒懂 Web Component》。按 Web Component 的理念来实现一下:
class Isolation extends HTMLElement {
constructor() {
super();
const name = this.getAttribute('data-app-name');
const mode = this.getAttribute('data-isolation-mode');
const html = `<div class="wrapper">${this.innerHTML.trim()}</div>`;
// 根据隔离模式来生成对应的 appElement
const appElement = mode === 'shadowDOM' ? shadowDOMIsolation(html) : scopedCSSIsolation(name, html);
// 清除内容
this.innerHTML = '';
// 再追加包裹的内容
this.appendChild(appElement);
}
}
customElements.define('isolation-content', Isolation)
还要记得在 index.html
里引入这个文件:
<script src="scopedCSSIsolation.js"></script>
<script src="shadowDOMIsolation.js"></script>
<script src="Isolation.js"></script>
最终的效果如下:
总结
最后我们来总结一下这篇实践:
- Qiankun 的样式隔离主要分为 Shadow DOM 隔离 以及 Scoped CSS 隔离 两种
- Shadow DOM 隔离主要利用了 Shadow DOM 硬隔离的特点来做样式隔离
- Scoped CSS 则是对
style
元素的 CSS 文本进行处理,在原有选择器上添加下个父类选择器,以此做样式隔离
这篇文章的代码都放在 这个仓库 mini-css-sandbox,需要的自行提取即可。如果你喜欢我的分享,可以来一波一键三连,点赞、在看就是我最大的动力,比心 ❤️
转载自:https://juejin.cn/post/7153140440777097224