Skip to content

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 看成节点树,元素之间通过父子、兄弟关系连接。

DOM节点树示意图

易错点:不要把所有节点都当成元素节点。children、previousElementSibling 这类只取元素;若用 childNodes、previousSibling 可能拿到文本节点或空白文本。

查找节点:父节点 ​

为什么需要:点击关闭按钮时,往往要隐藏它所在的弹层。按钮是子,弹层是父,需要由子找父。

怎么想:子元素找父元素,用 parentNode;它返回的是对象,可以直接改样式。

怎么做:

JavaScript
元素.parentNode
html
<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
父元素.children
html
<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 树上,后续再按页面结构查找就找不到它。

课件图示 ​

课件第5页:1. 日期对象 Date l 日期对象:用来表示日期和时间的对象 l 作用:可以

课件第6页:1.1 实例化 l 日期对象使用必须先实例化:创建一个日期对象并获取时间 l 在

课件第7页:2.2 日期对象方法 - 格式化日期对象 使用场景:因为日期对象返回的数据我们不

课件第8页:总结 总结 1. 实例化日期对象怎么写? Ø new Date() 2. 日期对

课件第10页:2.2 日期对象方法 格式化日期对象另外一种方法 方法 作用 说明 toLoca

代码示例 ​

示例一:阻止默认行为(表单提交 / 链接跳转) ​

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

小结 ​

  1. "阻止默认行为"和"阻止冒泡"是两套机制:前者靠 e.preventDefault(),后者靠 e.stopPropagation();return 两者都做不到。
  2. 解绑的关键是"同一个引用":L2 靠 removeEventListener 传同一个具名函数,L0 靠新值覆盖旧值,匿名函数在 L2 下永远解不掉。
  3. scrollTop 可读写、offsetTop 只读,这是"能不能用 JS 控制滚动位置"的分水岭:返回顶部靠写 scrollTop = 0,测量位置只能读 offsetTop。
  4. clientWidth 与 offsetWidth 只差一条边框,取尺寸前先问自己"要不要把 border 算进去",答案决定用哪一组。
  5. offsetTop 的参照物是最近的定位父级,不是页面;数字"看起来不对"时,先查父级 position,再决定是否换用 getBoundingClientRect。
  6. 滚动、尺寸这类持续触发的事件,取值必须写在回调里,写在回调外的值只有第一次是新鲜的。
  7. 脚本位置比加载事件更根本:能放 </body> 前就放;需要提前执行时,只等 DOM 用 DOMContentLoaded,等图片外链用 load,后者一定更慢。
  8. 日期计算优先走时间戳、构造优先走数值参数:字符串格式的解析规则各浏览器不一致,是隐性兼容坑。