Web Components 与 Shadow DOM
Web Components 是浏览器原生提供的组件化方案,由 Custom Elements(自定义元素)、Shadow DOM(影子 DOM)、HTML Templates(模板)三件套组成。它不依赖任何框架,却能实现"封装、复用、隔离",是跨框架共享组件的基础设施。本文逐一拆解三大技术,并讨论它与框架的定位差异。
一、Web Components 三要素
| 技术 | 作用 | 类比框架中的概念 |
|---|---|---|
| Custom Elements | 定义新的 HTML 标签并绑定行为 | 组件类 |
| Shadow DOM | 封装内部结构与样式,与外部隔离 | scoped CSS、样式作用域 |
| HTML Templates | 声明可复用的结构片段 | 模板字符串、render 函数 |
三者可以独立使用,组合起来才能构建完整的封装组件。
二、自定义元素(Custom Elements)
定义与注册
用 customElements.define() 注册一个自定义元素。类必须继承 HTMLElement,元素名必须包含连字符(如 <my-card>),避免与原生元素冲突:
<my-card title="你好"></my-card>class MyCard extends HTMLElement {
constructor() {
super();
this.innerHTML = `<h3></h3><p>卡片内容</p>`;
}
connectedCallback() {
this.querySelector("h3").textContent = this.getAttribute("title");
}
}
customElements.define("my-card", MyCard);| 命名规则 | 要求 |
|---|---|
| 必须含连字符 | <my-card> 合法,<mycard> 非法 |
| 不能与内置元素重名 | 如不能定义 button |
| 名称规范 | 小写字母与连字符组合,不能以数字开头 |
生命周期回调
自定义元素从创建到销毁有一系列钩子,分别在对应时机被浏览器调用:
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
constructor() | 元素被创建(实例化)时 | 初始化状态、创建 Shadow DOM |
connectedCallback() | 元素被插入文档时 | 绑定事件、发起请求、渲染内容 |
disconnectedCallback() | 元素从文档移除时 | 解绑事件、清理定时器 |
attributeChangedCallback(name, oldValue, newValue) | 被观察的属性发生变化时 | 根据属性更新界面 |
adoptedCallback() | 元素被移动到新文档时 | 跨文档迁移处理 |
class MyCounter extends HTMLElement {
static observedAttributes = ["count"]; // 声明要观察的属性
constructor() {
super();
this._count = 0;
}
connectedCallback() {
this.render();
this.addEventListener("click", this.increment);
}
disconnectedCallback() {
this.removeEventListener("click", this.increment);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "count" && oldValue !== newValue) {
this._count = Number(newValue);
this.render();
}
}
increment = () => {
this._count++;
this.setAttribute("count", this._count); // 触发 attributeChangedCallback
};
render() {
this.textContent = `计数:${this._count}`;
}
}
customElements.define("my-counter", MyCounter);扩展内置元素
通过继承具体内置元素类并指定 extends,可以增强原生元素,例如给按钮增加能力:
<button is="fancy-button">普通按钮</button>class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.style.borderRadius = "8px";
this.style.padding = "8px 16px";
}
}
customElements.define("fancy-button", FancyButton, { extends: "button" });注意扩展内置元素必须使用 is 属性引用,且部分浏览器(如旧版 Safari)支持不佳。
三、Shadow DOM
attachShadow 与样式隔离
Shadow DOM 让组件拥有独立的影子树,内部样式与外部完全隔离:
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" }); // open 表示可通过元素访问影子树
shadow.innerHTML = `
<style>
.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
</style>
<div class="card">
<slot></slot>
</div>
`;
}
}
customElements.define("my-card", MyCard);外部页面的 CSS 无法穿透进入影子树,影子树内部的样式(如 .card)也不会泄漏到外部——这正是组件封装的关键。
mode 取值 | 含义 | 外部访问方式 |
|---|---|---|
open | 影子树可被外部访问 | element.shadowRoot 返回影子树 |
closed | 影子树对外不可见 | element.shadowRoot 返回 null |
:host 与 :host-context
:host 选择器让组件可以从内部设置自身(宿主元素)的样式;外部则可以通过自定义属性或类选择器设置宿主的对外样式:
/* 影子树内部的样式 */
:host {
display: block;
font-family: sans-serif;
}
/* 宿主元素拥有 .highlight 类时生效(配合外部 class 使用) */
:host(.highlight) .card {
border-color: #f95f5f;
}
/* :host-context 根据外部祖先环境调整 */
:host-context(.dark-theme) {
color: #fff;
}slot 插槽与命名插槽
<slot> 是影子树中的"占位符",外部内容会被"塞进"对应插槽。命名插槽(name 属性)实现精确分发:
class MyLayout extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.header { background: #f0f0f0; padding: 8px; }
.content { padding: 8px; }
</style>
<header class="header"><slot name="title">默认标题</slot></header>
<main class="content"><slot>默认内容</slot></main>
`;
}
}
customElements.define("my-layout", MyLayout);<my-layout>
<span slot="title">我的标题</span>
<p>这里是正文内容</p>
</my-layout>| 插槽类型 | 用法 | 回退内容 |
|---|---|---|
| 默认插槽 | 不带 name 的 <slot> | 外部所有未命名内容进入 |
| 命名插槽 | <slot name="title"> | 外部带 slot="title" 的内容进入 |
事件重定向
影子树内冒泡的事件默认不会泄露到外部 DOM;即便重新抛到外部,event.target 也会被重定向为宿主元素(composedPath() 可以取到真实目标):
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `<button><slot></slot></button>`;
}
}
customElements.define("my-button", MyButton);// 外部监听:点击影子内的 button,这里也能收到
document.querySelector("my-button").addEventListener("click", (event) => {
console.log(event.target); // 重定向为 <my-button> 宿主元素
console.log(event.composedPath()[0]); // 真实点击目标(影子内的 button)
});四、HTML Template 与 slot
template 元素
<template> 里的内容不会渲染、不会加载其中的资源,只是"蓝图"。通过 content 取到文档片段后克隆使用:
<template id="card-tpl">
<div class="card">
<h3 class="title"></h3>
<p class="desc"></p>
</div>
</template>const tpl = document.getElementById("card-tpl");
function createCard(title, desc) {
const node = tpl.content.cloneNode(true); // 深克隆模板内容
node.querySelector(".title").textContent = title;
node.querySelector(".desc").textContent = desc;
return node;
}
document.body.appendChild(createCard("标题一", "描述一"));template 与 slot 组合
把 template 的内容放进 Shadow DOM,同时保留插槽,组件结构声明更清晰:
<template id="my-card-tpl">
<style>
:host { display: block; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
</style>
<slot name="title">默认标题</slot>
<slot></slot>
</template>class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const tpl = document.getElementById("my-card-tpl");
shadow.appendChild(tpl.content.cloneNode(true));
}
}
customElements.define("my-card", MyCard);五、自定义元素与框架对比
| 对比项 | Web Components | Vue / React 组件 |
|---|---|---|
| 依赖 | 无,浏览器原生 | 需要引入框架与构建工具 |
| 样式隔离 | Shadow DOM 原生隔离 | 依赖 scoped / CSS Modules 等方案 |
| 数据响应 | 需自己实现或借助属性监听 | 框架内置响应式系统 |
| 状态管理 | 无内置,自己处理 | 各有生态方案(Pinia、Redux 等) |
| 跨框架复用 | 天然可跨 React/Vue/原生使用 | 同一框架内复用,跨框架困难 |
| 生态与工具链 | 较少,样板代码多 | 丰富成熟,开发效率高 |
| 学习成本 | 中(涉及 DOM 细节) | 中(涉及框架心智模型) |
互补关系:框架内优先用框架组件提升效率;需要跨团队、跨技术栈共享的公共组件(如设计系统、埋点组件、地图组件)可以用 Web Components 封装,任何框架都能直接使用。
六、实际应用场景
| 场景 | 说明 |
|---|---|
| 跨框架设计系统 | 用 Web Components 封装按钮、弹窗等基础组件,供多框架团队共用 |
| 第三方嵌入组件 | 聊天窗口、客服组件嵌入任意网站,样式互不干扰 |
| 微前端共享 | 不同技术栈的子应用共用同一套原生组件 |
| 浏览器扩展 | 扩展 UI 与宿主页面隔离,防止样式污染 |
| 低代码平台 | 运行时动态生成自定义元素组合页面 |
完整示例:一个带插槽与属性观察的徽章组件
<my-badge color="red" text="新品"></my-badge>class MyBadge extends HTMLElement {
static observedAttributes = ["text", "color"];
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
span {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #fff;
background: #4f9cf9;
}
</style>
<span></span>
`;
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const span = this.shadowRoot.querySelector("span");
span.textContent = this.getAttribute("text") || "";
const color = this.getAttribute("color");
if (color) span.style.background = color;
}
}
customElements.define("my-badge", MyBadge);Web Components 用浏览器原生的方式回答了"如何封装一个自带结构、样式与行为的标签"这个问题。它在框架之上补足了"跨框架复用"的一环,理解其隔离与通信模型后,无论是独立使用还是嵌入框架,都能发挥价值。