Skip to content

2-11综合案例-顶部导航案例 ​

本页对应 第二章WebAPIs,共 6 段视频,约 57 分钟。

学习目标 ​

  1. 能使用页面滚动事件、offsetTop 与 classList 实现吸附顶部导航栏的显示与隐藏,并验证滚动到指定位置时导航栏滑入、离开时隐藏。
  2. 能解释并实现“仿 Bilibili 滑动效果”:通过事件委托获取被点击链接的 offsetLeft,使用 transform: translateX() 移动滑块,并添加过渡动画。
  3. 能使用排他思想完成电梯导航点击高亮效果,并通过判断是否已存在 active 元素,结合逻辑与中断避免首次移除报错。
  4. 能实现点击电梯导航小模块后页面滑动到对应大模块位置,并使用 scrollTop = offsetTop - offsetHeight 避免固定导航栏遮挡标题。
  5. 能实现页面滚动时电梯导航小模块自动高亮:遍历大模块判断滚动位置,先清除旧高亮,再为对应小模块添加 active。
  6. 能调试电梯导航综合案例中的常见问题,包括首次无高亮元素报错、固定导航遮挡导致判断不准确、滚动回顶部后高亮未取消。

知识点 ​

DOM 树与节点类型 ​

为什么需要:HTML 写出来是嵌套结构,浏览器要能按层级找到、修改、增加、删除页面内容,就必须把文档解析成一棵可操作的对象树。DOM 把 HTML 文档以树状结构表现出来,称为 DOM 树或节点树。节点是树中的单个点,文档本身、元素、文本、注释都属于节点。重点掌握元素节点:所有标签如 body、div,其中 html 是根节点;属性节点如 href;文本节点如标签中的文字。

DOM树节点示意图

怎么想:先建立“树”的空间感:父节点在上,子节点在下,兄弟节点同级。查找、增加、删除都围绕“节点对象”进行。怎么做:用 document.querySelector() 先定位元素,再通过节点关系属性扩展。易错点:节点不等于标签字符串,查找到的结果是对象;文本节点、注释节点也会存在于节点树中,但日常布局操作多以元素节点为主。

查找父节点 ​

为什么需要:有时拿到的是子元素,却要控制它外面的父盒子,例如点击关闭按钮隐藏整个弹层。怎么想:子元素对象上有 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>

易错点:parentNode 可能取到文档等非元素节点;若只想取元素父节点,要理解节点类型。转写稿中“西服”应纠正为“吸附”,“打号栏”应纠正为“导航栏”,这类案例本质仍是先查找元素节点,再控制其类名或样式。

查找子节点与兄弟节点 ​

为什么需要:列表、导航、卡片组中经常要批量取子元素,或从当前项找前后兄弟。怎么想:父元素用 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.previousElementSibling) // 上一个兄弟
    console.log(li2.nextElementSibling)  // 下一个兄弟
    console.log(ul.children[0]) // 第一个孩子
    console.log(ul.children[2]) // 第三个孩子
  </script>
</body>

易错点:children 是集合,不是数组,不能直接 forEach 时要注意兼容或转换;previousSibling 可能拿到文本节点,找元素兄弟优先用 previousElementSibling。转写稿里的电梯导航用“小模块控制大模块”,推导时也是先按索引找到对应元素节点,再操作类名;其中“受/收/瘦”应结合语境理解为控制显示类名,不能与删除节点混淆。

增加节点 ​

为什么需要:发布信息、追加列表项时,需要在页面中动态增加元素。怎么想:先创建新节点,再把它放入指定父元素内部;放最后用 append,放最前用 prepend。怎么做:

javascript
element.append()
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 插入的是节点对象,不是 HTML 字符串;同一个节点重复插入会移动位置。转写稿中调试“初次没有 active 类直接移除会报错”时,用 old && old.classList.remove('active') 利用逻辑与中断避免误删,这提示我们:操作节点前先确认查找结果是否存在。

删除节点 ​

为什么需要:元素不再需要时,要从 DOM 树中移除。怎么想:删除与隐藏不同,隐藏只是看不见,节点还在;删除是真正从 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>

