Skip to content

DOM 事件委托 ​

学员列表通常由数组或 API 数据动态生成。新增、删除或重新查询数据后,列表会重新渲染,原来的按钮也会被新的按钮替换。

如果给每个按钮单独绑定事件,每次渲染后都要重新绑定,很容易出现“旧按钮能点,新按钮没反应”的问题。本节使用事件委托处理动态元素。

本节课解决四个问题:

  1. 为什么重新渲染后,直接绑定的事件会失效。
  2. 怎样利用事件冒泡,把监听器绑定到固定的父元素。
  3. 为什么事件委托要配合 closest() 使用。
  4. 怎样用 data-* 区分查看、编辑、切换状态和删除操作。

本节继续使用本地学员数组,不发送 API 请求。先把动态列表事件处理清楚,后面再接入 fetch。

先用静态卡片练习目标定位,再复现动态列表的直接绑定失效,最后统一处理多个按钮操作。

静态卡片委托 ​

p04c05 已经介绍:按钮内部的图标或文字可能成为不同的 event.target,而 event.currentTarget 是监听器实际挂载的元素。上一节完成了表单取值与校验,现在继续解决动态列表的按钮操作。先在一张静态的 <article> 学员卡片上绑定监听器,完成一次完整的查找过程:

text
event.target
  ↓ closest("button[data-action]")
操作按钮
  ↓ closest("[data-student-id]")
学员卡片与 ID
  ↓ dataset.action
switch 分发查看或编辑

卡片结构 ​

html
<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>

按钮定位与操作分发 ​

js
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”,再进入动态列表。

单张静态学员卡片委托源码
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: 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>

:::

直接绑定失效 ​

先看一个动态生成的学员列表:

html
<ul id="student-list"></ul>
js
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(),在首次渲染后执行以下代码:

js
function bindDeleteButtons() {
  const deleteButtons = document.querySelectorAll(".delete-button");

  for (const button of deleteButtons) {
    button.addEventListener("click", () => {
      console.log(`删除学员:${button.dataset.id}`);
    });
  }
}

bindDeleteButtons();

第一次运行没有问题。但是再次调用 renderStudents() 时,replaceChildren() 会移除原来的按钮,再创建一批新按钮。

js
students.push({ id: 3, name: "王五" });
renderStudents();

事件监听器属于具体的 DOM 元素。按钮被替换后,新按钮不会继承旧按钮的监听器。每次渲染后重新调用 bindDeleteButtons() 虽然可行,但容易遗漏;更稳妥的做法是监听不会被替换的列表容器。可在直接绑定失效示例(源码文件:./.code/p04/c07-event-delegation/01-direct-binding.html)中先点击原按钮,再点击“重新渲染”后的新按钮,对比两次点击的结果。

事件冒泡与按钮定位 ​

父元素监听 ​

点击按钮产生的 click 事件会向父元素冒泡。即使列表内容经常变化,外层的 studentList 仍然存在,因此可以只监听父元素。

mermaid Diagram

事件委托可以简单理解为:

不给每个子元素单独绑定事件,让固定的父元素统一接收冒泡事件,再判断用户实际操作了哪个子元素。

以下几个监听器是同一段处理逻辑的递进版本。跟练时,每次用新版本替换上一版,不要全部添加到同一个页面,否则一次点击会触发多个监听器。

最小版本如下:

js
studentList.addEventListener("click", (event) => {
  if (!event.target.matches(".delete-button")) {
    return;
  }

  console.log(`删除学员:${event.target.dataset.id}`);
});

无论列表重新渲染多少次,监听器始终在 studentList 上,不需要重新绑定。

这里要求父容器本身没有被替换,且子元素的事件能够冒泡到它。重新创建父容器,或在子元素上调用 stopPropagation(),都会影响这条接收路径;focus、blur 等不冒泡事件也不能直接照搬本例。

操作按钮定位 ​

matches() 的局限

操作按钮中经常包含图标:

html
<button class="delete-button" data-id="1" type="button">
  <span class="button-icon">×</span>
  删除
</button>

点击“删除”文字时,event.target 可能是 button;点击 × 图标时,event.target 是内部的 span。

js
event.target.matches(".delete-button");

span 不匹配 .delete-button,操作就会失效。页面看起来会变成“按钮有些位置能点,有些位置不能点”。

closest() 定位

closest(selector) 从当前元素开始向上查找,返回最近的匹配元素。

