主题
2-10client、scroll、offset
本页对应 第二章WebAPIs,共 8 段视频,约 1 小时 21 分。
学习目标
- 能使用事件对象
preventDefault()阻止表单默认提交和链接默认跳转,并说明return无法阻止默认行为。 - 能使用
removeEventListener移除具名回调的 L2 事件监听,能通过覆盖onclick移除 L0 事件监听,并说明匿名函数无法直接解绑。 - 能使用
load和DOMContentLoaded页面加载事件,并区分二者触发时机及绑定对象。 - 能使用
scroll事件与document.documentElement.scrollTop获取页面滚动距离,完成侧边栏显示隐藏和返回顶部案例,并使用scroll-behavior: smooth实现平滑滚动。 - 能使用
resize事件及clientWidth、clientHeight获取视口/元素尺寸,并能说明 flexible 源码中获取根元素、按屏幕宽度分份及修改根字号的核心逻辑。 - 能使用
offsetWidth、offsetHeight、offsetLeft、offsetTop获取元素尺寸和位置,能区分client与offset是否包含边框,并说明定位父级对offsetLeft/offsetTop的影响。
知识点
日期对象是什么
为什么需要:开发中经常要显示当前时间、生成指定日期、做倒计时。如果只用字符串拼日期,很难计算和格式化,所以需要 JavaScript 内置的 Date 对象来表示日期和时间。
怎么想:先明确 Date 是内置对象,不是普通数字或字符串。它可以表示当前系统日期和时间,也可以表示指定日期和时间。要使用它,必须先“实例化”,也就是创建一个日期对象。
怎么做:在代码中看到 new 关键字时,一般把这个操作称为实例化。下面保留本节示例代码:
html
<body>
<script>
// 1. 实例化日期对象
// 1.1 得到当前的日期和时间
const date = new Date()
console.log(date)
// 1.2 得到指定的日期和时间
const date1 = new Date('2099-12-12 08:08:08')
console.log(date1)
</script>
</body>易错点:Date 首字母要大写;不要漏写 new。调试时不要只看代码猜结果,像课堂演示那样保存后在控制台逐条打印,先确认 date 和 date1 的真实输出,再继续格式化。
格式化日期对象
为什么需要:直接打印日期对象,往往不是开发中常见的“年月日时分秒”格式。页面展示、拼接文案时,需要单独取出年、月、日、时、分、秒。
怎么想:Date 提供一组 get 方法。先取数字,再按需要拼接。这里最关键的是月份:getMonth() 取值为 0 ~ 11,所以要加 1 才是日常月份。
| 方法 | 作用 | 说明 |
|---|---|---|
| getFullYear() | 获得年份 | 获取四位年份 |
| getMonth() | 获得月份 | 取值为 0 ~ 11 |
| getDate() | 获取月份中的每一天 | 不同月份取值也不相同 |
| getDay() | 获取星期 | 取值为 0 ~ 6 |
| getHours() | 获取小时 | 取值为 0 ~ 23 |
| getMinutes() | 获取分钟 | 取值为 0 ~ 59 |
| getSeconds() | 获取秒 | 取值为 0 ~ 59 |
怎么做:
html
<body>
<script>
// 2. 格式化日期对象-转换成开发中常见的日期和时间格式
const myDate = new Date()
console.log(myDate.getFullYear()) // 得到年份 数字型的 2023
console.log(myDate.getMonth() + 1) // 得到月份 数字型的 0 需要加1
console.log(myDate.getDate()) // 得到几号 数字型的 27
console.log(myDate.getDay()) // 得到星期 数字型 5
console.log(myDate.getHours()) // 得到小时
console.log(myDate.getMinutes()) // 得到分钟
console.log(myDate.getSeconds()) // 得到秒数
console.log(`现在的日期是:\${myDate.getFullYear()}年\${myDate.getMonth() + 1}月\${myDate.getDate()}日`)
</script>
</body>易错点:getMonth() 忘记加 1 是最常见错误;getDay() 得到的是星期数字,不是“星期几”文字;这些方法返回的是数字型结果,拼接文案时要注意单位和连接符。
toLocaleString 与定时器
为什么需要:手动拼接年月日时分秒适合定制格式,但有时只需要一个现成的日期时间字符串,用 toLocaleString() 等方法更直接。页面时钟还需要定时刷新。
怎么想:先验证三个方法各自返回哪部分内容,再封装成函数。定时器每秒调用一次函数,把最新时间写入页面。代码注释里特别提醒:先写一次渲染,防止盒子有 1 秒空白期。
怎么做:
html
<body>
<div class="box"></div>
<script>
const date = new Date()
console.log(date.toLocaleString()) // 2023/1/27 23:19:20
console.log(date.toLocaleDateString()) // 2023/1/27
console.log(date.toLocaleTimeString()) // 23:19:20
// 1. 封装一个 getDateTime函数,里面格式化时间对象
function getDateTime() {
const date = new Date()
return date.toLocaleString()
}
// 注意:先写上这句话,防止盒子有1秒的空白期
document.querySelector('.box').innerText = getDateTime()
// 3. 显示到box盒子里面,并且添加定时器
setInterval(function () {
document.querySelector('.box').innerText = getDateTime()
}, 1000)
</script>
</body>易错点:new Date() 要放在函数内部,才能每次取得最新时间;setInterval 的第二个参数单位是毫秒,1000 表示 1 秒;调试时可在控制台观察输出是否符合当前环境格式。
时间戳
为什么需要:做倒计时不能直接拿“日期字符串”相减,而要用时间戳计算剩余毫秒数。时间戳是指 1970 年 01 月 01 日 00 时 00 分 00 秒起至现在的总毫秒数,是数字型。
怎么想:将来时间戳减去现在时间戳,得到剩余时间毫秒数,再把毫秒数换算成年月日时分秒,就是倒计时时间。
怎么做:
javascript
// 1. 实例化
const date = new Date()
// 2. 获取时间戳
console.log(date.getTime())
// 还有一种获取时间戳的方法
console.log(+new Date())
// 还有一种获取时间戳的方法
console.log(Date.now())易错点:获取时间戳的方法分别为 getTime、Date.now 和 +new Date();注意时间戳单位是毫秒,不是秒。调试时同样建议在控制台逐条打印,确认三种写法得到的结果类型和数值。
DOM 树与节点类型
为什么需要:DOM 把 HTML 文档表现成树状结构,我们才能用 JavaScript 按“父子兄弟”关系定位并操作页面。课堂调试中,script 放在 head 里直接获取下面按钮会报错,本质就是节点还没加载出来,查找结果为空。
怎么想:先认清树上的“点”都是节点:元素节点、属性节点、文本节点、注释节点等。实际开发重点抓元素节点,因为标签才是我们最常操作的对象;html 是根节点。
怎么做:把 HTML 看成节点树,元素之间通过父子、兄弟关系连接。

