欢迎光临

Web Components 与 Shadow DOM 深度实战:构建框架无关的可复用组件库

在前端生态被 React、Vue、Angular 三大框架统治的今天,很多开发者忽略了浏览器原生提供的组件化方案——Web Components。它不依赖任何框架,由 W3C 标准定义,可以在任何环境中使用,甚至可以跨框架复用。本文将深入讲解 Web Components 的核心技术:Custom Elements、Shadow DOM、HTML Templates 与 Slots,并通过实战案例带你构建一个完整可用的组件库。

Web Components 开发

一、Web Components 是什么?为什么需要它?

Web Components 是一组浏览器原生技术的统称,它让开发者能够创建可复用、封装良好的自定义元素。这套技术包含三个核心规范:

  • Custom Elements:定义新的 HTML 标签及其行为逻辑
  • Shadow DOM:提供 CSS 和 DOM 的样式与结构隔离
  • HTML Templates & Slots:定义可复用的 HTML 片段和内容分发插槽

与 React/Vue 组件相比,Web Components 最大的优势在于框架无关性。一个编写良好的 Web Component 可以同时在 React、Vue、Angular 甚至纯 HTML 项目中使用,无需任何适配层。此外,因为它是浏览器原生 API,不存在框架运行时开销,也无需打包编译。

适用场景

Web Components 并非要取代框架,而是在特定场景下发挥独特价值:

场景 是否适合 Web Components 说明
跨团队/跨框架组件库 非常适合 如 Design System,多团队多技术栈共用
微前端架构 非常适合 各子应用用不同框架,用 WC 做集成层
独立嵌入组件 非常适合 如第三方评论框、支付组件
复杂状态管理应用 不太适合 框架的响应式系统更适合
SSR 密集型应用 需评估 SSR 支持有限,需配合 Declarative Shadow DOM

二、Custom Elements:定义你的第一个自定义元素

Custom Elements 是 Web Components 的灵魂。通过

1
customElements.define()

,你可以注册一个全新的 HTML 标签,并为其绑定生命周期回调。

2.1 基本写法

自定义元素的类必须继承

1
HTMLElement

