主题
键盘事件控制元素移动与获取事件对象(Day16 1/3)
本页对应 Day16_DOM,共 6 段视频,约 1 小时 14 分。
学习目标
- 能按分类复述鼠标、键盘、文档、表单、图片、过渡、动画及其他事件的典型事件名,并说明键盘事件可监听的对象限制。
- 能使用
keydown编写键盘控制元素上下左右移动的案例,通过事件对象判断按键方向,并用offsetLeft、offsetTop与style.left、style.top更新元素位置。 - 能通过事件回调函数的形参获取事件对象,区分鼠标事件对象、键盘事件对象等类型,并说明
window.event的兼容用法及不推荐原因。 - 能列举鼠标事件对象的位置属性和
button值含义,以及键盘事件对象中keyCode、which、key的作用与keypress的触发限制。 - 能使用所有事件对象共有的
type、timeStamp、target属性,并说明target与回调函数中this的区别。 - 能使用
stopPropagation阻止事件冒泡,验证捕获阶段仍会触发,并说明return false不能阻止原生 DOM 事件冒泡。
知识点
一、先把事件分类串起来:为什么需要总表
DOM 事件很多,学到 Day16 如果只记零散事件名,写案例时很容易“知道要监听,却想不起用哪个”。课堂先把常用事件分成 8 类:鼠标事件、键盘事件、文档事件、表单事件、图片事件、过渡事件、动画事件、其他事件。这样复习时先定位分类,再回忆事件名和对象属性。
怎么想:鼠标事件最多,先记 click、dblclick、contextmenu;再记 mousedown、mouseup、mousemove;进入离开推荐 mouseenter/mouseleave,不推荐 mouseover/mouseout,因为后者会因后代元素冒泡而反复触发。滚轮事件要注意 mousewheel 与火狐的 DOMMouseScroll,事件名大小写不统一时只能用 addEventListener。键盘事件记 keydown、keyup、keypress;文档事件记 load、DOMContentLoaded;表单事件记 submit、reset、focus、blur、input、change;图片事件记 load、error;过渡记 transitionrun、transitionstart、transitionend;动画记 animationstart、animationend、animationiteration;其他记 scroll、resize。
易错点:mouseenter/mouseleave 不冒泡,mouseover/mouseout 会受后代影响;DOMContentLoaded 只等 DOM 元素,load 还等外部资源;change 对输入框通常要“内容改变且失焦”,对单选、复选、下拉则一改就触发。
二、键盘事件控制元素移动:从需求到边界
为什么需要:拖拽案例练的是鼠标按下、移动、抬起;坦克移动案例练的是键盘持续控制,并加入“不能跑出场地”的边界判断。怎么想:先确定用上下左右键,监听给 document,因为页面里没有表单控件;用 keydown,因为按下就要动,keypress 对方向键不触发。先用 console.log(event.keyCode) 调试,得到左、上、右、下分别是 37、38、39、40,再用 switch 分支。
核心代码可整理为:
js
document.addEventListener('keydown', function (event) {
switch (event.keyCode) {
case 37:
tank.style.transform = 'rotate(-90deg)';
tank.style.left = Math.max(0, tank.offsetLeft - 5) + 'px';
break;
case 38:
tank.style.transform = 'rotate(0deg)';
tank.style.top = Math.max(0, tank.offsetTop - 5) + 'px';
break;
case 39:
tank.style.transform = 'rotate(90deg)';
tank.style.left = Math.min(
box.clientWidth - tank.offsetWidth,
tank.offsetLeft + 5
) + 'px';
break;
case 40:
tank.style.transform = 'rotate(180deg)';
tank.style.top = Math.min(
box.clientHeight - tank.offsetHeight,
tank.offsetTop + 5
) + 'px';
break;
}
});易错点:不要直接改 offsetLeft,它只读;要改 style.left/top 并加 px。边界用 Math.max/min 比 if 更简洁:左、上不能小于 0;右、下最大值是场地 clientWidth/clientHeight 减坦克 offsetWidth/offsetHeight。
三、事件对象怎么拿:形参优先,window.event 只作了解
为什么需要:要判断按键、鼠标位置、目标元素,都离不开事件对象。怎么想:无论 onclick = function(event){} 还是 addEventListener('click', function(event){}),给回调函数写形参即可自动拿到事件对象。具名函数也可以:function handler(event){}。不同事件得到的事件对象类型不同,如 pointerEvent、mouseEvent、keyboardEvent,但大类都是事件对象。
易错点:不写形参却直接用 event,有时能拿到,是因为浏览器有全局 window.event,它只表示“当前正在触发的事件”;课堂明确不建议依赖它,代码可读性差。旧 IE 兼容写法 event || window.event 现在只需了解。
四、鼠标/键盘事件对象的常用属性
为什么需要:鼠标要位置和按键,键盘要按键值。怎么做:鼠标对象常用 button(0 左键、1 滚轮、2 右键)、offsetX/offsetY、clientX/clientY、pageX/pageY、screenX/screenY;键盘对象常用 keyCode、which、key。keyCode 与 which 返回数字,key 返回按键名字符串。调试时先打印再判断,不要死记滚轮正负值。
易错点:keydown 能收到所有按键,keypress 只对可输入字符更可靠;用 keyCode 判断字母时大小写不敏感,key 更能区分。鼠标位置四组要分清目标元素、视口、页面、屏幕。
五、所有事件对象共有的属性与方法
为什么需要:不同事件对象有共有信息。属性有 type(事件名)、timeStamp(触发时刻时间戳,可用于判断两次触发间隔)、target(真正触发事件的目标元素);方法有 stopPropagation() 阻止冒泡、preventDefault() 阻止默认行为。
js
box.onclick = function (event) {
console.log(event.type, event.timeStamp, event.target);
};易错点:this 通常指向绑定事件的元素,event.target 指向实际触发元素;点子元素 P 时,监听在 box 上的回调里 this 是 box,target 是 P。
六、阻止冒泡:只认 stopPropagation
为什么需要:点 P 时事件会冒泡到 box,若只想让 P 响应,就要阻止。怎么做:在目标元素回调里写 event.stopPropagation()。调试时先不加,点第一个 P 看到“第一个段落被点击”后又输出“box 被点击”;加上后不再冒泡。
js
p1.addEventListener('click', function (event) {
console.log('第一个段落被点击了');
event.stopPropagation();
});易错点:网上常说 return false 能阻止冒泡,课堂明确这是错的;原生 JS 阻止冒泡只用 stopPropagation()。另外,捕获阶段先于冒泡,阻止冒泡不影响捕获阶段已经执行的回调。
先保留本节的事件清单
先把本节笔记里的代码原样保留,方便对照复习:
text
1. 鼠标事件
click
dblclick
contextmenu
mousedown
mouseup
mousemove
mouseenter mouseover
mouseleave mouseout
mousewheel DOMMouseScroll
2. 键盘事件
keydown
keyup
keypress
3. 文档事件
load
DOMContentLoaded
4. 表单事件
submit
reset
focus
blur
select
input
change
5. 图片事件
load
error
6. 过渡事件
transitionstart
transitionend
transitionrun
7. 动画事件
animationstart
animationend
animationiteration
8. 其他事件
scroll
resize鼠标事件:最多,也最容易混
为什么需要:鼠标是网页里最常用的交互来源,点击、右击、按下抬起、移动、进入离开、滚轮都要靠它。怎么想:按动作分组记——单击 click、双击 dblclick、右击/菜单 contextmenu;按下 mousedown、抬起 mouseup、移动 mousemove;进入离开有 mouseenter/mouseover 和 mouseleave/mouseout;滚轮有 mousewheel 与火狐的 DOMMouseScroll。怎么做:监听后通过事件对象取位置,转写稿里反复提到八属性分四组:目标元素上的 offsetX/offsetY、视口上的 clientX/clientY、页面上的 pageX/pageY、屏幕上的 screenX/screenY;按键值用 button,0 左键、1 滚轮/中键、2 右键。易错点:mouseenter/mouseleave 不冒泡,mouseover/mouseout 会受后代元素影响;滚轮事件火狐兼容要用 DOMMouseScroll,且事件名有大小写,只能用 addEventListener;wheelDelta 与 detail 的正负不要死记,现场判断即可。
键盘事件:先想监听对象,再想按键值
为什么需要:键盘事件用于输入、快捷键、游戏式移动控制。怎么想:keydown 是按下,keyup 是抬起,keypress 也是按下但只对可输入字符更可靠。怎么做:键盘事件通常监听表单控件或 document,因为普通元素没有焦点时不合情理。转写稿里的坦克移动案例就是监听 document 的 keydown,先打印 event.keyCode,按左上右下得到 37、38、39、40,再用 switch 判断;改位置时不能直接改只读的 offsetLeft,要写 style.left 并加 px;限制边界时用 Math.max(0, left - 5) 和 Math.min(场地宽 - 坦克宽, left + 5),向上向下同理用 top。易错点:keypress 对 Ctrl、Alt、Shift 等不可输入字符不可靠;keydown 取字母码常是大写码,key 才能区分大小写;keyCode 与 which 在转写稿中说“一模一样”,但现代代码更推荐 key。
文档事件:load 与 DOMContentLoaded 的区别
为什么需要:脚本要操作 DOM,必须等元素可用。怎么想:load 等文档中一切资源,包括外部图片等;DOMContentLoaded 只要文档元素加载完即可,不必等外部文件。怎么做:一般更推荐 DOMContentLoaded,因为 DOM 操作只需元素就绪;若外部图片出意外,等 load 可能拖慢脚本。易错点:DOMContentLoaded 事件名有大写小写,只能用 addEventListener 监听,不能写成全小写。
表单事件:分清监听给 form 还是控件
为什么需要:表单提交、重置、聚焦失焦、选择、输入、值改变都要响应。怎么想:submit、reset 监听给 form;focus、blur、select、input、change 监听给表单控件。怎么做:input 是输入框内容一改就触发,适合实时校验;change 对文本框要“值改变且失去焦点”,但对单选、复选、下拉选项一改就触发,转写稿里的地址联动就是改省后更新城市。易错点:blur 常用于“用户写完了”再校验;submit 不是按钮点击事件,而是表单提交动作;reset 是表单重置动作。
图片事件:load 与 error 要成对考虑
为什么需要:图片加载成功或失败都需要反馈,转写稿里还用图片 load 做页面加载进度。怎么想:影响网页速度的主要是图片,所以挑几张高清大图监听 load,全部完成即可认为页面基本就绪;失败则用 error 兜底。怎么做:给图片监听 load 和 error,成功计数、失败也计数,避免进度卡死。易错点:不是所有网页都需要加载进度条,按产品需求做;不要只监听 load 而漏掉 error。
过渡事件:start、run、end 要分清
为什么需要:CSS 过渡有开始、运行、结束的不同时机。怎么想:transitionrun 在过渡开始前触发;transitionstart 要等延迟时间过去、真正开始过渡动作时触发;transitionend 在过渡结束触发。怎么做:需要“延迟后真正开始”的逻辑用 transitionstart,需要“过渡前”用 transitionrun。易错点:一个元素多个样式变化会产生多次触发,不要以为只触发一次。
动画事件:end 与 iteration 不同
为什么需要:动画开始、每次循环、最终结束都需要不同处理。怎么想:animationstart 开始,animationend 最终结束,animationiteration 每执行一次循环触发一次。怎么做:只想在彻底结束时做事用 animationend;要统计循环次数用 animationiteration。易错点:动画执行多次时,animationend 只在最后彻底结束触发,不要拿它当“每次循环”。
其他事件:scroll 与 resize
为什么需要:滚动和窗口尺寸变化是常见页面行为。怎么想:scroll 监听可滚动元素或 window;resize 通常监听 window。怎么做:内容能滚动才监听 scroll,窗口尺寸变化才监听 resize。易错点:元素内容不能滚动时,scroll 不会触发;resize 不要随便监听普通元素。
课堂画面






