Appearance
第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,不影响主应用和其他子应用?
这个问题可以被分解为两个方向:
- 子应用的样式不泄漏出去(outward isolation)——子应用定义的
.container不应该影响主应用的.container - 外部的样式不渗透进来(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;
}这段代码的关键步骤是:
- 创建一个容器
div,将子应用的 HTML 内容放入 - 调用
attachShadow({ mode: 'open' })创建 Shadow Root - 将原本的 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 选择器改写的完整处理时序,包括静态样式和动态样式两条路径: