Day20_PC 端电商商品详情页前端实战:从布局到放大镜与选项卡

导读:本文以原生 HTML、CSS(Less)、JavaScript 为主线,从零拆解 PC 端商品详情页的完整实现路径------工程化样式准备、顶栏与导航骨架、面包屑、放大镜、缩略图轮播、侧边栏与详情选项卡,以及 IIFE、事件委托与节流等工程实践。文中示例均可直接在浏览器中运行验证,并引用 MDN getBoundingClientRect事件委托 等权威资料。

目录


零、导读与学习价值

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 动态 ImageoffsetLeft 滚动、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 常见标签与语义化结构(headernavmain 等);
  • CSS 盒模型、display: flex、定位(relative / absolute);
  • JavaScript DOM 查询(querySelector)、事件监听、基础数组方法(forEach)。

若对 Less 编译 不熟,可先安装 VS Code 插件 Easy LessLive 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) :抹平浏览器默认 marginlist-style 等差异,让各端起点一致。
  • Less 变量 :用 @name: value 集中管理主题色,改一处全局生效。
  • Less 混合(Mixin) :可复用的样式块,如 .clearfix() 清除浮动。

概念与底层原理

浏览器对同一标签的默认样式并不一致(例如 bodymarginulpadding)。重置样式在级联层 最早生效,后续业务样式在此基础上叠加。根据 W3C CSS 级联规范,样式优先级由重要性、来源、特指度、源码顺序决定;重置通过低特指度选择器(如 * { margin: 0 })确保被业务样式覆盖。

Less 在构建阶段被编译成普通 CSS,变量在编译期替换,运行时浏览器只看到标准 CSS ------这与 CSS 自定义属性(--color)在运行时可变不同,适合「主题色固定」的电商站。CSS 变量的 inherit 语义由 CSS Custom Properties 规范 定义,可通过 JS 实时修改实现主题切换,但存在序列化开销。

.container { width: 1200px; margin: 0 auto; } 是经典定宽居中:块级元素水平外边距 auto 会均分剩余空间,从而居中。这是 CSS Box Modelmargin-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/rightauto 时,若元素有显式 width 且水平方向有剩余空间,浏览器会均分剩余空间给左右 margin,实现居中。这是 CSS Box Modelauto 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:logoform 分列两侧;搜索框 box-sizing: border-box 使 width 包含 paddingborder,避免撑破布局。box-sizing 改变 CSS Box Model 的计算方式:content-box(默认)width 只含内容,border-box width 含 padding + border。

页面导航 .page-nav 底部 2px solid 品牌红色;左侧 .all-cates h2 红底白字块,右侧 nav 内链水平排列。

面包屑 .path-navspan: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 更省事。

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-heightheight 相等实现单行垂直居中。

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 伪元素的 contentdisplay: 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-betweengap 的区别?

A:space-between 把剩余空间分到第一项前与最后一项后没有空隙 、项与项之间均分;gap 是项与项之间固定间距,两端不留「被拉大」的空白。顶栏两端贴边用 space-between 更贴切。gapCSS 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 窗口。

鼠标移动时:

  1. left = clientX - smallImageBox.getBoundingClientRect().left(同理 top)------见 MDN getBoundingClientRect。该 API 返回元素相对于视口 的位置与尺寸,由 CSSOM View Module 规范定义。相比 offsetLeft 需遍历 offsetParent 链,getBoundingClientRect 更直接。
  2. 蒙层中心跟鼠标:left -= maskWidth/2
  3. 边界钳制 :蒙层不能移出小图。用 Math.max/min 确保坐标在 [0, containerWidth - maskWidth] 区间内。
  4. 大图同步:largeImageBox.scrollLeft = left * 2(小图与大图 1:2,蒙层 200 对应大图 400 视窗)。scrollLeft/scrollTopCSSOM 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/Topbackground-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-imageleft: 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 更宽更高,通过滚动露出对应片段,比移动 imgleft 更符合「窗口取景」模型。

可运行示例(入门):局部坐标计算

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(transformopacity<1z-index 等)。

