Web Components:定义元素、封装结构、开放事件
当“提示卡”反复出现在页面上,我们希望它有自己的内部结构,同时仍像普通 HTML 元素一样使用。Web Components 是一组平台能力:自定义元素定义行为,Shadow DOM 封装内部树,template 保存可复制结构,slot 接收外部内容。它们可以组合,也不必每次全用。
一个可直接打开的自定义元素
保存为 HTML。页面有一个可关闭提示,点击“关闭”后提示消失,外部状态显示事件已收到。
<!doctype html>
<meta charset="utf-8">
<title>自定义提示</title>
<template id="notice-template">
<style>
:host { display: block; border: 1px solid #888; padding: 12px; }
button { margin-left: 12px; }
</style>
<slot>没有提示内容</slot>
<button type="button">关闭</button>
</template>
<study-notice>先理解作用域,再学习闭包。</study-notice>
<p id="status" role="status"></p>
<script>
class StudyNotice extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const template = document.querySelector('#notice-template');
root.append(template.content.cloneNode(true));
this.closeButton = root.querySelector('button');
}
connectedCallback() {
this.events?.abort();
this.events = new AbortController();
this.closeButton.addEventListener('click', () => {
this.closeButton.dispatchEvent(new CustomEvent('notice-dismiss', {
bubbles: true,
composed: true,
detail: { reason: 'button' }
}));
this.remove();
}, { signal: this.events.signal });
}
disconnectedCallback() {
this.events?.abort();
}
}
document.addEventListener('notice-dismiss', event => {
document.querySelector('#status').textContent =
`收到关闭事件:${event.detail.reason}`;
});
customElements.define('study-notice', StudyNotice);
</script>名字包含连字符,避免与原生标签混淆。define 把标签名与类联系起来;已经出现在文档中的对应元素可以被升级。constructor 调用 super 后建立内部结构,不要假定任意创建时机下外部子节点都已解析完成。本例模板明确位于脚本之前。
未定义的标签如何变成有行为的元素
HTML 可以先出现 <reading-level>,类定义后到达。定义之前,它已经是 DOM 中的一个元素;注册之后,符合条件的元素被升级,不需要把旧节点删除换成一个新节点。原来持有该元素的变量依然指向它。
保存下面 HTML 并打开:
<!doctype html>
<meta charset="utf-8">
<title>观察元素升级</title>
<reading-level level="2"></reading-level>
<pre id="trace"></pre>
<script>
const trace = document.querySelector('#trace');
const log = text => { trace.textContent += text + '\n'; };
const element = document.querySelector('reading-level');
class ReadingLevel extends HTMLElement {
static observedAttributes = ['level'];
constructor() {
super();
log('构造');
}
attributeChangedCallback(name, oldValue, newValue) {
log(`属性 ${oldValue} → ${newValue}`);
if (this.isConnected) this.render();
}
connectedCallback() {
log('接入文档');
this.render();
}
disconnectedCallback() { log('离开文档'); }
render() { this.textContent = `阅读等级 ${this.getAttribute('level')}`; }
}
log(`升级前 ${element instanceof ReadingLevel}`);
customElements.define('reading-level', ReadingLevel);
log(`升级后 ${element instanceof ReadingLevel}`);
element.setAttribute('level', '3');
element.remove();
document.body.append(element);
</script>这段具体程序先输出“升级前 false”,然后构造、属性 null→2、接入文档,再输出“升级后 true”;随后属性 2→3、离开文档、接入文档。构造仅一次,连接两次,原对象没有改变。已有被观察属性会在升级时触发对应的属性回调,不是只有升级后的 setAttribute 才算变化。
这个顺序来自本例:元素和属性先存在、随后 define、最后移除再插入。若注册先于 HTML 解析,constructor 可能在解析器还未填好属性与子节点时发生,所以不要在 constructor 读取外部子树来完成渲染。constructor 负责自身基础状态,连接与属性回调负责响应外部环境。
连接、属性和 JavaScript property 各有自己的入口
connectedCallback 在接入文档时触发,重新插入还会再次触发;前面提示组件因此先 abort 旧监听再注册。disconnectedCallback 负责移除时的外部资源清理;移动到另一个文档还涉及 adoptedCallback。较新的状态保留移动能力有专门回调,但普通 remove/append 仍要按其生命周期处理。
HTML 属性总是字符串或缺失。level="3" 不会自动变成类中数字 property;反过来,element.level = 3 也不会自动触发 attributeChangedCallback,除非你定义 setter 去更新特性或状态。布尔特性通常按是否存在解释,因此 disabled="false" 仍表示该特性存在。
还有一个延迟定义的边界:如果升级前先执行 element.level = 4,它可能创建自有属性,遮蔽后来原型上的同名 setter。需要支持这种预升级赋值时,应明确接管旧自有值、删除遮蔽再通过正式接口应用;不能假定定义 setter 后浏览器会自动补调用一次。设计初始化时要防止属性更新互相触发,形成无限递归。
customElements.whenDefined(name) 等待注册完成,不保证任意脱离文档的节点都已经升级,更不保证字体、网络内容全部就绪。脱离文档的子树可按需要显式 upgrade;这与元素已经接入、内容已渲染是不同状态。
Shadow DOM 封装哪些东西
外部普通选择器不能直接像 light DOM 那样穿进 shadow tree;内部样式也有封装边界。:host 选择宿主元素,slot 把调用方内容呈现在指定位置。插槽内容仍属于调用方的 light DOM,并没有被复制进 shadow tree。
mode: 'open' 允许从宿主的 shadowRoot 取得内部根;closed 则让这个公开入口返回 null。closed 不是安全沙箱,不能用它藏秘密。CSS 继承、自定义属性等还可能穿过边界,需要有意识地设计可定制接口。
template 的 content 是 DocumentFragment。直接 append template.content 会移动其中的节点;这里 cloneNode(true) 使每个组件实例都有自己的结构。大量组件共用同一个真实节点会导致节点在实例之间搬家。
事件跨越边界需要公开约定
本例从内部按钮派发自定义事件。bubbles: true 让它向祖先传播,composed: true 允许穿过 Shadow DOM 边界,外部 document 才能收到。两个开关解决不同问题。
跨边界后 target 会按封装规则重新指向可见目标,外部不应依赖内部按钮结构。需要分析传播路径可读 composedPath,但 closed 根仍隐藏相应内部细节。公开事件应提供稳定 detail,让调用方只关心“提示关闭了”,不必知道按钮藏在第几层 div 里。
自定义标签并不会自动获得原生按钮、输入框的全部语义。本例真正交互的仍是 button;表单关联自定义元素还有 ElementInternals 等专门能力,需要另行设计值、校验、disabled 与可访问行为,不能只给标签起个 input 风格的名字就算完成。
练习
如果去掉 composed,document 还能收到本例从 shadow 内部发出的事件吗?
为什么每次接入文档都无条件注册新监听器可能出错?为什么模板使用 cloneNode?
为什么升级没有替换原DOM对象?为什么移除再插入会再次连接,却不再次构造?
在提示组件外添加“恢复”按钮,关闭后插回原来的元素对象。连续关闭、恢复三次,每次关闭只能收到一次 notice-dismiss。禁止通过重新创建元素来绕过重复连接问题。