易错点:element.remove() 删除节点,classList.remove() 只移除类名,二者不要混用;删除前要确保元素已查到。转写稿的调试过程说明:先 querySelector 判断有没有目标,再执行移除类名或样式控制,能减少“空对象报错”。

为什么移动端要单独认识 touch 事件 ​

为什么需要:移动端没有鼠标,用户主要用手指或触控笔操作屏幕。桌面端常用的 click 虽然也能用,但它和“手指按下、滑动、抬起”不是一回事。移动端要做出跟手的效果,就要直接监听触屏事件。

怎么想:先把“触摸”拆成三个动作:按下、移动、抬起。对应就是 touchstart、touchmove、touchend。Android 和 iOS 都支持 touch 事件。

怎么做:先不要急着写复杂逻辑,只绑定事件并打印,确认事件真的触发。

易错点:不要用鼠标事件的经验硬套 touch;touchmove 不是只触发一次,而是滑动过程中持续触发。

三个基础触屏事件 ​

笔记里的常见触屏事件表如下:

常见触屏事件

touch 对象代表一个触摸点。触摸点可能是一根手指,也可能是一根触摸笔。触屏事件可响应用户手指(或触控笔)对屏幕或者触控板操作。

为什么需要:只有先认清事件顺序,后面做滑动、拖拽、移动端轮播才有基础。

怎么想:touchstart 是“开始摸”,touchmove 是“一直摸”,touchend 是“摸完了”。先验证这三个阶段是否按顺序出现。

怎么做:保留笔记中的最小案例:

html
<body>
  <div class="box"></div>
  <script>
    // 触摸事件
    const box = document.querySelector('.box')
    // 1. 手指触屏开始事件 touchstart
    box.addEventListener('touchstart', function () {
      console.log('我开始摸了')
    })
    // 2. 手指触屏滑动事件 touchmove
    box.addEventListener('touchmove', function () {
      console.log('我一直摸')
    })

    // 3. 手指触屏结束事件  touchend
    box.addEventListener('touchend', function () {
      console.log('我摸完了')
    })
  </script>
</body>

易错点:如果 .box 没有宽高或被别的元素盖住,手指可能根本摸不到它。先给 .box 一个可见面积,再测试事件。

touch 对象与触摸点 ​

为什么需要:移动端可能多指操作,也可能用触控笔。事件对象里需要知道“当前是哪个触摸点”。

怎么想:touch 对象代表一个触摸点,一根手指或一根触控笔就是一个触摸点。多指时会有多个触摸点。初学阶段先建立“事件对象里能拿到触摸点信息”的意识,常见入口是 event.touches。

怎么做:可以在回调里先打印事件对象,观察触摸点集合是否存在,再决定后续取坐标。

易错点:不要把“触摸点数量”和“事件触发次数”混为一谈;也不要在还没验证事件触发时,就直接写复杂坐标计算。

最小案例的调试过程 ​

为什么需要:touch 事件在桌面浏览器里直接用鼠标点,通常不会触发;很多同学以为代码写错,其实是测试方式不对。

怎么想:先确认环境,再确认元素,再确认事件。调试顺序应是:打开移动端模拟或真机 → 找到 .box → 手指按下、滑动、抬起 → 看控制台输出。

怎么做:在 Chrome 开发者工具中切换到移动设备模拟,或使用真机调试。按下时看是否输出“我开始摸了”;滑动时看是否连续输出“我一直摸”;抬起时看是否输出“我摸完了”。

易错点:touchmove 会高频触发,控制台刷屏是正常现象;不要因为输出很多就以为代码坏了。真正做业务时,也不要在 touchmove 里做大量 DOM 操作,否则容易卡顿。

从转写稿借来的调试习惯与同音字校正 ​

需要说明:转写稿主要记录的是后面的滚动、吸附导航和电梯导航案例,并不是 touch 事件本身。因此这里只借用它的调试习惯,不把滚动案例硬套到 touch 上。

