
简介一份面向前端初学者与电商项目开发者的仿京东商品详情页静态页面实战包聚焦商品主图轮播、价格与规格联动、用户评价列表、购物车入口等典型模块帮助读者理解商城详情页从结构搭建到交互实现的关键环节。压缩包共44个文件、约398KB以31个PNG及少量GIF、JPG商品图和图标素材为主另含1个HTML页面、1个CSS样式表与2个JavaScript文件jQuery库及特性检测脚本素材与代码分离便于直接替换商品图或改造布局。目前已有4705人学习下载。解压后可在HBuilder等IDE中打开运行适合作为课程练习、毕业设计原型或个人商城项目的基础模板页面涉及响应式布局、图片懒加载、规格切换与价格计算等知识点读者可据此拆解DOM结构、复用样式组织方式并在此基础上接入Vue或React等框架继续扩展。1. 商品详情页前端最容易被跳过的一步把“能看”做成“能用”商品详情页前端是很多人练手的第一个完整页面也是最容易做成“截图级还原”的页面静态图看着没问题鼠标一上去放大镜糊成马赛克规格点完价格不更新手机端点加购没反应。这套方案要解决的不是“画得像”而是把详情页拆成可交互、可验收的前端工程主图切换与放大镜、SKU 规格联动、价格与数量计算、加购按钮状态、移动端适配。适合两类人课程设计或毕设选题需要一份完整前端页面的学生以及想补 DOM 事件与状态计算基本功的入门开发者。下面按结构、交互、样式、排查、验收的顺序推每一章都落到能直接改的参数和能复现的代码上。2. 页面结构拆解六个区块与原生三件套的选型理由先把页面拆成区块再决定用什么写。详情页的信息密度高但层级是固定的拆错一次后面所有交互都会挤在一个文件里。我一般先画一张区块图把首屏拆成六块顶部通栏负责 logo、搜索框、购物车入口面包屑回答“我在哪”商品主区是核心左边画廊右边购买面板服务条放发货、运费、售后说明详情与评价用 Tab 收起长内容推荐楼层放在页面底部用横向滚动或四列网格。六块里只有商品主区需要复杂交互其余是静态结构和轻交互先把边界划清楚后面写 JS 就不会全堆在index.html的script标签里。2.1 首屏骨架主图区与购买面板的职责边界主图区只负责“看图”主图容器、遮罩层、放大镜大图容器、缩略图列表。购买面板只负责“选和买”标题、价格、规格组、数量、加购按钮。两个区域通过一个共享的selected对象通信主图区不关心价格购买面板不关心图片坐标这样放大镜写崩了也不会影响加购逻辑。!-- 商品主区左侧画廊 右侧购买面板职责分离 -- main classproduct section classgallery div classgallery-viewer img idmainImage srcassets/images/p1.jpg alt商品主图 div classgallery-mask hidden/div /div div classgallery-zoom hidden img idzoomImage srcassets/images/p1.jpg alt /div ul classgallery-thumbs liimg srcassets/images/p1.jpg alt主图 1>// main.js模块只暴露 init入口统一调度避免脚本之间互相依赖 const modules [Gallery, Sku, Cart]; document.addEventListener(DOMContentLoaded, () { modules.forEach((mod) { // 某个模块初始化失败时其余模块仍然可以工作 try { mod.init?.(); } catch (err) { console.error([init fail] ${mod.name}, err); } }); });modules数组就是页面的功能清单新增一个交互就加一个模块顺序按依赖排画廊先初始化SKU 再初始化购物车最后读 SKU 的状态。try/catch不是摆设放大镜模块因为拿不到 DOM 报错时加购按钮仍然能点排查时看控制台就知道是哪个模块挂了。模块之间不直接互相调用共享状态放在一个store.js里规模小的时候直接用一个普通对象也行但不要在每个模块里各自维护一份selected。2.3 目录组织与开发顺序先静态后交互每步都能跑目录按“样式、逻辑、数据、资源”四类分不要按页面区块分文件夹否则找一张图要翻三层。开发顺序也有讲究先写 HTML 结构再写tokens.css定颜色和间距接着写page.css把布局撑起来然后按画廊、SKU、购物车的顺序写 JS最后做响应式和懒加载。每一步结束都刷新一次页面确保当前步骤可运行不要一口气写完再调那样出错时不知道是哪一层的问题。goods-detail/ ├── index.html ├── css/ │ ├── reset.css # 统一默认样式清掉 margin 和列表符号 │ ├── tokens.css # 颜色、字号、间距变量改主题只动这里 │ └── page.css # 布局与组件样式 ├── js/ │ ├── mock.js # SKU、图片、评价等静态数据 │ ├── store.js # 共享的 selected 与数量状态 │ ├── gallery.js # 主图切换与放大镜 │ ├── sku.js # 规格选择与价格计算 │ ├── cart.js # 数量加减与加购状态 │ └── main.js # 统一初始化入口 └── assets/ └── images/mock.js单独放数据答辩时想换商品只改这个文件里的标题、价格和 SKU 列表页面结构不用动。store.js只放状态和读写方法不放 DOM 操作这样在控制台里能直接打印当前选中了什么规格排查状态问题时省很多事。图片目录按p1.jpg、p2.jpg命名缩略图和大图共用一套源文件避免两套图对不上。3. 核心交互实现放大镜、SKU 联动与加购状态机详情页的交互看起来多真正需要写逻辑的只有三处鼠标在主图上移动时的坐标映射、规格选择后的可点判断、价格和按钮的状态切换。这三处写稳了页面就从“静态排版”变成“可操作的前端页面”。下面每段代码都按“结构、计算、边界”三层拆参数放在代码上方说明改的时候知道改哪个数。3.1 主图放大镜坐标映射与边界钳制放大镜的本质是坐标映射不是 CSS 特效。结构三件套小图容器、遮罩层、右侧大图容器。小图容器设position: relative遮罩绝对定位大图容器设overflow: hidden内部图片的宽度设为小图宽度乘以放大倍数。鼠标移动时先算鼠标相对小图容器的坐标再让遮罩中心跟随鼠标最后把遮罩位移乘以放大倍数、取反作用到大图上。大图容器一般放在主图右侧宽度等于小图宽度高度等于小图高度这样视觉上不会把购买面板挤走。// gallery.js放大镜核心逻辑 const viewer document.querySelector(.gallery-viewer); const mask viewer.querySelector(.gallery-mask); const zoomImg document.querySelector(.gallery-zoom img); // 放大倍数大图容器宽度 / 小图容器宽度一般取 2~3 const ZOOM 2.5; viewer.addEventListener(mousemove, (e) { const rect viewer.getBoundingClientRect(); // 鼠标相对小图容器的坐标clientX 不受页面滚动影响 const x e.clientX - rect.left; const y e.clientY - rect.top; // 遮罩一半宽高用于把鼠标放在遮罩中心 const halfW mask.offsetWidth / 2; const halfH mask.offsetHeight / 2; // 边界钳制遮罩不能超出小图容器 const left Math.min(Math.max(x - halfW, 0), rect.width - mask.offsetWidth); const top Math.min(Math.max(y - halfH, 0), rect.height - mask.offsetHeight); mask.style.left ${left}px; mask.style.top ${top}px; // 大图位移 遮罩位移 × 放大倍数方向相反 zoomImg.style.transform translate(${-left * ZOOM}px, ${-top * ZOOM}px); }); viewer.addEventListener(mouseenter, () { mask.hidden false; zoomImg.parentElement.hidden false; }); viewer.addEventListener(mouseleave, () { mask.hidden true; zoomImg.parentElement.hidden true; });ZOOM取 2.5 时遮罩宽高应该设成小图宽高的四成左右这样大图刚好填满容器不会露出空白。rect.width用的是getBoundingClientRect()页面滚动后坐标仍然正确换成offsetLeft就要额外处理滚动偏移。viewer不能加 padding否则鼠标坐标的基准会和图片边缘错开遮罩看起来永远偏一截。切换缩略图时除了改mainImage.src还要把zoomImage.src改成同一张图并把zoomImg.style.transform重置为translate(0, 0)否则上一张图的位移会残留。3.2 SKU 规格选择先判断“还能不能点”再谈选中态规格选择的难点不在样式在“哪些值还能点”。不能因为某个颜色没货就把颜色按钮全禁掉因为它可能和另一个尺码组合有货。正确做法是每选一个值就把它代入当前选择看看是否存在stock 0的完整组合存在就允许点。数据结构用扁平数组每个 SKU 是一条可售组合字段名和 HTML 上的>// mock.js每个 SKU 是一条可售组合stock 为 0 表示不可售 const skuList [ { id: S1, color: 深空黑, size: 128G, price: 4299, stock: 12 }, { id: S2, color: 深空黑, size: 256G, price: 4799, stock: 0 }, { id: S3, color: 月光白, size: 128G, price: 4299, stock: 5 }, { id: S4, color: 月光白, size: 256G, price: 4799, stock: 8 }, ]; // store.js只存已选中的规格初始为空 const selected {}; // 找出完全匹配当前选择的 SKU未选全时返回 undefined function findSku() { return skuList.find((sku) Object.keys(selected).every((key) selected[key] sku[key]) ); } // 判断某个规格值是否可点把它代入当前选择看有没有有货组合 function isOptionEnabled(group, value) { const draft { ...selected, [group]: value }; return skuList.some((sku) sku.stock 0 Object.keys(draft).every((key) draft[key] sku[key]) ); }findSku()里的Object.keys(selected).every只比较已经选中的字段所以选了一个规格时也能找到对应的价格区间不会直接返回undefined。isOptionEnabled()是渲染按钮时调用的对每个值单独判断禁用态按钮加disabled和灰色样式但仍然渲染出来让用户知道有这个选项、只是当前组合不可售。点击按钮时先写selected[group] value再重渲染所有规格组和价格不要只改当前按钮的样式否则其他组的禁用态不会跟着变。3.3 价格、数量与加购按钮用一个状态函数收口价格和按钮最容易出现“各改各的”问题选规格时改一次按钮文字数量变化时又改一次最后出现“规格已选但按钮还写着请选择规格”。解决办法是只留一个updateBuyBar()任何状态变化都调它里面按“未选全、选全但无货、选全有货”三种情况分别设置按钮文案和禁用态。价格同理选全显示具体价没选全显示区间价。// cart.js所有状态变化都走这一个函数避免按钮文案不同步 const buyBtn document.getElementById(buyBtn); const priceText document.getElementById(priceText); const qtyInput document.getElementById(qtyInput); const groups [color, size]; // 规格组数量和 HTML 上的>/* tokens.css颜色和间距只在这里定义 */ :root { --color-primary: #d94a3d; /* 主色用于加购按钮和选中态 */ --color-price: #c8342a; /* 价格色比主色深一档 */ --color-text: #1f2329; /* 正文 */ --color-text-weak: #6b7280; /* 辅助说明 */ --color-border: #e5e7eb; /* 分割线和边框 */ --color-bg: #f5f6f7; /* 页面底色 */ --font-xs: 12px; --font-sm: 14px; --font-md: 16px; --font-lg: 24px; --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --radius-sm: 4px; --radius-md: 8px; }价格色比主色深一档是为了在白色背景上更醒目但不要用纯红纯红在灰色边框旁边会显得刺眼。间距只用--space-*这几档不要在样式里随手写margin: 13px后面做响应式时很难统一调整。字号三到四档足够详情页正文用--font-sm标题用--font-lg辅助说明用--font-xs层级拉开但不混乱。4.2 栅格与断点桌面两栏移动端单列桌面端用grid把商品主区分成两栏左栏固定 400px 放画廊右栏1fr放购买面板中间留 24px。容器最大宽度 1200px居中。屏幕宽度小于 960px 时改成单列画廊在上、购买面板在下小于 600px 时缩略图改成横向滚动加购按钮吸底避免用户选完规格还要往下找按钮。图片用aspect-ratio固定比例加载过程中不会把下面的内容顶来顶去。/* page.css主区栅格与断点 */ .product { display: grid; grid-template-columns: 400px 1fr; gap: var(--space-6); max-width: 1200px; margin: 0 auto; padding: var(--space-4); } .gallery-viewer { position: relative; aspect-ratio: 1 / 1; /* 固定比例图片加载时不抖动 */ overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); } .gallery-mask { position: absolute; width: 40%; height: 40%; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.6); pointer-events: none; /* 遮罩不拦截鼠标事件 */ } media (max-width: 960px) { .product { grid-template-columns: 1fr; /* 单列画廊在上 */ } } media (max-width: 600px) { .gallery-thumbs { display: flex; overflow-x: auto; /* 缩略图横向滚动 */ gap: var(--space-2); } }.gallery-mask上的pointer-events: none很关键遮罩跟着鼠标移动时如果拦截事件mousemove会在遮罩和容器之间来回触发表现就是放大镜一抖一抖。断点只设两个960px 和 600px再多会增加维护成本课程设计里这两个足够覆盖笔记本、平板和手机。移动端加购按钮吸底用position: sticky; bottom: 0不要用fixed否则会盖住页面底部的推荐楼层。4.3 骨架屏与图片懒加载首屏不卡长图不抢带宽详情页的图片数量多首屏主图、缩略图、详情长图加起来十几张。首屏主图不要懒加载它是第一视觉详情长图用>/* 骨架屏加载中的占位块不要用真实图片做模糊 */ .skeleton { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }// 详情长图懒加载提前 200px 开始加载避免用户滚到才闪出来 const io new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; img.src img.dataset.src; // 真实地址写在 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />