Skip to content

第5章 CSS 隔离与资源加载

"JavaScript 沙箱防止的是逻辑污染,CSS 隔离防止的是视觉坍塌——后者往往更难调试,因为它不会抛出任何异常,只是默默地让你的页面面目全非。"

本章要点

  • 深入理解 CSS 隔离的三种核心策略:Shadow DOM、Scoped CSS、Dynamic Stylesheet,掌握每种方案的实现原理与边界条件
  • 从源码层面剖析 import-html-entry 如何将一个 HTML 文件解析为 Scripts、Styles、Template 三部分
  • 理解子应用资源预加载策略的设计哲学与实现细节
  • 掌握资源加载失败时的容错与重试机制,构建生产级别的健壮性

还记得前言中那个凌晨三点的故事吗?一个 .container { margin: 0 auto } 穿透了沙箱,导致全站白屏。那次事故的根因不在 JavaScript 隔离——JS 沙箱工作得很好。问题出在 CSS。

CSS 的全局性是 Web 平台最古老的设计决策之一。在单体应用中,这个问题通过 BEM 命名约定、CSS Modules、CSS-in-JS 等方案已经被充分驯化。但在微前端场景下,情况完全不同——你无法要求所有子应用统一使用同一种样式方案,你甚至无法确保不同团队不会使用相同的 class 名。CSS 隔离不是锦上添花,它是微前端架构的生存底线。

上一章我们深入剖析了 JS 沙箱的三种实现(SnapshotSandbox、LegacySandbox、ProxySandbox)。本章的主角是 CSS 隔离——同样重要,但实现路径截然不同。JS 隔离的核心武器是 Proxy,CSS 隔离的核心武器却分裂成了三条路线,每条路线都有自己的优势与致命缺陷。

我们还将深入 import-html-entry 的源码——这是乾坤资源加载的基石。理解它如何解析 HTML、提取样式和脚本,是理解整个乾坤资源管理体系的前提。

让我们开始。

5.1 CSS 隔离三策略:Shadow DOM、Scoped CSS、Dynamic Stylesheet

5.1.1 问题的本质

CSS 隔离需要解决的核心问题只有一个:如何让子应用的样式只作用于子应用自身的 DOM,不影响主应用和其他子应用?

这个问题可以被分解为两个方向:

  1. 子应用的样式不泄漏出去(outward isolation)——子应用定义的 .container 不应该影响主应用的 .container
  2. 外部的样式不渗透进来(inward isolation)——主应用的全局 reset 样式不应该破坏子应用的内部布局
typescript
// CSS 隔离的两个方向
interface CSSIsolation {
  outwardIsolation: boolean;  // 子应用样式不泄漏
  inwardIsolation: boolean;   // 外部样式不渗透
}

// 三种策略的隔离能力对比
const strategies: Record<string, CSSIsolation> = {
  shadowDOM:         { outwardIsolation: true,  inwardIsolation: true },
  scopedCSS:         { outwardIsolation: true,  inwardIsolation: false },
  dynamicStylesheet: { outwardIsolation: true,  inwardIsolation: false },
};
// Shadow DOM 是唯一能同时做到双向隔离的方案
// 但它的代价也最大——这就是架构权衡的经典案例

下图展示了三种 CSS 隔离策略的架构对比与隔离能力差异:

乾坤提供了两个配置项来控制 CSS 隔离策略:

typescript
// 乾坤的 CSS 隔离配置
registerMicroApps([
  {
    name: 'sub-app',
    entry: '//localhost:7100',
    container: '#container',
    activeRule: '/sub-app',
  },
], {
  // 方式一:严格隔离 —— 使用 Shadow DOM
  // 对应源码中的 strictStyleIsolation
  sandbox: {
    strictStyleIsolation: true,
  },

  // 方式二:实验性隔离 —— 使用 Scoped CSS
  // 对应源码中的 experimentalStyleIsolation
  sandbox: {
    experimentalStyleIsolation: true,
  },
});
// 两者不能同时开启
// 如果都不开启,则使用 Dynamic Stylesheet(默认策略)

5.1.2 策略一:Shadow DOM(strictStyleIsolation)

Shadow DOM 是 Web Components 标准的一部分,它提供了浏览器原生的 DOM 和样式隔离能力。乾坤的 strictStyleIsolation 选项正是利用了这个能力。

原理:将子应用的整个 DOM 树包裹在一个 Shadow DOM 中。Shadow DOM 内部的样式天然不会泄漏到外部,外部的样式也无法渗透进来(除了可继承的 CSS 属性)。

来看乾坤源码中 strictStyleIsolation 的实现:

typescript
// qiankun/src/loader.ts(简化)
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  const appElement = containerElement.firstChild as HTMLElement;

  if (strictStyleIsolation) {
    // 核心:如果开启了严格样式隔离
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: strictStyleIsolation is not supported in this browser.'
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        // 创建 Shadow DOM
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // 兼容旧版 API
        shadow = (appElement as any).createShadowRoot();
      }
      // 将子应用的 HTML 内容放入 Shadow DOM 中
      shadow.innerHTML = innerHTML;
    }
  }

  // ... scopedCSS 的处理逻辑(见下一节)

  return appElement;
}

这段代码的关键步骤是:

  1. 创建一个容器 div,将子应用的 HTML 内容放入
  2. 调用 attachShadow({ mode: 'open' }) 创建 Shadow Root
  3. 将原本的 innerHTML 移入 Shadow Root

这样,子应用的所有 DOM 节点和样式都运行在 Shadow DOM 内部,与外界天然隔离。