转写稿里反复出现“先打印一下看看结果”“先看控制台有没有报错”“先验证再改逻辑”。这个习惯同样适用于 touch:先打印三个事件有没有触发,再写滑动逻辑。转写稿中的同音字也要按上下文校正,例如“西服顶部导航栏”应理解为“吸附顶部导航栏”,“打号栏”应理解为“导航栏”,“受/收”要结合代码理解为显示类名,“N”指被卷去的头部/滚动距离。做 touch 调试时也一样:先根据控制台和实际现象校正判断,不要凭感觉猜。

易错点清单 ​

  • 桌面鼠标直接点通常不触发 touch,要用移动端模拟或真机。
  • .box 没宽高、被遮挡,会导致摸不到。
  • touchmove 是连续触发,不是一次触发。
  • 先打印验证,再写业务;不要一上来就阻止默认滚动。
  • touch 对象代表触摸点,可能来自手指,也可能来自触控笔;多指时要意识到有多个触摸点。

插件:先会用,再理解 ​

插件就是别人已经写好、经过一定验证的代码。我们把它引入项目,按文档完成配置,就能较快实现轮播、手势、弹层等效果。
为什么需要?因为实际开发不可能所有功能都从零手写,插件能节省时间,也能借鉴成熟实现。
怎么想?不要一上来就改插件源码,先判断“它解决什么问题、我的需求是否匹配”。
怎么做?先看官网和示例,再复制最小可用代码跑通。
易错点:不要只看博客片段,版本、引入顺序、初始化时机不对,都会导致插件不生效。

学习插件的四步法 ​

笔记给出的思路很实用:

  1. 看官网,了解这个插件可以完成什么需求:https://www.swiper.com.cn/
  2. 查看基本使用流程:https://www.swiper.com.cn/usage/index.html
  3. 写个小 demo,对照在线演示找到符合自己需求的例子:https://www.swiper.com.cn/demo/index.html
  4. 再应用到开发中。

为什么需要这四步?因为插件的“配置项很多”,但项目往往只用其中一小部分。先跑通最小 demo,能快速排除路径、结构、初始化顺序等问题。易错点是把官网示例整段复制进项目却不改选择器和结构,最后误以为插件有 bug。

AlloyFinger:轻量级手势插件 ​

AlloyFinger 是腾讯 AlloyTeam 开源的超轻量级 Web 手势插件,可以为元素注册各种手势事件。
GitHub 地址:https://github.com/AlloyTeam/AlloyFinger
使用步骤:

  1. 下载 js 库:http://alloyteam.github.io/AlloyFinger/alloy_finger.js
  2. 将 AlloyFinger 库引入当前文件:<script src="alloy_finger.js"></script>
    或使用在线地址:<script src="https://unpkg.com/alloyfinger@0.1.16/alloy_finger.js"></script>
  3. 配置:
javascript
new AlloyFinger(element, {  // element 是给哪个元素做滑动事件
  swipe: function (e) {
    // 滑动的时候要做的事情 e.direction 可以判断上下左右滑动 Left Right 等
  }
})

为什么需要?移动端手势判断自己写会涉及 touchstart、touchmove、touchend 和方向计算,插件把这些封装好了。
怎么想?先确定给哪个元素绑定,再在回调里根据 e.direction 做业务。
易错点:element 必须先获取到;脚本要先引入再 new;在线地址要能访问;不要把 swipe 写成普通点击事件。

用吸顶导航理解“位置判断” ​

转写稿里的“西服顶部导航栏”应理解为“吸附/吸顶导航栏”。核心思路是:监听页面滚动,比较页面被卷去的头部距离和头部盒子的 offsetTop。调试时先打印头部盒子的 offsetTop,转写稿中得到约 53,于是判断 scrollTop >= 53 时给顶部导航盒子添加显示类,否则移除。
为什么需要?因为吸顶效果本质是“滚动到某个位置后显示/隐藏”。
怎么想?先找参照盒子,再比较 document.documentElement.scrollTop 与 盒子.offsetTop。
怎么做?滚动事件里做判断,满足条件添加类,不满足移除类。
易错点:过渡效果放在显示类上更自然;移除类后直接隐藏,不会出现“慢慢滑回去”的别扭效果。