【实战要点】

  • 经典应用场景:京东商品主图悬停放大、天猫 Lens 效果,本质都是「局部坐标 → 取景窗口」。
  • 常见坑 :滚动页面后仍用旧的偏移量------getBoundingClientRect 每次 move 重新取即可;若用 offsetLeft 累加需考虑 offsetParent 链。
  • 性能与最佳实践mousemove 触发频繁,逻辑保持轻量;必要时可用 requestAnimationFrame 合并写入。

【本章小结】

步骤 API/属性
局部坐标 clientX/YgetBoundingClientRect()
蒙层定位 absolute + 钳制
大图 scrollLeft/Top 比例联动

【面试考点】

Q1:为什么用 scrollLeft 而不是改大图的 left

A:overflow:hidden 的容器通过滚动「移动内容」,语义是视口不动、底图动;直接改 left 也可以,但大图常远大于视窗,用滚动更符合 DOM 结构,也便于与真实 <img> 宽高配合。

Q2:getBoundingClientRectoffsetLeft 的区别?

A:getBoundingClientRect 返回相对于视口 的坐标,受滚动影响,无需遍历 offsetParent;offsetLeft 相对** offsetParent**(定位祖先元素),需逐层累加。放大镜需精确跟随鼠标,用视口坐标更直接。

Q3:mouseenter/mouseleavemouseover/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> 动态节点。
  • datasetdata-* 属性的 JS 访问入口,如 data-indexdataset.index
  • 节流(本课实现) :两次有效操作间隔至少 400ms,用 event.timeStamp 判断。

概念与底层原理

缩略图列表来自 goodData.imgsrc 数组,每项含小图 s 与大图 bforEach 创建 Image 节点追加到 .thumb-wrapperposition:absolute; left 控制横向滚动)。

单格宽度:offsetWidth + parseInt(getStyle(el, 'marginRight')),需 getComputedStyle 读取 margin(见第六章)。offsetWidth 包含 border + padding + content,由 CSSOM View 定义,会触发回流(reflow)------频繁访问时应缓存。

点击箭头:改变 thumbWrapper.style.left,每次移动 imgItemWidth * 2left 上限 0、下限 imgItemWidth*5 - thumbWrapper.offsetWidth(可视 5 张时的边界,与案例数据量相关)。

节流if (event.timeStamp - btn.time <= 400) return; 否则更新 btn.time = event.timeStamptimeStamp 为事件创建时的时间戳(毫秒,规范见 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.timeStampperformance.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 上监听 clickevent.target.nodeName === 'IMG' 时切换主图 src 与放大镜大图 src。委托利用事件冒泡(DOM Events 规范),动态增删节点时无需重新绑定。focusblur 不冒泡,不可委托。

.thumb-wrapper { transition: left 300ms; } 提供滑动动画;父级 overflow: hidden 形成视窗。transitionCSS 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-wrapperleft,不是 scrollLeft------子项总宽大于 372px 时被裁切;transitionCSS 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 向上找。focusblur 不冒泡,不能委托。委托减少监听器数量,适合动态列表;但过多嵌套会增加冒泡路径开销。

Q2:节流和防抖区别?

A:节流------间隔内执行一次(滚动、按钮连点);防抖------连续触发只执行最后一次(搜索框输入后再请求)。本课箭头用节流。节流保证最低频率执行,防抖合并多次触发为一次。

Q3:event.timeStamp 的精度是多少?与 performance.now()Date.now() 有什么区别?

A:event.timeStampDOMHighResTimeStamp,精度与 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'),再给当前 tabNavItemtabPanelItems[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 替代单纯的 .activehidden 属性替代 display:none,对 AT(辅助技术)更明确。ARIA 规范由 WAI-ARIA 1.2 定义。切换时需 JS 同步更新 aria-selectedhidden市面应用:天猫、京东商品详情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>

【代码注释】第一项默认带 activetab-nav-itemtab-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 ...')) 相同模式;NodeListforEach,IE 需 Array.prototype.slicefor 循环。

项目调用:

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 整体间跳焦点)。具体:监听 keydownArrowRight → 激活下一个 Tab 并 focus()ArrowLeft → 激活上一个。同时给所有非当前 Tab 按钮设 tabindex="-1",当前 Tab 设 tabindex="0",保证 Tab 键只落在一个焦点上(roving tabindex 模式)。这是 WAI-ARIA Authoring Practices 的推荐实现。


