Skip to content

DOM 回顾(Day17 1/3) ​

本页对应 Day17_Senior,共 6 段视频,约 1 小时 41 分。

学习目标 ​

  1. 能够按“节点与元素获取、文档结构关系、元素属性/内容/尺寸/位置/滚动操作、HTMLDOM 表单与表格、document、事件监听与事件流、事件对象与事件委托”梳理 DOM 知识体系,并指出各部分的典型 API 与用途。
  2. 能够将轮播图图片容器改为横向排列,使用绝对定位与内容撑开宽度确定包裹元素尺寸,并通过修改 left 配合 CSS transition 实现滑动切换效果。
  3. 能够实现轮播图首尾克隆与图片索引、按钮索引分离控制,正确处理临界索引切换,并解释使用 setTimeout 异步回调避免浏览器合并连续样式操作的原因。
  4. 能够使用 document.visibilitychange 与 document.hidden 判断页面离开/返回状态,并在离开窗口时清除轮播定时器、返回时重新启动定时器。
  5. 能够说明 JavaScript 垃圾、垃圾回收、内存泄露与内存溢出的含义及区别,并解释变量引用对对象生命周期的影响。
  6. 能够描述引用计数算法的引用标记加减规则、对象变为垃圾的条件,并通过互相引用示例说明其无法回收循环引用对象、可能导致内存泄露的缺点。

知识点 ​

DOM 体系回顾 ​

为什么需要:DOM 知识点多且容易混,学完要能把“获取元素—结构关系—元素操作—HTML DOM—document—事件”串起来。

怎么想:元素只是节点的一种,日常操作最多的是元素节点;拿到元素后,既能通过父子兄弟关系找其他元素,也能读写属性、内容、尺寸、位置和滚动位置。

怎么做:按主线复习:

  • 获取元素:从 document 出发;
  • 文档结构:父元素、子元素、兄弟元素;
  • 元素操作:属性、内容、尺寸、位置、滚动;
  • HTML DOM:不同标签元素对应对象类型不同,表单、表格有特殊方法;
  • document 本身也是节点;
  • 事件:监听/取消监听、事件流(捕获与冒泡)、回调中的 this、常用事件列表、事件对象。

易错点:事件对象里 target 常用于事件委托。委托有两个好处:新添加的元素也能触发;大量元素监听时更省内存。

轮播图滑动效果:先横排,再整体位移 ​

为什么需要:原来靠隐藏/显示图片切换,效果生硬;滑动效果要让图片横着排成一个整体,再改包裹元素的位置。

怎么想:把 ul 里的 li 横向排列,让 ul 绝对定位,宽度由内容撑开;切换时改 ul 的 left,并加 CSS 过渡。

怎么做:

css
.image-list {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  transition: left 600ms;
}
.image-list li {
  flex: 0 0 auto;
}
.playbook {
  overflow: hidden;
}
js
const imageItems = document.querySelectorAll('.image-list li');
const imageWrapper = document.querySelector('#image');
const imageWidth = imageItems[0].offsetWidth;
imageWrapper.style.left = -index * imageWidth + 'px';

易错点:ul 不设宽度时,普通块元素宽度随父元素;绝对定位后宽度可由内容撑开。li 加 flex: 0 0 auto 可避免收缩,防止 offsetWidth 取不准。

首尾克隆与两个索引 ​

为什么需要:到最后一张再点“下一个”,希望第一张从右边出来;到第一张再点“上一个”,希望最后一张从左边出来。

怎么想:把最后一张克隆到最前,把第一张克隆到最后,形成首尾“垫图”;但图片数量变多,按钮索引和图片索引不能共用一个变量。

怎么做:

js
const lastClone = imageItems[imageItems.length - 1].cloneNode(true);
imageWrapper.insertBefore(lastClone, imageItems[0]);

const firstClone = imageItems[1].cloneNode(true);
imageWrapper.appendChild(firstClone);

const imageItemsNew = imageWrapper.querySelectorAll('li');
let index = 0;        // 按钮索引
let imageIndex = 1;   // 图片索引,初始显示真正第一张
imageWrapper.style.left = -imageWidth + 'px';

