微前端源码精讲

第5章 CSS 隔离与资源加载

作者 杨艺韬 · 14,895 字 · 发布于 · 更新于

第5章 CSS 隔离与资源加载

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

本章要点

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

本章引用的 qiankun 源码以 v2.10.16 为准、import-html-entry 源码以 v1.17.0 为准,其他版本的文件路径与实现细节可能对不上。


还记得前言开头那段 .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

在开始看具体方案之前、我想先帮你建立一个"比较的坐标系"。任何隔离技术、都在"隔离强度"和"使用便捷性"这两个维度上做权衡——隔离强度高、通常意味着对使用者有更多约束(比如挂到 body 上的弹窗组件拿不到样式);隔离强度低、通常意味着隔离不彻底(某些情况下仍会冲突)。没有免费的隔离、只有根据场景选择合适的妥协点。这一节的三种策略、刚好构成了这个权衡坐标系上的三个关键节点(下面的分数是作者的主观打分,只用来示意相对位置)——Shadow DOM 在"隔离强度"维度打满(10 分)、但"使用便捷性"扣到 3 分;Scoped CSS 在两个维度上都能拿 7 分;Dynamic Stylesheet 在"隔离强度"只有 4 分、但"使用便捷性"能拿 9 分。作为工程师、我们的任务不是"选最强的那个"、而是"选当前场景下权衡最合理的那个"。

5.1.1 问题的本质

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

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

  1. 子应用的样式不泄漏出去(outward isolation)——子应用定义的 .container 不应该影响主应用的 .container
  2. 外部的样式不渗透进来(inward isolation)——主应用的全局 reset 样式不应该破坏子应用的内部布局
// 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 隔离策略的架构对比与隔离能力差异:

flowchart TB
    Config["乾坤 CSS 隔离配置"] --> Strict["strictStyleIsolation: true"]
    Config --> Experimental["experimentalStyleIsolation: true"]
    Config --> Default["均不开启 (默认)"]
    Strict --> ShadowDOM["Shadow DOM 策略"]
    Experimental --> ScopedCSS["Scoped CSS 策略"]
    Default --> DynamicSS["Dynamic Stylesheet 策略"]
    ShadowDOM --> |"向外隔离"| OutYes1["完美"]
    ShadowDOM --> |"向内隔离"| InYes["完美"]
    ShadowDOM --> |"弹窗兼容"| PopNo["差 -- 弹窗逃逸 Shadow DOM"]
    ScopedCSS --> |"向外隔离"| OutYes2["良好"]
    ScopedCSS --> |"向内隔离"| InNo1["无"]
    ScopedCSS --> |"多应用并存"| MultiYes["支持"]
    DynamicSS --> |"向外隔离"| OutYes3["良好(仅单应用)"]
    DynamicSS --> |"向内隔离"| InNo2["无"]
    DynamicSS --> |"多应用并存"| MultiNo["不支持"]
    style ShadowDOM fill:#e3f2fd,stroke:#1565c0
    style ScopedCSS fill:#fff3e0,stroke:#e65100
    style DynamicSS fill:#e8f5e9,stroke:#2e7d32

乾坤提供了两个配置项来控制 CSS 隔离策略,它们都写在 start()(或 loadMicroApp 的第二个参数)的 sandbox 里——registerMicroApps 的第二个参数是生命周期钩子,不收沙箱配置:

// 乾坤的 CSS 隔离配置
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'sub-app',
    entry: '//localhost:7100',
    container: '#container',
    activeRule: '/sub-app',
  },
]);

start({
  sandbox: {
    // 方式一:严格隔离 —— 使用 Shadow DOM
    // 对应源码中的 strictStyleIsolation
    strictStyleIsolation: true,

    // 方式二:实验性隔离 —— 使用 Scoped CSS(二选一,把上一行换成这一行)
    // 对应源码中的 experimentalStyleIsolation
    // experimentalStyleIsolation: true,
  },
});
// 两者同时开启时以 strictStyleIsolation 为准(isEnableScopedCSS 会忽略 experimentalStyleIsolation)
// 如果都不开启,则使用 Dynamic Stylesheet(默认策略)

5.1.2 策略一:Shadow DOM(strictStyleIsolation)

Shadow DOM 是 Web Components 最初四项提案之一(另外三项是 Custom Elements、HTML Templates、HTML Imports,其中 HTML Imports 后来已被废弃)——它是浏览器给我们的"DOM 隔离原语"。在 Shadow DOM 出现之前、要想实现真正的样式隔离、唯一的办法是 iframe;而 iframe 的代价我们已经讨论过——通信困难、共享上下文难、性能开销大。Shadow DOM 的伟大在于、它第一次让浏览器原生支持了"DOM 内的子树隔离"——不用 iframe、就能让一段 DOM 和它的样式生活在一个"迷你世界"里、外面的样式进不来、里面的样式漏不出去。这种能力一旦存在、就注定会被微前端利用——因为它回答了微前端里最迫切的一个问题:"如何让子应用的样式不污染主应用?"

Shadow DOM 是 Web Components 标准的一部分,它提供了浏览器原生的 DOM 和样式隔离能力。乾坤的 strictStyleIsolation 选项正是利用了这个能力(注意:v2.10.16 的 src/loader.ts:281-285 在开发环境会打印一条警告——strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!)。

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

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

// 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]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } 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 内部,与外界天然隔离。

<!-- 隔离后的 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 提供了最强的隔离能力,但它在微前端场景下有三个严重的实际问题:

// 问题一:弹窗类组件无法正常工作
// 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 的合成事件系统依赖事件冒泡到根容器(React 16 把监听挂在 document 上,
// 17 起改挂在渲染这棵 React 树的根容器上);事件穿过 Shadow Root 时 target 会被重定向,
// 依赖 e.target 做判断的处理器可能失效
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)

Scoped CSS 是 Vue 单文件组件里非常成功的一个特性——每个组件的 <style scoped> 块里的选择器、都会在编译时被附加一个 [data-v-xxx] 属性选择器(组件模板里的元素也同时被加上这个 hash 属性)、让它只匹配当前组件的 DOM。乾坤的 experimentalStyleIsolation 选项、本质上是把 Vue SFC 的这个思路、应用到了微前端场景——不过有一个关键差异:Vue 的 scoped 是在编译时完成的、乾坤的 scoped 是在运行时完成的。运行时完成、意味着需要付出额外的 CSS 解析和重写成本、但也带来了一个好处——子应用本身不需要做任何修改就能被隔离。这种"运行时改写"的思路、和我们在第 4 章讨论的 JS 沙箱里的 Proxy 拦截、是同一种哲学——在运行时、在外部、做你没法在源头做的事情。

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

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