代码示例
示例 1:键盘事件控制元素移动(限制在场地内)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>键盘控制元素移动</title>
<style>
* { margin: 0; padding: 0; }
/* 场地:定位参照物,坦克的 left/top 以它为准 */
#box {
position: relative;
width: 600px;
height: 400px;
border: 10px solid #333;
margin: 50px auto;
background: #f0f0f0;
}
/* 坦克:必须定位,left/top 才能生效 */
#tank {
position: absolute;
left: 0;
top: 0;
width: 60px;
height: 60px;
background: #e74c3c;
border-radius: 8px;
}
</style>
</head>
<body>
<div id="box">
<div id="tank"></div>
</div>
<script>
(function () {
var box = document.querySelector('#box');
var tank = document.querySelector('#tank');
var speed = 5; // 每按一次移动的像素,越大越快
// 监听给 document:页面上没有表单控件,键盘事件只能给 document
document.addEventListener('keydown', function (event) {
var keyCode = event.keyCode; // 左37 上38 右39 下40
var left, top;
switch (keyCode) {
case 37: // 向左:left 变小,最小不能小于 0
left = Math.max(tank.offsetLeft - speed, 0);
tank.style.left = left + 'px';
tank.style.transform = 'rotate(-90deg)';
break;
case 38: // 向上:top 变小,最小不能小于 0
top = Math.max(tank.offsetTop - speed, 0);
tank.style.top = top + 'px';
tank.style.transform = 'rotate(0deg)';
break;
case 39: // 向右:left 变大,最大不能超过 场地宽 - 坦克宽
left = Math.min(tank.offsetLeft + speed, box.clientWidth - tank.offsetWidth);
tank.style.left = left + 'px';
tank.style.transform = 'rotate(90deg)';
break;
case 40: // 向下:top 变大,最大不能超过 场地高 - 坦克高
top = Math.min(tank.offsetTop + speed, box.clientHeight - tank.offsetHeight);
tank.style.top = top + 'px';
tank.style.transform = 'rotate(180deg)';
break;
}
});
})();
</script>
</body>
</html>要点解释 / 易错点:
- 事件只能给
document:本例没有表单控件元素,键盘事件监听给普通 div 是触发不了的。 - 必须用
keydown:keypress只有可输入字符(字母、数字、标点、空格、回车)才触发,方向键、Ctrl/Alt/Shift 都不触发。 - 读位置用
offsetLeft / offsetTop:它返回的是相对「第一个定位祖先元素」的纯数字,正好对应left/top;el.style.left只能读到行内样式,用计算样式还要getComputedStyle,更麻烦。 - 写位置必须
el.style.left = 值 + 'px':offsetLeft是只读的,改不了;漏写'px'单位不生效。 - 边界判断用
Math.max / Math.min:Math.max(算出的值, 0)保证不小于 0;Math.min(算出的值, 最大值)保证不超过最大值,比 if 判断少写好几行。 - 最大值的算法:
box.clientWidth - tank.offsetWidth。用clientWidth是因为它不含边框——坦克只能在边框以内移动,定位的参考点也在边框内侧。 rotate()是转到目标角度,不是累加,所以每个方向直接写死 -90 / 0 / 90 / 180 即可。- 已知局限:DOM 的键盘事件同一时刻只能捕获一个键,无法实现「同时按两个键斜着走」。
示例 2:获取事件对象,查看 type / timeStamp / target
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件对象通用属性</title>
<style>
#box { padding: 20px; width: 600px; background: #ccc; }
#box p { padding: 10px; background: #fff; margin: 10px 0; }
</style>
</head>
<body>
<div id="box">
<p>第一个段落</p>
<p>第二个段落</p>
</div>
<script>
var box = document.querySelector('#box');
var firstP = box.firstElementChild;
var lastP = box.lastElementChild;
// 同一个回调函数,可以监听多个事件,用 event.type 区分
function handler(event) {
console.log('事件名 type:', event.type);
console.log('时间戳 timeStamp:', event.timeStamp); // 从页面打开那一刻开始算的毫秒数
console.log('目标元素 target:', event.target); // 真正发生动作的元素,可能是 box 也可能是 p
console.log('this:', this); // 永远指向绑定事件的元素,即 box
}
// 三种绑定方式都能拿到事件对象:只要给回调函数写形参即可
box.onclick = handler;
box.ondblclick = handler;
box.addEventListener('contextmenu', handler); // 右击
// 演示:不写形参也能用 event,靠的是 window.event 全局属性
box.onmousedown = function () {
console.log('window.event 也能拿到:', window.event.type);
};
</script>
</body>
</html>要点解释 / 易错点:
- 获取事件对象只有一种正确姿势:给事件回调函数写一个形参(
function (event) {}),无论onclick = fn、onclick = function(){}还是addEventListener,形参都能自动拿到。 type是事件名('click'、'dblclick'),不是「事件类型」的字面含义;它的价值在于多个事件共用一个回调函数时用来区分。timeStamp是从「页面打开那一刻」开始算的毫秒数,不是 1970 年。单看没意义,通常拿两次事件的时间戳相减做节流判断。target≠this:target是真正发生动作、不可再分的元素(可能是里层的 p);this永远是绑定了事件的那个元素(本例一直是 box)。点击第一个 p 时,target是 p,this是 box。- 不同类型事件的事件对象类型不同:鼠标事件得到 MouseEvent(或 PointerEvent),键盘事件得到 KeyboardEvent,但大类上都属于事件对象。
- 不写形参也能用
event,那是window.event全局属性,一个时刻只触发一个事件才成立;不推荐,因为看不出来源、可读性差,属于早期兼容写法。
示例 3:阻止事件冒泡 stopPropagation
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>阻止事件冒泡</title>
<style>
#box { padding: 20px; width: 600px; background: #ccc; }
#box p { padding: 10px; background: #fff; margin: 10px 0; }
</style>
</head>
<body>
<div id="box">
<p>第一个段落</p>
<p>第二个段落</p>
</div>
<script>
var box = document.querySelector('#box');
var firstP = box.firstElementChild;
var lastP = box.lastElementChild;
box.addEventListener('click', function (event) {
console.log('box 被点击了');
});
// 第一个 p:在回调里阻止冒泡,事件传不到 box
firstP.addEventListener('click', function (event) {
console.log('第一个段落被点击了');
event.stopPropagation();
});
// 第二个 p:没有阻止,点击后依然会冒泡到 box
lastP.addEventListener('click', function (event) {
console.log('最后一个段落被点击了');
});
</script>
</body>
</html>要点解释 / 易错点:
- 冒泡顺序:点击第一个 p,先捕获(window → document → … → p),再冒泡(p → box → … → window)。回调在冒泡阶段执行,所以在 p 的回调里调
stopPropagation(),后面的 box 回调就不会执行了。 stopPropagation()只影响冒泡,不影响捕获:如果把 box 的监听改成addEventListener('click', fn, true)在捕获阶段执行,那么即使 p 里阻止冒泡,box 的回调照样会先执行——因为它在捕获阶段就已经跑完了。return false不能阻止冒泡:原生 JS 里从来就没有这个功能,网上大量文章是错的(那是 jQuery 里的写法)。阻止冒泡只有event.stopPropagation()这一种方式。preventDefault()是另一件事:它阻止的是浏览器的默认行为(如链接跳转、表单提交),和冒泡无关,两个方法不要混用。- 本例用
addEventListener而不是onclick,是为了能分别给父元素和子元素绑定各自的处理函数,互不覆盖。
常见错误
- 现象:给普通
div监听keydown没反应。→ 原因:键盘事件通常只对可获焦元素、表单控件或document/window有效。→ 修法:把监听挂到document,或先让目标元素可获焦。 - 现象:用
keypress监听方向键、Ctrl、Shift无效。→ 原因:keypress只对可输入字符按键更可靠。→ 修法:统一改用keydown。 - 现象:写了
return false仍冒泡。→ 原因:原生 JS 中return false不能阻止冒泡。→ 修法:在事件回调中调用event.stopPropagation()。 - 现象:读取
el.style.left得到空字符串,后续计算出错。→ 原因:行内样式未设置时读不到计算后的定位值。→ 修法:用offsetLeft/offsetTop或getComputedStyle读取。 - 现象:拖拽后鼠标移动仍持续触发。→ 原因:
mousemove在mouseup后未解绑。→ 修法:按下时绑定,抬起时移除同一回调。
练习
- 用键盘方向键控制一个方块在容器内移动,要求不能越出上下左右边界,并用
Math.max/min简化边界判断。 - 给同一个回调函数同时监听
click和dblclick,通过事件对象的type区分当前触发的是哪个事件。 - 写一个父子元素都监听
click的示例:分别演示不阻止冒泡、阻止冒泡,以及捕获阶段监听时stopPropagation()的影响。 - 实现一个简单拖拽:
mousedown后绑定mousemove,mouseup后解绑,并说明为什么不能一开始就常驻监听mousemove。 - 给一个过渡元素和一个动画元素分别监听结束事件,记录触发次数,并解释动画多次执行时
animationend与animationiteration的差异。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 回顾 | 18:00 |
| 2 | 键盘事件控制元素移动 | 23:49 |
| 3 | 获取事件对象 | 12:43 |
| 4 | 鼠标事件对象和键盘事件对象 | 03:13 |
| 5 | 所有类型的事件对象都有的属性 | 09:33 |
| 6 | 阻止事件冒泡 | 06:40 |
小结
- 事件是否能触发,先看监听目标是否合理,尤其是键盘事件和滚动事件。
- 键盘控制移动优先用
keydown,不要依赖keypress处理功能键。 - 元素边界限制的本质是约束目标坐标,
Math.max/min可减少分支代码。 - 原生 JS 阻止冒泡只认
event.stopPropagation(),不要相信return false。 - 事件对象优先通过回调形参获取,不要把
window.event当作常规写法。 - 拖拽类交互要遵循“按下绑定、抬起解绑”,避免监听泄漏和误触发。
- 过渡/动画事件要结合触发次数和业务阶段使用,不能只看事件名。