处理内部按钮的 click,然后从组件宿主派发一个命名的 CustomEvent。使用方页面在该宿主上监听事件并读取 event.detail。本指南将在 Mac 上构建一个完整的本地浏览器演示,并说明每个监听器需要哪些事件选项。
定义使用方需要的通知
对于纯 JavaScript Web 组件,使用方是使用自定义元素的页面或外层组件。这里的具体任务是让使用方在有人激活内部按钮时作出响应,而无需查询组件的 shadow DOM。
示例使用一个标有 Tile A 的合成方块。它公开的事件是 tile-activate,携带一个 itemId 和一个激活 count。CustomEvent 提供 detail 字段来传递这些应用数据;事件名称区分大小写。派发代码和监听器必须使用完全相同的名称。CustomEvent 构造函数文档。
将这些选择视为一个小型接口约定:
- 名称:
tile-activate。 - 含义: 内部按钮已被激活,计数已更新。
- 载荷:
detail.itemId用于标识方块;detail.count包含更新后的计数。 - 来源: 自定义元素宿主派发通知。
- 传递: 直接使用方和祖先监听器都可以接收通知,包括跨越外层 shadow 边界的情况。
这些是本示例中的设计选择。实用的事件应描述使用方关心的操作。这样组件之后可以更改内部标记,同时保留相同的通知接口。
在 Mac 上构建并打开完整示例
你需要一款较新的桌面浏览器和一个纯文本编辑器。这个单文件示例无需安装软件包或执行构建步骤。它使用继承自 HTMLElement 的自主自定义元素,这是 HTML 标准自定义元素章节中记录的平台模式。
在 TextEdit 中,选择 文件 > 新建,然后选择 格式 > 制作纯文本。粘贴下面的完整文档。选择 文件 > 存储,将文件命名为 component-click.html,并在出现提示时选择“使用 .html”。这是 Apple 文档中介绍的在 TextEdit 中创建 HTML 文件的步骤。在 Mac 上使用 TextEdit 处理 HTML 文稿。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Component click notification</title>
</head>
<body>
<h1>Component click notification</h1>
<section id="demo">
<action-tile id="tile" item-id="tile-a"></action-tile>
<p id="direct-status">Direct listener: waiting</p>
<p id="parent-status">Parent listener: waiting</p>
</section>
<script>
class ActionTile extends HTMLElement {
constructor() {
super();
this._count = 0;
this._button = null;
this._root = this.attachShadow({ mode: "open" });
this._handleClick = () => {
this._count += 1;
this.dispatchEvent(new CustomEvent("tile-activate", {
detail: {
itemId: this.getAttribute("item-id"),
count: this._count
},
bubbles: true,
composed: true
}));
};
}
connectedCallback() {
if (!this._button) {
this._button = document.createElement("button");
this._button.type = "button";
this._button.textContent = "Activate Tile A";
this._root.append(this._button);
}
this._button.addEventListener("click", this._handleClick);
}
disconnectedCallback() {
this._button.removeEventListener("click", this._handleClick);
}
}
customElements.define("action-tile", ActionTile);
const tile = document.getElementById("tile");
const demo = document.getElementById("demo");
const directStatus = document.getElementById("direct-status");
const parentStatus = document.getElementById("parent-status");
tile.addEventListener("tile-activate", (event) => {
const { itemId, count } = event.detail;
directStatus.textContent =
`Direct listener: ${itemId}, activation ${count}`;
});
demo.addEventListener("tile-activate", (event) => {
const { itemId, count } = event.detail;
parentStatus.textContent =
`Parent listener: ${itemId}, activation ${count}`;
});
</script>
</body>
</html>
在浏览器中打开保存的文件,然后点击 Activate Tile A。预期的首次结果是 Direct listener: tile-a, activation 1 和 Parent listener: tile-a, activation 1。此后每次激活都会使显示的两个计数各增加 1。这是示例的预期结果,并非报告的浏览器测试结果。
代码中清楚地展示了关键步骤:更新组件状态、创建通知、从 this 派发通知,然后让使用方更新自己的界面。dispatchEvent() 会在返回前同步调用适用的监听器,因此使用方可以在处理函数中看到已更新的计数。dispatchEvent 文档。
选择正确的冒泡和 shadow 边界选项
派发目标和监听器所在位置决定了哪些选项起作用。即使 bubbles 为 false,直接附加到宿主上的监听器也能接收到在该宿主上派发的事件。普通祖先监听器需要冒泡。要跨越 shadow 边界,还需要设置 composed。bubbles、composed 和 cancelable 的底层默认值均为 false。DOM 标准:Event 接口。
下表根据这些传播规则整理而成。假定监听器以未启用捕获的普通方式注册,且没有处理函数停止传播。
| 自定义事件的起点 | 使用方的监听位置 | 该路径的配置 |
|---|---|---|
| 组件宿主 | 同一宿主 | 两个标志都无需设为 true |
| 组件宿主 | 同一树中的普通祖先 | bubbles: true |
| 内部 shadow 按钮 | 该 shadow root 外的普通祖先 | bubbles: true, composed: true |
| 位于另一个 shadow 树内的组件宿主 | 外层 shadow root 外的普通祖先 | bubbles: true, composed: true |
在演示中,this.dispatchEvent(...) 会从宿主派发通知。因此它无需离开组件自身的 shadow root。如果稍后将此方块嵌套在另一个组件中,composed: true 会使通知能够跨越外层组件的 shadow 边界。应将这种更广的传递范围明确纳入公开接口。Event composed 属性文档。
要进行有针对性的实验,只将 bubbles: true 改为 bubbles: false,保存并重新加载页面。激活后,直接状态应更新,而父级状态仍显示 Parent listener: waiting。之后恢复冒泡。在此文档中,仅将 composed 改为 false,应不会影响两个监听器的工作,因为这两条路径都不会跨越 shadow 边界。
保持初始化和重新连接行为
事件传递最初看似正常,但组件移动或重新连接后可能产生重复通知。应明确区分创建、连接和清理各自的职责。
构造函数会先调用 super(),再初始化状态并附加 shadow root。渲染发生在 connectedCallback() 中,并通过已有的按钮引用进行保护,因此只运行一次。这些选择遵循 HTML 标准对自定义元素构造函数的要求,以及其关于保护初始化逻辑的指导:连接回调再次运行时,这些逻辑不应重复执行。自定义元素构造函数和反应的要求。
组件在连接时注册内部点击监听器,在断开连接时将其移除。重新连接时,它会重用已有按钮并保留 _count。连接和断开连接回调是元素进入或离开文档时调用的生命周期钩子。使用自定义元素。
保存的 _handleClick 函数很重要:移除监听器时必须指定注册时使用的同一个函数,并使用相同的 capture 设置。在移除调用中创建一个新的箭头函数,无法匹配已注册的监听器。removeEventListener 文档。
如果你在更大的组件中将监听器添加到 window、document 或其他外部目标,也应为这些监听器明确安排清理。让生命周期变更与通知含义各自独立,避免元素重新连接时意外地将其视为用户操作并发出通知。
排查通知缺失或重复的问题
在更改事件标志之前,先利用两行可见状态定位故障。它们可以帮助区分组件通知本身的问题和祖先事件传递的问题。
- 两个状态都没有变化: 比较派发和注册时使用的事件名称,然后检查自定义元素定义和按钮处理函数是否运行。在此文档中,使用方监听器会在用户激活之前注册。
- 只有直接状态发生变化: 检查
bubbles。同时检查是否有中间处理函数停止了传播。 - 仅当嵌套在另一个组件中时传递失败: 找出该路径上的 shadow 边界,并检查
composed。 - 重新连接后计数跳增: 检查监听器是否重复注册,尤其要留意每次元素连接时新建的匿名函数。
即使源码文本相同,重复创建的匿名函数仍是不同的监听器对象。保留稳定的函数引用可以让注册和清理更容易理解。addEventListener() 还用于区分监听器的捕获选项,该选项会影响监听器的运行时机。addEventListener 文档。
原生 click 可能已经能够传出 shadow 树,因为浏览器派发的点击事件是 composed 的。如果使用方需要一个有名称的组件操作和明确的载荷,那么单独的自定义事件会很有用。除非有意如此,否则不要把这两种通知都当作同一个应用操作来处理。Event composed 属性文档。
最后,在事件设计中应区分通知和是否允许继续执行。本示例会先增加计数,再派发一个不可取消的事件。使用方调用 preventDefault() 无法撤销此次更新。若要实现可取消的请求,必须明确检查 dispatchEvent() 的返回值,然后再执行所请求的操作。dispatchEvent 返回值。
对于此任务,完整约定很简单:一个内部按钮处理函数更新状态,一个命名的自定义事件从宿主发出,使用方读取文档所述的载荷。先确保直接监听器正常工作,然后再启用外层界面实际需要的传播路径。
来源
- HTML 标准 — 自定义元素 — WHATWG。
- DOM 标准 — WHATWG。
- CustomEvent:CustomEvent() 构造函数 — MDN Web 文档。
- EventTarget:dispatchEvent() 方法 — MDN Web 文档。
- Event:composed 属性 — MDN Web 文档。
- 使用自定义元素 — MDN Web 文档。
- EventTarget:addEventListener() 方法 — MDN Web 文档。
- EventTarget:removeEventListener() 方法 — MDN Web 文档。
- 在 Mac 上使用 TextEdit 处理 HTML 文稿 — Apple。