/* 原始 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"] { font-size: 14px; }

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

// qiankun/src/sandbox/patchers/css.ts(精简,保留核心逻辑)
enum RuleType {
  // 会被改写的规则类型
  STYLE = 1, MEDIA = 4, SUPPORTS = 12,
  // 原样保留的规则类型
  IMPORT = 3, FONT_FACE = 5, PAGE = 6, KEYFRAMES = 7, KEYFRAME = 8,
}

const rawDocumentBodyAppend = HTMLBodyElement.prototype.appendChild;

export class ScopedCSS {
  private static ModifiedTag = 'Symbol(style-modified-qiankun)';
  private sheet: StyleSheet;
  private swapNode: HTMLStyleElement;

  constructor() {
    // 一个挂在 body 上、但被 disabled 的"中转" <style>:
    // 借浏览器的 CSS 解析器把文本变成 cssRules,又不让它真的生效
    const styleNode = document.createElement('style');
    rawDocumentBodyAppend.call(document.body, styleNode);
    this.swapNode = styleNode;
    this.sheet = styleNode.sheet!;
    this.sheet.disabled = true;
  }

  process(styleNode: HTMLStyleElement, prefix: string = '') {
    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;
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      styleNode.textContent = this.rewrite(rules, prefix);    // 写回改写后的 CSS
      this.swapNode.removeChild(textNode);
      (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      return;
    }

    // 内容为空的 <style>(如 CSS-in-JS 稍后才写入文本):
    // 用 MutationObserver 等它有内容之后再改写一次
    const mutator = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (ScopedCSS.ModifiedTag in styleNode) return;
        if (mutation.type === 'childList') {
          const rules = arrayify<CSSRule>((styleNode.sheet as any)?.cssRules ?? []);
          styleNode.textContent = this.rewrite(rules, prefix);
          (styleNode as any)[ScopedCSS.ModifiedTag] = true;
        }
      }
    });
    mutator.observe(styleNode, { childList: true });
  }

  private rewrite(rules: CSSRule[], prefix: string = '') {
    let css = '';
    rules.forEach((rule) => {
      switch (rule.type) {
        case RuleType.STYLE:    css += this.ruleStyle(rule as CSSStyleRule, prefix); break;
        case RuleType.MEDIA:    css += this.ruleMedia(rule as CSSMediaRule, prefix); break;      // 递归处理 @media
        case RuleType.SUPPORTS: css += this.ruleSupport(rule as CSSSupportsRule, prefix); break; // 递归处理 @supports
        default:
          // @keyframes / @font-face / @import 等原样保留
          if (typeof rule.cssText === 'string') css += `${rule.cssText}`;
      }
    });
    return css;
  }

  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 = typeof rule.cssText === 'string' ? rule.cssText : '';

    // html { } / body { } / :root { } → 整体替换为前缀
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }
    // html body { } / html > body { } → 去掉 html 部分
    if (rootCombinationRE.test(rule.selectorText)) {
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
      if (!siblingSelectorRE.test(rule.selectorText)) {
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }
    // 一般选择器(含 a,span,p 这类分组选择器):逐个加前缀
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, sel) => {
        if (rootSelectorRE.test(item)) {
          return item.replace(rootSelectorRE, (m) => {
            const whitePrevChars = [',', '('];              // 保留 body,html / *:not(:root) 里的前导字符
            if (m && whitePrevChars.includes(m[0])) return `${m[0]}${prefix}`;
            return prefix;
          });
        }
        return `${p}${prefix} ${sel.replace(/^ */, '')}`;
      }),
    );
    return cssText;
  }
  // ruleMedia / ruleSupport:递归调用 rewrite 处理内部规则,再包回 @media / @supports
}

export const QiankunCSSRewriteAttr = 'data-qiankun';
export const process = (appWrapper: HTMLElement, stylesheetElement: HTMLStyleElement | HTMLLinkElement, appName: string) => {
  if (!processor) processor = new ScopedCSS();             // 懒单例
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }
  const tag = (appWrapper.tagName || '').toLowerCase();
  if (tag && stylesheetElement.tagName === 'STYLE') {
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;   // 例如 div[data-qiankun="sub-app"]
    processor.process(stylesheetElement, prefix);
  }
};

有两个实现细节值得注意。第一,它没有自己写 CSS 解析器,而是把样式文本塞进一个挂在 body 上、但 sheet.disabled = true 的中转 <style>,让浏览器解析出 cssRules 后再逐条改写——这比 new CSSStyleSheet().replaceSync() 的兼容面更广。第二,process 只处理 <style>,对 <link> 只打一条警告;不过子应用 HTML 里的外链样式在 import-html-entry 阶段就已经被下载并内联成 <style>(见 5.2 节),运行时动态插入的 <link rel="stylesheet"> 也会被 dynamicAppend 补丁先转换成 <style> 再交给 process,所以实际覆盖面基本不受这条限制影响(HTMLBodyElement.prototype.insertBefore 没有被劫持,走这条路插入的样式例外)。

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

// 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> / <link>。这一步不是靠 MutationObserver,而是靠 5.1.4 节将要讲的 dynamicAppend 补丁:它劫持了 HTMLHeadElement.prototype.appendChild / insertBefore 和 HTMLBodyElement.prototype.appendChild,只要子应用往 head/body 里插样式节点,就先调用 css.process 改写选择器,再把节点插进子应用的容器里。css.ts 里那个 MutationObserver 的分工更窄——只负责"插入时文本还是空的 <style>"(styled-components 之类 CSS-in-JS 库的典型行为),等它真正有了内容再补一次改写。

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

sequenceDiagram
    participant Loader as 乾坤 Loader
    participant Element as createElement()
    participant ScopedCSS as css.process
    participant Patch as dynamicAppend 补丁
    participant DOM as 子应用容器
    Note over Loader: 子应用加载阶段
    Loader->>Element: 创建子应用容器元素
    Element->>Element: 设置 data-qiankun 属性
    Element->>ScopedCSS: 遍历模板里已有的 style 标签
    ScopedCSS->>ScopedCSS: 借 disabled 的中转 style 解析出 cssRules
    ScopedCSS->>ScopedCSS: 逐条改写选择器添加前缀
    ScopedCSS->>DOM: 写回改写后的 CSS
    Note over Patch: 子应用运行阶段
    Patch->>Patch: 劫持 head/body 的 appendChild / insertBefore
    Patch->>ScopedCSS: 子应用动态插入 style/link
    ScopedCSS->>ScopedCSS: 改写新增样式的选择器(空 style 交给 MutationObserver 等内容)
    Patch->>DOM: 插入到子应用容器而非 document.head

Scoped CSS 的局限性:

/* 问题一:权重提升 */
/* 原始样式 */
.container { color: red; }
/* 转换后 */
div[data-qiankun="sub-app"] .container { color: red; }


/* 问题二:@keyframes 不受作用域影响 */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}



/* 问题三::root 和 body 的语义丢失 */
/* 原始 */
:root { --primary-color: #1890ff; }
/* 转换后 */
div[data-qiankun="sub-app"] { --primary-color: #1890ff; }

5.1.4 策略三:Dynamic Stylesheet(默认策略)

这是三种策略中最朴素的一种、但也是乾坤的默认策略——原因很简单:它对子应用的约束最少、对浏览器的兼容性最好、在"单实例模式"(同时只有一个子应用激活)下表现完全没有问题。这让我想起软件工程里一个反直觉的道理——默认值的选择往往不是"功能最强的那个"、而是"对用户最宽容的那个"。好的默认值、应该"让没有特殊需求的人都能成功"——而不是"让有特殊需求的人满意"。乾坤把 Dynamic Stylesheet 作为默认策略、正是遵循了这个原则——绝大多数企业微前端场景都是单实例模式、Dynamic Stylesheet 在这种场景下工作良好;那些需要多实例的高级场景、再让用户主动开启其他策略。

当 strictStyleIsolation 和 experimentalStyleIsolation 都不开启时,乾坤使用的是最简单也最"粗暴"的策略:动态样式表挂载与卸载。

原理:子应用挂载时,它的样式随子应用一起进入 DOM;子应用卸载时,样式随之离开 DOM。这样,同一时刻只有当前活跃的子应用的样式存在于页面中。

// 动态样式表的核心逻辑(概念性伪代码)

// 子应用挂载时
function mount(app: MicroApp) {
  // 将子应用的 <style> 和 <link> 标签插入到文档中
  app.styles.forEach((styleElement) => {
    document.head.appendChild(styleElement);
  });
  // 渲染子应用 DOM
  app.container.innerHTML = app.template;
}

// 子应用卸载时
function unmount(app: MicroApp) {
  // 从文档中移除子应用的样式
  app.styles.forEach((styleElement) => {
    document.head.removeChild(styleElement);
  });
  // 清理子应用 DOM
  app.container.innerHTML = '';
}

实际的实现比上面的伪代码复杂得多,而且有一个关键差异:乾坤并不会把子应用的样式插到 document.head。模板里的样式(包括被 import-html-entry 内联的外链 CSS)本来就在子应用的包裹 div 里,卸载时随包裹元素一起被移除;子应用运行时通过 document.head.appendChild 之类动态添加的样式,则被乾坤劫持后改插到子应用容器内一个模拟的 <qiankun-head> 里,并记录在 dynamicStyleSheetElements 数组中,以便再次挂载时原样重建。这就是 src/sandbox/patchers/dynamicAppend/ 目录做的事:

// qiankun/src/sandbox/patchers/dynamicAppend/common.ts(精简)
function getOverwrittenAppendChildOrInsertBefore(opts) {
  return function appendChildOrInsertBefore(this: HTMLHeadElement | HTMLBodyElement, newChild, refChild = null) {
    const element = newChild as any;
    const { rawDOMAppendOrInsertBefore, isInvokedByMicroApp, containerConfigGetter, target = 'body' } = opts;
    // 只拦截 link / style / script,且必须是子应用创建的元素
    if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
      return rawDOMAppendOrInsertBefore.call(this, element, refChild);
    }

    const { appName, appWrapperGetter, dynamicStyleSheetElements, scopedCSS, excludeAssetFilter } =
      containerConfigGetter(element);

    switch (element.tagName) {
      case 'LINK':
      case 'STYLE': {
        let stylesheetElement = newChild as any;
        const { href } = stylesheetElement;
        if (excludeAssetFilter && href && excludeAssetFilter(href)) {
          return rawDOMAppendOrInsertBefore.call(this, element, refChild);   // 用户声明跳过的资源不管
        }

        const appWrapper = appWrapperGetter();
        if (scopedCSS) {
          // 关键步骤一:开启了 experimentalStyleIsolation 就先做选择器改写
          // <link rel="stylesheet"> 会先被 fetch 下来转成 <style> 再改写
          if (element.tagName === 'LINK' && element.rel === 'stylesheet' && element.href) {
            stylesheetElement = convertLinkAsStyle(element, (styleEl) => css.process(appWrapper, styleEl, appName), fetch);
          } else {
            css.process(appWrapper, stylesheetElement, appName);
          }
        }

        // 关键步骤二:插到子应用容器里(head 目标插到模拟的 <qiankun-head>),而不是 document.head
        const mountDOM = target === 'head' ? getAppWrapperHeadElement(appWrapper) : appWrapper;
        const referenceNode = mountDOM.contains(refChild) ? refChild : null;
        const result = rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);

        // 关键步骤三:记录下来,供再次挂载时 rebuildCSSRules 重建
        dynamicStyleSheetElements.push(stylesheetElement);
        return result;
      }
      case 'SCRIPT': {
        // 动态 script 走 import-html-entry 的 execScripts 在沙箱里执行,DOM 里只留一个注释占位
        // ...
      }
    }
  };
}

export function patchHTMLDynamicAppendPrototypeFunctions(isInvokedByMicroApp, containerConfigGetter) {
  // 劫持 head.appendChild / body.appendChild / head.insertBefore,以及对应的 removeChild
  HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({ /* target: 'head' */ });
  HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({ /* target: 'body' */ });
  HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({ /* target: 'head' */ });
  // ...
  return function unpatch() { /* 恢复原型方法 */ };
}

严格沙箱(forStrictSandbox.ts)与宽松沙箱(forLooseSandbox.ts)共用这段逻辑,区别只在"怎么判断这个元素是不是子应用创建的":前者劫持 document.createElement,把元素与创建它的沙箱 proxy 关联起来;后者退而求其次,用 single-spa 的 checkActivityFunctions(window.location) 判断当前 URL 下该子应用是否处于激活态。

卸载时的处理也和伪代码里"逐个 removeChild"不同:因为动态样式已经在子应用容器内,容器被移除时它们自然一起消失;free() 只做两件事——记录 styled-components 这类"没有 textContent、规则只存在于 sheet.cssRules"的样式的规则(DOM 移除后浏览器会清空它们),以及在所有子应用都卸载后才把被劫持的原型方法恢复原状(isAllAppsUnmounted())。再次挂载时,rebuild() 会把 dynamicStyleSheetElements 按原顺序重新插回容器,并把记录下来的规则用 insertRule 补回去。

Dynamic Stylesheet 的核心限制——它只能处理路由级别的应用切换,无法处理多个子应用同时激活的场景:

当两个子应用同时显示(例如左侧订单列表 + 右侧商品详情),两者都有 .card { padding: 16px; } 时,Dynamic Stylesheet 完全无能为力——因为两个应用的样式同时存在于文档中。这就是为什么它只适用于"同一时刻只有一个子应用活跃"的路由切换场景。

5.1.5 三种策略的全面对比

维度 Shadow DOM Scoped CSS Dynamic Stylesheet
向外隔离 完美 良好 良好(仅单应用)
向内隔离 完美 无 无
多应用并存 支持 支持 不支持
性能开销 无 低(选择器改写) 低(DOM 操作)
第三方 UI 库兼容 差(弹窗逃逸) 中等(权重变化) 好(完全兼容)
推荐场景 纯自研组件、无弹窗 多应用并存首选 路由级单应用切换

💡 最佳实践:在生产环境中,推荐的组合策略是:默认使用 Dynamic Stylesheet(零成本、高兼容),对于需要多个子应用同时显示的场景开启 experimentalStyleIsolation(Scoped CSS),只在子应用完全自研且无弹窗组件时才考虑 strictStyleIsolation(Shadow DOM)。很多团队还会在此基础上叠加 CSS Modules 或 CSS-in-JS——这不是"二选一"的关系,而是"框架级隔离 + 应用级隔离"的多层防御。

"多层防御"的思想、是软件安全和健壮性设计里一个非常关键的原则——来自军事工程的"Defense in Depth"(纵深防御)。它的核心洞察是——任何单一防线都会被突破、所以你需要多条防线、让攻击者(或者 bug)必须同时突破所有防线才能造成伤害。应用到 CSS 隔离——框架层提供一道隔离(比如 Scoped CSS 的选择器改写)、应用层再提供一道隔离(比如 CSS Modules 的编译时 hash)、就算框架层的隔离有漏网之鱼(比如 @keyframes、@font-face 被原样保留、仍是全局生效)、应用层的隔离仍在兜底。这种"叠 buff"式的防御、比单纯追求"一道完美防线"要健壮得多——这是任何生产级微前端工程都应该采纳的工程直觉。工程的智慧、从来不是"寻找完美方案"、而是"用若干个不完美方案的组合、逼近完美"——而这种组合能力、恰恰是工程师最值得训练的核心能力之一。一个真正成熟的工程师、永远在想"还能叠一道保险吗"——而不是"这一道够不够"。

5.2 import-html-entry 源码剖析:HTML → Scripts + Styles + Template

import-html-entry 这个包的价值、远比它的名字听起来要大得多——它是整个乾坤体系的"HTML 手术刀"。乾坤选择用 HTML Entry(给一个 URL 就能加载子应用)而不是 JS Entry 的根本原因、是为了对子应用友好——但这个"友好"背后、需要有一个足够强大的工具能够"把子应用的 HTML 文件拆解成可以被微前端框架重新组装的碎片"。import-html-entry 就是这个拆解工具——它能从一个子应用的 HTML 里、精准地抽出 JS、CSS、模板骨架三类资源、然后交给微前端框架的不同模块分别处理。这种"先拆解、再重组"的思路、是工程领域非常经典的一种模式——Babel 的 AST 解析与重新生成、Webpack 的 chunk 拆分与 bundle 合并、git 的 blob 拆解与 tree 重建、数据库的 SQL 解析与执行计划——都是同一个思想的不同实现。理解 import-html-entry 的工作原理、不只是理解乾坤、更是理解"解析-处理-重组"这种通用软件设计模式。

下图展示了 import-html-entry 将一个 HTML 文件解析为三部分资源的完整流程:

flowchart LR
    HTML["子应用 HTML\nindex.html"] --> Fetch["fetch(entry)\n获取 HTML 文本"]
    Fetch --> Parse["processTpl()\n正则解析 HTML"]
    Parse --> Scripts["Scripts 列表\n外联 + 内联 JS"]
    Parse --> Styles["Styles 列表\n外链 CSS 的 URL"]
    Parse --> Template["Template\nscript/link 被替换为注释占位符的 HTML"]
    Scripts --> ExecScripts["execScripts()\n在沙箱中执行 JS"]
    Styles --> CSS["getEmbedHTML()\n下载外链 CSS 并内联回模板"]
    Template --> Mount["挂载到容器 DOM"]
    ExecScripts --> Lifecycle["导出生命周期\nbootstrap / mount / unmount"]
    style HTML fill:#e3f2fd,stroke:#1565c0
    style Parse fill:#fff3e0,stroke:#e65100
    style Lifecycle fill:#e8f5e9,stroke:#2e7d32

5.2.1 import-html-entry 的定位

在第3章的乾坤架构总览中,我们提到乾坤的子应用加载方式是 HTML Entry——直接获取子应用的 HTML 文件,从中解析出 JavaScript 脚本、CSS 样式和 HTML 模板。这一切的背后,是一个名为 import-html-entry 的独立包。

// 乾坤加载子应用的入口
// qiankun/src/loader.ts(极简化)
import { importEntry } from 'import-html-entry';

async function loadApp(
  app: LoadableApp,
  configuration: FrameworkConfiguration,
) {
  // 一行代码完成子应用资源解析
  const {
    template,       // script 标签被替换成注释占位、外链 CSS 已被下载并内联成 <style> 的 HTML 模板
    execScripts,    // 执行所有脚本并返回子应用导出的生命周期
    assetPublicPath,// 子应用的资源公共路径
    getExternalScripts,     // 获取所有外部脚本的内容(loader 会先 await 它,确保脚本都下载好再执行)
  } = await importEntry(entry, importEntryOpts);
  await getExternalScripts();

  // ... 后续的沙箱创建、生命周期调用等
}

importEntry 是整个资源加载流程的起点。它接收子应用的 URL,返回一个包含模板、脚本执行器、样式获取器的对象。让我们深入它的源码。

5.2.2 processTpl:HTML 解析的核心

import-html-entry 的核心函数是 processTpl。它的任务是将一段 HTML 字符串解析为三部分:scripts(脚本引用列表)、styles(样式引用列表)、template(去除脚本和样式后的纯 HTML)。

// import-html-entry/src/process-tpl.js(精简,保留核心逻辑;原文件是 JS 而非 TS)

// 关键正则——用于匹配 HTML 中的各类资源标签
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;

export default function processTpl(tpl, baseURI, postProcessTemplate) {
  let scripts = [];
  const styles = [];
  let entry = null;
  const moduleSupport = isModuleScriptSupported();

  const template = tpl
    // 步骤一:移除 HTML 注释,避免注释中的标签被误解析
    .replace(HTML_COMMENT_REGEX, '')

    // 步骤二:处理 <link>
    .replace(LINK_TAG_REGEX, (match) => {
      if (match.match(STYLE_TYPE_REGEX)) {                 // rel="stylesheet"
        const styleHref = match.match(STYLE_HREF_REGEX);
        if (styleHref) {
          let newHref = styleHref[2];
          if (!hasProtocol(newHref)) newHref = getEntirePath(newHref, baseURI);   // 相对路径 → 绝对路径
          if (match.match(LINK_IGNORE_REGEX)) return genIgnoreAssetReplaceSymbol(newHref); // <link ignore>
          styles.push(newHref);                           // 记录 URL,标签替换为注释占位符
          return genLinkReplaceSymbol(newHref);
        }
      }
      // rel="preload"/"prefetch"(字体除外)也替换为占位符,其余 <link> 原样保留
      // ...
      return match;
    })

    // 步骤三:内联 <style> 原样留在模板里,只处理 <style ignore>
    .replace(STYLE_TAG_REGEX, (match) =>
      STYLE_IGNORE_REGEX.test(match) ? genIgnoreAssetReplaceSymbol('style file') : match)

    // 步骤四:处理 <script>——最复杂的部分
    .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
      const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
      // 浏览器支持 ESM 就跳过 nomodule 脚本,不支持就跳过 type="module" 脚本
      const moduleScriptIgnore =
        (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) ||
        (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX));
      // type 不是 JS(如 text/template)的脚本原样保留
      const matchedScriptType = scriptTag.match(SCRIPT_TYPE_REGEX)?.[2];
      if (!isValidJavaScriptType(matchedScriptType)) return match;

      if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
        // 外部脚本:记录 URL、是否 async / crossorigin、是否为 entry
        const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX);
        let matchedScriptSrc = scriptTag.match(SCRIPT_SRC_REGEX)[2];
        if (entry && matchedScriptEntry) throw new SyntaxError('You should not set multiply entry script!');
        if (!hasProtocol(matchedScriptSrc)) matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
        entry = entry || (matchedScriptEntry && matchedScriptSrc);

        if (scriptIgnore) return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file');
        if (moduleScriptIgnore) return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport);

        const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
        const crossOriginScript = !!scriptTag.match(SCRIPT_CROSSORIGIN_REGEX);
        scripts.push((asyncScript || crossOriginScript)
          ? { async: asyncScript, src: matchedScriptSrc, crossOrigin: crossOriginScript }
          : matchedScriptSrc);
        return genScriptReplaceSymbol(matchedScriptSrc, asyncScript, crossOriginScript);
      }

      // 内联脚本:整段 <script>...</script> 原文存入 scripts(纯注释的脚本块会被丢弃)
      if (scriptIgnore) return genIgnoreAssetReplaceSymbol('js file');
      if (moduleScriptIgnore) return genModuleScriptReplaceSymbol('js file', moduleSupport);
      const code = getInlineCode(match);
      const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//'));
      if (!isPureCommentBlock) scripts.push(match);
      return inlineScriptReplaceSymbol;
    });

  scripts = scripts.filter(script => !!script);

  let tplResult = {
    template,
    scripts,
    styles,
    // 步骤五:没有用 entry 属性标记入口脚本时,默认最后一个脚本(无论内联还是外链)就是入口
    entry: entry || scripts[scripts.length - 1],
  };
  if (typeof postProcessTemplate === 'function') tplResult = postProcessTemplate(tplResult);
  return tplResult;
}

注意几个容易记错的细节:styles 里只有外链样式的 URL,内联 <style> 原样留在模板中;scripts 里外链脚本存的是 URL 字符串(带 async / crossorigin 属性的存对象),内联脚本存的是整段 <script> 原文,后面靠 isInlineCode(是否以 < 开头)区分;入口脚本默认是最后一个脚本,而不是"最后一个外链脚本"。

这段代码的特点在于它用正则替换完成了 HTML 解析,而不是把整段 HTML 交给 DOMParser(源码里 DOMParser 只在 utils.js 的 parseUrl 里出场,用来把 src 属性里的 &amp; 之类实体还原成真实 URL)。

🔥 深度洞察:为什么用正则而不是 DOM Parser?

你可能会疑惑:用正则解析 HTML 不是一个糟糕的做法吗?确实如此——在一般场景下。源码里没有注释解释这个取舍,但从它要交付的结果可以推断出合理的理由:1)template 必须是"原文 + 占位注释"——后续 getEmbedHTML 要用 genLinkReplaceSymbol(href) 精确地把注释替换回 <style>,如果经过一次 DOM 解析再序列化,HTML 会被浏览器规范化(补全标签、调整属性引号),占位就不再可控;2)正则方案可以精确控制哪些标签被处理、哪些被跳过(ignore 属性、text/ng-template、nomodule),颗粒度更细;3)对于微前端场景中通常不太复杂的 HTML 模板,正则的性能完全足够。代价也很明确——它对 HTML 的写法有隐含要求,模板字符串里出现 <script> 字面量这类边界情况会被误匹配(见本章思考题 1)。这是一个在不完美的选择中做出合理权衡的例子。

5.2.3 getExternalStyleSheets:样式资源的获取

processTpl 只是解析出了样式的引用列表,真正的样式内容还需要通过网络请求获取。这就是 getExternalStyleSheets 的职责。

// import-html-entry/src/index.js(精简)

// 全局样式缓存——同一个 URL 的样式只请求一次(缓存的是 fetch 的 Promise)
const styleCache = {};

export function getExternalStyleSheets(styles, fetch = defaultFetch) {
  return allSettledButCanBreak(styles.map(async styleLink => {
    if (isInlineCode(styleLink)) {
      // 以 "<" 开头的是内联样式(配置式 entry 可能传入),直接取标签内文本
      return getInlineCode(styleLink);
    }
    // 外部样式:命中缓存直接复用,否则发起请求
    return styleCache[styleLink] ||
      (styleCache[styleLink] = fetch(styleLink).then(response => {
        if (response.status >= 400) {
          throw new Error(`${styleLink} load failed with status ${response.status}`);
        }
        return response.text();
      }));
  }))
    .then(results => results
      .map((result, i) => {
        if (result.status === 'fulfilled') result.value = { src: styles[i], value: result.value };
        return result;
      })
      // 忽略失败的请求,避免某一个样式下载异常阻塞后续资源加载
      .filter(result => {
        if (result.status === 'rejected') Promise.reject(result.reason);
        return result.status === 'fulfilled';
      })
      .map(result => result.value));
}

// importHTML 内部会立刻调用它:把下载到的 CSS 内联回模板里的占位注释处
function getEmbedHTML(template, styles, opts = {}) {
  const { fetch = defaultFetch } = opts;
  return getExternalStyleSheets(styles, fetch).then(styleSheets =>
    styleSheets.reduce((html, { src, value }) =>
      html.replace(genLinkReplaceSymbol(src), isInlineCode(src) ? `${src}` : `<style>/* ${src} */${value}</style>`),
    template));
}

有两点值得注意。第一,样式不是"懒加载"的——importHTML 解析完模板后会立刻调用 getEmbedHTML,把所有外链 CSS 下载下来内联成 <style>;子应用挂载时样式已经在模板里了。第二,某个样式文件加载失败不会让整个子应用加载失败——allSettledButCanBreak 会把失败项过滤掉、只在控制台留下一个未处理的 rejection,页面会以"少一份样式"的状态继续渲染。

还有一个容易被想当然的点:import-html-entry 不会改写 CSS 里的相对路径。子应用 CSS 中的 background: url(./images/bg.png) 被内联到主应用页面后,相对路径的基准就从子应用的域名变成了主应用的域名,背景图、字体文件会 404。这个问题的正确解法在构建阶段——乾坤文档 docs/faq/README.zh.md 的「微应用打包之后 css 中的字体文件和图片加载 404」一条把病因写得很直白:「qiankun 将外链样式改成了内联样式,但是字体文件和背景图片的加载路径是相对路径」,而「css 文件一旦打包完成,就无法通过动态修改 publicPath 来修正」;它给出的解法都在构建期:图片等静态资源上传 CDN、CSS 里直接写 CDN 地址,用 url-loader 把较小的字体和图片打成 base64,或用 file-loader 给资源注入绝对地址的 publicPath(例如 //cdn.example.com/sub-app/),让 CSS 里的 url() 在构建期就变成绝对地址;乾坤运行时注入的 __INJECTED_PUBLIC_PATH_BY_QIANKUN__ 只能修正 webpack 运行时加载的 JS chunk 和动态资源,管不到已经写死在 CSS 文本里的相对路径。

5.2.4 getExternalScripts:脚本资源的获取

脚本资源的获取逻辑与样式类似,但多了一些复杂性——脚本有执行顺序的要求,而且入口脚本需要特殊处理。

// import-html-entry/src/index.js(精简)

// 全局脚本缓存(同样缓存 fetch 的 Promise)
const scriptCache = {};

export function getExternalScripts(scripts, fetch = defaultFetch, entry) {
  const fetchScript = (scriptUrl, opts) => scriptCache[scriptUrl] ||
    (scriptCache[scriptUrl] = fetch(scriptUrl, opts).then(response => {
      // 浏览器把 4xx/5xx 的脚本响应当作 error 事件处理,这里保持一致
      if (response.status >= 400) throw new Error(`${scriptUrl} load failed with status ${response.status}`);
      return response.text();
    }));

  // 入口脚本下载失败必须中断整个加载;普通脚本失败则跳过
  const shouldBreakWhileError = (i) => scripts[i] === entry;
  return allSettledButCanBreak(scripts.map(async script => {
    if (typeof script === 'string') {
      return isInlineCode(script) ? getInlineCode(script) : fetchScript(script);
    }
    // 带 async / crossorigin 的脚本以对象形式存储
    const { src, async, crossOrigin } = script;
    const fetchOpts = crossOrigin ? { credentials: 'include' } : {};
    if (async) {
      // async 脚本用空闲时间下载,返回的 content 是一个 Promise
      return { src, async: true, content: new Promise((resolve, reject) =>
        requestIdleCallback(() => fetchScript(src, fetchOpts).then(resolve, reject))) };
    }
    return fetchScript(src, fetchOpts);
  }), shouldBreakWhileError)
    .then(results => /* 同 getExternalStyleSheets:过滤失败项,映射为 { src, value } */ results);
}

注意这里的失败语义:普通脚本下载失败会被过滤掉、继续执行其余脚本,但入口脚本下载失败会直接让 execScripts 的 Promise reject——因为没有入口脚本就拿不到子应用的生命周期。

脚本获取完成后,还需要执行。执行脚本是 execScripts 函数的职责,它会在沙箱的 Proxy window 上下文中运行脚本代码:

// import-html-entry/src/index.js(精简,核心逻辑)
function getExecutableScript(scriptSrc, scriptText, opts = {}) {
  const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;
  const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
  // speedy 模式下把常用全局变量一次性解构到局部,避免每次访问都经过 Proxy
  const scopedGlobalVariableDefinition = scopedGlobalVariables.length
    ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';

  // 必须绑到真正的全局 window 上,否则嵌套微前端时 window.proxy 会取错层
  const globalWindow = (0, eval)('window');
  globalWindow.proxy = proxy;
  return strictGlobal
    ? (scopedGlobalVariableDefinition
        // strictGlobal + speedy:with(this) 让未声明的全局读写都落到 proxy 上
        ? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
        : `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`)
    // 非严格模式:只把 window / self / globalThis 三个名字指向 proxy,不用 with
    : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}

export function execScripts(entry, scripts, proxy = window, opts = {}) {
  const { fetch = defaultFetch, strictGlobal = false, success, error = () => {}, beforeExec = () => {}, afterExec = () => {}, scopedGlobalVariables = [] } = opts;

  return getExternalScripts(scripts, fetch, entry).then(scriptsText => {
    const geval = (scriptSrc, inlineScript) => {
      const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
      const code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables });
      evalCode(scriptSrc, code);        // 内部用 (0, eval) 间接调用,并按 scriptSrc 缓存包装后的函数
      afterExec(inlineScript, scriptSrc);
    };

    function exec(scriptSrc, inlineScript, resolve) {
      if (scriptSrc === entry) {
        // 入口脚本:记录执行前的全局属性快照,执行后找出"新出现的那个全局变量"作为导出
        noteGlobalProps(strictGlobal ? proxy : window);
        try {
          geval(scriptSrc, inlineScript);
          const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
          resolve(exports);
        } catch (e) {
          console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
          throw e;                       // 入口脚本报错必须抛出,让 Promise 进入 settled
        }
      } else if (typeof inlineScript === 'string') {
        try {
          geval(scriptSrc?.src ?? scriptSrc, inlineScript);
        } catch (e) {
          // 与浏览器行为一致:普通脚本报错不阻塞其他脚本
          throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
        }
      } else {
        // async 脚本:等它下载完成后再执行,不参与顺序调度
        inlineScript.async && inlineScript?.content
          .then(text => geval(inlineScript.src, text))
          .catch(e => throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`));
      }
    }

    // 按 HTML 中的原始顺序逐个执行同步脚本
    function schedule(i, resolvePromise) {
      if (i < scriptsText.length) {
        const { src, value } = scriptsText[i];
        exec(src, value, resolvePromise);
        if (!entry && i === scriptsText.length - 1) resolvePromise();
        else schedule(i + 1, resolvePromise);
      }
    }
    return new Promise(resolve => schedule(0, success || resolve));
  }).catch((e) => { error(); throw e; });
}

