自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

JavaScript

  1. 01JavaScript 入门
  2. 02类型、值与变量
  3. 03表达式与运算符
  4. 04语句
  5. 05对象
  6. 06数组
  7. 07函数
  8. 08类
  9. 09模块
  10. 10正则表达式
  11. 11浏览器环境
  12. 12文档对象模型 (DOM)
  13. 13Window 对象
  14. 14事件处理
  15. 15HTTP 通信
正在加载课程章节内容
课程编程JavaScript事件处理

浏览器事件处理:从监听器到流畅交互

一张商品卡片本来只是静态内容。直到用户点击“加入购物车”、在搜索框里输入关键词、按下回车提交表单,它才真正成为可以交流的界面。

这些交互看起来各不相同,背后却遵循同一套机制:浏览器发现动作,创建事件对象,再沿 DOM 路径把事件交给提前注册的监听器。掌握这条路径之后,许多“点一下为什么触发两次”“动态按钮为什么没反应”“滚动时页面为什么卡顿”的问题都会变得可解释。

本章会从一个按钮开始,逐步走到事件传播、委托、表单与高频事件优化。所有 DOM 示例都应放在浏览器页面中运行;纯函数示例会单独标明可在 Node.js 中验证。

JavaScript 事件监听器模型流程图,展示用户点击按钮、浏览器派发事件对象、addEventListener 注册多个监听器并更新 DOM。

事件处理的核心问题有三个:事件发生在哪里,当前由谁处理,以及处理之后是否还要执行默认动作或继续传播。

学习路线

完成本章后,你应该能够:

  • 用 addEventListener() 注册、移除和批量清理监听器。
  • 从事件对象中区分 target 与 currentTarget。
  • 预测捕获、目标、冒泡三个阶段的执行顺序。
  • 正确选择 preventDefault()、stopPropagation() 与 stopImmediatePropagation()。
  • 用事件委托管理动态生成的列表项。
  • 处理鼠标、指针、拖放、键盘和表单事件。
  • 根据交互目标选择防抖、节流或 requestAnimationFrame()。

监听器:注册、执行与移除

事件监听器是一段提前登记的函数。事件尚未发生时,它只是等待;事件发生后,浏览器调用它,并把本次事件对象作为参数传入。

html
<button id="save" type="button">保存</button>
<p id="status" aria-live="polite"></p>
 
<script>
  const button = document.querySelector("#save");
  const status = document.querySelector("#status");
 
  button.addEventListener("click", event => {
    status.textContent = "保存完成";
    event.currentTarget.disabled = true;
  });
</script>

点击按钮后,段落显示“保存完成”,按钮也会被禁用。这里有三个角色:

  • button 是监听事件的对象。
  • "click" 是事件类型,使用 addEventListener() 时不写 on 前缀。
  • 回调函数是事件发生后要执行的逻辑,event 由浏览器传入。

三种常见绑定方式

旧代码中常能看到以下三种写法:

html
<button onclick="save()">保存</button>
javascript
button.onclick = save;
javascript
button.addEventListener("click", save);

内联事件把行为写进 HTML,不利于复用与维护。事件属性一次只能保存一个函数,后一次赋值会覆盖前一次。addEventListener() 可以给同一元素、同一事件注册多个监听器,因此更适合组织现代页面交互。

javascript
function markDirty() {
  console.log("记录表单已经修改");
}
 
function updatePreview() {
  console.log("刷新预览");
}
 
input.addEventListener("input", markDirty);
input.addEventListener("input", updatePreview);

一次 input 事件会依次调用这两个函数。把函数名交给监听器时不要加括号;markDirty() 表示立即调用并把返回值交出去,markDirty 才表示交出函数本身。

需要传入额外参数时,用包装函数:

javascript
function changeTheme(theme, event) {
  document.documentElement.dataset.theme = theme;
  console.log(event.type);
}
 
button.addEventListener("click", event => {
  changeTheme("dark", event);
});
1.1
同一个按钮的点击需要同时记录日志和更新界面,哪种方式最合适?

移除时必须使用同一个函数引用

removeEventListener() 不是根据函数内容寻找监听器,而是比较事件类型、函数对象和捕获标记。

javascript
function handleClick(event) {
  console.log(event.type);
}
 
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);

