DOM 事件委托
学员列表通常由数组或 API 数据动态生成。新增、删除或重新查询数据后,列表会重新渲染,原来的按钮也会被新的按钮替换。
如果给每个按钮单独绑定事件,每次渲染后都要重新绑定,很容易出现“旧按钮能点,新按钮没反应”的问题。本节使用事件委托处理动态元素。
本节课解决四个问题:
- 为什么重新渲染后,直接绑定的事件会失效。
- 怎样利用事件冒泡,把监听器绑定到固定的父元素。
- 为什么事件委托要配合
closest()使用。 - 怎样用
data-*区分查看、编辑、切换状态和删除操作。
本节继续使用本地学员数组,不发送 API 请求。先把动态列表事件处理清楚,后面再接入 fetch。
先用静态卡片练习目标定位,再复现动态列表的直接绑定失效,最后统一处理多个按钮操作。
静态卡片委托
p04c05 已经介绍:按钮内部的图标或文字可能成为不同的 event.target,而 event.currentTarget 是监听器实际挂载的元素。上一节完成了表单取值与校验,现在继续解决动态列表的按钮操作。先在一张静态的 <article> 学员卡片上绑定监听器,完成一次完整的查找过程:
event.target
↓ closest("button[data-action]")
操作按钮
↓ closest("[data-student-id]")
学员卡片与 ID
↓ dataset.action
switch 分发查看或编辑卡片结构
<article id="student-card" data-student-id="1001">
<h2>张三</h2>
<button data-action="view" type="button">
<span class="button-icon" aria-hidden="true">⌕</span>
查看
</button>
<button data-action="edit" type="button">
<span class="button-icon" aria-hidden="true">✎</span>
编辑
</button>
</article>按钮定位与操作分发
const studentCard = document.querySelector("#student-card");
studentCard.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button[data-action]");
if (!button || !studentCard.contains(button)) {
return;
}
const card = button.closest("[data-student-id]");
if (!card) {
return;
}
const studentId = card.dataset.studentId;
const action = button.dataset.action;
switch (action) {
case "view":
console.log(`查看学员:${studentId}`);
break;
case "edit":
console.log(`编辑学员:${studentId}`);
break;
}
});点击图标时,event.target 可能是 .button-icon,第一次 closest() 会向上找到按钮;第二次 closest() 再向上找到带有 data-student-id 的卡片。switch 只负责分发操作,暂时不修改数组,也不重新渲染页面。
先运行单张静态学员卡片委托(源码文件:./.code/p04/c07-event-delegation/00-static-card-delegation.html)。分别点击按钮文字和图标,在 Console 中确认输出“查看学员:1001”和“编辑学员:1001”,再进入动态列表。
单张静态学员卡片委托源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>单张静态学员卡片委托</title>
<style>
body {
max-width: 680px;
margin: 40px auto;
padding: 0 20px;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
.student-card {
padding: 22px;
border: 1px solid #dcdfe6;
border-radius: 10px;
background: #fafcff;
}
.student-card h2 {
margin: 0 0 16px;
}
button {
margin-right: 8px;
padding: 9px 14px;
border: 0;
border-radius: 6px;
background: #409eff;
color: #fff;
cursor: pointer;
font: inherit;
}
.button-icon {
margin-right: 4px;
}
#message {
min-height: 24px;
color: #606266;
}
</style>
</head>
<body>
<h1>单张静态学员卡片</h1>
<article id="student-card" class="student-card" data-student-id="1001">
<h2>张三</h2>
<button data-action="view" type="button">
<span class="button-icon" aria-hidden="true">⌕</span>
查看
</button>
<button data-action="edit" type="button">
<span class="button-icon" aria-hidden="true">✎</span>
编辑
</button>
</article>
<p id="message">请点击查看或编辑,也可以点击按钮图标。</p>
<script>
const studentCard = document.querySelector("#student-card");
const message = document.querySelector("#message");
studentCard.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button || !studentCard.contains(button)) {
return;
}
const card = button.closest("[data-student-id]");
if (!card) {
return;
}
const studentId = card.dataset.studentId;
const action = button.dataset.action;
switch (action) {
case "view":
console.log(`查看学员:${studentId}`);
message.textContent = `查看学员:${studentId}`;
break;
case "edit":
console.log(`编辑学员:${studentId}`);
message.textContent = `编辑学员:${studentId}`;
break;
}
});
</script>
</body>
</html>:::
直接绑定失效
先看一个动态生成的学员列表:
<ul id="student-list"></ul>const students = [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
];
const studentList = document.querySelector("#student-list");
function renderStudents() {
studentList.replaceChildren();
for (const student of students) {
const item = document.createElement("li");
const button = document.createElement("button");
item.append(`${student.name} `);
button.classList.add("delete-button");
button.dataset.id = String(student.id);
button.textContent = "删除";
item.append(button);
studentList.append(item);
}
}
renderStudents();失效复现
沿用上一步的 students、studentList 和 renderStudents(),在首次渲染后执行以下代码:
function bindDeleteButtons() {
const deleteButtons = document.querySelectorAll(".delete-button");
for (const button of deleteButtons) {
button.addEventListener("click", () => {
console.log(`删除学员:${button.dataset.id}`);
});
}
}
bindDeleteButtons();第一次运行没有问题。但是再次调用 renderStudents() 时,replaceChildren() 会移除原来的按钮,再创建一批新按钮。
students.push({ id: 3, name: "王五" });
renderStudents();事件监听器属于具体的 DOM 元素。按钮被替换后,新按钮不会继承旧按钮的监听器。每次渲染后重新调用 bindDeleteButtons() 虽然可行,但容易遗漏;更稳妥的做法是监听不会被替换的列表容器。可在直接绑定失效示例(源码文件:./.code/p04/c07-event-delegation/01-direct-binding.html)中先点击原按钮,再点击“重新渲染”后的新按钮,对比两次点击的结果。
事件冒泡与按钮定位
父元素监听
点击按钮产生的 click 事件会向父元素冒泡。即使列表内容经常变化,外层的 studentList 仍然存在,因此可以只监听父元素。
事件委托可以简单理解为:
不给每个子元素单独绑定事件,让固定的父元素统一接收冒泡事件,再判断用户实际操作了哪个子元素。
以下几个监听器是同一段处理逻辑的递进版本。跟练时,每次用新版本替换上一版,不要全部添加到同一个页面,否则一次点击会触发多个监听器。
最小版本如下:
studentList.addEventListener("click", (event) => {
if (!event.target.matches(".delete-button")) {
return;
}
console.log(`删除学员:${event.target.dataset.id}`);
});无论列表重新渲染多少次,监听器始终在 studentList 上,不需要重新绑定。
这里要求父容器本身没有被替换,且子元素的事件能够冒泡到它。重新创建父容器,或在子元素上调用 stopPropagation(),都会影响这条接收路径;focus、blur 等不冒泡事件也不能直接照搬本例。
操作按钮定位
matches() 的局限
操作按钮中经常包含图标:
<button class="delete-button" data-id="1" type="button">
<span class="button-icon">×</span>
删除
</button>点击“删除”文字时,event.target 可能是 button;点击 × 图标时,event.target 是内部的 span。
event.target.matches(".delete-button");span 不匹配 .delete-button,操作就会失效。页面看起来会变成“按钮有些位置能点,有些位置不能点”。
closest() 定位
closest(selector) 从当前元素开始向上查找,返回最近的匹配元素。
studentList.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest(".delete-button");
if (!button) {
return;
}
console.log(`删除学员:${button.dataset.id}`);
});点击图标时,查找过程是:
span.button-icon → button.delete-button点击按钮空白区域时,event.target 本身就是按钮,closest() 会直接返回它。
可以在嵌套图标定位示例(源码文件:./.code/p04/c07-event-delegation/02-closest-delegation.html)中分别点击图标和按钮文字,核对两处是否执行同一操作。
操作范围校验
完整写法还要确认按钮确实位于当前列表内:
studentList.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button[data-action]");
if (!button || !studentList.contains(button)) {
return;
}
console.log(button.dataset.action);
});closest() 会一路向祖先元素查找。contains() 把结果限制在当前组件内部,避免意外处理外层同名元素。
target 与 currentTarget
在事件委托中,两者的区别非常明确:
studentList.addEventListener("click", (event) => {
console.log(event.target);
console.log(event.currentTarget);
});| 属性 | 在当前案例中的值 |
|---|---|
event.target | 用户最初点击的按钮、图标或其他子元素 |
event.currentTarget | 绑定监听器的 studentList |
不能使用 currentTarget 查找具体按钮,因为它始终是列表容器。要从 target 出发,再使用 closest()。
委托处理函数的固定顺序是:从 event.target 找最近的操作按钮,确认按钮仍属于当前容器,再找到对应学员记录。这个顺序能兼容按钮内部图标,也能避免处理无关元素。
多操作委托
操作标记
一条学员记录通常有多个按钮。可以用 data-action 描述按钮职责。下面改用一张带多个操作按钮的静态列表练习分发;将其保存为新页面,不与前面的动态渲染代码放在一起:
<ul id="student-list">
<li data-student-id="1">
<span>张三</span>
<button data-action="view" type="button">查看</button>
<button data-action="edit" type="button">编辑</button>
<button data-action="delete" type="button">删除</button>
</li>
</ul>监听器先找到按钮,再读取操作名称:
const studentList = document.querySelector("#student-list");
studentList.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button[data-action]");
if (!button || !studentList.contains(button)) {
return;
}
const action = button.dataset.action;
if (action === "view") {
console.log("查看学员");
} else if (action === "edit") {
console.log("编辑学员");
} else if (action === "delete") {
console.log("删除学员");
}
});HTML 负责说明“这个按钮是什么操作”,JavaScript 负责执行对应功能。增加按钮时,不需要再增加监听器。
学员定位
把学员 ID 放在整条记录上,不需要在每个按钮上重复保存:
<li data-student-id="12">
<span>王五</span>
<button data-action="edit" type="button">编辑</button>
<button data-action="delete" type="button">删除</button>
</li>从按钮继续向上查找学员记录:
const studentItem = button.closest("[data-student-id]");
if (!studentItem) {
return;
}
const studentId = Number(studentItem.dataset.studentId);
console.log(studentId);注意两个规则:
- HTML 中的
data-student-id,在 JavaScript 中写成dataset.studentId。 dataset读取的值都是字符串,需要数字 ID 时使用Number()转换。
console.log(studentItem.dataset.studentId); // "12"
console.log(Number(studentItem.dataset.studentId)); // 12按钮只描述操作,整条记录保存学员 ID。这样同一行的查看、编辑、删除按钮都能找到同一个业务对象,不需要在每个按钮上重复保存 ID。
操作分发
操作增加后,可以使用 switch 让分支更清楚。下面用 Console 输出代替实际业务操作,先验证按钮与学员 ID 的对应关系。沿用上面的静态列表和 studentList 变量,只把上一版 if 监听器替换为以下代码:
function viewStudent(studentId) {
console.log(`查看学员:${studentId}`);
}
function editStudent(studentId) {
console.log(`编辑学员:${studentId}`);
}
function deleteStudent(studentId) {
console.log(`删除学员:${studentId}`);
}
function handleStudentAction(event) {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button[data-action]");
if (!button || !studentList.contains(button)) {
return;
}
const studentItem = button.closest("[data-student-id]");
if (!studentItem) {
return;
}
const studentId = Number(studentItem.dataset.studentId);
const action = button.dataset.action;
switch (action) {
case "view":
viewStudent(studentId);
break;
case "edit":
editStudent(studentId);
break;
case "delete":
deleteStudent(studentId);
break;
}
}
studentList.addEventListener("click", handleStudentAction);统一监听器只负责三件事:
- 找到操作按钮。
- 读取学员 ID 和操作名称。
- 把任务交给对应函数。
具体的查看、编辑和删除逻辑放在独立函数中,事件处理函数不会越写越长。
在操作分发示例(源码文件:./.code/p04/c07-event-delegation/03-action-dispatch.html)中分别点击查看、切换状态、删除按钮,核对页面提示和学员 ID。附件使用“切换状态”展示同一分发方法。
动态学员列表案例
功能需求
使用本地数组完成一个可以反复渲染的学员操作列表:
- 提交表单后新增一名学员并重新渲染。
- 点击按钮图标或文字都能正确识别操作。
- 查看、编辑、切换状态和删除共用一个列表监听器。
- 学员 ID 保存到表格行,按钮只保存操作名称。
- 点击“重新渲染”后,所有操作仍然有效。
- 数组为空时显示空数据提示。
下面只展示与事件委托直接相关的关键函数。表格、学员数组、studentBody、renderStudents()、showMessage() 和各业务操作函数均在完整页面(源码文件:./.code/p04/c07-event-delegation/04-dynamic-student-list.html)中定义;运行时使用完整页面,不要把下面的片段与前面的练习代码直接拼接。
操作按钮创建
function createActionButton(action, label, icon, className = "") {
const button = document.createElement("button");
const iconElement = document.createElement("span");
button.type = "button";
button.className = `action-button ${className}`.trim();
button.dataset.action = action;
iconElement.classList.add("button-icon");
iconElement.textContent = icon;
iconElement.setAttribute("aria-hidden", "true");
button.append(iconElement, label);
return button;
}按钮内部故意放入一个 span 图标,用来验证 closest() 能正确处理嵌套元素。
学员行创建
function createStudentRow(student) {
const row = document.createElement("tr");
const idCell = document.createElement("td");
const nameCell = document.createElement("td");
const classCell = document.createElement("td");
const statusCell = document.createElement("td");
const actionCell = document.createElement("td");
row.dataset.studentId = String(student.id);
idCell.textContent = String(student.id);
nameCell.textContent = student.name;
classCell.textContent = student.className;
statusCell.textContent = student.disabled ? "已停用" : "正常";
actionCell.append(
createActionButton("view", "查看", "⌕"),
createActionButton("edit", "编辑", "✎"),
createActionButton(
"toggle",
student.disabled ? "启用" : "停用",
student.disabled ? "✓" : "—",
),
createActionButton("delete", "删除", "×", "is-danger"),
);
row.append(idCell, nameCell, classCell, statusCell, actionCell);
return row;
}委托点击处理
function handleStudentAction(event) {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button[data-action]");
if (!button || !studentBody.contains(button)) {
return;
}
const row = button.closest("tr[data-student-id]");
if (!row) {
return;
}
const studentId = Number(row.dataset.studentId);
const student = students.find((item) => item.id === studentId);
if (!student) {
showMessage("没有找到对应的学员", "error");
return;
}
switch (button.dataset.action) {
case "view":
viewStudent(student);
break;
case "edit":
editStudent(student);
break;
case "toggle":
toggleStudent(student);
break;
case "delete":
deleteStudent(student);
break;
}
}
studentBody.addEventListener("click", handleStudentAction);监听器只绑定一次。新增、编辑、切换状态和删除都会调用 renderStudents(),但表格主体 studentBody 不会被替换,因此新按钮仍然能响应。
运行验证
打开完整附件后,默认应显示 3 名学员,其中李四为停用状态。
可以按下面步骤验证:
- 点击任意操作按钮的图标和文字,均应识别为同一操作,说明
closest()找到了按钮。 - 点击“重新渲染列表”后,再点击查看、编辑、停用或删除,操作仍然有效。
- 新增一名学员后,立即点击新行的操作按钮;不需要重新绑定监听器。
- 依次删除全部学员,表格应显示“暂无学员数据”;此时新增学员后,新的操作按钮仍可使用。
完整代码见动态学员操作列表(源码文件:./.code/p04/c07-event-delegation/04-dynamic-student-list.html)。
动态学员操作列表
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DOM 事件委托</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f3f6fa;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
button,
input,
select {
font: inherit;
}
.page-container {
width: min(1100px, calc(100% - 32px));
margin: 40px auto;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.page-title {
margin: 0 0 8px;
font-size: 28px;
}
.page-description {
margin: 0;
color: #606266;
line-height: 1.7;
}
.render-button,
.submit-button,
.action-button {
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s, transform 0.2s;
}
.render-button {
flex: 0 0 auto;
padding: 10px 16px;
background: #fff;
color: #409eff;
box-shadow: 0 2px 10px rgb(0 0 0 / 7%);
}
.render-button:hover,
.action-button:hover {
background: #ecf5ff;
}
.render-button:active,
.submit-button:active,
.action-button:active {
transform: translateY(1px);
}
.panel {
margin-bottom: 18px;
padding: 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
}
.student-form {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto;
align-items: end;
gap: 16px;
}
.form-field {
display: grid;
gap: 8px;
}
.form-field label {
color: #606266;
font-size: 14px;
}
.form-field input,
.form-field select {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
outline: none;
}
.form-field input:focus,
.form-field select:focus {
border-color: #409eff;
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
}
.submit-button {
height: 40px;
padding: 0 20px;
background: #409eff;
color: #fff;
}
.submit-button:hover {
background: #66b1ff;
}
.message {
min-height: 22px;
margin: 0 0 14px;
color: #606266;
font-size: 14px;
}
.message.is-success {
color: #1b9a59;
}
.message.is-error {
color: #d93026;
}
.table-wrapper {
overflow-x: auto;
}
.student-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.student-table th,
.student-table td {
padding: 15px 14px;
border-bottom: 1px solid #ebeef5;
text-align: left;
}
.student-table th {
background: #f8fafc;
color: #606266;
font-size: 14px;
font-weight: 600;
}
.student-table tbody tr:hover {
background: #fafcff;
}
.student-table tbody tr.is-disabled {
color: #909399;
background: #fafafa;
}
.status-badge {
display: inline-block;
min-width: 58px;
padding: 4px 9px;
border-radius: 12px;
background: #e8f8f0;
color: #1b9a59;
font-size: 12px;
text-align: center;
}
.status-badge.is-disabled {
background: #ebeef5;
color: #606266;
}
.action-cell {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.action-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 10px;
background: transparent;
color: #409eff;
}
.action-button.is-danger {
color: #f56c6c;
}
.action-button.is-danger:hover {
background: #fef0f0;
}
.button-icon {
display: inline-grid;
width: 16px;
height: 16px;
place-items: center;
font-weight: 700;
}
.empty-cell {
height: 150px;
color: #909399;
text-align: center !important;
}
.summary {
margin: 14px 0 0;
color: #606266;
font-size: 14px;
}
.tip {
margin: 0;
padding: 14px 16px;
border-left: 4px solid #409eff;
border-radius: 4px;
background: #ecf5ff;
color: #52606d;
line-height: 1.7;
}
@media (max-width: 760px) {
.page-container {
margin: 24px auto;
}
.page-header {
align-items: stretch;
flex-direction: column;
}
.render-button {
align-self: flex-start;
}
.student-form {
grid-template-columns: 1fr;
}
.submit-button {
width: 100%;
}
}
</style>
</head>
<body>
<main class="page-container">
<header class="page-header">
<div>
<h1 class="page-title">动态学员操作列表</h1>
<p class="page-description">
列表反复渲染,查看、编辑、状态和删除操作始终共用一个父元素监听器。
</p>
</div>
<button id="render-button" class="render-button" type="button">
重新渲染列表
</button>
</header>
<section class="panel">
<form id="student-form" class="student-form">
<div class="form-field">
<label for="student-name">学员姓名</label>
<input
id="student-name"
name="name"
maxlength="20"
placeholder="例如:陈七"
required
/>
</div>
<div class="form-field">
<label for="class-name">所在班级</label>
<select id="class-name" name="className" required>
<option value="">请选择班级</option>
<option value="Python 1 班">Python 1 班</option>
<option value="Python 2 班">Python 2 班</option>
<option value="Web 全栈班">Web 全栈班</option>
</select>
</div>
<button class="submit-button" type="submit">新增学员</button>
</form>
</section>
<section class="panel">
<p id="message" class="message" role="status">等待操作</p>
<div class="table-wrapper">
<table class="student-table">
<thead>
<tr>
<th>编号</th>
<th>姓名</th>
<th>班级</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="student-body"></tbody>
</table>
</div>
<p id="summary" class="summary"></p>
</section>
<p class="tip">
试着点击操作按钮里的符号和文字,再新增学员或重新渲染列表。按钮虽然被重新创建,事件仍然有效。
</p>
</main>
<script>
let students = [
{
id: 1,
name: "张三",
className: "Python 1 班",
disabled: false,
},
{
id: 2,
name: "李四",
className: "Python 2 班",
disabled: true,
},
{
id: 3,
name: "王五",
className: "Web 全栈班",
disabled: false,
},
];
let nextStudentId = 4;
const studentForm = document.querySelector("#student-form");
const studentNameInput = document.querySelector("#student-name");
const studentBody = document.querySelector("#student-body");
const renderButton = document.querySelector("#render-button");
const message = document.querySelector("#message");
const summary = document.querySelector("#summary");
function showMessage(text, type = "") {
message.textContent = text;
message.className = "message";
if (type) {
message.classList.add(`is-${type}`);
}
}
function createActionButton(action, label, icon, className = "") {
const button = document.createElement("button");
const iconElement = document.createElement("span");
button.type = "button";
button.className = `action-button ${className}`.trim();
button.dataset.action = action;
iconElement.classList.add("button-icon");
iconElement.textContent = icon;
iconElement.setAttribute("aria-hidden", "true");
button.append(iconElement, label);
return button;
}
function createTextCell(text) {
const cell = document.createElement("td");
cell.textContent = text;
return cell;
}
function createStudentRow(student) {
const row = document.createElement("tr");
const statusCell = document.createElement("td");
const statusBadge = document.createElement("span");
const actionCell = document.createElement("td");
row.dataset.studentId = String(student.id);
row.classList.toggle("is-disabled", student.disabled);
statusBadge.classList.add("status-badge");
statusBadge.classList.toggle("is-disabled", student.disabled);
statusBadge.textContent = student.disabled ? "已停用" : "正常";
statusCell.append(statusBadge);
actionCell.classList.add("action-cell");
actionCell.append(
createActionButton("view", "查看", "⌕"),
createActionButton("edit", "编辑", "✎"),
createActionButton(
"toggle",
student.disabled ? "启用" : "停用",
student.disabled ? "✓" : "—",
),
createActionButton("delete", "删除", "×", "is-danger"),
);
row.append(
createTextCell(String(student.id)),
createTextCell(student.name),
createTextCell(student.className),
statusCell,
actionCell,
);
return row;
}
function renderStudents() {
if (students.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 5;
cell.classList.add("empty-cell");
cell.textContent = "暂无学员数据";
row.append(cell);
studentBody.replaceChildren(row);
summary.textContent = "共 0 名学员";
return;
}
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.append(createStudentRow(student));
}
studentBody.replaceChildren(fragment);
const disabledCount = students.filter((student) => student.disabled).length;
summary.textContent =
`共 ${students.length} 名学员,${disabledCount} 名已停用`;
}
function viewStudent(student) {
const status = student.disabled ? "已停用" : "正常";
showMessage(
`学员 ${student.id}:${student.name},${student.className},状态:${status}`,
);
}
function editStudent(student) {
const newName = window.prompt("请输入新的学员姓名", student.name);
if (newName === null) {
showMessage("已取消编辑");
return;
}
const normalizedName = newName.trim();
if (!normalizedName) {
showMessage("姓名不能为空", "error");
return;
}
student.name = normalizedName;
renderStudents();
showMessage(`已将学员姓名修改为 ${student.name}`, "success");
}
function toggleStudent(student) {
student.disabled = !student.disabled;
renderStudents();
const status = student.disabled ? "停用" : "启用";
showMessage(`已${status}学员:${student.name}`, "success");
}
function deleteStudent(student) {
const confirmed = window.confirm(`确认删除学员“${student.name}”吗?`);
if (!confirmed) {
showMessage("已取消删除");
return;
}
students = students.filter((item) => item.id !== student.id);
renderStudents();
showMessage(`已删除学员:${student.name}`, "success");
}
function handleStudentAction(event) {
const button = event.target.closest("button[data-action]");
if (!button || !studentBody.contains(button)) {
return;
}
const row = button.closest("tr[data-student-id]");
if (!row) {
return;
}
const studentId = Number(row.dataset.studentId);
const student = students.find((item) => item.id === studentId);
if (!student) {
showMessage("没有找到对应的学员", "error");
return;
}
switch (button.dataset.action) {
case "view":
viewStudent(student);
break;
case "edit":
editStudent(student);
break;
case "toggle":
toggleStudent(student);
break;
case "delete":
deleteStudent(student);
break;
}
}
studentForm.addEventListener("submit", (event) => {
event.preventDefault();
const formData = new FormData(studentForm);
const name = String(formData.get("name") ?? "").trim();
const className = String(formData.get("className") ?? "").trim();
if (!name || !className) {
showMessage("请填写姓名并选择班级", "error");
return;
}
students.push({
id: nextStudentId,
name,
className,
disabled: false,
});
nextStudentId += 1;
studentForm.reset();
renderStudents();
showMessage(`已新增学员:${name}`, "success");
studentNameInput.focus();
});
// 事件委托只绑定一次,列表重新渲染后仍然有效。
studentBody.addEventListener("click", handleStudentAction);
renderButton.addEventListener("click", () => {
renderStudents();
showMessage("列表已重新渲染,操作按钮仍然有效", "success");
});
renderStudents();
</script>
</body>
</html>可复制的练习源码
复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。
.code/p04/c07-event-delegation/01-direct-binding.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>动态按钮的直接绑定问题</title>
<style>
body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
li { margin: 10px 0; padding: 12px; background: #f3f6f8; }
button { margin-left: 10px; padding: 6px 10px; }
#message { color: #a44b10; }
</style>
</head>
<body>
<h1>直接绑定不能覆盖后添加的按钮</h1>
<button id="add-button" type="button">动态新增学员</button>
<ul id="student-list"><li>张三 <button class="view-button" type="button">查看</button></li></ul>
<p id="message">先查看张三,再新增并查看新学员</p>
<script>
const studentList = document.querySelector("#student-list");
const message = document.querySelector("#message");
for (const button of document.querySelectorAll(".view-button")) {
button.addEventListener("click", () => { message.textContent = `已查看:${button.parentElement.firstChild.textContent.trim()}`; });
}
document.querySelector("#add-button").addEventListener("click", () => {
const item = document.createElement("li");
item.innerHTML = '李四 <button class="view-button" type="button">查看</button>';
studentList.append(item);
message.textContent = "新按钮没有单独绑定监听器,因此点击后不会更新消息";
});
</script>
</body>
</html>.code/p04/c07-event-delegation/02-closest-delegation.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>使用 closest 完成事件委托</title>
<style>
body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
li { margin: 10px 0; padding: 14px; border: 1px solid #d4dce5; }
button { float: right; padding: 6px 10px; }
</style>
</head>
<body>
<h1>父元素统一监听动态按钮</h1>
<button id="add-button" type="button">新增学员</button>
<ul id="student-list"><li data-id="1">张三 <button data-action="view" type="button"><span>查看</span></button></li></ul>
<p id="message">等待操作</p>
<script>
const studentList = document.querySelector("#student-list");
let nextId = 2;
studentList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (button === null || !studentList.contains(button)) return;
const item = button.closest("li[data-id]");
document.querySelector("#message").textContent = `查看学员 ${item.dataset.id}:${item.firstChild.textContent.trim()}`;
});
document.querySelector("#add-button").addEventListener("click", () => {
const item = document.createElement("li");
item.dataset.id = String(nextId);
item.innerHTML = `新学员 ${nextId} <button data-action="view" type="button"><span>查看</span></button>`;
studentList.append(item);
nextId += 1;
});
</script>
</body>
</html>.code/p04/c07-event-delegation/03-action-dispatch.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>使用 data-action 分发操作</title>
<style>
body { max-width: 760px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; border-bottom: 1px solid #d9e0e8; text-align: left; }
button { margin-right: 6px; padding: 6px 9px; }
</style>
</head>
<body>
<h1>识别操作并读取学员 ID</h1>
<table><thead><tr><th>姓名</th><th>状态</th><th>操作</th></tr></thead><tbody id="student-body">
<tr data-student-id="1"><td>张三</td><td class="status">在读</td><td><button data-action="view">查看</button><button data-action="toggle">切换状态</button><button data-action="delete">删除</button></td></tr>
<tr data-student-id="2"><td>李四</td><td class="status">在读</td><td><button data-action="view">查看</button><button data-action="toggle">切换状态</button><button data-action="delete">删除</button></td></tr>
</tbody></table>
<p id="message">等待操作</p>
<script>
const studentBody = document.querySelector("#student-body");
const message = document.querySelector("#message");
studentBody.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (button === null) return;
const row = button.closest("tr[data-student-id]");
const studentId = Number(row.dataset.studentId);
switch (button.dataset.action) {
case "view": message.textContent = `查看学员 ${studentId}`; break;
case "toggle": row.querySelector(".status").textContent = row.querySelector(".status").textContent === "在读" ? "停用" : "在读"; message.textContent = `已切换学员 ${studentId} 状态`; break;
case "delete": row.remove(); message.textContent = `已删除学员 ${studentId}`; break;
}
});
</script>
</body>
</html>小结
本节课最核心的是掌握六件事:
- 动态元素重新创建后,不会继承旧元素的监听器。
- 事件委托把监听器绑定到固定父元素,统一处理子元素的冒泡事件。
- 使用
event.target.closest()兼容按钮内部的图标和文字。 - 使用
contains()把操作范围限制在当前组件内部。 - 使用
data-action描述操作,使用data-student-id关联业务数据。 - 监听器只绑定一次,渲染函数只负责更新 DOM。