Skip to content

DOM 事件 ​

上一节已经能把学员数组显示成列表。现在要加入搜索、班级筛选和重置:用户点击按钮、输入关键字或切换班级时,列表应随操作更新。浏览器会为这些操作产生事件,JavaScript 监听事件后执行对应功能。

本节课解决五个问题:

  1. 怎样使用 addEventListener() 绑定事件。
  2. 怎样通过事件对象获取操作信息。
  3. input、change、click、submit 等事件怎样选择。
  4. 怎样阻止表单刷新和链接跳转。
  5. 事件为什么会从子元素向父元素冒泡。

动态列表的事件委托放到后面的专门章节。本节先掌握事件基础。

前面的短示例沿用各小节给出的 HTML 和变量;同一元素的不同绑定写法用于比较,不要全部叠加。综合案例中的 HTML 和 JavaScript 则按顺序放在同一个文件中。

事件绑定与事件对象 ​

点击事件 ​

html
<button id="search-button" type="button">搜索</button>
<p id="message">等待操作</p>
js
const searchButton = document.querySelector("#search-button");
const message = document.querySelector("#message");

searchButton.addEventListener("click", () => {
  message.textContent = "开始搜索学员";
});

addEventListener() 可以先简单理解为:当指定事件发生时,调用给定函数。

js
element.addEventListener("事件名称", 处理函数);

点击按钮后,浏览器调用箭头函数,段落文字会从“等待操作”变为“开始搜索学员”。第一个参数是事件名称,第二个参数是收到事件后执行的函数。

事件名称不加 on。下面的处理函数用于比较两种事件名称的写法:

js
function handleSearch() {
  message.textContent = "开始搜索学员";
}

searchButton.addEventListener("click", handleSearch);   // 正确
searchButton.addEventListener("onclick", handleSearch); // 不会监听到 click

事件绑定方式 ​

页面中可能看到三种写法。

HTML 行内事件

html
<button onclick="handleSearch()">搜索</button>

事件和 HTML 混在一起,不利于维护,课程项目不使用。

DOM 事件属性

js
searchButton.onclick = handleSearch;

同一个属性只能保留最后一个处理函数。下面两个函数分别输出不同文字,便于观察覆盖结果:

js
function firstHandler() {
  console.log("更新学员列表");
}

function secondHandler() {
  console.log("记录搜索操作");
}

searchButton.onclick = firstHandler;
searchButton.onclick = secondHandler; // 覆盖前一个

addEventListener

js
searchButton.addEventListener("click", firstHandler);
searchButton.addEventListener("click", secondHandler);

两个函数都会执行,也可以按函数引用单独移除。项目统一使用这种方式。

addEventListener() 让事件代码与 HTML 结构分开,也允许一个元素绑定多个职责。例如一个搜索按钮可以同时更新列表、记录操作日志;后面移除监听器时也能精确指定要移除哪个函数。

绑定时机

如果脚本没有使用 defer,也没有放在 </body> 前面,可以等待 DOM 创建完成:

js
document.addEventListener("DOMContentLoaded", () => {
  const searchButton = document.querySelector("#search-button");
  console.log(searchButton);
});

当前课程项目优先使用外部脚本加 defer,不需要每个文件都重复包一层 DOMContentLoaded。

html
<script src="./app.js" defer></script>

window 的 load 会等待图片等资源加载完成,通常比 DOMContentLoaded 更晚。普通 DOM 初始化不需要等待所有图片。

事件对象 ​

浏览器调用处理函数时,会传入一个事件对象。

js
searchButton.addEventListener("click", (event) => {
  console.log(event);
  console.log(event.type);
  console.log(event.target);
  console.log(event.currentTarget);
});

常用成员:

成员作用
type当前事件名称
target最初触发事件的元素
currentTarget当前正在执行监听器的元素
key键盘按键名称
clientX、clientY鼠标在浏览器可视区域中的坐标
preventDefault()阻止浏览器默认行为
stopPropagation()阻止事件继续传播

参数名可以自己定义,但项目中通常使用 event:

js
searchButton.addEventListener("click", (event) => {
  console.log(event.type);
});