下面的移除不会生效,因为两次创建的是两个不同的箭头函数:

javascript
button.addEventListener("click", () => {
  console.log("clicked");
});
 
button.removeEventListener("click", () => {
  console.log("clicked");
});

如果注册时使用了捕获模式,移除时也要匹配捕获标记:

javascript
panel.addEventListener("click", handleClick, { capture: true });
panel.removeEventListener("click", handleClick, { capture: true });
1.2
用一个新建的匿名箭头函数调用 removeEventListener,可以移除内容相同的旧匿名监听器。

用选项控制监听器生命周期

第三个参数推荐写成选项对象,意图更清楚:

javascript
button.addEventListener("click", handleClick, {
  once: true,
});
 
document.addEventListener("click", handleClick, {
  capture: true,
});
 
window.addEventListener("touchmove", trackGesture, {
  passive: true,
});

常用选项如下:

选项作用典型场景
once执行一次后自动移除首次引导、一次性确认
capture在捕获阶段调用需要从外层提前观察事件
passive承诺不会阻止默认行为只读取滚动或触摸状态
signal信号中止时移除监听器页面模块统一清理

当一个界面模块注册了多个监听器,可以用同一个 AbortController 管理:

javascript
const controller = new AbortController();
const options = { signal: controller.signal };
 
button.addEventListener("click", handleClick, options);
searchInput.addEventListener("input", handleSearch, options);
window.addEventListener("resize", handleResize, options);
 
// 模块卸载时,一次移除上面三个监听器
controller.abort();

passive: true 与 preventDefault() 的意图相反。既然已经承诺不阻止默认行为,浏览器就可能忽略监听器里的 preventDefault(),并在控制台给出警告。

1.3
一个页面模块注册了多个监听器,卸载时希望一次清理,哪种方案最直接?

确保元素已经进入 DOM

如果脚本执行时目标元素还没被解析,querySelector() 会返回 null。常见方案有三种:

  1. 把脚本放在 </body> 之前。
  2. 给外部脚本添加 defer。
  3. 等待 DOMContentLoaded。
javascript
document.addEventListener("DOMContentLoaded", () => {
  const button = document.querySelector("#save");
  button.addEventListener("click", handleClick);
});

DOMContentLoaded 表示 HTML 已经解析成 DOM;window 的 load 还要等待图片、样式表等资源完成加载。普通交互只依赖 DOM 时,不必等到 load。

1.4
交互逻辑只要求 HTML 已经解析成 DOM,通常等待哪个事件就足够?

事件对象:读懂一次交互的上下文

浏览器调用监听器时,会把这次交互的信息装进事件对象。不同事件类型会增加不同属性:鼠标事件有坐标,键盘事件有按键,提交事件会指向表单。

javascript
button.addEventListener("click", event => {
  console.log(event.type);             // click
  console.log(event.target);
  console.log(event.currentTarget);
  console.log(event.defaultPrevented); // false
});

最常见的通用属性包括:

属性含义
type事件类型,例如 click、input
target事件最初发生的对象
currentTarget当前正在执行监听器的对象
bubbles该事件是否支持冒泡
cancelable默认行为是否允许取消
defaultPrevented默认行为是否已经被阻止
eventPhase当前处于捕获、目标还是冒泡阶段

target 与 currentTarget 为什么会不同

看一个按钮内部带图标的例子:

html
<button id="buy" type="button">
  <span class="icon" aria-hidden="true">+</span>
  加入购物车
</button>
javascript
const button = document.querySelector("#buy");
 
button.addEventListener("click", event => {
  console.log(event.target);
  console.log(event.currentTarget);
});

如果用户点中加号:

  • event.target 是真正点中的 span。
  • event.currentTarget 是注册监听器的 button。

如果用户点中文字所在的按钮空白区域,两者都可能是 button。因此,target 会随具体命中位置改变,currentTarget 则由当前正在运行哪一个监听器决定。

事件对象中 target 指向真正点中的图标,currentTarget 指向正在执行监听器的按钮。

普通函数监听器里的 this 通常等于 currentTarget:

javascript
button.addEventListener("click", function (event) {
  console.log(this === event.currentTarget); // true
});

箭头函数没有自己的 this,所以需要稳定引用监听元素时,直接使用 event.currentTarget 更清楚。

