☰
原生JS实现电商商品详情页:轮播、SKU联动与价格计算
2026/10/29 5:59:49 网站建设 项目流程

简介:这是一份面向前端初学者与商城项目开发者的「仿京东商品展示详情页面」实战源码,围绕商品详情页的结构搭建、样式布局与交互实现展开,可用于课程练习、毕业设计参考或自有商城页面的二次改造。压缩包共44个文件,约398KB,以png、gif、jpg图片素材和html、css、js核心页面文件为主,另含jQuery与Modernizr等脚本及说明文件,完整呈现主图轮播、规格选择、价格与库存展示、评价列表等模块所需素材。已有4705人学习下载,说明其作为入门级前端案例具有较高参考价值。读者可获得可直接在HBuilder等IDE中运行的页面原型,并据此理解响应式布局、图片懒加载、购物车异步请求与交互细节优化的实现思路,便于按需拆分组件、替换素材并迁移到Vue或React等框架项目中。

1. 从一个商品详情页说起:这份代码复刻了哪些真实交互

打开任意一个电商 App 的商品页,用户从上往下滑的动作其实只有几个:看主图、选颜色尺码、确认价格、点加购。可这四步背后压着轮播位移计算、规格互斥判断、促销叠加规则、弹层滚动锁定这一堆活。这个仿主流电商的商品展示详情页面项目,就是把这套东西从零手写一遍,不依赖任何 UI 框架,用 HTML、CSS 和原生 JavaScript 把页面骨架、SKU 联动、价格计算、弹层交互全部落地。它适合两类人:一类是刚学完 CSS 布局想找个真实页面练手的前端新手,另一类是接手了老项目、需要在不引入构建工具的前提下补齐交互细节的开发者。整套代码可以直接双击打开,也能扔进任意静态服务器,改数据、换皮肤、加模块都不需要重新配置环境。

2. 技术栈与目录结构:纯静态三件套怎么撑起一个详情页

2.1 技术选型:为什么这个页面不值得上框架

这类页面的交互复杂度集中在一处:规格与价格的联动。它没有路由、没有跨页状态、没有复杂的列表复用,用 Vue 或 React 反而要额外配一套构建链路,改个按钮颜色都得等热更新。所以项目走的是最朴素的路线——HTML5 语义标签打骨架,CSS3 的 Flex 加 Grid 做布局,ES6 的模块化脚本处理交互。轮播没有引第三方库,手写 touch 事件;数据请求用 fetch 读本地 JSON,模拟真实接口的异步时序;模板渲染用模板字符串拼 HTML,避免引入模板引擎。

这套选型的代价要说清楚:没有响应式框架,视图更新得自己手动操作 DOM,所以代码里所有渲染函数都遵循一个约定——先算状态,再一次性替换容器 innerHTML,不做零散的 appendChild。这样既避免了状态和视图对不上,也让每次改动都能在一个函数里看完。我一般会把这种约定写在 main.js 顶部的注释里,防止后来接手的人随手加一行 document.querySelector 就改 DOM,那是最容易出玄学 bug 的地方。

CSS 这边用了自定义属性做主题变量,主色、圆角、字号全部收在 variable.css 里。后面想把这套页面改成别的站点风格,只改这一个文件就够,不用全局搜颜色值。

2.2 目录结构:每个文件负责哪一段像素

目录按职责切成四层,找东西不用翻大文件:

product-detail/ ├── index.html # 唯一页面入口 ├── css/ │ ├── reset.css # 基础样式归零 │ ├── variable.css # 主题变量:色值、圆角、间距 │ └── detail.css # 页面级布局与组件样式 ├── js/ │ ├── main.js # 入口:拉数据、编排各模块 │ ├── swiper.js # 主图轮播 │ ├── sku.js # 规格选择与互斥 │ ├── price.js # 价格与优惠计算 │ └── lazy.js # 图片懒加载 ├── data/ │ └── product.json # 模拟接口返回的商品数据 └── assets/ └── images/ # 主图、规格图、图标

