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 动态 Image、offsetLeft 滚动、timeStamp 节流、事件委托换图
8 侧边栏选项卡 .active 显隐、tab() 函数封装
9 商品详情选项卡 同一 tab() 复用不同 DOM 集合

完整页面效果可将本节最终阶段的所有代码保存为同目录下的 index.html,保持同级 css/、js/、images/ 结构后双击打开查看。

0.2 核心名词速查

术语 一句话解释
Less CSS 预处理器,支持变量、嵌套、混合,编译为浏览器可识别的 CSS
IIFE 立即执行函数,创建私有作用域,避免全局变量污染
事件委托 在父元素上统一监听,通过 event.target 判断实际触发源
排他思想 切换状态时先清除所有项的 active,再给当前项加上 active
getBoundingClientRect 返回元素相对视口的位置与尺寸,用于计算鼠标在容器内的坐标
节流(本节用法) 用 event.timeStamp 限制两次有效点击的最小间隔,防止连点

0.3 为什么要学本篇

  • 岗位价值:电商详情页是前端高频业务,放大镜、规格区、详情 Tab 几乎每家平台都有。
  • 技术底座:不依赖框架即可完成布局 + 交互,为后续 Vue/React 组件化打基础。
  • 工程习惯:模块化样式、公共函数库、数据与视图分离,是大型项目的通用套路。

0.4 前置知识

阅读本文前,建议已掌握:

  • HTML5 常见标签与语义化结构(header、nav、main 等);
  • CSS 盒模型、display: flex、定位(relative / absolute);
  • JavaScript DOM 查询(querySelector)、事件监听、基础数组方法(forEach)。

若对 Less 编译 不熟,可先安装 VS Code 插件 Easy Less 或 Live Less Compiler ,保存 .less 时自动生成同目录 index.css。

0.5 建议练习路线

九个渐进模块按依赖关系拼装,推荐顺序:
#mermaid-svg-V8Ia7U1TUvlWGOTT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-V8Ia7U1TUvlWGOTT .error-icon{fill:#552222;}#mermaid-svg-V8Ia7U1TUvlWGOTT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-V8Ia7U1TUvlWGOTT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .marker.cross{stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-V8Ia7U1TUvlWGOTT p{margin:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label text{fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label span{color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster-label span p{background-color:transparent;}#mermaid-svg-V8Ia7U1TUvlWGOTT .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT span{fill:#333;color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node rect,#mermaid-svg-V8Ia7U1TUvlWGOTT .node circle,#mermaid-svg-V8Ia7U1TUvlWGOTT .node ellipse,#mermaid-svg-V8Ia7U1TUvlWGOTT .node polygon,#mermaid-svg-V8Ia7U1TUvlWGOTT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .rough-node .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label text,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label{text-anchor:middle;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .rough-node .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label,#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label{text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node.clickable{cursor:pointer;}#mermaid-svg-V8Ia7U1TUvlWGOTT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .arrowheadPath{fill:#333333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster text{fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT .cluster span{color:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-V8Ia7U1TUvlWGOTT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-V8Ia7U1TUvlWGOTT rect.text{fill:none;stroke-width:0;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape p,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-V8Ia7U1TUvlWGOTT .icon-shape .label rect,#mermaid-svg-V8Ia7U1TUvlWGOTT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V8Ia7U1TUvlWGOTT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-V8Ia7U1TUvlWGOTT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-V8Ia7U1TUvlWGOTT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 整体准备
顶栏
Logo与搜索
页面导航
面包屑
放大镜
缩略图
侧栏Tab
详情Tab

【代码注释】每步只新增一块 DOM 与对应 Less 片段,避免一次写完全页难以调试;放大镜之后 JS 开始介入,缩略图阶段依赖放大镜建立的 #zoomBox 结构。

阶段 范围 本步验收标准
样式骨架 整体准备 → 面包屑 顶栏、搜索、红底导航、面包屑视觉正确
核心交互 放大镜 + 缩略图 悬停放大、缩略图滚动与换图
组件复用 侧栏Tab + 详情Tab 两处选项卡共用 tab(),无全局变量冲突

0.6 渐进式文件变更与后续衔接

每前进一个模块,通常只改 css/index.less(增量样式)与 js/index.js(增量 IIFE),data.js / functions.js 从 05 模块起复制并沿用:

阶段 新增/修改重点 典型新增选择器或 id
整体准备 reset、variables、mixins、.container ---
骨架布局 顶栏、header、nav、.path-nav .topbar、.page-nav
放大镜 .zoom-box、蒙层、大图窗 #zoomBox
缩略图 .thumb-box、箭头、.thumb-wrapper #thumbBox
侧栏 Tab .product-siderbar、侧栏 Tab #siderbarTab
详情 Tab .product-intro-tab、详情 Tab #introTab

【代码注释】相邻两个阶段的 index.less 做 diff 可只看本阶段新增片段;index.js 在缩略图阶段之后常是「前面 IIFE 保留 + 底部追加」,勿删旧逻辑。

本阶段终点能力 :静态布局 + 放大镜 + 缩略图 + 双 Tab。goodData.goodsDetail.crumbData 已在 data.js 预留,下一阶段 会在 .product-box 区用动态 dl/dd 做规格选择与算价,并引入原生 CSS var() / calc() 做布局计算与主题色管理------与本篇 Less 变量互补而非替代。


一、工程化准备:Less、重置与公共样式

名词解释

  • CSS 重置(Reset) :抹平浏览器默认 margin、list-style 等差异,让各端起点一致。
  • Less 变量 :用 @name: value 集中管理主题色,改一处全局生效。
  • Less 混合(Mixin) :可复用的样式块,如 .clearfix() 清除浮动。

概念与底层原理

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

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

.container { width: 1200px; margin: 0 auto; } 是经典定宽居中:块级元素水平外边距 auto 会均分剩余空间,从而居中。这是 CSS Box Model 中 margin-top/bottom 在块级格式化上下文(BFC)中的行为:若元素有显式宽度且水平方向有剩余空间,auto 边距会拉伸填充。

2026 年的现状:Less 嵌套语法 vs 原生 CSS 嵌套

Less 的嵌套写法在 2026 年已可以用原生 CSS 嵌套完全替代(Chrome 120+、Firefox 117+、Safari 17.2+ 原生支持,全球覆盖率 > 92%):

css 复制代码
/* Less 写法(需编译) */
.topbar {
    background: #eaeaea;
    .container {
        display: flex;
        a { color: #666; }
    }
}

/* 原生 CSS 嵌套(无需编译,直接在浏览器运行) */
.topbar {
    background: #eaeaea;
    & .container {         /* & 显式引用父级,与 Less 的 & 相同 */
        display: flex;
        & a { color: #666; }
    }
}

【代码注释】原生 CSS 嵌套规范来自 CSS Nesting Module Level 1。新项目若目标浏览器支持率满足要求,可直接用原生嵌套,省去 Less 编译步骤。老项目迁移时,区别在于原生 CSS 嵌套要求非 & 开头的选择器必须加 &,否则部分解析器行为不一致。

mermaid 图:Less 编译与样式引入关系

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...index.less 入口 --> B@import variables -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

【代码注释】入口文件按「变量 → 混合 → 重置 → 业务」顺序 @import,保证业务里能使用 @form-red 等变量,且重置不被业务意外覆盖(同一选择器时仍遵循 CSS 优先级规则)。

可运行示例(入门):变量与嵌套

将下面内容保存为 demo-less-vars.html,放在本文所在目录后双击打开:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Less 变量思想演示(编译后等价 CSS)</title>
  <style>
    /* 模拟 Less 编译结果:@form-red: #ea4a36 */
    .search-btn {
      background: #ea4a36;
      color: #fff;
      border: none;
      padding: 8px 16px;
    }
    .search-input {
      border: 2px solid #ea4a36;
      padding: 6px;
    }
  </style>
</head>
<body>
  <input class="search-input" placeholder="搜索商品">
  <button class="search-btn">搜索</button>
</body>
</html>

【代码注释】真实项目中在 variables.less 定义 @form-red: #ea4a36,搜索框边框与按钮背景共用该变量;改品牌色只改一行。这是「设计令牌」思想的雏形,与 Ant Design 的 @primary-color 同类。

可运行示例(实战):重置 + 定宽容器

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>重置与 container 演示</title>
  <style>
    body, ul, p { margin: 0; padding: 0; }
    ul { list-style: none; }
    a { text-decoration: none; color: #666; }
    .container {
      width: 1200px;
      margin-left: auto;
      margin-right: auto;
      background: #f5f5f5;
      padding: 12px;
    }
  </style>
</head>
<body>
  <div class="container">
    <p>定宽 1200px 居中区域 ------ 电商 PC 站常见版心宽度</p>
  </div>
</body>
</html>

【代码注释】margin: auto 仅对块级且设定了 width 的元素水平居中;与 max-width + 百分比配合可做简单响应式,本项目以 PC 固定版心为主。

项目中 reset.less 还统一了标题字号、table 折叠边框、html { overflow-y: scroll; } 防止滚动条闪动等细节。

混合示例(清除浮动):

less 复制代码
.clearfix() {
  &::after {
    content: "";
    display: block;
    clear: both;
  }
}

【代码注释】伪元素 ::after 生成块级盒子并 clear: both,使父元素包裹住浮动子元素;Flex 布局普及后用得少,但读懂有助于维护老项目。

变量与嵌套(项目 variables.less + 顶栏片段):

less 复制代码
@form-red: #ea4a36;
@btn-red: #e1251b;
@sep-color: #b3aeae;

.topbar {
    height: 30px;
    background: #eaeaea;
    .container {
        display: flex;
        justify-content: space-between;
        a {
            padding: 0 10px;
            &:not(:last-child) {
                border-right: 1px solid @sep-color;
            }
        }
    }
}

【代码注释】& 代表父选择器,编译为 .topbar .container a:not(:last-child);嵌套层级不宜超过 3~4 层,否则生成选择器过长、特指度难控。@sep-color 与竖线分隔线共用,改灰色系只改变量。

入口 index.less 标准写法:

less 复制代码
@import "variables";
@import "mixins";
@import "reset";
// 以下为各模块业务样式,随模块递增而追加
.container { width: 1200px; margin-left: auto; margin-right: auto; }

【代码注释】@import 无扩展名时 Less 自动找 .less;编译后浏览器只请求一个 index.css,利于缓存。

【实战要点】

  • 经典应用场景 :多页面站点用同一套 reset + variables + mixins,各页 index.less 只写差异部分。天猫、京东的 PC 站均采用 1200px 版心 + Less 编译流程。
  • 常见坑 :Less 修改后忘记重新编译,index.css 仍是旧内容------开发时用 Live Less 或构建脚本监听;@import 在 CSS 中会阻塞渲染,Less 的 @import 编译后合并到单文件可避免此问题。
  • 性能与最佳实践 :生产环境只引入编译后的一个 index.css,减少 HTTP 请求;变量名语义化(@btn-red 而非 @c1);考虑 CSS 压缩与 contenthash 文件名做长期缓存。

【本章小结】

要点 说明
重置 统一标签默认样式,减少跨浏览器差异
变量/混合 主题色与公共片段集中维护
.container 1200px 版心 + 水平居中

记忆口诀:「先 reset,再变量,版心 container 包全场」。

【面试考点】

Q1:Less 变量和 CSS 变量(--x)有什么区别?

A:Less 变量在编译阶段 替换,浏览器看不到变量名;CSS 变量在运行时 由浏览器解析,可用 JS 修改 document.documentElement.style.setProperty('--x', ...) 做主题切换。电商固定皮肤常用 Less;需要动态换肤用 CSS 变量更合适。追问「CSS 变量性能」时答:CSS 变量继承与计算发生在样式解析阶段,与编译后常量差异可忽略;但频繁 JS 修改 --x 会触发重排,建议配合 requestAnimationFrame 批量写入。

Q2:为什么要做 CSS Reset?

A:消除 User Agent 默认样式差异,避免「本地正常、别的浏览器歪了」;现代项目也可用 Normalize.css 保留有用默认(如 h1 字号),思路类似但策略不同。Reset 会抹掉所有默认,需自行补充(如 table { border-collapse: collapse });Normalize 保留有用部分并修正已知 bug。

Q3:margin: auto 居中的原理是什么?

A:块级盒子的 margin-left/right 为 auto 时,若元素有显式 width 且水平方向有剩余空间,浏览器会均分剩余空间给左右 margin,实现居中。这是 CSS Box Model 中 auto margin 的分配规则。垂直方向 margin: auto 在普通流中不居中(高度由内容决定),需配合 Flex 或绝对定位。

Q4:Less 文件改了但页面没变?

A:浏览器只加载 index.css,不识别 .less。确认:1)已安装编译插件或构建任务;2)保存的是 index.less 而非只改未引入的片段;3)HTML 里 <link href="./css/index.css"> 路径正确;4)强刷缓存(DevTools 禁用 cache)。

