Web Components 入门实例教程:封装一个用户卡片
原作者:阮一峰。来源:Web Components 入门实例教程。原文发表于2019年8月6日;2026年10月5日核对整理,保留原文教学流程并明确补充现代使用边界。

组件是前端开发的重要组织方式,React、Vue 等框架都以组件为核心。浏览器也提供了一组原生能力,通常统称为 Web Components:不必先加载第三方框架,就可以定义自己的元素,并封装它的内容、样式与行为。
阮一峰这篇2019年的文章不是完整规范教程,而是通过一个用户卡片演示最基本的开发过程。下面按原文九个部分整理,保留全部代码示例;涉及封闭 Shadow DOM、样式隔离与生命周期的描述,则在相应位置补充边界。原文关于“已可用于生产”的判断有当年的背景,具体采用前仍应按目标浏览器及组件行为验证。
代码校勘:原文若干渐进示例在自定义元素 constructor() 中读取属性或插入子节点。按现行 HTML Standard 的自定义元素构造器约束,这些片段不应作为可直接照抄的实现;本文保留它们用于解释原文演进,并在下方给出将模板渲染和属性读取移到生命周期回调的合规示例。

一、自定义元素
目标是一个包含头像、姓名、邮箱和关注按钮的用户卡片。把它封装成组件后,使用者只需在网页中放入一个标签:
<user-card></user-card>
这种自行命名的 HTML 标签称为自定义元素(custom element)。自定义元素名称必须包含连字符,以区别浏览器自带元素,因此可以写 user-card,不能直接写成 usercard。原文的最终演示链接是 JSBin 完整代码;本稿未运行该外部演示。
二、用 customElements.define() 注册元素
先用 JavaScript 定义一个类,所有 <user-card> 元素都是该类的实例。继承 HTMLElement,就继承了 HTML 元素的基本能力。构造器内先调用 super(),初始化父类。
class UserCard extends HTMLElement {
constructor() {
super();
}
}
随后调用浏览器原生的 customElements.define(),把标签名与类建立联系:
window.customElements.define('user-card', UserCard);
注册的是标签名 user-card 与构造器 UserCard 的对应关系。示例逐步替换同一个类定义,不能把后续所有版本同时加载并反复注册同名元素。
三、给自定义元素添加内容
此时元素还是空的,可以在类里依次创建头像、容器、姓名、邮箱和按钮,再把它们连接成 DOM 树。以下是原文第一种写法:
class UserCard extends HTMLElement {
constructor() {
super();
var image = document.createElement('img');
image.src = 'https://semantic-ui.com/images/avatar2/large/kristy.png';
image.classList.add('image');
var container = document.createElement('div');
container.classList.add('container');
var name = document.createElement('p');
name.classList.add('name');
name.innerText = 'User Name';
var email = document.createElement('p');
email.classList.add('email');
email.innerText = '[email protected]';
var button = document.createElement('button');
button.classList.add('button');
button.innerText = 'Follow';
container.append(name, email, button);
this.append(image, container);
}
}
this.append(image, container) 中的 this 是当前自定义元素实例,原文用它展示 DOM 结构的创建过程。这里以及后面的构造器片段是原文演示代码,不符合现行 HTML Standard 对自定义元素构造器不得添加子节点的约束;可复用实现见下方的校正示例。姓名和邮箱写入文本时应使用 textContent,避免把数据解析成 HTML。
四、用 template 定义结构
用 JavaScript 逐一创建节点比较繁琐,可以改用 <template> 保存 HTML 结构。模板内容本身不会像普通可见节点一样直接呈现;需要克隆并插入文档或组件。
<template id="userCardTemplate">
<img src="https://semantic-ui.com/images/avatar2/large/kristy.png" class="image">
<div class="container">
<p class="name">User Name</p>
<p class="email">[email protected]</p>
<button class="button">Follow</button>
</div>
</template>
然后改写类,查找模板,并对 templateElem.content 调用 cloneNode(true):
class UserCard extends HTMLElement {
constructor() {
super();
var templateElem = document.getElementById('userCardTemplate');
var content = templateElem.content.cloneNode(true);
this.appendChild(content);
}
}
必须克隆模板里的所有子节点,而不是把它们直接搬走。因为页面上可能有多个组件实例,后面的实例仍需要使用同一份模板。true 表示连同后代节点一起深度克隆。
原文在此给出如下结构总览,展示标签、模板和注册脚本的位置:
<body>
<user-card></user-card>
<template>...</template>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
var templateElem = document.getElementById('userCardTemplate');
var content = templateElem.content.cloneNode(true);
this.appendChild(content);
}
}
window.customElements.define('user-card', UserCard);
</script>
</body>
结构示意说明:上面 <template>...</template> 是省略写法;实际模板必须保留前面示例的 id="userCardTemplate" 及完整内容。模板还需要在查找发生前可用。这一段用于说明组合关系,不是直接复制即可运行的完整文件。
五、添加样式
可以先用全局选择器选中自定义元素:
user-card {
/* ... */
}
更适合封装的目标,是让组件的样式与结构放在一起。原文把样式写进模板,定义卡片尺寸、头像大小、容器间距以及姓名、邮箱和按钮的外观:
<template id="userCardTemplate">
<style>
:host {
display: flex;
align-items: center;
width: 450px;
height: 180px;
background-color: #d4d4d4;
border: 1px solid #d5d5d5;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border-radius: 3px;
overflow: hidden;
padding: 10px;
box-sizing: border-box;
font-family: 'Poppins', sans-serif;
}
.image {
flex: 0 0 auto;
width: 160px;
height: 160px;
vertical-align: middle;
border-radius: 5px;
}
.container {
box-sizing: border-box;
padding: 20px;
height: 160px;
}
.container > .name {
font-size: 20px;
font-weight: 600;
line-height: 1;
margin: 0;
margin-bottom: 5px;
}
.container > .email {
font-size: 12px;
opacity: 0.75;
line-height: 1;
margin: 0;
margin-bottom: 15px;
}
.container > .button {
padding: 10px 25px;
font-size: 12px;
border-radius: 5px;
text-transform: uppercase;
}
</style>
<img src="https://semantic-ui.com/images/avatar2/large/kristy.png" class="image">
<div class="container">
<p class="name">User Name</p>
<p class="email">[email protected]</p>
<button class="button">Follow</button>
</div>
</template>
其中 :host 表示承载该 shadow tree 的自定义元素本身。需要补全一个前提::host 在 Shadow DOM 中的样式上下文才有对应作用。仅把 <style> 放入普通模板,再把内容克隆到普通light DOM,并不会自动隔离样式;原文后面的 attachShadow() 步骤才建立相应封装。
示例固定了450×180像素的卡片与160×160像素的头像,并指定Poppins字体。这些值用于演示布局,不代表已经适配小屏幕、长姓名、较大的系统字号或字体加载失败。
六、通过属性传入参数
目前卡片内容直接写在模板中。为了复用,将头像、姓名和邮箱改成自定义元素的属性:
<user-card
image="https://semantic-ui.com/images/avatar2/large/kristy.png"
name="User Name"
email="[email protected]"
></user-card>
模板相应保留结构,移除写死的内容:
<template id="userCardTemplate">
<style>...</style>
<img class="image">
<div class="container">
<p class="name"></p>
<p class="email"></p>
<button class="button">Follow John</button>
</div>
</template>
类中读取元素的 image、name、email 属性,分别赋给图片和文字节点:
class UserCard extends HTMLElement {
constructor() {
super();
var templateElem = document.getElementById('userCardTemplate');
var content = templateElem.content.cloneNode(true);
content.querySelector('img').setAttribute('src', this.getAttribute('image'));
content.querySelector('.container>.name').innerText = this.getAttribute('name');
content.querySelector('.container>.email').innerText = this.getAttribute('email');
this.appendChild(content);
}
}
window.customElements.define('user-card', UserCard);
通过 getAttribute() 取得的是属性的字符串值,缺少属性时可能得到 null。示例只在初始化时读取一次,因此之后修改属性不会自动更新组件。若需要响应属性变化,应声明 observedAttributes 并实现 attributeChangedCallback,再把更新逻辑集中起来;这一机制不在原文代码中,不能假定已经具备。
源站的邮箱示例经过邮件地址保护,取回时显示为 [email protected] 一类占位文本,本稿未猜测或还原私人邮箱。头像URL同样应视为输入:真实应用要限制允许的协议与来源,并处理空值、加载失败及替代文字。
七、建立 Shadow DOM
接下来把组件内部结构放入独立的 shadow tree。调用 this.attachShadow() 得到 shadow root,再把模板克隆结果追加进去。原文使用 mode: 'closed':
class UserCard extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow( { mode: 'closed' } );
var templateElem = document.getElementById('userCardTemplate');
var content = templateElem.content.cloneNode(true);
content.querySelector('img').setAttribute('src', this.getAttribute('image'));
content.querySelector('.container>.name').innerText = this.getAttribute('name');
content.querySelector('.container>.email').innerText = this.getAttribute('email');
shadow.appendChild(content);
}
}
window.customElements.define('user-card', UserCard);
closed 表示外部代码无法通过通常的 element.shadowRoot 属性取得这个根节点;创建时返回的引用仍可在组件内部使用。与之对应,open 模式允许通过该属性取得根节点。
对原文绝对化说法的更正:Shadow DOM 是 DOM 与样式的封装机制,不是安全边界,也不能保证代码保密。开发者工具仍能观察内部结构,部分样式会继承,事件也存在跨边界传播及重定向规则;组件里的JavaScript更不会因放入shadow tree就失去访问外部对象的能力。因此不应理解成“用户完全看不见内部代码”或“内部任何代码都无法影响外部”。
至此,用户卡片具备了自定义标签、模板、属性和 shadow tree 这几个组成部分。这个过程说明浏览器原生能力可以承载小型组件,但并不自动补齐状态管理、数据绑定及所有框架能力。
八、扩展组件
与用户交互
要让静态用户卡片响应操作,可以找到shadow tree中的按钮并监听事件。原文给出的扩展如下:
this.$button = shadow.querySelector('button');
this.$button.addEventListener('click', () => {
// do something
});
监听器中的逻辑由实际需求决定,原文没有实现真正的关注行为。为可访问性,应保留原生按钮的语义,检查键盘操作、焦点样式和状态反馈;不要仅仅让鼠标点击看起来有效。
封装成独立脚本文件
原文把模板放在页面里。另一种方式是在脚本中创建并注入模板,这样模板和类定义就可以收进同一个JavaScript文件。网页加载该脚本后,即可使用 <user-card>。原文到这里没有继续展开实现。
脚本注入模板时,静态可信结构和外部数据应分开处理;不要把不可信的姓名或邮箱直接拼入 innerHTML。组件多次挂载、移除或重新连接时,也要检查监听器和资源生命周期。
原文推荐继续阅读 Web Components Tutorial for Beginners 与 Custom Elements v1: Reusable Web Components。
符合构造器约束的属性与模板示例
下面的补充示例保持原文的 template、属性和 Shadow DOM 主题,但不在构造器中读取属性或添加子节点。模板应先出现在文档中,再连接 <user-card>;connectedCallback() 负责创建一次内部结构,attributeChangedCallback() 在观察到属性变化后更新文本和图片。文本使用 textContent;图片地址仍须在真实应用中按可信来源策略校验。
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['image', 'name', 'email'];
}
constructor() {
super();
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
const template = document.getElementById('userCardTemplate');
if (!template) return; // 页面应先定义模板
if (!this.shadowRoot.hasChildNodes()) {
this.shadowRoot.append(template.content.cloneNode(true));
}
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const root = this.shadowRoot;
if (!root) return; // 属性回调可能早于 connectedCallback
const image = root.querySelector('img');
const name = root.querySelector('.name');
const email = root.querySelector('.email');
if (!image || !name || !email) return;
const imageUrl = this.getAttribute('image');
if (imageUrl) image.setAttribute('src', imageUrl);
else image.removeAttribute('src');
image.alt = this.getAttribute('name') || '用户头像';
name.textContent = this.getAttribute('name') || '';
email.textContent = this.getAttribute('email') || '';
}
}
customElements.define('user-card', UserCard);
该示例是按标准约束整理的静态参考片段,未在浏览器执行。构造器只调用 super();断开后重新连接不会重复克隆子节点;设置或更改 image、name、email 时会重新渲染。可对照 HTML Standard 的 custom element conformance 规则。Shadow DOM 的 open / closed 选择不改变这些构造器要求,也不构成安全边界。
九、参考来源与使用边界
补注核对依据:MDN自定义元素文档说明构造器与生命周期要求及属性变化回调;MDN Shadow DOM文档明确closed模式不是强安全机制,并说明模板样式需要加入shadow tree。
原文参考了 Uday Hiwarale 的 The anatomy of Web Components。中文原文、全部章节及代码均以阮一峰的源页面为核对依据;本文不包含评论区内容。
本次只做了静态代码阅读,没有在浏览器运行这些示例,也没有验证外部头像、JSBin或跨浏览器行为。生命周期、属性更新、安全边界及可访问性补充属于本文校正;其余步骤沿原文顺序保留。原文声明 Creative Commons BY-NC-ND 3.0;本文中文译写与补注依据另行授权制作,原文作者、许可声明及来源链接均予保留。
版权与来源:阮一峰《Web Components 入门实例教程》,按原文声明 Creative Commons BY-NC-ND 3.0 并保留署名。本文译写与补注依据另行授权制作;该源许可本身不表示允许改编。











暂无评论内容