这里有个细节值得说:swiper.js、sku.js、price.js 都是纯函数加一个 init 出口,不互相 import。main.js 负责把它们串起来,比如规格变化后调用 price 的重新计算,再调用视图刷新。这种星形依赖的好处是,任何一个模块单独抽出来放到别的页面也能用,测试的时候也不用把整页跑起来。

提示:本地直接双击 index.html 打开时,部分浏览器会因为 file:// 协议拦截 fetch 读 JSON。要么起一个静态服务,要么把数据内联成 JS 变量,二选一。我习惯起服务,因为后面换真实接口时路径不用改。

2.3 首屏骨架:从语义化标签到三段式布局

详情页的 HTML 结构决定了后面 CSS 好不好写。项目里把首屏拆成三段:顶部导航(固定)、主图区(可滑动)、信息区(价格、标题、服务标签)。底部加购栏用 fixed 定位独立于文档流,不参与滚动。

<!-- index.html 首屏骨架节选 --> <body> <header class="nav-bar"> <button class="nav-back" aria-label="返回"></button> <span class="nav-title">商品详情</span> </header> <main class="page-main"> <!-- 主图区:轮播容器,图片由 JS 渲染 --> <section class="gallery" id="gallery"> <div class="gallery-track" id="galleryTrack"></div> <div class="gallery-dots" id="galleryDots"></div> </section> <!-- 信息区:价格、标题、服务承诺 --> <section class="info"> <div class="price-box" id="priceBox"></div> <h1 class="product-title" id="productTitle"></h1> <ul class="service-tags" id="serviceTags"></ul> </section> <!-- 规格入口:点击后拉起底部弹层 --> <section class="sku-entry" id="skuEntry"> <span class="sku-entry-label">已选</span> <span class="sku-entry-value" id="skuEntryValue">请选择 颜色 尺码</span> </section> </main> <!-- 底部加购栏,固定在视口底部 --> <footer class="action-bar"> <button class="btn-cart" id="btnCart">加入购物车</button> <button class="btn-buy" id="btnBuy">立即购买</button> </footer> </body>

这段骨架有两个考虑。一是所有动态内容都留了空容器加 id,页面加载时不会出现「先闪一下假数据再被替换」的尴尬;二是主图区用 track 加 dots 两层,track 负责横向位移,dots 单独一层浮在上面,这样切换页码时只动 track 的 transform,dots 只改 class,两者互不干扰,性能上也省一次重排。

信息区没有用 flex 硬排价格和标题,而是让它们各自成块垂直排列。原因是价格区在促销时可能变成两行(原价加划线价加券后价),用固定高度会溢出,让文档流自己撑开更稳。

3. 核心模块拆解:轮播、规格联动、价格计算三条主线

3.1 主图轮播:translateX 位移与 touch 事件节流

轮播是详情页第一个要处理的交互,也是最容易写崩的。项目里没有用 scroll-snap,而是老老实实算位移:所有图片横向排成一行,容器 overflow hidden,通过改变 track 的 translateX 来切换。手势部分监听 touchstart、touchmove、touchend 三个事件,滑动距离超过阈值才翻页。

// js/swiper.js export function createSwiper({ track, dots, total, onChange }) { let index = 0; let startX = 0; let deltaX = 0; let dragging = false; const width = track.parentElement.clientWidth; // 用 transform 位移,避免改 left 触发重排 function render(animate = true) { track.style.transition = animate ? 'transform .3s ease' : 'none'; track.style.transform = `translateX(${-index * width}px)`; dots.querySelectorAll('.dot').forEach((dot, i) => { dot.classList.toggle('active', i === index); }); } track.addEventListener('touchstart', (e) => { dragging = true; startX = e.touches[0].clientX; deltaX = 0; track.style.transition = 'none'; }, { passive: true }); track.addEventListener('touchmove', (e) => { if (!dragging) return; deltaX = e.touches[0].clientX - startX; // 拖动时实时跟手,不叠加动画 track.style.transform = `translateX(${-index * width + deltaX}px)`; }, { passive: true }); track.addEventListener('touchend', () => { dragging = false; // 位移超过屏宽 20% 判定为翻页 const threshold = width * 0.2; if (deltaX < -threshold && index < total - 1) index += 1; else if (deltaX > threshold && index > 0) index -= 1; render(); onChange && onChange(index); }); render(false); return { goTo(i) { index = i; render(); } }; }

这段代码的关键参数有三个。threshold 取屏宽的 20%,是我反复调出来的值——低于 15% 用户会觉得「稍微一碰就翻页」,高于 30% 又显得迟钝。passive: true 让浏览器的滚动优化不被阻塞,代价是 touchmove 里不能调用 preventDefault,所以横向滑动时要靠 CSS 的 touch-action 限制方向。transition 在拖动开始前必须置为 none,否则手指还在拖,元素却在追动画,视觉上就是「粘手」。

render 函数把位移和圆点状态放在一起更新,是为了保证任何一次翻页,图和点永远同步。如果拆成两处调,中间一旦有异常抛出,就会出现图翻了点没翻的状态不一致。

onChange 回调交给 main.js,用来做图片懒加载的触发判断——只有翻到某一页才去加载那一页的高清图,首屏只加载第一张。

3.2 SKU 规格联动:一个状态对象管住所有按钮

规格选择是详情页真正的复杂度来源。颜色和尺码不是完全自由的组合,某些颜色只有大码,某些尺码只有深色。项目里没有写一堆 if-else,而是把可售组合抽成一张矩阵,用状态对象加查表的方式判断按钮该不该置灰。

// js/sku.js // 可售组合矩阵:key 为 "颜色|尺码",value 为库存 const STOCK_MAP = { '黑色|S': 3, '黑色|M': 0, '黑色|L': 5, '白色|S': 0, '白色|M': 8, '白色|L': 2, '灰色|S': 4, '灰色|M': 6, '灰色|L': 0 }; // 当前选中状态,未选为 null const selected = { color: null, size: null }; function isOptionAvailable(key, value) { // 另一维度未选时,只要该值存在任意可售组合就亮起 const other = key === 'color' ? selected.size : selected.color; if (!other) { return Object.keys(STOCK_MAP).some((k) => { const [c, s] = k.split('|'); return (key === 'color' ? c : s) === value && STOCK_MAP[k] > 0; }); } // 另一维度已选,必须两者组合有库存才亮 const comboKey = key === 'color' ? `${value}|${other}` : `${other}|${value}`; return (STOCK_MAP[comboKey] || 0) > 0; } export function pick(key, value) { if (selected[key] === value) { selected[key] = null; // 再点一次取消选择 } else { if (!isOptionAvailable(key, value)) return false; selected[key] = value; } // 取消选择后,另一维度如果变成不可售,也要一并清空 const otherKey = key === 'color' ? 'size' : 'color'; if (selected[otherKey] && !isOptionAvailable(otherKey, selected[otherKey])) { selected[otherKey] = null; } return true; } export function getSelected() { return { ...selected }; }

isOptionAvailable 是这个模块的核心。它的逻辑分两种情况:另一维度没选时,做「存在性判断」,只要这个颜色在矩阵里出现过且有库存就点亮;另一维度已选时,做「组合判断」,必须两个值拼出的 key 在库存表里大于零。这两条规则合起来,才能实现用户先点颜色时不会被误导——他不会看到一堆灰按钮,而是看到所有可能亮的颜色。

pick 里的取消选择分支容易被忽略。用户点了「黑色」再点一次,状态要回到 null,同时要检查之前选的尺码是否还有效,无效就一并清掉。少了这一步,会出现「颜色为空但尺码还亮着」的脏状态,加购时后端校验直接失败。

库存值我放在矩阵里而不是单独一个数组,因为详情页真正需要的是「这个组合能不能买」,不是「总共剩几件」。如果要做「仅剩 3 件」的提示,从同一个 key 里取就行,不用再维护第二份数据,血泪经验是两份数据永远会不同步。

3.3 价格计算:把满减、券、运费收进一个纯函数

价格区看着简单,实际是详情页最容易改出事故的地方。原价、活动价、优惠券、满减、运费,任何一条规则变动都要重算。项目里把所有规则收进一个纯函数,输入商品数据和用户选中的券,输出一个结构化的价格明细,视图只负责把结果铺出来。