Q5:2026 年还有必要用 Less 吗?原生 CSS 已经支持嵌套和自定义属性。

A:这是个场景问题。原生 CSS 已经覆盖了 Less 的大多数日常用途:

  • CSS 嵌套(Chrome 120+ / Firefox 117+):写法与 Less/Sass 几乎相同,无需构建步骤。
  • CSS 自定义属性 (--color: #ea4a36):运行时可变,document.documentElement.style.setProperty('--color', ...) 实现主题切换,比 Less 变量更强大。
  • color-mix() / oklch() :现代浏览器原生颜色混合,替代 Less 的 lighten()/darken()。

仍值得用 Less 的场景 :① 需要兼容不支持 CSS 嵌套的旧浏览器(IE 11、部分 Edge 遗留);② 需要 @for 循环、条件 @if、复杂 mixin 参数化生成(如表格格宽批量生成)等编译期逻辑 ,原生 CSS 无法替代;③ 团队现有项目已大量使用 Less 变量,迁移成本高。新项目建议优先用 CSS 自定义属性 + 原生嵌套,仅在确实需要编译期 logic 时引入 Sass/Less。

可运行示例(补充):Less 编译前后对照

保存为 demo-less-compile.html(内联已编译 CSS,模拟 index.css 效果):

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Less 编译结果演示</title>
  <style>
    /* 等价于 variables.less + 业务 */
    .topbar { height: 30px; line-height: 30px; background: #eaeaea; }
    .topbar .container {
      width: 1200px; margin: 0 auto;
      display: flex; justify-content: space-between;
    }
    .topbar a { padding: 0 10px; color: #666; text-decoration: none; }
    .topbar a:not(:last-child) { border-right: 1px solid #b3aeae; }
  </style>
</head>
<body>
  <div class="topbar">
    <div class="container">
      <span>欢迎</span>
      <nav><a href="#">订单</a><a href="#">购物车</a></nav>
    </div>
  </div>
</body>
</html>

【代码注释】真实项目里 @sep-color 在 Less 里定义,编译后变成 #b3aeae 常量;改 @sep-color 后必须重新编译才能看到效果。


二、页面骨架:顶栏、Logo、导航与面包屑

名词解释

  • Flex 主轴/交叉轴 :justify-content 控制主轴对齐,align-items 控制交叉轴。
  • 面包屑(Breadcrumb):层级路径导航,利于用户定位与 SEO 结构化数据。

概念与底层原理

顶栏 .topbar 高度 30px、背景 #eaeaea,内部 .container 使用 display: flex; justify-content: space-between,把「欢迎区」与「功能链接区」推到两端。根据 CSS Flexbox 规范,space-between 会让首个 flex 项与主轴起始边对齐、末项与主轴结束边对齐,剩余空间均匀分配到各项之间。

链接分隔采用 a:not(:last-child) { border-right: ... },利用相邻关系选择器 避免最后一项多一条竖线。:not() 伪类由 CSS Selectors Level 3 定义,可简化排他逻辑。

Logo 区 .page-header .container 同样 Flex:logo 与 form 分列两侧;搜索框 box-sizing: border-box 使 width 包含 padding 和 border,避免撑破布局。box-sizing 改变 CSS Box Model 的计算方式:content-box(默认)width 只含内容,border-box width 含 padding + border。

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

面包屑 .path-nav 用 span:not(:last-child)::after { content: "/\00a0"; } 在项与项之间插入斜杠;\00a0 是不换行空格(Unicode NBSP),防止斜杠与文字贴太紧。::after 生成的内容不改变 DOM 结构,屏幕阅读器可识别(取决于 CSS content 属性的 CSS Pseudo-Elements 规范 实现)。

mermaid 图:详情页纵向区块

#mermaid-svg-s8icw5OT4jSCt10y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s8icw5OT4jSCt10y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s8icw5OT4jSCt10y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s8icw5OT4jSCt10y .error-icon{fill:#552222;}#mermaid-svg-s8icw5OT4jSCt10y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s8icw5OT4jSCt10y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s8icw5OT4jSCt10y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s8icw5OT4jSCt10y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .marker.cross{stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s8icw5OT4jSCt10y p{margin:0;}#mermaid-svg-s8icw5OT4jSCt10y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label text{fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label span{color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster-label span p{background-color:transparent;}#mermaid-svg-s8icw5OT4jSCt10y .label text,#mermaid-svg-s8icw5OT4jSCt10y span{fill:#333;color:#333;}#mermaid-svg-s8icw5OT4jSCt10y .node rect,#mermaid-svg-s8icw5OT4jSCt10y .node circle,#mermaid-svg-s8icw5OT4jSCt10y .node ellipse,#mermaid-svg-s8icw5OT4jSCt10y .node polygon,#mermaid-svg-s8icw5OT4jSCt10y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .rough-node .label text,#mermaid-svg-s8icw5OT4jSCt10y .node .label text,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label,#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label{text-anchor:middle;}#mermaid-svg-s8icw5OT4jSCt10y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .rough-node .label,#mermaid-svg-s8icw5OT4jSCt10y .node .label,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label,#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label{text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .node.clickable{cursor:pointer;}#mermaid-svg-s8icw5OT4jSCt10y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .arrowheadPath{fill:#333333;}#mermaid-svg-s8icw5OT4jSCt10y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s8icw5OT4jSCt10y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s8icw5OT4jSCt10y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s8icw5OT4jSCt10y .cluster text{fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y .cluster span{color:#333;}#mermaid-svg-s8icw5OT4jSCt10y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s8icw5OT4jSCt10y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s8icw5OT4jSCt10y rect.text{fill:none;stroke-width:0;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape,#mermaid-svg-s8icw5OT4jSCt10y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape p,#mermaid-svg-s8icw5OT4jSCt10y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s8icw5OT4jSCt10y .icon-shape .label rect,#mermaid-svg-s8icw5OT4jSCt10y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s8icw5OT4jSCt10y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s8icw5OT4jSCt10y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s8icw5OT4jSCt10y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} topbar 顶栏
page-header Logo+搜索
page-nav 分类导航
page-main 主内容
path-nav 面包屑
product 商品区
product-section 侧栏+详情

【代码注释】自上而下堆叠;主内容 margin-top: 15px 与顶栏区拉开呼吸感,是电商详情典型信息架构。

可运行示例(入门):顶栏 Flex

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>顶栏 Flex 演示</title>
  <style>
    .topbar { height: 30px; line-height: 30px; background: #eaeaea; }
    .container {
      width: 900px; margin: 0 auto;
      display: flex; justify-content: space-between;
    }
    .topbar a {
      padding: 0 10px; color: #666; text-decoration: none;
    }
    .topbar a:not(:last-child) { border-right: 1px solid #b3aeae; }
  </style>
</head>
<body>
  <div class="topbar">
    <div class="container">
      <div>欢迎来到尚品汇!请 <a href="#">登录</a></div>
      <nav>
        <a href="#">我的订单</a>
        <a href="#">购物车</a>
        <a href="#">会员</a>
      </nav>
    </div>
  </div>
</body>
</html>

【代码注释】line-height 等于 height 实现单行垂直居中;:not(:last-child) 精准控制「除最后一项外都有右边框」。

最终页 DOM 与模块对应(08 模块):

区域 主要 class / id 对应章节
顶栏 .topbar 二
Logo+搜索 .page-header 二
主导航 .page-nav 二
面包屑 .path-nav 二
放大镜 #zoomBox 三
缩略图 #thumbBox 四
侧栏 Tab #siderbarTab 五
详情 Tab #introTab 五
规格区占位 .product-box 下一阶段扩展

【代码注释】id 与 JS 强绑定,改名必须同步改 querySelector;class 负责样式。缩略图 HTML 里注释掉的静态 <img> 列表由 JS 动态生成替代,避免写死 14 张图。

可运行示例(实战):面包屑伪元素分隔

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>面包屑演示</title>
  <style>
    .path-nav { display: flex; padding: 9px 0; }
    .path-nav span:not(:last-child)::after {
      content: "/\00a0";
      padding: 0 5px;
      color: #ccc;
    }
    .path-nav a { color: #666; text-decoration: none; }
  </style>
</head>
<body>
  <nav class="path-nav">
    <span><a href="#">手机、数码</a></span>
    <span><a href="#">手机</a></span>
    <span><a href="#">Apple</a></span>
    <span>iPhone 6s 系列</span>
  </nav>
</body>
</html>

【代码注释】最后一项通常是当前页,不加链接、不追加斜杠;SEO 上可用 <ol> + JSON-LD BreadcrumbList 结构化数据,视觉上 ::after 更省事。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Logo 与搜索框</title>
  <style>
    .page-header .container {
      width: 900px; margin: 0 auto;
      display: flex; justify-content: space-between; align-items: flex-start;
    }
    .logo { width: 120px; height: 40px; background: #e1251b; margin: 20px; }
    form { display: flex; margin-top: 28px; }
    form input {
      box-sizing: border-box; width: 360px; height: 32px;
      border: 2px solid #ea4a36; padding: 0 6px; outline: none;
    }
    form button {
      width: 64px; height: 32px; border: none; background: #ea4a36; color: #fff;
    }
  </style>
</head>
<body>
  <div class="page-header">
    <div class="container">
      <div class="logo"></div>
      <form><input type="text" placeholder="搜索"><button type="button">搜索</button></form>
    </div>
  </div>
</body>
</html>

【代码注释】box-sizing: border-box 保证 width: 490px 时边框不撑破一行;outline: none 去掉默认焦点框后应自行加 :focus 样式以照顾无障碍。

可运行示例(实战):页面主导航 .page-nav

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>页面导航演示</title>
  <style>
    .page-nav { border-bottom: 2px solid #e1251b; }
    .page-nav .container {
      width: 900px; margin: 0 auto;
      display: flex; height: 50px; line-height: 50px;
    }
    .all-cates h2 {
      width: 210px; text-align: center; margin: 0;
      font-size: 14px; color: #fff; background: #e1251b;
    }
    .page-nav nav { display: flex; }
    .page-nav nav a { margin: 0 22px; color: #333; font-size: 16px; text-decoration: none; }
  </style>
</head>
<body>
  <div class="page-nav">
    <div class="container">
      <div class="all-cates"><h2>全部商品分类</h2></div>
      <nav>
        <a href="#">服装城</a>
        <a href="#">美妆馆</a>
        <a href="#">秒杀</a>
      </nav>
    </div>
  </div>
</body>
</html>

【代码注释】左侧 h2 固定 210px 红底块是电商站「全部分类」入口的视觉锚点;右侧 nav Flex 横排频道链接。line-height 与 height 相等实现单行垂直居中。

min-width: 1200px 的作用(04 及以后模块):

较后版本给 .topbar、.page-header、.page-nav 增加 min-width: 1200px,与 .container 的 1200px 版心配合:窗口变窄时整条顶栏出现横向滚动,而不是 Flex 子项被挤压变形。这是 PC 站「保设计稿宽度」的常见折中,与移动端 meta viewport 响应式是不同策略。

【实战要点】

  • 经典应用场景 :京东、天猫 PC 详情顶栏 + 搜索 + 红底分类条,结构与本节一致;面包屑还支持 JSON-LD BreadcrumbList 结构化数据,让 Google 在搜索结果中展示导航路径,提升点击率(CTR)。
  • 常见坑 1 :Flex 子项默认 min-width: auto(由内容决定),长文字不换行会撑破布局------对子项设 min-width: 0 可让其被压缩;再配合 overflow: hidden; text-overflow: ellipsis 截断过长文字。
  • 常见坑 2 :::after 伪元素的 content 在 display: flex 的父元素中成为 flex 子项 ,若需要它只作为装饰不参与布局,可设 order 或改用其他方案。
  • 性能与最佳实践 :纯 CSS 骨架无需 JS,首屏可先渲染静态 HTML,利于 FCP(First Contentful Paint)。min-width: 1200px 在窗口变窄时触发水平滚动,比 Flex 子项被压缩变形更接近设计稿意图;移动端适配时需配合 meta viewport 另外处理。

【本章小结】

模块 布局手段 记忆点
topbar Flex 两端对齐 竖线分隔链接
page-header Flex + border-box 表单 品牌色边框
page-nav 底边框 + Flex 分类块 + 横向 nav
path-nav Flex + ::after 最后一项无斜杠

【面试考点】

Q1:justify-content: space-between 和 gap 的区别?

A:space-between 把剩余空间分到第一项前与最后一项后没有空隙 、项与项之间均分;gap 是项与项之间固定间距,两端不留「被拉大」的空白。顶栏两端贴边用 space-between 更贴切。gap 由 CSS Box Alignment 定义,可用于 Grid/Flex 布局。

Q2::not() 选择器的性能如何?

A:现代浏览器对 :not() 优化良好,性能与其他伪类相近;但复杂选择器链(如 .nav a:not(.external):hover)仍会增加匹配成本。从右向左匹配时,:not() 内的条件同样会逐一测试。

Q3:content: "/\00a0" 中的 \00a0 是什么?

A:Unicode 不换行空格(NBSP),十六进制 00A0。CSS 中 \00a0 或 \nbsp 均可表示,比普通空格(\0020)宽且不折行。用于面包屑分隔可防止斜杠与文字紧贴。


三、商品放大镜:布局与坐标联动

名词解释

  • 蒙层(mask):半透明方块,标示当前放大区域。
  • 视口坐标 :event.clientX/Y 相对浏览器可视区域,需减去容器偏移得到局部坐标。

概念与底层原理

结构三层:.small-image(400×400)内绝对定位 .mask-box(200×200);.large-image 在右侧绝对定位,内部 img 尺寸约为小图 2 倍,容器 overflow: hidden 只露出 400×400 窗口。

鼠标移动时:

  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/scrollTop 是 CSSOM View 定义的元素属性,改变的是内容滚动位置而非元素本身偏移。

显示/隐藏:mouseenter 显示蒙层与大图窗,mouseleave 隐藏。这两个事件不会冒泡,比 mouseover/mouseout 更适合处理进出逻辑(见 DOM Events 规范)。

两种主流放大镜实现路线对比:

路线 原理 优点 缺点
scrollLeft/Top(本项目) 大图容器 overflow:hidden,通过滚动位置露出对应区域 与真实 <img> 宽高配合自然,无需计算比例系数 大图须宽/高是小图 n 倍;浏览器对 scrollLeft 赋值会触发 Layout
background-position 用 background-image + background-size 模拟大图,通过改变背景位置实现取景 无需 overflow:hidden 嵌套容器;兼容 CSS 变量驱动 background-position 负值公式较绕:(-offsetX * zoom + glassSize/2)px

scrollLeft/Top 与 background-position 的坐标公式推导:

设小图显示尺寸 W×H,大图实际尺寸 nW×nH(n=2),蒙层宽 w(=W/2),视窗宽 W:

  • 蒙层左上角坐标 left 范围 [0, W-w];
  • scrollLeft = left * n ------ 蒙层移动 1px,大图内容滚 n px,使视窗中心始终对准鼠标;
  • background-position 路线:-left * n px(取负值,因背景向左移使右侧内容"进入"取景框);实际还要加半个玻璃偏移修正:-(left * n - glassSize/2) px。

这说明两种方案的数学本质完全相同,只是操作不同 DOM 属性。

CSS 层叠结构(布局先于 JS):

less 复制代码
.zoom-box {
    position: relative;           // 定位上下文
    .small-image {
        position: relative;
        width: 400px; height: 400px;
        cursor: move;
        .mask-box {
            display: none;
            position: absolute;
            width: 200px; height: 200px;
            background: rgba(255,255,255,.5);
        }
    }
    .large-image {
        display: none;
        position: absolute;
        left: 420px; top: 0;       // 小图右侧露出
        width: 400px; height: 400px;
        overflow: hidden;           // 视窗裁剪
        img { width: 800px; }      // 实际大图 2 倍
    }
}

【代码注释】.large-image 的 left: 420px 留出 20px 间距;overflow: hidden 形成 400×400 取景框。蒙层默认 display: none,由 JS 在 mouseenter 时改为 block。

放大倍率与 scrollLeft 对应关系:

元素 宽高 说明
小图 .small-image 400×400 用户操作区域
蒙层 .mask-box 200×200 视窗为小块的 1/2
大图视窗 .large-image 400×400 与小图同尺寸窗口
大图 img 800×800 为小图 2 倍
JS 联动 scrollLeft = left * 2 蒙层移动 1px,大图内容滚 2px

【代码注释】倍率 2 来自「大图尺寸 / 小图尺寸」;若设计稿改为 3 倍图,把乘数改为 3 并同步改 img 宽高。

性能优化(可选): mousemove 触发极频,可在处理器内用 requestAnimationFrame 合并 DOM 写入,避免一帧内多次改 style 导致多余回流:

javascript 复制代码
var ticking = false;
smallImageBox.onmousemove = function(event) {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(function() {
        // 此处放入 left/top/scrollLeft 计算与赋值
        ticking = false;
    });
};

【代码注释】rAF 与屏幕刷新率对齐(通常 60fps),比无节制改 style 更平滑;本课教学版为便于理解未使用,上线交互区建议加上。

mermaid 图:放大镜坐标流

大图容器 蒙层 小图区域 用户鼠标 大图容器 蒙层 小图区域 用户鼠标 #mermaid-svg-UToX1acfMHHJq3zN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UToX1acfMHHJq3zN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UToX1acfMHHJq3zN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UToX1acfMHHJq3zN .error-icon{fill:#552222;}#mermaid-svg-UToX1acfMHHJq3zN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UToX1acfMHHJq3zN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UToX1acfMHHJq3zN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UToX1acfMHHJq3zN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UToX1acfMHHJq3zN .marker.cross{stroke:#333333;}#mermaid-svg-UToX1acfMHHJq3zN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UToX1acfMHHJq3zN p{margin:0;}#mermaid-svg-UToX1acfMHHJq3zN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UToX1acfMHHJq3zN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-UToX1acfMHHJq3zN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .sequenceNumber{fill:white;}#mermaid-svg-UToX1acfMHHJq3zN #sequencenumber{fill:#333;}#mermaid-svg-UToX1acfMHHJq3zN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-UToX1acfMHHJq3zN .messageText{fill:#333;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN .labelText,#mermaid-svg-UToX1acfMHHJq3zN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .loopText,#mermaid-svg-UToX1acfMHHJq3zN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UToX1acfMHHJq3zN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UToX1acfMHHJq3zN .noteText,#mermaid-svg-UToX1acfMHHJq3zN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-UToX1acfMHHJq3zN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UToX1acfMHHJq3zN .actorPopupMenu{position:absolute;}#mermaid-svg-UToX1acfMHHJq3zN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-UToX1acfMHHJq3zN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UToX1acfMHHJq3zN .actor-man circle,#mermaid-svg-UToX1acfMHHJq3zN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-UToX1acfMHHJq3zN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} mousemove(clientX/Y) 换算局部 left/top 更新 left/top(钳制) scrollLeft = left*2

【代码注释】scrollLeft 移动的是内容 而非盒子本身;大图 img 更宽更高,通过滚动露出对应片段,比移动 img 的 left 更符合「窗口取景」模型。

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

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>坐标换算演示</title>
  <style>
    #box { width: 300px; height: 200px; background: #eee; position: relative; margin: 40px; }
    #dot { width: 10px; height: 10px; background: red; position: absolute; border-radius: 50%; }
  </style>
</head>
<body>
  <div id="box"><div id="dot"></div></div>
  <p id="info"></p>
  <script>
    var box = document.getElementById('box');
    var dot = document.getElementById('dot');
    var info = document.getElementById('info');
    box.onmousemove = function (e) {
      var rect = box.getBoundingClientRect();
      var x = e.clientX - rect.left - 5;
      var y = e.clientY - rect.top - 5;
      dot.style.left = Math.max(0, Math.min(x, box.clientWidth - 10)) + 'px';
      dot.style.top = Math.max(0, Math.min(y, box.clientHeight - 10)) + 'px';
      info.textContent = '局部坐标: ' + Math.round(e.clientX - rect.left) + ', ' + Math.round(e.clientY - rect.top);
    };
  </script>
</body>
</html>

【代码注释】红点中心对准鼠标需减去自身半宽;边界 Math.max/min 与放大镜蒙层钳制同一逻辑。

可运行示例(实战):简化版放大镜(IIFE)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>简化放大镜</title>
  <style>
    #zoomBox { position: relative; width: 200px; height: 200px; border: 1px solid #ccc; }
    .small { position: relative; width: 200px; height: 200px; background: linear-gradient(135deg,#6cf,#f6c); cursor: move; }
    .mask { display: none; position: absolute; width: 100px; height: 100px; background: rgba(255,255,255,.4); border: 1px solid #999; left: 0; top: 0; }
    .large-win { display: none; position: absolute; left: 220px; top: 0; width: 200px; height: 200px; overflow: hidden; border: 1px solid #ccc; }
    .large-win .inner { width: 400px; height: 400px; background: linear-gradient(135deg,#6cf,#f6c); }
  </style>
</head>
<body>
  <div id="zoomBox">
    <div class="small">
      <div class="mask"></div>
    </div>
    <div class="large-win"><div class="inner"></div></div>
  </div>
  <script>
    (function () {
      var small = document.querySelector('.small');
      var mask = document.querySelector('.mask');
      var largeWin = document.querySelector('.large-win');
      var inner = document.querySelector('.inner');
      small.onmouseenter = function () {
        mask.style.display = 'block';
        largeWin.style.display = 'block';
      };
      small.onmousemove = function (e) {
        var r = small.getBoundingClientRect();
        var left = e.clientX - r.left - mask.offsetWidth / 2;
        var top = e.clientY - r.top - mask.offsetHeight / 2;
        left = Math.max(0, Math.min(left, small.clientWidth - mask.offsetWidth));
        top = Math.max(0, Math.min(top, small.clientHeight - mask.offsetHeight));
        mask.style.left = left + 'px';
        mask.style.top = top + 'px';
        largeWin.scrollLeft = left * 2;
        largeWin.scrollTop = top * 2;
      };
      small.onmouseleave = function () {
        mask.style.display = 'none';
        largeWin.style.display = 'none';
      };
    })();
  </script>
</body>
</html>

【代码注释】用渐变块代替真实商品图,比例仍为 2:1;IIFE 包裹避免 zoomBox 等变量泄漏到全局。完整商品图版本使用 images/b1.png 等大图资源。

项目中的核心逻辑:

javascript 复制代码
(function() {
    var zoomBox = document.querySelector('#zoomBox');
    var smallImageBox = zoomBox.querySelector('.small-image');
    var largeImageBox = zoomBox.querySelector('.large-image');
    var maskBox = zoomBox.querySelector('.mask-box');

    smallImageBox.onmouseenter = function() {
        maskBox.style.display = 'block';
        largeImageBox.style.display = 'block';
    };

    smallImageBox.onmousemove = function(event) {
        var left = event.clientX - smallImageBox.getBoundingClientRect().left;
        var top = event.clientY - smallImageBox.getBoundingClientRect().top;
        left -= maskBox.offsetWidth / 2;
        top -= maskBox.offsetHeight / 2;
        if (left < 0) left = 0;
        else if (left > smallImageBox.clientWidth - maskBox.offsetWidth) {
            left = smallImageBox.clientWidth - maskBox.offsetWidth;
        }
        if (top < 0) top = 0;
        else if (top > smallImageBox.clientHeight - maskBox.offsetHeight) {
            top = smallImageBox.clientHeight - maskBox.offsetHeight;
        }
        maskBox.style.left = left + 'px';
        maskBox.style.top = top + 'px';
        largeImageBox.scrollLeft = left * 2;
        largeImageBox.scrollTop = top * 2;
    };

    smallImageBox.onmouseleave = function() {
        maskBox.style.display = 'none';
        largeImageBox.style.display = 'none';
    };
})();

【代码注释】offsetWidth 含 border,与 clientWidth 区分使用;比例 * 2 来自小图 400、蒙层 200、大图视窗 400 与 img 800 的尺寸关系,换图时需按设计稿重算倍率。

层叠上下文(为何大图能盖住旁边内容):

less 复制代码
.zoom-box { position: relative; z-index: auto; }
.small-image { position: relative; }   /* 蒙层 absolute 相对小图定位 */
.large-image {
    position: absolute;
    left: 420px;
    z-index: 1;   /* 可按需抬高,避免被右侧规格区遮挡 */
}

【代码注释】position: relative 建立定位上下文;未设 z-index 时仍按 DOM 顺序与层叠规则绘制。若大图被遮挡,检查后续兄弟元素是否创建了更高 stacking context(transform、opacity<1、z-index 等)。

【实战要点】

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

【本章小结】

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

【面试考点】

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

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

Q2:getBoundingClientRect 与 offsetLeft 的区别?

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

Q3:mouseenter/mouseleave 与 mouseover/mouseout 的区别?

A:前者不冒泡 ,只在进入/离开元素本身触发;后者会冒泡,进出子元素也触发。放大镜只需监听进出小图区,用 mouseenter/mouseleave 更简单,避免子元素干扰。

Q4:页面有滚动时,getBoundingClientRect 的坐标还准确吗?

A:准确。getBoundingClientRect 返回的是相对于视口(viewport)的坐标 ,已经自动考虑了页面滚动------它始终以浏览器可视区左上角为原点,无论页面滚了多少。clientX/Y 也是视口坐标,两者相减直接得到局部坐标,不需要加 window.scrollX/Y。对比:若用 event.pageX - offsetLeft(文档坐标)则需要加滚动量修正,两者混用会出错。推荐统一用 clientX/Y + getBoundingClientRect,这是 CSSOM View Module 推荐的视口坐标体系。

Q5:放大镜中的 scrollLeft 赋值会不会影响性能?

A:scrollLeft 赋值会触发强制回流(Forced Reflow) ------浏览器必须在写操作完成后重新计算布局,代价高于 transform。高频 mousemove(60fps 下每帧约 16ms)中连续赋值 scrollLeft 是瓶颈点。现代生产环境的解法:① 改用 transform: translate() + CSS overflow:hidden 的方案避免 scrollLeft;② 在赋值前加 requestAnimationFrame 包裹,让 DOM 写操作合并到下一帧,消除同一帧内多次回流。


四、缩略图轮播:动态渲染、边界与节流

名词解释

  • new Image() :创建图片 DOM,设置 src 后插入文档,等价于 <img> 动态节点。
  • dataset :data-* 属性的 JS 访问入口,如 data-index → dataset.index。
  • 节流(本课实现) :两次有效操作间隔至少 400ms,用 event.timeStamp 判断。

概念与底层原理

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

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

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

节流 :if (event.timeStamp - btn.time <= 400) return; 否则更新 btn.time = event.timeStamp。timeStamp 为事件创建时的时间戳(毫秒,规范见 DOM 事件)。节流确保高频操作下按固定频率执行,与防抖(debounce,停止触发后延迟执行)不同------箭头连点适合节流,搜索框输入适合防抖。

event.timeStamp 的底层精度:

根据 W3C Event Timing API 规范,event.timeStamp 是一个 DOMHighResTimeStamp------与 performance.now() 同精度,可精确到亚毫秒(浏览器实现中通常精确到 0.1ms 或更高)。它表示事件被硬件生成 的时间,相对于 performance.timeOrigin(页面导航起点),是单调递增时钟,不受系统时钟调整影响。Date.now() 则返回 Unix 纪元毫秒,精度仅 1ms,且受系统时间校正影响。

复制代码
event.timeStamp  ≈  performance.now()  (基准相同,均相对 performance.timeOrigin)
                 ≠  Date.now()          (Date.now 基准是 1970-01-01 UTC)

【代码注释】三者基准不同 :event.timeStamp 与 performance.now() 均相对于 performance.timeOrigin(页面导航起点),可直接相减得到「事件发生距页面加载的毫秒数」;Date.now() 基于 Unix 纪元,与前两者相减无实际意义。节流中用 e.timeStamp - btn.time <= 400 利用的正是「同一基准下的差值」特性。

本课节流的两种写法对比(时间戳 vs 闭包计时器):

javascript 复制代码
// 本课:时间戳节流(leading 优先,第一次立即执行)
btn.time = 0;
btn.onclick = function(e) {
    if (e.timeStamp - btn.time <= 400) return;
    btn.time = e.timeStamp;
    doScroll();
};

// 通用闭包节流(leading + trailing,可选尾部执行)
function throttle(fn, interval) {
    var last = 0;
    return function(e) {
        var now = Date.now();          // 或 performance.now()
        if (now - last >= interval) {
            fn.apply(this, arguments);
            last = now;
        }
    };
}
var throttledScroll = throttle(doScroll, 400);
btn.onclick = throttledScroll;

【代码注释】本课把 time 挂在按钮 DOM 节点上(btn.time),是「就近存放状态」的简洁写法;闭包写法用私有变量 last 更符合工程封装原则。两种写法都属于 leading throttle (第一次立即执行);若需要 trailing throttle (停止后再执行一次),需在 else 分支用 setTimeout 补发,适合「滚动到底后需要最终刷新一次」的场景。

事件委托 :在 thumbWrapper 上监听 click,event.target.nodeName === 'IMG' 时切换主图 src 与放大镜大图 src。委托利用事件冒泡(DOM Events 规范),动态增删节点时无需重新绑定。focus、blur 不冒泡,不可委托。

.thumb-wrapper { transition: left 300ms; } 提供滑动动画;父级 overflow: hidden 形成视窗。transition 由 CSS Transitions 规范 定义,会触发 GPU 加速(当动画属性为 transform/opacity 时)。

mermaid 图:缩略图交互

#mermaid-svg-yAmFZtbVV8FF769j{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yAmFZtbVV8FF769j .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yAmFZtbVV8FF769j .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yAmFZtbVV8FF769j .error-icon{fill:#552222;}#mermaid-svg-yAmFZtbVV8FF769j .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yAmFZtbVV8FF769j .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yAmFZtbVV8FF769j .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yAmFZtbVV8FF769j .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .marker.cross{stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yAmFZtbVV8FF769j p{margin:0;}#mermaid-svg-yAmFZtbVV8FF769j .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label text{fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label span{color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster-label span p{background-color:transparent;}#mermaid-svg-yAmFZtbVV8FF769j .label text,#mermaid-svg-yAmFZtbVV8FF769j span{fill:#333;color:#333;}#mermaid-svg-yAmFZtbVV8FF769j .node rect,#mermaid-svg-yAmFZtbVV8FF769j .node circle,#mermaid-svg-yAmFZtbVV8FF769j .node ellipse,#mermaid-svg-yAmFZtbVV8FF769j .node polygon,#mermaid-svg-yAmFZtbVV8FF769j .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .rough-node .label text,#mermaid-svg-yAmFZtbVV8FF769j .node .label text,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label,#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label{text-anchor:middle;}#mermaid-svg-yAmFZtbVV8FF769j .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .rough-node .label,#mermaid-svg-yAmFZtbVV8FF769j .node .label,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label,#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label{text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .node.clickable{cursor:pointer;}#mermaid-svg-yAmFZtbVV8FF769j .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .arrowheadPath{fill:#333333;}#mermaid-svg-yAmFZtbVV8FF769j .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yAmFZtbVV8FF769j .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yAmFZtbVV8FF769j .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yAmFZtbVV8FF769j .cluster text{fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j .cluster span{color:#333;}#mermaid-svg-yAmFZtbVV8FF769j div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yAmFZtbVV8FF769j .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yAmFZtbVV8FF769j rect.text{fill:none;stroke-width:0;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape,#mermaid-svg-yAmFZtbVV8FF769j .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape p,#mermaid-svg-yAmFZtbVV8FF769j .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yAmFZtbVV8FF769j .icon-shape .label rect,#mermaid-svg-yAmFZtbVV8FF769j .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yAmFZtbVV8FF769j .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yAmFZtbVV8FF769j .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yAmFZtbVV8FF769j :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} goodData.imgsrc
forEach 创建 Image
thumb-wrapper
prev/next 改 left
委托 click 换主图
timeStamp 节流 400ms

【代码注释】数据、视图、行为分离:data.js 只放数据,index.js 负责 DOM 操作与事件。

可运行示例(入门):动态创建缩略图

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>动态缩略图</title>
  <style>
    .thumb-wrapper { display: flex; gap: 8px; }
    .thumb-wrapper img { width: 50px; height: 50px; border: 1px solid #ccc; cursor: pointer; }
    #main { width: 120px; height: 120px; background: #ddd; margin-bottom: 12px; display: flex; align-items: center; justify-content: center; }
  </style>
</head>
<body>
  <div id="main">主图区</div>
  <div class="thumb-wrapper" id="wrap"></div>
  <script>
    var imgs = ['#e74c3c','#3498db','#2ecc71','#f39c12'];
    var wrap = document.getElementById('wrap');
    var main = document.getElementById('main');
    imgs.forEach(function (color, i) {
      var img = new Image();
      img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="'+color+'"/></svg>');
      img.dataset.index = i;
      wrap.appendChild(img);
    });
    wrap.onclick = function (e) {
      if (e.target.nodeName === 'IMG') {
        main.style.background = imgs[e.target.dataset.index];
        main.textContent = '第 ' + (Number(e.target.dataset.index) + 1) + ' 张';
      }
    };
  </script>
</body>
</html>

【代码注释】用 SVG data URL 代替真实 png,演示 new Image() + 委托;dataset.index 与数组下标对应,换主图时取 goodData.imgsrc[i].s / .b。

可运行示例(实战):timeStamp 节流

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>节流演示</title>
  <style>button { padding: 10px 20px; margin: 20px; }</style>
</head>
<body>
  <button id="btn">快速连点(400ms 内只生效一次)</button>
  <p id="log"></p>
  <script>
    var btn = document.getElementById('btn');
    var log = document.getElementById('log');
    var count = 0;
    btn.time = 0;
    btn.onclick = function (e) {
      if (e.timeStamp - btn.time <= 400) return;
      btn.time = e.timeStamp;
      count++;
      log.textContent = '有效点击次数: ' + count + ',timeStamp=' + Math.round(e.timeStamp);
    };
  </script>
</body>
</html>

【代码注释】本课用的是时间戳节流 而非 setTimeout 防抖:防抖是「停手后再执行」,节流是「固定间隔内最多一次」。购物车加减、轮播箭头常用节流。

完整缩略图逻辑(与 06 模块一致,含上一张按钮):

javascript 复制代码
(function(){
    var prevBtn = document.querySelector('#thumbBox .thumb-prev');
    var nextBtn = document.querySelector('#thumbBox .thumb-next');
    var thumbWrapper = document.querySelector('#thumbBox .thumb-wrapper');
    var smallImage = document.querySelector('#zoomBox .small-image img');
    var largeImage = document.querySelector('#zoomBox .large-image img');

    goodData.imgsrc.forEach(function(imgItem, index) {
        var imgBox = new Image();
        imgBox.src = imgItem.s;
        imgBox.dataset.index = index;
        thumbWrapper.appendChild(imgBox);
    });

    var imgItemWidth = thumbWrapper.firstElementChild.offsetWidth +
        parseInt(getStyle(thumbWrapper.firstElementChild, 'marginRight'), 10);

    prevBtn.time = 0;
    prevBtn.onclick = function(event) {
        if (event.timeStamp - prevBtn.time <= 400) return;
        prevBtn.time = event.timeStamp;
        var left = thumbWrapper.offsetLeft + imgItemWidth * 2;
        if (left > 0) left = 0;
        thumbWrapper.style.left = left + 'px';
    };

    nextBtn.time = 0;
    nextBtn.onclick = function(event) {
        if (event.timeStamp - nextBtn.time <= 400) return;
        nextBtn.time = event.timeStamp;
        var left = thumbWrapper.offsetLeft - imgItemWidth * 2;
        var minLeft = imgItemWidth * 5 - thumbWrapper.offsetWidth;
        if (left < minLeft) left = minLeft;
        thumbWrapper.style.left = left + 'px';
    };

    thumbWrapper.onclick = function(event) {
        if (event.target.nodeName === 'IMG') {
            var i = event.target.dataset.index;
            smallImage.src = goodData.imgsrc[i].s;
            largeImage.src = goodData.imgsrc[i].b;
        }
    };
})();

【代码注释】parseInt(..., 10) 避免八进制歧义;minLeft 与「可视约 5 张」相关,图片条数变化时应改为 -(thumbWrapper.scrollWidth - 父级可视宽) 动态计算。

节流 vs 防抖(与本课相关的两种写法):

策略 本课用法 典型代码形态 适用
时间戳节流 箭头 timeStamp 间隔 400ms if (now - last <= 400) return 连点、滚动采样
定时器防抖 本篇未用 clearTimeout(t); t = setTimeout(fn, 300) 搜索框、resize 布局
javascript 复制代码
// 防抖示意(搜索框,非本课主流程)
var timer;
input.oninput = function () {
  clearTimeout(timer);
  timer = setTimeout(function () { fetchSuggestions(input.value); }, 300);
};

【代码注释】防抖在「停止输入 300ms 后」才请求;节流在「400ms 内只认第一次有效点击」。放大镜的 mousemove 若需限频,用 requestAnimationFrame 比防抖更合适。

更稳健的滚动边界(扩展):

javascript 复制代码
var viewW = thumbWrapper.parentElement.clientWidth;
var maxScroll = thumbWrapper.scrollWidth - viewW; // 若用 transform 滚动可类比
var minLeft = -maxScroll;
if (left < minLeft) left = minLeft;

【代码注释】用内容总宽减视窗宽得到最大左移量,图片条数变化时无需手改「5 张」魔法数。

缩略图区 CSS 结构:

less 复制代码
.thumb-box {
    display: flex;
    justify-content: space-between;
    .thumb-content {
        position: relative;
        width: 372px;
        overflow: hidden;              // 视窗
        .thumb-wrapper {
            position: absolute;
            left: 0;
            display: flex;
            transition: left 300ms;    // 滑动动画
            img {
                width: 50px; height: 50px;
                margin-right: 20px;
            }
        }
    }
}

【代码注释】移动的是 .thumb-wrapper 的 left,不是 scrollLeft------子项总宽大于 372px 时被裁切;transition 由 CSS Transitions 定义,仅动画 left 时部分浏览器走主线程,大量图片时可用 transform: translateX 触发合成层。

上一张按钮与边界公式:

javascript 复制代码
prevBtn.onclick = function(event) {
    if (event.timeStamp - prevBtn.time <= 400) return;
    prevBtn.time = event.timeStamp;
    var left = thumbWrapper.offsetLeft + imgItemWidth * 2;
    if (left > 0) left = 0;
    thumbWrapper.style.left = left + 'px';
};

【代码注释】offsetLeft 为负表示向左滑出过;left > 0 时钳回 0,不能再往右拖。下一张则 left -= imgItemWidth*2,下限 imgItemWidth*5 - thumbWrapper.offsetWidth 与「可视约 5 张、总图 14 张」的数据量匹配------换数据条数时需重算或改为根据 scrollWidth 动态算边界。

new Image() 与 document.createElement('img'):

本课用 new Image() 创建节点;与 createElement('img') 等价,且可在 onload 里做预加载。设置 src 后插入 DOM,浏览器异步拉取图片;dataset.index 对应 goodData.imgsrc 下标,委托点击时 O(1) 取数据。

【实战要点】

  • 经典应用场景:商品多图横滑、相册箭头;与放大镜联动是详情页标配。
  • 常见坑 :parseInt(getStyle(..., 'marginRight')) 在 margin 为 auto 时得到 NaN------案例里写死 margin;offsetLeft 相对 offsetParent,结构变化要重新测宽。
  • 性能与最佳实践 :委托适合「子节点动态增减」;高频 mousemove 才考虑 rAF,点击节流 400ms 对 UX 足够。

【本章小结】

技术点 作用
goodData 图片 s/b 与后续规格数据同源
动态 Image 数据驱动缩略图条
left 滚动 横向轮播
timeStamp 箭头连点节流
委托 点击缩略图换主图

【面试考点】

Q1:事件委托的条件是什么?

A:事件需冒泡到祖先;子元素上不要用 stopPropagation 阻断;祖先处理器里用 event.target 判断真实来源,必要时 closest 向上找。focus、blur 不冒泡,不能委托。委托减少监听器数量,适合动态列表;但过多嵌套会增加冒泡路径开销。

Q2:节流和防抖区别?

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

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

A:event.timeStamp 是 DOMHighResTimeStamp,精度与 performance.now() 相同,可达亚毫秒级(通常 0.1ms)。它们的基准相同,均相对于 performance.timeOrigin(页面导航起点),是单调递增时钟 ,不受系统时间调整影响。Date.now() 基准是 1970 Unix 纪元,精度仅 1ms,且可能因系统时间校正而跳变。用于节流/防抖,三者均可;精确测量 handler 执行时间用 performance.now() 最可靠:const start = performance.now(); doWork(); console.log(performance.now() - start + 'ms')。

Q4:为什么用 style.left 移动 .thumb-wrapper 而不是 transform: translateX?

A:本课用 left 直观对应「轨道偏移」,且与 transition: left 配套。生产环境更推荐 transform 动画(合成层、少触发 layout);改法:记录 translateX 变量,箭头点击改 transform,委托换图逻辑不变。

可运行示例(补充):缩略图 + 主图联动(迷你版)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>缩略图联动演示</title>
  <style>
    #main { width: 120px; height: 120px; background: #ddd; margin-bottom: 8px; display: flex; align-items: center; justify-content: center; }
    .track { width: 200px; overflow: hidden; }
    .wrap { display: flex; gap: 6px; transition: transform .2s; }
    .wrap img { width: 40px; height: 40px; cursor: pointer; border: 2px solid transparent; }
    .wrap img.on { border-color: #e1251b; }
  </style>
</head>
<body>
  <div id="main">主图</div>
  <button type="button" id="prev">←</button>
  <button type="button" id="next">→</button>
  <div class="track"><div class="wrap" id="wrap"></div></div>
  <script>
    var colors = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6'];
    var wrap = document.getElementById('wrap');
    var main = document.getElementById('main');
    var x = 0;
    colors.forEach(function (c, i) {
      var img = new Image();
      img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="'+c+'"/></svg>');
      img.dataset.i = i;
      wrap.appendChild(img);
    });
    function pick(i) {
      main.style.background = colors[i];
      main.textContent = '图 ' + (i + 1);
      wrap.querySelectorAll('img').forEach(function (n) { n.classList.toggle('on', n.dataset.i == i); });
    }
    wrap.onclick = function (e) { if (e.target.tagName === 'IMG') pick(e.target.dataset.i); };
    document.getElementById('next').onclick = function () { x -= 46; wrap.style.transform = 'translateX(' + x + 'px)'; };
    document.getElementById('prev').onclick = function () { x += 46; if (x > 0) x = 0; wrap.style.transform = 'translateX(' + x + 'px)'; };
    pick(0);
  </script>
</body>
</html>

【代码注释】用 transform 演示横向滚动;点击缩略图更新主图色块,对应项目中改 smallImage.src / largeImage.src 的逻辑。


五、选项卡:排他思想与函数复用

名词解释

  • Tab 导航项 / 面板项 :导航 .tab-nav-item 与内容 .tab-panel-item 一一对应。
  • 排他 :切换前清除所有 active,保证唯一选中。

概念与底层原理

CSS 控制显隐:.tab-panel-item { display: none; },.tab-panel-item.active { display: block; };导航 .tab-nav-item.active 改变边框/背景(侧边栏顶边变红,详情 Tab 红底白字)。

JS 层封装 tab(tabNavItems, tabPanelItems):forEach 给每个导航绑 click,回调里双重循环 classList.remove('active'),再给当前 tabNavItem 与 tabPanelItems[index] 加 active。

侧边栏 #siderbarTab 与详情 #introTab 共用同一函数 ,体现 DRY;07 模块中曾手写 for 循环实现排他,08 模块起改为调用 tab()------这是「先跑通再抽象」的合理重构顺序。

选项卡的无障碍(Accessibility)增强:

商业级产品页的选项卡需要 ARIA 支持,使屏幕阅读器能正确朗读当前 Tab:

html 复制代码
<div role="tablist" aria-label="商品信息">
  <button role="tab" aria-selected="true" aria-controls="panel-intro" id="tab-intro">介绍</button>
  <button role="tab" aria-selected="false" aria-controls="panel-spec" id="tab-spec">规格</button>
</div>
<div role="tabpanel" id="panel-intro" aria-labelledby="tab-intro">商品介绍内容</div>
<div role="tabpanel" id="panel-spec" aria-labelledby="tab-spec" hidden>规格参数内容</div>

【代码注释】role="tab" + aria-selected 替代单纯的 .active;hidden 属性替代 display:none,对 AT(辅助技术)更明确。ARIA 规范由 WAI-ARIA 1.2 定义。切换时需 JS 同步更新 aria-selected 与 hidden。市面应用:天猫、京东商品详情Tab均实现了基础 ARIA,以通过无障碍审计。

纯 CSS :target 方案(了解,不依赖 JS):

利用 URL hash 与 :target 伪类可实现零 JS 选项卡:

html 复制代码
<a href="#tab1">介绍</a><a href="#tab2">规格</a>
<div id="tab1">介绍内容</div>
<div id="tab2">规格内容</div>
<style>
  div[id^="tab"] { display: none; }
  div[id^="tab"]:target { display: block; }
</style>

【代码注释】:target 匹配 URL 中 #xxx 对应的元素,纯 CSS 实现无需 JS。缺点:切换 Tab 会改变浏览器历史记录;SEO 需额外处理;初始无 hash 时无面板展示。电商主流方案仍用 JS 控制 active,以精确控制初始状态与键盘导航。

两套 Tab 的 CSS 差异(同一套 JS):

位置 导航 .active 样式 面板显隐
侧边栏 #siderbarTab 顶边 border-top-color: @btn-red,底边与内容区融合 display: none/block
详情 #introTab 红底白字 background: @btn-red 同上
less 复制代码
// 侧边栏:强调「顶边红线」
.tab-nav-item.active {
    border-top-color: @btn-red;
    border-bottom-color: #fff;
}
// 详情区:强调「整块红底」
.tab-nav-item.active {
    color: #fff;
    background: @btn-red;
}

【代码注释】JS 只切换 active 类名,视觉差异完全由 CSS 决定------同一 tab() 可服务于不同 UI 皮肤,符合「结构、表现、行为」分离。

从手写到 tab() 的演进:

javascript 复制代码
// 07 阶段:内联排他(逻辑与 08 的 tab() 相同)
tabNavItems.forEach(function(tabNavItem, index) {
    tabNavItem.onclick = function() {
        for (var i = 0; i < tabNavItems.length; i++) {
            tabNavItems[i].classList.remove('active');
            tabPanelItems[i].classList.remove('active');
        }
        tabNavItem.classList.add('active');
        tabPanelItems[index].classList.add('active');
    };
});
// 08 阶段:提取后一行调用
tab(document.querySelectorAll('#introTab .tab-nav-item'),
    document.querySelectorAll('#introTab .tab-panel-item'));

【代码注释】提取函数后新增 Tab 只需 HTML + 一行 tab(...),避免复制粘贴导致某处漏改 remove('active')。

侧栏 Tab 的 HTML 骨架(需与 nav/panel 数量一致):

html 复制代码
<div class="product-siderbar" id="siderbarTab">
  <div class="tab-nav">
    <div class="tab-nav-item active">相关分类</div>
    <div class="tab-nav-item">推荐品牌</div>
  </div>
  <div class="tab-panel">
    <div class="tab-panel-item active">...分类列表...</div>
    <div class="tab-panel-item">...品牌内容...</div>
  </div>
</div>

【代码注释】第一项默认带 active;tab-nav-item 与 tab-panel-item 按索引一一对应。#siderbarTab 拼写与源码一致(Sierbar 为项目原有命名),JS 选择器勿写错。

可封装升级的 tab 写法(练习):

javascript 复制代码
function tab(rootSelector) {
  var root = document.querySelector(rootSelector);
  var nav = root.querySelectorAll('.tab-nav-item');
  var panels = root.querySelectorAll('.tab-panel-item');
  nav.forEach(function (item, index) {
    item.onclick = function () {
      nav.forEach(function (n) { n.classList.remove('active'); });
      panels.forEach(function (p) { p.classList.remove('active'); });
      item.classList.add('active');
      panels[index].classList.add('active');
    };
  });
}
tab('#siderbarTab');
tab('#introTab');

【代码注释】只传容器 id 可减少 querySelectorAll 重复;注意限定在 root 内查询,避免选中页面其他 Tab。

mermaid 图:选项卡状态机

#mermaid-svg-ItrqqHT8BN1Dwv4a{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ItrqqHT8BN1Dwv4a .error-icon{fill:#552222;}#mermaid-svg-ItrqqHT8BN1Dwv4a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ItrqqHT8BN1Dwv4a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .marker.cross{stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ItrqqHT8BN1Dwv4a p{margin:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ItrqqHT8BN1Dwv4a g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ItrqqHT8BN1Dwv4a .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ItrqqHT8BN1Dwv4a .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ItrqqHT8BN1Dwv4a .edgeLabel .label text{fill:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .label div .edgeLabel{color:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .cluster-label,#mermaid-svg-ItrqqHT8BN1Dwv4a .nodeLabel{color:#131300;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .note-edge{stroke-dasharray:5;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note text{fill:black;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagram .edgeLabel{color:red;}#mermaid-svg-ItrqqHT8BN1Dwv4a #dependencyStart,#mermaid-svg-ItrqqHT8BN1Dwv4a #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ItrqqHT8BN1Dwv4a .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ItrqqHT8BN1Dwv4a :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 默认第一项 active
点击 nav1
点击 nav2
点击 nav0
Tab0
Tab1
Tab2
每次切换前清除全部 active

【代码注释】状态只有「哪一项 active」,无中间态;与 React useState(activeIndex) 模型一致。

可运行示例(入门):纯 CSS+JS 选项卡

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>选项卡入门</title>
  <style>
    .tab-nav { display: flex; }
    .tab-nav button { padding: 8px 16px; border: 1px solid #ddd; background: #fff; cursor: pointer; }
    .tab-nav button.active { background: #e1251b; color: #fff; border-color: #e1251b; }
    .tab-panel { display: none; padding: 12px; border: 1px solid #ddd; }
    .tab-panel.active { display: block; }
  </style>
</head>
<body>
  <div class="tab-nav" id="nav">
    <button class="active" type="button">介绍</button>
    <button type="button">规格</button>
    <button type="button">售后</button>
  </div>
  <div class="tab-panel active" data-i="0">商品介绍内容</div>
  <div class="tab-panel" data-i="1">规格参数内容</div>
  <div class="tab-panel" data-i="2">售后服务内容</div>
  <script>
    var nav = document.querySelectorAll('#nav button');
    var panels = document.querySelectorAll('.tab-panel');
    nav.forEach(function (btn, index) {
      btn.onclick = function () {
        nav.forEach(function (n) { n.classList.remove('active'); });
        panels.forEach(function (p) { p.classList.remove('active'); });
        btn.classList.add('active');
        panels[index].classList.add('active');
      };
    });
  </script>
</body>
</html>

【代码注释】索引 index 来自 forEach 闭包,保证 nav 与 panel 下标对齐;生产环境可抽成 tab() 函数。

可运行示例(实战):封装 tab 并复用

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>tab 函数复用</title>
  <style>
    .wrap { margin: 20px; }
    .tab-nav { display: flex; gap: 4px; }
    .tab-nav span { padding: 6px 12px; cursor: pointer; border: 1px solid #ccc; }
    .tab-nav span.active { background: #c81623; color: #fff; }
    .tab-panel { display: none; padding: 10px; border: 1px solid #eee; }
    .tab-panel.active { display: block; }
  </style>
</head>
<body>
  <div class="wrap" id="a">
    <div class="tab-nav"><span class="active">A1</span><span>A2</span></div>
    <div class="tab-panel active">面板 A1</div>
    <div class="tab-panel">面板 A2</div>
  </div>
  <div class="wrap" id="b">
    <div class="tab-nav"><span class="active">B1</span><span>B2</span></div>
    <div class="tab-panel active">面板 B1</div>
    <div class="tab-panel">面板 B2</div>
  </div>
  <script>
    function tab(tabNavItems, tabPanelItems) {
      tabNavItems.forEach(function (tabNavItem, index) {
        tabNavItem.onclick = function () {
          for (var i = 0; i < tabNavItems.length; i++) {
            tabNavItems[i].classList.remove('active');
            tabPanelItems[i].classList.remove('active');
          }
          tabNavItem.classList.add('active');
          tabPanelItems[index].classList.add('active');
        };
      });
    }
    tab(document.querySelectorAll('#a .tab-nav span'), document.querySelectorAll('#a .tab-panel'));
    tab(document.querySelectorAll('#b .tab-nav span'), document.querySelectorAll('#b .tab-panel'));
  </script>
</body>
</html>

【代码注释】与项目中 tab(document.querySelectorAll('#introTab ...')) 相同模式;NodeList 可 forEach,IE 需 Array.prototype.slice 或 for 循环。

项目调用:

javascript 复制代码
(function(){
    tab(
      document.querySelectorAll('#siderbarTab .tab-nav-item'),
      document.querySelectorAll('#siderbarTab .tab-panel-item')
    );
})();

(function(){
    tab(
      document.querySelectorAll('#introTab .tab-nav-item'),
      document.querySelectorAll('#introTab .tab-panel-item')
    );
})();

【代码注释】两个 IIFE 各自只负责一块 DOM,互不干扰;若写成全局脚本需注意 querySelectorAll 顺序与 HTML 结构一致。

【实战要点】

  • 经典应用场景:商品详情「介绍/规格/售后」、个人中心多 Tab、后台表单分组。
  • 常见坑 :nav 与 panel 数量不一致导致 tabPanelItems[i] 为 undefined;应用 JS 初始化保证第一项带 active 与 HTML 一致。
  • 性能与最佳实践 :纯显示切换用 display 即可;频繁切换且内容重可用懒加载 panel 内容。

【本章小结】

层次 职责
CSS .active 控制显隐与强调样式
JS 排他 + 索引对应
函数 tab() 多处复用

【面试考点】

Q1:如何用 React 实现同样效果?

A:用 useState(0) 存 activeIndex,渲染时 className={i===activeIndex?'active':''};原理仍是排他,只是状态上提到组件级。React 的 key 与 Vue 的 v-for index 类似,确保 DOM 复用正确。

Q2:选项卡用 display 切换与 visibility 的区别?

A:display: none 将元素从渲染树完全移除,不占位;visibility: hidden 保留位置但仍不可见。若 Tab 面板高度不同,用 display 切换会导致下方内容跳动------可用固定高度容器或 visibility 避免此问题。本项目面板高度固定,用 display 性能更好。

Q3:如何实现选项卡懒加载?

A:首次切换到某 Tab 时再加载数据,而不是初始化时全部请求。实现:给每个 panel 加 data-loaded 标记,点击非加载 Tab 时才发请求并设标记。适合内容重(如富文本、图表)的场景。

Q4:选项卡需要支持键盘操作(Tab 键、方向键),怎么做?

A:ARIA 规范要求 role="tablist" 内用方向键 切换 Tab,而非 Tab 键(Tab 键只在 tablist 整体间跳焦点)。具体:监听 keydown,ArrowRight → 激活下一个 Tab 并 focus();ArrowLeft → 激活上一个。同时给所有非当前 Tab 按钮设 tabindex="-1",当前 Tab 设 tabindex="0",保证 Tab 键只落在一个焦点上(roving tabindex 模式)。这是 WAI-ARIA Authoring Practices 的推荐实现。


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

名词解释

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

概念与底层原理

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

functions.js 提供:

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

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

IIFE、CommonJS 与 ES Module 的作用域隔离机制对比:

方案 隔离方式 共享方式 静态分析 Tree-shaking
IIFE 函数作用域(运行时) 挂到 window 或传参 不支持 不可
CommonJS 模块作用域(require 包裹) module.exports 不支持(动态 require) 有限
ES Module 模块作用域(语言原生) export / import 静态分析 完整支持

ES Module 的关键优势:

  • 静态 import :在文件顶层,不可动态改变,打包工具(Webpack/Rollup/Vite)可在编译期确定哪些 export 被使用;未被引用的 export 会被 Tree-shaking 删除,减小包体积。
  • 严格模式(Strict Mode) :type="module" 的脚本默认严格模式,this 在顶层为 undefined 而非 window,避免隐式全局。
  • defer 默认行为 :type="module" 自动 defer(HTML 解析完毕后执行,按顺序),无需手动添加 defer 属性。

IIFE 项目升级到 ES Module 的最小改造:

javascript 复制代码
// data.js → 改为 export
export var goodData = { imgsrc: [...], goodsDetail: {...} };

// functions.js → 改为 export
export function getStyle(ele, attr) { ... }
export function tab(navItems, panelItems) { ... }

// index.js → 改为 import
import { goodData } from './data.js';
import { getStyle, tab } from './functions.js';
// 各 IIFE 改为普通函数或顶层代码(已有模块作用域,无需 IIFE)

【代码注释】export 使变量/函数可被外部 import;未 export 的内部变量对其他模块不可见------比 IIFE 的作用域隔离更彻底 ,因为 IIFE 若把值挂到 window 仍是全局污染。Rollup/Vite 等打包工具可通过静态分析 import 关系做 Tree-shaking:未被任何入口文件间接 import 到的 export 会被从产物中完全剔除。

html 复制代码
<!-- HTML 中 -->
<script type="module" src="./js/index.js"></script>
<!-- data.js 和 functions.js 无需单独引入,由 index.js import -->

【代码注释】type="module" 让 import 自动解析依赖图,不再需要手动维护三个 <script> 的顺序。模块只执行一次(浏览器缓存模块实例),即使多个文件 import 同一模块也只加载一次,与 IIFE 的「每次 <script> 都重新执行」不同。

mermaid 图:页面脚本与 DOM 依赖

#mermaid-svg-8OK5oQcnolSL0rC7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8OK5oQcnolSL0rC7 .error-icon{fill:#552222;}#mermaid-svg-8OK5oQcnolSL0rC7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8OK5oQcnolSL0rC7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8OK5oQcnolSL0rC7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .marker.cross{stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8OK5oQcnolSL0rC7 p{margin:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label text{fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label span{color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster-label span p{background-color:transparent;}#mermaid-svg-8OK5oQcnolSL0rC7 .label text,#mermaid-svg-8OK5oQcnolSL0rC7 span{fill:#333;color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .node rect,#mermaid-svg-8OK5oQcnolSL0rC7 .node circle,#mermaid-svg-8OK5oQcnolSL0rC7 .node ellipse,#mermaid-svg-8OK5oQcnolSL0rC7 .node polygon,#mermaid-svg-8OK5oQcnolSL0rC7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .rough-node .label text,#mermaid-svg-8OK5oQcnolSL0rC7 .node .label text,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label,#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8OK5oQcnolSL0rC7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .rough-node .label,#mermaid-svg-8OK5oQcnolSL0rC7 .node .label,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label,#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label{text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .node.clickable{cursor:pointer;}#mermaid-svg-8OK5oQcnolSL0rC7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .arrowheadPath{fill:#333333;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8OK5oQcnolSL0rC7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster text{fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 .cluster span{color:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8OK5oQcnolSL0rC7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8OK5oQcnolSL0rC7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape p,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8OK5oQcnolSL0rC7 .icon-shape .label rect,#mermaid-svg-8OK5oQcnolSL0rC7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8OK5oQcnolSL0rC7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8OK5oQcnolSL0rC7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8OK5oQcnolSL0rC7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} index.html 结构
index.css 编译自 Less
data.js goodData
functions.js getStyle tab
index.js 各 IIFE
放大镜 IIFE
缩略图 IIFE
侧栏 Tab IIFE
详情 Tab IIFE

【代码注释】脚本必须放在 </body> 前或 defer,保证执行时 DOM 已解析;index.js 内多个 IIFE 顺序无关,因彼此无共享闭包变量。

最终页核心 DOM 区块(语义化结构):

html 复制代码
<div class="topbar">...</div>
<div class="page-header">...</div>
<div class="page-nav">...</div>
<div class="page-main">
  <nav class="path-nav">...</nav>
  <div class="product">
    <div class="product-preview">
      <div class="zoom-box" id="zoomBox">...</div>
      <div class="thumb-box" id="thumbBox">...</div>
    </div>
  </div>
  <div class="product-section">
    <aside class="product-siderbar" id="siderbarTab">...</aside>
    <div class="product-intro">
      <div class="product-intro-tab" id="introTab">...</div>
    </div>
  </div>
</div>

【代码注释】#zoomBox、#thumbBox、#siderbarTab、#introTab 的 id 与 JS 选择器强绑定,改名需同步改脚本;类名采用 BEM 风格片段(product-preview)便于维护。

goodData.goodsDetail 字段说明(为进阶功能预留):

字段 用途
title / price 商品标题与基准价
recommend / support / address 文案展示
crumbData[] 规格维度:颜色、内存等,每项含 changePrice
imgsrc[] 缩略图与小/大图 URL

【代码注释】本阶段页面逻辑仅消费 imgsrc;规格动态 DOM、加价、数量联动在后续章节用同一数据结构扩展,避免换页重造数据模型。

可运行示例(入门):getStyle 思想

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>getComputedStyle 演示</title>
  <style>#box { width: 100px; margin-right: 20px; background: #9cf; }</style>
</head>
<body>
  <div id="box"></div>
  <script>
    function getStyle(ele, attr) {
      if (window.getComputedStyle) {
        return getComputedStyle(ele)[attr];
      }
      return ele.currentStyle[attr];
    }
    var box = document.getElementById('box');
    alert('width: ' + getStyle(box, 'width') + ', marginRight: ' + getStyle(box, 'marginRight'));
  </script>
</body>
</html>

【代码注释】getComputedStyle 返回带单位的字符串,需 parseInt 参与运算;属性名驼峰 marginRight 与 DOM 一致。

可运行示例(实战):goodData 结构

javascript 复制代码
var goodData = {
    imgsrc: [
        { b: "./images/b1.png", s: "./images/s1.png" },
        { b: "./images/b2.png", s: "./images/s2.png" }
    ],
    goodsDetail: {
        title: "示例手机",
        price: 5299,
        crumbData: [
            {
                title: "选择颜色",
                data: [
                    { type: "金色", changePrice: 0 },
                    { type: "黑色", changePrice: 90 }
                ]
            }
        ]
    }
};

【代码注释】s 小图、b 大图分离,点击缩略图时分别赋给放大镜两个 img;changePrice 为规格加价,后续章节用于动态算价。

functions.js 完整实现:

javascript 复制代码
function getStyle(ele, attr) {
    if (window.getComputedStyle) {
        return getComputedStyle(ele)[attr];
    }
    return ele.currentStyle[attr];
}

function tab(tabNavItems, tabPanelItems) {
    tabNavItems.forEach(function (tabNavItem, index) {
        tabNavItem.onclick = function () {
            for (var i = 0; i < tabNavItems.length; i++) {
                tabNavItems[i].classList.remove('active');
                tabPanelItems[i].classList.remove('active');
            }
            tabNavItem.classList.add('active');
            tabPanelItems[index].classList.add('active');
        };
    });
}

【代码注释】getStyle 兼容 IE 的 currentStyle;tab 未做边界校验,调用方需保证两个 NodeList 等长。tabNavItems 为静态 NodeList,动态增删 Tab 项后需重新查询或改用事件委托。

页面底部脚本顺序(08 模块):

html 复制代码
<script src="./js/data.js"></script>
<script src="./js/functions.js"></script>
<script src="./js/index.js"></script>

【代码注释】顺序不可颠倒:index.js 依赖全局 goodData、getStyle、tab。若用 type="module",需改为 import 并去掉全局变量写法。

imgsrc 为何有 14 条?

课堂数据用 3 张图循环填充,便于演示「横向滚动条很长」;每条 { s, b } 分别对应缩略图与放大镜大图。条数变化时记得同步调整滚动下限或改用动态 scrollWidth 计算。

javascript 复制代码
// data.js 片段
imgsrc: [
  { b: "./images/b1.png", s: "./images/s1.png" },
  { b: "./images/b2.png", s: "./images/s2.png" },
  { b: "./images/b3.png", s: "./images/s3.png" }
  // ... 重复多组用于拉长列表
]

【代码注释】s 小图、b 大图路径相对 HTML;部署时通常走 CDN 绝对 URL。goodsDetail 含标题、价格、crumbData 规格维度,供后续动态规格区使用。

【实战要点】

  • 经典应用场景 :详情页 JSON 接口返回后赋值给 goodData,刷新缩略图与标题。
  • 常见坑 :getComputedStyle 对 display:none 的元素部分属性为 auto 或 0;测量宽高前元素须在布局树中可见。
  • 性能与最佳实践 :公共函数放单独文件利于缓存;生产可用模块化 import 替代全局。

【本章小结】

文件 职责
data.js 静态/mock 数据
functions.js getStyle、tab
index.js 页面行为 IIFE

【面试考点】

Q1:IIFE 和 ES Module 的「私有变量」有何不同?

A:IIFE 靠函数作用域隐藏变量;ES Module 靠模块作用域,未 export 的绑定外部不可见,且支持静态分析和 tree-shaking。IIFE 是运行期隔离,Module 是编译期隔离------后者打包工具可消除未使用的导出。

Q2:getComputedStyle 的性能如何?读取哪些属性会触发强制回流?

A:浏览器为优化性能,会把多个 DOM 修改批量合并 (lazy flush),延迟到真正需要时才重绘。但一旦 JS 读取「布局依赖型」属性,浏览器必须立即刷新挂起的写操作以返回准确值------这叫强制同步回流(Forced Synchronous Layout / Layout Thrashing):

javascript 复制代码
// 触发强制回流的属性(读取时必须先刷新队列):
// offsetWidth/offsetHeight/offsetLeft/offsetTop/offsetParent
// clientWidth/clientHeight/clientLeft/clientTop
// scrollWidth/scrollHeight/scrollLeft/scrollTop
// getComputedStyle() 所有几何相关属性
// getBoundingClientRect()

// 反模式(读写交错,每次都强制回流):
for (var i = 0; i < items.length; i++) {
    var w = items[i].offsetWidth;     // 读 → 回流
    items[i].style.width = w + 10 + 'px';  // 写 → 使队列脏
}
// 上面循环 N 次 = N 次回流!

// 最佳实践(批量读,再批量写):
var widths = items.map(function(el) { return el.offsetWidth; }); // 批量读(1次回流)
items.forEach(function(el, i) { el.style.width = widths[i] + 10 + 'px'; }); // 批量写

【代码注释】缩略图中 parseInt(getStyle(el, 'marginRight')) 只在初始化时调用一次,缓存为 imgItemWidth,后续 onclick 不再重新读取,正是这种「一次读取、多次复用」的正确实践。getComputedStyle 在元素 display:none 时,部分属性返回 0 或 auto,测量宽高前需确保元素在布局树中可见。

Q3:数据驱动(goodData)与 DOM 操作如何平衡?

A:数据驱动利于维护(修改数据自动反映视图),但原生 JS 需手动同步 DOM。框架(Vue/React)提供响应式机制,自动将数据变化映射到 DOM。本项目规模较小,直接操作 DOM + 数据分离足够;若状态复杂(如购物车数量、规格联动),可引入轻量状态管理或框架。

Q4:<script> 放在 </body> 前与加 defer 属性有何区别?

A:两种方案的结果相近但机制不同:

放在 </body> 前 defer 属性 type="module"
下载时机 HTML 解析到该标签时开始下载 HTML 解析同时并行下载(不阻塞解析) 同上(defer 语义)
执行时机 HTML 解析完毕后立即执行 HTML 解析完毕后按顺序执行 同左,按声明顺序
保证顺序 是(DOM 顺序) 是 是
需要 DOM 就绪 是 是 是

defer 的本质优势是并行下载 不阻塞 HTML 解析,从而加快首屏渲染(FCP/LCP);而脚本放 </body> 前,下载仍然是串行的(遇到 <script> 才开始下载)。本项目三个 <script> 放 </body> 前靠顺序保证依赖,改用 ES Module + import 后可无需关心标签顺序(由模块系统自动解析依赖图)。


总结

知识点回顾(思维导图)

#mermaid-svg-Uye2laaQcfcnqGNK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Uye2laaQcfcnqGNK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Uye2laaQcfcnqGNK .error-icon{fill:#552222;}#mermaid-svg-Uye2laaQcfcnqGNK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Uye2laaQcfcnqGNK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Uye2laaQcfcnqGNK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Uye2laaQcfcnqGNK .marker.cross{stroke:#333333;}#mermaid-svg-Uye2laaQcfcnqGNK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Uye2laaQcfcnqGNK p{margin:0;}#mermaid-svg-Uye2laaQcfcnqGNK .edge{stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 path,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth--1{stroke-width:17;}#mermaid-svg-Uye2laaQcfcnqGNK .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-0{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-0{stroke-width:14;}#mermaid-svg-Uye2laaQcfcnqGNK .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-1{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-1{stroke-width:11;}#mermaid-svg-Uye2laaQcfcnqGNK .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-2{stroke-width:8;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-3{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-3{stroke-width:5;}#mermaid-svg-Uye2laaQcfcnqGNK .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-4{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-4{stroke-width:2;}#mermaid-svg-Uye2laaQcfcnqGNK .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-5{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-5{stroke-width:-1;}#mermaid-svg-Uye2laaQcfcnqGNK .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-6{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-6{stroke-width:-4;}#mermaid-svg-Uye2laaQcfcnqGNK .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-7{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-7{stroke-width:-7;}#mermaid-svg-Uye2laaQcfcnqGNK .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-8{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-8{stroke-width:-10;}#mermaid-svg-Uye2laaQcfcnqGNK .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-9{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-9{stroke-width:-13;}#mermaid-svg-Uye2laaQcfcnqGNK .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 path,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 polygon,#mermaid-svg-Uye2laaQcfcnqGNK .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 text{fill:black;}#mermaid-svg-Uye2laaQcfcnqGNK .node-icon-10{font-size:40px;color:black;}#mermaid-svg-Uye2laaQcfcnqGNK .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .edge-depth-10{stroke-width:-16;}#mermaid-svg-Uye2laaQcfcnqGNK .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled,#mermaid-svg-Uye2laaQcfcnqGNK .disabled circle,#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:lightgray;}#mermaid-svg-Uye2laaQcfcnqGNK .disabled text{fill:#efefef;}#mermaid-svg-Uye2laaQcfcnqGNK .section-root rect,#mermaid-svg-Uye2laaQcfcnqGNK .section-root path,#mermaid-svg-Uye2laaQcfcnqGNK .section-root circle,#mermaid-svg-Uye2laaQcfcnqGNK .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-Uye2laaQcfcnqGNK .section-root text{fill:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-root span{color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .section-2 span{color:#ffffff;}#mermaid-svg-Uye2laaQcfcnqGNK .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-Uye2laaQcfcnqGNK .edge{fill:none;}#mermaid-svg-Uye2laaQcfcnqGNK .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-Uye2laaQcfcnqGNK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PC 商品详情页
工程化样式
Less 变量混合 → 2026 原生 CSS 替代路径
Reset 与 container 版心
CSS 层叠规范 W3C
布局骨架
Flex 顶栏/header/nav
space-between 两端对齐
min-width 防挤压
面包屑伪元素 after NBSP
放大镜交互
getBoundingClientRect 视口坐标
scrollLeft/Top 比例联动
两种路线 scrollLeft vs background-position
边界钳制 Math.max/min
rAF 性能优化
缩略图轮播
new Image 动态渲染
event.timeStamp 节流
leading vs trailing
vs performance.now
事件委托换图
transform 生产优化
选项卡
排他思想
tab 函数复用
ARIA role tablist tab tabpanel
纯 CSS :target 方案
键盘导航 roving tabindex
工程实践
IIFE 作用域隔离
ES Module 静态分析 tree-shaking
goodData 数据分离
getComputedStyle 强制回流避坑
defer vs body 尾部加载

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

高频面试题速查

  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.timeOrigin;Date.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.js 和 functions.js 改为 export,index.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 于本目录直接打开;完整电商页面请使用同目录下渐进式示例中的最终版页面。

相关推荐
程序员大阳25 分钟前
HTML+CSS+JS对话式实战(6)-学生端查看活动功能开发
javascript·css·html·模块·查看学生
jay神1 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神1 小时前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat1 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
噢,我明白了2 小时前
JAVA中的一些基础应用知识点
java·开发语言
miss2 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan2 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真2 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment2 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan3 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端