不要把事件对象保存起来,过一段时间后再依赖它的 currentTarget。监听器执行结束后,currentTarget 不再代表正在执行的监听节点。异步逻辑需要某个元素时,先把它保存到局部变量。

javascript
button.addEventListener("click", event => {
  const clickedButton = event.currentTarget;
 
  setTimeout(() => {
    clickedButton.textContent = "已处理";
  }, 300);
});
2.1
监听器注册在按钮上,用户点中了按钮内部的图标。此时哪项判断正确?
2.2
在监听器中,要稳定取得当前绑定监听器的元素,应读取 event 的哪个属性?

传播模型:捕获、目标与冒泡

点击一个嵌套按钮时,浏览器不会只查看按钮本身。它会建立一条事件路径,从 window、document 和外层祖先元素一直通向目标元素。

DOM 事件传播三阶段图解,展示从窗口到按钮的捕获阶段、目标阶段和冒泡阶段。

一次典型的点击包含三个阶段:

  1. 捕获阶段:从外层向目标元素前进。
  2. 目标阶段:事件到达真正的目标。
  3. 冒泡阶段:从目标元素向外层祖先返回。
html
<section id="outer">
  <div id="middle">
    <button id="inner" type="button">点我</button>
  </div>
</section>
javascript
const outer = document.querySelector("#outer");
const middle = document.querySelector("#middle");
const inner = document.querySelector("#inner");
 
outer.addEventListener("click", () => {
  console.log("outer 捕获");
}, { capture: true });
 
middle.addEventListener("click", () => {
  console.log("middle 捕获");
}, { capture: true });
 
inner.addEventListener("click", () => {
  console.log("inner 目标");
});
 
middle.addEventListener("click", () => {
  console.log("middle 冒泡");
});
 
outer.addEventListener("click", () => {
  console.log("outer 冒泡");
});

点击按钮时,控制台顺序为:

console
outer 捕获
middle 捕获
inner 目标
middle 冒泡
outer 冒泡

默认的 addEventListener("click", handler) 在目标或冒泡阶段执行。添加 { capture: true } 后,它会在捕获阶段执行。并非所有事件都会冒泡,例如 focus、blur、mouseenter 和 mouseleave;委托这类行为时需要选择可冒泡的替代事件,或使用捕获监听。

3.1
点击最内层按钮时,默认传播顺序是哪一种?

默认行为与传播不是一回事

事件可能同时带来两类结果:

  • 浏览器默认行为,例如链接跳转、表单提交。
  • DOM 传播,让外层监听器也有机会处理。

preventDefault() 只取消允许取消的默认行为:

javascript
const form = document.querySelector("#signup");
 
form.addEventListener("submit", event => {
  event.preventDefault();
 
  const data = new FormData(event.currentTarget);
  console.log(data.get("email"));
});

调用后,表单不会按默认方式导航或刷新,但 submit 事件仍可沿传播路径继续前进。可以用 event.cancelable 判断是否允许取消,用 event.defaultPrevented 判断是否已经取消。

stopPropagation() 会阻止事件继续前往传播路径上的后续节点。在下面的冒泡场景里,这意味着不再经过外层祖先:

javascript
dialog.addEventListener("click", () => {
  closeDialog();
});
 
dialogPanel.addEventListener("click", event => {
  event.stopPropagation();
});

点击背景会关闭弹窗;点击内容面板不会继续冒泡到背景监听器。

如果同一元素上注册了多个同类监听器,stopPropagation() 不会取消该元素上尚未执行的其他监听器。确实需要连同它们一起停止时,才使用 stopImmediatePropagation():

javascript
button.addEventListener("click", event => {
  event.stopImmediatePropagation();
  console.log("只执行到这里");
});
 
button.addEventListener("click", () => {
  console.log("不会执行");
});

在 addEventListener() 的回调里 return false 不会自动阻止默认行为或冒泡。需要明确调用 preventDefault() 或传播控制方法。也不要习惯性阻止传播,否则事件委托、弹窗关闭和统计监听器都可能失效。

3.2
下面哪些说法正确?

事件委托:让父元素管理动态子元素

假设任务列表中的项目会不断新增。如果给每个“完成”和“删除”按钮分别绑定监听器,新增项目后还要重复绑定,清理也容易遗漏。