// js/price.js /** * @param {Object} product 商品数据 * @param {Array} coupons 用户已领取的券 * @param {string} couponId 当前选中的券 id * @param {number} quantity 购买数量 */ export function calcPrice(product, coupons, couponId, quantity = 1) { const subtotal = product.price * quantity; // 满减:满足阶梯就减,取最高一档 const fullReduce = (product.fullReduce || []) .filter((r) => subtotal >= r.threshold) .sort((a, b) => b.reduce - a.reduce)[0]; const reduceAmount = fullReduce ? fullReduce.reduce : 0; // 优惠券:只在满足门槛时生效 const coupon = coupons.find((c) => c.id === couponId); const couponAmount = coupon && subtotal >= coupon.threshold ? coupon.discount : 0; // 运费:满 99 包邮,否则 8 元 const freight = subtotal >= 99 ? 0 : 8; const payAmount = Math.max(subtotal - reduceAmount - couponAmount + freight, 0); return { subtotal, reduceAmount, couponAmount, freight, payAmount: Number(payAmount.toFixed(2)) }; }

函数本身不碰 DOM,也不读全局变量,所有输入从参数来,这让它可以直接在控制台里跑几组用例验证。参数 quantity 默认 1,加购弹层里改数量时传实际值进来,价格区跟着重算。

有一处必须注意:满减取的是「满足条件的最高一档」,用 sort 后取第 0 个。如果商品配了互斥的两档活动,这里就要改成筛选不同活动类型,不能简单排序。项目里只做单档叠加,是我刻意收窄的范围——真实电商的促销引擎远比这个复杂,但在练手项目里把它做成可读、可测的一百行,比抄一套看不懂的规则引擎更有价值。

最后 Math.max(..., 0) 是兜底,防止各种优惠叠加后算出负数,支付接口对负金额是直接拒的。toFixed(2) 之后再用 Number 转一次,是为了去掉多余的小数位,同时保证返回的是数值而不是字符串,视图里再格式化展示。

4. 数据层与渲染:本地 JSON 怎么假装成一个接口

4.1 数据契约:product.json 里的字段设计

数据文件是整个页面的源头,字段设计得好,后面所有模块都省事。项目里按「展示什么」而不是「后端给什么」来组织,把同一个区块要用的字段放在一起,减少渲染时的多次查找。

{ "id": "p1001", "title": "纯棉宽松圆领长袖上衣", "subTitle": "秋冬新款 舒适亲肤", "price": 129, "originPrice": 259, "fullReduce": [ { "threshold": 200, "reduce": 20 }, { "threshold": 300, "reduce": 50 } ], "images": [ { "thumb": "assets/images/1-s.jpg", "large": "assets/images/1-l.jpg" }, { "thumb": "assets/images/2-s.jpg", "large": "assets/images/2-l.jpg" } ], "sku": { "colors": [ { "value": "黑色", "preview": "assets/images/black.jpg" }, { "value": "白色", "preview": "assets/images/white.jpg" } ], "sizes": ["S", "M", "L"] }, "services": ["7天无理由", "运费险", "48小时发货"] }

images 拆成 thumb 和 large 两个字段,是给懒加载留的口子:首屏只加载 thumb,用户翻到那一页或者点开大图时才用 large 替换。sku 里的 colors 带 preview 图,选中颜色后主图区第一张会换成对应颜色,这个联动在 main.js 里做,数据层只负责提供素材。

services 用纯字符串数组,渲染时直接遍历成标签。如果以后要加图标,改成对象数组即可,渲染函数兼容两种形态的成本很低,不值得一开始就设计成复杂结构。

4.2 请求封装:fetch 读本地文件与统一错误处理

虽然数据在本地,但项目里还是包了一层请求函数,目的是让「换真实接口」这件事变成改一个常量。所有调用方都只认 getProduct(id) 这个签名,不关心数据从哪来。

