DOM 事件很多,但日常开发翻来覆去用的就那几类。这篇按鼠标、拖拽、键盘、表单、焦点、文档/窗口六大类整理,先把事件绑定和事件流这两个基础概念讲清,再逐类过一遍,标注哪些已经废弃、哪些有坑。
一、先说清楚:on 前缀是怎么回事
很多老教程(包括本文的早期版本)把事件写成 onclick、onmouseover,其实事件本名是 click、mouseover,on 前缀只是”事件处理属性”的命名方式。绑定事件有三种写法:
<!-- 1. HTML 属性(不推荐,逻辑和结构耦合) -->
<button onclick="handleClick()">点我</button>
// 2. DOM 属性(同一事件只能绑一个处理函数,后绑的覆盖先绑的)
btn.onclick = function () {
console.log("clicked");
};
// 3. addEventListener(推荐:可绑多个、可控制捕获/冒泡、可移除)
btn.addEventListener("click", handleClick);
btn.removeEventListener("click", handleClick);
结论:日常一律用 addEventListener,事件名不带 on。
二、事件流:捕获 → 目标 → 冒泡
一次点击并不是”直接命中”目标元素,而是完整走三个阶段:
window → document → html → body → div(捕获阶段,从外向内)
↓
目标元素(目标阶段)
↓
div → body → html → document → window(冒泡阶段,从内向外)
addEventListener 第三个参数决定在哪个阶段触发:
el.addEventListener("click", fn); // 默认 false,冒泡阶段触发
el.addEventListener("click", fn, true); // 捕获阶段触发
两个常用控制方法:
event.stopPropagation():阻止事件继续传播(不再往上冒)。event.preventDefault():阻止默认行为(如 a 标签跳转、表单提交),不影响传播。
理解了冒泡,才能理解后面 mouseover 和 mouseenter 的区别,也是事件委托的原理基础。
三、鼠标事件
| 事件名称 | 描述 |
|---|---|
click |
当用户在元素上按下并释放鼠标按钮时触发 |
dblclick |
当用户在元素上快速连续点击两次鼠标按钮时触发 |
mousedown |
当用户在元素上按下鼠标按钮(还未释放)时触发 |
mouseup |
当用户在元素上释放鼠标按钮时触发 |
mousemove |
当鼠标指针在元素上移动时触发 |
mouseover |
当鼠标指针移动到元素上方或其子元素上方时触发 |
mouseout |
当鼠标指针移出元素或其子元素时触发 |
mouseenter |
当鼠标指针移动到元素内部时触发(不会冒泡) |
mouseleave |
当鼠标指针移出元素时触发(不会冒泡) |
contextmenu |
当用户右键点击元素时触发(在显示上下文菜单前) |
wheel |
当用户滚动鼠标滚轮时触发 |
几个要点:
1. mouseover/mouseout 与 mouseenter/mouseleave 的区别(高频面试题):
mouseover和mouseout会冒泡:鼠标在父元素内部、进入或离开子元素时也会触发,做悬停效果时容易疯狂重复触发。mouseenter和mouseleave不冒泡:只在鼠标穿过被绑定元素本身的边界时触发一次,子元素之间移动不触发。
// 父元素里有多个子元素时,体验一下差别
parent.addEventListener("mouseover", () => console.log("over")); // 划过每个子元素都打印
parent.addEventListener("mouseenter", () => console.log("enter")); // 只在进入 parent 时打印一次
悬停显示/隐藏这类需求,优先用 mouseenter/mouseleave。
2. 触发顺序:一次完整单击依次触发 mousedown → mouseup → click;双击则是两轮 mousedown/mouseup/click 后再触发 dblclick。
3. 自定义右键菜单:先屏蔽默认菜单再显示自己的。
el.addEventListener("contextmenu", e => {
e.preventDefault(); // 屏蔽浏览器默认右键菜单
showMyMenu(e.clientX, e.clientY);
});
4. 事件对象常用坐标:clientX/clientY(相对视口)、pageX/pageY(相对文档,含滚动)、offsetX/offsetY(相对目标元素)。
四、拖拽事件
| 事件名称 | 描述 | 触发时机 |
|---|---|---|
dragstart |
拖拽开始 | 用户开始拖动元素时 |
dragenter |
进入目标元素 | 被拖拽元素进入目标元素边界时 |
dragover |
在目标元素上移动 | 被拖拽元素在目标元素上方移动 |
dragend |
拖拽结束 | 拖拽操作结束时(释放鼠标或取消操作) |
drop |
元素放置 | 被拖拽元素在目标元素上释放 |
此外还有 drag(拖动过程中在被拖元素上持续触发)和 dragleave(离开目标元素)。
两个必知的坑:
1. 想让元素可拖,先加 draggable="true"(img 和 a 标签默认可拖)。
2. drop 不触发?因为没阻止 dragover 的默认行为。 浏览器默认不允许放置,必须在 dragover(保险起见 dragenter 也加上)里 preventDefault():
// 被拖元素:用 dataTransfer 携带数据
source.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", e.target.id);
});
// 放置目标:不 preventDefault,drop 永远不会触发
target.addEventListener("dragover", e => e.preventDefault());
target.addEventListener("drop", e => {
e.preventDefault();
const id = e.dataTransfer.getData("text/plain");
e.target.appendChild(document.getElementById(id));
});
dataTransfer 是拖拽事件专属的数据中转站:setData/getData 传数据,e.dataTransfer.files 拿到拖入的文件(文件上传拖拽区就是这么做的)。
五、键盘事件
| 事件名称 | 描述 |
|---|---|
keydown |
按下按键时触发(含 Alt、Shift 等),按住不放会连续触发 |
keyup |
释放按键时触发一次 |
keypress已被标准废弃,不要再用;判断按键也不要用keyCode(同样废弃),统一用event.key。
input.addEventListener("keydown", e => {
if (e.key === "Enter") submit();
if (e.key === "Escape") close();
if (e.ctrlKey && e.key === "s") {
// 组合键:ctrlKey/shiftKey/altKey/metaKey
e.preventDefault(); // 拦截浏览器默认的"保存网页"
save();
}
});
六、表单事件
| 事件名称 | 描述 |
|---|---|
input |
输入内容每变化一次就触发(打一个字一次) |
change |
值改变且失去焦点后触发;select/checkbox/radio 选择即触发 |
submit |
表单提交时在 <form> 上触发 |
reset |
点击重置按钮时在 <form> 上触发 |
select |
用户在输入框中选中文本时触发 |
input 和 change 的区别是这组的核心:实时搜索、字数统计用 input;只关心最终值(如校验)用 change。
// 拦截表单默认提交刷新页面,改走 ajax
form.addEventListener("submit", e => {
e.preventDefault();
ajaxSubmit(new FormData(form));
});
七、焦点事件
| 事件名称 | 描述 | 是否冒泡 |
|---|---|---|
focus |
元素获得焦点时触发 | 否 |
blur |
元素失去焦点时触发 | 否 |
focusin |
同 focus,但会冒泡 | 是 |
focusout |
同 blur,但会冒泡 | 是 |
focus/blur 不冒泡,没法在父元素上做事件委托;需要委托时(比如一个表单里几十个输入框统一处理)用 focusin/focusout:
form.addEventListener("focusin", e => e.target.classList.add("active"));
form.addEventListener("focusout", e => e.target.classList.remove("active"));
八、文档与窗口事件
| 事件名称 | 描述 |
|---|---|
DOMContentLoaded |
HTML 解析完、DOM 树构建完成即触发(不等图片、样式) |
load |
页面所有资源(图片、样式、iframe)加载完成后在 window 上触发;也用于 img 等单个资源加载完成 |
scroll |
元素或窗口滚动时触发(注意加节流) |
resize |
窗口大小改变时触发(注意加防抖) |
error |
资源加载失败或脚本运行出错时触发 |
abort |
资源加载被中断时触发 |
visibilitychange |
页面可见性变化(切 tab、最小化)时在 document 上触发 |
DOMContentLoaded vs load:想尽早操作 DOM 用前者,需要拿图片尺寸等资源信息才用后者。老代码里的 window.onload 大多其实只需要 DOMContentLoaded。
unload已废弃(移动端和 bfcache 场景根本不触发),页面离开前的逻辑(如埋点上报)用visibilitychange或pagehide代替。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
navigator.sendBeacon("/log", data); // 页面隐藏时上报,比 unload 可靠
}
});
scroll 和 resize 触发频率极高,处理函数里有计算或 DOM 操作的话,记得配合防抖节流。
九、最后:数量多了怎么办——事件委托
列表有 1000 个 li,不需要绑 1000 个监听器。利用冒泡,把事件绑在父元素上,通过 e.target 判断真正点的是谁:
ul.addEventListener("click", e => {
const li = e.target.closest("li");
if (li) console.log("点击了", li.dataset.id);
});
省内存、动态新增的子元素自动生效。展开原理见我另一篇:JS事件委托原理。