主题
2-9事件流和事件委托
本页对应 第二章WebAPIs,共 8 段视频,约 51 分钟。
学习目标
- 能区分事件捕获与事件冒泡的执行顺序,并通过
addEventListener第三个参数验证捕获、冒泡两种阶段。 - 能在事件回调中获取事件对象,使用
stopPropagation()阻止事件冒泡,并验证子元素事件不再触发父元素同名事件。 - 能对比
mouseover/mouseout与mouseenter/mouseleave的冒泡差异,并根据是否需要事件委托选择正确事件类型。 - 能解释事件委托的原理与优势,使用父元素绑定事件,并通过
e.target、e.target.tagName筛选真正触发元素。 - 能使用事件委托完成 tab 栏切换:用
data-*自定义属性保存索引,通过dataset读取序号,实现顶部高亮排他与底部内容显示排他。 - 能列举页面加载、页面滚动、页面尺寸事件及元素尺寸与位置获取的常用 API,并写出获取元素宽高或位置的基本代码。
知识点
页面加载事件:load
为什么需要:页面里的图片、外联 CSS、外联 JavaScript 等资源加载需要时间。如果脚本在资源还没处理完时就去操作 DOM,容易拿不到元素。转写稿里反复使用 addEventListener 注册事件,本节沿用同一思路,只是把监听对象换成 window。
怎么想:load 等的是“页面所有资源加载完毕”。所以它适合做“资源全部处理完再执行”的事情,例如依赖图片尺寸的初始化。老代码常把 script 写在 head 中,直接找 DOM 会找不到,这时就需要等加载完成。
怎么做:
javascript
window.addEventListener('load', function() {
// xxxxx
})调试时可先在回调里写 console.log('load 触发'),确认触发时机后再写业务逻辑。转写稿中“补货”应纠正为“捕获”,“复盒子”应纠正为“父盒子”;本节不展开事件流,只保留正确的监听写法。
易错点:load 监听的是 window,不是随便一个元素;它等待资源较多,触发相对晚,不要把它当成“HTML 一解析完就执行”。
DOMContentLoaded
为什么需要:有时不需要等图片、样式表全部加载完,只要 HTML 文档被完全加载和解析完成,就要初始化 DOM。这样比 load 更早。
怎么想:DOMContentLoaded 的关键是“HTML 文档解析完成”,无需等待样式表、图像等完全加载。它适合在 DOM 结构就绪后绑定事件、查找元素。
怎么做:
javascript
document.addEventListener('DOMContentLoaded', function() {
// xxxxx
})易错点:监听对象是 document,不是 window;不要把它和 load 混用。转写稿里把 target 说成“他跟他”、把 tagName 说成“tiger name”,整理时应写成 target、tagName,注意驼峰命名。
元素滚动事件:scroll
为什么需要:很多网页要检测用户把页面滚动到某个区域后做处理,比如固定导航栏、返回顶部。转写稿提到页面滚动会出现吸附导航、电梯导航,这类效果都离不开滚动检测。
怎么想:滚动条滚动时会持续触发 scroll。监听整个页面滚动时,对象是 window。先确认“谁在滚”,再在回调里读取滚动位置。
怎么做:
javascript
window.addEventListener('scroll', function() {
// xxxxx
})易错点:scroll 会持续触发,回调里不要写过于复杂的逻辑;先打印或读取位置确认触发,再写业务。转写稿中的“补货”不是本节概念,不要误写成滚动事件的一部分。
scrollTop / scrollLeft
为什么需要:只知道“发生了滚动”不够,还要知道被卷去的头部或左侧距离,才能判断是否到达某个区域。
怎么想:scrollTop / scrollLeft 表示被卷去的头部或者左侧,可以读取,也可以修改(赋值)。读取用于判断位置,赋值可用于回到某个位置。
scrollTop / scrollLeft, 被卷去的头部或者左侧,可以读取,也可以修改(赋值)
易错点:读取和赋值要分清;不要把“卷去距离”当成元素宽高。调试时沿用转写稿里的 console.log 思路,先打印数值,确认单位与变化。
页面尺寸事件:resize
为什么需要:窗口尺寸改变时,页面布局可能需要重新计算或调整。监听 resize 可以在尺寸变化时执行处理。
怎么想:窗口尺寸改变时触发,监听对象是 window。先绑定事件,再在回调里读取需要的尺寸。
怎么做:
javascript
window.addEventListener('resize', function() {
// xxxxx
})易错点:resize 可能频繁触发,回调逻辑要尽量轻;不要把 resize 和 scroll 混淆。
clientWidth 和 clientHeight
为什么需要:有时要获取元素可见部分的宽高,用于布局判断或尺寸计算。
怎么想:clientWidth 和 clientHeight 获取元素的可见部分宽高,不包含 border、margin、滚动条等。
clientWidth和clientHeight,获取元素的可见部分宽高(不包含border,margin,滚动条等)
易错点:不要把 clientWidth 当成包含边框的宽度;它和 scrollTop 不是一类属性。转写稿中“GS”应写为“JS”,“mo over / mo enter”应写为 mouseover / mouseenter,本节只保留其他事件相关写法,不扩展未讲内容。
先说明:本节笔记与转写稿的对应关系
本节笔记的主题是“元素尺寸与位置”,核心结论只有一句:获取元素的自身宽高时,读到的是包含元素自身设置的宽高、padding、border 的尺寸。转写稿中,这一节只出现在今日内容总览里:提到“最后我们会学习元素的尺寸与位置,可以通过 JS 形式拿到标签的大小,还有它在页面中的位置是多少”,并引出后续“吸附导航”“电梯导航”等案例。转写稿没有展开这一节的代码演示和调试过程,因此下面不虚构运行结果,只按笔记结论把知识点讲清;转写稿里“GS”应写作“JS”,“菜标栏/电梯大行”应理解为“侧边栏/电梯导航”。