事件委托把监听器放在稳定存在的父元素上。子元素的点击冒泡到父元素后,再根据 target 判断真正点中了什么。

事件委托减少监听器的对比图,左侧展示逐项绑定造成多条监听线,右侧展示父级列表用一个监听器通过 event.target 处理项目和动态新增项目。

html
<ul id="todo-list">
  <li data-id="1">
    学习事件对象
    <button type="button" data-action="done">
      <span aria-hidden="true">✓</span>
      完成
    </button>
    <button type="button" data-action="remove">删除</button>
  </li>
</ul>
javascript
const list = document.querySelector("#todo-list");
 
list.addEventListener("click", event => {
  if (!(event.target instanceof Element)) return;
 
  const button = event.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;
 
  const item = button.closest("li");
  if (!item) return;
 
  if (button.dataset.action === "done") {
    item.classList.toggle("done");
  }
 
  if (button.dataset.action === "remove") {
    item.remove();
  }
});

这段代码有四层保护:

  1. 确认 target 是可以调用 closest() 的元素。
  2. 从图标或文字向上寻找真正的操作按钮。
  3. 用 contains() 确保按钮属于当前委托容器。
  4. 找到所属列表项后,再根据 data-action 分发行为。

动态追加的新列表项无需重新绑定监听器,因为它的点击同样会冒泡到 list。此时 event.target 是具体命中的子元素,event.currentTarget 始终是 list。

委托并不适合所有事件。不会冒泡的事件无法直接依赖父元素冒泡处理,例如可用 focusin、focusout 代替 focus、blur 做焦点委托;或者在父元素上使用捕获监听。

4.1
委托点击时,用户可能点中按钮里的图标。可以调用 target 的哪个方法向上找到最近的按钮?
4.2
事件委托注册在父列表上,因此动态新增的列表按钮也能被同一个点击监听器处理。

鼠标、指针与拖放:处理位置和连续动作

鼠标交互不只有 click。一个完整点击通常经历按下与松开,进入和离开元素又有两组容易混淆的事件。

事件触发时机
mousedown鼠标键在元素上按下
mouseup鼠标键在元素上松开
click一次主要按键点击完成
dblclick完成双击
mousemove指针在元素范围内移动
mouseenter / mouseleave进入或离开元素整体,不因子元素切换反复触发
mouseover / mouseout进入或离开目标及其子元素,会冒泡
javascript
const box = document.querySelector(".box");
 
box.addEventListener("mousedown", () => {
  box.classList.add("pressed");
});
 
box.addEventListener("mouseup", () => {
  box.classList.remove("pressed");
});
 
box.addEventListener("mouseenter", () => {
  box.textContent = "鼠标进入";
});
 
box.addEventListener("mouseleave", () => {
  box.textContent = "鼠标离开";
});

读取坐标时,要先确认坐标系:

javascript
box.addEventListener("mousemove", event => {
  console.log({
    viewportX: event.clientX,
    viewportY: event.clientY,
    pageX: event.pageX,
    pageY: event.pageY,
  });
});
  • clientX、clientY 相对浏览器视口。
  • pageX、pageY 相对整个文档,包含滚动距离。
  • button 表示触发按下或松开的按键,主要按键通常为 0。
  • buttons 是当前持续按下的按键组合,适合拖动过程。

如果界面还要支持触摸屏或触控笔,优先考虑 Pointer Events。pointerdown、pointermove、pointerup 的使用方式与鼠标事件相似,还能通过 pointerType 区分输入设备。

javascript
slider.addEventListener("pointerdown", event => {
  slider.setPointerCapture(event.pointerId);
});
 
slider.addEventListener("pointermove", event => {
  if (event.buttons === 0) return;
  updateSlider(event.clientX);
});

setPointerCapture() 能让指针移出元素后,后续移动事件仍发送给当前拖动控件,避免滑块拖到边界外突然失去响应。

5.1
关于鼠标与指针事件,哪些说法正确?

原生拖放的关键是允许 drop

桌面页面中还可以使用 HTML Drag and Drop。先把元素标记为可拖动,再通过 dataTransfer 传递信息:

html
<div id="task-42" draggable="true">把我拖到完成区</div>
<section id="done-zone">完成区</section>
javascript
const task = document.querySelector("#task-42");
const doneZone = document.querySelector("#done-zone");
 