// js/main.js 里的数据层 const API_BASE = './data'; export async function getProduct(id) { const url = `${API_BASE}/product.json?id=${id}`; const controller = new AbortController(); // 8 秒超时,避免弱网下页面一直转圈 const timer = setTimeout(() => controller.abort(), 8000); try { const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return { ok: true, data }; } catch (err) { // 区分超时和普通失败,便于视图给不同文案 const isTimeout = err.name === 'AbortError'; return { ok: false, message: isTimeout ? '加载超时,请重试' : '数据加载失败' }; } finally { clearTimeout(timer); } }

返回值用 { ok, data } 加 { ok, message } 两种形态,而不是抛异常让调用方 try-catch。原因是详情页的失败态很轻——只需要在页面顶部显示一条提示加重试按钮,不需要中断流程。统一成 result 对象后,渲染函数可以一路往下走,看到 ok 为 false 就切到错误视图,代码里不用到处包 try。

AbortController 加 8 秒超时是弱网下的后悔药。没有它,用户在地铁里打开页面会一直看到骨架屏,体验比直接报错还差。

4.3 渲染分层:模板字符串、加载态与空态

渲染函数按区块拆,每个函数只负责一块 DOM,输入是数据,输出是 innerHTML 字符串。这样做的副作用是每次更新都全量替换,但详情页区块数量有限,性能完全够,换来的是状态永远和视图一致。