target 与 currentTarget ​

按钮内部可能还有图标:

html
<button id="save-button" type="button">
  <span class="icon">✓</span>
  保存
</button>
js
const saveButton = document.querySelector("#save-button");

saveButton.addEventListener("click", (event) => {
  console.log(event.target);
  console.log(event.currentTarget);
});

点击文字时,target 可能是按钮;点击图标时,target 可能是 span。currentTarget 始终是绑定监听器的按钮。

在普通按钮事件中,需要操作按钮本身时使用 currentTarget:

js
saveButton.addEventListener("click", (event) => {
  event.currentTarget.disabled = true;
});

点击内部图标时,target 可能是图标,currentTarget 仍然是按钮。普通按钮事件中要修改绑定监听器的按钮,优先使用 currentTarget。

事件目标辨析 ​

打开下面的练习,分别点击“保存”文字和左侧图标,观察输出。附件把文字和图标分别放在两个 span 中:

  • event.target 会随着实际点击位置变化:点击图标时是图标 span,点击文字时是文字 span。
  • event.currentTarget 始终是绑定监听器的 button。

这一步只观察两个属性。下一节先把事件用于表单取值与校验;p04c07 再使用 closest(),从变化的 target 向上找到真正的操作按钮。

辨析 target 与 currentTarget 源码
html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>辨析 target 与 currentTarget</title>
  <style>
    body {
      max-width: 680px;
      margin: 40px auto;
      padding: 0 20px;
      color: #303133;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }

    button {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 12px 18px;
      border: 0;
      border-radius: 6px;
      background: #409eff;
      color: #fff;
      cursor: pointer;
      font: inherit;
    }

    .button-icon {
      display: inline-grid;
      width: 24px;
      height: 24px;
      place-items: center;
      border-radius: 4px;
      background: rgb(255 255 255 / 22%);
    }

    dl {
      display: grid;
      grid-template-columns: 180px 1fr;
      gap: 10px 16px;
      margin-top: 24px;
      padding: 18px;
      background: #f5f7fa;
    }

    dt { font-weight: 700; }
    dd { margin: 0; color: #606266; }
  </style>
</head>
<body>
  <h1>谁触发,谁在监听</h1>
  <p>请分别点击按钮文字和左侧图标,比较两个事件属性。</p>

  <button id="save-button" type="button">
    <span class="button-icon" aria-hidden="true">✓</span>
    <span class="button-label">保存</span>
  </button>

  <dl>
    <dt>event.target</dt>
    <dd id="target-result">等待点击</dd>
    <dt>event.currentTarget</dt>
    <dd id="current-target-result">等待点击</dd>
  </dl>

  <script>
    const saveButton = document.querySelector("#save-button");
    const targetResult = document.querySelector("#target-result");
    const currentTargetResult = document.querySelector("#current-target-result");

    function describeElement(element) {
      const className = element.className;
      return `<${element.tagName.toLowerCase()}>${className ? `.${className}` : ""}`;
    }

    saveButton.addEventListener("click", (event) => {
      targetResult.textContent = describeElement(event.target);
      currentTargetResult.textContent = describeElement(event.currentTarget);
    });
  </script>
</body>
</html>

:::

常用事件与默认行为 ​

鼠标事件 ​

事件触发时机
click单击
dblclick双击
mousedown按下鼠标按钮
mouseup松开鼠标按钮
mouseenter鼠标进入元素
mouseleave鼠标离开元素
mouseover进入元素或内部子元素,会冒泡
mouseout离开元素或内部子元素,会冒泡

普通悬停效果优先使用 CSS :hover。只有悬停后需要执行 JavaScript 逻辑时,才监听鼠标事件。

js
const studentCard = document.querySelector(".student-card");

studentCard.addEventListener("mouseenter", () => {
  studentCard.classList.add("is-hover");
});

studentCard.addEventListener("mouseleave", () => {
  studentCard.classList.remove("is-hover");
});

mouseenter 和 mouseleave 不会因为鼠标在内部子元素之间移动而反复触发,更适合普通卡片进入和离开场景。

选择原则:单击按钮使用 click;普通视觉悬停优先写 CSS :hover;确实要在进入或离开卡片时执行 JavaScript,再使用 mouseenter、mouseleave。

输入、选择与焦点事件 ​

表单输入最常用的是 input 和 change。

input

输入框内容每次发生变化时触发,适合实时字数、搜索建议和即时校验。

html
<label for="keyword">搜索关键字</label>
<input id="keyword" placeholder="请输入关键字" />
<span id="keyword-count">0 个字符</span>
js
const keywordInput = document.querySelector("#keyword");
const keywordCount = document.querySelector("#keyword-count");

keywordInput.addEventListener("input", () => {
  const length = keywordInput.value.length;
  keywordCount.textContent = `${length} 个字符`;
});

每输入或删除一个字符,input 都会触发一次,所以右侧字数会实时更新。

这里指用户编辑控件。JavaScript 执行 keywordInput.value = "张三" 不会自动触发 input 或 change;程序回填后如果需要重新统计或搜索,应显式调用对应的处理函数。

change

选择框、单复选框的值确定改变时触发;文本框通常在修改后失去焦点时触发。

html
<label for="class-name">班级</label>
<select id="class-name">
  <option value="">全部班级</option>
  <option value="Python 1 班">Python 1 班</option>
  <option value="Python 2 班">Python 2 班</option>
</select>
js
const classSelect = document.querySelector("#class-name");

classSelect.addEventListener("change", () => {
  console.log(`当前班级:${classSelect.value || "全部班级"}`);
});

用户切换下拉选项后,控制台会输出当前班级。文本框的 change 通常在用户修改后失去焦点时触发,因此实时搜索和实时字数不使用它。

需求推荐事件
输入时实时显示字数input
输入时显示搜索建议input
下拉选项切换change
复选框状态切换change
用户完成文本修改后处理change

focus 与 blur

事件触发时机
focus控件获得焦点
blur控件失去焦点
js
keywordInput.addEventListener("focus", () => {
  keywordInput.classList.add("is-focus");
});

keywordInput.addEventListener("blur", () => {
  keywordInput.classList.remove("is-focus");
});

很多焦点样式可以直接使用 CSS :focus。JavaScript 焦点事件适合失去焦点校验、记录当前编辑字段等逻辑。

JavaScript 也可以主动切换焦点:

js
keywordInput.focus();
keywordInput.blur();

键盘事件 ​

事件触发时机
keydown按键按下,可以连续触发
keyup按键松开

通过 event.key 判断按键:

js
keywordInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    console.log("开始搜索");
  }

  if (event.key === "Escape") {
    keywordInput.value = "";
    keywordCount.textContent = "0 个字符";
  }
});

