主题
2-21JavaScript实战-放大镜案例
本页对应 第二章WebAPIs,共 7 段视频,约 58 分钟。
学习目标
- 能独立分析放大镜案例的
small、middle、large三个盒子结构,并说明四个业务功能分别对应的事件与交互结果。 - 能在
small上使用mouseover事件委托,判断目标为img后读取其src同步到middle图片,并通过classList为当前图片父级添加active、移除其他同级active。 - 能使用
mouseenter、mouseleave控制large与遮罩层的显示隐藏,将中图地址设置为large背景图,并使用定时器延迟隐藏且在再次进入时清除定时器。 - 能在
middle上监听mousemove,使用offsetX、offsetY设置遮罩层left、top,减去遮罩宽高一半实现居中,并使用pointer-events: none避免遮罩层引起坐标抖动。 - 能动态计算
maxX、maxY将遮罩层限制在middle范围内,并在鼠标移动时通过background-position按-2倍X/Y反向移动large背景图,实现大图跟随效果。
知识点
先看本节笔记

本节笔记只有一句核心:
js
元素.classList.contains()元素.classList.contains() 看看有没有包含某个类,如果有则返回 true,没有则返回 false。
这句话虽然短,但它是后面做“排他思想”“切换状态”“判断当前元素是否已经选中”时非常常用的一个 API。转写稿里的放大镜案例反复操作 active 类:鼠标经过小图片时,给当前小图片的父级 li 添加 active,并移除其他 li 的 active。本节就把“怎么判断有没有类”这件事补完整。
为什么需要判断是否有类
为什么需要: 页面上很多状态是靠类名控制的,比如选中、显示、隐藏、加边框。转写稿里小图片的绿色边框,不是直接改 style,而是给 li 添加 active 类。只要类在,样式就在;类不在,样式就没了。可是在添加或删除之前,我们有时要先知道:这个元素现在到底有没有这个类?
怎么想: 把类名想成一个开关。contains('active') 不是在问“类名长什么样”,而是在问“这个元素身上有没有 active 这个类”。答案只有两个:true 或 false。
怎么做: 先拿到元素,再调用 classList.contains('类名')。
js
const hasActive = 元素.classList.contains('active');
console.log(hasActive); // true 或 false易错点: contains 里写的是类名,不带点。写 contains('active'),不要写成 contains('.active')。返回值是布尔值,不要再和字符串比较。
contains 的基本用法:先判断再操作
为什么需要: 如果当前元素已经有 active,再 add('active') 不会报错,但逻辑上可能重复;如果当前元素没有 active,直接 remove('active') 也不会报错,但你想做的可能是“没有就加上”。所以判断可以让逻辑更清楚。
怎么想: 先 contains 得到布尔值,再用 if 分流。
怎么做:
js
const li = document.querySelector('.item');
if (li.classList.contains('active')) {
li.classList.remove('active');
} else {
li.classList.add('active');
}这段代码表达的是:有 active 就移除,没有就添加。它和 classList.toggle('active') 的效果接近,但这里重点是理解 contains 的判断作用。
易错点: classList 是元素上的属性,不是字符串方法。不要写成 元素.className.contains();className 是字符串,字符串的 contains 并不是这里要用的类名判断方法。
放到放大镜案例里理解 active 类
为什么需要: 转写稿中,鼠标经过小图片时,需要让当前小图片的父级 li 有边框,其他 li 不要有边框。这就是典型的“先移除其他,再给当前添加”。
怎么想: 转写稿里的实际做法是先在小盒子 small 里找到已有的 .active,把它 remove 掉,再给当前图片的父级 li 添加 active。如果只想判断“当前这个 li 有没有 active”,就可以用 contains。
怎么做: 结合转写稿里的 active 操作,可以写成这样理解:
js
// 转写稿里的排他思路:先移除已有的 active
const activeLi = small.querySelector('.active');
if (activeLi) {
activeLi.classList.remove('active');
}
// 再给当前小图片的父级 li 添加 active
e.target.parentNode.classList.add('active');如果要判断当前 li 是否已经有 active,可以:
js
const li = e.target.parentNode;
console.log(li.classList.contains('active'));易错点: 转写稿里反复强调:加边框的不是图片本身,而是图片的父级 li。所以判断类名时也要看准元素,不要拿 img 去判断 li 上的 active。另外,事件委托里用的是 mouseover,因为需要冒泡;mouseenter 不冒泡,不能直接拿来做事件委托。调试时先打印 e.target,确认当前经过的是不是 img,再拿 src,这是转写稿里已经验证过的过程。
调试时怎么确认 contains 的结果
为什么需要: 类名操作出错时,靠猜很容易把 active、layer、large 看混。转写稿里也提醒过,layer 和 large 长得像,容易写错,所以命名要清楚。
怎么想: 先在控制台打印布尔值,确认“有没有类”,再决定 add 还是 remove。
怎么做:
js
console.log(元素.classList.contains('active'));如果输出 true,说明有这个类;输出 false,说明没有。再配合 Elements 面板看元素上的 class 是否真的变化。
易错点: 不要把 contains 和 add、remove 混在一起用。contains 只负责判断,不负责添加或删除。还要注意类名拼写,active 不要写成 avtive;classList 不要写成 classlist。
小结
本节的核心就是:元素.classList.contains('类名') 用来判断元素身上有没有某个类,有返回 true,没有返回 false。在转写稿的放大镜案例里,active 类控制小图片边框,实际落地是 remove 和 add;而 contains 补上了“先判断”的这一环。记住三点:类名不带点、判断的是元素本身、调试先打印布尔值。
课件图示


