一张商品卡片本来只是静态内容。直到用户点击“加入购物车”、在搜索框里输入关键词、按下回车提交表单,它才真正成为可以交流的界面。
这些交互看起来各不相同,背后却遵循同一套机制:浏览器发现动作,创建事件对象,再沿 DOM 路径把事件交给提前注册的监听器。掌握这条路径之后,许多“点一下为什么触发两次”“动态按钮为什么没反应”“滚动时页面为什么卡顿”的问题都会变得可解释。
本章会从一个按钮开始,逐步走到事件传播、委托、表单与高频事件优化。所有 DOM 示例都应放在浏览器页面中运行;纯函数示例会单独标明可在 Node.js 中验证。

事件处理的核心问题有三个:事件发生在哪里,当前由谁处理,以及处理之后是否还要执行默认动作或继续传播。
完成本章后,你应该能够:
addEventListener() 注册、移除和批量清理监听器。target 与 currentTarget。preventDefault()、stopPropagation() 与 stopImmediatePropagation()。requestAnimationFrame()。事件监听器是一段提前登记的函数。事件尚未发生时,它只是等待;事件发生后,浏览器调用它,并把本次事件对象作为参数传入。
<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 由浏览器传入。旧代码中常能看到以下三种写法:
<button onclick="save()">保存</button>button.onclick = save;button.addEventListener("click", save);内联事件把行为写进 HTML,不利于复用与维护。事件属性一次只能保存一个函数,后一次赋值会覆盖前一次。addEventListener() 可以给同一元素、同一事件注册多个监听器,因此更适合组织现代页面交互。
function markDirty() {
console.log("记录表单已经修改");
}
function updatePreview() {
console.log("刷新预览");
}
input.addEventListener("input", markDirty);
input.addEventListener("input", updatePreview);一次 input 事件会依次调用这两个函数。把函数名交给监听器时不要加括号;markDirty() 表示立即调用并把返回值交出去,markDirty 才表示交出函数本身。
需要传入额外参数时,用包装函数:
function changeTheme(theme, event) {
document.documentElement.dataset.theme = theme;
console.log(event.type);
}
button.addEventListener("click", event => {
changeTheme("dark", event);
});removeEventListener() 不是根据函数内容寻找监听器,而是比较事件类型、函数对象和捕获标记。
function handleClick(event) {
console.log(event.type);
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);下面的移除不会生效,因为两次创建的是两个不同的箭头函数:
button.addEventListener("click", () => {
console.log("clicked");
});
button.removeEventListener("click", () => {
console.log("clicked");
});如果注册时使用了捕获模式,移除时也要匹配捕获标记:
panel.addEventListener("click", handleClick, { capture: true });
panel.removeEventListener("click", handleClick, { capture: true });第三个参数推荐写成选项对象,意图更清楚:
button.addEventListener("click", handleClick, {
once: true,
});
document.addEventListener("click", handleClick, {
capture: true,
});
window.addEventListener("touchmove", trackGesture, {
passive: true,
});常用选项如下:
当一个界面模块注册了多个监听器,可以用同一个 AbortController 管理:
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(),并在控制台给出警告。
如果脚本执行时目标元素还没被解析,querySelector() 会返回 null。常见方案有三种:
</body> 之前。defer。DOMContentLoaded。document.addEventListener("DOMContentLoaded", () => {
const button = document.querySelector("#save");
button.addEventListener("click", handleClick);
});DOMContentLoaded 表示 HTML 已经解析成 DOM;window 的 load 还要等待图片、样式表等资源完成加载。普通交互只依赖 DOM 时,不必等到 load。
浏览器调用监听器时,会把这次交互的信息装进事件对象。不同事件类型会增加不同属性:鼠标事件有坐标,键盘事件有按键,提交事件会指向表单。
button.addEventListener("click", event => {
console.log(event.type); // click
console.log(event.target);
console.log(event.currentTarget);
console.log(event.defaultPrevented); // false
});最常见的通用属性包括:
target 与 currentTarget 为什么会不同看一个按钮内部带图标的例子:
<button id="buy" type="button">
<span class="icon" aria-hidden="true">+</span>
加入购物车
</button>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 则由当前正在运行哪一个监听器决定。

普通函数监听器里的 this 通常等于 currentTarget:
button.addEventListener("click", function (event) {
console.log(this === event.currentTarget); // true
});箭头函数没有自己的 this,所以需要稳定引用监听元素时,直接使用 event.currentTarget 更清楚。
不要把事件对象保存起来,过一段时间后再依赖它的 currentTarget。监听器执行结束后,currentTarget 不再代表正在执行的监听节点。异步逻辑需要某个元素时,先把它保存到局部变量。
button.addEventListener("click", event => {
const clickedButton = event.currentTarget;
setTimeout(() => {
clickedButton.textContent = "已处理";
}, 300);
});点击一个嵌套按钮时,浏览器不会只查看按钮本身。它会建立一条事件路径,从 window、document 和外层祖先元素一直通向目标元素。