易错点:不要把所有节点都当成元素节点。children、previousElementSibling 这类只取元素;若用 childNodes、previousSibling 可能拿到文本节点或空白文本。
查找节点:父节点
为什么需要:点击关闭按钮时,往往要隐藏它所在的弹层。按钮是子,弹层是父,需要由子找父。
怎么想:子元素找父元素,用 parentNode;它返回的是对象,可以直接改样式。
怎么做:
JavaScript
元素.parentNodehtml
<body>
<div class="pop">
<a href="javascript:;" class="close"></a>
</div>
<script>
// 点击关闭按钮可以关闭父盒子
const closeBtn = document.querySelector('.close')
// 利用孩子选取父节点,返回的也是一个元素对象
console.log(closeBtn.parentNode)
closeBtn.addEventListener('click', function () {
this.parentNode.style.display = 'none'
})
</script>
</body>易错点:先 console.log 验证 parentNode 是否拿到预期对象;若查找结果为 null,再访问 .style 就会报错,通常是节点尚未加载或选择器写错。
查找节点:子节点与兄弟节点
为什么需要:有时已知父元素,要批量取孩子;有时已知第二个 li,要取它上一个或下一个兄弟。
怎么想:父找子用 children,得到伪数组;兄弟用 previousElementSibling 和 nextElementSibling。课堂演示时先打印对象,确认返回结构,再用下标取具体孩子。
怎么做:
javascript
父元素.childrenhtml
<body>
`<ul>`
<li>我是第1个孩子</li>
<li>我是第2个孩子</li>
<li>我是第3个孩子</li>
<li>我是第4个孩子</li>
</ul>
<script>
// 1. 查询子节点
const ul = document.querySelector('ul')
console.log(ul.children)
// 2. 查询兄弟节点
const li2 = document.querySelector('ul li:nth-child(2)')
// console.log(li2)
console.log(li2.previousElementSibling) // 上一个兄弟
console.log(li2.nextElementSibling) // 下一个兄弟
console.log(ul.children[0]) // 第一个孩子
console.log(ul.children[2]) // 第三个孩子
</script>
</body>html
<body>
`<ul>`
<li>我是第1个孩子</li>
<li>我是第2个孩子</li>
<li>我是第3个孩子</li>
<li>我是第4个孩子</li>
</ul>
<script>
// 2. 查询兄弟节点
const li2 = document.querySelector('ul li:nth-child(2)')
// console.log(li2)
console.log(li2.previousElementSibling) // 上一个兄弟
console.log(li2.nextElementSibling) // 下一个兄弟
console.log(ul.children[0]) // 第一个孩子
console.log(ul.children[2]) // 第三个孩子
</script>
</body>易错点:children 是动态集合,按下标取;兄弟查找注意“元素兄弟”和“所有兄弟”的区别。课堂调试时若打印出空白文本,说明用错了 previousSibling 一类属性。
增加节点
为什么需要:发布信息、追加列表项时,需要动态创建并插入节点。
怎么想:分两步:先 createElement 创建新节点,再把它放入父元素内部。放最后用 append,放最前用 prepend。
怎么做:
javascript
element.append()javascript
element.prepend()html
<body>
<ul>
<li>我是小li</li>
</ul>
<script>
// 1. 创建节点
const li = document.createElement('li')
li.innerHTML = '我是放到后面的'
console.log(li)
// 2. 追加给父元素
const ul = document.querySelector('ul')
// 2.1 append 放到ul 的最后面 类似css的 after伪元素
ul.append(li)
// 2.2 prepend放到 ul 的最前面 类似css的 before伪元素
const firstli = document.createElement('li')
firstli.innerHTML = '我是放到前面的'
ul.prepend(firstli)
</script>
</body>易错点:append/prepend 接收的是节点对象,不是随便的字符串;创建后先打印验证。插入前要确保父元素已查到。
删除节点
为什么需要:页面中不再需要的节点应移出 DOM 树,而不是只藏起来。
怎么想:remove() 直接把对象从所属 DOM 树删除;display:none 只是隐藏,节点仍在树中。课堂演示中两者效果肉眼相似,但 DOM 结构不同。
怎么做:
JavaScript
element.remove()html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>删除节点</title>
</head>
<body>
<div class="remove">我要删除</div>
<div class="none">我要隐藏</div>
<script>
// 1. 删除节点, remove 会从dom树中删除这个元素
const remove = document.querySelector('.remove')
remove.remove()
// 2. display:none 隐藏元素,页面看不见,但是dom树中还存在这个标签
const none = document.querySelector('.none')
none.style.display = 'none'
</script>
</body>
</html>易错点:删除和隐藏要按需求选;删除后该节点对象不再挂在 DOM 树上,后续再按页面结构查找就找不到它。
课件图示