这段代码里有三个和沙箱直接相关的设计:其一,strictGlobal(乾坤在非 loose 沙箱下传 true)决定要不要用 with(window) 把未声明的全局读写兜到 proxy 上——这正是第 4 章讲的"子应用代码如何在沙箱中执行"的落地点;其二,入口脚本的"导出"不是靠 export,而是靠 noteGlobalProps / getGlobalProp 对比执行前后的全局属性,找出 webpack library 配置挂上去的那个全局变量(这段逻辑 fork 自 SystemJS 的 global extra);其三,evalCode 会按 scriptSrc 缓存 (0, eval) 得到的函数,同一个子应用第二次加载时不用再解析一遍源码。

5.2.5 完整的资源加载流程

现在我们可以串联起整个资源加载流程了:

// import-html-entry/src/index.js(精简):importEntry → importHTML 的核心流程
export default function importHTML(url, opts = {}) {
  const { fetch = defaultFetch, getPublicPath = defaultGetPublicPath, getTemplate = defaultGetTemplate, postProcessTemplate } = opts;

  // 同一个 URL 的整个解析结果也会缓存(embedHTMLCache)
  return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
    // Step 1: 获取 HTML 内容(可按 Content-Type 的 charset 解码 gbk 之类的编码)
    .then(response => readResAsString(response, autoDecodeResponse))
    .then(html => {
      // Step 2: 解析 HTML,提取 scripts、styles、template
      const assetPublicPath = getPublicPath(url);          // 默认取 URL 去掉文件名后的目录
      const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);

      // Step 3: 立刻下载所有外链 CSS 并内联回模板,再返回资源访问接口
      return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
        template: embedHTML,
        assetPublicPath,
        getExternalScripts: () => getExternalScripts(scripts, fetch),
        getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
        execScripts: (proxy, strictGlobal, opts = {}) => {
          if (!scripts.length) return Promise.resolve();
          return execScripts(entry, scripts, proxy, { fetch, strictGlobal, ...opts });
        },
      }));
    }));
}