常见 key 值:

按键event.key
回车Enter
EscEscape
空格一个空格字符串
方向键ArrowUp、ArrowDown、ArrowLeft、ArrowRight

按下回车时,上面的代码会输出“开始搜索”;按下 Esc 时会清空输入框和字符数。程序修改 value 不会自动触发 input,所以要同步更新字符数。判断按键时使用 event.key,不要使用已经过时的 keyCode 数字。

表单提交与默认行为 ​

使用 JavaScript 调 API 时,应监听表单的 submit,而不是只监听提交按钮的 click。

html
<form id="search-form">
  <label for="search-keyword">搜索关键字</label>
  <input id="search-keyword" name="keyword" />
  <button type="submit">搜索</button>
</form>
js
const searchForm = document.querySelector("#search-form");

searchForm.addEventListener("submit", (event) => {
  event.preventDefault();

  const keyword = searchForm.elements.keyword.value.trim();
  console.log(`搜索关键字:${keyword}`);
});

这样点击按钮和在输入框中按回车,都会执行同一段搜索逻辑。

默认行为

浏览器对部分元素有默认行为:

  • 提交表单会发送请求或刷新页面。
  • 点击链接会跳转。
  • 某些按键会滚动页面或提交表单。

preventDefault() 只阻止默认行为,不会停止事件传播。

html
<a id="course-link" href="./course.html">查看课程</a>
js
const courseLink = document.querySelector("#course-link");

courseLink.addEventListener("click", (event) => {
  event.preventDefault();
  console.log("暂时不跳转,先检查权限");
});

