主题
2-6统计用户字数案例
本页对应 第二章WebAPIs,共 6 段视频,约 52 分钟。
学习目标
- 能使用
focus、blur为表单元素绑定获得/失去焦点事件,并能用元素.focus()实现页面加载后自动获得焦点。 - 能独立实现小米搜索框案例:通过焦点事件控制下拉菜单显示/隐藏,并使用
classList.add/remove切换搜索框边框样式。 - 能区分
keydown、keyup、input的触发时机、执行顺序及取值差异,说明keydown为何拿不到最后一次按键值。 - 能使用
input事件与value.length完成用户输入字数统计,并通过焦点事件控制统计提示的显示与隐藏。 - 能说明事件对象来自事件回调函数的第一个参数,并能使用
e.key判断回车键、使用e.target、e.offsetX/Y获取事件源或鼠标坐标。 - 能复述事件流的基本概念,并了解使用
removeEventListener解绑事件的基本写法与适用场景。
知识点
为什么要学习事件流?
为什么需要: 初学事件时,最常见的疑惑是“我明明只点了子盒子,为什么父盒子的点击事件也弹了一次?”这背后就是事件流。事件流指的是事件完整执行过程中的流动路径。不理解它,就很难解释弹窗两次、事件委托、阻止冒泡等问题。
怎么想: 把一次点击想成一次“从外到里,再从里到外”的传递。触发事件时会经历两个阶段:捕获阶段、冒泡阶段。捕获是从DOM根元素开始依次调用同名事件,也就是从外到里;冒泡则相反,从当前元素向上调用祖先元素的同名事件。