function renderPrice(priceDetail, originPrice) { const { payAmount, reduceAmount, couponAmount } = priceDetail; // 有优惠才显示优惠行,避免占位空白 const discountLine = reduceAmount + couponAmount > 0 ? `<span class="price-discount">已优惠 ¥${(reduceAmount + couponAmount).toFixed(2)}</span>` : ''; return ` <div class="price-main"> <span class="price-symbol">¥</span> <span class="price-value">${payAmount}</span> <span class="price-origin">¥${originPrice}</span> </div> ${discountLine} `; }

加载态用骨架屏而不是转圈图标,因为详情页的布局是固定的,骨架屏能让用户提前知道内容在哪,视觉上也不会出现「加载完整个页面抖一下」。空态只在商品下架或规格全无库存时出现,复用同一个容器,换一段文案即可。

注意:模板字符串拼接用户可控的内容时一定要转义,项目里商品标题来自配置尚可不管,一旦接真实接口,标题里的引号和尖括号会直接破坏 DOM 结构。

5. 避坑与排查:真机和弱网下才暴露的四个问题

5.1 首屏图片抖动:没预留高度的代价

现象:页面刚打开时,主图区是一片空白,价格和标题紧贴在导航栏下面,等图片加载完,整块内容突然往下跳一大截。

原因:主图容器没有写死高度,图片没加载时高度为 0,加载后撑开,触发了文档流重排。桌面端网速快,肉眼几乎看不出来,但弱网或者首次访问时非常明显。

解决:给容器按宽高比预留空间。图片是 1:1 的,就直接用 aspect-ratio,老浏览器不支持就退化成 padding-top 占位。

.gallery { position: relative; width: 100%; aspect-ratio: 1 / 1; /* 现代浏览器直接按比例撑开 */ overflow: hidden; background: #f5f5f5; /* 加载中的底色,避免纯白刺眼 */ } .gallery-track img { width: 100%; height: 100%; object-fit: cover; display: block; }

object-fit: cover 保证不同比例的图都填满不拉伸,display: block 去掉行内元素底部的几像素间隙,那个间隙在轮播里会表现成「每张图下面一条白线」,排查起来很费时间。

5.2 规格按钮点不动:touch 与 click 打架

现象:在手机上点规格按钮,第一下没反应,要点第二下才选中;有时候点关闭弹层的遮罩,结果把下面的加购按钮也点了。

原因:移动端浏览器默认有 300 毫秒的点击延迟判断,用来区分单击和双击缩放。项目里如果在 touchstart 里做了逻辑、又在 click 上绑了一份,就会出现执行两次或者顺序错乱。遮罩关闭后立即移除 DOM,touch 事件还会穿透到下层元素,这就是点击穿透。

解决:统一只监听 click,同时在按钮上加 touch-action: manipulation 去掉延迟判断;遮罩关闭时先加一个 pointer-events: none,等下一帧再移除,或者用 setTimeout 延迟 300 毫秒移除。

.btn-sku { touch-action: manipulation; /* 禁用双击缩放带来的点击延迟 */ -webkit-tap-highlight-color: transparent; /* 去掉安卓点击高亮块 */ }

5.3 底部栏被安全区吃掉:env() 没写

现象:在带底部手势条的机型上,加入购物车按钮贴到了屏幕最边缘,被系统手势条压住一半,点起来很别扭。

原因:底部栏用了 fixed 定位加 bottom: 0,没有考虑安全区内边距。这个区域在普通模拟器里看不出来,只有真机才暴露。

解决:用 env(safe-area-inset-bottom) 叠加原本的内边距,同时给页面加上 viewport-fit=cover。

.action-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 10px 16px; /* 关键:安全区高度加上基础内边距 */ padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); background: #fff; box-shadow: 0 -2px 8px rgba(0, 0, 0, .06); }

env() 的第二个参数是兜底值,不支持该函数的浏览器会取 0px,不会把整条样式废掉。

5.4 弹层滚动穿透:body overflow 在 iOS 上不生效

现象:打开规格选择弹层后,手指在弹层空白处上下滑,背景页面跟着滚;弹层关掉后,页面滚动位置还跑偏了。

原因:给 body 设置 overflow: hidden 在部分移动端浏览器上不能真正阻止滚动,尤其是弹层本身内容不满一屏时。

解决:打开弹层时记录当前 scrollTop,把 body 改成 fixed 并设置 top 为负的滚动值;关闭时恢复定位并把滚动位置还原。

let lockTop = 0; function lockScroll() { lockTop = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${lockTop}px`; document.body.style.width = '100%'; } function unlockScroll() { document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, lockTop); }

这套写法看着笨,但在真机上是最稳的。用 overscroll-behavior: contain 只能解决一部分场景,弹层内容可滚动时仍然会传导到背景。

6. 二次开发:把它改成你自己商品的落地页

拿到这份代码之后,最常见的改造方向有三个:换数据源、换视觉、加模块。前两个改动量小,第三个要看加的位置。

改造目标需要动的文件大致工作量注意点
接入真实商品接口main.js 的 getProduct半小时字段名对齐,图片路径可能带域名
换主题色和圆角variable.css十分钟别在 detail.css 里写死颜色
加评价区index.html + 新增 review.js两小时评价分页要独立状态
加推荐位index.html + main.js 编排一小时推荐商品点击要新开页面

接真实接口时,最省事的做法是保留渲染函数不动,只在数据层做一层字段映射。后端返回的字段名和 product.json 对不上是常态,与其改遍所有渲染函数,不如在 getProduct 里加一个 adapter。

// 把后端字段适配成本地约定的结构 function adapt(raw) { return { id: raw.goods_id, title: raw.goods_name, price: Number(raw.sale_price) / 100, // 后端常用分存储 originPrice: Number(raw.market_price) / 100, images: (raw.img_list || []).map((u) => ({ thumb: u, large: u })), sku: { colors: raw.sku_colors || [], sizes: raw.sku_sizes || [] }, services: raw.service_tags || [] }; }

金额单位是我踩过最多次的坑。后端用分、前端用元,中间漏除 100,页面上就会出现「¥12900」这种价格,测试环境可能没人注意,上线后就是事故。从那以后我每次接价格字段,都强制在 adapter 里写一行注释标明单位,并且在控制台打印一次换算前后的值对一遍再往下走。

加模块的位置也有讲究。评价区和推荐位都应该放在信息区之后、底部栏之前,用独立的 section 包裹,不要去改已有的结构。新增的模块自己的数据请求、自己的渲染函数、自己的骨架屏,跟主流程解耦。这样将来某个模块要下线,删掉对应的 section 和 js 文件就行,不会牵一发动全身。

还有一个我自己的习惯:每次改完页面,都会把浏览器窗口从 320px 拉到 1440px 走一遍,重点看底部栏、规格按钮和轮播圆点这三个地方。它们是布局里最容易在窄屏挤爆、在宽屏拉散的组件,提前发现比上线后被人截图反馈要省事得多。希望这份拆解帮到你,动手改起来比读十遍都管用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询