task.addEventListener("dragstart", event => {
  event.dataTransfer.setData("text/plain", event.currentTarget.id);
  event.dataTransfer.effectAllowed = "move";
});
 
doneZone.addEventListener("dragover", event => {
  event.preventDefault();
  event.dataTransfer.dropEffect = "move";
});
 
doneZone.addEventListener("drop", event => {
  event.preventDefault();
 
  const id = event.dataTransfer.getData("text/plain");
  const draggedItem = document.getElementById(id);
  if (draggedItem) {
    event.currentTarget.append(draggedItem);
  }
});

浏览器默认不允许任意元素接收拖放,因此目标区的 dragover 必须调用 preventDefault(),drop 才能按预期发生。生产界面还应提供键盘可操作的替代方式,不能把拖放设为唯一入口。

5.2
要让普通元素成为原生拖放目标,通常需要在 dragover 中阻止默认行为。

键盘事件:识别操作意图,而不是拦截字符

键盘交互主要使用 keydown 和 keyup:

  • keydown 在按键按下时触发,长按时可能重复触发。
  • keyup 在按键松开时触发。
  • keypress 无法覆盖所有按键,也不适合新代码。

event.key 与 event.code 表达的不是同一件事:

javascript
searchInput.addEventListener("keydown", event => {
  console.log({
    key: event.key,
    code: event.code,
    ctrl: event.ctrlKey,
    shift: event.shiftKey,
    repeating: event.repeat,
  });
});
  • key 表示考虑键盘布局和修饰键后的按键含义,例如 "a"、"A"、"Enter"。
  • code 表示物理按键位置,例如 "KeyA"、"Digit1"。

处理“按回车搜索”应使用 key:

javascript
searchInput.addEventListener("keydown", event => {
  if (event.key !== "Enter" || event.isComposing) return;
 
  event.preventDefault();
  runSearch(event.currentTarget.value);
});

isComposing 表示用户可能正在通过输入法组合文字。组合尚未完成时抢先提交,会截断中文、日文等输入。

全局快捷键还要避免干扰正在输入文字的用户:

javascript
document.addEventListener("keydown", event => {
  const target = event.target;
  const isEditing =
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement ||
    target?.isContentEditable;
 
  if (isEditing) return;
 
  if (event.key === "/" && !event.ctrlKey && !event.metaKey) {
    event.preventDefault();
    searchInput.focus();
  }
});

不要只靠 keydown 禁止“非数字键”来保证数字输入。用户还可以粘贴、拖入、自动填充或使用输入法。文本值变化应该在 input 事件中读取,最终合法性还要在提交时验证。

javascript
ageInput.addEventListener("input", event => {
  const value = event.currentTarget.value;
  hint.textContent = /^\d*$/.test(value)
    ? ""
    : "年龄只能包含数字";
});
6.1
实现按回车提交搜索时,通常应该判断哪个属性?
6.2
只在 keydown 中阻止非数字按键,就能保证输入框最终一定只有数字。

表单事件:把输入、验证与提交串起来

表单不是“一个按钮加几个输入框”,而是一组有明确语义的事件:

事件适合处理的任务
focus元素获得焦点时显示帮助
blur元素失去焦点时结束提示
input值每次变化时更新预览、搜索建议
change用户确认一次修改后处理
submit统一验证并发送整张表单

对文本框而言,input 通常每次编辑都会触发;change 通常要等本次修改被确认,例如离开输入框。blur 只说明失去焦点,不要求值发生变化。focus 与 blur 不冒泡,委托焦点行为时可使用会冒泡的 focusin 与 focusout。

javascript
email.addEventListener("focus", () => {
  hint.textContent = "请输入常用邮箱";
});
 
email.addEventListener("blur", () => {
  hint.textContent = "";
});
 
email.addEventListener("input", event => {
  preview.textContent = event.currentTarget.value;
});
 
email.addEventListener("change", event => {
  console.log("用户确认了新值", event.currentTarget.value);
});
7.1
关于常见表单事件,哪些说法正确?

统一监听表单的 submit

不要只监听提交按钮的 click。用户按回车、点击提交按钮或调用 requestSubmit(),都应该走表单的同一套提交逻辑。