怎么做: 先用addEventListener注册事件,再通过第三个参数控制阶段:传true走捕获,传false或不传走冒泡。实际开发以冒泡为主,捕获很少使用。
易错点: 不要以为“点击子盒子只触发子盒子”。默认存在冒泡,所以子元素事件会向上影响父级;调试时可在回调里console.log(e.target)看真正触发元素,也可先点子盒子观察弹窗顺序。
捕获阶段
为什么需要: 捕获能让我们理解事件从根元素向目标元素传递的过程,也能解释为什么给父元素加true后,点击子元素会先触发父元素。
怎么想: 捕获需要写对应代码才能看到效果,语法是:
html
元素.addEventListener('click', 回调函数, 是否使用捕获)怎么做: 第三个参数传true代表捕获阶段触发。
html
<body>
<div class="father">
父盒子
<div class="son">子盒子</div>
</div>
<script>
// 事件流
const father = document.querySelector('.father')
const son = document.querySelector('.son')
// 1. 事件捕获
// // 点击父盒子
father.addEventListener('click', function () {
alert('我是爸爸')
}, true) // 事件捕获
// 点击子盒子
son.addEventListener('click', function () {
alert('我是儿子')
}, true) // 事件捕获
</script>
</body>易错点: true是捕获,false是冒泡且为默认值;捕获阶段很少用于业务,不要为了“看起来高级”而滥用。调试时若发现先弹“爸爸”再弹“儿子”,说明捕获生效了。
冒泡阶段
为什么需要: 冒泡是默认存在的事件流动方式,也是实际工作中最常用的机制。点击子盒子会依次向上调用所有父级元素的同名事件,所以会出现“子盒子点击,父盒子也响应”的现象。
怎么想: 当一个元素触发事件后,同样的事件会在该元素的所有祖先元素中依次被触发。简单理解:子元素触发后,会依次向上调用父级元素的同名事件。
怎么做: 不传第三个参数或传false,都是冒泡。
html
<body>
<div class="father">
父盒子
<div class="son">子盒子</div>
</div>
<script>
// 事件流
const father = document.querySelector('.father')
const son = document.querySelector('.son')
// 2. 事件冒泡
// 点击父盒子
father.addEventListener('click', function () {
alert('我是爸爸')
})
// 点击子盒子
son.addEventListener('click', function () {
alert('我是儿子')
}, false)
</script>
</body>易错点: 点击子盒子弹出两次,不是代码写错,而是冒泡导致父元素也被触发;alert会阻塞页面,调试多个事件时容易看不清顺序,可改用console.log观察。
阻止冒泡
为什么需要: 默认冒泡会让子元素事件影响到父级或祖先元素,若只想让事件限制在当前元素内,就要阻止事件冒泡。
怎么想: 阻止冒泡需要拿到事件对象。转写稿里也强调:事件对象是事件回调函数的第一个参数,里面包含事件触发相关信息,常命名为e、ev或event。
怎么做: 在子元素回调中接收e,调用e.stopPropagation()。
html
<body>
<div class="father">
父盒子
<div class="son">子盒子</div>
</div>
<script>
// 事件流
const father = document.querySelector('.father')
const son = document.querySelector('.son')
// 1. 事件冒泡
// 点击父盒子
father.addEventListener('click', function () {
alert('我是爸爸')
})
// 点击子盒子
son.addEventListener('click', function (e) {
alert('我是儿子')
// 1.1 先获取事件对象
// 1.2 事件对象.stopPropagation() 注意是个方法
e.stopPropagation()
})
</script>
</body>易错点: stopPropagation是方法,必须加括号调用;不要写成属性赋值。事件对象必须从回调参数拿,不能凭空写e。另外,mouseover和mouseout会有冒泡效果,mouseenter和mouseleave没有冒泡效果,推荐后者。
事件委托
为什么需要: 如果同时给多个元素注册事件,需要循环多次注册;大量事件监听比较耗费性能。事件委托可以把原本注册在子元素上的事件委托给父元素,让父元素担当事件监听职务,减少注册次数,提高程序性能。
怎么想: 事件委托利用事件冒泡的特点:给父元素注册事件,触发子元素时会冒泡到父元素,从而触发父元素的事件。再利用事件对象e.target得到真正触发事件的元素。
怎么做: 给父元素ul注册点击事件,判断e.target.tagName === 'LI'再操作。tagName得到的是大写标签名,所以比较时写'LI'。
html
<body>
<ul>
<li>第1个孩子</li>
<li>第2个孩子</li>
<li>第3个孩子</li>
<li>第4个孩子</li>
<li>第5个孩子</li>
</ul>
<script>
// 需求: 点击每个小li都会有弹窗效果
// 1. 获取父元素ul
const ul = document.querySelector('ul')
// 2. 给ul注册点击事件
ul.addEventListener('click', function (e) {
// alert('我会弹窗')
// 3. 利用事件对象.target 得到目标元素
// console.log(e.target)
// e.target.style.color = 'pink'
// 需求2:点击哪个小li,对应的li变色
// console.dir(e.target.tagName) 可以得到目标元素的标签名
if (e.target.tagName === 'LI') {
e.target.style.color = 'pink'
}
})
</script>笔记中“大量注册”的示例用于说明性能问题:
html
<script>
// 假设页面中有 10000 个 button 元素
const buttons = document.querySelectorAll('table button');
for(let i = 0; i <= buttons.length; i++) {
// 为 10000 个 button 元素添加了事件
buttons.addEventListener('click', function () {
// 省略具体执行逻辑...
})
}
</script>易错点: 这个示例本身有笔误:buttons是集合,不能直接buttons.addEventListener,实际应取buttons[i];循环条件也不应<= buttons.length,否则会越界。事件委托中不要直接操作this当目标元素,要用e.target;若父元素内部还有子标签,需判断真正目标或使用closest。
阻止默认行为
为什么需要: 有些元素自带默认行为,例如点击提交按钮会提交表单,点击链接会跳转。业务中需要先校验或拦截时,就要阻止默认行为。
怎么想: 阻止默认行为同样依赖事件对象。它和阻止冒泡不同:stopPropagation阻止事件继续传播,preventDefault阻止元素默认动作。
怎么做: 在事件回调中调用e.preventDefault()。
html
<body>
<form action="">
姓名: <input type="text" name="username">
<button>提交</button>
</form>
<a href="http://www.baidu.com">点击跳转</a>
<script>
// 阻止默认行为
const form = document.querySelector('form')
const input = document.querySelector('[name=username]')
form.addEventListener('submit', function (e) {
// 如果input表单的值为空则不允许提交
if (input.value === '') {
// return 无法阻止提交事件
e.preventDefault() // 阻止提交事件
}
})
document.querySelector('a').addEventListener('click', function (e) {
e.preventDefault()
})
</script>
</body>易错点: 笔记中已注明return无法阻止提交事件;要阻止默认行为必须调用e.preventDefault()。同时不要把preventDefault和stopPropagation混用:前者管默认动作,后者管冒泡传播。
事件解绑:让事件只生效一次
为什么需要: 有些按钮只允许点击一次,比如提交后不再重复提交、弹窗只提示一次。第一次点击执行完逻辑后,如果还把事件处理函数留在元素上,后续再点还会继续触发。事件解绑就是移除事件处理函数,让元素不再响应某个事件。
怎么想: 解绑的前提是“已经绑定”。转写稿里反复强调:事件要先获取元素,再给元素绑定事件,回调函数是事件触发后执行的函数。解绑同样围绕“元素、事件类型、回调函数”三件事展开。先确认元素是谁、绑定的是什么事件、处理函数是哪一个,再决定怎么移除。
怎么做: 本节用两个按钮对比两种写法:addEventListener 绑定的 L2 事件监听,用 removeEventListener 移除;onclick 赋值的 L0 事件监听,用 onclick = null 移除。注意,转写稿里“时间监听、回掉函数、帮定”都是口误,正确写法是“事件监听、回调函数、绑定”。
先看本节代码结构
本节笔记没有附图片,核心就是下面这段代码。两个按钮分别演示 L2 和 L0 的解绑方式:
html
<body>
<button class="l2">移除L2事件监听</button>
<button class="l0">移除L0事件监听</button>
<script>
// 需求:按钮就点击一次,然后移除点击事件
// 1. l2事件监听
const l2 = document.querySelector('.l2')
l2.addEventListener('click', fn)
function fn() {
alert('我点击了')
// 移除事件监听
l2.removeEventListener('click', fn)
}
// 2. l0事件监听
const l0 = document.querySelector('.l0')
l0.onclick = function () {
alert('我点击了')
// 移除事件监听
l0.onclick = null
}
</script>
</body>易错点: 解绑不是删除元素,也不是把页面上所有事件都清空;它只移除“指定元素、指定事件类型、指定处理函数”的那一个监听。
L2 事件监听的解绑
为什么需要: addEventListener 可以给同一个元素绑定多个事件处理函数,所以移除时也要精确指定“移除哪一个”,不能笼统地说“移除点击事件”。
怎么想: removeEventListener 需要和 addEventListener 对应起来。代码里绑定的是 'click' 事件,处理函数是 fn,移除时也写 l2.removeEventListener('click', fn)。这里最关键的是:绑定和移除必须使用同一个函数引用。
怎么做: 先把处理函数声明为具名函数 fn,绑定和移除都传 fn。在 fn 内部先执行 alert,再执行移除。这样第一次点击会弹窗,弹窗结束后监听被移除,第二次点击就不会再触发。
易错点: 不能写成匿名函数再移除,因为两次写的匿名函数不是同一个引用;事件类型不要写成 'onclick',addEventListener 和 removeEventListener 里写 'click'。调试时可以借用转写稿里“先验证事件有没有绑定成功”的习惯:先点一次确认能弹窗,再点第二次确认不再弹窗;如果第二次还弹,就检查函数是不是同一个 fn、事件名是否一致。
L0 事件监听的解绑
为什么需要: onclick 是给元素的事件属性赋值,同一个元素后一次赋值会覆盖前一次赋值。它的移除方式和 addEventListener 不同,不能直接套用 removeEventListener。
怎么想: 既然 onclick 是通过赋值绑定的,那么移除就是把它重新赋值为 null。代码里在回调函数内部执行 l0.onclick = null,表示这次点击处理完后,把点击属性清空。
怎么做: 用 l0.onclick = function () { ... } 绑定,函数内部先 alert,再写 l0.onclick = null。第一次点击执行函数,随后属性被置空;后续再点击,元素上没有 onclick 处理函数,就不会再执行。
易错点: onclick = null 只移除 L0 方式绑定的处理函数,不会影响通过 addEventListener 绑定的监听;反过来,removeEventListener 也不能用来移除 onclick 赋值的匿名函数。两种绑定方式要对应各自的解绑方式。
小结
事件解绑的核心是“绑定和解绑要对应”:L2 用 addEventListener 绑定,就用 removeEventListener 移除,并且必须传同一个具名函数;L0 用 onclick 赋值绑定,就用 onclick = null 移除。本节转写稿主要讲焦点、键盘、input 和事件对象,没有单独展开解绑调试,所以这里不虚构额外过程,只按本节代码推导:第一次点击触发回调,回调内部执行解绑,第二次点击不再触发。
课件图示








