深入理解 Web Components
Custom Elements、Shadow DOM、Template/Slot、Declarative Shadow DOM 完整 API 与最佳实践
什么是 Web Components?
定义:Web Components 是一组浏览器原生的组件化标准,由 Custom Elements(自定义元素)、Shadow DOM(影子 DOM)和 HTML Templates(模板与插槽)三项核心技术组成。它允许开发者创建可复用、封装良好、与框架无关的自定义 HTML 元素。
涉及场景:
- 跨框架组件库:同一套组件可在 React、Vue、Angular 甚至原生页面中使用
- 设计系统:企业级设计系统(如 Shoelace、Spectrum)用 Web Components 保证一致性
- 微前端:不同技术栈的子应用通过 Web Components 集成,样式互不污染
- 第三方嵌入:嵌入到客户页面的 Widget(聊天窗口、支付按钮)用 Shadow DOM 隔离样式
- SSR 友好:Declarative Shadow DOM 让 Web Components 支持服务端渲染
- 表单增强:
ElementInternals让自定义元素无缝参与原生表单验证和提交
作用:
- 真正的封装:Shadow DOM 隔离 CSS 和 DOM,组件内部样式不会泄漏或被外部覆盖
- 浏览器原生:无需框架支持,浏览器直接解析和渲染
- 长期稳定:作为 Web 标准,不会像框架一样频繁变更 API
- 面试考点:Shadow DOM 的样式隔离原理、事件穿透(
composed)、::part()穿透方式是常见考题
Web Components 三大核心技术
Web Components = Custom Elements + Shadow DOM + HTML Templates
┌──────────────────────────────────────────────┐
│ Custom Elements │
│ 定义自定义 HTML 元素及其行为 │
├──────────────────────────────────────────────┤
│ Shadow DOM │
│ 封装组件的 DOM 和样式,与外部隔离 │
├──────────────────────────────────────────────┤
│ HTML Templates (<template> + <slot>) │
│ 定义可复用的 HTML 片段和内容插槽 │
└──────────────────────────────────────────────┘Custom Elements
定义自定义元素
javascript
// 自主定制元素(Autonomous Custom Element)
class MyButton extends HTMLElement {
// 1. 声明需要监听的属性
static get observedAttributes() {
return ['variant', 'size', 'disabled'];
}
// 2. 表单关联(可选)
static get formAssociated() {
return true;
}
constructor() {
super();
// 不要在构造函数中访问属性或子元素
// 可以:设置初始状态、创建 Shadow DOM、添加事件监听
this.attachShadow({ mode: 'open' });
this._internals = this.attachInternals(); // 表单内部状态
}
// 3. 生命周期回调
connectedCallback() {
// 元素被添加到 DOM 时调用
// 可以安全地访问属性和子元素
this.render();
this.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
// 元素从 DOM 中移除时调用
// 清理:移除事件监听、取消定时器、断开观察者
this.removeEventListener('click', this._handleClick);
}
adoptedCallback() {
// 元素被移动到新文档时调用(document.adoptNode)
// 很少使用
}
attributeChangedCallback(name, oldValue, newValue) {
// observedAttributes 中声明的属性变化时调用
if (oldValue !== newValue) {
this.render();
}
}
// 4. 自定义方法
_handleClick = (e) => {
if (this.hasAttribute('disabled')) {
e.preventDefault();
e.stopPropagation();
return;
}
this.dispatchEvent(new CustomEvent('my-click', {
bubbles: true,
composed: true, // 穿透 Shadow DOM 边界
detail: { originalEvent: e }
}));
};
render() {
const variant = this.getAttribute('variant') || 'default';
const size = this.getAttribute('size') || 'medium';
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
button {
padding: ${size === 'small' ? '4px 8px' : size === 'large' ? '12px 24px' : '8px 16px'};
border: 1px solid;
border-radius: 4px;
cursor: pointer;
font-size: inherit;
background: ${variant === 'primary' ? '#1890ff' : '#fff'};
color: ${variant === 'primary' ? '#fff' : '#333'};
}
</style>
<button part="button">
<slot></slot>
</button>
`;
}
}
// 注册自定义元素(名称必须包含连字符)
customElements.define('my-button', MyButton);html
<!-- 使用 -->
<my-button variant="primary" size="large">点击我</my-button>
<my-button disabled>禁用状态</my-button>自定义内置元素(Customized Built-in Element)
javascript
// 扩展已有的 HTML 元素
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => {
this.style.transform = 'scale(0.95)';
setTimeout(() => this.style.transform = '', 150);
});
}
connectedCallback() {
this.style.transition = 'transform 0.15s';
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });html
<!-- 使用 is 属性 -->
<button is="fancy-button">Fancy!</button>
<!-- ⚠️ Safari 不支持自定义内置元素 -->customElements API
javascript
// 定义
customElements.define('my-element', MyElement);
customElements.define('fancy-btn', FancyBtn, { extends: 'button' });
// 获取已注册的构造函数
const MyElement = customElements.get('my-element');
// 等待元素被定义
await customElements.whenDefined('my-element');
// 返回 Promise,元素定义后 resolve
// 升级未定义的元素
customElements.upgrade(element);
// 检查元素名称是否有效
// 必须包含连字符 (-)
// 不能以数字开头
// 全小写Shadow DOM
创建 Shadow DOM
javascript
class MyCard extends HTMLElement {
constructor() {
super();
// mode: 'open' — 外部可通过 element.shadowRoot 访问
// mode: 'closed' — 外部无法访问(更安全)
const shadow = this.attachShadow({ mode: 'open' });
// 其他选项
this.attachShadow({
mode: 'open',
delegatesFocus: true, // 焦点委托:点击 shadow 内部时自动聚焦
slotAssignment: 'manual' // 手动分配 slot(而非按 name 自动匹配)
});
}
}Shadow DOM 的样式隔离
javascript
class StyledComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* :host 选择宿主元素本身 */
:host {
display: block;
border: 1px solid #ddd;
padding: 16px;
}
/* :host() 条件选择 */
:host(.dark) {
background: #333;
color: #fff;
}
/* :host-context() 根据祖先元素匹配 */
:host-context(.theme-dark) {
background: #1a1a1a;
}
/* 内部样式不会泄漏到外部 */
p {
color: blue; /* 只影响 shadow 内部的 p */
}
/* ::slotted() 选择被分配到 slot 的元素 */
::slotted(h2) {
color: red;
/* 只能选择直接分配的顶级元素,不能选子元素 */
}
/* ::part() 配合 part 属性,允许外部自定义样式 */
/* 在外部使用:my-component::part(title) { color: blue; } */
</style>
<div part="header">
<slot name="title"></slot>
</div>
<div part="body">
<slot></slot>
</div>
`;
}
}css
/* 外部样式(穿透 Shadow DOM 的方式) */
/* 1. CSS 自定义属性可以穿透 */
my-component {
--primary-color: blue;
/* shadow 内部可以用 var(--primary-color) */
}
/* 2. ::part() 选择器 */
my-component::part(title) {
font-size: 24px;
color: navy;
}
/* 3. 可继承属性自然穿透 */
my-component {
font-family: Arial; /* 会继承到 shadow 内部 */
color: #333; /* 会继承 */
}事件与 Shadow DOM
javascript
// 事件穿透 Shadow DOM 边界需要 composed: true
this.dispatchEvent(new CustomEvent('my-event', {
bubbles: true, // 冒泡
composed: true, // 穿透 Shadow DOM 边界
detail: { data: 'value' }
}));
// 原生事件的 composed 属性
// composed: true(可穿透): click, focus, blur, input, keydown, mousedown ...
// composed: false(不穿透): scroll, load, unload, abort, error, select ...
// 事件重定向(retargeting)
// 在 Shadow DOM 外部,event.target 会被重定向为宿主元素
// 在 Shadow DOM 内部,event.target 是实际的目标元素
shadow.querySelector('button').addEventListener('click', (e) => {
console.log(e.target); // <button>(shadow 内部)
console.log(e.composedPath()); // 完整的事件路径,包括 shadow 内部元素
});
document.addEventListener('click', (e) => {
console.log(e.target); // <my-component>(被重定向为宿主元素)
});HTML Templates
<template> 元素
html
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
</style>
<div class="card">
<slot name="title"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
</template>
<script>
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const template = document.getElementById('card-template');
// cloneNode(true) 深克隆模板内容
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>
<!-- 使用 -->
<my-card>
<h2 slot="title">卡片标题</h2>
<p>默认 slot 的内容</p>
<div slot="footer">底部信息</div>
</my-card>Slot(插槽)
javascript
// 监听 slot 变化
const slot = this.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
const assigned = slot.assignedNodes({ flatten: true });
console.log('slot 内容变化:', assigned);
});
// 获取 slot 分配的元素
slot.assignedElements(); // 只返回元素节点
slot.assignedNodes(); // 返回所有节点(包括文本)
slot.assignedNodes({ flatten: true }); // 展平回退内容
// 手动分配 slot(slotAssignment: 'manual')
const shadow = this.attachShadow({ mode: 'open', slotAssignment: 'manual' });
const slot = shadow.querySelector('slot');
slot.assign(element1, element2); // 手动指定哪些元素分配到此 slotDeclarative Shadow DOM(声明式 Shadow DOM)
html
<!-- 服务端渲染 Web Components!不需要 JS 就能有 Shadow DOM -->
<my-card>
<template shadowrootmode="open">
<style>
.card { border: 1px solid #ddd; padding: 16px; }
</style>
<div class="card">
<slot></slot>
</div>
</template>
<p>这是卡片内容</p>
</my-card>
<!-- shadowrootmode 值 -->
<!-- "open" — 可通过 element.shadowRoot 访问 -->
<!-- "closed" — 外部不可访问 -->
<!--
浏览器解析到 <template shadowrootmode> 时:
1. 自动创建 Shadow Root
2. 将 template 内容作为 shadow tree
3. 不需要 JavaScript!
4. 适合 SSR / SSG
-->javascript
// JS 中获取声明式 Shadow DOM
const host = document.querySelector('my-card');
console.log(host.shadowRoot); // ShadowRoot(如果 mode 是 open)
// 服务端序列化
const html = element.getHTML({ serializableShadowRoots: true });
// 包含 <template shadowrootmode> 的 HTML 字符串ElementInternals API
javascript
class MyInput extends HTMLElement {
static get formAssociated() { return true; }
static get observedAttributes() { return ['value', 'required']; }
constructor() {
super();
this._internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<input type="text" part="input" />
`;
const input = this.shadowRoot.querySelector('input');
input.addEventListener('input', (e) => {
this._internals.setFormValue(e.target.value);
this._validate(e.target.value);
});
}
_validate(value) {
if (this.hasAttribute('required') && !value) {
this._internals.setValidity(
{ valueMissing: true },
'此字段必填',
this.shadowRoot.querySelector('input')
);
} else {
this._internals.setValidity({});
}
}
// 表单相关属性
get form() { return this._internals.form; }
get validity() { return this._internals.validity; }
get validationMessage() { return this._internals.validationMessage; }
checkValidity() { return this._internals.checkValidity(); }
reportValidity() { return this._internals.reportValidity(); }
// CustomStateSet(自定义状态)
// this._internals.states.add('checked');
// CSS 中用 :state(checked) 选择
}
customElements.define('my-input', MyInput);html
<form>
<my-input name="username" required></my-input>
<button type="submit">提交</button>
</form>Adopted Stylesheets(共享样式表)
javascript
// 创建可复用的样式表
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: block; }
.container { padding: 16px; }
`);
// 多个 Shadow DOM 共享同一个样式表(性能优化)
class ComponentA extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet];
}
}
class ComponentB extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet]; // 共享,不会重复创建
}
}
// 也可以用于 document
document.adoptedStyleSheets = [sheet];
// 异步加载
const sheet2 = new CSSStyleSheet();
await sheet2.replace('@import url("styles.css")');总结
Web Components 核心知识点:
┌──────────────────────────────────────────────────────────┐
│ Custom Elements │
│ • customElements.define() 注册自定义元素 │
│ • 生命周期:constructor → connected → attributeChanged │
│ • → disconnected → adopted │
│ • observedAttributes 声明要监听的属性 │
│ • 名称必须包含连字符(my-component) │
├──────────────────────────────────────────────────────────┤
│ Shadow DOM │
│ • attachShadow({ mode: 'open'|'closed' }) │
│ • 样式隔离:内部样式不泄漏,外部样式不侵入 │
│ • 穿透方式:CSS变量、::part()、可继承属性 │
│ • 事件:composed: true 穿透边界,target 被重定向 │
├──────────────────────────────────────────────────────────┤
│ Template & Slot │
│ • <template> 惰性 DOM 片段,cloneNode 使用 │
│ • <slot name="x"> 命名插槽,默认 slot 接收未指定的内容 │
│ • ::slotted() 选择 slot 中分配的元素 │
├──────────────────────────────────────────────────────────┤
│ 新特性 │
│ • Declarative Shadow DOM:SSR 友好,无需 JS │
│ • ElementInternals:表单关联、自定义验证、状态 │
│ • Adopted Stylesheets:多组件共享样式表 │
└──────────────────────────────────────────────────────────┘