易错点:克隆后必须重新获取图片集合;按钮索引 index 与图片索引 imageIndex 要同步变化,按钮点第几个对应 imageIndex = index + 1。

过渡控制与异步修正 ​

为什么需要:临界点直接改 left,浏览器会按最近方向滑动,导致“该从右边出来却从左边出来”。

怎么想:先取消过渡,瞬移到真正的第一张;再恢复过渡,异步切到目标位置。关键是两步不能被浏览器合并。

怎么做:

js
imageWrapper.style.transition = 'none';
imageWrapper.style.left = -imageWidth + 'px';

setTimeout(() => {
  setActive();
}, 0);

setActive 中恢复过渡:

js
imageWrapper.style.transition = 'left 600ms';

易错点:连续写“取消过渡、恢复过渡”,浏览器可能只取最后结果,仍保留过渡;用 setTimeout(..., 0) 把操作拆成异步步骤。prev 临界处理思路相同,只是瞬移到真正最后一张。

离开窗口暂停定时器 ​

为什么需要:离开页面后浏览器可能暂停界面渲染,但定时器仍在跑,回来时索引和画面不同步,出现连跳或倒走。

怎么想:监听页面可见性变化,离开时停定时器,回来时重新开启。

怎么做:

js
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    clearInterval(timerId);
  } else {
    timerId = setInterval(next, 3000);
  }
});

易错点:事件监听在 document 上,不是 window;document.hidden 为 true 表示离开。

垃圾回收与引用计数 ​

为什么需要:用不到的数据要及时释放,否则占内存,严重时内存溢出。

怎么想:引用计数给对象记“被引用次数”:被引用加一,取消引用减一,为零可回收。

怎么做:

js
let a = { name: '小乐' };
let b = a;      // 引用计数增加
a = null;       // 减少一次
b = null;       // 再减少,归零后可回收

易错点:两个对象互相引用时,引用计数都不为零,可能无法回收,造成内存泄露;内存泄露是垃圾未回收,内存溢出是需要内存时空间不够。

节点:元素只是节点的一种 ​

为什么需要:DOM 操作首先要知道“操作对象是谁”。页面结构在 DOM 中是一棵树,节点是树上的基本单位,元素只是节点类型中的一种。
怎么想:先分清“节点”和“元素”。日常开发大多操作元素节点,但 document 也是节点,只是不是元素。
怎么做:把 DOM 看成文档结构:先拿到某个元素,再通过父元素、子元素、兄弟元素继续找其他元素。
易错点:不要把“节点”等同于“元素”;也不要把 document 当普通元素去读尺寸、改样式。

获取元素与文档接口 ​

为什么需要:后续所有属性、样式、内容、事件操作,都建立在“先拿到元素”上。
怎么想:获取入口来自文档,常见思路是“按 id/选择器直接取”,或“先取一个元素,再按文档结构找父子兄弟”。
怎么做:把获取元素和文档结构放在一起理解:文档接口负责入口,父子兄弟关系负责继续定位。
易错点:动态增删元素后,querySelectorAll 得到的静态集合不会自动更新,需要重新获取;不要和会实时更新的集合混为一谈。

元素操作 ​

为什么需要:拿到元素后,真正的工作是改属性、改样式、读写内容、读尺寸位置、控制滚动。
怎么想:不要零散记 API,按类别记:属性、样式、内容、尺寸、位置、滚动位置。转写稿里反复提醒“这些名字不要搞混”。
怎么做:操作前先判断你要改的是“属性”还是“样式”,是“读内容”还是“写内容”,是“读尺寸”还是“改位置”。
易错点:属性、样式、内容、位置容易混用;读到的尺寸和位置要结合当前布局、定位、滚动状态理解,不能只看变量名。

HTML DOM:表单与表格 ​

为什么需要:不同标签元素获取成 JS 对象后,构造函数和类型可能不同,会有各自更专用的方法。
怎么想:普通元素操作是通用能力;HTML DOM 是在通用 DOM 之上,针对表单、表格等标签做特殊整理。
怎么做:重点记表单和表格的专属操作,尤其是表格添加子元素时有更简单的方式。
易错点:不要把 HTML DOM 理解成另一套 DOM;它仍然是 DOM,只是不同标签元素对象有不同接口。

