微前端学习 - CSS 隔离篇

327 阅读2分钟

往期阅读

微前端学习 - window 隔离篇

摘要

承接上问 window 隔离篇,本文将从 CSS 样式隔离上分析一下 qiankun 的实现逻辑。

css 隔离方案

Web Components

通过创建 影子 dom,与主文档隔离。影子 DOM(Shadow DOM)允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的。

css 选择器

通过添加属性选择器,精选约束,类似 vue 中的 scoped css。

qiankun 中的实现

qiankun 中提供了 sandbox.strictStyleIsolationsandbox.experimentalStyleIsolation 分别开启 shadow dom 或者 scoped css,scoped css 与 shadow dom 是互斥的,所以在不支持 shadow dom 中,即使把两个打开了也只会生效一个。受如下代码影响。

export function isEnableScopedCSS(sandbox: FrameworkConfiguration['sandbox']) {
  if (typeof sandbox !== 'object') {
    return false;
  }

  if (sandbox.strictStyleIsolation) {
    return false;
  }

  return !!sandbox.experimentalStyleIsolation;
}

在 qiankun 中,在 loadApp 时,会使用 import-html-entry 进行子应用的资源获取解析等,这一部分呢,就先忽略吧,预计会放在后面流程串联中介绍。本次就关心 css 隔离的内部实现细节吧。

shadow dom

  1. loadApp

在 loadApp 调用中,importHtmlentry 会返回对应的 template 以及 assetPublicPath

VD6TDDCM9QP.png

  1. 根据 template 进行 shadow dom 的包裹

源码中创建空的 div 元素,并设置了对应模板为子元素,但实际上,这个 containerElement 并没有别的用处了。。。。。这里我理解只是单纯地将 string 转变为 dom 元素对象。

MEJS9DP_FIIJ4A.png

appContent 为对应 app 的渲染模板内容

S3~0T1O2BMJ.png

将 appContent 的子元素作为 shadow dom 的元素插入

4ZCOSW09B649TJ0QU3FMV8E.png

可见渲染的元素模板中,会将子应用的 head 标签给替换为 qiankun-head 同时会添加一些属性。

export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) {
  return (tpl: string) => {
    let tplWithSimulatedHead: string;

    if (tpl.indexOf('<head>') !== -1) {
      // We need to mock a head placeholder as native head element will be erased by browser in micro app
      tplWithSimulatedHead = tpl
        .replace('<head>', `<${qiankunHeadTagName}>`)
        .replace('</head>', `</${qiankunHeadTagName}>`);
    } else {
      // Some template might not be a standard html document, thus we need to add a simulated head tag for them
      tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
    }

    return `<div id="${getWrapperId(
      name,
    )}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
      sandboxOpts,
    )}>${tplWithSimulatedHead}</div>`;
  };
}

export function getWrapperId(name: string) {
  return `__qiankun_microapp_wrapper_for_${snakeCase(name)}__`;
}

至于 shadow dom 里面的样式如何生成的呢,这里就是 import-entry-html 中返回 execScripts 所做的了,会绑定对应的 global 进行 js 沙箱隔离地执行模板的脚本文件。shadow dom 的方案相对来说会更加简单,高效。

scoped css

scoped css 方案会在对应的子应用中添加 scoped 属性进行 css 隔离,类似于 vue 的 scoped css。

在渲染的子应用模板添加 data-qiankun 属性

2YEEF44.png

改写原有的 css 样式表规则

awddOU5.png

  1. 对于 css 样式的属性设置上,qiankun 会改写原有的标签元素添加方法
  • HTMLHeadElement.prototype.appendChild
  • HTMLBodyElement.prototype.appendChild
  • HTMLHeadElement.prototype.insertBefore