js
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}`);
});

点击图标时,查找过程是:

text
span.button-icon → button.delete-button

点击按钮空白区域时,event.target 本身就是按钮,closest() 会直接返回它。

可以在嵌套图标定位示例(源码文件:./.code/p04/c07-event-delegation/02-closest-delegation.html)中分别点击图标和按钮文字,核对两处是否执行同一操作。

操作范围校验

完整写法还要确认按钮确实位于当前列表内:

js
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

在事件委托中,两者的区别非常明确:

js
studentList.addEventListener("click", (event) => {
  console.log(event.target);
  console.log(event.currentTarget);
});
属性在当前案例中的值
event.target用户最初点击的按钮、图标或其他子元素
event.currentTarget绑定监听器的 studentList

不能使用 currentTarget 查找具体按钮,因为它始终是列表容器。要从 target 出发,再使用 closest()。

委托处理函数的固定顺序是:从 event.target 找最近的操作按钮,确认按钮仍属于当前容器,再找到对应学员记录。这个顺序能兼容按钮内部图标,也能避免处理无关元素。

多操作委托 ​

操作标记 ​

一条学员记录通常有多个按钮。可以用 data-action 描述按钮职责。下面改用一张带多个操作按钮的静态列表练习分发;将其保存为新页面,不与前面的动态渲染代码放在一起:

html
<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>

监听器先找到按钮,再读取操作名称:

js
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 放在整条记录上,不需要在每个按钮上重复保存:

html
<li data-student-id="12">
  <span>王五</span>
  <button data-action="edit" type="button">编辑</button>
  <button data-action="delete" type="button">删除</button>
</li>

从按钮继续向上查找学员记录:

js
const studentItem = button.closest("[data-student-id]");

if (!studentItem) {
  return;
}

const studentId = Number(studentItem.dataset.studentId);
console.log(studentId);

注意两个规则:

  1. HTML 中的 data-student-id,在 JavaScript 中写成 dataset.studentId。
  2. dataset 读取的值都是字符串,需要数字 ID 时使用 Number() 转换。
js
console.log(studentItem.dataset.studentId);        // "12"
console.log(Number(studentItem.dataset.studentId)); // 12

按钮只描述操作,整条记录保存学员 ID。这样同一行的查看、编辑、删除按钮都能找到同一个业务对象,不需要在每个按钮上重复保存 ID。

操作分发 ​

操作增加后,可以使用 switch 让分支更清楚。下面用 Console 输出代替实际业务操作,先验证按钮与学员 ID 的对应关系。沿用上面的静态列表和 studentList 变量,只把上一版 if 监听器替换为以下代码:

js
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);

统一监听器只负责三件事:

  1. 找到操作按钮。
  2. 读取学员 ID 和操作名称。
  3. 把任务交给对应函数。

具体的查看、编辑和删除逻辑放在独立函数中,事件处理函数不会越写越长。

在操作分发示例(源码文件:./.code/p04/c07-event-delegation/03-action-dispatch.html)中分别点击查看、切换状态、删除按钮,核对页面提示和学员 ID。附件使用“切换状态”展示同一分发方法。

动态学员列表案例 ​

功能需求 ​

使用本地数组完成一个可以反复渲染的学员操作列表:

  1. 提交表单后新增一名学员并重新渲染。
  2. 点击按钮图标或文字都能正确识别操作。
  3. 查看、编辑、切换状态和删除共用一个列表监听器。
  4. 学员 ID 保存到表格行,按钮只保存操作名称。
  5. 点击“重新渲染”后,所有操作仍然有效。
  6. 数组为空时显示空数据提示。

下面只展示与事件委托直接相关的关键函数。表格、学员数组、studentBody、renderStudents()、showMessage() 和各业务操作函数均在完整页面(源码文件:./.code/p04/c07-event-delegation/04-dynamic-student-list.html)中定义;运行时使用完整页面,不要把下面的片段与前面的练习代码直接拼接。

操作按钮创建 ​

js
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() 能正确处理嵌套元素。

学员行创建 ​

js
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;
}

委托点击处理 ​

js
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 名学员,其中李四为停用状态。

可以按下面步骤验证:

  1. 点击任意操作按钮的图标和文字,均应识别为同一操作,说明 closest() 找到了按钮。
  2. 点击“重新渲染列表”后,再点击查看、编辑、停用或删除,操作仍然有效。
  3. 新增一名学员后,立即点击新行的操作按钮;不需要重新绑定监听器。
  4. 依次删除全部学员,表格应显示“暂无学员数据”;此时新增学员后,新的操作按钮仍可使用。

完整代码见动态学员操作列表(源码文件:./.code/p04/c07-event-delegation/04-dynamic-student-list.html)。

动态学员操作列表
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
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
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
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>

小结 ​

本节课最核心的是掌握六件事:

  1. 动态元素重新创建后,不会继承旧元素的监听器。
  2. 事件委托把监听器绑定到固定父元素,统一处理子元素的冒泡事件。
  3. 使用 event.target.closest() 兼容按钮内部的图标和文字。
  4. 使用 contains() 把操作范围限制在当前组件内部。
  5. 使用 data-action 描述操作,使用 data-student-id 关联业务数据。
  6. 监听器只绑定一次,渲染函数只负责更新 DOM。