整个流程可以概括为五步:fetch HTML → processTpl 解析 → 下载外链 CSS 并内联回模板(importEntry 内部完成)→ 乾坤 loader 调用 getExternalScripts 预先下载 JS、把模板渲染进容器 → execScripts 在沙箱中执行 JS 并取出生命周期。注意样式和脚本的加载时机并不对称:CSS 在 importEntry 返回之前就已经内联进模板;JS 则是先下载、后由乾坤在沙箱就绪后执行。

🔥 深度洞察:HTML Entry vs JS Entry 的架构抉择

import-html-entry 实现的 HTML Entry 模式是乾坤区别于 single-spa 的核心设计决策之一。single-spa 使用 JS Entry——子应用暴露一个 JS 入口文件,框架只管执行这个文件。HTML Entry 的优势在于:1)子应用无需关心资源如何被加载,保持与独立部署时完全一致的 HTML 结构;2)框架可以自动发现并处理所有资源依赖;3)子应用的接入成本极低。但代价也很明显:HTML 解析依赖正则(如上所述),无法处理所有 HTML 变体;资源加载多了一跳(先请求 HTML,再从中提取并请求 JS/CSS)。这再一次证明了微前端设计中一个反复出现的主题:没有完美的方案,只有适合特定约束的权衡。