(或其子类如

1
HTMLButtonElement

)。元素名称必须包含连字符(如

1
my-button

),以避免与原生 HTML 标签冲突。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class MyButton extends HTMLElement {
  constructor() {
    super();
    this._count = 0;
  }

  // 元素被插入 DOM 时触发
  connectedCallback() {
    this.render();
    this.addEventListener('click', this.handleClick);
  }

  // 元素从 DOM 移除时触发
  disconnectedCallback() {
    this.removeEventListener('click', this.handleClick);
  }

  // 监听属性变化
  static get observedAttributes() {
    return ['variant', 'disabled'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  handleClick = () => {
    this._count++;
    this.dispatchEvent(new CustomEvent('my-click', {
      detail: { count: this._count },
      bubbles: true,
      composed: true,
    }));
  };

  render() {
    const variant = this.getAttribute('variant') || 'primary';
    const disabled = this.hasAttribute('disabled');
    this.innerHTML = `<button class="btn btn-${variant}" ${disabled ? 'disabled' : ''}>
      <slot></slot>
    </button>`;
  }
}

customElements.define('my-button', MyButton);

使用时就像普通 HTML 标签一样:


1
2
&lt;my-button variant="primary"&gt;点击我&lt;/my-button&gt;
&lt;my-button variant="danger" disabled&gt;禁用按钮&lt;/my-button&gt;

2.2 生命周期回调详解

Custom Elements 提供了丰富的生命周期回调,与 React 组件生命周期有相似之处但语义不同:

回调方法 触发时机 常见用途
1
constructor
元素创建时(尚不在 DOM 中) 初始化状态、绑定 Shadow DOM
1
connectedCallback
元素插入 DOM 启动定时器、事件监听、数据请求
1
disconnectedCallback
元素从 DOM 移除 清理定时器、移除监听、释放资源
1
adoptedCallback
元素被移到新 document iframe 场景处理
1
attributeChangedCallback
observedAttributes 中属性变化 响应外部属性更新,触发重渲染

关键陷阱

1
constructor

中不能访问子元素或读取属性(此时可能还没设置),DOM 操作应放到

1
connectedCallback

中。

三、Shadow DOM:真正的样式与 DOM 隔离

Shadow DOM 是 Web Components 实现封装的核心机制。它将一棵独立的 DOM 子树挂载到宿主元素上,这棵子树与外部文档的 CSS 和 JavaScript 完全隔离。

Shadow DOM 隔离机制

3.1 创建 Shadow DOM


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class MyCard extends HTMLElement {
  constructor() {
    super();
    // open: 外部可通过 element.shadowRoot 访问
    // closed: 外部无法访问(element.shadowRoot 为 null)
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      &lt;style&gt;
        :host {
          display: block;
          border: 1px solid #e0e0e0;
          border-radius: 8px;
          padding: 16px;
          margin: 8px 0;
          font-family: sans-serif;
        }
        .title {
          font-size: 18px;
          font-weight: 600;
          color: #1a1a1a;
          margin: 0 0 8px;
        }
        .body {
          color: #555;
          line-height: 1.6;
        }
      &lt;/style&gt;
      &lt;div class="card"&gt;
        &lt;div class="title"&gt;&lt;slot name="title"&gt;默认标题&lt;/slot&gt;&lt;/div&gt;
        &lt;div class="body"&gt;&lt;slot&gt;默认内容&lt;/slot&gt;&lt;/div&gt;
      &lt;/div&gt;
    `;
  }
}

customElements.define('my-card', MyCard);

使用方式:


1
2
3
4
&lt;my-card&gt;
  &lt;span slot="title"&gt;Shadow DOM 实战&lt;/span&gt;
  &lt;p&gt;这段内容会被分发到默认插槽中。&lt;/p&gt;
&lt;/my-card&gt;

3.2 样式隔离与穿透

Shadow DOM 内的样式不会泄漏到外部,外部样式也不会影响 Shadow DOM 内部(除了可继承属性如

1
color

1
font-family

)。这带来了几个关键技巧:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* :host 选择器指向宿主元素本身 */
:host {
  display: block;
}

/* :host() 可以根据宿主属性匹配 */
:host([theme="dark"]) {
  background: #1a1a1a;
  color: #fff;
}

/* :host-context() 根据外部祖先元素匹配 */
:host-context(.dark-mode) {
  background: #222;
}

/* ::slotted() 选择被分发到 slot 的元素 */
::slotted(img) {
  max-width: 100%;
  border-radius: 4px;
}

/* CSS 变量可以从外部穿透 Shadow DOM */
:host {
  --card-bg: #ffffff;
  background: var(--card-bg);
}

/* 外部可以覆盖:my-card { --card-bg: #f5f5f5; } */

3.3 open vs closed 模式

1
attachShadow({ mode: 'open' })

允许通过

1
element.shadowRoot

访问内部 DOM,方便调试和外部交互。

1
closed

模式则完全封闭。生产环境建议用

1
open

,因为

1
closed

并不能提供真正的安全保障,反而给调试带来困难。

四、Templates 与 Slots:内容分发机制

1
&lt;template&gt;

标签定义了一段不会被渲染的 HTML 片段,可以通过 JavaScript 克隆并插入文档。

1
&lt;slot&gt;

则实现了内容分发,让组件使用者可以将自定义内容投射到组件内部的指定位置。

4.1 使用 template 优化性能


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
&lt;template id="row-template"&gt;
  &lt;tr&gt;
    &lt;td class="name"&gt;&lt;/td&gt;
    &lt;td class="age"&gt;&lt;/td&gt;
    &lt;td class="action"&gt;
      &lt;button class="delete"&gt;删除&lt;/button&gt;
    &lt;/td&gt;
  &lt;/tr&gt;
&lt;/template&gt;

&lt;script&gt;
class DataTable extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    const tpl = document.getElementById('row-template');
   
    // 克隆 template 内容,比 innerHTML 字符串解析更快
    shadow.appendChild(tpl.content.cloneNode(true));
  }
}
&lt;/script&gt;
1
template.content.cloneNode(true)

比字符串拼接后

1
innerHTML

赋值更高效,因为浏览器在解析

1
&lt;template&gt;

时已经构建好了 DOM 树,克隆时无需重新解析 HTML。

4.2 具名插槽与默认插槽

一个组件可以包含多个插槽,通过

1
name

属性区分:


1
2
3
4
5
6
7
8
9
10
11
12
13
&lt;!-- 组件内部 --&gt;
&lt;article class="layout"&gt;
  &lt;header&gt;&lt;slot name="header"&gt;&lt;/slot&gt;&lt;/header&gt;
  &lt;main&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/main&gt;  &lt;!-- 默认插槽 --&gt;
  &lt;footer&gt;&lt;slot name="footer"&gt;默认页脚&lt;/slot&gt;&lt;/footer&gt;
&lt;/article&gt;

&lt;!-- 使用方式 --&gt;
&lt;my-layout&gt;
  &lt;h1 slot="header"&gt;页面标题&lt;/h1&gt;
  &lt;p&gt;主体内容会被放入默认插槽。&lt;/p&gt;
  &lt;p&gt;多段内容都会进入默认插槽。&lt;/p&gt;
&lt;/my-layout&gt;

五、实战:构建一个可复用的 Modal 对话框组件

下面我们将所学知识整合,构建一个生产级的 Modal 组件。它包含:属性驱动的显示/隐藏、事件回调、动画、键盘交互、Shadow DOM 样式隔离。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
class MyModal extends HTMLElement {
  constructor() {
    super();
    this._shadow = this.attachShadow({ mode: 'open' });
    this._shadow.innerHTML = `
      &lt;style&gt;
        :host {
          position: fixed;
          top: 0; left: 0; right: 0; bottom: 0;
          display: none;
          z-index: 9999;
        }
        :host([open]) {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .overlay {
          position: absolute;
          top: 0; left: 0; right: 0; bottom: 0;
          background: rgba(0,0,0,0.5);
          opacity: 0;
          transition: opacity 0.3s;
        }
        :host([open]) .overlay {
          opacity: 1;
        }
        .dialog {
          position: relative;
          background: var(--modal-bg, #fff);
          border-radius: 12px;
          padding: 24px;
          min-width: 320px;
          max-width: 90vw;
          max-height: 85vh;
          overflow: auto;
          box-shadow: 0 8px 32px rgba(0,0,0,0.15);
          transform: scale(0.9);
          transition: transform 0.3s;
        }
        :host([open]) .dialog {
          transform: scale(1);
        }
        .header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 16px;
        }
        .close-btn {
          border: none;
          background: none;
          font-size: 20px;
          cursor: pointer;
          color: #999;
          padding: 4px 8px;
        }
        .close-btn:hover { color: #333; }
      &lt;/style&gt;
      &lt;div class="overlay"&gt;&lt;/div&gt;
      &lt;div class="dialog"&gt;
        &lt;div class="header"&gt;
          &lt;slot name="title"&gt;&lt;span&gt;对话框&lt;/span&gt;&lt;/slot&gt;
          &lt;button class="close-btn"&gt;&amp;times;&lt;/button&gt;
        &lt;/div&gt;
        &lt;slot&gt;&lt;/slot&gt;
      &lt;/div&gt;
    `;
  }

  connectedCallback() {
    this._shadow.querySelector('.overlay')
      .addEventListener('click', () =&gt; this.close());
    this._shadow.querySelector('.close-btn')
      .addEventListener('click', () =&gt; this.close());
    document.addEventListener('keydown', this._onKeyDown);
  }

  disconnectedCallback() {
    document.removeEventListener('keydown', this._onKeyDown);
  }

  _onKeyDown = (e) =&gt; {
    if (e.key === 'Escape' &amp;&amp; this.hasAttribute('open')) {
      this.close();
    }
  };

  open() {
    this.setAttribute('open', '');
    this.dispatchEvent(new CustomEvent('my-modal-open', {
      bubbles: true, composed: true
    }));
  }

  close() {
    this.removeAttribute('open');
    this.dispatchEvent(new CustomEvent('my-modal-close', {
      bubbles: true, composed: true
    }));
  }
}

customElements.define('my-modal', MyModal);

使用方式:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
&lt;my-modal id="confirm-modal"&gt;
  &lt;span slot="title"&gt;确认操作&lt;/span&gt;
  &lt;p&gt;你确定要执行此操作吗?&lt;/p&gt;
  &lt;button onclick="document.getElementById('confirm-modal').close()"&gt;
    取消
  &lt;/button&gt;
&lt;/my-modal&gt;

&lt;button onclick="document.getElementById('confirm-modal').open()"&gt;
  打开对话框
&lt;/button&gt;

&lt;script&gt;
  document.addEventListener('my-modal-close', (e) =&gt; {
    console.log('Modal 已关闭', e.target);
  });
&lt;/script&gt;

六、进阶技巧与最佳实践

6.1 响应式属性与 Proxy

Web Components 没有内置响应式系统,但可以借助 ES6 Proxy 或 getter/setter 实现:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ReactiveElement extends HTMLElement {
  #state = {};

  set state(value) {
    this.#state = new Proxy(value, {
      set: (target, key, val) =&gt; {
        target[key] = val;
        this.update();
        return true;
      }
    });
    this.update();
  }

  get state() {
    return this.#state;
  }

  update() {
    // 触发重新渲染
    this.render();
  }

  render() { /* ... */ }
}

6.2 在 React/Vue 中使用 Web Components

在 React 中使用 Web Components 时,注意 React 对自定义元素的属性传递需要特殊处理。React 19 已原生支持 Web Components,旧版 React 需要用 ref 手动设置属性:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// React 19+ 可以直接使用
function App() {
  return (
    &lt;my-modal open={true} onMyModalClose={() =&gt; console.log('closed')}&gt;
      &lt;span slot="title"&gt;React 集成&lt;/span&gt;
      &lt;p&gt;来自 React 的内容&lt;/p&gt;
    &lt;/my-modal&gt;
  );
}

// React 18 及以下需要 ref
function OldReactApp() {
  const modalRef = useRef();
  useEffect(() =&gt; {
    const modal = modalRef.current;
    modal.addEventListener('my-modal-close', handleClose);
    return () =&gt; modal.removeEventListener('my-modal-close', handleClose);
  }, []);

  return &lt;my-modal ref={modalRef}&gt;...&lt;/my-modal&gt;;
}

在 Vue 中则更加自然,Vue 原生支持自定义元素:


1
2
3
4
5
6
&lt;template&gt;
  &lt;my-modal :open="visible" @my-modal-close="visible = false"&gt;
    &lt;span slot="title"&gt;Vue 集成&lt;/span&gt;
    &lt;p&gt;来自 Vue 的内容&lt;/p&gt;
  &lt;/my-modal&gt;
&lt;/template&gt;

6.3 使用 Lit 简化开发

原生 Web Components 的模板代码比较冗长。Google 开源的

1
Lit

库提供了响应式属性声明和模板字面量语法,大幅简化开发:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('lit-counter')
class LitCounter extends LitElement {
  static styles = css`
    :host { display: block; padding: 20px; }
    button { font-size: 16px; padding: 8px 16px; }
  `;

  @property({ type: Number })
  count = 0;

  render() {
    return html`
      &lt;button @click=${() =&gt; this.count++}&gt;
        点击次数:${this.count}
      &lt;/button&gt;
    `;
  }
}

Lit 的核心优势:声明式响应式属性、模板自动更新、CSS 模板隔离、约 5KB 的运行时体积。对于构建大型组件库,强烈推荐使用 Lit。

七、浏览器兼容性与 polyfill

截至 2025 年,Web Components 的核心 API 在所有主流浏览器中均已原生支持:

特性 Chrome Firefox Safari Edge
Custom Elements v1 67+ 63+ 10.1+ 79+
Shadow DOM v1 53+ 63+ 10+ 79+
HTML Templates 26+ 22+ 8+ 79+
Declarative Shadow DOM 112+ 123+ 17+ 112+

对于需要支持旧浏览器的项目,可以使用

1
@webcomponents/webcomponentsjs

polyfill,但会增加约 60KB 的体积。现代项目基本无需 polyfill。

八、总结

Web Components 提供了一套浏览器原生的组件化方案,其框架无关性使其在跨技术栈场景中具有不可替代的价值。核心要点回顾:

  • Custom Elements 定义自定义标签和生命周期,元素名必须含连字符
  • Shadow DOM 实现 CSS 和 DOM 隔离,
    1
    :host

    1
    ::slotted()

    、CSS 变量是关键穿透技巧

  • Templates & Slots 提供高效的 HTML 复用和内容分发机制
  • 事件通信 使用
    1
    CustomEvent

    +

    1
    bubbles: true, composed: true

    穿透 Shadow DOM

  • Lit 库可大幅简化开发,适合构建大型组件库

Web Components 不是要取代 React 或 Vue,而是为特定场景提供补充方案。在设计系统、微前端、第三方嵌入组件等领域,它往往是比框架组件更好的选择。掌握 Web Components,你就拥有了一套跨越所有框架的组件化能力,这在技术栈频繁更迭的前端世界,是一笔宝贵的资产。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Web Components 与 Shadow DOM 深度实战:构建框架无关的可复用组件库
分享到: 更多 (0)