六、数据驱动与工具函数库

名词解释

  • getComputedStyle :返回元素最终计算后的 CSS 属性值(只读)。
  • currentStyle:IE 旧 API,现代项目作兼容兜底。

概念与底层原理

data.js 导出 goodDataimgsrc 数组供缩略图;goodsDetail 含标题、价格、推荐文案及 crumbData 规格维度(颜色、内存等)------为后续规格加价、数量联动等进阶功能预留数据结构。

functions.js 提供:

  • getStyle(ele, attr):兼容 IE 的样式读取,供 marginRight 等计算。getComputedStyleCSSOM 规范 定义,返回只读的 CSSStyleDeclaration 对象;IE8- 用 currentStyle(非标准,已废弃)。
  • tab(tabNavItems, tabPanelItems):选项卡通用逻辑。

各功能块用 IIFE 包裹,内部 var 不污染 window。IIFE(立即执行函数表达式)通过函数作用域隔离变量,避免全局命名空间污染------这是 ES6 模块化出现前的主流模块化方案。脚本顺序:data.jsfunctions.jsindex.js,因为 index.js 依赖 goodDatatab 函数。

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 大图分离,点击缩略图时分别赋给放大镜两个 imgchangePrice 为规格加价,后续章节用于动态算价。

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 的 currentStyletab 未做边界校验,调用方需保证两个 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 依赖全局 goodDatagetStyletab。若用 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,刷新缩略图与标题。
  • 常见坑getComputedStyledisplay:none 的元素部分属性为 auto0;测量宽高前元素须在布局树中可见。
  • 性能与最佳实践 :公共函数放单独文件利于缓存;生产可用模块化 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 时,部分属性返回 0auto,测量宽高前需确保元素在布局树中可见。

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 尾部加载

【代码注释】从外到内:先搭样式架构,再堆交互模块,最后抽函数与数据------与真实项目迭代顺序一致。每个节点对应博客中一个可独立实践的知识点,可按此图自查是否全部掌握。

高频面试题速查

  1. Less 变量 vs CSS 变量 --- 编译期 vs 运行期;2026 年新项目优先 CSS 自定义属性;Less 仍适合编译期循环/条件逻辑。
  2. margin: auto 居中 --- 块级 + 显式 width + 水平剩余空间;垂直方向不生效(需 Flex 或绝对定位)。
  3. :not(:last-child)::after 面包屑 --- 排他分隔、NBSP(\00a0)、屏幕阅读器感知。
  4. getBoundingClientRect vs offsetLeft --- 视口坐标(受滚动影响但无需计算)vs offsetParent 链(需逐层累加)。
  5. mouseenter vs mouseover --- 是否冒泡、子元素干扰;放大镜用 mouseenter 更简洁。
  6. 放大镜两种实现 --- scrollLeft 方案(大图宽×n)vs background-position 方案(负值公式);数学本质相同。
  7. 放大镜中 getBoundingClientRect 与页面滚动 --- 返回视口坐标,自动包含滚动偏移;不需加 window.scrollY
  8. 放大镜 scrollLeft 触发回流 --- 每次赋值触发 Forced Reflow;生产推荐改 transform + rAF 合并写入。
  9. thumb-wrapper 用 left 而非 scrollLeft --- absolute + overflow:hidden 裁切视窗;transition: left 提供动画;生产推荐改 transform: translateX 走合成层。
  10. event.timeStamp vs performance.now() vs Date.now() --- 前两者精度相同(DOMHighResTimeStamp),均相对 performance.timeOriginDate.now() 为 Unix 毫秒,受系统时间校正影响。
  11. leading vs trailing 节流 --- leading:第一次立即执行(箭头连点适用);trailing:停止后再执行一次(滚动到底刷新列表适用)。
  12. 事件委托条件 --- 冒泡、event.target 判断来源、focus/blur 不可委托(改用 focusin/focusout)。
  13. 选项卡 display vs visibility --- display:none 移出渲染树不占位;visibility:hidden 占位;面板高度不一致时用 visibility 避免跳动。
  14. 选项卡 ARIA --- role="tablist/tab/tabpanel"aria-selected、roving tabindex、方向键切换,符合 WAI-ARIA 规范。
  15. tab() 复用与 CSS 分离 --- JS 只切换 active;视觉样式全在 CSS,同一函数可服务不同 UI 皮肤。
  16. IIFE vs CommonJS vs ES Module --- IIFE 函数作用域(运行时);CommonJS 模块作用域(动态 require);ESM 静态分析 + tree-shaking + 默认 defer。
  17. getComputedStyle 强制回流 --- 读写交错导致 Layout Thrashing;最佳实践:批量读再批量写;缓存 offsetWidth 等布局属性。
  18. defer vs 脚本放 </body> --- defer 并行下载不阻塞 HTML 解析,加快 FCP;ESM 默认 defer 并自动解析依赖图。