一次典型的点击包含三个阶段:
<section id="outer">
<div id="middle">
<button id="inner" type="button">点我</button>
</div>
</section>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
点击按钮时,控制台顺序为:
outer 捕获
middle 捕获
inner 目标
middle 冒泡
outer 冒泡默认的 addEventListener("click", handler) 在目标或冒泡阶段执行。添加 { capture: true } 后,它会在捕获阶段执行。并非所有事件都会冒泡,例如 focus、blur、mouseenter 和 mouseleave;委托这类行为时需要选择可冒泡的替代事件,或使用捕获监听。
事件可能同时带来两类结果:
preventDefault() 只取消允许取消的默认行为:
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() 会阻止事件继续前往传播路径上的后续节点。在下面的冒泡场景里,这意味着不再经过外层祖先:
dialog.addEventListener("click", () => {
closeDialog();
});
dialogPanel.addEventListener("click", event => {
event.stopPropagation();
});点击背景会关闭弹窗;点击内容面板不会继续冒泡到背景监听器。
如果同一元素上注册了多个同类监听器,stopPropagation() 不会取消该元素上尚未执行的其他监听器。确实需要连同它们一起停止时,才使用 stopImmediatePropagation():
button.addEventListener("click", event => {
event.stopImmediatePropagation();
console.log("只执行到这里");
});
button.addEventListener("click", () => {
console.log("不会执行");
});在 addEventListener() 的回调里 return false 不会自动阻止默认行为或冒泡。需要明确调用 preventDefault() 或传播控制方法。也不要习惯性阻止传播,否则事件委托、弹窗关闭和统计监听器都可能失效。
假设任务列表中的项目会不断新增。如果给每个“完成”和“删除”按钮分别绑定监听器,新增项目后还要重复绑定,清理也容易遗漏。
事件委托把监听器放在稳定存在的父元素上。子元素的点击冒泡到父元素后,再根据 target 判断真正点中了什么。