原生 addEventListener() 中只写 return false 不会阻止默认行为。下面是独立对比,测试时不要同时保留前面的 preventDefault() 监听器:

js
searchForm.addEventListener("submit", () => {
  return false; // 不能代替 preventDefault()
});

选择原则:按钮的局部操作使用 click;表单提交统一监听 submit;需要阻止刷新或跳转时,调用 event.preventDefault(),不要依赖 return false。

事件传播与监听器 ​

事件冒泡 ​

一次点击通常经历三个阶段:捕获阶段从外层祖先传向目标,目标阶段到达实际点击元素,冒泡阶段再从目标向祖先传播。addEventListener() 默认不监听捕获阶段;本节的父元素监听器通过冒泡收到点击,暂不使用 capture: true。

点击子元素时,事件会从最初元素向外层祖先元素传播,这个过程叫事件冒泡。

html
<article id="student-card">
  <button id="edit-button" type="button">编辑</button>
</article>
js
const studentCard = document.querySelector("#student-card");
const editButton = document.querySelector("#edit-button");

studentCard.addEventListener("click", () => {
  console.log("点击了学员卡片");
});

editButton.addEventListener("click", () => {
  console.log("点击了编辑按钮");
});

点击编辑按钮后,通常依次输出:

text
点击了编辑按钮
点击了学员卡片

传播过程可以简单表示为:

mermaid Diagram

冒泡不是错误。后面的事件委托章节正是利用这个机制处理动态列表。

并非所有事件都会冒泡。常用的 click、input、change 可以按这个思路处理;focus、blur、mouseenter、mouseleave 不冒泡。需要委托焦点变化时,可以使用会冒泡的 focusin、focusout。

阻止冒泡

确实不希望父元素响应时,可以调用:

js
editButton.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("只执行编辑操作");
});

不要看到重复执行就随意添加 stopPropagation()。先检查父子元素的职责,确认传播确实不需要再阻止。

stopPropagation() 和 preventDefault() 解决的问题不同:前者阻止事件继续向外传播,后者阻止浏览器默认动作。提交表单时通常需要后者,点击卡片内独立按钮时才可能需要前者。

监听器移除与单次执行 ​

removeEventListener() 必须使用绑定时的同一个函数引用。

js
function handleSearch() {
  console.log("搜索学员");
}

searchButton.addEventListener("click", handleSearch);
searchButton.removeEventListener("click", handleSearch);

下面的写法无法移除:

js
searchButton.addEventListener("click", () => {
  console.log("搜索学员");
});

searchButton.removeEventListener("click", () => {
  console.log("搜索学员");
});

两个箭头函数看起来一样,但它们是两个不同的函数对象。

有些引导提示只需要响应一次:

html
<button id="help-button" type="button">帮助</button>
js
const helpButton = document.querySelector("#help-button");

helpButton.addEventListener(
  "click",
  () => {
    console.log("显示首次使用提示");
  },
  { once: true },
);

once: true 表示执行一次后由浏览器自动移除监听器。

综合案例:学员条件搜索 ​

根据本地学员数组完成条件搜索:

  1. 输入关键字时实时显示字符数。
  2. 提交搜索表单时阻止页面刷新。
  3. 根据姓名、手机号和班级过滤学员。
  4. 切换班级时自动重新搜索。
  5. 在关键字输入框按 Escape 清空条件。
  6. 点击重置按钮恢复全部学员。

下面的 HTML 放在 <body> 中,后续 JavaScript 代码按顺序放在 </body> 前的同一个 <script> 中。列表节点的创建沿用上一节的方法,这里一并写出,方便单独复现本案例。

页面结构 ​

html
<main>
  <h1>学员条件搜索</h1>
  <form id="search-form">
    <label for="keyword">搜索关键字</label>
    <input id="keyword" name="keyword" type="search" />
    <span id="keyword-count">0 个字符</span>

    <label for="class-name">班级</label>
    <select id="class-name" name="class_name">
      <option value="">全部班级</option>
      <option value="Python 1 班">Python 1 班</option>
      <option value="Python 2 班">Python 2 班</option>
      <option value="Web 全栈班">Web 全栈班</option>
    </select>

    <button type="submit">搜索</button>
    <button id="reset-button" type="button">重置</button>
  </form>

  <h2>搜索结果</h2>
  <span id="result-summary">正在准备数据...</span>
  <section id="student-list"></section>
