Qiankun实践——实现一个CSS沙箱

5,464 阅读3分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

哈喽,大家好,我是海怪。

上篇文章讲了如何实现一个 Qiankun 的 JS 沙箱(实际应该是 3 个,哈哈),那这篇文章就带大家来实现一下 CSS 的沙箱。

Qiankun 的 CSS 沙箱原理上并不难,但与 JS 沙箱不同的是,它的源码比较分散,阅读起来要跳转好几个地方,有点麻烦。因此,这篇文章同样也会精简整个实现过程,尽量让读者读起来不费劲。

文章中的源码都放在我的 这个仓库 mini-css-sandbox 里,需要的自行提取即可。废话不多说,那现在就让我们开始吧~

准备工作

首先,我们来做一些准备工作,分别添加以下文件:

  • index.html:入口 HTML
  • shadowDOMIsolation.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,需要的自行提取即可。如果你喜欢我的分享,可以来一波一键三连,点赞、在看就是我最大的动力,比心 ❤️