主题
商品预览缩略图-效果实现04与侧边栏选项卡布局(Day20 3/3)
本页对应 Day20_PC项目,共 5 段视频,约 59 分钟。
学习目标
- 能使用事件委托为缩略图容器绑定单击事件,判断点击目标为
img,并通过自定义属性data-index获取索引,正确更新放大镜小图与大图的src。 - 能按照给定结构完成侧边栏选项卡与商品详情选项卡的 HTML 布局,正确划分侧边栏、商品搭配、商品详情选项卡区域。
- 能实现侧边栏选项卡切换效果:切换时取消其他导航项和内容面板的选中状态,并为当前导航项及对应内容面板添加激活状态。
- 能完成商品详情选项卡的样式与交互:设置导航项横排、边框与激活样式,默认隐藏内容面板并显示第一个面板,实现点击切换。
- 能封装通用选项卡函数,接收导航按钮集合与内容面板集合作为参数,并复用于侧边栏选项卡和商品详情选项卡。
- 能诊断并修复 Flex 布局中侧边栏被侧轴拉伸的问题,通过设置
align-items: flex-start使左右区域按预期高度显示。
知识点
先明确目标:点击缩略图,大小图同步换图
这一节要解决的是商品预览区的联动:页面上有一组缩略图,用户点击某一张后,放大镜区域的小图和大图都要切换到对应商品图。缩略图不是写死的,而是前面根据 goodsData 遍历动态创建的,所以每个缩略图都要能拿到自己的索引,再用索引去数据里取 { s, b } 这样的对象,分别改小图和大图的 src。
为什么需要索引? 因为大图不是缩略图本身,不能直接从小图 src 推出来;正确做法是从数据源 goodsData 里取。怎么想? 点击时先知道“点的是第几个”,再用 goodsData[index] 取对象。怎么做? 可以给每个缩略图加点击事件,也可以做事件委托。易错点: 缩略图、大小图往往是包裹元素里的 img,改 src 要改到内部 img,不能改外层容器。
方案一:在创建 image 时直接绑定点击事件
最直观的办法,是在遍历 goodsData 创建 img 的地方顺手绑定事件。因为 forEach 的回调里天然有 item 和 index,闭包会把当前这一项数据保存下来。
js
goodsData.forEach((item, index) => {
const img = document.createElement('img');
img.src = item.s;
img.alt = `商品缩略图${index + 1}`;
img.addEventListener('click', () => {
smallImage.src = item.s;
largeImage.src = item.b;
});
thumbList.appendChild(img);
});为什么能行? 每次循环都创建一个 img,回调函数里能访问当次的 item。怎么想? 既然创建时就知道数据,直接绑定最省事。易错点: 转写里提到,这种写法会形成闭包;十几个元素问题不大,但元素很多时,内存占用时间会变长,性能不如事件委托。另外变量名别写错,转写里就出现过“多了个 g”这类拼写错误导致报错。
方案二:事件委托,把点击交给父元素
更推荐的做法是事件委托:不给每个 img 绑定,而是给缩略图的父容器绑定一次。点击后通过 e.target 判断真正点到了谁。
js
thumbList.addEventListener('click', function (e) {
if (e.target.tagName !== 'IMG') return;
const index = e.target.dataset.index;
const item = goodsData[index];
smallImage.src = item.s;
largeImage.src = item.b;
});为什么需要? 减少监听器数量,避免每个缩略图都形成闭包。怎么想? 点击事件会冒泡到父元素,父元素统一处理。怎么做? 先判断 e.target.tagName 是不是 IMG;不是就 return,否则取索引换图。易错点: tagName / nodeName 得到的是大写,如 IMG、DIV,判断时不要写成小写 img;点到缩略图之间的空白 div 时不能继续执行。
关键难点:事件委托里怎么拿到索引
事件委托写在循环外面,没有 forEach 的闭包可用,直接拿 index 拿不到。转写里的解决方案是:创建缩略图时,用自定义属性把索引记下来。
js
goodsData.forEach((item, index) => {
const img = document.createElement('img');
img.src = item.s;
img.alt = `商品缩略图${index + 1}`;
img.dataset.index = index; // 生成 data-index
thumbList.appendChild(img);
});点击时:
js
const index = e.target.dataset.index;
const item = goodsData[index];为什么需要? 数据在创建时能拿到,点击时也要能拿到。怎么想? “能获取时先记下来,触发事件时再取出来。” 怎么做? dataset.index = index 会在元素上生成 data-index,点击时 dataset.index 取回。易错点: 索引取出来后是字符串,但数组下标会自动转换;真正容易错的是把 dataset 拼错、把 index 写到错误元素上,或者大小图变量选错,导致“都改给小图了”。
调试过程与易错点
转写里调试时出现过几个典型问题:一是事件没添加,点了没反应;二是改 src 改到了包裹元素而不是内部 img;三是大小图变量写混,出现“改偏了”“都改给小图了”;四是 HTML 忘了保存,样式或结构不生效。排查时先确认监听有没有加上,再打印 e.target 和 index,最后检查 smallImage、largeImage 是否精确指向内部图片元素。由于两个立即执行函数里的变量不能共用,需要用到时要在当前作用域单独获取。
小结
缩略图联动的核心链路是:动态创建缩略图 → 用 data-index 记录索引 → 父容器做事件委托 → 判断 IMG → 取索引 → 从 goodsData 取 {s,b} → 分别修改小图和大图 src。直接绑定能实现,但事件委托更稳、更省监听器;自定义属性则解决了“事件委托时拿不到循环索引”的问题。
一、先把大结构分清楚:左栏与右栏
为什么需要:页面下半部分同时有侧边栏选项卡、商品搭配、商品详情选项卡,如果直接往版心里堆,后面样式和 JS 都会找不到位置。
怎么想:先把这一整块放进 product-down-section,再分左右:左边是 product-side-bar,右边是 product-intro;右边继续分上下:上面商品搭配,下面商品详情选项卡。
怎么做:
html
<div class="product-down-section">
<div class="product-side-bar">侧边栏选项卡</div>
<div class="product-intro">
<div class="product-collection">商品搭配</div>
<div class="product-intro-table" id="intro-tab">商品详情选项卡</div>
</div>
</div>易错点:不要重复加版心;左右两块要放在同一个伸缩容器里,后面才能一左一右排列。
二、侧边栏选项卡的 HTML:导航和面板一一对应
为什么需要:选项卡由“按钮”和“内容面板”两部分组成,点击按钮只显示对应面板。
怎么想:导航放两个 table-nav-item,面板放两个 table-panel-item,默认给第一组加 active。
怎么做:
html
<div class="table-nav">
<div class="table-nav-item active">相关分类</div>
<div class="table-nav-item">推荐品牌</div>
</div>
<div class="table-panel">
<div class="table-panel-item active">相关分类内容</div>
<div class="table-panel-item">推荐品牌内容</div>
</div>易错点:类名是 table-nav,不要写成 table-navy;面板默认要隐藏,只有 .active 才显示:
less
.table-panel-item {
display: none;
&.active { display: block; }
}三、样式:两个按钮横排、等宽、选中变红
为什么需要:导航按钮要横向排列,选中项要明显区别于未选中项。
怎么想:父级 display:flex;每个按钮宽 105、行高 37、文字居中;默认上边框白、下边框灰,选中后上边框变红、下边框变白。
怎么做:
less
.table-nav {
display: flex;
height: 40px;
.table-nav-item {
width: 105px;
line-height: 37px;
text-align: center;
border-top: 2px solid #fff;
border-bottom: 1px solid #ccc;
&.active {
border-top-color: @btn-red;
border-bottom-color: #fff;
}
}
}易错点:行高不要直接写 40,因为上下边框也占位置;转写稿里调试时就是先把默认态写出来,再看选中态。
四、面板内容:两列列表与商品卡片
为什么需要:相关分类是短列表,推荐商品是图文卡片,布局方式不同。
怎么想:第一个面板里的 ul 用 display:flex; flex-wrap:wrap;,每个 li 宽 50%,一行两个;商品列表每个 li 改成竖向 flex,让图片、标题、价格、按钮居中。
怎么做:
less
.table-panel-item:first-child ul {
display: flex;
flex-wrap: wrap;
li { width: 50%; line-height: 28px; border-bottom: 1px dashed #ccc; }
}
.product-list li {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px 0 15px;
border-bottom: 1px solid #ededed;
img { width: 152px; }
p { width: 100%; }
.price { font-size: 16px; color: #8c8c8c; }
}易错点:p 不写 width:100% 时,受 align-items:center 影响,文字不会靠左;图片也要限制宽度,否则会撑破卡片。
五、JS 切换:先排他,再给当前项加选中
为什么需要:点击“推荐品牌”时,导航和面板必须同时切换。
怎么想:导航集合和面板集合一一对应,先全部移除 active,再用当前索引给对应的导航和面板加 active。
怎么做:
js
const navItems = document.querySelectorAll('.table-nav-item');
const panelItems = document.querySelectorAll('.table-panel-item');
navItems.forEach((item, index) => {
item.onclick = function () {
for (let i = 0; i < navItems.length; i++) {
navItems[i].classList.remove('active');
panelItems[i].classList.remove('active');
}
navItems[index].classList.add('active');
panelItems[index].classList.add('active');
};
});易错点:转写稿调试时出现过“单击事件没添加”的情况,写完一定要刷新点击;另外不能只移除导航的 active,面板也要移除。
六、封装选项卡函数,并修复侧轴拉伸
为什么需要:商品详情选项卡和侧边栏选项卡逻辑一样,复制两遍维护麻烦。
怎么想:把两个集合作为参数传进 table 函数,页面里只负责取元素和调用。
怎么做:
js
function table(navList, panelList) {
navList.forEach((item, index) => {
item.onclick = function () {
for (let i = 0; i < navList.length; i++) {
navList[i].classList.remove('active');
panelList[i].classList.remove('active');
}
navList[index].classList.add('active');
panelList[index].classList.add('active');
};
});
}易错点:左边侧边栏突然被拉得很高,是父级 display:flex 默认 align-items:stretch 导致的,在 product-down-section 上加 align-items:flex-start 即可。转写稿里还出现过“默认谁都没有显示”,最后发现是 HTML 忘了保存,这类问题要先检查文件是否保存再查代码。
先明确本节目标:侧边栏选项卡要“能切换、能复用”
为什么需要:侧边栏选项卡不是单纯把文字排上去,而是要让“相关分类 / 推荐品牌”这类导航按钮和下面的内容面板一一对应,点击谁就显示谁。
怎么想:先搭结构,再做样式,最后写 JS。转写稿里反复强调“结构脑子里要能画出来”,因为选项卡本质是“导航集合 + 内容集合”的对应关系。
怎么做:整体放在 product-section 中,左侧是 product-sidebar,右侧再分“商品搭配”和“商品详情选项卡”。
易错点:不要一上来写 JS;结构没分清楚,后面选择器会到处找不到元素。
html
<div class="product-section">
<div class="product-sidebar">
<!-- 侧边栏选项卡 -->
</div>
<div class="product-intro">
<div class="product-collection"><!-- 商品搭配,先空着 --></div>
<div class="product-intro-tab" id="introTab">
<div class="tab-nav">
<div class="tab-nav-item active">相关分类</div>
<div class="tab-nav-item">推荐品牌</div>
</div>
<div class="tab-panel">
<div class="tab-panel-item active">...</div>
<div class="tab-panel-item">...</div>
</div>
</div>
</div>
</div>选项卡导航样式:横排、选中态、边框合并
为什么需要:导航按钮默认是块级元素,会竖着排;选项卡必须横排,而且选中项要有明显状态。
怎么想:外层用 display:flex 让导航横过来;每个按钮固定宽高、文字居中;默认上边框白色、下边框灰色,选中后上边框变红、下边框变白。
怎么做:转写稿里用了 tab-nav、tab-nav-item,选中类名是 active。
易错点:相邻按钮边框会“加倍”,不要用表格的边框合并思路,而是每个按钮设右边框,再给父元素补左边框,或者按转写稿方式单独处理。
css
.product-section {
margin-top: 30px;
display: flex;
justify-content: space-between;
align-items: flex-start; /* 解决左侧被 flex 拉伸 */
}
.product-sidebar {
width: 210px;
border: 1px solid #ccc;
}
.tab-nav {
display: flex;
height: 40px;
}
.tab-nav-item {
width: 105px;
line-height: 37px;
text-align: center;
border-top: 2px solid #fff;
border-bottom: 1px solid #ccc;
}
.tab-nav-item.active {
border-top-color: #e1251b;
border-bottom-color: #fff;
}
.tab-panel-item {
display: none;
padding: 10px;
}
.tab-panel-item.active {
display: block;
}内容面板:默认只显示一个
为什么需要:两个面板不能同时出现,否则“选项卡”就没有意义。
怎么想:内容项默认 display:none,只有带 active 的才 display:block。HTML 里先给第一个面板加 active。
怎么做:转写稿中视频4曾出现“默认谁都没显示”,调试发现是 HTML 忘了保存;保存后默认面板就出来了。
易错点:CSS 写了隐藏,但 HTML 没给默认 active,页面会像“坏了”一样空白。
JS 切换:排他、索引对应、当前项激活
为什么需要:点击导航时,要清掉所有旧选中,再给当前导航和对应面板加 active。
怎么想:导航集合和面板集合一一对应,所以可以用同一个索引 index。转写稿里视频3一开始点不动,原因是只写了逻辑,忘了给 tab-nav-item 绑 onclick。
怎么做:先遍历导航项绑事件;事件里先循环移除所有 active,再给当前项和 panelItems[index] 加 active。
易错点:不要只清导航不清面板;也不要只给当前加、忘了排他。
js
function tab(navItems, panelItems) {
navItems.forEach(function (item, index) {
item.onclick = function () {
for (let i = 0; i < navItems.length; i++) {
navItems[i].classList.remove('active');
panelItems[i].classList.remove('active');
}
item.classList.add('active');
panelItems[index].classList.add('active');
};
});
}封装 tab 函数:商品详情选项卡直接复用
为什么需要:侧边栏选项卡和商品详情选项卡逻辑一样,复制两遍容易漏改。
怎么想:把“导航集合”和“面板集合”作为参数传进去,函数内部不关心具体是哪个选项卡。
怎么做:侧边栏调用一次;商品详情从 #introTab 容器里查两组集合再调用。转写稿里说“现在想搞个选项卡就跟玩儿似的”,就是因为复用了。
易错点:传参必须是元素集合,不是单个元素;查询范围要限定在对应容器内,避免选到别的选项卡。
js
tab(
document.querySelectorAll('.product-sidebar .tab-nav-item'),
document.querySelectorAll('.product-sidebar .tab-panel-item')
);
tab(
document.querySelectorAll('#introTab .tab-nav-item'),
document.querySelectorAll('#introTab .tab-panel-item')
);收尾调试:左侧突然变高是 flex 拉伸
为什么需要:转写稿最后发现左侧侧边栏“突然这么高”,不是样式写错,而是父容器 display:flex 后,子项默认在侧轴拉伸。
怎么想:左侧没有固定高度时,会被右侧内容撑得一样高。
怎么做:在 product-section 上加 align-items:flex-start,让子项从侧轴起点对齐。
易错点:遇到“莫名变高/变宽”,先看父级是不是 flex,再看 align-items、flex 方向。这个调试过程比死记样式更重要。
(本节笔记未附图片,以上不虚构配图;代码按转写稿中的结构、类名和同音字纠正整理。)
课堂画面





代码示例
1. 商品预览缩略图 —— 事件委托 + 自定义属性存索引
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品预览缩略图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
ul, li { list-style: none; }
.zoom-book { width: 420px; margin: 30px auto; }
.zoom-book .big-box { width: 420px; height: 420px; border: 1px solid #ccc; }
.zoom-book .big-box img,
.zoom-book .small-box img { width: 100%; height: 100%; display: block; }
.zoom-book .small-box { width: 100px; height: 100px; margin-top: 10px; border: 1px solid #ccc; }
.zoom-book .thumb-list { display: flex; margin-top: 10px; }
.zoom-book .thumb-list img { width: 60px; height: 60px; margin-right: 8px; cursor: pointer; display: block; }
</style>
</head>
<body>
<div class="zoom-book">
<div class="big-box"><img class="large-image" src="" alt=""></div>
<div class="small-box"><img class="small-image" src="" alt=""></div>
<ul class="thumb-list"></ul>
</div>
<script>
(function () {
// 每个对象里 s = 小图地址,b = 大图地址
var data = [
{ s: 'https://dummyimage.com/100x100/e1251b/fff&text=1', b: 'https://dummyimage.com/420x420/e1251b/fff&text=1' },
{ s: 'https://dummyimage.com/100x100/333/fff&text=2', b: 'https://dummyimage.com/420x420/333/fff&text=2' },
{ s: 'https://dummyimage.com/100x100/08a/fff&text=3', b: 'https://dummyimage.com/420x420/08a/fff&text=3' }
];
var thumbList = document.querySelector('.zoom-book .thumb-list');
var smallImage = document.querySelector('.zoom-book .small-image');
var largeImage = document.querySelector('.zoom-book .large-image');
// 1. 遍历数据生成缩略图,同时把索引记录到元素的自定义属性上
data.forEach(function (item, index) {
var img = document.createElement('img');
img.src = item.s;
img.dataset.index = index; // 生成 data-index="0/1/2"
thumbList.appendChild(img);
});
// 默认展示第一张
smallImage.src = data[0].s;
largeImage.src = data[0].b;
// 2. 事件委托:只给父容器绑一次
thumbList.onclick = function (e) {
if (e.target.tagName !== 'IMG') return; // tagName 是大写
var obj = data[e.target.dataset.index]; // 取回索引 → 取数据
smallImage.src = obj.s;
largeImage.src = obj.b;
};
})();
</script>
</body>
</html>要点与易错点
img.dataset.index = index会生成data-index属性,相当于把循环里的index“存”在 DOM 上;事件委托处没有循环、没有闭包,只能靠它取回索引。- 委托只绑一次(绑在
.thumb-list上),比给每个img都写onclick省内存,元素多了差距明显。 e.target.tagName返回的是大写IMG,判断写成'img'永远不成立。- 委托对象要选准:若绑到
.zoom-book上,点大图/小图也会命中IMG,必须再缩小判断范围。
2. 侧边栏选项卡 —— 布局 + 效果
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>侧边栏选项卡</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
ul, li { list-style: none; }
a { text-decoration: none; }
:root { --btn-red: #e1251b; } /* 对应 less 中的 @btn-red */
.product-down-section {
width: 1200px;
margin: 30px auto 0;
display: flex;
justify-content: space-between;
}
.product-side-bar { width: 210px; border: 1px solid #ccc; }
/* ---- 选项卡导航 ---- */
.table-nav { display: flex; height: 40px; }
.table-nav-item {
width: 105px;
line-height: 37px; /* 40 - 上下边框各 1px - 上边框 2px */
text-align: center;
border-top: 2px solid #fff;
border-bottom: 1px solid #ccc;
cursor: pointer;
}
.table-nav-item.active {
border-top-color: var(--btn-red);
border-bottom-color: #fff;
}
/* ---- 选项卡内容 ---- */
.table-panel-item { display: none; padding: 10px; }
.table-panel-item.active { display: block; }
.category { display: flex; flex-wrap: wrap; }
.category li { width: 50%; line-height: 28px; border-bottom: 1px dashed #ccc; }
.products li {
margin: 5px 0 15px;
border-bottom: 1px solid #ededed;
display: flex;
flex-direction: column;
align-items: center;
}
.products li img { width: 152px; display: block; }
.products li p { width: 100%; }
.products li .price { font-size: 16px; color: var(--btn-red); }
.products li a {
margin: 5px 0;
padding: 2px 14px;
line-height: 18px;
border: 1px solid #8c8c8c;
color: #8c8c8c;
}
</style>
</head>
<body>
<div class="product-down-section">
<!-- 左侧:侧边栏选项卡 -->
<div class="product-side-bar" id="side-tab">
<div class="table-nav">
<div class="table-nav-item active">相关分类</div>
<div class="table-nav-item">推荐品牌</div>
</div>
<div class="table-panel">
<div class="table-panel-item active">
<ul class="category">
<li>手机通讯</li><li>电脑办公</li><li>摄影摄像</li><li>影音娱乐</li>
</ul>
<ul class="products">
<li>
<img src="https://dummyimage.com/152x152/eee/333&text=1" alt="">
<p class="product-t">推荐商品标题一</p>
<p class="price">¥1999.00</p>
<a href="javascript:;">加入购物车</a>
</li>
<li>
<img src="https://dummyimage.com/152x152/eee/333&text=2" alt="">
<p class="product-t">推荐商品标题二</p>
<p class="price">¥2999.00</p>
<a href="javascript:;">加入购物车</a>
</li>
</ul>
</div>
<div class="table-panel-item">推荐品牌内容</div>
</div>
</div>
<!-- 右侧占位(下一段代码填充) -->
<div class="product-intro" style="width: 980px;"></div>
</div>
<script>
(function () {
var navItems = document.querySelectorAll('#side-tab .table-nav-item');
var panelItems = document.querySelectorAll('#side-tab .table-panel-item');
navItems.forEach(function (item, index) { // forEach 自带闭包 index
item.onclick = function () {
// 1. 先排他:所有导航 + 所有内容都取消选中
for (var i = 0; i < navItems.length; i++) {
navItems[i].classList.remove('active');
panelItems[i].classList.remove('active');
}
// 2. 再让当前点击项和它对应的内容选中
navItems[index].classList.add('active');
panelItems[index].classList.add('active');
};
});
})();
</script>
</body>
</html>要点与易错点
.table-nav用 `display
常见错误
- 现象:缩略图点击后无反应。原因:事件委托里判断
tagName时写成小写img,而实际返回大写IMG。修法:统一转小写判断,或直接判断大写IMG。 - 现象:事件委托能点中图片,但拿不到对应索引。原因:委托回调外层没有循环,无法形成闭包保存索引。修法:创建缩略图时写入
dataset.index,点击时从e.target.dataset.index读取。 - 现象:点击缩略图后大图小图改错,或改到包裹元素上。原因:选择器选到了外层容器,或
s/b字段对应错误。修法:精确定位smallImage、largeImage,按数据对象字段分别赋值。 - 现象:选项卡点击不切换。原因:导航项未注册点击事件,或事件写错位置。修法:在导航项上绑定
click,或统一事件委托,并确认事件已注册。 - 现象:选项卡内容默认全显示,或切换后空白。原因:
panel未默认隐藏,active类未同步,或 HTML 未保存/默认项未加active。修法:CSS 控制默认隐藏与激活显示,默认第一个加active,JS 中同步导航与内容。 - 现象:左侧侧边栏高度被拉到和右侧一样高。原因:父容器
display:flex默认align-items:stretch。修法:父容器设置align-items:flex-start。
练习
- 用事件委托实现缩略图点击切换大小图,要求:点空白区域不触发,且能拿到当前图片索引。
- 复现“标签名大小写导致点击无效”的问题,并给出两种修法。
- 实现侧边栏选项卡:默认第一个选中,点击导航切换对应内容,导航与内容
active同步。 - 将选项卡逻辑封装为
table(navItems, panelItems),分别用于侧边栏选项卡和商品详情选项卡。 - 修复商品详情页左侧侧边栏被 flex 拉伸的问题,并说明触发条件。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 商品预览缩略图-效果实现04 | 14:31 |
| 2 | 侧边栏选项卡布局 | 23:12 |
| 3 | 侧边栏选项卡-效果实现 | 05:14 |
| 4 | 商品详情选项卡-布局 | 10:54 |
| 5 | 商品详情选项卡-效果实现 | 05:55 |
小结
- 动态生成的缩略图更适合用事件委托,避免逐个绑定。
- 动态元素需要索引时,应在创建阶段用
dataset记录,事件触发时再读取。 tagName返回大写,判断标签名时必须处理大小写。- 大小图切换要精确选择目标元素,并按数据字段分别赋值。
- 选项卡核心是“排他 + 当前激活 + 导航与内容一一对应”。
- 重复选项卡逻辑应封装为函数,元素集合通过参数传入。
- flex 容器默认会拉伸侧轴项目,必要时用
align-items:flex-start修正。 - 相邻边框重复时,可通过去掉单侧边框并由父容器补边来解决。