学习建议

  1. 渐进对照 :按 0.5 节的阶段顺序保存各阶段的 index.html,用 diff 工具对比相邻阶段的 index.less / index.js 增量,精准看到每阶段「只新增了什么」。
  2. 放大镜拆解 :先只做 CSS 层叠(蒙层 + 大图窗),再加 mousemove 坐标换算,最后接 scrollLeft 联动。彻底跑通后再尝试改用 background-position 方案,感受两种路线的数学等价性。
  3. 缩略图边界 :把图片数量改成 5 张或 20 张,观察下限公式是否还准确;练习改为动态计算 -(thumbWrapper.scrollWidth - viewerWidth),消除硬编码数量。
  4. 抽象练习 :将 tab(navItems, panelItems) 改为 tab('#introTab') 在函数内部 querySelectorAll,体会「传 selector 比传 NodeList 更 DRY」的 API 设计权衡。
  5. 进阶预习 :阅读 goodsDetail.crumbData 结构,思考如何用 document.createElement + 排他实现规格按钮选中(与选项卡同源,只是同时切多个维度)。
  6. 性能实测 :用浏览器 DevTools Performance 面板录制悬停放大镜 1 秒,观察 mousemove 触发多少次 Layout;再加 requestAnimationFrame 合并写操作,对比两次录制的 Layout 时间占比。
  7. ES Module 改造练习 :将 data.jsfunctions.js 改为 exportindex.js 改为 import,HTML 中改用 <script type="module">,感受模块化带来的依赖自动管理和作用域隔离。
  8. 无障碍升级 :给选项卡补全 role="tablist/tab/tabpanel"aria-selected,用 Chrome 内置 Accessibility 树查看变化,感受 ARIA 对屏幕阅读器的影响。
  9. 能力衔接 :在最终阶段跑通后,继续实现 .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 于本目录直接打开;完整电商页面请使用同目录下渐进式示例中的最终版页面。

相关推荐
lly20240613 小时前
建造者模式
开发语言
众创岛13 小时前
java环境配置(windows)
java·开发语言
问心无愧051313 小时前
ctf show web入门259
android·前端·笔记
ct97813 小时前
Object.defineProperty/Proxy与 vue2 + vue3 响应式原理
前端·javascript·vue.js
光泽雨13 小时前
C# 扩展方法(Extension Method)在语法上的核心灵魂。
开发语言·c#
代码小书生13 小时前
shutil,一个文件操作的 Python 库!
开发语言·python·策略模式
啄缘之间13 小时前
10.【学习】SPI & UART 验证环境与测试用例
开发语言·经验分享·学习·fpga开发·测试用例·verilog
yu859395813 小时前
基于MATLAB的层合板等效模量及极限强度计算实现
开发语言·matlab
星轨初途13 小时前
【C++ 进阶】list 核心机制解析及 vector 巅峰对决
开发语言·数据结构·c++·经验分享·笔记·list