主题
DOM 回顾(Day17 1/3)
本页对应 Day17_Senior,共 6 段视频,约 1 小时 41 分。
学习目标
- 能够按“节点与元素获取、文档结构关系、元素属性/内容/尺寸/位置/滚动操作、HTMLDOM 表单与表格、document、事件监听与事件流、事件对象与事件委托”梳理 DOM 知识体系,并指出各部分的典型 API 与用途。
- 能够将轮播图图片容器改为横向排列,使用绝对定位与内容撑开宽度确定包裹元素尺寸,并通过修改
left配合 CSStransition实现滑动切换效果。 - 能够实现轮播图首尾克隆与图片索引、按钮索引分离控制,正确处理临界索引切换,并解释使用
setTimeout异步回调避免浏览器合并连续样式操作的原因。 - 能够使用
document.visibilitychange与document.hidden判断页面离开/返回状态,并在离开窗口时清除轮播定时器、返回时重新启动定时器。 - 能够说明 JavaScript 垃圾、垃圾回收、内存泄露与内存溢出的含义及区别,并解释变量引用对对象生命周期的影响。
- 能够描述引用计数算法的引用标记加减规则、对象变为垃圾的条件,并通过互相引用示例说明其无法回收循环引用对象、可能导致内存泄露的缺点。
知识点
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. 轮播图—滑动效果(克隆首尾 + 双索引 + 过渡控制)
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"><</a>
<a href="javascript:;" class="next">></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,且只被对方引用,计数永远不归零。
- 修法:知道这是引用计数的固有缺陷,实际引擎用标记清除解决;写代码时避免无谓的双向强引用。
练习
横向布局与宽度:把 5 张图的
ul改成横排,不使用固定宽度,验证ul的实际宽度是 4500px;然后给li去掉flex:0 0 auto,把图片换成一张超小图,观察读取到的宽度是否还稳定,写出结论。双索引改造:在已有轮播图上完成
btnIndex/imgIndex拆分,并补齐三处同步:next自增、prev自减、底部按钮点击。列出这三处各自的赋值语句,并说明为什么按钮点击时是+1。临界值推导:克隆首尾图片后集合长度为 7。分别写出
next和prev在越界时应该赋给imgIndex的表达式(用length表示),并画一张 0–6 的索引对照表,标出哪两个索引是"用户看不到的克隆图"。异步修复:复现"取消过渡无效"的问题,用
setTimeout修复,并额外测试传0和不传第二个参数两种情况,记录效果差异,说明原因。可见性暂停:给轮播图加上
visibilitychange处理,要求离开时停表、回来时续表且不重复开启。故意在false分支里不先clearInterval就setInterval,观察现象并解释。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | DOM总结 | 05:27 |
| 2 | 轮播图-滑动效果01 | 16:03 |
| 3 | 轮播图-滑动效果02 | 28:59 |
| 4 | 轮播图-滑动效果03 | 16:29 |
| 5 | 轮播图-离开本窗口定时停止 | 10:55 |
| 6 | 垃圾回收-引用计数算法 | 23:36 |
小结
- 滑动式轮播的本质只有一件事:图片横向排成一列,改包裹元素的
left,过渡交给 CSS。 - 让
ul绝对定位是个一石二鸟的动作——既脱离文档流的宽度约束让li撑开容器,又为left位移提供了定位上下文。 - 无缝切换靠的是首尾各克隆一张,代价是图片数量与按钮数量脱钩,必须用两个索引分别维护,并额外处理初始偏移。
- 边界处理的思路是"用户看到的"和"集合里实际的"分开算:越界时先瞬移回真实对应的那张,再走正常过渡。
- 浏览器会合并同一任务内的连续样式变更,"去过渡 + 加过渡"必须靠异步任务拆开,
setTimeout(fn, 0)即可,不必真的等待。 querySelectorAll拿到的是静态快照,DOM 结构一变就要重新获取,这个坑在动态增删节点时普遍存在。- 定时器与渲染在后台标签页的节奏不一致,是"回来乱跳"的根因;
visibilitychange+document.hidden是标准的对齐手段。 - 引用计数无法回收循环引用,这是它被标记清除取代的原因;理解它更多是为了应对面试和建立内存意识。