代码示例
示例一:放大镜业务一(小图 → 中图跟随变化 + 边框排他)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>放大镜 · 业务一</title>
<style>
* { margin: 0; padding: 0; }
li { list-style: none; }
.picture { position: relative; width: 820px; height: 500px; margin: 40px auto; }
/* 中盒子 */
.middle { position: relative; width: 400px; height: 400px; cursor: move; }
.middle img { display: block; width: 100%; height: 100%; }
/* 遮罩层(业务二才用,先隐藏) */
.layer { display: none; position: absolute; left: 0; top: 0;
width: 200px; height: 200px; background: rgba(0, 0, 0, .4);
pointer-events: none; }
/* 大盒子 */
.large { display: none; position: absolute; left: 420px; top: 0;
width: 400px; height: 400px;
background-repeat: no-repeat; background-size: 800px 800px; }
/* 小盒子 */
.small { position: absolute; left: 0; top: 420px; width: 400px; }
.small ul { display: flex; gap: 12px; }
.small li { border: 2px solid transparent; }
.small li.active { border-color: #4caf50; }
.small img { display: block; width: 70px; height: 70px; }
</style>
</head>
<body>
<div class="picture">
<!-- 中盒子:里面第 1 个孩子是图,第 2 个孩子是遮罩层 -->
<div class="middle">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%23c0392b'/%3E%3C/svg%3E" alt="">
<div class="layer"></div>
</div>
<!-- 大盒子 -->
<div class="large"></div>
<!-- 小盒子 -->
<div class="small">
<ul>
<li class="active"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%23c0392b'/%3E%3C/svg%3E" alt=""></li>
<li><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%232980b9'/%3E%3C/svg%3E" alt=""></li>
<li><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%2327ae60'/%3E%3C/svg%3E" alt=""></li>
<li><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%23f39c12'/%3E%3C/svg%3E" alt=""></li>
<li><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%238e44ad'/%3E%3C/svg%3E" alt=""></li>
</ul>
</div>
</div>
<script>
(function () {
// 防止变量污染,整个放大镜业务放进立即执行函数
var small = document.querySelector('.small');
var middle = document.querySelector('.middle');
var middleImg = middle.children[0]; // 中盒子里第一张图
// 1.1 事件委托:注册在小盒子身上
small.addEventListener('mouseover', function (e) {
// 只有经过的是图片才处理
if (e.target.tagName !== 'IMG') return;
// 1.2 把当前小图的地址给中图(三张图本就是同一张图)
middleImg.src = e.target.src;
// 1.3 排他:先清掉所有 li 的 active,再给当前图片的爸爸加 active
var activeLi = small.querySelector('.active');
if (activeLi) activeLi.classList.remove('active');
e.target.parentNode.classList.add('active');
});
})();
</script>
</body>
</html>关键行与易错点
mouseover有冒泡,才能做事件委托;mouseenter没有冒泡,用在这里拿不到e.target,是常见踩坑点。e.target.tagName !== 'IMG'必须判断:鼠标划过ul或li的空白处也会触发mouseover,不判断就会报错。middle.children[0]取的是中盒子里第一个元素节点(children返回伪数组),不是childNodes。- 边框加在
li(图片的爸爸parentNode) 上,不是加在img上;转写稿特意强调过这一点。 - 排他思想:先
remove再add;判断是否已有类可用classList.contains('active')。 - 小图、中图、大图本质是同一张图,所以只要
src一赋值,看起来就“换图”了。
示例二:放大镜完整版(显示隐藏 + 遮罩跟随 + 限定范围 + 大图跟随)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>放大镜 · 完整版</title>
<style>
* { margin: 0; padding: 0; }
li { list-style: none; }
.picture { position: relative; width: 820px; height: 500px; margin: 40px auto; }
/* 中盒子:cursor 写在这里,因为遮罩层被穿透了 */
.middle { position: relative; width: 400px; height: 400px; cursor: move; }
.middle img { display: block; width: 100%; height: 100%; }
/* 遮罩层:pointer-events:none 让鼠标事件穿透 */
.layer { display: none; position: absolute; left: 0; top: 0;
width: 200px; height: 200px; background: rgba(0, 0, 0, .4);
pointer-events: none; }
/* 大盒子:背景图按原尺寸 800×800 显示,形成放大效果 */
.large { display: none; position: absolute; left: 420px; top: 0;
width: 400px; height: 400px;
background-repeat: no-repeat; background-size: 800px 800px; }
/* 小盒子 */
.small { position: absolute; left: 0; top: 420px; width: 400px; }
.small ul { display: flex; gap: 12px; }
.small li { border: 2px solid transparent; }
.small li.active { border-color: #4caf50; }
.small img { display: block; width: 70px; height: 70px; }
</style>
</head>
<body>
<div class="picture">
<div class="middle">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%23c0392b'/%3E%3C/svg%3E" alt="">
<div class="layer"></div>
</div>
<div class="large"></div>
<div class="small">
<ul>
<li class="active"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='800'%3E%3Crect width='800' height='800' fill='%23c0392b'/%3E%3C/svg%3E" alt=""></li>
<li><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800常见错误
- 现象:小图悬停无反应,或事件委托失效 → 原因:给
small绑了mouseenter,没有冒泡 → 修法:改用mouseover,并判断e.target.tagName === 'IMG'。 - 现象:边框加到图片上,离开后状态混乱 → 原因:
active加给了img,不是小图父级li→ 修法:先清理small内所有li.active,再给e.target.parentNode.classList.add('active')。 - 现象:大盒子隐藏延迟异常,越滑定时器越多 → 原因:每次
mouseleave都新建定时器,未清理旧定时器 → 修法:用外层timerId,mouseenter先clearTimeout(timerId),mouseleave再setTimeout。 - 现象:遮罩跟随鼠标时抖动、坐标跳变 → 原因:鼠标进入遮罩层,
offsetX/offsetY变成相对遮罩计算 → 修法:给遮罩加pointer-events: none,事件仍绑在middle上。 - 现象:遮罩跑出中盒子边界 → 原因:
left/top未做范围限制,或把200写死 → 修法:先算maxX = middle.offsetWidth - layer.offsetWidth、maxY同理,再用Math.max/Math.min夹取。 - 现象:大图移动方向反了、速度不对或样式无效 → 原因:未用负两倍关系,或漏
px、background-position格式错 → 修法:写成backgroundPosition = `\${-2 * x}px \${-2 * y}px`。 - 现象:鼠标移动样式不生效 → 原因:
cursor: move加在已设pointer-events: none的遮罩上 → 修法:把cursor: move加到middle。
练习
- 改错:现有代码给
small绑定mouseenter并尝试事件委托,导致小图悬停无反应。请改为可用方案,并保留“经过小图才处理”的判断。 - 实现小图选中态:鼠标经过任意小图时,仅其父级
li获得active,其余li的active必须移除;写出关键 JS。 - 实现大盒子显隐:鼠标进入
middle立即显示大盒子和遮罩,离开时遮罩立即隐藏、大盒子延迟 250ms 隐藏;要求页面最多只存在一个隐藏定时器。 - 实现遮罩约束:用
maxX/maxY和Math.min/Math.max让遮罩只能在中盒子内移动,并写出maxX的计算式。 - 实现大图联动:已知中图 400×400、大图 800×800,中图内坐标为
x、y,写出大图background-position的设置代码,并保证方向与比例正确。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 本次实战安排 | 03:03 |
| 2 | 放大镜案例-布局和业务分析 | 05:07 |
| 3 | 放大镜案例-鼠标经过小图片中图片跟随变化 | 14:00 |
| 4 | 放大镜案例-鼠标经过中盒子显示大盒子 | 11:42 |
| 5 | 放大镜案例-遮罩盒子跟随鼠标移动 | 12:35 |
| 6 | 放大镜案例-限定遮罩盒子范围 | 06:28 |
| 7 | 放大镜案例-大图片跟随移动 | 05:13 |
小结
- 放大镜应按“小图→中图→大图”的链路拆分和排错,四个业务可独立验证。
- 事件委托前先确认事件是否冒泡,
mouseover与mouseenter不能混用。 - 类名控制选中态,
display控制显隐,left/top控制遮罩,background-position控制大图,职责要分清。 - 遮罩坐标必须相对
middle计算,pointer-events: none是消除自遮挡抖动的关键。 - 边界约束不要写死数值,先算
maxX/maxY再夹取,适配不同尺寸更稳。 - 定时器要“进入先清、离开再开”,避免重复创建和延迟异常。
- 大图联动重点检查三件事:比例、方向、单位与格式。
- 调试顺序建议:元素获取→事件触发→坐标/地址→样式写入。