html
<form id="signup">
  <label>
    邮箱
    <input name="email" type="email" required>
  </label>
  <label>
    年龄
    <input name="age" type="number" min="18" step="1" required>
  </label>
  <button type="submit">注册</button>
</form>
<p id="message" aria-live="polite"></p>
javascript
const form = document.querySelector("#signup");
const message = document.querySelector("#message");
 
form.addEventListener("submit", event => {
  event.preventDefault();
 
  const form = event.currentTarget;
  if (!form.checkValidity()) {
    form.reportValidity();
    message.textContent = "请先修正表单中的问题。";
    return;
  }
 
  const data = new FormData(form);
  const age = Number(data.get("age"));
 
  if (!Number.isInteger(age) || age < 18) {
    message.textContent = "年龄需要是 18 岁或以上的整数。";
    return;
  }
 
  message.textContent = `准备提交:${data.get("email")}`;
});

这里分成两层验证:

  1. checkValidity() 使用 HTML 中的 required、type、min 等约束。
  2. JavaScript 检查业务规则,并决定是否真正发起网络请求。

FormData 按控件的 name 读取值,没有 name 的字段不会进入数据集。前端验证用于及时反馈,服务器仍必须再次验证,因为客户端数据可以被修改。

form.requestSubmit() 会像用户提交一样运行约束验证并触发 submit;form.submit() 会绕过这两步。需要程序化触发正常提交流程时,优先使用 requestSubmit()。

7.2
为什么表单验证通常监听 submit 而不是只监听提交按钮的 click?

高频事件:用防抖和节流控制成本

input、scroll、resize、pointermove 可能在很短时间内触发几十次。事件本身通常不是问题,监听器里反复布局、搜索、渲染或发送请求才会让页面变慢。

防抖与节流的时间线对比,上方展示停止后等待再执行一次,下方展示固定间隔执行。

防抖:等用户停下来再做

防抖会在每次触发时重置计时器。只有一段时间内没有新事件,最后一次调用才会执行。它适合搜索建议、自动保存和输入校验。

javascript
function debounce(fn, delay) {
  let timerId;
 
  function debounced(...args) {
    const context = this;
    clearTimeout(timerId);
 
    timerId = setTimeout(() => {
      timerId = undefined;
      fn.apply(context, args);
    }, delay);
  }
 
  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = undefined;
  };
 
  return debounced;
}
 
const handleSearch = debounce(value => {
  loadSuggestions(value);
}, 300);
 
function handleSearchInput(event) {
  handleSearch(event.currentTarget.value);
}
 
searchInput.addEventListener("input", handleSearchInput);

这里在原始监听器执行期间读取输入值,再把普通字符串交给防抖函数,避免延迟执行时继续依赖事件对象。移除时使用 handleSearchInput;模块销毁前还可以调用 handleSearch.cancel(),取消尚未执行的任务。

8.1
搜索框需要在用户停止输入 300ms 后只查询最后一个关键词,应该优先使用什么?
8.2
防抖函数在安排新计时器前,通常先调用哪个函数取消旧计时器?

节流:持续操作时按固定节奏执行

节流允许函数在一个时间窗口内最多执行一次。下面是一个“立即执行、窗口内忽略后续调用”的版本:

javascript
function throttle(fn, interval) {
  let lastTime = -Infinity;
 
  return function (...args) {
    const now = Date.now();
    if (now - lastTime < interval) return;
 
    lastTime = now;
    return fn.apply(this, args);
  };
}
 
const updateScrollStatus = throttle(() => {
  status.textContent = `滚动位置:${window.scrollY}`;
}, 100);
 
window.addEventListener("scroll", updateScrollStatus);

这个版本保证持续滚动时每 100ms 最多更新一次,但可能忽略停止前的最后一次状态。如果业务必须拿到最后一次值,需要选择带尾调用的节流实现。节流并没有唯一实现,是否首调用、是否尾调用必须根据业务明确。

8.3
所有节流实现都一定会在停止触发后再执行最后一次调用。

与绘制同步时考虑 requestAnimationFrame

如果事件的目标是更新动画位置,按浏览器绘制节奏合并更新通常更自然:

javascript
let framePending = false;
let latestX = 0;
 