代码示例
示例 1:事件流 —— 捕获、冒泡与阻止冒泡
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件流:捕获 / 冒泡 / 阻止冒泡</title>
<style>
.father {
width: 300px;
height: 300px;
background-color: skyblue;
padding: 20px;
margin-bottom: 30px;
}
.son {
width: 150px;
height: 150px;
background-color: pink;
margin: 20px auto;
line-height: 150px;
text-align: center;
}
</style>
</head>
<body>
<h3>1. 事件捕获(第三个参数 true):点儿子,先弹爸爸再弹儿子</h3>
<div class="father father1">
父盒子
<div class="son son1">子盒子</div>
</div>
<h3>2. 事件冒泡(默认 false):点儿子,先弹儿子再弹爸爸</h3>
<div class="father father2">
父盒子
<div class="son son2">子盒子</div>
</div>
<h3>3. 阻止冒泡:点儿子只弹儿子</h3>
<div class="father father3">
父盒子
<div class="son son3">子盒子</div>
</div>
<script>
// 1. 事件捕获:从 DOM 根元素开始,从外到里依次调用同名事件
const father1 = document.querySelector('.father1')
const son1 = document.querySelector('.son1')
father1.addEventListener('click', function () {
alert('我是爸爸(捕获)')
}, true)
son1.addEventListener('click', function () {
alert('我是儿子(捕获)')
}, true)
// 2. 事件冒泡:从里到外,依次向上调用所有父级元素的同名事件(默认行为)
const father2 = document.querySelector('.father2')
const son2 = document.querySelector('.son2')
father2.addEventListener('click', function () {
alert('我是爸爸(冒泡)')
})
son2.addEventListener('click', function () {
alert('我是儿子(冒泡)')
}, false)
// 3. 阻止冒泡:先拿到事件对象,再调用 stopPropagation()
const father3 = document.querySelector('.father3')
const son3 = document.querySelector('.son3')
father3.addEventListener('click', function () {
alert('我是爸爸(被阻止)')
})
son3.addEventListener('click', function (e) {
alert('我是儿子(已阻止冒泡)')
e.stopPropagation() // 方法,必须加小括号
})
</script>
</body>
</html>关键行与易错点
addEventListener('click', fn, true)第三个参数传true表示在捕获阶段触发;传false或省略表示冒泡阶段触发,实际开发以冒泡为主。- 捕获是“从外到里”,冒泡是“从里到外”,所以示例 1 先弹爸爸、示例 2 先弹儿子。
- 阻止冒泡的前提是拿到事件对象:回调函数的第一个形参
e,然后调用e.stopPropagation()。注意它是方法,忘写括号就没效果。 - 易错:
stopPropagation()只阻止事件传播,不会阻止默认行为;阻止默认行为用的是e.preventDefault(),两者不要混用。 - 易错:
onclick方式同一元素只能绑定一个处理函数,后写的会覆盖前面的,推荐统一用addEventListener。 - 补充:
mouseover / mouseout有冒泡效果,mouseenter / mouseleave没有冒泡效果(推荐)。
示例 2:事件委托 + 阻止默认行为
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件委托 + 阻止默认行为</title>
<style>
ul { list-style: none; padding: 0; }
li {
width: 200px;
height: 40px;
line-height: 40px;
border: 1px solid #ccc;
margin-bottom: 6px;
text-align: center;
cursor: pointer;
}
</style>
</head>
<body>
<ul>
<li>第1个孩子</li>
<li>第2个孩子</li>
<li>第3个孩子</li>
<li>第4个孩子</li>
<li>第5个孩子</li>
</ul>
<form action="">
姓名: <input type="text" name="username">
<button>提交</button>
</form>
<a href="http://www.baidu.com">点击跳转</a>
<script>
// 事件委托:只给父元素 ul 注册一次点击事件
const ul = document.querySelector('ul')
ul.addEventListener('click', function (e) {
// e.target 是真正触发事件的元素
// console.dir(e.target.tagName) 可以得到目标元素的标签名
if (e.target.tagName === 'LI') {
e.target.style.color = 'pink'
}
})
// 阻止默认行为:表单值为空则不允许提交
const form = document.querySelector('form')
const input = document.querySelector('[name=username]')
form.addEventListener('submit', function (e) {
if (input.value === '') {
// return 无法阻止提交事件
e.preventDefault() // 阻止提交事件
}
})
// 阻止链接跳转
document.querySelector('a').addEventListener('click', function (e) {
e.preventDefault()
})
</script>
</body>
</html>关键行与易错点
- 事件委托的原理就是事件冒泡:给父元素注册事件,点击子元素时冒泡到父元素,从而触发父元素的处理函数,减少注册次数、提高性能。
- 通过
e.target拿到真正触发事件的元素,再用e.target.tagName === 'LI'判断点的是不是小 li。 - 易错:
tagName返回的是大写标签名('LI'),写成'li'判断会永远不成立。 - 易错:不要用循环给每个元素注册事件,且笔记中的循环示例本身有坑 ——
buttons是querySelectorAll得到的集合,集合上没有addEventListener方法;同时i <= buttons.length会越界,应为i < buttons.length。 - 阻止默认行为要用事件对象上的
e.preventDefault():表单空值时return并不能阻止提交,链接跳转同理。
示例 3:小米搜索框案例(焦点事件控制显示隐藏)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>小米搜索框案例(焦点事件)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { padding: 100px; }
.mi { position: relative; width: 300px; }
.search-text {
width: 300px;
height: 40px;
padding: 0 10px;
border: 1px solid #ccc;
outline: none;
}
/* 获得焦点时给搜索框加上的类:橙色边框 */
.search { border: 1px solid #ff6700; }
/* 下拉菜单:绝对定位,初始隐藏 */
.result-list {
position: absolute;
top: 40px;
left: 0;
width: 300px;
border: 1px solid #ff6700;
border-top: none;
list-style: none;
display: none;
}
.result-list li {
height: 36px;
line-height: 36px;
padding-left: 10px;
cursor: pointer;
}
.result-list li:hover { background-color: #f5f5f5; }
</style>
</head>
<body>
<div class="mi">
<input type="search" class="search-text" placeholder="小米手机">
<ul class="result-list">
<li>小米手机</li>
<li>小米电视</li>
<li>小米手环</li>
<li>小米笔记本</li>
</ul>
</div>
<script>
// 短横线类名在 JS 中不能直接做变量名,所以变量用下划线
const searchText = document.querySelector('.search-text')
const resultList = document.querySelector('.result-list')
// 获得焦点:显示下拉菜单 + 搜索框变色
searchText.addEventListener('focus', function () {
resultList.style.display = 'block'
searchText.classList.add('search')
})
// 失去焦点:隐藏下拉菜单 + 移除类名恢复原来的灰色边框
searchText.addEventListener('blur', function () {
resultList.style.display = 'none'
searchText.classList.remove('search')
})
</script>
</body>
</html>关键行与易错点
- 第一步必须先在 CSS 中把下拉菜单
display: none隐藏,这一步不能省略,否则页面一打开下拉菜单就露出来了。 - 获得焦点事件是
focus,失去焦点事件是blur;显示用display = 'block',隐藏用display = 'none'。 - 边框变色采用“加类名 / 移除类名”的方式(
classList.add('search')、classList.remove('search')),比直接改style.border更便于复用和还原。 - 易错:
search-text这种带短横线的类名不能直接当变量名,变量名要改成searchText。 - 易错:
元素.focus()(加小括号)是自动获得焦点的方法,和绑定focus事件是两回事;百度首页一打开搜索框就有光标,就是用了元素.focus()。
常见错误
- 点击子元素时父元素事件也执行,误以为绑错元素 → 事件默认冒泡到祖先元素 → 不需要父级响应时在子元素回调中调用
e.stopPropagation();需要批量处理时改用事件委托并判断e.target。 addEventListener第三个参数 true/false 混用,导致父子执行顺序与预期相反 → 捕获与冒泡阶段选择不清 → 默认不传第三参数按冒泡处理;确需捕获时显式传true,并固定团队约定。- 阻止冒泡写成
e.stopPropagation或漏写事件对象 → 把方法当属性、回调未接收e→ 写成function (e) { e.stopPropagation() },并检查回调第一个参数是否存在。 - 事件委托中直接操作
e.target,点到li内span时样式或逻辑错乱 →target是真正触发事件的最深元素 → 用e.target.tagName、closest('li')或contains判断并锁定目标子项。 - 搜索框失焦隐藏下拉,点击下拉项时下拉先消失 →
blur先触发,点击项未来得及响应 → 下拉项用mousedown并preventDefault(),或延迟隐藏并做状态判断。 keydown中读取输入框value拿不到最后一次按键 → 按键值尚未写入表单值 → 需要完整值用keyup或input;仅判断按键用e.key。removeEventListener解绑无效 → 绑定与解绑传入的不是同一函数引用 → 使用具名函数绑定,并用同一函数引用解绑;onclick方式可置null。- 表单提交监听中用
return阻止提交无效 →return只结束当前回调,不取消默认行为 → 使用e.preventDefault()。
练习
- 用事件委托实现:点击
ul下任意li变色;若点到li内的span,也要让所在li变色。 - 实现卡片点击弹窗,但卡片内“删除”按钮点击时只执行删除逻辑,不触发卡片弹窗。
- 实现搜索框:聚焦显示下拉菜单并高亮边框,失焦隐藏并恢复边框;点击下拉项时菜单不能先消失。
- 实现留言统计:
textarea设置maxlength="200",输入时实时显示“已输入 x/200 字”,页面初始也要显示“0/200”。 - 实现回车提交:在输入框中按回车触发提交提示,其他按键不触发;同时说明为什么不在
keydown中依赖输入框最新value做完整校验。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 焦点事件 | 07:47 |
| 2 | 小米搜索框案例 | 10:05 |
| 3 | 键盘和input事件 | 10:05 |
| 4 | 统计用户字数案例-上 | 07:45 |
| 5 | 统计用户字数案例-下 | 05:14 |
| 6 | 事件对象 | 11:17 |
小结
- 事件流默认走冒泡,捕获必须显式启用;排查父子触发顺序先看第三个参数。
stopPropagation()只解决传播问题,不应滥用来掩盖错误的事件绑定结构。- 事件委托的核心是“父级监听 + 冒泡 +
target判断”,适合列表和批量元素。 - 焦点显隐类交互要重点处理
blur与点击下拉项的时序冲突。 keydown、keyup、input的触发时机不同,取值完整性和按键判断要分开选事件。- 事件对象是回调第一个参数,
e.key判断按键,e.target判断真实触发元素。 preventDefault()管默认行为,stopPropagation()管传播,二者职责不能混用。- 解绑事件必须保证函数引用一致;匿名函数无法直接
removeEventListener。