Custom Elements 自定义元素
一、自定义元素概述
1.1 Web Components 技术栈
Custom Elements 是 Web Components 技术栈的核心之一,与 Shadow DOM、HTML Templates、HTML Imports 共同构成浏览器原生的组件化能力:
Web Components = Custom Elements + Shadow DOM + HTML Templates
1.2 两类自定义元素
| 类型 | 描述 | 示例 |
|---|---|---|
| 自主定制元素 | 从 HTMLElement 继承,完全自主实现 | <my-element> |
| 定制内建元素 | 继承自标准 HTML 元素 | <p is="word-count"> |
// 自主定制元素
class PopupInfo extends HTMLElement {
constructor() { super(); }
}
// 定制内建元素
class WordCount extends HTMLParagraphElement {
constructor() { super(); }
}
二、自主定制元素
2.1 基本结构
class MyButton extends HTMLElement {
constructor() {
// 必须首先调用 super
super();
// 组件逻辑
}
}
customElements.define('my-button', MyButton);
<my-button>Click Me</my-button>
2.2 生命周期回调
| 回调 | 触发时机 |
|---|---|
connectedCallback() |
元素被插入 DOM |
disconnectedCallback() |
元素从 DOM 移除 |
adoptedCallback() |
元素移动到新文档 |
attributeChangedCallback() |
属性增删改 |
class FeatureCard extends HTMLElement {
static get observedAttributes() {
return ['title', 'image', 'disabled'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
connectedCallback() {
console.log('Element added to page');
}
disconnectedCallback() {
console.log('Element removed from page');
}
attributeChangedCallback(name, oldValue, newValue) {
if (old !== new) {
this.render();
}
}
render() {
const title = this.getAttribute('title') || 'Default';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; border: 1px solid #ddd; padding: 16px; }
h3 { margin: 0; }
</style>
<h3>${title}</h3>
<slot></slot>
`;
}
}
三、属性与特性
3.1 响应属性
将 HTML 属性与 JavaScript 属性同步:
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['username', 'avatar'];
}
get username() {
return this.getAttribute('username');
}
set username(val) {
this.setAttribute('username', val);
}
attributeChangedCallback(name, old, val) {
if (old !== val) {
this.render();
}
}
}
3.2 属性反射到属性
class ProgressBar extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
get value() {
return Number(this.getAttribute('value'));
}
set value(val) {
this.setAttribute('value', Math.max(0, Math.min(100, val)));
// 反映到 DOM
this.shadowRoot.querySelector('.bar').style.width = val + '%';
}
}
四、作用域化自定义元素注册表
4.1 什么是作用域化注册
传统的 customElements.define 是全局的,可能导致命名冲突。作用域化注册允许在特定 DOM 子树上使用独立的注册表:
// 创建作用域化注册表
const myRegistry = new CustomElementRegistry();
// 在注册表中定义元素
myRegistry.define('my-element', class extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello from scoped registry!';
}
});
// 创建 Shadow Root 并关联注册表
const host = document.createElement('div');
const shadow = host.attachShadow({
mode: 'open',
customElementRegistry: myRegistry
});
4.2 应用场景
// 微前端场景:不同团队使用独立的组件注册表
const teamARegistry = new CustomElementRegistry();
const teamBRegistry = new CustomElementRegistry();
teamARegistry.define('a-button', AButton);
teamBRegistry.define('b-button', BButton);
// 不同 shadow host 使用不同注册表
const hostA = document.createElement('div');
hostA.attachShadow({ mode: 'open', customElementRegistry: teamARegistry });
const hostB = document.createElement('div');
hostB.attachShadow({ mode: 'open', customElementRegistry: teamBRegistry });
五、实战应用
5.1 完整表单输入组件
class FormInput extends HTMLElement {
static get observedAttributes() {
return ['label', 'type', 'value', 'error', 'disabled'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.setupListeners();
}
setupListeners() {
const input = this.shadowRoot.querySelector('input');
input.addEventListener('input', (e) => {
this.value = e.target.value;
this.dispatchEvent(new Event('input', { bubbles: true }));
});
}
get value() {
return this.getAttribute('value') || '';
}
set value(val) {
this.setAttribute('value', val);
}
render() {
const { label, type = 'text', error, disabled } = this;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; font-family: system-ui; }
label { display: block; margin-bottom: 4px; font-weight: 500; }
input {
width: 100%; padding: 8px; border: 1px solid #ccc;
border-radius: 4px; box-sizing: border-box;
}
input:disabled { background: #f5f5f5; cursor: not-allowed; }
.error { color: red; font-size: 12px; margin-top: 4px; }
</style>
<label><slot name="label">${label}</slot></label>
<input type="${type}" value="${this.value}" ?disabled="${disabled}" />
${error ? `<div class="error">${error}</div>` : ''}
`;
}
}
customElements.define('form-input', FormInput);
<form-input
label="用户名"
type="text"
value="admin"
error="用户名已被占用">
</form-input>
5.2 实时数据展示组件
class LiveValue extends HTMLElement {
static get observedAttributes() {
return ['value', 'precision', 'unit'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.startAnimation();
}
disconnectedCallback() {
this.stopAnimation();
}
startAnimation() {
this._frameId = requestAnimationFrame(() => this.animate());
}
stopAnimation() {
if (this._frameId) {
cancelAnimationFrame(this._frameId);
}
}
animate() {
const current = Number(this.getAttribute('value')) || 0;
const target = this._targetValue ?? current;
const diff = target - current;
if (Math.abs(diff) > 0.01) {
this.setAttribute('value', (current + diff * 0.1).toFixed(2));
this._frameId = requestAnimationFrame(() => this.animate());
}
}
attributeChangedCallback(name, old, val) {
if (name === 'value') {
this._targetValue = Number(val);
}
}
render() {
const { value = 0, precision = 2, unit = '' } = this;
this.shadowRoot.innerHTML = `
<style>
:host { font-variant-numeric: tabular-nums; }
</style>
${Number(value).toFixed(Number(precision))}${unit}
`;
}
}
customElements.define('live-value', LiveValue);
六、面试高频问题
Q: 自主定制元素和定制内建元素的区别是什么?
回答要点:自主定制元素是全新元素,继承自 HTMLElement,使用时直接作为标签如 <my-element>;定制内建元素继承自已有 HTML 元素如 HTMLParagraphElement,使用时通过 is 属性如 <p is="word-count">。Safari 不支持定制内建元素。
Q: 什么时候使用 attributeChangedCallback?
回答要点:当需要根据 HTML 属性变化更新组件状态或 DOM 时使用。但必须先在 observedAttributes 中声明要观察的属性。适合用于属性驱动的组件,对于纯内部状态的组件可能不需要。
Q: customElements.define 的第三个参数是什么?
回答要点:第三个参数是可选的配置对象,目前支持 { extends: 'element-name' } 用于定制内建元素。第二个参数是元素类构造函数。