</main>

学员数据与列表渲染 ​

准备 6 名本地学员,并查询页面中需要更新的元素:

js
const students = [
  { id: 1, name: "张三", className: "Python 1 班", mobile: "13800000001" },
  { id: 2, name: "李四", className: "Python 2 班", mobile: "13800000002" },
  { id: 3, name: "王五", className: "Web 全栈班", mobile: "13800000003" },
  { id: 4, name: "赵六", className: "Python 1 班", mobile: "13800000004" },
  { id: 5, name: "钱七", className: "Python 2 班", mobile: "13800000005" },
  { id: 6, name: "孙八", className: "Web 全栈班", mobile: "13800000006" },
];

const searchForm = document.querySelector("#search-form");
const keywordInput = document.querySelector("#keyword");
const keywordCount = document.querySelector("#keyword-count");
const classSelect = document.querySelector("#class-name");
const resetButton = document.querySelector("#reset-button");
const studentList = document.querySelector("#student-list");
const resultSummary = document.querySelector("#result-summary");

用学员数据创建卡片,再根据结果数组更新列表和统计文字:

js
function createStudentCard(student) {
  const card = document.createElement("article");
  const name = document.createElement("h3");
  const className = document.createElement("p");
  const mobile = document.createElement("p");

  card.classList.add("student-card");
  card.dataset.id = String(student.id);

  name.textContent = student.name;
  className.textContent = `班级:${student.className}`;
  mobile.textContent = `手机号:${student.mobile}`;

  card.append(name, className, mobile);
  return card;
}

function renderStudentList(studentData) {
  if (studentData.length === 0) {
    const emptyMessage = document.createElement("p");
    emptyMessage.classList.add("empty-message");
    emptyMessage.textContent = "没有找到符合条件的学员";

    studentList.replaceChildren(emptyMessage);
    resultSummary.textContent = "共 0 条结果";
    return;
  }

  const fragment = document.createDocumentFragment();

  for (const student of studentData) {
    fragment.append(createStudentCard(student));
  }

  studentList.replaceChildren(fragment);
  resultSummary.textContent = `共 ${studentData.length} 条结果`;
}

studentList 使用 <section>,因此既能放学员卡片,也能在空结果时放提示段落。列表渲染函数会替换旧节点,避免多次搜索后出现重复卡片。

条件过滤 ​

js
function filterStudents(students, keyword, className) {
  const normalizedKeyword = keyword.trim().toLowerCase();

  return students.filter((student) => {
    const matchesKeyword =
      !normalizedKeyword ||
      student.name.toLowerCase().includes(normalizedKeyword) ||
      student.mobile.includes(normalizedKeyword);

    const matchesClass =
      !className || student.className === className;

    return matchesKeyword && matchesClass;
  });
}

空关键字和空班级都表示“不限制这一项条件”。只有姓名或手机号匹配关键字,并且班级也匹配时,学员才会进入结果数组。

搜索与事件绑定 ​

js
function runSearch() {
  const keyword = keywordInput.value;
  const className = classSelect.value;
  const result = filterStudents(students, keyword, className);

  renderStudentList(result);
}

搜索函数主动读取当前表单状态。提交、班级切换和清空条件都可以调用它。

绑定事件

js
searchForm.addEventListener("submit", (event) => {
  event.preventDefault();
  runSearch();
});

keywordInput.addEventListener("input", () => {
  const length = keywordInput.value.length;
  keywordCount.textContent = `${length} 个字符`;
});

classSelect.addEventListener("change", runSearch);

keywordInput.addEventListener("keydown", (event) => {
  if (event.key !== "Escape") {
    return;
  }

  keywordInput.value = "";
  keywordCount.textContent = "0 个字符";
  runSearch();
});

resetButton.addEventListener("click", () => {
  searchForm.reset();
  keywordCount.textContent = "0 个字符";
  runSearch();
  keywordInput.focus();
});

