Skip to content

第12章 Web Components 与微前端

"最好的隔离不是框架给你的——是浏览器本来就有的。"

本章要点

  • 深入理解 Shadow DOM 的两种模式(open/closed)及其在微前端场景中的隔离能力与边界
  • 掌握 Custom Elements 的完整生命周期,将其作为微应用容器实现加载、通信与销毁
  • 通过 Lit 框架的实战案例,体验 Web Components 驱动的微前端开发范式
  • 正视 Web Components 的真实局限:SSR 兼容性、表单集成、无障碍访问的挑战与应对策略
  • 理解 Web Components 在微前端技术版图中的独特定位:不是替代方案,而是基础设施

你可能已经注意到一个有趣的现象:前面章节中,无论是乾坤的 strictStyleIsolation,还是无界的组件级嵌入方案,底层都绕不开同一个东西——Web Components

这不是巧合。

当我们费尽心思用 JavaScript 去模拟 CSS 隔离、用 Proxy 去拦截全局变量、用各种 hack 去阻止子应用之间的相互污染时,浏览器其实早就准备好了一套原生的隔离方案。Shadow DOM 提供 DOM 和样式的天然边界,Custom Elements 提供标准化的生命周期钩子,HTML Templates 和 Slots 提供灵活的内容分发机制。这三驾马车组成的 Web Components 标准,本身就是浏览器对"组件隔离"问题的官方回答。

那么问题来了:既然浏览器原生就支持隔离,为什么微前端框架们还要自己造轮子?

答案并不简单。这一章,我们将从 Shadow DOM 的隔离机制出发,一路走到 Custom Elements 容器化实践,再用 Lit 框架搭建一个完整的微前端方案,最后直面 Web Components 的真实局限。读完之后,你会理解:Web Components 不是微前端的银弹,但它是微前端架构师工具箱里最不该被忽视的那把瑞士军刀。

下图展示了 Web Components 三大标准在微前端中各自承担的角色及其协作关系:

12.1 Shadow DOM:浏览器原生的隔离机制

12.1.1 Shadow DOM 的本质:一面单向镜

要理解 Shadow DOM,最好忘掉所有技术文档里的抽象定义,想象一面单向镜

从外面(Light DOM)看进去,你看不到里面的细节——内部的样式、结构、事件都被隔离在镜子后面。但从里面(Shadow DOM)看出去,你依然能感知到外部世界的存在——继承的 CSS 属性(如 font-familycolor)会穿透进来。