5.3 子应用资源的预加载策略

5.3.1 为什么需要预加载

预加载的概念在软件工程史上有一条漫长的血脉——从 CPU 的分支预测、到操作系统的页面预取、到 CDN 的边缘预热、到浏览器的 <link rel="prefetch">——人类一直在和一个最根本的物理事实作战——数据从一个地方移动到另一个地方需要时间、如果我们能在"需要用"之前就开始移动、用户就感觉不到这段时间。微前端的资源预加载、只是这条血脉在前端工程领域的最新延伸。它的核心洞察是——用户的意图往往是可以被合理预测的——如果用户已经在"订单"子应用里停留了几分钟、有很大概率他下一步会切到"商品"子应用;与其等他真的切过去再开始加载、不如在他还在看订单的时候、就在后台把商品子应用的资源慢慢拉下来。这就是预加载最朴素也最强大的思想——用当前的闲置时间、换未来的加载时间。

如果不做预加载,子应用的资源(HTML、JS、CSS)要等用户导航到对应路由时才开始加载。这意味着用户每次首次访问一个子应用,都需要经历:

路由切换 → 请求 HTML → 解析 HTML → 请求 JS/CSS → 执行 JS → 渲染

整个链路串行地经过多次网络往返(取决于网络条件和资源大小,通常是秒级),这段时间用户看到的是一片空白——这在追求极致体验的 C 端应用中是不可接受的。