用仿 B 站滑动条理解“点击后移动” ​

转写稿中的红色滑块必须是一个单独盒子,不能做成下划线。点击哪个链接,就取该链接的 offsetLeft,再设置滑块 transform: translateX(值)。调试时打印各链接的 offsetLeft,得到 0、60、120 等值,说明距离可直接用于移动。
为什么需要?因为滑块要跟着当前选中项移动。
怎么想?点击事件委托给父元素,判断 e.target.tagName === 'A',再取当前 A 的 offsetLeft。
怎么做?line.style.transform = 'translateX(' + e.target.offsetLeft + 'px)',并给滑块加 transition。
易错点:px 不能省;事件委托要判断点击的是不是 A;滑块要单独获取。

用电梯导航理解“排他 + 逻辑与中断” ​

电梯导航有两块业务:点击小模块高亮并滚动到大模块;页面滚动大模块时反向控制小模块高亮。点击高亮用排他:先移除旧 active,再给当前项添加。转写稿调试时出现“不能设置属性,因为它是空的”,定位到 remove('active'):页面初始没有任何 .small.active,直接移除会报错。解决方法是先查询,再利用逻辑与中断:

javascript
const old = document.querySelector('.small.active');
old && old.classList.remove('active');

页面滚动控制高亮时,遍历所有大模块,判断 scrollTop >= big.offsetTop - sticky.offsetHeight,先移除旧高亮,再给对应小模块添加。为什么要减 sticky.offsetHeight?因为固定导航会盖住标题。易错点:移除旧高亮的代码要写在判断前;小模块和大模块要一一对应;固定导航高度最好用 offsetHeight 动态获取,而不是手写固定值。

课件图示 ​