代码示例
示例一:阻止默认行为(表单提交 / 链接跳转)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>阻止默认行为</title>
</head>
<body>
<form action="http://www.itcast.cn">
<input type="text" name="username">
<button>提交</button>
</form>
<a href="http://www.baidu.com">点击跳转</a>
<script>
// 1. 表单:没填内容不允许提交
const form = document.querySelector('form')
const input = document.querySelector('[name=username]')
form.addEventListener('submit', function (e) {
// return 只能结束函数,不能阻止提交,必须用 e.preventDefault()
if (input.value === '') {
e.preventDefault()
}
})
// 2. 链接:点击不跳转
const a = document.querySelector('a')
a.addEventListener('click', function (e) {
e.preventDefault()
})
</script>
</body>
</html>关键行与易错点
- 阻止默认行为固定写法:
事件对象.preventDefault(),事件对象就是回调函数的形参e,没有形参就拿不到这个方法。 - 表单提交用
submit事件,不是click;return只是结束回调函数,不能阻止提交或跳转(转写稿中专门演示过:写了return后表单照样提交)。 - 阻止后地址栏不会再出现
?username=,链接点击也无效,这就是“默认行为被拦住了”。
示例二:页面加载事件 + 移除事件监听
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面加载事件与移除事件监听</title>
<script>
// 1. load:等所有外部资源(图片、外链 CSS/JS)加载完毕才触发,加在 window 上
window.addEventListener('load', function () {
console.log('load:所有资源加载完毕')
})
// 2. DOMContentLoaded:HTML 文档(DOM)加载完就触发,加在 document 上,更快
document.addEventListener('DOMContentLoaded', function () {
console.log('DOMContentLoaded:HTML 文档加载完毕')
})
</script>
</head>
<body>
<button class="l2">L2 只能点一次</button>
<button class="l0">L0 只能点一次</button>
<script>
// L2 版本:removeEventListener 移除,必须是具名函数
const btnL2 = document.querySelector('.l2')
function fn() {
alert('我点击了')
btnL2.removeEventListener('click', fn)
}
btnL2.addEventListener('click', fn)
// L0 版本:on 方式,直接赋 null 覆盖掉原函数即可解绑
const btnL0 = document.querySelector('.l0')
btnL0.onclick = function () {
alert('我点击了')
btnL0.onclick = null
}
</script>
</body>
</html>关键行与易错点
load加在window上,DOMContentLoaded加在document上;两个都写时DOMContentLoaded先执行,因为它不等图片和外链 CSS。- 控制台应先打印
DOMContentLoaded再打印load,顺序反了说明写错了对象。 removeEventListener('click', fn)的第二个参数必须是具名函数名,且只写名字不加小括号调用;写匿名函数永远移除不掉。- L0 的移除不是调方法,而是给
onclick重新赋值null,把原来的函数覆盖掉。
示例三:页面滚动事件——侧边栏显示隐藏 + 返回顶部
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面滚动事件</title>
<style>
/* 丝滑滚动:让 html 标签滚动时平滑过渡 */
html {
scroll-behavior: smooth;
}
body {
height: 3000px; /* 撑出滚动条 */
margin: 0;
}
.box {
margin: 100px;
width: 300px;
height: 200px;
background: pink;
}
.elevator {
position: fixed;
right: 20px;
top: 200px;
width: 100px;
height: 200px;
background: #409eff;
color: #fff;
opacity: 0; /* 默认隐藏 */
transition: all .3s; /* 淡入淡出 */
}
.backTop {
position: fixed;
right: 20px;
bottom: 50px;
}
</style>
</head>
<body>
<div class="box">小兔鲜盒子</div>
<div class="elevator">电梯导航</div>
<a href="javascript:;" class="backTop">顶部</a>
<script>
const elevator = document.querySelector('.elevator')
const box = document.querySelector('.box')
const backTop = document.querySelector('.backTop')
// 1. 页面滚动事件,加在 window 上
window.addEventListener('scroll', function () {
// 2. 取页面被卷去的头部,必须写在回调里,才能每次滚动拿到最新值
const n = document.documentElement.scrollTop
// 3. 三元表达式:滚到盒子位置就显示,否则隐藏
elevator.style.opacity = n >= box.offsetTop ? 1 : 0
})
// 4. 返回顶部:scrollTop 可读写,赋 0 即回到顶部
backTop.addEventListener('click', function () {
document.documentElement.scrollTop = 0
})
</script>
</body>
</html>关键行与易错点
- 页面滚动的“主角”是
html标签,取它要用document.documentElement,不是document.html,也不是document。 scrollTop拿到的滚动距离是数字、不带单位,所以可直接和box.offsetTop比较,不用parseInt。scrollTop是可读写的:读是获取滚动距离,写(赋 0)就是回到顶部。const n = ...这一句必须放在 scroll 回调函数内部,放到外面只会取到初始值,滚动时不会更新。- 原来写死的
300换成box.offsetTop更严谨;注意offsetTop以有定位的父级为准,父级没定位才以浏览器文档为准。 offsetTop/offsetHeight是只读的,且offsetHeight含边框,clientHeight不含边框,别混用。html { scroll-behavior: smooth; }只是让滚动变丝滑,不影响 JS 逻辑;不加这行点击“顶部”会瞬间跳回。
常见错误
1. 滚动距离写在 scroll 回调外面
- 现象:侧边栏只在页面刚加载时判断一次,之后滚动再无反应;或显示后不再隐藏。
- 原因:
document.documentElement.scrollTop只在页面初始化时读取了一次,之后是个不再更新的固定值。 - 修法:把读取与判断都放进
window.addEventListener('scroll', function () { ... })的回调内部,每次滚动重新取值。
2. 用 return 阻止表单提交或链接跳转
- 现象:表单里明明写了
if (!input.value) return,点击提交后地址栏仍出现?username=;链接照样跳转。 - 原因:
return只能结束当前回调函数的执行,无法取消元素自身的默认行为。 - 修法:在回调形参里接住事件对象,调用
e.preventDefault()。
3. 用 removeEventListener 解绑匿名函数
- 现象:只想让按钮生效一次,点击后事件依旧触发。
- 原因:移除时传入的匿名函数与注册时传入的不是同一个引用,浏览器认为这是两个不同的处理函数。
- 修法:把回调提取为具名函数,注册和移除都传该函数名(不加括号);L0 写法则直接
el.onclick = null覆盖。
4. getMonth() 忘记 +1、getDay() 直接当星期文字用
- 现象:12 月显示成 0,或 1 月显示成 0 月;星期显示成数字 5 而不是"星期五"。
- 原因:
getMonth()返回 0~11,getDay()返回 0~6。 - 修法:月份
+1后再拼接;星期用一个['日','一','二','三','四','五','六']数组按下标取值。
5. 用 document.html 或 document.body 拿 HTML 元素
- 现象:
Cannot read properties of undefined,或body.scrollTop一直是 0。 - 原因:HTML 根元素有固定写法,且页面滚动是由
html元素承担的,不是body。 - 修法:统一写
document.documentElement;document.body只用来拿 body 标签本身。
6. 拿到 offsetTop 与"距页面顶部"对不上
- 现象:本想得到 185,结果得到 5。
- 原因:
offsetTop以最近的有定位的父级为参照,父级加了position: relative后基准就变了。 - 修法:先检查父级
position;需要绝对距页面顶部的距离时,改用getBoundingClientRect().top + scrollTop,或沿offsetParent逐级累加。
7. 在 head 里直接 querySelector
- 现象:脚本报
null,按钮点击事件注册失败。 - 原因:代码自上而下执行,DOM 还没解析到目标元素。
- 修法:优先把
script放到</body>之前;必须放 head 时用DOMContentLoaded包裹,或需要等图片/外链资源时用load。
8. new Date('2099-12-12 08:08:08') 在部分浏览器得到 Invalid Date
- 现象:Chrome 正常,Safari 里所有取值都是
NaN。 - 原因:带空格、带横杠的日期字符串不属于规范强制支持的格式,各引擎解析规则不同。
- 修法:改成
'2099/12/12 08:08:08',或用new Date(2099, 11, 12, 8, 8, 8)传数值参数。
练习
练习 1|倒计时 用时间戳计算距离 2099-01-01 00:00:00 的剩余时间,页面显示"还剩 X 天 X 时 X 分 X 秒",每秒刷新。要求:差值除以 1000 后,天/时/分/秒各自用取余和整除换算;结束后清除定时器。思考并在注释里写明:为什么剩余秒数要用 Math.floor 而不是直接相除。
练习 2|待办清单(增删节点) 一个输入框 + 一个"发布"按钮,点击后把输入内容作为新的 li 追加到 ul 末尾,输入为空时不允许添加。每个 li 右侧有一个删除按钮,点击后把自己这一条从 DOM 中移除。要求至少使用 createElement、append、remove、parentNode(或 e.target)各一次。
练习 3|把脚本挪到 head 把练习 2 的 <script> 整体移到 <head> 中,先观察报错,再用 DOMContentLoaded 修好;然后再换成 load,在控制台打印两个事件的触发顺序,用一句话解释你看到的现象。
练习 4|电梯导航升级 把课上"滚动超过 300px 显示侧边栏"的固定 300,改为某个盒子的 offsetTop,使判断随布局变化自动生效;再给返回顶部按钮加上丝滑滚动。要求:在注释中标出"读取滚动距离"那一行必须在回调内部的原因。
练习 5|响应式背景色 监听窗口尺寸变化,用 document.documentElement.clientWidth 判断:小于 768 背景红、768~1200 黄、大于 1200 绿。完成后把判断条件里的 clientWidth 换成 offsetWidth,观察数值差异并说明差在哪里。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 阻止默认行为 | 06:57 |
| 2 | 拓展-移除事件监听(了解) | 10:43 |
| 3 | 页面加载事件 | 09:18 |
| 4 | 页面滚动事件 | 12:24 |
| 5 | 显示隐藏侧边栏案例 | 10:51 |
| 6 | 点击返回按钮返回顶部案例 | 06:02 |
| 7 | 页面尺寸事件和flexible源码分析 | 11:52 |
| 8 | 元素尺寸和位置-offset家族 | 13:44 |
小结
- "阻止默认行为"和"阻止冒泡"是两套机制:前者靠
e.preventDefault(),后者靠e.stopPropagation();return两者都做不到。 - 解绑的关键是"同一个引用":L2 靠
removeEventListener传同一个具名函数,L0 靠新值覆盖旧值,匿名函数在 L2 下永远解不掉。 scrollTop可读写、offsetTop只读,这是"能不能用 JS 控制滚动位置"的分水岭:返回顶部靠写scrollTop = 0,测量位置只能读offsetTop。clientWidth与offsetWidth只差一条边框,取尺寸前先问自己"要不要把 border 算进去",答案决定用哪一组。offsetTop的参照物是最近的定位父级,不是页面;数字"看起来不对"时,先查父级position,再决定是否换用getBoundingClientRect。- 滚动、尺寸这类持续触发的事件,取值必须写在回调里,写在回调外的值只有第一次是新鲜的。
- 脚本位置比加载事件更根本:能放
</body>前就放;需要提前执行时,只等 DOM 用DOMContentLoaded,等图片外链用load,后者一定更慢。 - 日期计算优先走时间戳、构造优先走数值参数:字符串格式的解析规则各浏览器不一致,是隐性兼容坑。