预加载(Prefetch)的策略是:在主应用空闲时,提前加载其他子应用的资源,将它们缓存起来。当用户真正导航时,资源已经在缓存中,加载时间趋近于零。

5.3.2 乾坤的预加载实现

乾坤通过 start() 的 prefetch 选项控制预加载行为(另外还导出了一个 prefetchApps() 函数——即 prefetch.ts 里的 prefetchImmediately——供 loadMicroApp 场景手动触发预加载):

// 乾坤的预加载配置
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([...]);

start({
  prefetch: true,           // 默认开启:在第一个子应用挂载后预加载其他子应用
  // prefetch: 'all',       // 主应用 start 后立即预加载所有子应用
  // prefetch: ['app1'],    // 第一个子应用挂载后,只预加载指定的子应用
  // prefetch: (apps) => {  // 自定义预加载策略
  //   return { criticalAppNames: ['app1'], minorAppsName: ['app2'] };
  // },
});

来看预加载的源码实现:

// qiankun/src/prefetch.ts(精简)
import { importEntry } from 'import-html-entry';
import { getAppStatus, getMountedApps, NOT_LOADED } from 'single-spa';

// 弱网判断:开启了省流量模式,或非 wifi / 有线且 effectiveType 是 2g / 3g
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /([23])g/.test(navigator.connection.effectiveType))
  : false;

// 利用浏览器空闲时间执行预加载
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // 离线或弱网环境下不预加载
    return;
  }

  requestIdleCallback(async () => {
    // 第一阶段:获取并解析 HTML,提取资源列表(这一步已经把外链 CSS 下载并内联了)
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);

    // 第二阶段:在下一个空闲时段确认样式已缓存
    requestIdleCallback(getExternalStyleSheets);

    // 第三阶段:在再下一个空闲时段加载脚本
    requestIdleCallback(getExternalScripts);
  });
}

