微前端源码精讲
第5章 CSS 隔离与资源加载
第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
在开始看具体方案之前、我想先帮你建立一个”比较的坐标系”。任何隔离技术、都在”隔离强度”和”使用便捷性”这两个维度上做权衡——隔离强度高、通常意味着对使用者有更多约束(比如不能用弹窗组件、CSS 变量不继承);隔离强度低、通常意味着隔离不彻底(某些情况下仍会冲突)。没有免费的隔离、只有根据场景选择合适的妥协点。这一节的三种策略、刚好构成了这个权衡坐标系上的三个关键节点——Shadow DOM 在”隔离强度”维度打满(10 分)、但”使用便捷性”扣到 3 分;Scoped CSS 在两个维度上都能拿 7 分;Dynamic Stylesheet 在”隔离强度”只有 4 分、但”使用便捷性”能拿 9 分。作为工程师、我们的任务不是”选最强的那个”、而是”选当前场景下权衡最合理的那个”。
5.1.1 问题的本质
CSS 隔离需要解决的核心问题只有一个:如何让子应用的样式只作用于子应用自身的 DOM,不影响主应用和其他子应用?
这个问题可以被分解为两个方向:
- 子应用的样式不泄漏出去(outward isolation)——子应用定义的
.container不应该影响主应用的.container - 外部的样式不渗透进来(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 隔离策略:
// 乾坤的 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 是 W3C 2014 年确立的 Web Components 四大支柱之一(另外三个是 Custom Elements、HTML Templates、HTML Imports)——它是浏览器给我们的”DOM 隔离原语”。在 Shadow DOM 出现之前、要想实现真正的样式隔离、唯一的办法是 iframe;而 iframe 的代价我们已经讨论过——通信困难、共享上下文难、性能开销大。Shadow DOM 的伟大在于、它第一次让浏览器原生支持了”DOM 内的子树隔离”——不用 iframe、就能让一段 DOM 和它的样式生活在一个”迷你世界”里、外面的样式进不来、里面的样式漏不出去。这种能力一旦存在、就注定会被微前端利用——因为它回答了微前端里最迫切的一个问题:“如何让子应用的样式不污染主应用?”
Shadow DOM 是 Web Components 标准的一部分,它提供了浏览器原生的 DOM 和样式隔离能力。乾坤的 strictStyleIsolation 选项正是利用了这个能力。
原理:将子应用的整个 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]: 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;
}
这段代码的关键步骤是:
- 创建一个容器
div,将子应用的 HTML 内容放入 - 调用
attachShadow({ mode: 'open' })创建 Shadow Root - 将原本的 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 的合成事件系统依赖事件冒泡到 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)
Scoped CSS 是 Vue 单文件组件里非常成功的一个特性——每个组件的 <style scoped> 块里的选择器、都会被编译时自动加上一个属性前缀、让它只匹配当前组件的 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"] body { font-size: 14px; }
来看核心源码。乾坤的 Scoped CSS 处理逻辑在 css.ts 文件中:
// 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} }`;
},
};
然后在创建子应用元素时,会为根节点添加对应的属性:
// 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 选择器改写的完整处理时序,包括静态样式和动态样式两条路径:
sequenceDiagram
participant Loader as 乾坤 Loader
participant Element as createElement()
participant ScopedCSS as ScopedCSS.process
participant Observer as MutationObserver
participant DOM as Shadow DOM / 容器
Note over Loader: 子应用加载阶段
Loader->>Element: 创建子应用容器元素
Element->>Element: 设置 data-qiankun 属性
Element->>ScopedCSS: 遍历已有 style 标签
ScopedCSS->>ScopedCSS: CSSStyleSheet 解析规则
ScopedCSS->>ScopedCSS: 逐条改写选择器添加前缀
ScopedCSS->>DOM: 写回改写后的 CSS
Note over Observer: 子应用运行阶段
Observer->>Observer: 监听 style 标签插入/内容变化
Observer->>ScopedCSS: 检测到新 style 标签
ScopedCSS->>ScopedCSS: 改写新增样式的选择器
ScopedCSS->>DOM: 写回改写后的 CSS
Scoped CSS 的局限性:
/* 问题一:权重提升 */
/* 原始样式 */
.container { color: red; }
/* 转换后 */
div[data-qiankun="sub-app"] .container { color: red; }
/* 选择器权重从 (0,1,0) 变成了 (0,2,1)
这可能导致样式覆盖关系发生意外变化 */
/* 问题二:@keyframes 不受作用域影响 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 动画名称是全局的,无法被 scoped 处理 */
/* 如果两个子应用都定义了 fadeIn,后加载的会覆盖前者 */
/* 问题三::root 和 body 的语义丢失 */
/* 原始 */
:root { --primary-color: #1890ff; }
/* 转换后 */
div[data-qiankun="sub-app"] { --primary-color: #1890ff; }
/* CSS 变量定义在子应用的根 div 上而非 :root
某些依赖 :root 级 CSS 变量的组件库可能受影响 */
5.1.4 策略三:Dynamic Stylesheet(默认策略)
这是三种策略中最朴素的一种、但也是乾坤的默认策略——原因很简单:它对子应用的约束最少、对浏览器的兼容性最好、在”单实例模式”(同时只有一个子应用激活)下表现完全没有问题。这让我想起软件工程里一个反直觉的道理——默认值的选择往往不是”功能最强的那个”、而是”对用户最宽容的那个”。Git 的默认 diff 算法是 Myers、不是性能最好的;Python 的默认 GC 是引用计数、不是最优秀的;React 的默认 state 更新是异步批处理、不是最简单的。好的默认值、应该”让没有特殊需求的人都能成功”——而不是”让有特殊需求的人满意”。乾坤把 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 = '';
}
实际的实现比上面的伪代码复杂得多。乾坤通过劫持 HTMLHeadElement.prototype.appendChild 来追踪子应用动态添加的样式:
// qiankun/src/sandbox/patchers/dynamicAppend/forStrictSandbox.ts(简化)
function patchDocumentCreateElement() {
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
HTMLHeadElement.prototype.appendChild = function appendChild<T extends Node>(
this: HTMLHeadElement, newChild: T,
): T {
const element = newChild as any;
if (isHijackingTag(element.tagName)) {
const currentApp = getCurrentRunningApp();
if (currentApp) {
// 关键步骤一:将样式标签与当前子应用关联
dynamicStyleSheetElements.push(element);
// 关键步骤二:如果开启了 scoped CSS,做选择器改写
if (scopedCSS) ScopedCSS.process(element, currentApp.name);
// 关键步骤三:插入到子应用容器中(而非 document.head)
return rawHeadAppendChild.call(currentApp.container, element) as T;
}
}
return rawHeadAppendChild.call(this, element) as T;
};
}
// 卸载时:遍历 dynamicStyleSheetElements 批量移除,清空记录,恢复劫持的 API
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)、就算框架层的隔离被绕过(比如有个样式写成了 !important)、应用层的隔离仍在兜底。这种”叠 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"]
Parse --> Template["Template\n去除 script/link 后的 HTML 骨架"]
Scripts --> ExecScripts["execScripts()\n在沙箱中执行 JS"]
Styles --> CSS["getExternalStyleSheets()\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 和 style 的纯 HTML 模板
execScripts, // 执行所有脚本并返回子应用导出的函数
assetPublicPath,// 子应用的资源公共路径
getExternalStyleSheets, // 获取所有外部样式表的内容
getExternalScripts, // 获取所有外部脚本的内容
} = await importEntry(entry, importEntryOpts);
// ... 后续的沙箱创建、生命周期调用等
}
importEntry 是整个资源加载流程的起点。它接收子应用的 URL,返回一个包含模板、脚本执行器、样式获取器的对象。让我们深入它的源码。
5.2.2 processTpl:HTML 解析的核心
import-html-entry 的核心函数是 processTpl。它的任务是将一段 HTML 字符串解析为三部分:scripts(脚本引用列表)、styles(样式引用列表)、template(去除脚本和样式后的纯 HTML)。
// import-html-entry/src/process-tpl.ts(简化,保留核心逻辑)
// 关键正则——用于匹配 HTML 中的各类资源标签
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const LINK_TAG_REGEX =
/<link[^>]*?\brel="stylesheet"\b[^>]*?\bhref="([^"]+)"[^>]*?\/?>/gi;
const STYLE_TAG_REGEX = /<style[^>]*>([\s\S]*?)<\/style>/gi;
const COMMENT_REGEX = /<!--[\s\S]*?-->/g;
export default function processTpl(tpl: string, baseURI: string): TemplateResult {
const scripts: ScriptObject[] = [];
const styles: StyleObject[] = [];
let entry: string | null = null;
// 步骤一:移除 HTML 注释,避免注释中的标签被误解析
const cleanedTpl = tpl.replace(COMMENT_REGEX, '');
// 步骤二:处理 <link rel="stylesheet">
// 将相对路径转为绝对路径,记录到 styles 数组,替换为注释占位符
let template = cleanedTpl.replace(LINK_TAG_REGEX, (match, href) => {
if (isPreloadOrPrefetch(match)) return match; // 跳过 preload/prefetch
const absoluteHref = getEntirePath(href, baseURI);
styles.push({ src: absoluteHref, isInline: false });
return genLinkReplaceSymbol(absoluteHref);
});
// 步骤三:处理 <style> 内联标签
template = template.replace(STYLE_TAG_REGEX, (match, content) => {
styles.push({ content, isInline: true });
return match; // 内联样式保留在模板中
});
// 步骤四:处理 <script> 标签——最复杂的部分
template = template.replace(ALL_SCRIPT_REGEX, (match) => {
const scriptMatch = match.match(SCRIPT_TAG_REGEX);
if (!scriptMatch) return match;
const [, attrs, src, restAttrs, inlineContent] = scriptMatch;
const allAttrs = `${attrs} ${restAttrs}`;
// 跳过 type="module" 和 nomodule 脚本
if (isModuleScript(allAttrs) || isNoModuleScript(allAttrs)) {
return genIgnoreAssetReplaceSymbol('module/nomodule script');
}
if (src) {
// 外部脚本:记录 URL、是否 async、是否为 entry
const absoluteSrc = getEntirePath(src, baseURI);
const isEntry = /\bentry\b/.test(allAttrs);
if (isEntry) entry = absoluteSrc;
scripts.push({ src: absoluteSrc, isInline: false, async: /\basync\b/.test(allAttrs), isEntry });
return genScriptReplaceSymbol(absoluteSrc, isEntry);
}
if (inlineContent) {
// 内联脚本:直接记录内容
const isEntry = /\bentry\b/.test(allAttrs);
scripts.push({ content: inlineContent, isInline: true, isEntry });
return genScriptReplaceSymbol('inline', isEntry);
}
return match;
});
// 步骤五:如果没有标记 entry 的脚本,默认最后一个外部脚本为入口
if (!entry && scripts.length > 0) {
const lastExternal = scripts.filter((s) => !s.isInline).pop();
if (lastExternal) { lastExternal.isEntry = true; entry = lastExternal.src!; }
}
return { template, scripts, styles, entry };
}
这段代码的精妙之处在于它用正则替换完成了 HTML 解析——没有使用 DOM Parser,因为这段代码需要在沙箱环境中运行,而沙箱中的 DOM API 可能被劫持。正则方案虽然不够”优雅”,但更可靠。
🔥 深度洞察:为什么用正则而不是 DOM Parser?
你可能会疑惑:用正则解析 HTML 不是一个糟糕的做法吗?确实如此——在一般场景下。但 import-html-entry 有它的特殊考量:1)它运行在主应用的上下文中,如果使用
DOMParser,解析过程中 HTML 中的<img>等标签会触发真实的资源请求——这不是我们想要的,我们只想”解析”而不想”执行”;2)正则方案可以精确控制哪些标签被处理、哪些被跳过,颗粒度更细;3)性能上,对于微前端场景中通常不太复杂的 HTML 模板,正则的性能完全足够。这是一个在不完美的选择中做出合理权衡的例子。
5.2.3 getExternalStyleSheets:样式资源的获取
processTpl 只是解析出了样式的引用列表,真正的样式内容还需要通过网络请求获取。这就是 getExternalStyleSheets 的职责。
// import-html-entry/src/index.ts(简化)
// 全局样式缓存——同一个 URL 的样式只请求一次
const styleCache: Record<string, string> = {};
export function getExternalStyleSheets(
styles: StyleObject[],
fetch: typeof window.fetch = defaultFetch,
): Promise<string[]> {
return Promise.all(
styles.map((styleInfo) => {
if (styleInfo.isInline) {
// 内联样式直接返回内容
return Promise.resolve(styleInfo.content!);
}
const { src } = styleInfo;
// 检查缓存
if (styleCache[src!]) {
return Promise.resolve(styleCache[src!]);
}
// 发起网络请求获取样式内容
return fetch(src!)
.then((response) => {
if (!response.ok) {
throw new Error(
`${src} load failed with status ${response.status}`
);
}
return response.text();
})
.then((styleText) => {
// 缓存结果
styleCache[src!] = styleText;
// 关键步骤:处理样式中的相对路径
// url(./images/bg.png) → url(https://sub-app.com/images/bg.png)
return processRelativeUrl(styleText, src!);
});
})
);
}
// 处理 CSS 中的相对路径引用
function processRelativeUrl(styleText: string, baseUrl: string): string {
// 匹配 url() 引用
const urlRE = /url\(\s*['"]?\s*([^'")]+)\s*['"]?\s*\)/g;
return styleText.replace(urlRE, (match, url) => {
// 绝对路径不处理
if (/^(https?:)?\/\//.test(url) || /^data:/.test(url)) {
return match;
}
// 相对路径转绝对路径
const absoluteUrl = new URL(url, baseUrl).href;
return `url("${absoluteUrl}")`;
});
}
注意 processRelativeUrl 这个函数——它解决了一个微前端特有的问题:子应用的 CSS 中通常包含相对路径的资源引用(如 background: url(./images/bg.png))。当这段 CSS 被提取并注入到主应用的页面时,相对路径的基准已经变了——从子应用的域名变成了主应用的域名。如果不做转换,所有相对路径的背景图、字体文件都会加载失败。
5.2.4 getExternalScripts:脚本资源的获取
脚本资源的获取逻辑与样式类似,但多了一些复杂性——脚本有执行顺序的要求,而且入口脚本需要特殊处理。
// import-html-entry/src/index.ts(简化)
// 全局脚本缓存
const scriptCache: Record<string, string> = {};
export function getExternalScripts(
scripts: ScriptObject[],
fetch: typeof window.fetch = defaultFetch,
): Promise<string[]> {
// 并行获取所有外部脚本
const fetchPromises = scripts.map((scriptInfo) => {
if (scriptInfo.isInline) {
return Promise.resolve(scriptInfo.content!);
}
const { src } = scriptInfo;
if (scriptCache[src!]) {
return Promise.resolve(scriptCache[src!]);
}
return fetch(src!)
.then((response) => {
if (!response.ok) {
throw new Error(
`${src} load failed with status ${response.status}`
);
}
return response.text();
})
.then((scriptText) => {
scriptCache[src!] = scriptText;
return scriptText;
});
});
return Promise.all(fetchPromises);
}
脚本获取完成后,还需要执行。执行脚本是 execScripts 函数的职责,它会在沙箱的 Proxy window 上下文中运行脚本代码:
// import-html-entry/src/index.ts(简化,核心逻辑)
export function execScripts(
entry: string | null,
scripts: ScriptObject[],
proxy: WindowProxy = window,
opts: ExecScriptsOpts = {},
): Promise<any> {
return getExternalScripts(scripts, opts.fetch).then((scriptTexts) => {
// 核心:将脚本包裹在 IIFE 中,绑定 proxy 作为 window
function exec(scriptSrc: string, inlineScript: string) {
const codeToRun = opts.strictGlobal
? `;(function(window, self, globalThis){;${inlineScript}\n}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: inlineScript;
try {
(0, eval)(codeToRun);
} catch (e) {
console.error(`[import-html-entry]: error occurs while executing ${scriptSrc}`);
throw e;
}
}
// 按顺序执行同步脚本,async 脚本用 requestIdleCallback 并行执行
// 入口脚本执行后,收集其导出(即子应用的 bootstrap/mount/unmount)
// ...(调度逻辑省略,核心是保证执行顺序与原始 HTML 中一致)
});
}
5.2.5 完整的资源加载流程
现在我们可以串联起整个资源加载流程了:
// import-html-entry 的核心流程
// 1. importEntry:入口函数
async function importEntry(url: string, opts?: ImportEntryOpts) {
// Step 1: 获取 HTML 内容
const html = await fetch(url).then((res) => res.text());
// Step 2: 解析 HTML,提取 scripts、styles、template
const { template, scripts, styles, entry } = processTpl(html, url);
// Step 3: 返回资源访问接口
return {
template,
// 获取所有外部样式表内容(懒加载,调用时才请求)
getExternalStyleSheets: () => getExternalStyleSheets(styles),
// 获取所有外部脚本内容(懒加载)
getExternalScripts: () => getExternalScripts(scripts),
// 在指定 proxy 上下文中执行所有脚本
execScripts: (proxy?: WindowProxy, strictGlobal?: boolean) =>
execScripts(entry, scripts, proxy, { strictGlobal }),
// 子应用的资源基路径
assetPublicPath: getPublicPath(url),
};
}
整个流程可以概括为五步:fetch HTML → processTpl 解析 → 并行获取 JS/CSS → execScripts 在沙箱中执行 → 注入样式并渲染模板。
🔥 深度洞察: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">、到 React 的 useDeferredValue——人类一直在和一个最根本的物理事实作战——数据从一个地方移动到另一个地方需要时间、如果我们能在”需要用”之前就开始移动、用户就感觉不到这段时间。微前端的资源预加载、只是这条血脉在前端工程领域的最新延伸。它的核心洞察是——用户的意图往往是可以被合理预测的——如果用户已经在”订单”子应用里停留了几分钟、有很大概率他下一步会切到”商品”子应用;与其等他真的切过去再开始加载、不如在他还在看订单的时候、就在后台把商品子应用的资源慢慢拉下来。这就是预加载最朴素也最强大的思想——用当前的闲置时间、换未来的加载时间。这和你把周末买好的食材放冰箱、工作日直接拿出来做饭、是同一种生活智慧。
在默认流程中,子应用的资源(HTML、JS、CSS)在用户导航到对应路由时才开始加载。这意味着用户每次首次访问一个子应用,都需要经历:
路由切换 → 请求 HTML → 解析 HTML → 请求 JS/CSS → 执行 JS → 渲染
整个链路可能耗时 1-3 秒(取决于网络条件和资源大小),这段时间用户看到的是一片空白——这在追求极致体验的 C 端应用中是不可接受的。
预加载(Prefetch)的策略是:在主应用空闲时,提前加载其他子应用的资源,将它们缓存起来。当用户真正导航时,资源已经在缓存中,加载时间趋近于零。
5.3.2 乾坤的预加载实现
乾坤提供了 prefetchApps 配置来控制预加载行为:
// 乾坤的预加载配置
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';
// 利用浏览器空闲时间执行预加载
function prefetch(
entry: string,
opts?: ImportEntryOpts,
): void {
// 关键:使用 requestIdleCallback 确保预加载不影响主线程
if (navigator.connection) {
// 如果用户处于弱网环境(2G/3G),跳过预加载
const { effectiveType, saveData } = (navigator as any).connection;
if (effectiveType === '2g' || effectiveType === 'slow-2g' || saveData) {
return;
}
}
requestIdleCallback(async () => {
// 第一阶段:获取并解析 HTML,提取资源列表
const { getExternalStyleSheets, getExternalScripts } =
await importEntry(entry, opts);
// 第二阶段:在下一个空闲时段加载样式
requestIdleCallback(() => getExternalStyleSheets());
// 第三阶段:在再下一个空闲时段加载脚本
requestIdleCallback(() => getExternalScripts());
});
}
// 预加载策略调度器
export function doPrefetchStrategy(
apps: RegistrableApp[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
): void {
if (prefetchStrategy === true) {
// 策略一:监听 single-spa:first-mount 事件
// 第一个子应用挂载后,预加载所有未加载的其他子应用
window.addEventListener('single-spa:first-mount', () => {
const notLoadedApps = apps.filter(
(app) => getAppsLoadingStatus()[app.name] === 'NOT_LOADED'
);
notLoadedApps.forEach(({ entry }) => prefetch(entry, importEntryOpts));
}, { once: true });
}
if (prefetchStrategy === 'all') {
// 策略二:立即预加载所有子应用
apps.forEach(({ entry }) => prefetch(entry, importEntryOpts));
}
if (typeof prefetchStrategy === 'function') {
// 策略三:自定义——区分关键应用(立即加载)和非关键应用(空闲加载)
const { criticalAppNames = [], minorAppsName = [] } = prefetchStrategy(apps);
criticalAppNames.forEach((name) => {
const app = apps.find((a) => a.name === name);
if (app) prefetch(app.entry, importEntryOpts);
});
requestIdleCallback(() => {
minorAppsName.forEach((name) => {
const app = apps.find((a) => a.name === name);
if (app) prefetch(app.entry, importEntryOpts);
});
});
}
}
5.3.3 三级预加载的设计哲学
注意 prefetch 函数中使用了三次 requestIdleCallback,将预加载分为三个阶段。这不是随意为之,而是精心设计的渐进式策略:
设计意图很清晰:idle1 获取 HTML 并解析(最轻量)→ idle2 加载 CSS 文件(中等开销)→ idle3 加载 JS 文件(体积最大,放最后)。每一阶段都在浏览器空闲时才执行,如果主线程突然繁忙(用户操作),预加载会自动让步。
5.3.4 弱网感知:被忽视的细节
上面源码中有一段容易被忽视的代码:
if (navigator.connection) {
const { effectiveType, saveData } = (navigator as any).connection;
if (effectiveType === '2g' || effectiveType === 'slow-2g' || saveData) {
return; // 弱网环境下跳过预加载
}
}
这是一个非常贴心的设计——在 2G/慢速网络或者用户开启了”节省流量”模式时,跳过预加载。因为在这种条件下,预加载反而会与当前页面的资源加载竞争本就有限的带宽,适得其反。在实际生产中,还可以进一步增强弱网检测,利用 navigator.connection.downlink(带宽)和 navigator.connection.rtt(延迟)做更精细的判断。
💡 最佳实践:在实际项目中,建议对乾坤默认的预加载策略进行增强。1)对高频访问的子应用使用
criticalAppNames立即预加载;2)对低频子应用使用minorAppsName延迟预加载;3)结合用户的历史访问数据动态调整预加载优先级——用户最常使用的子应用优先加载。这比简单地”预加载所有子应用”更高效。
5.4 资源加载失败的容错与重试机制
5.4.1 生产环境的残酷现实
做后端出身的工程师、对”网络不可靠”这件事有近乎本能的警觉——他们知道任何一个远程调用都可能超时、失败、返回垃圾数据。但很多前端工程师——特别是那些一直在”开发环境 + 稳定 CDN”里工作的工程师——往往对这件事麻木。在本地开发时、fetch 一个 JS 文件永远是成功的;在阿里云 CDN 上的生产环境、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,
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';
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,
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: typeof url === 'string' ? url : url.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 三部分,getExternalStyleSheets和getExternalScripts并行获取外部资源并处理相对路径 - 预加载采用三级 requestIdleCallback 设计:HTML 解析 → CSS 加载 → JS 加载,渐进式完成,确保不影响当前应用的交互性能,同时具备弱网感知能力
- 生产级容错需要四层防御:资源重试(指数退避 + 随机抖动)→ CDN 故障转移 → 优雅降级 UI → 监控告警,缺一不可
本章三种策略本质上是在 CSS 的全局选择器之上”人为加一层局部范围”——Shadow DOM 用浏览器原生能力加(最彻底)、Scoped CSS 用属性选择器加(半彻底)、Dynamic Stylesheet 用时间切片加(最弱但最兼容)。这条思路与《Vue 3 源码》第 8 章讨论的 scoped style 编译机制(给选择器附加 data-v-xxx 属性)属于同一家族。
选择 CSS 隔离方案时可以依次回答三个问题:(1) 子应用需要多”完美”的隔离——仅防污染还是严格作用域?(2) 是否需要和主应用共享主题/CSS 变量——共享则避免 Shadow DOM;(3) 用户是否在多个子应用间频繁切换——会则 Dynamic Stylesheet 的挂载/卸载可能导致闪烁。
思考题
-
源码理解:
processTpl使用正则而非 DOM Parser 解析 HTML。请分析这两种方案各自的优缺点,并思考在什么场景下正则方案会解析失败(提示:考虑 HTML 模板字符串中包含<script>字面量的情况)。 -
方案对比:Shadow DOM 的
strictStyleIsolation和 Scoped CSS 的experimentalStyleIsolation都号称能实现 CSS 隔离,但它们的隔离边界完全不同。请从”弹窗类组件”和”CSS 变量”两个角度,详细分析两种方案在使用 Ant Design 组件库时分别会遇到什么问题。 -
架构设计:本章提到预加载使用了
requestIdleCallback的三级策略。如果让你重新设计这个预加载系统,你会如何利用用户的历史行为数据来智能化预加载优先级?请设计一个算法并给出伪代码。 -
生产实践:假设你的微前端应用部署在全球多个地区,子应用的 CDN 资源偶尔出现区域性故障。请设计一套完整的资源加载容错方案,考虑以下约束:a)故障检测延迟不超过 5 秒;b)切换到备用 CDN 后,后续请求也应该自动使用备用 CDN;c)原始 CDN 恢复后能自动回切。
-
延伸思考:CSS 隔离的三种策略都有明显的局限性。如果浏览器标准层面要设计一种”完美的”CSS 作用域方案(类似于
@scope提案),它应该具备哪些能力?现有的 CSS@scope规范是否已经解决了微前端的 CSS 隔离问题?