typescript
// 创建一面"单向镜"
class IsolatedContainer extends HTMLElement {
  constructor() {
    super();
    // attachShadow 就是安装这面镜子
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* 这些样式只在镜子内部生效 */
        .title { color: red; font-size: 24px; }
        .content { padding: 16px; background: #f5f5f5; }
      </style>
      <div class="title">我是隔离的标题</div>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('isolated-container', IsolatedContainer);
html
<style>
  /* 外部样式:试图影响 Shadow DOM 内部 */
  .title { color: blue; font-size: 48px; }
  .content { background: yellow; }
</style>

<isolated-container>
  <p>我是 Light DOM 中的内容,会被投影到 slot 中</p>
</isolated-container>

<!-- 结果:Shadow DOM 内部的 .title 是红色 24px,不受外部 .title 影响 -->
<!-- 外部的 .title 规则对 Shadow DOM 内部完全无效 -->

这个例子揭示了 Shadow DOM 隔离的核心特征:CSS 选择器无法穿透 Shadow Boundary。无论外部写了多么激进的 * { color: blue !important; },Shadow DOM 内部的元素都不会被匹配到。这正是微前端梦寐以求的样式隔离能力。

12.1.2 open 与 closed:两种隔离哲学

下图展示了 Shadow DOM 的 open 和 closed 两种模式在微前端场景下的信息可访问性差异:

attachShadow 接受一个 mode 参数,它决定了外部代码能否通过 JavaScript 访问 Shadow DOM 内部:

typescript
// mode: 'open' —— 协作式隔离
const openShadow = element.attachShadow({ mode: 'open' });
// 外部可以通过 element.shadowRoot 访问内部 DOM
console.log(element.shadowRoot); // ShadowRoot {...}
console.log(element.shadowRoot.querySelector('.title')); // <div class="title">

// mode: 'closed' —— 强制式隔离
const closedShadow = element.attachShadow({ mode: 'closed' });
// 外部无法通过标准 API 访问内部 DOM
console.log(element.shadowRoot); // null

这两种模式背后是截然不同的设计哲学:

特性open 模式closed 模式
element.shadowRoot返回 ShadowRoot返回 null
外部 JS 可否操作内部 DOM可以不可以(标准途径)
CSS 隔离完全隔离完全隔离
事件 retarget
适用场景组件库、微前端容器安全敏感的第三方组件
浏览器原生使用<video><input><video> 的内部控件

在微前端场景中,绝大多数时候应该选择 open 模式。原因很实际:

typescript
// 微前端主应用可能需要与子应用的 Shadow DOM 交互
class MicroAppContainer extends HTMLElement {
  private shadow: ShadowRoot;

  constructor() {
    super();
    // 使用 open 模式,允许主应用在必要时操作内部 DOM
    // 比如:注入全局样式变量、监控子应用状态、错误捕获
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  // 主应用可能需要向子应用注入主题变量
  injectThemeVariables(variables: Record<string, string>): void {
    const styleEl = document.createElement('style');
    const cssVars = Object.entries(variables)
      .map(([key, value]) => `--${key}: ${value};`)
      .join('\n');
    styleEl.textContent = `:host { ${cssVars} }`;
    this.shadow.appendChild(styleEl);
  }
}

closed 模式虽然看似更安全,但实际上存在一个尴尬的事实——它并不能真正阻止恶意访问。通过拦截 Element.prototype.attachShadow,攻击者完全可以在组件创建之前截获 ShadowRoot 引用:

typescript
// 绕过 closed 模式的"攻击"手段
const originalAttachShadow = Element.prototype.attachShadow;
const shadowRootMap = new WeakMap<Element, ShadowRoot>();

Element.prototype.attachShadow = function(init: ShadowRootInit): ShadowRoot {
  const shadowRoot = originalAttachShadow.call(this, init);
  // 即使是 closed 模式,这里也能拿到 shadowRoot 引用
  shadowRootMap.set(this, shadowRoot);
  return shadowRoot;
};

// 后续代码可以通过 shadowRootMap.get(element) 获取任何元素的 ShadowRoot

💡 深度洞察closed 模式的设计初衷不是防御恶意代码——那是安全沙箱(如 iframe)的工作。它的真正价值在于声明意图:告诉组件的使用者"请不要依赖我的内部结构,因为它随时可能变化"。这和面向对象编程中 private 的理念一致——防君子不防小人,但对代码维护极有价值。

12.1.3 样式隔离的细节:什么能穿透,什么不能

下图展示了 Shadow DOM 样式隔离的边界,区分了被阻断和可穿透的不同类型的样式规则:

Shadow DOM 的样式隔离不是"绝对的墙",更像是"有窗户的墙"。理解哪些东西能穿透、哪些不能,对于微前端的样式管理至关重要。

typescript
// 演示样式穿透行为
class StylePenetrationDemo extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .box {
          padding: 20px;
          border: 1px solid #ccc;
        }
      </style>
      <div class="box">
        <p>观察我的字体和颜色</p>
        <a href="#">观察我是否有下划线</a>
      </div>
    `;
  }
}
customElements.define('style-demo', StylePenetrationDemo);
html
<style>
  body {
    font-family: 'Microsoft YaHei', sans-serif;
    color: #333;
    font-size: 14px;
    line-height: 1.6;
  }
  a { color: red; text-decoration: none; }
  p { margin-bottom: 20px; }
</style>

<style-demo></style-demo>

能穿透 Shadow Boundary 的:

CSS 属性穿透行为原因
font-family继承穿透可继承属性
color继承穿透可继承属性
font-size继承穿透可继承属性
line-height继承穿透可继承属性
CSS Custom Properties继承穿透设计如此,这是特性

不能穿透 Shadow Boundary 的:

CSS 属性/选择器被阻挡原因
标签选择器 p { }阻挡选择器无法穿透
类选择器 .box { }阻挡选择器无法穿透
a { color: red }阻挡选择器无法穿透
全局重置 * { }阻挡选择器无法穿透

这意味着在微前端场景中,CSS 自定义属性(Custom Properties)是主应用向子应用传递设计令牌(Design Tokens)的最佳通道

typescript
// 主应用:通过 CSS Custom Properties 传递设计体系
class ThemeAwareMicroApp extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
        }
        .header {
          /* 使用主应用传递的设计令牌,提供合理的 fallback */
          background: var(--theme-primary, #1890ff);
          color: var(--theme-text-inverse, #fff);
          padding: var(--theme-spacing-md, 16px);
          border-radius: var(--theme-radius, 4px);
          font-size: var(--theme-font-size-lg, 18px);
        }
        .body {
          padding: var(--theme-spacing-md, 16px);
          color: var(--theme-text-primary, #333);
          background: var(--theme-bg-primary, #fff);
        }
      </style>
      <div class="header">
        <slot name="title">默认标题</slot>
      </div>
      <div class="body">
        <slot></slot>
      </div>
    `;
  }
}
customElements.define('theme-aware-app', ThemeAwareMicroApp);

基于 VitePress 构建