function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 监听 single-spa:first-mount 事件:第一个子应用挂载后,预加载状态仍为 NOT_LOADED 的其他子应用
  window.addEventListener('single-spa:first-mount', function listener() {
    const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
    notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  apps.forEach(({ entry }) => prefetch(entry, opts));
}

// 预加载策略调度器
export function doPrefetchStrategy(apps: AppMetadata[], prefetchStrategy: PrefetchStrategy, importEntryOpts?: ImportEntryOpts) {
  const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));

  if (Array.isArray(prefetchStrategy)) {
    // 策略:字符串数组——第一个子应用挂载后,只预加载指定的子应用
    prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
  } else if (isFunction(prefetchStrategy)) {
    (async () => {
      // 策略:自定义函数——关键应用立即预加载,次要应用等第一个子应用挂载后再预加载
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();
  } else {
    switch (prefetchStrategy) {
      case true:   // 默认:第一个子应用挂载后预加载其余所有子应用
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;
      case 'all':  // start() 后立即预加载所有子应用
        prefetchImmediately(apps, importEntryOpts);
        break;
      default:
        break;
    }
  }
}

顺带一提,prefetch.ts 顶部自带了一个 requestIdleCallback 的降级实现:浏览器不支持时优先用 MessageChannel(没有 setTimeout 的 4ms 最小延迟),再不行才退到 setTimeout。

5.3.3 三级预加载的设计哲学

注意 prefetch 函数中使用了三次 requestIdleCallback,将预加载分为三个阶段。这不是随意为之,而是精心设计的渐进式策略:

设计意图很清晰:idle1 获取 HTML 并解析(最轻量)→ idle2 加载 CSS 文件(中等开销)→ idle3 加载 JS 文件(体积最大,放最后)。每一阶段都在浏览器空闲时才执行,如果主线程突然繁忙(用户操作),预加载会自动让步。严格说,按 5.2.3 节的分析,外链 CSS 在 importEntry 返回前就已经随 getEmbedHTML 下载完毕,idle2 的 getExternalStyleSheets 实际命中的是 styleCache——三段式的"意图"保留了,但 CSS 这一段在当前版本里几乎是零成本的。

5.3.4 弱网感知:被忽视的细节

上面源码中有一段容易被忽视的代码:

// src/prefetch.ts:63
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /([23])g/.test(navigator.connection.effectiveType))
  : false;

if (!navigator.onLine || isSlowNetwork) {
  return; // 离线或弱网环境下跳过预加载
}

这是一个非常贴心的设计——在离线、用户开启了"节省流量"模式、或者处在非 wifi / 有线连接且 effectiveType 为 2G / 3G 的网络下时,跳过预加载。因为在这种条件下,预加载反而会与当前页面的资源加载竞争本就有限的带宽,适得其反。在实际生产中,还可以进一步增强弱网检测,利用 navigator.connection.downlink(带宽)和 navigator.connection.rtt(延迟)做更精细的判断。

💡 最佳实践:在实际项目中,建议对乾坤默认的预加载策略进行增强。1)对高频访问的子应用使用 criticalAppNames 立即预加载;2)对低频子应用使用 minorAppsName 延迟预加载;3)结合用户的历史访问数据动态调整预加载优先级——用户最常使用的子应用优先加载。这比简单地"预加载所有子应用"更高效。

5.4 资源加载失败的容错与重试机制

5.4.1 生产环境的残酷现实

做后端出身的工程师、对"网络不可靠"这件事有近乎本能的警觉——他们知道任何一个远程调用都可能超时、失败、返回垃圾数据。但很多前端工程师——特别是那些一直在"开发环境 + 稳定 CDN"里工作的工程师——往往对这件事麻木。在本地开发时、fetch 一个 JS 文件永远是成功的;到了生产环境、就算假设 fetch 一个 JS 文件的成功率是 99.99%——那 0.01% 的失败、在每天百万 UV 的应用上、就是每天一百次的白屏事故。这是前端和后端工程哲学的一个微妙差异——后端天生是"不可靠优先"(assume failure、design for recovery)、前端常常是"可靠优先"(assume success、handle failure as exception)。做微前端的时候、你必须把 mindset 切换到后端的那一边——因为微前端的加载链路里、每一跳都是一次跨域 HTTP 请求、每一跳都有失败概率、而这些概率会乘起来。一个依赖 5 个远程 JS 文件的子应用、每个 JS 的可靠性是 99.99%、整体可靠性就是 99.99%^5 ≈ 99.95%——意味着每 2000 次加载里有 1 次整体失败。这就是为什么生产级微前端必须构建完整的容错体系——不是"锦上添花"、而是"基本生存能力"。

在理想环境中,资源加载总是成功的。但生产环境远非理想——CDN 节点可能临时故障、子应用的部署可能还没完成、网络可能短暂抖动。如果子应用的任何一个 JS 文件加载失败,整个子应用就无法启动,用户看到的是一片空白。

// 没有容错机制时的脆弱链路
try {
  const { execScripts } = await importEntry('//cdn.example.com/sub-app/');
  await execScripts(sandbox.proxy);
} catch (error) {
  // 资源加载失败 → 子应用完全不可用 → 用户看到白屏
  // 默认情况下,乾坤只会在控制台打印错误
  // 用户体验:灾难性
  console.error('[qiankun] 子应用加载失败', error);
}

5.4.2 import-html-entry 的内置容错

import-html-entry 本身提供了一定程度的容错能力:

// import-html-entry 的 fetch 配置
importEntry('//cdn.example.com/sub-app/', {
  fetch: customFetch, // 可以传入自定义的 fetch 函数
});

// 利用自定义 fetch 实现重试
function createFetchWithRetry(
  maxRetries: number = 3,
  retryDelay: number = 1000,
): typeof window.fetch {
  return async function fetchWithRetry(
    url: RequestInfo | URL,
    init?: RequestInit,
  ): Promise<Response> {
    let lastError: Error | null = null;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const response = await window.fetch(url, init);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        return response;
      } catch (error) {
        lastError = error as Error;
        console.warn(
          `[Fetch Retry] Attempt ${attempt + 1}/${maxRetries + 1} failed for ${url}: ${lastError.message}`
        );

        if (attempt < maxRetries) {
          // 指数退避:每次重试等待时间翻倍
          const delay = retryDelay * Math.pow(2, attempt);
          // 加入随机抖动,避免多个请求同时重试造成"惊群效应"
          const jitter = delay * 0.5 * Math.random();
          await new Promise((r) => setTimeout(r, delay + jitter));
        }
      }
    }

    throw lastError;
  };
}

// 使用
start({
  fetch: createFetchWithRetry(3, 1000),
  // 第一次失败:等 ~1s 后重试
  // 第二次失败:等 ~2s 后重试
  // 第三次失败:等 ~4s 后重试
  // 第四次失败:抛出错误
});

5.4.3 构建完整的容错体系

仅仅重试是不够的。一个生产级别的容错体系需要多层防御:

第二层:CDN 故障转移——自定义 fetch 函数依次尝试多个 CDN 源(cdn1.example.com → cdn2.example.com → cdn-backup.example.com),每个源重试 2 次,配合 AbortSignal.timeout(10000) 避免长时间等待。

第三层:优雅降级 UI——通过 addGlobalUncaughtErrorHandler 捕获加载错误,在子应用容器中渲染友好的降级页面(包含"刷新"和"返回"按钮),而非让用户面对白屏:

import { addGlobalUncaughtErrorHandler } from 'qiankun';

// 需自行实现:按项目里加载失败的错误特征(如错误信息含 "load failed")判断
declare function isLoadError(event: Event | string): boolean;