为什么需要元素尺寸与位置
为什么需要:只写 CSS 时,盒子大小由样式决定;但一旦进入交互,比如页面滚动后出现吸附导航、点击电梯导航跳到某个模块,JS 就必须知道“这个元素现在实际占多大”“它在页面中的位置在哪里”。笔记里说的“自身宽高、包含 padding、border”,正是实际占位尺寸,而不是你在 CSS 里写的那一个 width 数字。
怎么想:先区分“样式值”和“实际占位值”。style.width 读的是行内样式里写的值,可能是 "200px",也可能为空;元素实际占位则要把内容宽、padding、border 一起算进去。所以读尺寸时不能只盯着 width。
怎么做:按笔记结论,读取元素自身宽高时,要认识到结果包含自身设置的宽高、padding、border。这类读取是“读结果”,不是“改样式”。
易错点:不要把 CSS 的 width 直接等同于元素实际占位宽;不要以为读了尺寸就能修改尺寸,尺寸读取属性通常是只读的。
怎么想:盒模型决定“读到的是哪一圈”
为什么需要:如果不知道读的是盒模型的哪一圈,就会把 padding、border 漏掉,导致拖拽、定位、吸顶判断出现几像素偏差。
怎么想:把元素想成一个盒子:最里面是内容,外面一圈是 padding,再外面是 border,最外面还有 margin。笔记明确说“包含元素自身设置的宽高、padding、border”,说明它读的是盒子占位,不含 margin。所以判断时先问:我要的是内容区,还是整个盒子占位?笔记这一节要的是后者。
怎么做:看示意图时按“内容 + padding + border”去数;写代码前先确认 CSS 里有没有 padding、border,否则容易误判。
易错点:把 margin 也算进去是常见错误;margin 是盒子外面的间距,不属于笔记所说的自身宽高包含范围。
怎么做:用尺寸属性读取占位尺寸
为什么需要:后续做吸附导航、电梯导航时,需要拿真实占位尺寸做判断,不能靠猜。
怎么想:笔记给的是结论,没有给代码。按结论,读取元素自身宽高时应使用能返回“包含 padding、border 的占位尺寸”的属性;学习时先记住“读实际占位,不读样式字符串”。
怎么做:先获取元素,再读取它的尺寸结果;得到的是数值型结果,单位按像素理解。由于转写稿没有给这一节的具体代码,这里不补造代码,避免与课堂实际写法冲突。你只需把握:读尺寸是“获取”,不是“设置”。
易错点:元素被 display:none 隐藏时,尺寸读不到实际占位;小数像素在不同环境可能被取整;只读属性不能直接赋值修改。
位置部分:笔记只点到,不臆造具体 API
为什么需要:标题里有“位置”,转写稿也提到“页面中的位置是多少”,说明位置和尺寸是一组能力。做电梯导航时,必须知道模块在页面中的位置,才能滚到对应区域。
怎么想:位置一定要先问“相对谁”。相对页面、相对定位父级、相对视口,结果可能不同。笔记没有列出具体位置属性名,转写稿也没有展开,所以这里不能编造具体方法。
怎么做:本节先建立“尺寸是盒子占多大,位置是盒子在哪里”的区分;具体读取方式等后续案例或课堂代码展开。不要因为标题有“位置”就随便套一个属性。
易错点:把尺寸当位置、把相对父级的位置当页面位置,都会导致滚动定位偏差;位置计算前要先确认定位参照物。
小结
本节要抓住一句话:元素自身宽高的读取结果包含自身设置的宽高、padding、border,不包含 margin。为什么需要:交互案例要拿真实占位;怎么想:按盒模型从内容到 border 数一圈;怎么做:读实际占位而不是读 style.width;易错点:漏 padding/border、多算 margin、把只读结果当可写样式。位置部分笔记只点到,转写稿未展开,学习时先分清“相对谁”,不臆造具体 API。
课件图示