document ​

为什么需要:document 是文档入口,获取元素、监听页面级事件都离不开它。
怎么想:它不算元素,但它是节点;它代表整个文档,不是页面里的某个盒子。
怎么做:把 document 用在“文档级”操作上,例如获取元素、绑定页面级监听。
易错点:不要对 document 做元素样式、尺寸、位置那类操作;它和普通元素对象的职责不同。

事件监听与解除 ​

为什么需要:页面交互靠事件驱动;只会绑不会解,容易造成重复触发和资源浪费。
怎么想:先明确“监听谁、监听什么、触发后做什么”,再考虑是否需要解除监听。
怎么做:使用监听事件和取消事件监听成对理解;回调函数单独抽出来时,解除才能对得上。
易错点:匿名回调往往难以准确解除;重复绑定会让同一行为执行多次。

事件流、回调中的 this ​

为什么需要:事件不是只在目标上发生,还会经历捕获和冒泡;回调里的 this 也影响代码指向。
怎么想:先判断事件流阶段,再判断回调函数的写法。转写稿强调事件触发分捕获和冒泡,回调中的 this 要专门总结。
怎么做:分析事件从哪来到哪去,明确当前监听处在哪个阶段;写回调时不要默认 this 永远指向某个对象。
易错点:阻止冒泡、阻止默认行为要放对位置;箭头函数和普通函数对 this 的处理不同,不能想当然。

事件对象、常用事件与事件委托 ​