addGlobalUncaughtErrorHandler((event: Event | string) => {
  if (isLoadError(event)) {
    const container = document.querySelector('#sub-app-container');
    if (container) {
      container.innerHTML = `
        <div class="loading-error">
          <h2>页面加载遇到问题</h2>
          <p>请检查网络连接后重试</p>
          <button onclick="window.location.reload()">刷新页面</button>
        </div>
      `;
    }
  }
});
// 第四层:监控与告警——将监控集成到自定义 fetch 中
function createMonitoredFetch(
  appName: string,
  baseFetch: typeof window.fetch,
): typeof window.fetch {
  return async function monitoredFetch(
    url: RequestInfo | URL,
    init?: RequestInit,
  ): Promise<Response> {
    const startTime = performance.now();

    try {
      const response = await baseFetch(url, init);
      return response;
    } catch (error) {
      // 失败:上报错误信息到监控平台
      navigator.sendBeacon?.('/api/monitor/load-error', JSON.stringify({
        appName,
        resourceUrl: url instanceof Request ? url.url : String(url),
        duration: performance.now() - startTime,
        networkType: (navigator as any).connection?.effectiveType,
        timestamp: Date.now(),
      }));
      throw error;
    }
  };
}

5.4.4 资源缓存策略

除了容错和重试,合理的缓存策略可以从根本上减少加载失败的概率——已经缓存的资源不需要网络请求,自然不会失败。import-html-entry 内置了内存级缓存(styleCache 和 scriptCache),但内存缓存只在页面不刷新时有效。生产环境建议构建多级缓存:Level 1 为 import-html-entry 内存缓存(自动),Level 2 为 Service Worker 缓存(Stale-While-Revalidate 策略),Level 3 为 HTTP/CDN 缓存。

🔥 深度洞察:资源加载的"冰山模型"

大多数微前端文章只讲"如何加载子应用",很少有人深入讨论加载失败的处理。但在生产环境中,你需要为资源加载构建一个完整的"冰山模型":水面上是用户看到的流畅体验,水面下是多层容错机制在默默工作。重试策略处理短暂的网络抖动,CDN 故障转移处理单点故障,优雅降级 UI 处理完全不可恢复的场景,监控告警确保团队能快速发现和定位问题。这四层防御缺一不可——少了任何一层,都意味着某种故障场景下用户会看到白屏。生产环境的可靠性不是来自代码永远正确,而是来自代码在出错时仍能优雅地处理。

5.4.5 资源版本一致性保障

还有一个容易被忽视的问题:子应用在部署更新期间,HTML 文件和 JS/CSS 文件可能暂时不一致——HTML 已经指向了新版本的 JS,但新版本的 JS 文件还没有部署完成。

典型场景:T0 用户请求 HTML 获取到新版(指向 main.abc123.js),T1 请求该 JS 返回 404(文件还在部署中),T2 部署完成但用户已经看到加载失败。

解决方案是版本校验 + 延迟重试:加载失败时用 cache: 'no-cache' 重新请求 HTML,如果内容发生了变化说明正在部署中,等待数秒后重试整个加载流程。如果 HTML 未变化则说明是真正的资源错误,交给上层错误处理。


CSS 隔离和资源加载,是微前端架构中两个"不够性感但极其重要"的主题。CSS 隔离没有 JS 沙箱那种令人兴奋的 Proxy 黑魔法,资源加载也没有路由拦截那种巧妙的设计。但它们是微前端在生产环境中稳定运行的基石——一个 CSS 样式穿透就能导致全站布局错乱,一个资源加载失败就能让子应用完全不可用。

回顾本章的核心脉络:CSS 隔离的三种策略(Shadow DOM、Scoped CSS、Dynamic Stylesheet)各有边界,没有银弹;import-html-entry 用正则解析 HTML 是一个"不完美但合理"的工程决策;预加载的三级空闲策略体现了对用户体验和系统负载的精细平衡;资源容错的"冰山模型"是生产环境可靠性的底层保障。

下一章,我们将进入乾坤的最后一个核心主题——应用间通信。子应用不是孤岛,它们需要共享数据、协调行为、响应全局事件。乾坤是如何在保持隔离性的同时,为子应用之间搭建安全的通信桥梁的?

本章小结

  • CSS 隔离三策略各有适用场景:Shadow DOM 提供最强隔离但兼容性最差(弹窗逃逸、事件冒泡问题),Scoped CSS 通过选择器改写实现中等隔离(权重变化、@keyframes 问题),Dynamic Stylesheet 通过动态挂载/卸载实现最弱但兼容性最好的隔离(不支持多应用并存)
  • import-html-entry 是乾坤资源加载的核心:processTpl 用正则将 HTML 解析为 scripts + styles + template 三部分,外链 CSS 在 importEntry 内部就被下载并内联回模板,getExternalScripts 并行下载脚本、execScripts 在沙箱 proxy 上执行;CSS 里的相对路径它并不改写,需要子应用在构建期用绝对 publicPath 解决
  • 预加载采用三级 requestIdleCallback 设计:HTML 解析 → CSS 加载 → JS 加载,渐进式完成,确保不影响当前应用的交互性能,同时具备弱网感知能力
  • 生产级容错需要四层防御:资源重试(指数退避 + 随机抖动)→ CDN 故障转移 → 优雅降级 UI → 监控告警,缺一不可

本章三种策略本质上是在 CSS 的全局选择器之上"人为加一层局部范围"——Shadow DOM 用浏览器原生能力加(最彻底)、Scoped CSS 用属性选择器加(半彻底)、Dynamic Stylesheet 用时间切片加(最弱但最兼容)。这条思路与《Vue 3 设计与实现》专栏第 19 章讨论的 <style scoped> 编译机制(给选择器附加 data-v-xxx 属性)属于同一家族。

选择 CSS 隔离方案时可以依次回答三个问题:(1) 子应用需要多"完美"的隔离——仅防污染还是严格作用域?(2) 是否需要和主应用共享主题——CSS 变量能继承进 Shadow DOM,但主应用的类名样式进不去,靠全局样式下发主题时 Shadow DOM 需要另行注入;(3) 用户是否在多个子应用间频繁切换——会则 Dynamic Stylesheet 的挂载/卸载可能导致闪烁。

思考题

  1. 源码理解:processTpl 使用正则而非 DOM Parser 解析 HTML。请分析这两种方案各自的优缺点,并思考在什么场景下正则方案会解析失败(提示:考虑 HTML 模板字符串中包含 <script> 字面量的情况)。

  2. 方案对比:Shadow DOM 的 strictStyleIsolation 和 Scoped CSS 的 experimentalStyleIsolation 都号称能实现 CSS 隔离,但它们的隔离边界完全不同。请从"弹窗类组件"和"CSS 变量"两个角度,详细分析两种方案在使用 Ant Design 组件库时分别会遇到什么问题。

  3. 架构设计:本章提到预加载使用了 requestIdleCallback 的三级策略。如果让你重新设计这个预加载系统,你会如何利用用户的历史行为数据来智能化预加载优先级?请设计一个算法并给出伪代码。

  4. 生产实践:假设你的微前端应用部署在全球多个地区,子应用的 CDN 资源偶尔出现区域性故障。请设计一套完整的资源加载容错方案,考虑以下约束:a)故障检测延迟不超过 5 秒;b)切换到备用 CDN 后,后续请求也应该自动使用备用 CDN;c)原始 CDN 恢复后能自动回切。

  5. 延伸思考:CSS 隔离的三种策略都有明显的局限性。如果浏览器标准层面要设计一种"完美的"CSS 作用域方案(类似于 @scope 提案),它应该具备哪些能力?现有的 CSS @scope 规范是否已经解决了微前端的 CSS 隔离问题?