<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">删除</
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))
这段代码有四层保护:
target 是可以调用 closest() 的元素。contains() 确保按钮属于当前委托容器。data-action 分发行为。动态追加的新列表项无需重新绑定监听器,因为它的点击同样会冒泡到 list。此时 event.target 是具体命中的子元素,event.currentTarget 始终是 list。
委托并不适合所有事件。不会冒泡的事件无法直接依赖父元素冒泡处理,例如可用 focusin、focusout 代替 focus、blur 做焦点委托;或者在父元素上使用捕获监听。
鼠标交互不只有 click。一个完整点击通常经历按下与松开,进入和离开元素又有两组容易混淆的事件。
const box = document.querySelector(".box");
box.addEventListener("mousedown", () => {
box.classList.add("pressed");
});
box.addEventListener("mouseup", () => {
box.classList.remove("pressed");
});
box.addEventListener("mouseenter", () =>
读取坐标时,要先确认坐标系:
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 区分输入设备。
slider.addEventListener("pointerdown", event => {
slider.setPointerCapture(event.pointerId);
});
slider.addEventListener("pointermove", event => {
if (event.buttons === 0) return;
updateSlider(event.clientX);
});setPointerCapture() 能让指针移出元素后,后续移动事件仍发送给当前拖动控件,避免滑块拖到边界外突然失去响应。
drop桌面页面中还可以使用 HTML Drag and Drop。先把元素标记为可拖动,再通过 dataTransfer 传递信息:
<div id="task-42" draggable="true">把我拖到完成区</div>
<section id="done-zone">完成区</section>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
浏览器默认不允许任意元素接收拖放,因此目标区的 dragover 必须调用 preventDefault(),drop 才能按预期发生。生产界面还应提供键盘可操作的替代方式,不能把拖放设为唯一入口。
键盘交互主要使用 keydown 和 keyup:
keydown 在按键按下时触发,长按时可能重复触发。keyup 在按键松开时触发。keypress 无法覆盖所有按键,也不适合新代码。event.key 与 event.code 表达的不是同一件事:
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:
searchInput.addEventListener("keydown", event => {
if (event.key !== "Enter" || event.isComposing) return;
event.preventDefault();
runSearch(event.currentTarget.value);
});isComposing 表示用户可能正在通过输入法组合文字。组合尚未完成时抢先提交,会截断中文、日文等输入。
全局快捷键还要避免干扰正在输入文字的用户:
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
不要只靠 keydown 禁止“非数字键”来保证数字输入。用户还可以粘贴、拖入、自动填充或使用输入法。文本值变化应该在 input 事件中读取,最终合法性还要在提交时验证。
ageInput.addEventListener("input", event => {
const value = event.currentTarget.value;
hint.textContent = /^\d*$/.test(value)
? ""
: "年龄只能包含数字";
});表单不是“一个按钮加几个输入框”,而是一组有明确语义的事件:
对文本框而言,input 通常每次编辑都会触发;change 通常要等本次修改被确认,例如离开输入框。blur 只说明失去焦点,不要求值发生变化。focus 与 blur 不冒泡,委托焦点行为时可使用会冒泡的 focusin 与 focusout。
email.addEventListener("focus", () => {
hint.textContent = "请输入常用邮箱";
});
email.addEventListener("blur", () => {
hint.textContent = "";
});
email.addEventListener("input", event => {
preview.textContent = event.currentTarget.value;
});
email.addEventListener(
submit不要只监听提交按钮的 click。用户按回车、点击提交按钮或调用 requestSubmit(),都应该走表单的同一套提交逻辑。
<form id="signup">
<label>
邮箱
<input name="email" type="email" required>
</label>
<label>
年龄
<input name="age" type="number" min="18" step="1" required>
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();
这里分成两层验证:
checkValidity() 使用 HTML 中的 required、type、min 等约束。FormData 按控件的 name 读取值,没有 name 的字段不会进入数据集。前端验证用于及时反馈,服务器仍必须再次验证,因为客户端数据可以被修改。
form.requestSubmit() 会像用户提交一样运行约束验证并触发 submit;form.submit() 会绕过这两步。需要程序化触发正常提交流程时,优先使用 requestSubmit()。
input、scroll、resize、pointermove 可能在很短时间内触发几十次。事件本身通常不是问题,监听器里反复布局、搜索、渲染或发送请求才会让页面变慢。

防抖会在每次触发时重置计时器。只有一段时间内没有新事件,最后一次调用才会执行。它适合搜索建议、自动保存和输入校验。
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
这里在原始监听器执行期间读取输入值,再把普通字符串交给防抖函数,避免延迟执行时继续依赖事件对象。移除时使用 handleSearchInput;模块销毁前还可以调用 handleSearch.cancel(),取消尚未执行的任务。
节流允许函数在一个时间窗口内最多执行一次。下面是一个“立即执行、窗口内忽略后续调用”的版本:
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);
这个版本保证持续滚动时每 100ms 最多更新一次,但可能忽略停止前的最后一次状态。如果业务必须拿到最后一次值,需要选择带尾调用的节流实现。节流并没有唯一实现,是否首调用、是否尾调用必须根据业务明确。
requestAnimationFrame如果事件的目标是更新动画位置,按浏览器绘制节奏合并更新通常更自然:
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
在同一帧内无论收到多少次 pointermove,这里只安排一次界面更新,并使用最新坐标。
下面用可注入时间的简化版本观察规则。防抖只保留最后一次输入;节流只放行满足间隔的调用。这段纯函数测试可在 Node.js 中运行。
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function throttleAt(fn, interval) {
let last = -Infinity
运行环境:Node.js v25.2.1
{
"debounceCalls": [
"javascript"
],
"throttleCalls": [
"第1次滚动",
"第3次滚动",
"第5次滚动"
]
}选择方法时先问:需要等用户停下来再处理,还是需要在持续操作时保持更新?前者用防抖,后者用节流;若更新与屏幕绘制直接相关,再考虑 requestAnimationFrame()。
现在把本章能力组合成一个“可筛选任务面板”。它应支持新增任务、完成与删除、延迟筛选、键盘清空,以及低频更新指针位置。
<main id="task-app">
<h1>今日任务</h1>
<form id="task-form">
<label>
新任务
<input name="title" required maxlength="40">
</label>
<button type="submit">添加</button>
submit,阻止默认提交,校验并新增任务。click,委托“完成”和“删除”操作。input 处理器加 300ms 防抖。pointermove 状态更新限制为每 100ms 最多一次。type,动态文本通过 textContent 写入。closest() 找到按钮。addEventListener() 组织监听器;移除时保留同一函数引用并匹配捕获标记。once 处理一次性动作,用 AbortSignal 批量清理模块监听器。target 是事件最初发生的位置,currentTarget 是当前执行监听器的节点。preventDefault() 管默认行为,stopPropagation() 管传播,二者不能互相替代。closest() 与边界检查。input,整表单流程监听 submit,键盘过滤不能代替最终验证。requestAnimationFrame()。