document.addEventListener("pointermove", event => {
  latestX = event.clientX;
  if (framePending) return;
 
  framePending = true;
  requestAnimationFrame(() => {
    marker.style.transform = `translateX(${latestX}px)`;
    framePending = false;
  });
});

在同一帧内无论收到多少次 pointermove,这里只安排一次界面更新,并使用最新坐标。

8.4
连续指针移动需要更新元素动画位置,哪种机制最适合与浏览器绘制节奏同步?

下面用可注入时间的简化版本观察规则。防抖只保留最后一次输入;节流只放行满足间隔的调用。这段纯函数测试可在 Node.js 中运行。

javascript
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
 
function throttleAt(fn, interval) {
  let last = -Infinity;
  return (value, now) => {
    if (now - last < interval) return;
    last = now;
    fn(value);
  };
}
 
const debounceCalls = [];
const debounced = debounce(value => debounceCalls.push(value), 40);
debounced("j");
setTimeout(() => debounced("js"), 10);
setTimeout(() => debounced("javascript"), 20);
 
const throttleCalls = [];
const throttled = throttleAt(value => throttleCalls.push(value), 100);
[
  ["第1次滚动", 0],
  ["第2次滚动", 40],
  ["第3次滚动", 120],
  ["第4次滚动", 180],
  ["第5次滚动", 240],
].forEach(([value, time]) => throttled(value, time));
 
setTimeout(() => {
  console.log({ debounceCalls, throttleCalls });
}, 90);

运行环境:Node.js v25.2.1

console
{
  "debounceCalls": [
    "javascript"
  ],
  "throttleCalls": [
    "第1次滚动",
    "第3次滚动",
    "第5次滚动"
  ]
}

选择方法时先问:需要等用户停下来再处理,还是需要在持续操作时保持更新?前者用防抖,后者用节流;若更新与屏幕绘制直接相关,再考虑 requestAnimationFrame()。

8.5
下面哪些场景与优化策略的搭配合理?

综合实践

现在把本章能力组合成一个“可筛选任务面板”。它应支持新增任务、完成与删除、延迟筛选、键盘清空,以及低频更新指针位置。

第一步:准备页面结构

html
<main id="task-app">
  <h1>今日任务</h1>
 
  <form id="task-form">
    <label>
      新任务
      <input name="title" required maxlength="40">
    </label>
    <button type="submit">添加</button>
  </form>
 
  <label>
    筛选
    <input id="task-filter" type="search" placeholder="输入关键词,Esc 清空">
  </label>
 
  <ul id="task-list"></ul>
  <p id="task-count" aria-live="polite">0 项任务</p>
  <p id="pointer-status">指针位置:等待移动</p>
</main>
 
<style>
  #task-list .done .task-title {
    text-decoration: line-through;
    opacity: 0.6;
  }
</style>

第二步:按事件职责拆分功能

  1. 监听表单 submit,阻止默认提交,校验并新增任务。
  2. 只在列表父元素上注册一个 click,委托“完成”和“删除”操作。
  3. 给筛选框的 input 处理器加 300ms 防抖。
  4. 筛选框按下 Escape 时清空内容并立即恢复全部任务。
  5. 用节流把 pointermove 状态更新限制为每 100ms 最多一次。
  6. 所有按钮都明确写出 type,动态文本通过 textContent 写入。
javascript
const app = document.querySelector("#task-app");
const form = document.querySelector("#task-form");
const list = document.querySelector("#task-list");
const filter = document.querySelector("#task-filter");
const count = document.querySelector("#task-count");
const pointerStatus = document.querySelector("#pointer-status");
 
let nextId = 1;
 
function debounce(fn, delay) {
  let timerId;
 
  function debounced(...args) {
    const context = this;
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(context, args), delay);
  }
 
  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = undefined;
  };
 
  return debounced;
}
 
function throttle(fn, interval) {
  let lastTime = -Infinity;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime < interval) return;
    lastTime = now;
    fn.apply(this, args);
  };
}
 
function updateCount() {
  const visibleItems = [...list.children].filter(item => !item.hidden);
  count.textContent = `${visibleItems.length} 项可见任务`;
}
 