代码示例
代码 1:页面加载 / 滚动 / 尺寸事件
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>页面加载 / 滚动 / 尺寸事件</title>
<style>
body { height: 2000px; margin: 0; }
.box {
width: 200px; height: 100px;
padding: 10px; border: 5px solid #333; margin: 20px;
background: pink;
}
.back-top {
position: fixed; right: 30px; bottom: 30px;
display: none;
}
</style>
</head>
<body>
<div class="box">clientWidth / clientHeight</div>
<button class="back-top">返回顶部</button>
<script>
// ① load:页面所有外部资源(图片、外联 CSS/JS)加载完毕才触发
window.addEventListener('load', function () {
console.log('load:所有资源加载完毕')
})
// ② DOMContentLoaded:HTML 文档被完全加载和解析完成就触发,不等图片
document.addEventListener('DOMContentLoaded', function () {
console.log('DOMContentLoaded:DOM 解析完成')
console.log(document.querySelector('.box')) // 此时能拿到元素
})
// ③ scroll:滚动条在滚动的时候持续触发
const backTop = document.querySelector('.back-top')
window.addEventListener('scroll', function () {
const n = document.documentElement.scrollTop // 被卷去的头部,可读取
console.log('scrollTop:', n)
backTop.style.display = n >= 300 ? 'block' : 'none'
})
// scrollTop 也可以修改(赋值)
backTop.addEventListener('click', function () {
document.documentElement.scrollTop = 0
})
// ④ resize:窗口尺寸改变的时候触发
window.addEventListener('resize', function () {
const box = document.querySelector('.box')
console.log('clientWidth:', box.clientWidth, 'clientHeight:', box.clientHeight)
})
</script>
</body>
</html>关键行与易错点
load监听的是window,等图片、外联 CSS/JS 全部加载完;DOMContentLoaded监听的是document,DOM 解析完就触发,不等图片。老代码把script写在head里找不到 DOM 元素,就是靠这两个事件包起来解决的。- 读取整个页面被卷去的头部用
document.documentElement.scrollTop;scrollTop既可读取也可赋值,赋值0就回到顶部。 clientWidth / clientHeight获取元素可见部分宽高,包含 padding,不包含 border、margin、滚动条——这一点最容易记反。scroll事件在滚动时持续触发,回调里不要写太重的逻辑。
代码 2:事件流——冒泡与阻止冒泡、mouseover 与 mouseenter
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件冒泡与阻止冒泡</title>
<style>
.father { width: 300px; height: 300px; background: pink; }
.son { width: 150px; height: 150px; background: purple; }
</style>
</head>
<body>
<div class="father">
<div class="son"></div>
</div>
<script>
const father = document.querySelector('.father')
const son = document.querySelector('.son')
// 冒泡阶段:第三个参数省略或写 false(默认)
father.addEventListener('click', function () {
console.log('我是爸爸')
})
son.addEventListener('click', function (e) {
console.log('我是儿子')
// 阻止冒泡:先拿到事件对象 e,再调用 stopPropagation(方法要加括号)
e.stopPropagation()
})
// mouseover 有冒泡,mouseenter 没有冒泡
father.addEventListener('mouseover', function () {
console.log('mouseover:经过了爸爸')
})
son.addEventListener('mouseover', function () {
console.log('mouseover:经过了儿子')
})
father.addEventListener('mouseenter', function () {
console.log('mouseenter:经过了爸爸')
})
son.addEventListener('mouseenter', function () {
console.log('mouseenter:经过了儿子')
})
</script>
</body>
</html>关键行与易错点
- 点
.son会先打印“我是儿子”,再打印“我是爸爸”,这就是冒泡:从里到外依次执行同名事件;捕获则相反(从外到里),靠事件监听第三个参数传true开启,开发中很少用。 - 阻止冒泡必须先拿到事件对象(回调函数的第一个参数),再调用
e.stopPropagation();它是方法,必须加括号,写成e.stopPropagation无效。 mouseover有冒泡:鼠标从父盒子移到子盒子时,父盒子的mouseover会再触发一次;mouseenter没有冒泡,只触发一次。所以事件委托必须用mouseover。- 演示时用
console.log,用alert会阻塞页面,连续弹出时容易误判执行顺序。
代码 3:事件委托版 tab 栏切换
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件委托版 tab 栏切换</title>
<style>
* { margin: 0; padding: 0; }
li { list-style: none; }
.tab { width: 600px; margin: 50px auto; }
.tab-nav { display: flex; border-bottom: 1px solid #ccc; }
.tab-nav li { flex: 1; text-align: center; }
.tab-nav a {
display: block; padding: 10px 0;
color: #333; text-decoration: none;
}
.tab-nav a.active { background: pink; color: #fff; }
.tab-content .item {
display: none; height: 150px; padding: 20px;
background: #f5f5f5;
}
.tab-content .item.active { display: block; }
</style>
</head>
<body>
<div class="tab">
<ul class="tab-nav">
<li><a href="javascript:;" data-id="0">精选</a></li>
<li><a href="javascript:;" data-id="1">美食</a></li>
<li><a href="javascript:;" data-id="2">百货</a></li>
<li><a href="javascript:;" data-id="3">客户预告</a></li>
<li><a href="javascript:;" data-id="4">最新专题</a></li>
</ul>
<div class="tab-content">
<div class="item active">精选内容</div>
<div class="item">美食内容</div>
<div class="item">百货内容</div>
<div class="item">客户预告内容</div>
<div class="item">最新专题内容</div>
</div>
</div>
<script>
// 1. 事件委托:把事件注册在父元素 ul 上,只注册一次
const ul = document.querySelector('.tab-nav')
const items = document.querySelectorAll('.tab-content .item')
ul.addEventListener('mouseover', function (e) {
// 2. 只有经过的是 A 链接才处理(tagName 必须大写)
if (e.target.tagName === 'A') {
// 3. 排他:先移除上一个 active
const prevA = document.querySelector('.tab-nav .active')
const prevItem = document.querySelector('.tab-content .active')
if (prevA) prevA.classList.remove('active')
if (prevItem) prevItem.classList.remove('active')
// 4. 当前元素高亮:事件委托里用 e.target,不要用 this
e.target.classList.add('active')
// 5. 通过自定义属性 data-id 拿到序号,显示对应盒子
const index = e.target.dataset.id
items[index].classList.add('active')
}
})
</script>
</body>
</html>关键行与易错点
- 绑在
ul而不是每个li:li有 5 个,ul只有一个,注册一次就够,减少注册次数;点击a后靠冒泡冒到ul上被处理,这就是事件委托。 - 必须用
mouseover:事件委托依赖冒泡,mouseenter没有冒泡,委托不生效。 e.target.tagName === 'A'中标签名是大写字符串,写'a'永远不成立;加这个判断是为了排除点到ul、li等非链接元素。- 事件委托里获取当前触发元素用
e.target,不能用this(this指向绑定的ul)。 - 序号通过自定义属性
data-id提供,读取用e.target.dataset.id(data-后面的名字转小驼峰);拿到序号后直接用items[index]选中对应盒子。 - 排他前先判断
prevA / prevItem是否存在,避免querySelector返回null时调用classList报错。
常见错误
1. head 里直接 querySelector,拿到 null,后续全报错
- 现象:
Cannot read properties of null (reading 'addEventListener') - 原因:script 在 head 中同步执行时,下方 DOM 还没解析出来
- 修法:优先把 script 放
</body>前;必须放 head 就包一层document.addEventListener('DOMContentLoaded', ...);若逻辑依赖图片等资源尺寸,改用window.addEventListener('load', ...)
2. 用 mouseenter / mouseleave 做事件委托,子元素毫无反应
- 现象:事件绑在 ul 上,鼠标划过 li 不触发任何逻辑
- 原因:
mouseenter/mouseleave不冒泡,委托的传递链从根上就断了 - 修法:委托场景改用
mouseover/mouseout,并在回调里用e.target.tagName过滤;若坚持用 mouseenter,就放弃委托、循环绑定到每个子元素
3. 阻止冒泡写了但没生效
- 现象:子元素回调里加了
stopPropagation,父元素照样触发 - 原因:三种典型写法问题——写成
e.stopPropagation(漏括号没调用)、写在父元素的回调里、把e参数漏在函数形参外 - 修法:
function (e) { e.stopPropagation(); ... },位置放在真正触发事件的那个元素(子元素)的回调首行
4. 委托里用 this 取当前元素,拿到的是父元素
- 现象:点击 li,变色/高亮作用在 ul 上(表现为整块全变)
- 原因:
this指向"绑定事件的元素",不是"被点击的元素" - 修法:委托场景一律用
e.target;结构有嵌套(如 a 里套 span)时用e.target.closest('a')兜底
5. 委托没加 tagName 判断,点空白区域也触发
- 现象:点 ul 的间隙、点 li 里的子标签,逻辑误触发或整片变色
- 原因:
e.target是"实际点到的任意节点",不等于你想要的 li/a - 修法:加守卫
if (e.target.tagName === 'LI') { ... },标签名必须是大写字符串;更稳的写法是if (!e.target.matches('.tab a')) return
6. 排他思想里 classList 带了点号
- 现象:
classList.remove('.active')静默失败,多个高亮叠加 - 原因:
classList.add/remove接收类名,不接收选择器 - 修法:
remove('active')、add('active')不带点;只有querySelector('.active')才带点
7. dataset 读写对不上
- 现象:
e.target.dataset.ID或dataset.dataId得到 undefined - 原因:
data-id→dataset.id,data-goods-id→dataset.goodsId(连字符转驼峰,全小写起头) - 修法:HTML 端统一
data-id="0",JS 端统一dataset.id;下标用items[e.target.dataset.id]前先确认该属性存在
8. 尺寸/滚动 API 选错
- 现象:
scrollTop永远 0;clientWidth比设计稿少 2px - 原因:滚动的不是被监听对象(页面滚 window,容器滚要自身
overflow:auto且有高度);clientWidth不含 border,也不含滚动条 - 修法:先问"谁在滚"再决定监听谁;要含 border 用
offsetWidth,要视口宽用window.innerWidth
练习
练习 1|委托入门加固 给一个含 5 个 li 的 ul,实现"点击哪个 li,哪个 li 文字变红",要求:事件只注册 1 次;点击 ul 空白处无任何反应;点击后能拿到被点 li 的文本内容并 console.log。 验收:在控制台点击 ul 的 padding 区域不打印任何东西;连点不同 li,红色只有一个。 提示:e.target.tagName === 'LI'、e.target.textContent。
练习 2|mouseover 与 mouseenter 对照实验 父子两个嵌套盒子,先用 mouseover/mouseout 绑定并打印,再用 mouseenter/mouseleave 绑定并打印,各跑一遍记录日志条数与顺序,写 3 行注释说明差异原因。 验收:能解释"为什么 over 那组在进入子盒子时会多打印一次父盒子的日志";能回答"这组实验说明了委托能不能用 enter"。
练习 3|吸顶 + 返回顶部(含 scrollTop 边界) 做一个长页面:滚动超过 300px 时导航吸顶、返回顶部按钮出现;点击按钮回到顶部。要求用 window 的 scroll 事件,并用一个布尔开关做简单节流。 验收:刷新后不滚动时按钮隐藏;滚回顶部后按钮自动隐藏;连续快速滚动不掉帧、控制台不刷屏。 提示:document.documentElement.scrollTop、window.scrollTo(0, 0)。
练习 4|tab 栏切换(事件委托 + 自定义属性) 把原来的 for 循环版 tab 改造为委托版:事件绑在 ul 上,5 个 a 手动写 data-id="0~4",底部 5 个 div 用 items[序号] 切换。要求上、下两个模块都用排他思想,且点击 tab 区域空白不变。 验收:鼠标划过每个 tab,高亮与内容一一对应且不串位;把 HTML 里某个 data-id 故意改错,能说出会发生什么现象。 提示:mouseover + tagName === 'A' + dataset.id。
练习 5|尺寸响应式小判断 用 resize + 视口宽度,实现窗口宽度小于 768px 时给 body 加 mobile 类、大于等于则移除;同时用 clientWidth 与 offsetWidth 各打印一次某个带 border 的盒子宽度,观察差值。 验收:拖动窗口边界时类名能正确增删且不重复叠加;能说出两个宽度值差多少、差在哪。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 今日内容 | 01:53 |
| 2 | 事件流 | 11:23 |
| 3 | 阻止事件冒泡 | 03:22 |
| 4 | mouseover和mouseenter的区别 | 04:21 |
| 5 | 事件委托的原理 | 06:47 |
| 6 | 利用事件委托得到目标元素 | 07:48 |
| 7 | 事件委托版本-tab栏切换上 | 08:37 |
| 8 | 事件委托版本-tab栏切换下 | 07:06 |
小结
- 绑定时机的问题优先于绑定写法的问题:能放 body 末尾就别依赖
load,DOMContentLoaded解决 DOM 就绪,load只留给"必须等资源"的场景。 - 冒泡是默认行为,不是异常:先默认接受它,只有父子逻辑确实要断开时才
stopPropagation,滥用会连带打死委托和"点空白关闭弹层"这类全局监听。 - 捕获是知识,冒泡是工具:第三个参数
true知道即可,业务代码基本不写;但要知道"为什么点子元素会触发父元素"这个问题的答案在冒泡。 - 选事件先看它冒不冒泡:要委托就选
click / mouseover / input这类冒泡事件,mouseenter / mouseleave天生不适合委托,这是选型而非技巧问题。 - 委托三件套固定套路:父元素上绑一次 →
e.target定位真正触发者 →tagName / matches / closest做过滤守卫,缺了第三步代码就不严谨。 - 委托里
this和e.target是两个东西,前者是绑定的父元素、后者是点到的元素,改造旧代码时把this换成e.target是最容易漏的一步。 - 尺寸 API 按三问选:含不含 border(
offsetvsclient)、含不含滚动条、是元素还是视口(innerWidth);滚动类问题永远先确认"谁在滚"。 data-*+dataset是 DOM 与 JS 之间的数据通道,比靠索引、靠文本内容、靠位置关系取数据都稳,改造循环版逻辑时它是第一选择。