课件第35页:3. M端事件 M端(移动端)有自己独特的地方。比如触屏事件 touch(也称触

课件第38页:4.插件-swiper l 小demo的实现步骤 1. 下载相关js文件和css

课件第40页:插件- AlloyFinger l AlloyFinger 是腾讯 AlloyT

课件第19页:2.1 DOM节点 l DOM树:DOM 将 HTML文档以树状结构直观的表现出

课件第20页:总结 总结 1. 什么是DOM树? Ø DOM 将 HTML文档以树状结构直观的

课件第54页:拓展-重绘和回流(导师讲解) l 解析(Parser)HTML,生成DOM树(D

课件第48页:步骤 通讯录(移动端) 新增业务 说明: 本次案例,我们尽量减少dom操作,采取

课件第51页:步骤 通讯录(移动端) 删除业务 说明: 本次案例,我们尽量减少dom操作,采取

代码示例 ​

示例一:DOM 节点查找 / 增加 / 删除 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>DOM 节点操作</title>
  <style>
    .pop { position: relative; width: 200px; height: 100px; background: #eee; margin-bottom: 20px; }
    .close { display: block; width: 20px; height: 20px; background: red; }
  </style>
</head>
<body>
  <div class="pop">
    <a href="javascript:;" class="close"></a>
  </div>

  <ul>
    <li>我是第1个孩子</li>
    <li>我是第2个孩子</li>
    <li>我是第3个孩子</li>
    <li>我是第4个孩子</li>
  </ul>

  <div class="remove">我要删除</div>
  <div class="none">我要隐藏</div>

  <script>
    // 1. 父节点:点击关闭按钮关闭父盒子
    const closeBtn = document.querySelector('.close')
    console.log(closeBtn.parentNode)          // 返回的是一个元素对象
    closeBtn.addEventListener('click', function () {
      this.parentNode.style.display = 'none'  // this 指向 closeBtn
    })

    // 2. 子节点 / 兄弟节点
    const ul = document.querySelector('ul')
    console.log(ul.children)                  // 只拿到元素节点,不含文本、注释

    const li2 = document.querySelector('ul li:nth-child(2)')
    console.log(li2.previousElementSibling)   // 上一个兄弟
    console.log(li2.nextElementSibling)       // 下一个兄弟
    console.log(ul.children[0])               // 第一个孩子
    console.log(ul.children[2])               // 第三个孩子

    // 3. 增加节点:先创建,再追加
    const li = document.createElement('li')
    li.innerHTML = '我是放到后面的'
    ul.append(li)                             // 放到父元素最后面,类似 ::after

    const firstli = document.createElement('li')
    firstli.innerHTML = '我是放到前面的'
    ul.prepend(firstli)                       // 放到父元素最前面,类似 ::before

    // 4. 删除节点
    const remove = document.querySelector('.remove')
    remove.remove()                           // 从 DOM 树中真正删除

    const none = document.querySelector('.none')
    none.style.display = 'none'               // 隐藏,但 DOM 树中仍然存在
  </script>
</body>
</html>

关键行与易错点

  • 元素.parentNode 返回的是父元素对象,可以直接改它的 style;this.parentNode 中的 this 指向事件绑定的元素本身。
  • 父元素.children 只返回元素节点集合,纯文本、注释节点不会算进去;而 childNodes 会把文本节点也算上,容易踩坑。
  • 兄弟节点要用 previousElementSibling / nextElementSibling,不要用 previousSibling / nextSibling,后者会把换行产生的文本节点也算进去。
  • append() / prepend() 是新的插入方法,一次可传多个节点,也可以直接传字符串;老写法是 appendChild()(只能放最后,且不能传字符串)。
  • remove() 是真正从 DOM 树移除,display:none 只是看不见,元素依然在 DOM 树里 —— 两者不是一回事。

示例二:仿 bilibili 滑动效果(事件委托 + offsetLeft + translateX) ​

js
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>仿 bilibili 滑动效果</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    .tab-list {
      position: relative;
      display: flex;
      width: 320px;
      height: 44px;
      margin: 100px auto;
      background: #fff;
      border-bottom: 1px solid #eee;
    }
    .tab-list a {
      flex: 1;
      line-height: 44px;
      text-align: center;
      font-size: 14px;
      color: #333;
      text-decoration: none;
    }
    /* 小滑块必须是一个单独的盒子,不能做成下划线 */
    .line {
      position: absolute;
      left: 0;
      bottom: 0;
      width: 80px;
      height: 3px;
      background: #fb7299;
      transition: all .3s;   /* 谁做动画就给谁加过渡 */
    }
  </style>
</head>
<body>
  <div class="tab-list">
    <a href="javascript:;">首页</a>
    <a href="javascript:;">动画</a>
    <a href="javascript:;">番剧</a>
    <a href="javascript:;">音乐</a>
    <span class="line"></span>
  </div>

  <script>
    // 1. 获取父元素,准备事件委托
    const list = document.querySelector('.tab-list')
    const line = document.querySelector('.line')

    // 2. 给父元素注册点击事件
    list.addEventListener('click', function (e) {
      // 只有点到了 A 才处理
      if (e.target.tagName === 'A') {
        // 拿到当前点击链接距离左侧的距离
        const left = e.target.offsetLeft
        // 让滑块水平移动过去
        line.style.transform = `translateX(${left}px)`
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • 事件委托:把点击事件绑在父元素 .tab-list 上,通过 e.target 判断真正点的是谁,比循环给每个 a 注册更省事。
  • e.target.tagName 返回的是大写标签名,必须写成 'A',写成 'a' 永远不成立。
  • 核心数据是 e.target.offsetLeft:第一个是 0,第二个是 60,第三个是 120…… 点谁就拿谁距离左侧的距离,喂给滑块即可。
  • translateX() 后面的单位 px 不能省,translateX(60) 是无效的。
  • 过渡 transition 一定要写在 .line 自己身上,并且 transform 只在原来的位置上继续移动,不会影响其他元素布局。
  • 别用下划线(border-bottom)冒充滑块 —— 那样没法单独移动,必须是独立盒子。

示例三:电梯导航(点击高亮 + 点击滑动 + 滚动高亮) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>电梯导航</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { height: 3200px; }

    /* 顶部固定导航栏,带边框 */
    .sticky {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 80px;
      line-height: 80px;
      text-align: center;
      background: #fff;
      border-bottom: 1px solid #ccc;
      z-index: 99;
    }

    /* 大模块 */
    .big {
      height: 600px;
      margin-bottom: 20px;
      padding-top: 100px;
      text-align: center;
      background: #f5f5f5;
      font-size: 30px;
    }

    /* 电梯导航(小模块) */
    .small {
      position: fixed;
      right: 20px;
      top: 200px;
      width: 80px;
    }
    .small a {
      display: block;
      height: 40px;
      line-height: 40px;
      margin-bottom: 5px;
      text-align: center;
      font-size: 12px;
      color: #333;
      text-decoration: none;
      background: #fff;
      border: 1px solid #ccc;
    }
    .small a.active {
      background: #fb7299;
      color: #fff;
    }
  </style>
</head>
<body>
  <div class="sticky">我是固定导航栏</div>

  <div class="big">新鲜好物</div>
  <div class="big">人气推荐</div>
  <div class="big">热门品牌</div>
  <div class="big">最新专题</div>

  <div class="small">
    <a href="javascript:;" class="small-item">新鲜好物</a>
    <a href="javascript:;" class="small-item">人气推荐</a>
    <a href="javascript:;" class="small-item">热门品牌</a>
    <a href="javascript:;" class="small-item">最新专题</a>
    <a href="javascript:;">返回顶部</a>
  </div>

  <script>
    // 获取元素:小模块 与 大模块 一一对应
    const smalls = document.querySelectorAll('.small-item')
    const bigs = document.querySelectorAll('.big')
    const sticky = document.querySelector('.sticky')

    // ===== 业务一:点击小模块 =====
    for (let i = 0; i < smalls.length; i++) {
      smalls[i].addEventListener('click', function () {
        // 1. 排他:先移除已有的 active(可能不存在,用逻辑与中断避免报错)
        const old = document.querySelector('.small .active')
        old && old.classList.remove('active')

        // 2. 点谁谁高亮
        this.classList.add('active')

        // 3. 页面滑动到对应的大模块(减去固定导航栏的高度)
        document.documentElement.scrollTop =
          bigs[i].offsetTop - sticky.offsetHeight
      })
    }

    // ===== 业务二:页面滚动 =====
    window.addEventListener('scroll', function () {
      const n = document.documentElement.scrollTop

      // 先移除所有小模块的高亮
      const old = document.querySelector('.small .active')
      old && old.classList.remove('active')

      // 再判断滚动到了哪个大模块
      for (let i = 0; i < bigs.length; i++) {
        if (n >= bigs[i].offsetTop - sticky.offsetHeight) {
          smalls[i].classList.add('active')
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • 页面滚动事件必须写在 window 的 scroll 里,滚动的距离取 document.documentElement.scrollTop,不要写错位置。
  • old && old.classList.remove('active') 是本节最关键的技巧:页面刚打开时没有任何小模块带 active,querySelector 返回 null,直接 remove 会报错。利用逻辑与中断 —— 前面为假(null)后面就不执行,自然不会报错。
  • 点击业务用传统 for 循环注册事件,不要用事件委托,否则会和后面的滚动业务互相影响。
  • 点击滑动时一定要减去固定导航栏的高度,否则标题会被顶部导航栏盖住。高度用 sticky.offsetHeight 动态获取(要带边框所以用 offsetHeight,不是 clientHeight),这样导航栏高度变了也不用改代码。
  • 滚动高亮里同样要减 sticky.offsetHeight,否则滚动到刚好贴合时高亮会“慢一拍”。
  • 滚动处理中“先移除全部高亮,再判断添加”这两句的顺序不能颠倒;如果写反了,刚加上的高亮会被下面的代码又清掉。
  • 因为 offsetTop 是从上到下递增的,越往下滚会同时满足多个条件,实际项目里可再加 else { break }(或倒序遍历取第一个满足的),保证同一时刻只亮一个小模块。

常见错误 ​

  1. 现象: 电梯导航首次点击时控制台报错,提示无法移除 active。
    原因: 页面初始没有小模块带 active,直接 remove('active') 会作用在 null 上。
    修法: 先查询当前 active 元素,再用 old && old.classList.remove('active') 或 if 判空后再移除。

  2. 现象: 点击电梯导航小模块后,页面滚动位置被固定顶部导航遮住标题。
    原因: scrollTop 直接等于大模块 offsetTop,没有扣除固定导航高度。
    修法: 滚动目标改为 big.offsetTop - sticky.offsetHeight,高度用 DOM 读取而非手写固定值。

  3. 现象: 页面滚动切换高亮时,已经到某个大模块但小模块高亮慢半拍或不准确。
    原因: 滚动距离判断没有扣除吸顶导航高度。
    修法: 判断条件使用 window.pageYOffset >= big.offsetTop - sticky.offsetHeight。

  4. 现象: 电梯导航点击高亮正常,但后续页面滚动高亮业务互相干扰。
    原因: 点击小模块使用事件委托,把不相关点击也纳入统一处理,影响后续业务边界。
    修法: 该案例点击高亮改用 for 循环给每个小模块单独注册点击事件,并配合排他思想。

  5. 现象: 吸顶导航滑出有动画,滑回隐藏时也慢慢滑走,效果不自然。
    原因: transition 加在基础盒子上,显示和隐藏都触发过渡。
    修法: 把过渡写在状态类(如 .sticky)上,只有添加状态类时才过渡;移除状态类后直接隐藏。

  6. 现象: 仿 B 站滑块点击后不动或移动位置错误。
    原因: 没有取当前点击 A 的 offsetLeft,或 translateX 未拼单位、未加过渡。
    修法: 事件委托中判断 e.target.tagName === 'A',用 line.style.transform = &#96;translateX(\${e.target.offsetLeft}px)&#96;,过渡加在滑块上。

练习 ​

  1. 电梯导航首次点击修复: 基于综合案例,保留“点击谁谁高亮”,要求页面初始无任何 active 时点击不报错,并说明你用的判空方式。
  2. 吸顶导航状态切换: 做一个顶部导航,滚动到头部模块时显示并吸顶,回到上方时隐藏;验收点:显示有过渡、隐藏无多余过渡。
  3. 仿 B 站滑块: 用事件委托实现点击不同 A 链接,底部滑块移动到对应链接下方;验收点:只响应 A,位移取 offsetLeft,有过渡。
  4. 移动端手势插件: 引入 AlloyFinger,对某个盒子监听 swipe,在页面输出 Left/Right/Up/Down;验收点:引入顺序正确、元素获取正确、方向可读。
  5. 页面滚动高亮: 在电梯导航中实现滚动到大模块时对应小模块高亮;验收点:扣除固定导航高度、回顶部后取消高亮、切换时不残留多个 active。

原视频对照 ​

#视频时长
1吸附顶部导航栏案例09:40
2仿bilibili滑动效果11:13
3综合案例-案例分析06:11
4综合案例-点击高亮效果12:44
5综合案例-点击页面滑动到对应位置08:15
6综合案例-页面滑动电梯导航高亮显示09:31

小结 ​

  1. 节点关系查找要优先使用元素节点相关 API,返回对象后再操作样式或类名。
  2. 新增节点先创建,再按业务位置选择 append 或 prepend 放入父元素。
  3. 删除节点与隐藏节点语义不同,业务上要明确是移出 DOM 树还是仅视觉隐藏。
  4. 移动端交互优先考虑 touchstart/touchmove/touchend 或成熟手势插件,不要只依赖 PC 端点击思维。
  5. 吸顶导航的核心是滚动距离与目标盒子位置比较,过渡效果应绑定在状态类上。
  6. 滑块跟随效果要围绕“目标元素 offsetLeft + translateX + 过渡”组织,事件委托必须限定触发元素。
  7. 电梯导航是双向联动:点击小模块控制页面滚动,页面滚动反控小模块高亮;实现时要统一索引、处理判空、扣除固定导航高度,并在回顶时清除高亮。
  8. 插件使用按“看官网、走流程、跑最小 demo、再接入业务”的顺序推进,避免直接堆进综合案例排错。