function createTask(title) {
  const item = document.createElement("li");
  item.dataset.id = String(nextId++);
 
  const text = document.createElement("span");
  text.className = "task-title";
  text.textContent = title;
 
  const doneButton = document.createElement("button");
  doneButton.type = "button";
  doneButton.dataset.action = "done";
  doneButton.setAttribute("aria-pressed", "false");
  doneButton.textContent = "完成";
 
  const removeButton = document.createElement("button");
  removeButton.type = "button";
  removeButton.dataset.action = "remove";
  removeButton.textContent = "删除";
 
  item.append(text, doneButton, removeButton);
  list.append(item);
  updateCount();
}
 
function applyFilter(keyword) {
  const normalized = keyword.trim().toLowerCase();
 
  for (const item of list.children) {
    const title = item.querySelector(".task-title").textContent.toLowerCase();
    item.hidden = !title.includes(normalized);
  }
 
  updateCount();
}
 
const applyFilterLater = debounce(applyFilter, 300);
 
form.addEventListener("submit", event => {
  event.preventDefault();
 
  const form = event.currentTarget;
  if (!form.checkValidity()) {
    form.reportValidity();
    return;
  }
 
  const data = new FormData(form);
  const title = String(data.get("title")).trim();
  if (!title) {
    form.elements.title.setCustomValidity("请输入非空白任务");
    form.reportValidity();
    form.elements.title.setCustomValidity("");
    return;
  }
 
  createTask(title);
  form.reset();
  form.elements.title.focus();
});
 
list.addEventListener("click", event => {
  if (!(event.target instanceof Element)) return;
 
  const button = event.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;
 
  const item = button.closest("li");
  if (!item) return;
 
  if (button.dataset.action === "done") {
    const completed = item.classList.toggle("done");
    button.setAttribute("aria-pressed", String(completed));
    button.textContent = completed ? "恢复" : "完成";
  } else if (button.dataset.action === "remove") {
    item.remove();
  }
 
  applyFilter(filter.value);
});
 
filter.addEventListener("input", event => {
  applyFilterLater(event.currentTarget.value);
});
 
filter.addEventListener("keydown", event => {
  if (event.key !== "Escape" || event.isComposing) return;
 
  event.preventDefault();
  applyFilterLater.cancel();
  event.currentTarget.value = "";
  applyFilter("");
});
 
app.addEventListener("pointermove", throttle(event => {
  pointerStatus.textContent =
    `指针位置:${event.clientX}, ${event.clientY}`;
}, 100));

验收标准

  • 点击“添加”或在输入框按回车都能新增任务,页面不会刷新。
  • 空任务无法提交,超过 40 个字符会受到字段约束。
  • 后续动态新增的任务无需额外绑定监听器,完成与删除仍然有效。
  • 输入筛选词后等待约 300ms 才更新,并以最后一次输入为准。
  • Escape 能清空筛选并立即显示全部任务。
  • 持续移动指针时状态会更新,但不会为每个原始事件都修改 DOM。
  • 点击按钮内部未来新增的图标时,委托逻辑仍能通过 closest() 找到按钮。

本章自测

9.1
哪个条件组合决定 removeEventListener 能匹配到原监听器?
9.2
委托监听器绑定在列表上时,表示该列表本身的事件对象属性是什么?
9.3
哪些事件或行为通常适合调用 preventDefault?
9.4
stopPropagation 会同时阻止事件冒泡和链接默认跳转。
9.5
持续拖动滑块时需要周期性刷新位置,最不符合需求的是哪一种策略?

总结

  • 用 addEventListener() 组织监听器;移除时保留同一函数引用并匹配捕获标记。
  • 用 once 处理一次性动作,用 AbortSignal 批量清理模块监听器。
  • target 是事件最初发生的位置,currentTarget 是当前执行监听器的节点。
  • 传播通常经历捕获、目标、冒泡;只有支持冒泡的事件才能直接用于冒泡委托。
  • preventDefault() 管默认行为,stopPropagation() 管传播,二者不能互相替代。
  • 动态列表优先把监听器放在稳定父元素上,并结合 closest() 与边界检查。
  • 文本变化依赖 input,整表单流程监听 submit,键盘过滤不能代替最终验证。
  • 等用户停下来用防抖,持续操作保持节奏用节流,与绘制同步可用 requestAnimationFrame()。
上一章Window 对象下一章HTTP 通信