export function patchHTMLDynamicAppendPrototypeFunctions(
  isInvokedByMicroApp: (element: HTMLElement) => boolean,
  containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
  const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
  const rawBodyAppendChild = HTMLBodyElement.prototype.appendChild;
  const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;

  // Just overwrite it while it have not been overwritten
  if (
    rawHeadAppendChild[overwrittenSymbol] !== true &&
    rawBodyAppendChild[overwrittenSymbol] !== true &&
    rawHeadInsertBefore[overwrittenSymbol] !== true
  ) {
    HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadAppendChild;
    HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawBodyAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'body',
    }) as typeof rawBodyAppendChild;

    HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadInsertBefore;
  }

  const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
  const rawBodyRemoveChild = HTMLBodyElement.prototype.removeChild;
  // Just overwrite it while it have not been overwritten
  if (rawHeadRemoveChild[overwrittenSymbol] !== true && rawBodyRemoveChild[overwrittenSymbol] !== true) {
    HTMLHeadElement.prototype.removeChild = getNewRemoveChild(
      rawHeadRemoveChild,
      containerConfigGetter,
      'head',
      isInvokedByMicroApp,
    );
    HTMLBodyElement.prototype.removeChild = getNewRemoveChild(
      rawBodyRemoveChild,
      containerConfigGetter,
      'body',
      isInvokedByMicroApp,
    );
  }

  return function unpatch() {
    HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
    HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
    HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
    HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;

    HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
  };
}
  1. 对于 style node,使用 MutationObserver 进行监听,改写 css rule
  process(styleNode: HTMLStyleElement, prefix: string = '') {
    console.log('>>>> 【scoped css】process func params', styleNode, prefix, 'textContent:', styleNode.textContent);
    if (ScopedCSS.ModifiedTag in styleNode) {
      return;
    }

    if (styleNode.textContent !== '') {
      const textNode = document.createTextNode(styleNode.textContent || '');
      this.swapNode.appendChild(textNode);
      const sheet = this.swapNode.sheet as any; // type is missing
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      console.log('>>>> 【scoped css】rules', rules);
      const css = this.rewrite(rules, prefix);
      // eslint-disable-next-line no-param-reassign
      console.log('>>>> 【scoped css】rewrited css', css);
      styleNode.textContent = css;

      // cleanup
      this.swapNode.removeChild(textNode);
      (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      return;
    }

    const mutator = new MutationObserver((mutations) => {
      for (let i = 0; i < mutations.length; i += 1) {
        const mutation = mutations[i];

        if (ScopedCSS.ModifiedTag in styleNode) {
          return;
        }

        if (mutation.type === 'childList') {
          const sheet = styleNode.sheet as any;
          const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
          console.log('>>>> 【scoped css】mutation rules', rules);
          const css = this.rewrite(rules, prefix);
          console.log('>>>> 【scoped css】mutation rewrited css', css);

          // eslint-disable-next-line no-param-reassign
          styleNode.textContent = css;
          // eslint-disable-next-line no-param-reassign
          (styleNode as any)[ScopedCSS.ModifiedTag] = true;
        }
      }
    });

    // since observer will be deleted when node be removed
    // we dont need create a cleanup function manually
    // see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/disconnect
    mutator.observe(styleNode, { childList: true });
  }
  1. css rule 改写操作如下
  private ruleStyle(rule: CSSStyleRule, prefix: string) {
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    const selector = rule.selectorText.trim();

    let cssText = '';
    if (typeof rule.cssText === 'string') {
      cssText = rule.cssText;
    }

    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        if (rootSelectorRE.test(item)) {
          return item.replace(rootSelectorRE, (m) => {
            // do not discard valid previous character, such as body,html or *:not(:root)
            const whitePrevChars = [',', '('];

            if (m && whitePrevChars.includes(m[0])) {
              return `${m[0]}${prefix}`;
            }

            // replace root selector with prefix
            return prefix;
          });
        }

        return `${p}${prefix} ${s.replace(/^ */, '')}`;
      }),
    );

    return cssText;
  }

改写的操作是为对应的 cssRule 选择器添加 div[data-qiankun='appName']

image.png

综合比较

  1. 对于 web component 的 shadow dom 方案来将,操作比较简单,且能够隔离父应用的样式影响。
  2. 对于 scoped css 方案,这里看下来感觉不是那么完美,对于 css 较多的情况下有可能会对性能有影响,因为还是用的 css 选择器,所以父应用的使用 标签选择器的样式修改也会影响到子应用。
  3. scoped css 对于 antd 挂载到 父应用 dom 元素中时,样式可能会丢失。 M8%~4261CDHE.png

R_IIK_LPOKK@Q6DWF{@5N1L.png