导读:本文以原生 HTML、CSS(Less)、JavaScript 为主线,从零拆解 PC 端商品详情页的完整实现路径------工程化样式准备、顶栏与导航骨架、面包屑、放大镜、缩略图轮播、侧边栏与详情选项卡,以及 IIFE、事件委托与节流等工程实践。文中示例均可直接在浏览器中运行验证,并引用 MDN
getBoundingClientRect、事件委托 等权威资料。
目录
- 零、导读与学习价值
- [0.1 示例覆盖清单](#0.1 示例覆盖清单)
- [0.2 核心名词速查](#0.2 核心名词速查)
- [0.3 为什么要学本篇](#0.3 为什么要学本篇)
- [0.4 前置知识](#0.4 前置知识)
- [0.5 建议练习路线](#0.5 建议练习路线)
- [0.6 渐进式文件变更与后续衔接](#0.6 渐进式文件变更与后续衔接)
- 一、工程化准备:Less、重置与公共样式
- 二、页面骨架:顶栏、Logo、导航与面包屑
- 三、商品放大镜:布局与坐标联动
- 名词解释
- 概念与底层原理
- [mermaid 图:放大镜坐标流](#mermaid 图:放大镜坐标流)
- 可运行示例(入门):局部坐标计算
- 可运行示例(实战):简化版放大镜(IIFE)
- 【实战要点】
- 【本章小结】
- 【面试考点】
- 四、缩略图轮播:动态渲染、边界与节流
- 五、选项卡:排他思想与函数复用
- 六、数据驱动与工具函数库
- 总结
零、导读与学习价值
0.1 示例覆盖清单
本文完整覆盖以下九个渐进式实战模块的知识点与实现要点:
| 序号 | 主题 | 核心技能 |
|---|---|---|
| 1 | 整体准备 | Less 入口、@import、CSS 重置、变量与混合、.container 定宽居中 |
| 2 | 顶部导航栏 | Flex 左右分布、链接分隔线、min-width 防挤压 |
| 3 | Logo 与搜索框 | 头部 Flex、表单 box-sizing、品牌色变量 |
| 4 | 页面导航 | 底边强调色、分类标题块、横向 nav 链接组 |
| 5 | 路径导航 | 面包屑 Flex、::after 伪元素分隔符 |
| 6 | 放大镜 | 绝对定位层叠、蒙层边界、scrollLeft/Top 比例联动 |
| 7 | 商品预览缩略图 | goodData 动态 Image、offsetLeft 滚动、timeStamp 节流、事件委托换图 |
| 8 | 侧边栏选项卡 | .active 显隐、tab() 函数封装 |
| 9 | 商品详情选项卡 | 同一 tab() 复用不同 DOM 集合 |
完整页面效果可将本节最终阶段的所有代码保存为同目录下的 index.html,保持同级 css/、js/、images/ 结构后双击打开查看。
0.2 核心名词速查
| 术语 | 一句话解释 |
|---|---|
| Less | CSS 预处理器,支持变量、嵌套、混合,编译为浏览器可识别的 CSS |
| IIFE | 立即执行函数,创建私有作用域,避免全局变量污染 |
| 事件委托 | 在父元素上统一监听,通过 event.target 判断实际触发源 |
| 排他思想 | 切换状态时先清除所有项的 active,再给当前项加上 active |
getBoundingClientRect |
返回元素相对视口的位置与尺寸,用于计算鼠标在容器内的坐标 |
| 节流(本节用法) | 用 event.timeStamp 限制两次有效点击的最小间隔,防止连点 |
0.3 为什么要学本篇
- 岗位价值:电商详情页是前端高频业务,放大镜、规格区、详情 Tab 几乎每家平台都有。
- 技术底座:不依赖框架即可完成布局 + 交互,为后续 Vue/React 组件化打基础。
- 工程习惯:模块化样式、公共函数库、数据与视图分离,是大型项目的通用套路。
0.4 前置知识
阅读本文前,建议已掌握:
- HTML5 常见标签与语义化结构(
header、nav、main等); - CSS 盒模型、
display: flex、定位(relative/absolute); - JavaScript DOM 查询(
querySelector)、事件监听、基础数组方法(forEach)。
若对 Less 编译 不熟,可先安装 VS Code 插件 Easy Less 或 Live Less Compiler ,保存 .less 时自动生成同目录 index.css。
0.5 建议练习路线
九个渐进模块按依赖关系拼装,推荐顺序:
#mermaid-svg-V8Ia7U1TUvlWGOTT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-V8Ia7U1TUvlWGOTT .error-icon{fill:#552222;}#mermaid-svg-V8Ia7U1TUvlWGOTT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-V8Ia7U1TUvlWGOTT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .marker.cross{stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-V8Ia7U1TUvlWGOTT p{margin:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label text{fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label span{color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label span p{background-color:transparent;}#mermaid-svg-V8Ia7U1TUvlWGOTT .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT span{fill:#333;color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node rect,#mermaid-svg-V8Ia7U1TUvlWGOTT .node circle,#mermaid-svg-V8Ia7U1TUvlWGOTT .node ellipse,#mermaid-svg-V8Ia7U1TUvlWGOTT .node polygon,#mermaid-svg-V8Ia7U1TUvlWGOTT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .rough-node .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label{text-anchor:middle;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .rough-node .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label{text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node.clickable{cursor:pointer;}#mermaid-svg-V8Ia7U1TUvlWGOTT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .arrowheadPath{fill:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster text{fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster span{color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-V8Ia7U1TUvlWGOTT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT rect.text{fill:none;stroke-width:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape p,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label rect,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-V8Ia7U1TUvlWGOTT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 整体准备
顶栏
Logo与搜索
页面导航
面包屑
放大镜
缩略图
侧栏Tab
详情Tab
【代码注释】每步只新增一块 DOM 与对应 Less 片段,避免一次写完全页难以调试;放大镜之后 JS 开始介入,缩略图阶段依赖放大镜建立的 #zoomBox 结构。
| 阶段 | 范围 | 本步验收标准 |
|---|---|---|
| 样式骨架 | 整体准备 → 面包屑 | 顶栏、搜索、红底导航、面包屑视觉正确 |
| 核心交互 | 放大镜 + 缩略图 | 悬停放大、缩略图滚动与换图 |
| 组件复用 | 侧栏Tab + 详情Tab | 两处选项卡共用 tab(),无全局变量冲突 |
0.6 渐进式文件变更与后续衔接
每前进一个模块,通常只改 css/index.less(增量样式)与 js/index.js(增量 IIFE),data.js / functions.js 从 05 模块起复制并沿用:
| 阶段 | 新增/修改重点 | 典型新增选择器或 id |
|---|---|---|
| 整体准备 | reset、variables、mixins、.container |
--- |
| 骨架布局 | 顶栏、header、nav、.path-nav |
.topbar、.page-nav |
| 放大镜 | .zoom-box、蒙层、大图窗 |
#zoomBox |
| 缩略图 | .thumb-box、箭头、.thumb-wrapper |
#thumbBox |
| 侧栏 Tab | .product-siderbar、侧栏 Tab |
#siderbarTab |
| 详情 Tab | .product-intro-tab、详情 Tab |
#introTab |
【代码注释】相邻两个阶段的 index.less 做 diff 可只看本阶段新增片段;index.js 在缩略图阶段之后常是「前面 IIFE 保留 + 底部追加」,勿删旧逻辑。
本阶段终点能力 :静态布局 + 放大镜 + 缩略图 + 双 Tab。goodData.goodsDetail.crumbData 已在 data.js 预留,下一阶段 会在 .product-box 区用动态 dl/dd 做规格选择与算价,并引入原生 CSS var() / calc() 做布局计算与主题色管理------与本篇 Less 变量互补而非替代。
一、工程化准备:Less、重置与公共样式
名词解释
- CSS 重置(Reset) :抹平浏览器默认
margin、list-style等差异,让各端起点一致。 - Less 变量 :用
@name: value集中管理主题色,改一处全局生效。 - Less 混合(Mixin) :可复用的样式块,如
.clearfix()清除浮动。
概念与底层原理
浏览器对同一标签的默认样式并不一致(例如 body 的 margin、ul 的 padding)。重置样式在级联层 最早生效,后续业务样式在此基础上叠加。根据 W3C CSS 级联规范,样式优先级由重要性、来源、特指度、源码顺序决定;重置通过低特指度选择器(如 * { margin: 0 })确保被业务样式覆盖。
Less 在构建阶段被编译成普通 CSS,变量在编译期替换,运行时浏览器只看到标准 CSS ------这与 CSS 自定义属性(--color)在运行时可变不同,适合「主题色固定」的电商站。CSS 变量的 inherit 语义由 CSS Custom Properties 规范 定义,可通过 JS 实时修改实现主题切换,但存在序列化开销。
.container { width: 1200px; margin: 0 auto; } 是经典定宽居中:块级元素水平外边距 auto 会均分剩余空间,从而居中。这是 CSS Box Model 中 margin-top/bottom 在块级格式化上下文(BFC)中的行为:若元素有显式宽度且水平方向有剩余空间,auto 边距会拉伸填充。
2026 年的现状:Less 嵌套语法 vs 原生 CSS 嵌套
Less 的嵌套写法在 2026 年已可以用原生 CSS 嵌套完全替代(Chrome 120+、Firefox 117+、Safari 17.2+ 原生支持,全球覆盖率 > 92%):
css
/* Less 写法(需编译) */
.topbar {
background: #eaeaea;
.container {
display: flex;
a { color: #666; }
}
}
/* 原生 CSS 嵌套(无需编译,直接在浏览器运行) */
.topbar {
background: #eaeaea;
& .container { /* & 显式引用父级,与 Less 的 & 相同 */
display: flex;
& a { color: #666; }
}
}
【代码注释】原生 CSS 嵌套规范来自 CSS Nesting Module Level 1。新项目若目标浏览器支持率满足要求,可直接用原生嵌套,省去 Less 编译步骤。老项目迁移时,区别在于原生 CSS 嵌套要求非 & 开头的选择器必须加 &,否则部分解析器行为不一致。
mermaid 图:Less 编译与样式引入关系
渲染错误: Mermaid 渲染失败: Parse error on line 2: ...index.less 入口 --> B@import variables -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'
【代码注释】入口文件按「变量 → 混合 → 重置 → 业务」顺序 @import,保证业务里能使用 @form-red 等变量,且重置不被业务意外覆盖(同一选择器时仍遵循 CSS 优先级规则)。
可运行示例(入门):变量与嵌套
将下面内容保存为 demo-less-vars.html,放在本文所在目录后双击打开:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Less 变量思想演示(编译后等价 CSS)</title>
<style>
/* 模拟 Less 编译结果:@form-red: #ea4a36 */
.search-btn {
background: #ea4a36;
color: #fff;
border: none;
padding: 8px 16px;
}
.search-input {
border: 2px solid #ea4a36;
padding: 6px;
}
</style>
</head>
<body>
<input class="search-input" placeholder="搜索商品">
<button class="search-btn">搜索</button>
</body>
</html>
【代码注释】真实项目中在 variables.less 定义 @form-red: #ea4a36,搜索框边框与按钮背景共用该变量;改品牌色只改一行。这是「设计令牌」思想的雏形,与 Ant Design 的 @primary-color 同类。
可运行示例(实战):重置 + 定宽容器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>重置与 container 演示</title>
<style>
body, ul, p { margin: 0; padding: 0; }
ul { list-style: none; }
a { text-decoration: none; color: #666; }
.container {
width: 1200px;
margin-left: auto;
margin-right: auto;
background: #f5f5f5;
padding: 12px;
}
</style>
</head>
<body>
<div class="container">
<p>定宽 1200px 居中区域 ------ 电商 PC 站常见版心宽度</p>
</div>
</body>
</html>
【代码注释】margin: auto 仅对块级且设定了 width 的元素水平居中;与 max-width + 百分比配合可做简单响应式,本项目以 PC 固定版心为主。
项目中 reset.less 还统一了标题字号、table 折叠边框、html { overflow-y: scroll; } 防止滚动条闪动等细节。
混合示例(清除浮动):
less
.clearfix() {
&::after {
content: "";
display: block;
clear: both;
}
}
【代码注释】伪元素 ::after 生成块级盒子并 clear: both,使父元素包裹住浮动子元素;Flex 布局普及后用得少,但读懂有助于维护老项目。
变量与嵌套(项目 variables.less + 顶栏片段):
less
@form-red: #ea4a36;
@btn-red: #e1251b;
@sep-color: #b3aeae;
.topbar {
height: 30px;
background: #eaeaea;
.container {
display: flex;
justify-content: space-between;
a {
padding: 0 10px;
&:not(:last-child) {
border-right: 1px solid @sep-color;
}
}
}
}
【代码注释】& 代表父选择器,编译为 .topbar .container a:not(:last-child);嵌套层级不宜超过 3~4 层,否则生成选择器过长、特指度难控。@sep-color 与竖线分隔线共用,改灰色系只改变量。
入口 index.less 标准写法:
less
@import "variables";
@import "mixins";
@import "reset";
// 以下为各模块业务样式,随模块递增而追加
.container { width: 1200px; margin-left: auto; margin-right: auto; }
【代码注释】@import 无扩展名时 Less 自动找 .less;编译后浏览器只请求一个 index.css,利于缓存。
【实战要点】
- 经典应用场景 :多页面站点用同一套
reset+variables+mixins,各页index.less只写差异部分。天猫、京东的 PC 站均采用 1200px 版心 + Less 编译流程。 - 常见坑 :Less 修改后忘记重新编译,
index.css仍是旧内容------开发时用 Live Less 或构建脚本监听;@import在 CSS 中会阻塞渲染,Less 的@import编译后合并到单文件可避免此问题。 - 性能与最佳实践 :生产环境只引入编译后的一个
index.css,减少 HTTP 请求;变量名语义化(@btn-red而非@c1);考虑 CSS 压缩与contenthash文件名做长期缓存。
【本章小结】
| 要点 | 说明 |
|---|---|
| 重置 | 统一标签默认样式,减少跨浏览器差异 |
| 变量/混合 | 主题色与公共片段集中维护 |
.container |
1200px 版心 + 水平居中 |
记忆口诀:「先 reset,再变量,版心 container 包全场」。
【面试考点】
Q1:Less 变量和 CSS 变量(--x)有什么区别?
A:Less 变量在编译阶段 替换,浏览器看不到变量名;CSS 变量在运行时 由浏览器解析,可用 JS 修改 document.documentElement.style.setProperty('--x', ...) 做主题切换。电商固定皮肤常用 Less;需要动态换肤用 CSS 变量更合适。追问「CSS 变量性能」时答:CSS 变量继承与计算发生在样式解析阶段,与编译后常量差异可忽略;但频繁 JS 修改 --x 会触发重排,建议配合 requestAnimationFrame 批量写入。
Q2:为什么要做 CSS Reset?
A:消除 User Agent 默认样式差异,避免「本地正常、别的浏览器歪了」;现代项目也可用 Normalize.css 保留有用默认(如 h1 字号),思路类似但策略不同。Reset 会抹掉所有默认,需自行补充(如 table { border-collapse: collapse });Normalize 保留有用部分并修正已知 bug。
Q3:margin: auto 居中的原理是什么?
A:块级盒子的 margin-left/right 为 auto 时,若元素有显式 width 且水平方向有剩余空间,浏览器会均分剩余空间给左右 margin,实现居中。这是 CSS Box Model 中 auto margin 的分配规则。垂直方向 margin: auto 在普通流中不居中(高度由内容决定),需配合 Flex 或绝对定位。
Q4:Less 文件改了但页面没变?
A:浏览器只加载 index.css,不识别 .less。确认:1)已安装编译插件或构建任务;2)保存的是 index.less 而非只改未引入的片段;3)HTML 里 <link href="./css/index.css"> 路径正确;4)强刷缓存(DevTools 禁用 cache)。
Q5:2026 年还有必要用 Less 吗?原生 CSS 已经支持嵌套和自定义属性。
A:这是个场景问题。原生 CSS 已经覆盖了 Less 的大多数日常用途:
- CSS 嵌套(Chrome 120+ / Firefox 117+):写法与 Less/Sass 几乎相同,无需构建步骤。
- CSS 自定义属性 (
--color: #ea4a36):运行时可变,document.documentElement.style.setProperty('--color', ...)实现主题切换,比 Less 变量更强大。 color-mix()/oklch():现代浏览器原生颜色混合,替代 Less 的lighten()/darken()。
仍值得用 Less 的场景 :① 需要兼容不支持 CSS 嵌套的旧浏览器(IE 11、部分 Edge 遗留);② 需要 @for 循环、条件 @if、复杂 mixin 参数化生成(如表格格宽批量生成)等编译期逻辑 ,原生 CSS 无法替代;③ 团队现有项目已大量使用 Less 变量,迁移成本高。新项目建议优先用 CSS 自定义属性 + 原生嵌套,仅在确实需要编译期 logic 时引入 Sass/Less。
可运行示例(补充):Less 编译前后对照
保存为 demo-less-compile.html(内联已编译 CSS,模拟 index.css 效果):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Less 编译结果演示</title>
<style>
/* 等价于 variables.less + 业务 */
.topbar { height: 30px; line-height: 30px; background: #eaeaea; }
.topbar .container {
width: 1200px; margin: 0 auto;
display: flex; justify-content: space-between;
}
.topbar a { padding: 0 10px; color: #666; text-decoration: none; }
.topbar a:not(:last-child) { border-right: 1px solid #b3aeae; }
</style>
</head>
<body>
<div class="topbar">
<div class="container">
<span>欢迎</span>
<nav><a href="#">订单</a><a href="#">购物车</a></nav>
</div>
</div>
</body>
</html>
【代码注释】真实项目里 @sep-color 在 Less 里定义,编译后变成 #b3aeae 常量;改 @sep-color 后必须重新编译才能看到效果。
二、页面骨架:顶栏、Logo、导航与面包屑
名词解释
- Flex 主轴/交叉轴 :
justify-content控制主轴对齐,align-items控制交叉轴。 - 面包屑(Breadcrumb):层级路径导航,利于用户定位与 SEO 结构化数据。
概念与底层原理
顶栏 .topbar 高度 30px、背景 #eaeaea,内部 .container 使用 display: flex; justify-content: space-between,把「欢迎区」与「功能链接区」推到两端。根据 CSS Flexbox 规范,space-between 会让首个 flex 项与主轴起始边对齐、末项与主轴结束边对齐,剩余空间均匀分配到各项之间。
链接分隔采用 a:not(:last-child) { border-right: ... },利用相邻关系选择器 避免最后一项多一条竖线。:not() 伪类由 CSS Selectors Level 3 定义,可简化排他逻辑。
Logo 区 .page-header .container 同样 Flex:logo 与 form 分列两侧;搜索框 box-sizing: border-box 使 width 包含 padding 和 border,避免撑破布局。box-sizing 改变 CSS Box Model 的计算方式:content-box(默认)width 只含内容,border-box width 含 padding + border。
页面导航 .page-nav 底部 2px solid 品牌红色;左侧 .all-cates h2 红底白字块,右侧 nav 内链水平排列。
面包屑 .path-nav 用 span:not(:last-child)::after { content: "/\00a0"; } 在项与项之间插入斜杠;\00a0 是不换行空格(Unicode NBSP),防止斜杠与文字贴太紧。::after 生成的内容不改变 DOM 结构,屏幕阅读器可识别(取决于 CSS content 属性的 CSS Pseudo-Elements 规范 实现)。
mermaid 图:详情页纵向区块
#mermaid-svg-s8icw5OT4jSCt10y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s8icw5OT4jSCt10y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s8icw5OT4jSCt10y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s8icw5OT4jSCt10y .error-icon{fill:#552222;}#mermaid-svg-s8icw5OT4jSCt10y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s8icw5OT4jSCt10y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .marker.cross{stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s8icw5OT4jSCt10y p{margin:0;}#mermaid-svg-s8icw5OT4jSCt10y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label text{fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label span{color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label span p{background-color:transparent;}#mermaid-svg-s8icw5OT4jSCt10y .label text,#mermaid-svg-s8icw5OT4jSCt10y span{fill:#333;color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .node rect,#mermaid-svg-s8icw5OT4jSCt10y .node circle,#mermaid-svg-s8icw5OT4jSCt10y .node ellipse,#mermaid-svg-s8icw5OT4jSCt10y .node polygon,#mermaid-svg-s8icw5OT4jSCt10y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .rough-node .label text,#mermaid-svg-s8icw5OT4jSCt10y .node .label text,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label,#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label{text-anchor:middle;}#mermaid-svg-s8icw5OT4jSCt10y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .rough-node .label,#mermaid-svg-s8icw5OT4jSCt10y .node .label,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label,#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label{text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .node.clickable{cursor:pointer;}#mermaid-svg-s8icw5OT4jSCt10y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .arrowheadPath{fill:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s8icw5OT4jSCt10y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s8icw5OT4jSCt10y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .cluster text{fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster span{color:#333;}#mermaid-svg-s8icw5OT4jSCt10y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s8icw5OT4jSCt10y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y rect.text{fill:none;stroke-width:0;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape,#mermaid-svg-s8icw5OT4jSCt10y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape p,#mermaid-svg-s8icw5OT4jSCt10y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label rect,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s8icw5OT4jSCt10y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s8icw5OT4jSCt10y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} topbar 顶栏
page-header Logo+搜索
page-nav 分类导航
page-main 主内容
path-nav 面包屑
product 商品区
product-section 侧栏+详情
【代码注释】自上而下堆叠;主内容 margin-top: 15px 与顶栏区拉开呼吸感,是电商详情典型信息架构。
可运行示例(入门):顶栏 Flex
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>顶栏 Flex 演示</title>
<style>
.topbar { height: 30px; line-height: 30px; background: #eaeaea; }
.container {
width: 900px; margin: 0 auto;
display: flex; justify-content: space-between;
}
.topbar a {
padding: 0 10px; color: #666; text-decoration: none;
}
.topbar a:not(:last-child) { border-right: 1px solid #b3aeae; }
</style>
</head>
<body>
<div class="topbar">
<div class="container">
<div>欢迎来到尚品汇!请 <a href="#">登录</a></div>
<nav>
<a href="#">我的订单</a>
<a href="#">购物车</a>
<a href="#">会员</a>
</nav>
</div>
</div>
</body>
</html>
【代码注释】line-height 等于 height 实现单行垂直居中;:not(:last-child) 精准控制「除最后一项外都有右边框」。
最终页 DOM 与模块对应(08 模块):
| 区域 | 主要 class / id | 对应章节 |
|---|---|---|
| 顶栏 | .topbar |
二 |
| Logo+搜索 | .page-header |
二 |
| 主导航 | .page-nav |
二 |
| 面包屑 | .path-nav |
二 |
| 放大镜 | #zoomBox |
三 |
| 缩略图 | #thumbBox |
四 |
| 侧栏 Tab | #siderbarTab |
五 |
| 详情 Tab | #introTab |
五 |
| 规格区占位 | .product-box |
下一阶段扩展 |
【代码注释】id 与 JS 强绑定,改名必须同步改 querySelector;class 负责样式。缩略图 HTML 里注释掉的静态 <img> 列表由 JS 动态生成替代,避免写死 14 张图。
可运行示例(实战):面包屑伪元素分隔
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>面包屑演示</title>
<style>
.path-nav { display: flex; padding: 9px 0; }
.path-nav span:not(:last-child)::after {
content: "/\00a0";
padding: 0 5px;
color: #ccc;
}
.path-nav a { color: #666; text-decoration: none; }
</style>
</head>
<body>
<nav class="path-nav">
<span><a href="#">手机、数码</a></span>
<span><a href="#">手机</a></span>
<span><a href="#">Apple</a></span>
<span>iPhone 6s 系列</span>
</nav>
</body>
</html>
【代码注释】最后一项通常是当前页,不加链接、不追加斜杠;SEO 上可用 <ol> + JSON-LD BreadcrumbList 结构化数据,视觉上 ::after 更省事。
可运行示例(补充):Logo 与搜索框布局
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Logo 与搜索框</title>
<style>
.page-header .container {
width: 900px; margin: 0 auto;
display: flex; justify-content: space-between; align-items: flex-start;
}
.logo { width: 120px; height: 40px; background: #e1251b; margin: 20px; }
form { display: flex; margin-top: 28px; }
form input {
box-sizing: border-box; width: 360px; height: 32px;
border: 2px solid #ea4a36; padding: 0 6px; outline: none;
}
form button {
width: 64px; height: 32px; border: none; background: #ea4a36; color: #fff;
}
</style>
</head>
<body>
<div class="page-header">
<div class="container">
<div class="logo"></div>
<form><input type="text" placeholder="搜索"><button type="button">搜索</button></form>
</div>
</div>
</body>
</html>
【代码注释】box-sizing: border-box 保证 width: 490px 时边框不撑破一行;outline: none 去掉默认焦点框后应自行加 :focus 样式以照顾无障碍。
可运行示例(实战):页面主导航 .page-nav
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>页面导航演示</title>
<style>
.page-nav { border-bottom: 2px solid #e1251b; }
.page-nav .container {
width: 900px; margin: 0 auto;
display: flex; height: 50px; line-height: 50px;
}
.all-cates h2 {
width: 210px; text-align: center; margin: 0;
font-size: 14px; color: #fff; background: #e1251b;
}
.page-nav nav { display: flex; }
.page-nav nav a { margin: 0 22px; color: #333; font-size: 16px; text-decoration: none; }
</style>
</head>
<body>
<div class="page-nav">
<div class="container">
<div class="all-cates"><h2>全部商品分类</h2></div>
<nav>
<a href="#">服装城</a>
<a href="#">美妆馆</a>
<a href="#">秒杀</a>
</nav>
</div>
</div>
</body>
</html>
【代码注释】左侧 h2 固定 210px 红底块是电商站「全部分类」入口的视觉锚点;右侧 nav Flex 横排频道链接。line-height 与 height 相等实现单行垂直居中。
min-width: 1200px 的作用(04 及以后模块):
较后版本给 .topbar、.page-header、.page-nav 增加 min-width: 1200px,与 .container 的 1200px 版心配合:窗口变窄时整条顶栏出现横向滚动,而不是 Flex 子项被挤压变形。这是 PC 站「保设计稿宽度」的常见折中,与移动端 meta viewport 响应式是不同策略。
【实战要点】
- 经典应用场景 :京东、天猫 PC 详情顶栏 + 搜索 + 红底分类条,结构与本节一致;面包屑还支持 JSON-LD
BreadcrumbList结构化数据,让 Google 在搜索结果中展示导航路径,提升点击率(CTR)。 - 常见坑 1 :Flex 子项默认
min-width: auto(由内容决定),长文字不换行会撑破布局------对子项设min-width: 0可让其被压缩;再配合overflow: hidden; text-overflow: ellipsis截断过长文字。 - 常见坑 2 :
::after伪元素的content在display: flex的父元素中成为 flex 子项 ,若需要它只作为装饰不参与布局,可设order或改用其他方案。 - 性能与最佳实践 :纯 CSS 骨架无需 JS,首屏可先渲染静态 HTML,利于 FCP(First Contentful Paint)。
min-width: 1200px在窗口变窄时触发水平滚动,比 Flex 子项被压缩变形更接近设计稿意图;移动端适配时需配合meta viewport另外处理。
【本章小结】
| 模块 | 布局手段 | 记忆点 |
|---|---|---|
| topbar | Flex 两端对齐 | 竖线分隔链接 |
| page-header | Flex + border-box 表单 | 品牌色边框 |
| page-nav | 底边框 + Flex | 分类块 + 横向 nav |
| path-nav | Flex + ::after |
最后一项无斜杠 |
【面试考点】
Q1:justify-content: space-between 和 gap 的区别?
A:space-between 把剩余空间分到第一项前与最后一项后没有空隙 、项与项之间均分;gap 是项与项之间固定间距,两端不留「被拉大」的空白。顶栏两端贴边用 space-between 更贴切。gap 由 CSS Box Alignment 定义,可用于 Grid/Flex 布局。
Q2::not() 选择器的性能如何?
A:现代浏览器对 :not() 优化良好,性能与其他伪类相近;但复杂选择器链(如 .nav a:not(.external):hover)仍会增加匹配成本。从右向左匹配时,:not() 内的条件同样会逐一测试。
Q3:content: "/\00a0" 中的 \00a0 是什么?
A:Unicode 不换行空格(NBSP),十六进制 00A0。CSS 中 \00a0 或 \nbsp 均可表示,比普通空格(\0020)宽且不折行。用于面包屑分隔可防止斜杠与文字紧贴。
三、商品放大镜:布局与坐标联动
名词解释
- 蒙层(mask):半透明方块,标示当前放大区域。
- 视口坐标 :
event.clientX/Y相对浏览器可视区域,需减去容器偏移得到局部坐标。
概念与底层原理
结构三层:.small-image(400×400)内绝对定位 .mask-box(200×200);.large-image 在右侧绝对定位,内部 img 尺寸约为小图 2 倍,容器 overflow: hidden 只露出 400×400 窗口。
鼠标移动时:
left = clientX - smallImageBox.getBoundingClientRect().left(同理top)------见 MDN getBoundingClientRect。该 API 返回元素相对于视口 的位置与尺寸,由 CSSOM View Module 规范定义。相比offsetLeft需遍历offsetParent链,getBoundingClientRect更直接。- 蒙层中心跟鼠标:
left -= maskWidth/2。 - 边界钳制 :蒙层不能移出小图。用
Math.max/min确保坐标在[0, containerWidth - maskWidth]区间内。 - 大图同步:
largeImageBox.scrollLeft = left * 2(小图与大图 1:2,蒙层 200 对应大图 400 视窗)。scrollLeft/scrollTop是 CSSOM View 定义的元素属性,改变的是内容滚动位置而非元素本身偏移。
显示/隐藏:mouseenter 显示蒙层与大图窗,mouseleave 隐藏。这两个事件不会冒泡,比 mouseover/mouseout 更适合处理进出逻辑(见 DOM Events 规范)。
两种主流放大镜实现路线对比:
| 路线 | 原理 | 优点 | 缺点 |
|---|---|---|---|
scrollLeft/Top(本项目) |
大图容器 overflow:hidden,通过滚动位置露出对应区域 |
与真实 <img> 宽高配合自然,无需计算比例系数 |
大图须宽/高是小图 n 倍;浏览器对 scrollLeft 赋值会触发 Layout |
background-position |
用 background-image + background-size 模拟大图,通过改变背景位置实现取景 |
无需 overflow:hidden 嵌套容器;兼容 CSS 变量驱动 |
background-position 负值公式较绕:(-offsetX * zoom + glassSize/2)px |
scrollLeft/Top 与 background-position 的坐标公式推导:
设小图显示尺寸 W×H,大图实际尺寸 nW×nH(n=2),蒙层宽 w(=W/2),视窗宽 W:
- 蒙层左上角坐标
left范围[0, W-w]; scrollLeft = left * n------ 蒙层移动 1px,大图内容滚 n px,使视窗中心始终对准鼠标;background-position路线:-left * n px(取负值,因背景向左移使右侧内容"进入"取景框);实际还要加半个玻璃偏移修正:-(left * n - glassSize/2) px。
这说明两种方案的数学本质完全相同,只是操作不同 DOM 属性。
CSS 层叠结构(布局先于 JS):
less
.zoom-box {
position: relative; // 定位上下文
.small-image {
position: relative;
width: 400px; height: 400px;
cursor: move;
.mask-box {
display: none;
position: absolute;
width: 200px; height: 200px;
background: rgba(255,255,255,.5);
}
}
.large-image {
display: none;
position: absolute;
left: 420px; top: 0; // 小图右侧露出
width: 400px; height: 400px;
overflow: hidden; // 视窗裁剪
img { width: 800px; } // 实际大图 2 倍
}
}
【代码注释】.large-image 的 left: 420px 留出 20px 间距;overflow: hidden 形成 400×400 取景框。蒙层默认 display: none,由 JS 在 mouseenter 时改为 block。
放大倍率与 scrollLeft 对应关系:
| 元素 | 宽高 | 说明 |
|---|---|---|
小图 .small-image |
400×400 | 用户操作区域 |
蒙层 .mask-box |
200×200 | 视窗为小块的 1/2 |
大图视窗 .large-image |
400×400 | 与小图同尺寸窗口 |
大图 img |
800×800 | 为小图 2 倍 |
| JS 联动 | scrollLeft = left * 2 |
蒙层移动 1px,大图内容滚 2px |
【代码注释】倍率 2 来自「大图尺寸 / 小图尺寸」;若设计稿改为 3 倍图,把乘数改为 3 并同步改 img 宽高。
性能优化(可选): mousemove 触发极频,可在处理器内用 requestAnimationFrame 合并 DOM 写入,避免一帧内多次改 style 导致多余回流:
javascript
var ticking = false;
smallImageBox.onmousemove = function(event) {
if (ticking) return;
ticking = true;
requestAnimationFrame(function() {
// 此处放入 left/top/scrollLeft 计算与赋值
ticking = false;
});
};
【代码注释】rAF 与屏幕刷新率对齐(通常 60fps),比无节制改 style 更平滑;本课教学版为便于理解未使用,上线交互区建议加上。
mermaid 图:放大镜坐标流
大图容器 蒙层 小图区域 用户鼠标 大图容器 蒙层 小图区域 用户鼠标 #mermaid-svg-UToX1acfMHHJq3zN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UToX1acfMHHJq3zN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UToX1acfMHHJq3zN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UToX1acfMHHJq3zN .error-icon{fill:#552222;}#mermaid-svg-UToX1acfMHHJq3zN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UToX1acfMHHJq3zN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UToX1acfMHHJq3zN .marker.cross{stroke:#333333;}#mermaid-svg-UToX1acfMHHJq3zN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UToX1acfMHHJq3zN p{margin:0;}#mermaid-svg-UToX1acfMHHJq3zN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UToX1acfMHHJq3zN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-UToX1acfMHHJq3zN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .sequenceNumber{fill:white;}#mermaid-svg-UToX1acfMHHJq3zN #sequencenumber{fill:#333;}#mermaid-svg-UToX1acfMHHJq3zN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .messageText{fill:#333;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN .labelText,#mermaid-svg-UToX1acfMHHJq3zN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .loopText,#mermaid-svg-UToX1acfMHHJq3zN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UToX1acfMHHJq3zN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UToX1acfMHHJq3zN .noteText,#mermaid-svg-UToX1acfMHHJq3zN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .actorPopupMenu{position:absolute;}#mermaid-svg-UToX1acfMHHJq3zN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-UToX1acfMHHJq3zN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN .actor-man circle,#mermaid-svg-UToX1acfMHHJq3zN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-UToX1acfMHHJq3zN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} mousemove(clientX/Y) 换算局部 left/top 更新 left/top(钳制) scrollLeft = left*2
【代码注释】scrollLeft 移动的是内容 而非盒子本身;大图 img 更宽更高,通过滚动露出对应片段,比移动 img 的 left 更符合「窗口取景」模型。
可运行示例(入门):局部坐标计算
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>坐标换算演示</title>
<style>
#box { width: 300px; height: 200px; background: #eee; position: relative; margin: 40px; }
#dot { width: 10px; height: 10px; background: red; position: absolute; border-radius: 50%; }
</style>
</head>
<body>
<div id="box"><div id="dot"></div></div>
<p id="info"></p>
<script>
var box = document.getElementById('box');
var dot = document.getElementById('dot');
var info = document.getElementById('info');
box.onmousemove = function (e) {
var rect = box.getBoundingClientRect();
var x = e.clientX - rect.left - 5;
var y = e.clientY - rect.top - 5;
dot.style.left = Math.max(0, Math.min(x, box.clientWidth - 10)) + 'px';
dot.style.top = Math.max(0, Math.min(y, box.clientHeight - 10)) + 'px';
info.textContent = '局部坐标: ' + Math.round(e.clientX - rect.left) + ', ' + Math.round(e.clientY - rect.top);
};
</script>
</body>
</html>
【代码注释】红点中心对准鼠标需减去自身半宽;边界 Math.max/min 与放大镜蒙层钳制同一逻辑。
可运行示例(实战):简化版放大镜(IIFE)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>简化放大镜</title>
<style>
#zoomBox { position: relative; width: 200px; height: 200px; border: 1px solid #ccc; }
.small { position: relative; width: 200px; height: 200px; background: linear-gradient(135deg,#6cf,#f6c); cursor: move; }
.mask { display: none; position: absolute; width: 100px; height: 100px; background: rgba(255,255,255,.4); border: 1px solid #999; left: 0; top: 0; }
.large-win { display: none; position: absolute; left: 220px; top: 0; width: 200px; height: 200px; overflow: hidden; border: 1px solid #ccc; }
.large-win .inner { width: 400px; height: 400px; background: linear-gradient(135deg,#6cf,#f6c); }
</style>
</head>
<body>
<div id="zoomBox">
<div class="small">
<div class="mask"></div>
</div>
<div class="large-win"><div class="inner"></div></div>
</div>
<script>
(function () {
var small = document.querySelector('.small');
var mask = document.querySelector('.mask');
var largeWin = document.querySelector('.large-win');
var inner = document.querySelector('.inner');
small.onmouseenter = function () {
mask.style.display = 'block';
largeWin.style.display = 'block';
};
small.onmousemove = function (e) {
var r = small.getBoundingClientRect();
var left = e.clientX - r.left - mask.offsetWidth / 2;
var top = e.clientY - r.top - mask.offsetHeight / 2;
left = Math.max(0, Math.min(left, small.clientWidth - mask.offsetWidth));
top = Math.max(0, Math.min(top, small.clientHeight - mask.offsetHeight));
mask.style.left = left + 'px';
mask.style.top = top + 'px';
largeWin.scrollLeft = left * 2;
largeWin.scrollTop = top * 2;
};
small.onmouseleave = function () {
mask.style.display = 'none';
largeWin.style.display = 'none';
};
})();
</script>
</body>
</html>
【代码注释】用渐变块代替真实商品图,比例仍为 2:1;IIFE 包裹避免 zoomBox 等变量泄漏到全局。完整商品图版本使用 images/b1.png 等大图资源。
项目中的核心逻辑:
javascript
(function() {
var zoomBox = document.querySelector('#zoomBox');
var smallImageBox = zoomBox.querySelector('.small-image');
var largeImageBox = zoomBox.querySelector('.large-image');
var maskBox = zoomBox.querySelector('.mask-box');
smallImageBox.onmouseenter = function() {
maskBox.style.display = 'block';
largeImageBox.style.display = 'block';
};
smallImageBox.onmousemove = function(event) {
var left = event.clientX - smallImageBox.getBoundingClientRect().left;
var top = event.clientY - smallImageBox.getBoundingClientRect().top;
left -= maskBox.offsetWidth / 2;
top -= maskBox.offsetHeight / 2;
if (left < 0) left = 0;
else if (left > smallImageBox.clientWidth - maskBox.offsetWidth) {
left = smallImageBox.clientWidth - maskBox.offsetWidth;
}
if (top < 0) top = 0;
else if (top > smallImageBox.clientHeight - maskBox.offsetHeight) {
top = smallImageBox.clientHeight - maskBox.offsetHeight;
}
maskBox.style.left = left + 'px';
maskBox.style.top = top + 'px';
largeImageBox.scrollLeft = left * 2;
largeImageBox.scrollTop = top * 2;
};
smallImageBox.onmouseleave = function() {
maskBox.style.display = 'none';
largeImageBox.style.display = 'none';
};
})();
【代码注释】offsetWidth 含 border,与 clientWidth 区分使用;比例 * 2 来自小图 400、蒙层 200、大图视窗 400 与 img 800 的尺寸关系,换图时需按设计稿重算倍率。
层叠上下文(为何大图能盖住旁边内容):
less
.zoom-box { position: relative; z-index: auto; }
.small-image { position: relative; } /* 蒙层 absolute 相对小图定位 */
.large-image {
position: absolute;
left: 420px;
z-index: 1; /* 可按需抬高,避免被右侧规格区遮挡 */
}
【代码注释】position: relative 建立定位上下文;未设 z-index 时仍按 DOM 顺序与层叠规则绘制。若大图被遮挡,检查后续兄弟元素是否创建了更高 stacking context(transform、opacity<1、z-index 等)。
【实战要点】
- 经典应用场景:京东商品主图悬停放大、天猫 Lens 效果,本质都是「局部坐标 → 取景窗口」。
- 常见坑 :滚动页面后仍用旧的偏移量------
getBoundingClientRect每次 move 重新取即可;若用offsetLeft累加需考虑 offsetParent 链。 - 性能与最佳实践 :
mousemove触发频繁,逻辑保持轻量;必要时可用requestAnimationFrame合并写入。
【本章小结】
| 步骤 | API/属性 |
|---|---|
| 局部坐标 | clientX/Y − getBoundingClientRect() |
| 蒙层定位 | absolute + 钳制 |
| 大图 | scrollLeft/Top 比例联动 |
【面试考点】
Q1:为什么用 scrollLeft 而不是改大图的 left?
A:overflow:hidden 的容器通过滚动「移动内容」,语义是视口不动、底图动;直接改 left 也可以,但大图常远大于视窗,用滚动更符合 DOM 结构,也便于与真实 <img> 宽高配合。
Q2:getBoundingClientRect 与 offsetLeft 的区别?
A:getBoundingClientRect 返回相对于视口 的坐标,受滚动影响,无需遍历 offsetParent;offsetLeft 相对** offsetParent**(定位祖先元素),需逐层累加。放大镜需精确跟随鼠标,用视口坐标更直接。
Q3:mouseenter/mouseleave 与 mouseover/mouseout 的区别?
A:前者不冒泡 ,只在进入/离开元素本身触发;后者会冒泡,进出子元素也触发。放大镜只需监听进出小图区,用 mouseenter/mouseleave 更简单,避免子元素干扰。
Q4:页面有滚动时,getBoundingClientRect 的坐标还准确吗?
A:准确。getBoundingClientRect 返回的是相对于视口(viewport)的坐标 ,已经自动考虑了页面滚动------它始终以浏览器可视区左上角为原点,无论页面滚了多少。clientX/Y 也是视口坐标,两者相减直接得到局部坐标,不需要加 window.scrollX/Y。对比:若用 event.pageX - offsetLeft(文档坐标)则需要加滚动量修正,两者混用会出错。推荐统一用 clientX/Y + getBoundingClientRect,这是 CSSOM View Module 推荐的视口坐标体系。
Q5:放大镜中的 scrollLeft 赋值会不会影响性能?
A:scrollLeft 赋值会触发强制回流(Forced Reflow) ------浏览器必须在写操作完成后重新计算布局,代价高于 transform。高频 mousemove(60fps 下每帧约 16ms)中连续赋值 scrollLeft 是瓶颈点。现代生产环境的解法:① 改用 transform: translate() + CSS overflow:hidden 的方案避免 scrollLeft;② 在赋值前加 requestAnimationFrame 包裹,让 DOM 写操作合并到下一帧,消除同一帧内多次回流。
四、缩略图轮播:动态渲染、边界与节流
名词解释
new Image():创建图片 DOM,设置src后插入文档,等价于<img>动态节点。dataset:data-*属性的 JS 访问入口,如data-index→dataset.index。- 节流(本课实现) :两次有效操作间隔至少 400ms,用
event.timeStamp判断。
概念与底层原理
缩略图列表来自 goodData.imgsrc 数组,每项含小图 s 与大图 b。forEach 创建 Image 节点追加到 .thumb-wrapper(position:absolute; left 控制横向滚动)。
单格宽度:offsetWidth + parseInt(getStyle(el, 'marginRight')),需 getComputedStyle 读取 margin(见第六章)。offsetWidth 包含 border + padding + content,由 CSSOM View 定义,会触发回流(reflow)------频繁访问时应缓存。
点击箭头:改变 thumbWrapper.style.left,每次移动 imgItemWidth * 2;left 上限 0、下限 imgItemWidth*5 - thumbWrapper.offsetWidth(可视 5 张时的边界,与案例数据量相关)。
节流 :if (event.timeStamp - btn.time <= 400) return; 否则更新 btn.time = event.timeStamp。timeStamp 为事件创建时的时间戳(毫秒,规范见 DOM 事件)。节流确保高频操作下按固定频率执行,与防抖(debounce,停止触发后延迟执行)不同------箭头连点适合节流,搜索框输入适合防抖。
event.timeStamp 的底层精度:
根据 W3C Event Timing API 规范,event.timeStamp 是一个 DOMHighResTimeStamp------与 performance.now() 同精度,可精确到亚毫秒(浏览器实现中通常精确到 0.1ms 或更高)。它表示事件被硬件生成 的时间,相对于 performance.timeOrigin(页面导航起点),是单调递增时钟,不受系统时钟调整影响。Date.now() 则返回 Unix 纪元毫秒,精度仅 1ms,且受系统时间校正影响。
event.timeStamp ≈ performance.now() (基准相同,均相对 performance.timeOrigin)
≠ Date.now() (Date.now 基准是 1970-01-01 UTC)
【代码注释】三者基准不同 :event.timeStamp 与 performance.now() 均相对于 performance.timeOrigin(页面导航起点),可直接相减得到「事件发生距页面加载的毫秒数」;Date.now() 基于 Unix 纪元,与前两者相减无实际意义。节流中用 e.timeStamp - btn.time <= 400 利用的正是「同一基准下的差值」特性。
本课节流的两种写法对比(时间戳 vs 闭包计时器):
javascript
// 本课:时间戳节流(leading 优先,第一次立即执行)
btn.time = 0;
btn.onclick = function(e) {
if (e.timeStamp - btn.time <= 400) return;
btn.time = e.timeStamp;
doScroll();
};
// 通用闭包节流(leading + trailing,可选尾部执行)
function throttle(fn, interval) {
var last = 0;
return function(e) {
var now = Date.now(); // 或 performance.now()
if (now - last >= interval) {
fn.apply(this, arguments);
last = now;
}
};
}
var throttledScroll = throttle(doScroll, 400);
btn.onclick = throttledScroll;
【代码注释】本课把 time 挂在按钮 DOM 节点上(btn.time),是「就近存放状态」的简洁写法;闭包写法用私有变量 last 更符合工程封装原则。两种写法都属于 leading throttle (第一次立即执行);若需要 trailing throttle (停止后再执行一次),需在 else 分支用 setTimeout 补发,适合「滚动到底后需要最终刷新一次」的场景。
事件委托 :在 thumbWrapper 上监听 click,event.target.nodeName === 'IMG' 时切换主图 src 与放大镜大图 src。委托利用事件冒泡(DOM Events 规范),动态增删节点时无需重新绑定。focus、blur 不冒泡,不可委托。
.thumb-wrapper { transition: left 300ms; } 提供滑动动画;父级 overflow: hidden 形成视窗。transition 由 CSS Transitions 规范 定义,会触发 GPU 加速(当动画属性为 transform/opacity 时)。
mermaid 图:缩略图交互
#mermaid-svg-yAmFZtbVV8FF769j{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yAmFZtbVV8FF769j .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yAmFZtbVV8FF769j .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yAmFZtbVV8FF769j .error-icon{fill:#552222;}#mermaid-svg-yAmFZtbVV8FF769j .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yAmFZtbVV8FF769j .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .marker.cross{stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yAmFZtbVV8FF769j p{margin:0;}#mermaid-svg-yAmFZtbVV8FF769j .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label text{fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label span{color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label span p{background-color:transparent;}#mermaid-svg-yAmFZtbVV8FF769j .label text,#mermaid-svg-yAmFZtbVV8FF769j span{fill:#333;color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .node rect,#mermaid-svg-yAmFZtbVV8FF769j .node circle,#mermaid-svg-yAmFZtbVV8FF769j .node ellipse,#mermaid-svg-yAmFZtbVV8FF769j .node polygon,#mermaid-svg-yAmFZtbVV8FF769j .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .rough-node .label text,#mermaid-svg-yAmFZtbVV8FF769j .node .label text,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label,#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label{text-anchor:middle;}#mermaid-svg-yAmFZtbVV8FF769j .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .rough-node .label,#mermaid-svg-yAmFZtbVV8FF769j .node .label,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label,#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label{text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .node.clickable{cursor:pointer;}#mermaid-svg-yAmFZtbVV8FF769j .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .arrowheadPath{fill:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yAmFZtbVV8FF769j .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yAmFZtbVV8FF769j .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .cluster text{fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster span{color:#333;}#mermaid-svg-yAmFZtbVV8FF769j div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yAmFZtbVV8FF769j .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j rect.text{fill:none;stroke-width:0;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape,#mermaid-svg-yAmFZtbVV8FF769j .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape p,#mermaid-svg-yAmFZtbVV8FF769j .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label rect,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yAmFZtbVV8FF769j .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yAmFZtbVV8FF769j :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} goodData.imgsrc
forEach 创建 Image
thumb-wrapper
prev/next 改 left
委托 click 换主图
timeStamp 节流 400ms
【代码注释】数据、视图、行为分离:data.js 只放数据,index.js 负责 DOM 操作与事件。
可运行示例(入门):动态创建缩略图
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>动态缩略图</title>
<style>
.thumb-wrapper { display: flex; gap: 8px; }
.thumb-wrapper img { width: 50px; height: 50px; border: 1px solid #ccc; cursor: pointer; }
#main { width: 120px; height: 120px; background: #ddd; margin-bottom: 12px; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="main">主图区</div>
<div class="thumb-wrapper" id="wrap"></div>
<script>
var imgs = ['#e74c3c','#3498db','#2ecc71','#f39c12'];
var wrap = document.getElementById('wrap');
var main = document.getElementById('main');
imgs.forEach(function (color, i) {
var img = new Image();
img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="'+color+'"/></svg>');
img.dataset.index = i;
wrap.appendChild(img);
});
wrap.onclick = function (e) {
if (e.target.nodeName === 'IMG') {
main.style.background = imgs[e.target.dataset.index];
main.textContent = '第 ' + (Number(e.target.dataset.index) + 1) + ' 张';
}
};
</script>
</body>
</html>
【代码注释】用 SVG data URL 代替真实 png,演示 new Image() + 委托;dataset.index 与数组下标对应,换主图时取 goodData.imgsrc[i].s / .b。
可运行示例(实战):timeStamp 节流
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>节流演示</title>
<style>button { padding: 10px 20px; margin: 20px; }</style>
</head>
<body>
<button id="btn">快速连点(400ms 内只生效一次)</button>
<p id="log"></p>
<script>
var btn = document.getElementById('btn');
var log = document.getElementById('log');
var count = 0;
btn.time = 0;
btn.onclick = function (e) {
if (e.timeStamp - btn.time <= 400) return;
btn.time = e.timeStamp;
count++;
log.textContent = '有效点击次数: ' + count + ',timeStamp=' + Math.round(e.timeStamp);
};
</script>
</body>
</html>
【代码注释】本课用的是时间戳节流 而非 setTimeout 防抖:防抖是「停手后再执行」,节流是「固定间隔内最多一次」。购物车加减、轮播箭头常用节流。
完整缩略图逻辑(与 06 模块一致,含上一张按钮):
javascript
(function(){
var prevBtn = document.querySelector('#thumbBox .thumb-prev');
var nextBtn = document.querySelector('#thumbBox .thumb-next');
var thumbWrapper = document.querySelector('#thumbBox .thumb-wrapper');
var smallImage = document.querySelector('#zoomBox .small-image img');
var largeImage = document.querySelector('#zoomBox .large-image img');
goodData.imgsrc.forEach(function(imgItem, index) {
var imgBox = new Image();
imgBox.src = imgItem.s;
imgBox.dataset.index = index;
thumbWrapper.appendChild(imgBox);
});
var imgItemWidth = thumbWrapper.firstElementChild.offsetWidth +
parseInt(getStyle(thumbWrapper.firstElementChild, 'marginRight'), 10);
prevBtn.time = 0;
prevBtn.onclick = function(event) {
if (event.timeStamp - prevBtn.time <= 400) return;
prevBtn.time = event.timeStamp;
var left = thumbWrapper.offsetLeft + imgItemWidth * 2;
if (left > 0) left = 0;
thumbWrapper.style.left = left + 'px';
};
nextBtn.time = 0;
nextBtn.onclick = function(event) {
if (event.timeStamp - nextBtn.time <= 400) return;
nextBtn.time = event.timeStamp;
var left = thumbWrapper.offsetLeft - imgItemWidth * 2;
var minLeft = imgItemWidth * 5 - thumbWrapper.offsetWidth;
if (left < minLeft) left = minLeft;
thumbWrapper.style.left = left + 'px';
};
thumbWrapper.onclick = function(event) {
if (event.target.nodeName === 'IMG') {
var i = event.target.dataset.index;
smallImage.src = goodData.imgsrc[i].s;
largeImage.src = goodData.imgsrc[i].b;
}
};
})();
【代码注释】parseInt(..., 10) 避免八进制歧义;minLeft 与「可视约 5 张」相关,图片条数变化时应改为 -(thumbWrapper.scrollWidth - 父级可视宽) 动态计算。
节流 vs 防抖(与本课相关的两种写法):
| 策略 | 本课用法 | 典型代码形态 | 适用 |
|---|---|---|---|
| 时间戳节流 | 箭头 timeStamp 间隔 400ms |
if (now - last <= 400) return |
连点、滚动采样 |
| 定时器防抖 | 本篇未用 | clearTimeout(t); t = setTimeout(fn, 300) |
搜索框、resize 布局 |
javascript
// 防抖示意(搜索框,非本课主流程)
var timer;
input.oninput = function () {
clearTimeout(timer);
timer = setTimeout(function () { fetchSuggestions(input.value); }, 300);
};
【代码注释】防抖在「停止输入 300ms 后」才请求;节流在「400ms 内只认第一次有效点击」。放大镜的 mousemove 若需限频,用 requestAnimationFrame 比防抖更合适。
更稳健的滚动边界(扩展):
javascript
var viewW = thumbWrapper.parentElement.clientWidth;
var maxScroll = thumbWrapper.scrollWidth - viewW; // 若用 transform 滚动可类比
var minLeft = -maxScroll;
if (left < minLeft) left = minLeft;
【代码注释】用内容总宽减视窗宽得到最大左移量,图片条数变化时无需手改「5 张」魔法数。
缩略图区 CSS 结构:
less
.thumb-box {
display: flex;
justify-content: space-between;
.thumb-content {
position: relative;
width: 372px;
overflow: hidden; // 视窗
.thumb-wrapper {
position: absolute;
left: 0;
display: flex;
transition: left 300ms; // 滑动动画
img {
width: 50px; height: 50px;
margin-right: 20px;
}
}
}
}
【代码注释】移动的是 .thumb-wrapper 的 left,不是 scrollLeft------子项总宽大于 372px 时被裁切;transition 由 CSS Transitions 定义,仅动画 left 时部分浏览器走主线程,大量图片时可用 transform: translateX 触发合成层。
上一张按钮与边界公式:
javascript
prevBtn.onclick = function(event) {
if (event.timeStamp - prevBtn.time <= 400) return;
prevBtn.time = event.timeStamp;
var left = thumbWrapper.offsetLeft + imgItemWidth * 2;
if (left > 0) left = 0;
thumbWrapper.style.left = left + 'px';
};
【代码注释】offsetLeft 为负表示向左滑出过;left > 0 时钳回 0,不能再往右拖。下一张则 left -= imgItemWidth*2,下限 imgItemWidth*5 - thumbWrapper.offsetWidth 与「可视约 5 张、总图 14 张」的数据量匹配------换数据条数时需重算或改为根据 scrollWidth 动态算边界。
new Image() 与 document.createElement('img'):
本课用 new Image() 创建节点;与 createElement('img') 等价,且可在 onload 里做预加载。设置 src 后插入 DOM,浏览器异步拉取图片;dataset.index 对应 goodData.imgsrc 下标,委托点击时 O(1) 取数据。
【实战要点】
- 经典应用场景:商品多图横滑、相册箭头;与放大镜联动是详情页标配。
- 常见坑 :
parseInt(getStyle(..., 'marginRight'))在 margin 为auto时得到NaN------案例里写死 margin;offsetLeft相对offsetParent,结构变化要重新测宽。 - 性能与最佳实践 :委托适合「子节点动态增减」;高频
mousemove才考虑rAF,点击节流 400ms 对 UX 足够。
【本章小结】
| 技术点 | 作用 |
|---|---|
goodData |
图片 s/b 与后续规格数据同源 |
动态 Image |
数据驱动缩略图条 |
left 滚动 |
横向轮播 |
timeStamp |
箭头连点节流 |
| 委托 | 点击缩略图换主图 |
【面试考点】
Q1:事件委托的条件是什么?
A:事件需冒泡到祖先;子元素上不要用 stopPropagation 阻断;祖先处理器里用 event.target 判断真实来源,必要时 closest 向上找。focus、blur 不冒泡,不能委托。委托减少监听器数量,适合动态列表;但过多嵌套会增加冒泡路径开销。
Q2:节流和防抖区别?
A:节流------间隔内执行一次(滚动、按钮连点);防抖------连续触发只执行最后一次(搜索框输入后再请求)。本课箭头用节流。节流保证最低频率执行,防抖合并多次触发为一次。
Q3:event.timeStamp 的精度是多少?与 performance.now() 和 Date.now() 有什么区别?
A:event.timeStamp 是 DOMHighResTimeStamp,精度与 performance.now() 相同,可达亚毫秒级(通常 0.1ms)。它们的基准相同,均相对于 performance.timeOrigin(页面导航起点),是单调递增时钟 ,不受系统时间调整影响。Date.now() 基准是 1970 Unix 纪元,精度仅 1ms,且可能因系统时间校正而跳变。用于节流/防抖,三者均可;精确测量 handler 执行时间用 performance.now() 最可靠:const start = performance.now(); doWork(); console.log(performance.now() - start + 'ms')。
Q4:为什么用 style.left 移动 .thumb-wrapper 而不是 transform: translateX?
A:本课用 left 直观对应「轨道偏移」,且与 transition: left 配套。生产环境更推荐 transform 动画(合成层、少触发 layout);改法:记录 translateX 变量,箭头点击改 transform,委托换图逻辑不变。
可运行示例(补充):缩略图 + 主图联动(迷你版)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>缩略图联动演示</title>
<style>
#main { width: 120px; height: 120px; background: #ddd; margin-bottom: 8px; display: flex; align-items: center; justify-content: center; }
.track { width: 200px; overflow: hidden; }
.wrap { display: flex; gap: 6px; transition: transform .2s; }
.wrap img { width: 40px; height: 40px; cursor: pointer; border: 2px solid transparent; }
.wrap img.on { border-color: #e1251b; }
</style>
</head>
<body>
<div id="main">主图</div>
<button type="button" id="prev">←</button>
<button type="button" id="next">→</button>
<div class="track"><div class="wrap" id="wrap"></div></div>
<script>
var colors = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6'];
var wrap = document.getElementById('wrap');
var main = document.getElementById('main');
var x = 0;
colors.forEach(function (c, i) {
var img = new Image();
img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="'+c+'"/></svg>');
img.dataset.i = i;
wrap.appendChild(img);
});
function pick(i) {
main.style.background = colors[i];
main.textContent = '图 ' + (i + 1);
wrap.querySelectorAll('img').forEach(function (n) { n.classList.toggle('on', n.dataset.i == i); });
}
wrap.onclick = function (e) { if (e.target.tagName === 'IMG') pick(e.target.dataset.i); };
document.getElementById('next').onclick = function () { x -= 46; wrap.style.transform = 'translateX(' + x + 'px)'; };
document.getElementById('prev').onclick = function () { x += 46; if (x > 0) x = 0; wrap.style.transform = 'translateX(' + x + 'px)'; };
pick(0);
</script>
</body>
</html>
【代码注释】用 transform 演示横向滚动;点击缩略图更新主图色块,对应项目中改 smallImage.src / largeImage.src 的逻辑。
五、选项卡:排他思想与函数复用
名词解释
- Tab 导航项 / 面板项 :导航
.tab-nav-item与内容.tab-panel-item一一对应。 - 排他 :切换前清除所有
active,保证唯一选中。
概念与底层原理
CSS 控制显隐:.tab-panel-item { display: none; },.tab-panel-item.active { display: block; };导航 .tab-nav-item.active 改变边框/背景(侧边栏顶边变红,详情 Tab 红底白字)。
JS 层封装 tab(tabNavItems, tabPanelItems):forEach 给每个导航绑 click,回调里双重循环 classList.remove('active'),再给当前 tabNavItem 与 tabPanelItems[index] 加 active。
侧边栏 #siderbarTab 与详情 #introTab 共用同一函数 ,体现 DRY;07 模块中曾手写 for 循环实现排他,08 模块起改为调用 tab()------这是「先跑通再抽象」的合理重构顺序。
选项卡的无障碍(Accessibility)增强:
商业级产品页的选项卡需要 ARIA 支持,使屏幕阅读器能正确朗读当前 Tab:
html
<div role="tablist" aria-label="商品信息">
<button role="tab" aria-selected="true" aria-controls="panel-intro" id="tab-intro">介绍</button>
<button role="tab" aria-selected="false" aria-controls="panel-spec" id="tab-spec">规格</button>
</div>
<div role="tabpanel" id="panel-intro" aria-labelledby="tab-intro">商品介绍内容</div>
<div role="tabpanel" id="panel-spec" aria-labelledby="tab-spec" hidden>规格参数内容</div>
【代码注释】role="tab" + aria-selected 替代单纯的 .active;hidden 属性替代 display:none,对 AT(辅助技术)更明确。ARIA 规范由 WAI-ARIA 1.2 定义。切换时需 JS 同步更新 aria-selected 与 hidden。市面应用:天猫、京东商品详情Tab均实现了基础 ARIA,以通过无障碍审计。
纯 CSS :target 方案(了解,不依赖 JS):
利用 URL hash 与 :target 伪类可实现零 JS 选项卡:
html
<a href="#tab1">介绍</a><a href="#tab2">规格</a>
<div id="tab1">介绍内容</div>
<div id="tab2">规格内容</div>
<style>
div[id^="tab"] { display: none; }
div[id^="tab"]:target { display: block; }
</style>
【代码注释】:target 匹配 URL 中 #xxx 对应的元素,纯 CSS 实现无需 JS。缺点:切换 Tab 会改变浏览器历史记录;SEO 需额外处理;初始无 hash 时无面板展示。电商主流方案仍用 JS 控制 active,以精确控制初始状态与键盘导航。
两套 Tab 的 CSS 差异(同一套 JS):
| 位置 | 导航 .active 样式 |
面板显隐 |
|---|---|---|
侧边栏 #siderbarTab |
顶边 border-top-color: @btn-red,底边与内容区融合 |
display: none/block |
详情 #introTab |
红底白字 background: @btn-red |
同上 |
less
// 侧边栏:强调「顶边红线」
.tab-nav-item.active {
border-top-color: @btn-red;
border-bottom-color: #fff;
}
// 详情区:强调「整块红底」
.tab-nav-item.active {
color: #fff;
background: @btn-red;
}
【代码注释】JS 只切换 active 类名,视觉差异完全由 CSS 决定------同一 tab() 可服务于不同 UI 皮肤,符合「结构、表现、行为」分离。
从手写到 tab() 的演进:
javascript
// 07 阶段:内联排他(逻辑与 08 的 tab() 相同)
tabNavItems.forEach(function(tabNavItem, index) {
tabNavItem.onclick = function() {
for (var i = 0; i < tabNavItems.length; i++) {
tabNavItems[i].classList.remove('active');
tabPanelItems[i].classList.remove('active');
}
tabNavItem.classList.add('active');
tabPanelItems[index].classList.add('active');
};
});
// 08 阶段:提取后一行调用
tab(document.querySelectorAll('#introTab .tab-nav-item'),
document.querySelectorAll('#introTab .tab-panel-item'));
【代码注释】提取函数后新增 Tab 只需 HTML + 一行 tab(...),避免复制粘贴导致某处漏改 remove('active')。
侧栏 Tab 的 HTML 骨架(需与 nav/panel 数量一致):
html
<div class="product-siderbar" id="siderbarTab">
<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>
【代码注释】第一项默认带 active;tab-nav-item 与 tab-panel-item 按索引一一对应。#siderbarTab 拼写与源码一致(Sierbar 为项目原有命名),JS 选择器勿写错。
可封装升级的 tab 写法(练习):
javascript
function tab(rootSelector) {
var root = document.querySelector(rootSelector);
var nav = root.querySelectorAll('.tab-nav-item');
var panels = root.querySelectorAll('.tab-panel-item');
nav.forEach(function (item, index) {
item.onclick = function () {
nav.forEach(function (n) { n.classList.remove('active'); });
panels.forEach(function (p) { p.classList.remove('active'); });
item.classList.add('active');
panels[index].classList.add('active');
};
});
}
tab('#siderbarTab');
tab('#introTab');
【代码注释】只传容器 id 可减少 querySelectorAll 重复;注意限定在 root 内查询,避免选中页面其他 Tab。
mermaid 图:选项卡状态机
#mermaid-svg-ItrqqHT8BN1Dwv4a{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ItrqqHT8BN1Dwv4a .error-icon{fill:#552222;}#mermaid-svg-ItrqqHT8BN1Dwv4a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ItrqqHT8BN1Dwv4a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .marker.cross{stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ItrqqHT8BN1Dwv4a p{margin:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ItrqqHT8BN1Dwv4a .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ItrqqHT8BN1Dwv4a .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel .label text{fill:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .label div .edgeLabel{color:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .cluster-label,#mermaid-svg-ItrqqHT8BN1Dwv4a .nodeLabel{color:#131300;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .note-edge{stroke-dasharray:5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note text{fill:black;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram .edgeLabel{color:red;}#mermaid-svg-ItrqqHT8BN1Dwv4a #dependencyStart,#mermaid-svg-ItrqqHT8BN1Dwv4a #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 默认第一项 active
点击 nav1
点击 nav2
点击 nav0
Tab0
Tab1
Tab2
每次切换前清除全部 active
【代码注释】状态只有「哪一项 active」,无中间态;与 React useState(activeIndex) 模型一致。
可运行示例(入门):纯 CSS+JS 选项卡
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>选项卡入门</title>
<style>
.tab-nav { display: flex; }
.tab-nav button { padding: 8px 16px; border: 1px solid #ddd; background: #fff; cursor: pointer; }
.tab-nav button.active { background: #e1251b; color: #fff; border-color: #e1251b; }
.tab-panel { display: none; padding: 12px; border: 1px solid #ddd; }
.tab-panel.active { display: block; }
</style>
</head>
<body>
<div class="tab-nav" id="nav">
<button class="active" type="button">介绍</button>
<button type="button">规格</button>
<button type="button">售后</button>
</div>
<div class="tab-panel active" data-i="0">商品介绍内容</div>
<div class="tab-panel" data-i="1">规格参数内容</div>
<div class="tab-panel" data-i="2">售后服务内容</div>
<script>
var nav = document.querySelectorAll('#nav button');
var panels = document.querySelectorAll('.tab-panel');
nav.forEach(function (btn, index) {
btn.onclick = function () {
nav.forEach(function (n) { n.classList.remove('active'); });
panels.forEach(function (p) { p.classList.remove('active'); });
btn.classList.add('active');
panels[index].classList.add('active');
};
});
</script>
</body>
</html>
【代码注释】索引 index 来自 forEach 闭包,保证 nav 与 panel 下标对齐;生产环境可抽成 tab() 函数。
可运行示例(实战):封装 tab 并复用
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>tab 函数复用</title>
<style>
.wrap { margin: 20px; }
.tab-nav { display: flex; gap: 4px; }
.tab-nav span { padding: 6px 12px; cursor: pointer; border: 1px solid #ccc; }
.tab-nav span.active { background: #c81623; color: #fff; }
.tab-panel { display: none; padding: 10px; border: 1px solid #eee; }
.tab-panel.active { display: block; }
</style>
</head>
<body>
<div class="wrap" id="a">
<div class="tab-nav"><span class="active">A1</span><span>A2</span></div>
<div class="tab-panel active">面板 A1</div>
<div class="tab-panel">面板 A2</div>
</div>
<div class="wrap" id="b">
<div class="tab-nav"><span class="active">B1</span><span>B2</span></div>
<div class="tab-panel active">面板 B1</div>
<div class="tab-panel">面板 B2</div>
</div>
<script>
function tab(tabNavItems, tabPanelItems) {
tabNavItems.forEach(function (tabNavItem, index) {
tabNavItem.onclick = function () {
for (var i = 0; i < tabNavItems.length; i++) {
tabNavItems[i].classList.remove('active');
tabPanelItems[i].classList.remove('active');
}
tabNavItem.classList.add('active');
tabPanelItems[index].classList.add('active');
};
});
}
tab(document.querySelectorAll('#a .tab-nav span'), document.querySelectorAll('#a .tab-panel'));
tab(document.querySelectorAll('#b .tab-nav span'), document.querySelectorAll('#b .tab-panel'));
</script>
</body>
</html>
【代码注释】与项目中 tab(document.querySelectorAll('#introTab ...')) 相同模式;NodeList 可 forEach,IE 需 Array.prototype.slice 或 for 循环。
项目调用:
javascript
(function(){
tab(
document.querySelectorAll('#siderbarTab .tab-nav-item'),
document.querySelectorAll('#siderbarTab .tab-panel-item')
);
})();
(function(){
tab(
document.querySelectorAll('#introTab .tab-nav-item'),
document.querySelectorAll('#introTab .tab-panel-item')
);
})();
【代码注释】两个 IIFE 各自只负责一块 DOM,互不干扰;若写成全局脚本需注意 querySelectorAll 顺序与 HTML 结构一致。
【实战要点】
- 经典应用场景:商品详情「介绍/规格/售后」、个人中心多 Tab、后台表单分组。
- 常见坑 :nav 与 panel 数量不一致导致
tabPanelItems[i]为undefined;应用 JS 初始化保证第一项带active与 HTML 一致。 - 性能与最佳实践 :纯显示切换用
display即可;频繁切换且内容重可用懒加载 panel 内容。
【本章小结】
| 层次 | 职责 |
|---|---|
| CSS | .active 控制显隐与强调样式 |
| JS | 排他 + 索引对应 |
| 函数 | tab() 多处复用 |
【面试考点】
Q1:如何用 React 实现同样效果?
A:用 useState(0) 存 activeIndex,渲染时 className={i===activeIndex?'active':''};原理仍是排他,只是状态上提到组件级。React 的 key 与 Vue 的 v-for index 类似,确保 DOM 复用正确。
Q2:选项卡用 display 切换与 visibility 的区别?
A:display: none 将元素从渲染树完全移除,不占位;visibility: hidden 保留位置但仍不可见。若 Tab 面板高度不同,用 display 切换会导致下方内容跳动------可用固定高度容器或 visibility 避免此问题。本项目面板高度固定,用 display 性能更好。
Q3:如何实现选项卡懒加载?
A:首次切换到某 Tab 时再加载数据,而不是初始化时全部请求。实现:给每个 panel 加 data-loaded 标记,点击非加载 Tab 时才发请求并设标记。适合内容重(如富文本、图表)的场景。
Q4:选项卡需要支持键盘操作(Tab 键、方向键),怎么做?
A:ARIA 规范要求 role="tablist" 内用方向键 切换 Tab,而非 Tab 键(Tab 键只在 tablist 整体间跳焦点)。具体:监听 keydown,ArrowRight → 激活下一个 Tab 并 focus();ArrowLeft → 激活上一个。同时给所有非当前 Tab 按钮设 tabindex="-1",当前 Tab 设 tabindex="0",保证 Tab 键只落在一个焦点上(roving tabindex 模式)。这是 WAI-ARIA Authoring Practices 的推荐实现。
六、数据驱动与工具函数库
名词解释
getComputedStyle:返回元素最终计算后的 CSS 属性值(只读)。currentStyle:IE 旧 API,现代项目作兼容兜底。
概念与底层原理
data.js 导出 goodData:imgsrc 数组供缩略图;goodsDetail 含标题、价格、推荐文案及 crumbData 规格维度(颜色、内存等)------为后续规格加价、数量联动等进阶功能预留数据结构。
functions.js 提供:
getStyle(ele, attr):兼容 IE 的样式读取,供marginRight等计算。getComputedStyle由 CSSOM 规范 定义,返回只读的CSSStyleDeclaration对象;IE8- 用currentStyle(非标准,已废弃)。tab(tabNavItems, tabPanelItems):选项卡通用逻辑。
各功能块用 IIFE 包裹,内部 var 不污染 window。IIFE(立即执行函数表达式)通过函数作用域隔离变量,避免全局命名空间污染------这是 ES6 模块化出现前的主流模块化方案。脚本顺序:data.js → functions.js → index.js,因为 index.js 依赖 goodData 与 tab 函数。
IIFE、CommonJS 与 ES Module 的作用域隔离机制对比:
| 方案 | 隔离方式 | 共享方式 | 静态分析 | Tree-shaking |
|---|---|---|---|---|
| IIFE | 函数作用域(运行时) | 挂到 window 或传参 |
不支持 | 不可 |
| CommonJS | 模块作用域(require 包裹) |
module.exports |
不支持(动态 require) | 有限 |
| ES Module | 模块作用域(语言原生) | export / import |
静态分析 | 完整支持 |
ES Module 的关键优势:
- 静态 import :在文件顶层,不可动态改变,打包工具(Webpack/Rollup/Vite)可在编译期确定哪些
export被使用;未被引用的export会被 Tree-shaking 删除,减小包体积。 - 严格模式(Strict Mode) :
type="module"的脚本默认严格模式,this在顶层为undefined而非window,避免隐式全局。 defer默认行为 :type="module"自动defer(HTML 解析完毕后执行,按顺序),无需手动添加defer属性。
IIFE 项目升级到 ES Module 的最小改造:
javascript
// data.js → 改为 export
export var goodData = { imgsrc: [...], goodsDetail: {...} };
// functions.js → 改为 export
export function getStyle(ele, attr) { ... }
export function tab(navItems, panelItems) { ... }
// index.js → 改为 import
import { goodData } from './data.js';
import { getStyle, tab } from './functions.js';
// 各 IIFE 改为普通函数或顶层代码(已有模块作用域,无需 IIFE)
【代码注释】export 使变量/函数可被外部 import;未 export 的内部变量对其他模块不可见------比 IIFE 的作用域隔离更彻底 ,因为 IIFE 若把值挂到 window 仍是全局污染。Rollup/Vite 等打包工具可通过静态分析 import 关系做 Tree-shaking:未被任何入口文件间接 import 到的 export 会被从产物中完全剔除。
html
<!-- HTML 中 -->
<script type="module" src="./js/index.js"></script>
<!-- data.js 和 functions.js 无需单独引入,由 index.js import -->
【代码注释】type="module" 让 import 自动解析依赖图,不再需要手动维护三个 <script> 的顺序。模块只执行一次(浏览器缓存模块实例),即使多个文件 import 同一模块也只加载一次,与 IIFE 的「每次 <script> 都重新执行」不同。
mermaid 图:页面脚本与 DOM 依赖
#mermaid-svg-8OK5oQcnolSL0rC7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8OK5oQcnolSL0rC7 .error-icon{fill:#552222;}#mermaid-svg-8OK5oQcnolSL0rC7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8OK5oQcnolSL0rC7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .marker.cross{stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8OK5oQcnolSL0rC7 p{margin:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label text{fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label span{color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label span p{background-color:transparent;}#mermaid-svg-8OK5oQcnolSL0rC7 .label text,#mermaid-svg-8OK5oQcnolSL0rC7 span{fill:#333;color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .node rect,#mermaid-svg-8OK5oQcnolSL0rC7 .node circle,#mermaid-svg-8OK5oQcnolSL0rC7 .node ellipse,#mermaid-svg-8OK5oQcnolSL0rC7 .node polygon,#mermaid-svg-8OK5oQcnolSL0rC7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .rough-node .label text,#mermaid-svg-8OK5oQcnolSL0rC7 .node .label text,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label,#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8OK5oQcnolSL0rC7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .rough-node .label,#mermaid-svg-8OK5oQcnolSL0rC7 .node .label,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label,#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label{text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .node.clickable{cursor:pointer;}#mermaid-svg-8OK5oQcnolSL0rC7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .arrowheadPath{fill:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8OK5oQcnolSL0rC7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster text{fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster span{color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8OK5oQcnolSL0rC7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape p,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label rect,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8OK5oQcnolSL0rC7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8OK5oQcnolSL0rC7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} index.html 结构
index.css 编译自 Less
data.js goodData
functions.js getStyle tab
index.js 各 IIFE
放大镜 IIFE
缩略图 IIFE
侧栏 Tab IIFE
详情 Tab IIFE
【代码注释】脚本必须放在 </body> 前或 defer,保证执行时 DOM 已解析;index.js 内多个 IIFE 顺序无关,因彼此无共享闭包变量。
最终页核心 DOM 区块(语义化结构):
html
<div class="topbar">...</div>
<div class="page-header">...</div>
<div class="page-nav">...</div>
<div class="page-main">
<nav class="path-nav">...</nav>
<div class="product">
<div class="product-preview">
<div class="zoom-box" id="zoomBox">...</div>
<div class="thumb-box" id="thumbBox">...</div>
</div>
</div>
<div class="product-section">
<aside class="product-siderbar" id="siderbarTab">...</aside>
<div class="product-intro">
<div class="product-intro-tab" id="introTab">...</div>
</div>
</div>
</div>
【代码注释】#zoomBox、#thumbBox、#siderbarTab、#introTab 的 id 与 JS 选择器强绑定,改名需同步改脚本;类名采用 BEM 风格片段(product-preview)便于维护。
goodData.goodsDetail 字段说明(为进阶功能预留):
| 字段 | 用途 |
|---|---|
title / price |
商品标题与基准价 |
recommend / support / address |
文案展示 |
crumbData[] |
规格维度:颜色、内存等,每项含 changePrice |
imgsrc[] |
缩略图与小/大图 URL |
【代码注释】本阶段页面逻辑仅消费 imgsrc;规格动态 DOM、加价、数量联动在后续章节用同一数据结构扩展,避免换页重造数据模型。
可运行示例(入门):getStyle 思想
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>getComputedStyle 演示</title>
<style>#box { width: 100px; margin-right: 20px; background: #9cf; }</style>
</head>
<body>
<div id="box"></div>
<script>
function getStyle(ele, attr) {
if (window.getComputedStyle) {
return getComputedStyle(ele)[attr];
}
return ele.currentStyle[attr];
}
var box = document.getElementById('box');
alert('width: ' + getStyle(box, 'width') + ', marginRight: ' + getStyle(box, 'marginRight'));
</script>
</body>
</html>
【代码注释】getComputedStyle 返回带单位的字符串,需 parseInt 参与运算;属性名驼峰 marginRight 与 DOM 一致。
可运行示例(实战):goodData 结构
javascript
var goodData = {
imgsrc: [
{ b: "./images/b1.png", s: "./images/s1.png" },
{ b: "./images/b2.png", s: "./images/s2.png" }
],
goodsDetail: {
title: "示例手机",
price: 5299,
crumbData: [
{
title: "选择颜色",
data: [
{ type: "金色", changePrice: 0 },
{ type: "黑色", changePrice: 90 }
]
}
]
}
};
【代码注释】s 小图、b 大图分离,点击缩略图时分别赋给放大镜两个 img;changePrice 为规格加价,后续章节用于动态算价。
functions.js 完整实现:
javascript
function getStyle(ele, attr) {
if (window.getComputedStyle) {
return getComputedStyle(ele)[attr];
}
return ele.currentStyle[attr];
}
function tab(tabNavItems, tabPanelItems) {
tabNavItems.forEach(function (tabNavItem, index) {
tabNavItem.onclick = function () {
for (var i = 0; i < tabNavItems.length; i++) {
tabNavItems[i].classList.remove('active');
tabPanelItems[i].classList.remove('active');
}
tabNavItem.classList.add('active');
tabPanelItems[index].classList.add('active');
};
});
}
【代码注释】getStyle 兼容 IE 的 currentStyle;tab 未做边界校验,调用方需保证两个 NodeList 等长。tabNavItems 为静态 NodeList,动态增删 Tab 项后需重新查询或改用事件委托。
页面底部脚本顺序(08 模块):
html
<script src="./js/data.js"></script>
<script src="./js/functions.js"></script>
<script src="./js/index.js"></script>
【代码注释】顺序不可颠倒:index.js 依赖全局 goodData、getStyle、tab。若用 type="module",需改为 import 并去掉全局变量写法。
imgsrc 为何有 14 条?
课堂数据用 3 张图循环填充,便于演示「横向滚动条很长」;每条 { s, b } 分别对应缩略图与放大镜大图。条数变化时记得同步调整滚动下限或改用动态 scrollWidth 计算。
javascript
// data.js 片段
imgsrc: [
{ b: "./images/b1.png", s: "./images/s1.png" },
{ b: "./images/b2.png", s: "./images/s2.png" },
{ b: "./images/b3.png", s: "./images/s3.png" }
// ... 重复多组用于拉长列表
]
【代码注释】s 小图、b 大图路径相对 HTML;部署时通常走 CDN 绝对 URL。goodsDetail 含标题、价格、crumbData 规格维度,供后续动态规格区使用。
【实战要点】
- 经典应用场景 :详情页 JSON 接口返回后赋值给
goodData,刷新缩略图与标题。 - 常见坑 :
getComputedStyle对display:none的元素部分属性为auto或0;测量宽高前元素须在布局树中可见。 - 性能与最佳实践 :公共函数放单独文件利于缓存;生产可用模块化
import替代全局。
【本章小结】
| 文件 | 职责 |
|---|---|
| data.js | 静态/mock 数据 |
| functions.js | getStyle、tab |
| index.js | 页面行为 IIFE |
【面试考点】
Q1:IIFE 和 ES Module 的「私有变量」有何不同?
A:IIFE 靠函数作用域隐藏变量;ES Module 靠模块作用域,未 export 的绑定外部不可见,且支持静态分析和 tree-shaking。IIFE 是运行期隔离,Module 是编译期隔离------后者打包工具可消除未使用的导出。
Q2:getComputedStyle 的性能如何?读取哪些属性会触发强制回流?
A:浏览器为优化性能,会把多个 DOM 修改批量合并 (lazy flush),延迟到真正需要时才重绘。但一旦 JS 读取「布局依赖型」属性,浏览器必须立即刷新挂起的写操作以返回准确值------这叫强制同步回流(Forced Synchronous Layout / Layout Thrashing):
javascript
// 触发强制回流的属性(读取时必须先刷新队列):
// offsetWidth/offsetHeight/offsetLeft/offsetTop/offsetParent
// clientWidth/clientHeight/clientLeft/clientTop
// scrollWidth/scrollHeight/scrollLeft/scrollTop
// getComputedStyle() 所有几何相关属性
// getBoundingClientRect()
// 反模式(读写交错,每次都强制回流):
for (var i = 0; i < items.length; i++) {
var w = items[i].offsetWidth; // 读 → 回流
items[i].style.width = w + 10 + 'px'; // 写 → 使队列脏
}
// 上面循环 N 次 = N 次回流!
// 最佳实践(批量读,再批量写):
var widths = items.map(function(el) { return el.offsetWidth; }); // 批量读(1次回流)
items.forEach(function(el, i) { el.style.width = widths[i] + 10 + 'px'; }); // 批量写
【代码注释】缩略图中 parseInt(getStyle(el, 'marginRight')) 只在初始化时调用一次,缓存为 imgItemWidth,后续 onclick 不再重新读取,正是这种「一次读取、多次复用」的正确实践。getComputedStyle 在元素 display:none 时,部分属性返回 0 或 auto,测量宽高前需确保元素在布局树中可见。
Q3:数据驱动(goodData)与 DOM 操作如何平衡?
A:数据驱动利于维护(修改数据自动反映视图),但原生 JS 需手动同步 DOM。框架(Vue/React)提供响应式机制,自动将数据变化映射到 DOM。本项目规模较小,直接操作 DOM + 数据分离足够;若状态复杂(如购物车数量、规格联动),可引入轻量状态管理或框架。
Q4:<script> 放在 </body> 前与加 defer 属性有何区别?
A:两种方案的结果相近但机制不同:
放在 </body> 前 |
defer 属性 |
type="module" |
|
|---|---|---|---|
| 下载时机 | HTML 解析到该标签时开始下载 | HTML 解析同时并行下载(不阻塞解析) | 同上(defer 语义) |
| 执行时机 | HTML 解析完毕后立即执行 | HTML 解析完毕后按顺序执行 | 同左,按声明顺序 |
| 保证顺序 | 是(DOM 顺序) | 是 | 是 |
| 需要 DOM 就绪 | 是 | 是 | 是 |
defer 的本质优势是并行下载 不阻塞 HTML 解析,从而加快首屏渲染(FCP/LCP);而脚本放 </body> 前,下载仍然是串行的(遇到 <script> 才开始下载)。本项目三个 <script> 放 </body> 前靠顺序保证依赖,改用 ES Module + import 后可无需关心标签顺序(由模块系统自动解析依赖图)。
总结
知识点回顾(思维导图)
#mermaid-svg-Uye2laaQcfcnqGNK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Uye2laaQcfcnqGNK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Uye2laaQcfcnqGNK .error-icon{fill:#552222;}#mermaid-svg-Uye2laaQcfcnqGNK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Uye2laaQcfcnqGNK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Uye2laaQcfcnqGNK .marker.cross{stroke:#333333;}#mermaid-svg-Uye2laaQcfcnqGNK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Uye2laaQcfcnqGNK p{margin:0;}#mermaid-svg-Uye2laaQcfcnqGNK .edge{stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 path,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth--1{stroke-width:17;}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-0{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-0{stroke-width:14;}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-1{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-1{stroke-width:11;}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-2{stroke-width:8;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-3{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-3{stroke-width:5;}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-4{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-4{stroke-width:2;}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-5{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-5{stroke-width:-1;}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-6{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-6{stroke-width:-4;}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-7{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-7{stroke-width:-7;}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-8{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-8{stroke-width:-10;}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-9{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-9{stroke-width:-13;}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-10{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-10{stroke-width:-16;}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-root rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-root path,#mermaid-svg-Uye2laaQcfcnqGNK .section-root circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-root text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-root span{color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 span{color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-Uye2laaQcfcnqGNK .edge{fill:none;}#mermaid-svg-Uye2laaQcfcnqGNK .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-Uye2laaQcfcnqGNK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PC 商品详情页
工程化样式
Less 变量混合 → 2026 原生 CSS 替代路径
Reset 与 container 版心
CSS 层叠规范 W3C
布局骨架
Flex 顶栏/header/nav
space-between 两端对齐
min-width 防挤压
面包屑伪元素 after NBSP
放大镜交互
getBoundingClientRect 视口坐标
scrollLeft/Top 比例联动
两种路线 scrollLeft vs background-position
边界钳制 Math.max/min
rAF 性能优化
缩略图轮播
new Image 动态渲染
event.timeStamp 节流
leading vs trailing
vs performance.now
事件委托换图
transform 生产优化
选项卡
排他思想
tab 函数复用
ARIA role tablist tab tabpanel
纯 CSS :target 方案
键盘导航 roving tabindex
工程实践
IIFE 作用域隔离
ES Module 静态分析 tree-shaking
goodData 数据分离
getComputedStyle 强制回流避坑
defer vs body 尾部加载
【代码注释】从外到内:先搭样式架构,再堆交互模块,最后抽函数与数据------与真实项目迭代顺序一致。每个节点对应博客中一个可独立实践的知识点,可按此图自查是否全部掌握。
高频面试题速查
- Less 变量 vs CSS 变量 --- 编译期 vs 运行期;2026 年新项目优先 CSS 自定义属性;Less 仍适合编译期循环/条件逻辑。
margin: auto居中 --- 块级 + 显式 width + 水平剩余空间;垂直方向不生效(需 Flex 或绝对定位)。:not(:last-child)与::after面包屑 --- 排他分隔、NBSP(\00a0)、屏幕阅读器感知。- getBoundingClientRect vs offsetLeft --- 视口坐标(受滚动影响但无需计算)vs offsetParent 链(需逐层累加)。
- mouseenter vs mouseover --- 是否冒泡、子元素干扰;放大镜用 mouseenter 更简洁。
- 放大镜两种实现 ---
scrollLeft方案(大图宽×n)vsbackground-position方案(负值公式);数学本质相同。 - 放大镜中
getBoundingClientRect与页面滚动 --- 返回视口坐标,自动包含滚动偏移;不需加window.scrollY。 - 放大镜 scrollLeft 触发回流 --- 每次赋值触发 Forced Reflow;生产推荐改
transform+rAF合并写入。 - thumb-wrapper 用 left 而非 scrollLeft --- absolute + overflow:hidden 裁切视窗;
transition: left提供动画;生产推荐改transform: translateX走合成层。 event.timeStampvsperformance.now()vsDate.now()--- 前两者精度相同(DOMHighResTimeStamp),均相对performance.timeOrigin;Date.now()为 Unix 毫秒,受系统时间校正影响。- leading vs trailing 节流 --- leading:第一次立即执行(箭头连点适用);trailing:停止后再执行一次(滚动到底刷新列表适用)。
- 事件委托条件 --- 冒泡、
event.target判断来源、focus/blur不可委托(改用focusin/focusout)。 - 选项卡 display vs visibility ---
display:none移出渲染树不占位;visibility:hidden占位;面板高度不一致时用 visibility 避免跳动。 - 选项卡 ARIA ---
role="tablist/tab/tabpanel"、aria-selected、roving tabindex、方向键切换,符合 WAI-ARIA 规范。 - tab() 复用与 CSS 分离 --- JS 只切换 active;视觉样式全在 CSS,同一函数可服务不同 UI 皮肤。
- IIFE vs CommonJS vs ES Module --- IIFE 函数作用域(运行时);CommonJS 模块作用域(动态 require);ESM 静态分析 + tree-shaking + 默认 defer。
- getComputedStyle 强制回流 --- 读写交错导致 Layout Thrashing;最佳实践:批量读再批量写;缓存
offsetWidth等布局属性。 defervs 脚本放</body>前 --- defer 并行下载不阻塞 HTML 解析,加快 FCP;ESM 默认 defer 并自动解析依赖图。
学习建议
- 渐进对照 :按 0.5 节的阶段顺序保存各阶段的
index.html,用 diff 工具对比相邻阶段的index.less/index.js增量,精准看到每阶段「只新增了什么」。 - 放大镜拆解 :先只做 CSS 层叠(蒙层 + 大图窗),再加
mousemove坐标换算,最后接scrollLeft联动。彻底跑通后再尝试改用background-position方案,感受两种路线的数学等价性。 - 缩略图边界 :把图片数量改成 5 张或 20 张,观察下限公式是否还准确;练习改为动态计算
-(thumbWrapper.scrollWidth - viewerWidth),消除硬编码数量。 - 抽象练习 :将
tab(navItems, panelItems)改为tab('#introTab')在函数内部querySelectorAll,体会「传 selector 比传 NodeList 更 DRY」的 API 设计权衡。 - 进阶预习 :阅读
goodsDetail.crumbData结构,思考如何用document.createElement+ 排他实现规格按钮选中(与选项卡同源,只是同时切多个维度)。 - 性能实测 :用浏览器 DevTools Performance 面板录制悬停放大镜 1 秒,观察
mousemove触发多少次 Layout;再加requestAnimationFrame合并写操作,对比两次录制的 Layout 时间占比。 - ES Module 改造练习 :将
data.js和functions.js改为export,index.js改为import,HTML 中改用<script type="module">,感受模块化带来的依赖自动管理和作用域隔离。 - 无障碍升级 :给选项卡补全
role="tablist/tab/tabpanel"和aria-selected,用 Chrome 内置 Accessibility 树查看变化,感受 ARIA 对屏幕阅读器的影响。 - 能力衔接 :在最终阶段跑通后,继续实现
.product-box内规格选择与crumbData联动------思路与 Tab 排他相同,只需多维度同时排他,并将changePrice累加到基准价格。
自检清单(最终阶段验收)
- 悬停小图出现蒙层与大图窗,移出隐藏
- 缩略图箭头连点不会「狂滚」
- 点击缩略图主图与大图同步更换
- 侧栏、详情两处 Tab 切换正常且互不影响
- 修改
index.less后已生成并刷新index.css - Console 无
goodData is not defined(脚本顺序)
常见错误排查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 放大镜大图不动 | 倍率或 img 尺寸不对 |
核对 800×800 与 * 2 |
| 缩略图点击无效 | 未命中 IMG 或 index 错 |
检查 dataset.index |
| 箭头连点仍狂滚 | 未更新 btn.time |
确认 400ms 判断写在 return 前 |
| Tab 切换空白 | nav/panel 数量不一致 | querySelectorAll 长度 |
| 样式不更新 | Less 未编译 | 保存 less 生成 css |
$ is not defined 等 |
脚本顺序错误 | data → functions → index |
本文示例代码均可保存为 .html 于本目录直接打开;完整电商页面请使用同目录下渐进式示例中的最终版页面。