为什么需要:事件对象携带鼠标、键盘等细节,还能阻止冒泡、阻止浏览器默认行为,并通过 target 做事件委托。
怎么想:常用事件列表要当工具表记;事件对象按鼠标、键盘等类别取特性;大量元素监听时考虑委托。
怎么做:用 target 判断真正触发者;事件委托有两点好处:新添加的元素也能有事件,大量元素监听时更节省资源。转写稿还提到可用页面可见性事件监听离开/回到窗口,并用 document.hidden 判断状态。
易错点:不要把 target 当成绑定元素;不要给大量元素逐个遍历绑定却忽略委托优化;阻止默认行为前要确认当前事件确实有默认行为。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 轮播图—滑动效果(克隆首尾 + 双索引 + 过渡控制) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>轮播图 - 滑动效果</title>
<style>
  * { margin: 0; padding: 0; }
  .playbook {
    position: relative;
    width: 900px;
    height: 400px;
    margin: 50px auto;
    overflow: hidden;          /* 超出可视区的图片隐藏 */
    border: 1px solid #ccc;
  }
  .images {
    position: absolute;        /* 绝对定位:不设宽度,由内容撑开 */
    left: 0;
    top: 0;
    display: flex;             /* 让 li 横着排 */
    list-style: none;
  }
  .images li {
    flex: 0 0 auto;            /* 关键:禁止收缩,保证 offsetWidth 取得准 */
    width: 900px;
    height: 400px;
    line-height: 400px;
    text-align: center;
    font-size: 80px;
    color: #fff;
  }
  .c1 { background: #e74c3c; }  /* 第 1 张 */
  .c2 { background: #3498db; }
  .c3 { background: #2ecc71; }
  .c4 { background: #9b59b6; }
  .c5 { background: #f39c12; }  /* 第 5 张 */
  .prev, .next {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 40px;
    height: 60px;
    line-height: 60px;
    text-align: center;
    background: rgba(0,0,0,.4);
    color: #fff;
    font-size: 30px;
    text-decoration: none;
  }
  .prev { left: 0; }
  .next { right: 0; }
  .points {
    position: absolute;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    list-style: none;
  }
  .points li {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: rgba(255,255,255,.6);
    cursor: pointer;
  }
  .points li.active { background: #fff; }
</style>
</head>
<body>

<div class="playbook">
  <ul class="images" id="image">
    <li class="c1">1</li>
    <li class="c2">2</li>
    <li class="c3">3</li>
    <li class="c4">4</li>
    <li class="c5">5</li>
  </ul>
  <a href="javascript:;" class="prev">&lt;</a>
  <a href="javascript:;" class="next">&gt;</a>
  <ol class="points">
    <li class="active"></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
  </ol>
</div>

<script>
  var wiper    = document.getElementById('image');       // 图片包裹元素
  var playbook = document.querySelector('.playbook');
  var btns     = document.querySelectorAll('.points li'); // 静态集合
  var prevBtn  = document.querySelector('.prev');
  var nextBtn  = document.querySelector('.next');

  /* ===== 1. 首尾各克隆一张,让轮播“环”起来 ===== */
  var lastClone = wiper.lastElementChild.cloneNode(true);      // 克隆第 5 张
  wiper.insertBefore(lastClone, wiper.firstElementChild);      // 插到最前面
  var firstClone = wiper.children[1].cloneNode(true);          // 此时 children[1] 才是真第 1 张
  wiper.appendChild(firstClone);                               // 追加到最后

  /* ===== 2. DOM 改完必须重新获取集合(现在是 7 张) ===== */
  var images = wiper.children;
  var imageWidth = images[0].offsetWidth;   // 单张宽度,别写死

  var index      = 0;   // 按钮索引
  var imageIndex = 1;   // 图片索引:0 是克隆的“第 5 张”,1 才是真第 1 张

  /* ===== 3. 初始位置:显示真正的第一张 ===== */
  wiper.style.left = -imageWidth + 'px';

  function setActive() {
    wiper.style.transition = 'left 600ms';        // 需要动画时再补上过渡
    wiper.style.left = -imageIndex * imageWidth + 'px';
    for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
    btns[index].classList.add('active');
  }

  function playNext() {
    index++;
    if (index > btns.length - 1) index = 0;

    imageIndex++;
    if (imageIndex > images.length - 1) {          // 已经滑到克隆的“第 1 张”
      wiper.style.transition = 'none';             // ① 先取消过渡
      wiper.style.left = -imageWidth + 'px';       // ② 瞬移回真第 1 张(肉眼无差)
      imageIndex = 2;                              // ③ 目标是用户认为的第 2 张
      setTimeout(setActive, 0);                    // ④ 丢进异步,浏览器才不合并
    } else {
      setActive();
    }
  }

  function playPrev() {
    index--;
    if (index < 0) index = btns.length - 1;

    imageIndex--;
    if (imageIndex < 0) {                          // 已经滑到克隆的“第 5 张”
      wiper.style.transition = 'none';
      wiper.style.left = -(images.length - 2) * imageWidth + 'px';  // 瞬移回真第 5 张
      imageIndex = images.length - 3;                               // 用户认为的第 4 张
      setTimeout(setActive, 0);
    } else {
      setActive();
    }
  }

  nextBtn.onclick = playNext;
  prevBtn.onclick = playPrev;

  btns.forEach(function (btn, i) {
    btn.onclick = function () {
      index = i;
      imageIndex = i + 1;      // 图片比按钮多一个克隆,索引要 +1
      setActive();
    };
  });

  /* ===== 4. 自动播放 + 悬停暂停 ===== */
  var timer = null;
  function start() {
    clearInterval(timer);                    // 防止重复开启
    timer = setInterval(playNext, 3000);
  }
  function stop() { clearInterval(timer); }

  start();
  playbook.onmouseenter = stop;
  playbook.onmouseleave = start;

  /* ===== 5. 离开窗口暂停定时器 ===== */
  document.addEventListener('visibilitychange', function () {
    document.hidden ? stop() : start();
  });
</script>
</body>
</html>

关键行说明

  • position:absolute 的 ul 不设宽度 → 宽度由内容撑开;display:flex 让 li 横向排,flex:0 0 auto 保证不收缩。
  • 克隆顺序很重要:先 insertBefore 把最后一张放到最前,再取 children[1](此时才是原第一张)克隆到末尾。
  • index 管按钮、imageIndex 管图片,两者数量不同(7 张图 vs 5 个按钮),初始值也不同(0 和 1)。
  • 临界处理三步走:transition='none' → 改 left 瞬移 → setTimeout(setActive, 0) 异步恢复动画。

易错点

  • 忘记 flex:0 0 auto,图片可能被压缩,offsetWidth 取到的值不准。
  • DOM 增删节点后不重新获取集合(querySelectorAll 拿到的是静态 NodeList,不会自动更新)。
  • 瞬移后忘记把 imageIndex 改回目标值,或者误用 index 去算 left。
  • 瞬移和 setActive 写成同步连续执行,浏览器会把两次样式变更合并,动画直接失效。

2. 离开本窗口暂停定时器(visibilitychange) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>离开窗口暂停定时器</title>
</head>
<body>
  <h3>切到别的标签页再回来看看</h3>
  <p>已过去:<span id="count">0</span> 秒</p>

  <script>
    var count = 0;
    var timer = null;
    var span = document.getElementById('count');

    function start() {
      if (timer) return;                 // 已经开着就别重复开
      timer = setInterval(function () {
        count++;
        span.textContent = count;
      }, 1000);
    }

    function stop() {
      clearInterval(timer);
      timer = null;
    }

    /* 关键:监听 document,不是 window */
    document.addEventListener('visibilitychange', function () {
      console.log(document.hidden);      // true=正在离开,false=回到本窗口
      if (document.hidden) {
        stop();                          // 离开:停掉定时器
      } else {
        start();                         // 回来:继续计时
      }
    });

    start();
  </script>
</body>
</html>

关键行说明

  • 事件名是 visibilitychange,离开和回来都会触发,靠 document.hidden 区分。
  • 监听对象必须是 document,挂到 window 上不生效。
  • start() 里先判断 timer,避免多次开启产生多个定时器。

易错点

  • 用 window.addEventListener('visibilitychange', ...) 监听不到。
  • 回来时忘记 start(),或者不判空导致定时器越叠越多,计时速度翻倍。
  • 浏览器在后台会暂停页面渲染但不暂停 JS 定时器,不处理就会出现“回来一下跳好几张”。

3. 引用计数算法演示(可在控制台直接运行) ​

js
/* ---------- 情况一:没有变量引用,创建完立刻是垃圾 ---------- */
[1, 2, 3];                 // 引用计数 0,用完即被回收

/* ---------- 情况二:被变量引用,计数 +1 ---------- */
var a = { name: '小乐' };   // 被 window.a 引用,计数 1

/* ---------- 情况三:再被另一个属性引用,计数 +1 ---------- */
var obj = { child: a };     // 被 window.obj.child 引用,计数 2

/* ---------- 情况四:断开一处引用,计数 -1 ---------- */
a = null;                   // 计数 1,还被 obj.child 引用着,不会回收

/* ---------- 情况五:再断开一处,计数

常见错误 ​

1. 给 ul.images 设了 display:flex,li 依然竖着排

  • 现象:li 没横过来,容器宽度还是 900px。
  • 原因:ul 是块元素,宽度由父容器算出 900px,li 被挤在 900px 里只能压缩换行。
  • 修法:给 ul 加 position:absolute(脱离文档流,宽度由内容撑开)或显式设宽;同时给 li 加 flex:0 0 auto,防止被压缩导致读取到的宽度偏小。

2. 克隆首尾图片后,imgList.length 还是 5

  • 现象:临界判断算错,最后一张点下一个时跳错图。
  • 原因:querySelectorAll 返回的是静态 NodeList,DOM 变了集合不会自动更新。
  • 修法:克隆操作全部完成之后,重新执行一次获取集合的代码,再往下做索引计算。

3. 用一个 index 同时管按钮和图片

  • 现象:点第 5 个按钮显示的却是倒数第二张,临界判断也全乱。
  • 原因:克隆后图片比按钮多 2 个,两者数量不一致,索引不再一一对应。
  • 修法:拆成两个变量(btnIndex / imgIndex);按钮点击时 imgIndex = btnIndex + 1;页面初始化时用 JS 把包裹元素 left 设成 -图片宽度,避免一打开就错位。

4. 临界切换时"取消过渡→瞬移→恢复过渡"没生效

  • 现象:代码写了,但图片还是从反方向滑过来,瞬移被吃掉。
  • 原因:去掉和加回 transition 是同步连续操作,浏览器只取最终样式,判定"有过渡",于是把两段动画合并成一段。
  • 修法:把 setActive 放进 setTimeout(fn, 0) 里,让两段样式变更分属同步和异步两个任务,浏览器不再合并。

5. 一刷新页面就看到图片滑一下

  • 现象:首屏有莫名其妙的一次滑动。
  • 原因:transition 写在 CSS 里,JS 初始化改 left 时触发了过渡。
  • 修法:transition 不写在 CSS,改由 setActive 里用 style.transition 每次设置。

6. 切到别的标签页再切回来,轮播连跳好几张甚至倒着走

  • 现象:离开一会儿回来,图片顺序错乱。
  • 原因:后台标签页浏览器暂停了渲染,但定时器仍在跑,imgIndex 一直在变,视觉和索引脱节。
  • 修法:document.addEventListener('visibilitychange', ...),用 document.hidden 判断:为 true 时 clearInterval,为 false 时重新 setInterval。注意事件绑在 document 上而不是 window。

7. 认为两个对象互相引用后置 null 就能被回收

  • 现象:以为已经清干净,实际内存里一直留着。
  • 原因:引用计数算法下两对象引用数各为 1,且只被对方引用,计数永远不归零。
  • 修法:知道这是引用计数的固有缺陷,实际引擎用标记清除解决;写代码时避免无谓的双向强引用。

练习 ​

  1. 横向布局与宽度:把 5 张图的 ul 改成横排,不使用固定宽度,验证 ul 的实际宽度是 4500px;然后给 li 去掉 flex:0 0 auto,把图片换成一张超小图,观察读取到的宽度是否还稳定,写出结论。

  2. 双索引改造:在已有轮播图上完成 btnIndex / imgIndex 拆分,并补齐三处同步:next 自增、prev 自减、底部按钮点击。列出这三处各自的赋值语句,并说明为什么按钮点击时是 +1。

  3. 临界值推导:克隆首尾图片后集合长度为 7。分别写出 next 和 prev 在越界时应该赋给 imgIndex 的表达式(用 length 表示),并画一张 0–6 的索引对照表,标出哪两个索引是"用户看不到的克隆图"。

  4. 异步修复:复现"取消过渡无效"的问题,用 setTimeout 修复,并额外测试传 0 和不传第二个参数两种情况,记录效果差异,说明原因。

  5. 可见性暂停:给轮播图加上 visibilitychange 处理,要求离开时停表、回来时续表且不重复开启。故意在 false 分支里不先 clearInterval 就 setInterval,观察现象并解释。

原视频对照 ​

#视频时长
1DOM总结05:27
2轮播图-滑动效果0116:03
3轮播图-滑动效果0228:59
4轮播图-滑动效果0316:29
5轮播图-离开本窗口定时停止10:55
6垃圾回收-引用计数算法23:36

小结 ​

  1. 滑动式轮播的本质只有一件事:图片横向排成一列,改包裹元素的 left,过渡交给 CSS。
  2. 让 ul 绝对定位是个一石二鸟的动作——既脱离文档流的宽度约束让 li 撑开容器,又为 left 位移提供了定位上下文。
  3. 无缝切换靠的是首尾各克隆一张,代价是图片数量与按钮数量脱钩,必须用两个索引分别维护,并额外处理初始偏移。
  4. 边界处理的思路是"用户看到的"和"集合里实际的"分开算:越界时先瞬移回真实对应的那张,再走正常过渡。
  5. 浏览器会合并同一任务内的连续样式变更,"去过渡 + 加过渡"必须靠异步任务拆开,setTimeout(fn, 0) 即可,不必真的等待。
  6. querySelectorAll 拿到的是静态快照,DOM 结构一变就要重新获取,这个坑在动态增删节点时普遍存在。
  7. 定时器与渲染在后台标签页的节奏不一致,是"回来乱跳"的根因;visibilitychange + document.hidden 是标准的对齐手段。
  8. 引用计数无法回收循环引用,这是它被标记清除取代的原因;理解它更多是为了应对面试和建立内存意识。