html
<!-- 隔离后的 DOM 结构 -->
<div id="__qiankun_microapp_wrapper_for_sub_app__">
  #shadow-root (open)
    <div id="sub-app-container">
      <style>
        .container { margin: 0 auto; }
        /* 这个样式被锁在 Shadow DOM 内部 */
        /* 外面的 .container 完全不受影响 */
      </style>
      <div class="container">子应用内容</div>
    </div>
</div>

Shadow DOM 的致命问题

虽然 Shadow DOM 提供了最强的隔离能力,但它在微前端场景下有三个严重的实际问题:

typescript
// 问题一:弹窗类组件无法正常工作
// Ant Design 的 Modal、Dropdown、Tooltip 等组件默认将 DOM 挂载到 document.body
// 在 Shadow DOM 中,这意味着弹窗直接脱离了 Shadow DOM 的样式作用域
const Modal = () => {
  return ReactDOM.createPortal(
    <div className="ant-modal">...</div>,
    document.body  // 挂载到 body —— 逃逸了 Shadow DOM!
  );
};
// 结果:弹窗没有样式,因为样式还锁在 Shadow DOM 里面

// 问题二:事件冒泡被截断
// Shadow DOM 内部触发的事件在冒泡到 Shadow Root 时会被重新 target
// React 的合成事件系统依赖事件冒泡到 document
// 这可能导致 React 事件处理器失效
document.addEventListener('click', (e) => {
  console.log(e.target);
  // 在 Shadow DOM 中,e.target 会被重定向为 Shadow Host
  // 而不是真正被点击的元素
});

// 问题三:CSS 继承属性穿透
// font-family, color, line-height 等可继承属性
// 仍然会从 Shadow Host 的父元素继承进 Shadow DOM
// 这不是完全的"零渗透"

🔥 深度洞察:Shadow DOM 的理想与现实

Shadow DOM 是浏览器原生提供的隔离方案,从理论上看它应该是 CSS 隔离的完美解——零运行时开销、双向隔离、标准化。但微前端场景远比 Web Components 场景复杂。Web Components 是从一开始就为 Shadow DOM 设计的,而微前端中的子应用是已有的完整应用,它们从未考虑过在 Shadow DOM 中运行。弹窗组件挂载到 body、样式通过 document.head 插入、事件冒泡到 document——这些都是 Web 应用几十年来形成的"隐含假设"。Shadow DOM 打破了这些假设,所以在生产环境中,strictStyleIsolation 的采用率远低于预期。这是一个深刻的教训:最强的隔离不一定是最好的隔离——适配性才是生产环境的第一优先级。

5.1.3 策略二:Scoped CSS(experimentalStyleIsolation)

乾坤的第二种策略是 experimentalStyleIsolation——"实验性样式隔离"。注意名字中的"实验性"——这表明即使乾坤团队自己也认为这个方案还不完美。

原理:给子应用的所有 CSS 选择器添加一个特定的属性选择器前缀,使样式只匹配带有该属性的 DOM 元素。子应用的根节点会被添加这个属性,从而实现样式的作用域限定。

css
/* 原始 CSS */
.container { margin: 0 auto; }
h1 { color: red; }
body { font-size: 14px; }

/* 转换后的 CSS(Scoped) */
div[data-qiankun="sub-app"] .container { margin: 0 auto; }
div[data-qiankun="sub-app"] h1 { color: red; }
div[data-qiankun="sub-app"] body { font-size: 14px; }

来看核心源码。乾坤的 Scoped CSS 处理逻辑在 css.ts 文件中:

typescript
// qiankun/src/sandbox/patchers/css.ts(简化)

const ScopedCSS = {
  process(styleNode: HTMLStyleElement, prefix: string) {
    // prefix 例如:div[data-qiankun="sub-app"]
    if (styleNode.textContent !== '') {
      const rewritten = this.rewrite(styleNode.textContent, prefix);
      styleNode.textContent = rewritten;
    }
  },

  rewrite(css: string, prefix: string): string {
    // 使用 CSSStyleSheet API 解析样式规则
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(css);
    let result = '';
    for (const rule of Array.from(sheet.cssRules)) {
      result += this.ruleStyle(rule as CSSStyleRule, prefix);
    }
    return result;
  },

  ruleStyle(rule: CSSStyleRule, prefix: string): string {
    // @media / @supports 规则:递归处理内部规则
    if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {
      let inner = '';
      for (const r of Array.from(rule.cssRules)) {
        inner += this.ruleStyle(r as CSSStyleRule, prefix);
      }
      return `@media ${(rule as CSSMediaRule).conditionText} { ${inner} }`;
    }

    // 普通样式规则:改写选择器
    const newSelector = rule.selectorText
      .split(',')
      .map((sel) => {
        sel = sel.trim();
        // :root → 替换为前缀
        if (/(^|\s+):root/.test(sel)) return sel.replace(/:root/, prefix);
        // body / html → 替换为前缀
        if (sel === 'body' || sel === 'html') return prefix;
        // 一般选择器:添加前缀
        return `${prefix} ${sel}`;
      })
      .join(', ');

    return `${newSelector} { ${rule.style.cssText} }`;
  },
};

然后在创建子应用元素时,会为根节点添加对应的属性:

typescript
// qiankun/src/loader.ts(简化)
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  // ...

  if (scopedCSS) {
    // 给子应用根节点添加 data-qiankun 属性
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }

    // 处理已有的 style 标签
    const styleNodes = appElement.querySelectorAll('style') || [];
    styleNodes.forEach((stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }

  return appElement;
}

同时,JS 沙箱还需要拦截子应用动态创建的 <style> 标签。乾坤通过 MutationObserver 监听新插入的 style 元素,一旦检测到内容变化就自动执行 ScopedCSS.process 进行选择器改写——确保运行时动态添加的样式同样受到隔离保护。

下图展示了 Scoped CSS 选择器改写的完整处理时序,包括静态样式和动态样式两条路径:

基于 VitePress 构建