renderStudentList(students);

同一个 runSearch() 被提交、班级切换、Esc 清空和重置复用,避免把过滤逻辑复制到每个监听器中。最后先渲染全部 6 名学员,等待用户操作。

运行与验收 ​

打开学员条件搜索(源码文件:./.code/p04/c05-events/04-student-search.html),默认应显示 6 名学员和“共 6 条结果”。

可以按下面步骤验证:

  1. 输入“张”,右侧字符数应实时变化;点击搜索或按回车后,只显示张三。
  2. 选择“Python 2 班”,列表应只显示李四和钱七;再输入一个不存在的关键字,应显示空数据提示。
  3. 在关键字输入框按 Esc,应清空关键字并恢复当前班级下的结果。
  4. 点击重置,应恢复全部学员,焦点回到关键字输入框。

完整附件还包含页面样式:

学员条件搜索
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: #f5f7fa;
      color: #303133;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }

    .page-container {
      width: min(980px, calc(100% - 32px));
      margin: 48px auto;
    }

    .page-title {
      margin: 0 0 22px;
      font-size: 28px;
    }

    .search-panel {
      margin-bottom: 18px;
      padding: 20px;
      border-radius: 8px;
      background: #fff;
      box-shadow: 0 5px 18px rgb(0 0 0 / 5%);
    }

    .search-form {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 220px auto;
      gap: 12px;
      align-items: end;
    }

    .search-form label {
      display: block;
      margin-bottom: 8px;
      font-size: 14px;
      font-weight: 600;
    }

    .input-wrap {
      position: relative;
    }

    input,
    select,
    button {
      min-height: 42px;
      border-radius: 5px;
      font: inherit;
    }

    input,
    select {
      width: 100%;
      border: 1px solid #dcdfe6;
      background: #fff;
      outline: none;
    }

    input {
      padding: 9px 92px 9px 12px;
    }

    select {
      padding: 9px 12px;
    }

    input:focus,
    select:focus {
      border-color: #16baaa;
    }

    #keyword-count {
      position: absolute;
      top: 40px;
      right: 12px;
      color: #909399;
      font-size: 13px;
    }

    .button-group {
      display: flex;
      gap: 8px;
    }

    button {
      padding: 0 18px;
      border: 1px solid transparent;
      cursor: pointer;
    }

    button[type="submit"] {
      background: #16baaa;
      color: #fff;
    }

    #reset-button {
      border-color: #dcdfe6;
      background: #fff;
      color: #606266;
    }

    .result-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin: 22px 0 14px;
    }

    .result-header h2 {
      margin: 0;
      font-size: 20px;
    }

    #result-summary {
      color: #606266;
      font-size: 14px;
    }

    .student-list {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 14px;
    }

    .student-card {
      padding: 20px;
      border-radius: 8px;
      background: #fff;
      box-shadow: 0 4px 15px rgb(0 0 0 / 5%);
    }

    .student-card h3 {
      margin: 0 0 12px;
    }

    .student-card p {
      margin: 7px 0;
      color: #606266;
    }

    .empty-message {
      grid-column: 1 / -1;
      padding: 70px 20px;
      border-radius: 8px;
      background: #fff;
      color: #909399;
      text-align: center;
    }

    @media (max-width: 760px) {
      .search-form,
      .student-list {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <main class="page-container">
    <h1 class="page-title">学员条件搜索</h1>

    <section class="search-panel">
      <form id="search-form" class="search-form">
        <div class="input-wrap">
          <label for="keyword">搜索关键字</label>
          <input
            id="keyword"
            name="keyword"
            type="search"
            autocomplete="off"
            placeholder="搜索姓名或手机号,按 Esc 清空"
          />
          <span id="keyword-count">0 个字符</span>
        </div>

        <div>
          <label for="class-name">班级</label>
          <select id="class-name" name="class_name">
            <option value="">全部班级</option>
            <option value="Python 1 班">Python 1 班</option>
            <option value="Python 2 班">Python 2 班</option>
            <option value="Web 全栈班">Web 全栈班</option>
          </select>
        </div>

        <div class="button-group">
          <button type="submit">搜索</button>
          <button id="reset-button" type="button">重置</button>
        </div>
      </form>
    </section>

    <header class="result-header">
      <h2>搜索结果</h2>
      <span id="result-summary">正在准备数据...</span>
    </header>

    <section id="student-list" class="student-list"></section>
  </main>

  <script>
    const students = [
      { id: 1, name: "张三", className: "Python 1 班", mobile: "13800000001" },
      { id: 2, name: "李四", className: "Python 2 班", mobile: "13800000002" },
      { id: 3, name: "王五", className: "Web 全栈班", mobile: "13800000003" },
      { id: 4, name: "赵六", className: "Python 1 班", mobile: "13800000004" },
      { id: 5, name: "钱七", className: "Python 2 班", mobile: "13800000005" },
      { id: 6, name: "孙八", className: "Web 全栈班", mobile: "13800000006" },
    ];

    const searchForm = document.querySelector("#search-form");
    const keywordInput = document.querySelector("#keyword");
    const keywordCount = document.querySelector("#keyword-count");
    const classSelect = document.querySelector("#class-name");
    const resetButton = document.querySelector("#reset-button");
    const studentList = document.querySelector("#student-list");
    const resultSummary = document.querySelector("#result-summary");

    function createStudentCard(student) {
      const card = document.createElement("article");
      const name = document.createElement("h3");
      const className = document.createElement("p");
      const mobile = document.createElement("p");

      card.classList.add("student-card");
      card.dataset.id = String(student.id);

      name.textContent = student.name;
      className.textContent = `班级:${student.className}`;
      mobile.textContent = `手机号:${student.mobile}`;

      card.append(name, className, mobile);
      return card;
    }

    function renderStudentList(studentData) {
      if (studentData.length === 0) {
        const emptyMessage = document.createElement("p");
        emptyMessage.classList.add("empty-message");
        emptyMessage.textContent = "没有找到符合条件的学员";

        studentList.replaceChildren(emptyMessage);
        resultSummary.textContent = "共 0 条结果";
        return;
      }

      const fragment = document.createDocumentFragment();

      for (const student of studentData) {
        fragment.append(createStudentCard(student));
      }

      studentList.replaceChildren(fragment);
      resultSummary.textContent = `共 ${studentData.length} 条结果`;
    }

    function filterStudents(students, keyword, className) {
      const normalizedKeyword = keyword.trim().toLowerCase();

      return students.filter((student) => {
        const matchesKeyword =
          !normalizedKeyword ||
          student.name.toLowerCase().includes(normalizedKeyword) ||
          student.mobile.includes(normalizedKeyword);

        const matchesClass =
          !className || student.className === className;

        return matchesKeyword && matchesClass;
      });
    }

    function runSearch() {
      const keyword = keywordInput.value;
      const className = classSelect.value;
      const result = filterStudents(students, keyword, className);

      renderStudentList(result);
    }

    searchForm.addEventListener("submit", (event) => {
      event.preventDefault();
      runSearch();
    });

    keywordInput.addEventListener("input", () => {
      const length = keywordInput.value.length;
      keywordCount.textContent = `${length} 个字符`;
    });

    classSelect.addEventListener("change", runSearch);

    keywordInput.addEventListener("keydown", (event) => {
      if (event.key !== "Escape") {
        return;
      }

      keywordInput.value = "";
      keywordCount.textContent = "0 个字符";
      runSearch();
    });

    resetButton.addEventListener("click", () => {
      searchForm.reset();
      keywordCount.textContent = "0 个字符";
      runSearch();
      keywordInput.focus();
    });

    renderStudentList(students);
  </script>
</body>
</html>

小结 ​

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

  1. 使用 addEventListener() 绑定事件,事件名称不加 on。
  2. 绑定时传入函数引用,不要提前调用函数。
  3. target 表示最初触发元素,currentTarget 表示当前监听元素。
  4. 表单监听 submit,并用 preventDefault() 阻止默认刷新。
  5. input 适合实时变化,change 适合选项或确定后的变化。
  6. 点击事件默认向上冒泡,它是后续事件委托的基础。