简介:「仿京东的商品展示详情页面」是一套面向前端初学者与商城项目开发者的实战型页面模板,可用于学习电商商品详情页的结构搭建、样式布局与交互实现,并便于在现有商城中二次修改。压缩包共44个文件,约398KB,以31个png、5个gif及少量jpg图片素材为主,配套2个JavaScript脚本、1个HTML页面、1个CSS样式表及说明文本和链接文件,覆盖页面骨架、视觉资源与基础交互。已有4700余人学习下载,说明其在同类练习中具有一定参考价值。内容围绕商品主图轮播、规格选择、价格与库存联动、购物车按钮、用户评价展示等模块展开,同时涉及响应式布局、图片懒加载和基础性能优化思路。解压后可在HBuilder等IDE中直接运行,适合用来对照理解电商详情页的模块划分、事件处理与样式组织,并作为课程练习或商城项目改造的起点。
1. 仿一个商品详情页:六块内容互相咬合,从零写反而更慢
A 同学简历上写着「独立完成电商详情页」,我让他把页面从头滚一遍,问题全出来了:主图切到第三张,右侧价格没跟着变;规格选到无货组合,加购按钮还是亮的;往下滚到评价区,顶部导航跟着一起滚走了。这三处都不是「不会写标签」的问题,而是前端在真实商品页里绕不开的状态耦合——主图、规格、价格、库存、促销五组数据互相牵制,改一个另外四个都得跟着动。
这套仿某自营电商商品详情页的前端实现,价值恰恰在这:它把主图切换、放大镜、SKU 组合禁用、阶梯价与满减、倒计时、评价标签筛选串成一条能跑通的链路,而不是一张好看的静态长图。适合刚学完 HTML/CSS 想找完整页面练手的人,也适合准备面试、需要把状态管理讲清楚的人,接私活要快速交付一个商品页模板的同样能用。下面按骨架、图册、价格、避坑、验收五步拆,每一步都能直接抄。
2. 骨架与栅格:把详情页切成 8 个区块,先定宽度再写标签
很多人打开编辑器第一件事是写<div class="header">,我一般反过来:先在纸上把这页切成几块,标出每块的宽度和是否随视口变化,再动键盘。原因很实在——详情页里最容易返工的不是样式,是尺寸。主图区宽度决定了放大镜遮罩的比例,信息区宽度决定了规格标签一行能放几个,价格区宽度决定了促销文案要不要折叠。这些数字定不下来,后面写的 CSS 全是废稿。
2.1 先量尺寸再写标签:8 个区块与一份宽度表
自营电商的详情页,内容区常见做法是 1200px 居中,两侧留白自适应,主图区固定 400px,右侧信息区吃掉剩下的 800px,再往下是通栏的内容区。我把这套比例整理成表,动手前先对一遍:
| 区块 | 典型宽度 | 布局方式 | 关键交互 |
|---|---|---|---|
| 顶部导航 | 100% | flex 两端对齐 | 吸顶、搜索框 |
| 面包屑 | 内容区宽 | 行内流 | 无 |
| 主图区 | 400px 固定 | 相对定位容器 | 缩略图切换、放大镜 |
| 信息区 | 自适应约 800px | flex 纵向 | SKU 选择、价格联动 |
| 促销条 | 内容区宽 | flex 可换行 | 展开更多、悬停浮层 |
| 规格选择组 | 自适应 | block 包 flex 标签 | 选中态、禁用态 |
| 评价摘要 | 内容区宽 | grid 两列 | 标签筛选、展开 |
| 推荐位 | 内容区宽 | grid 6 列 | 横向滚动 |
这张表里有两个数字是硬约束:主图区 400px、内容区 1200px。它们一旦确定,放大镜的遮罩尺寸、缩略图一行排 5 个还是 6 个、规格标签最多显示几个字,全都有依据了。剩下的是弹性区,用flex: 1或minmax()去自适应,不要写死像素。顺带说一句,1200px 这个数字不是玄学,它是在 1366 宽的笔记本上还能留出左右各 80px 呼吸空间的折中值。
2.2 一份能直接抄的语义化骨架
骨架这一步我只做一件事:把区块边界写清楚,内部细节全部留空。这样后面任何一个区块都能单独开发、单独联调,不会出现「改价格区的样式把主图挤歪了」的情况。
<!-- 商品详情页主体骨架:只定区块边界,内部细节分块填充 --> <main class="pdp"> <!-- 面包屑:单独一行,不参与主栅格 --> <nav class="pdp__crumb" aria-label="面包屑"> <a href="/">首页</a> / <a href="/list">分类</a> / <span>当前商品</span> </nav> <!-- 首屏:左图右信息,两栏固定 + 自适应 --> <section class="pdp__hero"> <!-- 主图区:固定 400px,内部再分主图/缩略图 --> <div class="gallery">/* 首屏两栏:左固定右自适应,minmax(0,1fr) 防止长标题把右栏撑破 */ .pdp__hero { display: grid; grid-template-columns: 400px minmax(0, 1fr); gap: 24px; max-width: 1200px; margin: 0 auto; } /* 主图舞台:用 aspect-ratio 锁死比例,图片加载前后高度不跳 */ .gallery__stage { position: relative; aspect-ratio: 1 / 1; background: #f5f5f5; /* 占位底色,图片未加载时不留白洞 */ overflow: hidden; } .gallery__main { width: 100%; height: 100%; object-fit: contain; /* contain 保证商品不被裁切,详情页优先保真 */ } /* 缩略图一行五个,多出来的换行而不是挤压 */ .gallery__thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding: 0; list-style: none; } /* 吸顶导航:sticky 生效的前提是没有任何祖先元素设置 overflow */ .pdp__nav { position: sticky; top: 0; z-index: 10; background: #fff; }逻辑说明:minmax(0, 1fr)里的那个 0 不是可有可无的。grid 子项的默认最小宽度是auto,遇到超长商品标题或一行不换行的促销文案时,右栏会被撑到超出容器,整页出现横向滚动条。这是仿写页面里最高频的错位原因之一。
参数说明:aspect-ratio: 1 / 1需要现代浏览器支持,老项目里退化成padding-top: 100%的伪元素占位也行。object-fit: contain和cover要按场景选——商品主图用contain保住商品全貌,缩略图和推荐位用cover保证格子填满不露底。z-index: 10是给吸顶导航留的层级,后面放大镜的大图容器要设成 20 以上,否则会被导航盖住。
3. 图册与 SKU 联动:用一份状态对象收口三类选中态
这一章是整套资源里最值得抄的部分。图册、规格、价格三者联动的难点不在代码量,而在「选中态存在哪」。我见过太多写法是:缩略图选中就给<li>加个activeclass,规格选中就给<button>加个checked,价格要变的时候再去 DOM 里把所有active翻一遍,拼出当前组合。这种写法在只有两个规格组、每组三个选项时还能跑,一旦加到四组、含无货组合,就是血泪经验现场——加购时算出来的 SKU 和页面上显示的价格对不上。
3.1 单一数据源:把选中态放进对象,DOM 只做投影
正确的顺序是反过来的:先有一个state对象保存「当前选了哪个规格」,再由它推导出「当前是哪个 SKU」「该显示什么价格」「哪些选项要禁用」,最后一步才是把结果写回 DOM。DOM 永远只是数据的投影,不承担存储职责。
// 页面的唯一数据源:规格定义 + 可售 SKU 列表 + 当前选中 const state = { // 规格组,每组含选项与该选项是否有货 groups: [ { name: '颜色', options: ['黑色', '白色', '蓝色'] }, { name: '版本', options: ['8+128', '12+256', '16+512'] }, ], // 可售组合,库存为 0 的组合不出现或标记 soldOut skus: [ { id: 'k1', attrs: { 颜色: '黑色', 版本: '8+128' }, price: 299900, stock: 12 }, { id: 'k2', attrs: { 颜色: '黑色', 版本: '12+256' }, price: 349900, stock: 0 }, { id: 'k3', attrs: { 颜色: '白色', 版本: '12+256' }, price: 359900, stock: 5 }, ], selected: {}, // { 颜色: '黑色', 版本: '8+128' } };逻辑说明:selected用普通对象存,键是规格组名,值是选项名,这样不用关心规格组有几个、顺序如何,遍历Object.entries(selected)就能还原出组合。skus里每个组合的id是唯一标识,加购、埋点、跳转都靠它,不要用拼接字符串当 ID——"黑色-8+128" 这种拼法,一旦选项名里出现连字符就会撞车。
参数说明:price全部用「分」为单位的整数存(299900 就是 2999 元),这一点第 4 章还会展开,这里先记住:凡是参与计算的金额,都不要用浮点数。stock为 0 表示该组合无货,渲染时对应选项要进禁用态,而不是等用户点了加购再弹提示。
3.2 主图切换与放大镜:三段式,不要一把梭
图册交互拆成三段:缩略图悬停换主图、主图悬停出遮罩、遮罩移动带动大图。三段各自独立,任何一段出问题都不影响另外两段。
// 第一段:缩略图切换,只改 src,不动 DOM 结构 const stage = document.querySelector('.gallery__main'); document.querySelectorAll('.gallery__thumbs img').forEach((thumb) => { thumb.addEventListener('mouseenter', () => { stage.src = thumb.src.replace('thumb-', 'main-'); // 命名约定换图,避免额外请求列表 }); }); // 第二段 + 第三段:放大镜,位置计算全部基于 getBoundingClientRect const lens = document.querySelector('.gallery__lens'); const zoom = document.querySelector('.gallery__zoom'); const stageBox = document.querySelector('.gallery__stage'); const ZOOM_RATIO = 2.5; // 大图放大倍数,2~3 之间观感最好 stageBox.addEventListener('mouseenter', () => { lens.hidden = false; zoom.hidden = false; zoom.style.backgroundImage = `url(${stage.src})`; // 大图按放大倍数等比放大,背景尺寸 = 舞台尺寸 × 倍数 zoom.style.backgroundSize = `${stageBox.clientWidth * ZOOM_RATIO}px ${stageBox.clientHeight * ZOOM_RATIO}px`; }); stageBox.addEventListener('mousemove', (e) => { const rect = stageBox.getBoundingClientRect(); // 每次重算,兼容滚动与缩放 const lensW = lens.offsetWidth; const lensH = lens.offsetHeight; // 遮罩中心跟随鼠标,同时夹紧在舞台范围内,防止贴边溢出 let x = e.clientX - rect.left - lensW / 2; let y = e.clientY - rect.top - lensH / 2; x = Math.max(0, Math.min(x, rect.width - lensW)); y = Math.max(0, Math.min(y, rect.height - lensH)); lens.style.transform = `translate(${x}px, ${y}px)`; // 大图背景反向位移,位移量 = 遮罩位置 × 放大倍数 zoom.style.backgroundPosition = `${-x * ZOOM_RATIO}px ${-y * ZOOM_RATIO}px`; }); stageBox.addEventListener('mouseleave', () => { lens.hidden = true; zoom.hidden = true; });逻辑说明:遮罩位置用translate而不是left/top,因为 transform 不触发重排,鼠标移动时性能差别肉眼可见。位置计算必须每次在mousemove里重新取getBoundingClientRect(),不能在mouseenter时缓存一次——页面一滚动,缓存的 rect 就过期了,放大镜会整体偏移,这个 bug 在真实项目里排查起来非常费时间。
参数说明:ZOOM_RATIO控制在 2 到 3 之间比较舒服,太小没有放大意义,太大背景图会糊。遮罩宽高建议设成舞台的 1/3 左右(width: 33%),这样大图的可视区域和遮罩是 1:3 的对应关系,观感自然。backgroundSize用像素值而不是cover,因为要精确对应倍数。另外注意,大图必须用原图,不能拿缩略图凑数,否则放大了就是马赛克。
3.3 SKU 可用性判断:从笛卡尔积到逐组过滤
用户选了颜色还没选版本时,哪些版本应该置灰?答案是:当前已选颜色下,所有库存为 0 的版本都置灰。这需要按「已选的其他组」去过滤候选组,而不是简单地看某个选项全局有没有货。
// 判断某个选项在当前已选条件下是否可选 function isOptionAvailable(groupName, option) { // 构造一个假设的选中态:把当前选项填进去 const probe = { ...state.selected, [groupName]: option }; // 只要存在任一个 SKU,其所有属性都与 probe 中已选的键值一致,就说明可选 return state.skus.some((sku) => sku.stock > 0 && Object.entries(probe).every(([k, v]) => sku.attrs[k] === v) ); } // 渲染规格组:每个选项根据可用性加 disabled,根据选中态加 active function renderSku() { const wrap = document.querySelector('[data-sku]'); wrap.innerHTML = state.groups.map((group) => { const tags = group.options.map((opt) => { const active = state.selected[group.name] === opt; // 已选中的选项本身不参与可用性判断,否则点了会自己把自己禁掉 const disabled = !active && !isOptionAvailable(group.name, opt); return `<button class="sku__tag${active ? ' is-active' : ''}" >// 金额一律用「分」为单位的整数,避免 0.1 + 0.2 这类浮点误差 const priceModel = { base: 359900, // 基础价(划线价) activity: 329900, // 活动价,通常已含单品直降 member: 319900, // 会员价,与活动价取更低者 }; // 第一层:取活动价与会员价中的较低值作为「当前价」 function currentPrice(model) { return Math.min(model.activity, model.member); } // 第二层:满减门槛与立减额,档位按门槛升序排列,匹配时从高往低找 const fullReduce = [ { threshold: 299900, reduce: 30000 }, // 满 2999 减 300 { threshold: 199900, reduce: 15000 }, // 满 1999 减 150 { threshold: 99900, reduce: 5000 }, // 满 999 减 50 ]; // 找当前价能命中的最高档位 function matchReduce(price, rules) { const hit = rules .filter((r) => price >= r.threshold) .sort((a, b) => b.threshold - a.threshold)[0]; return hit ? hit.reduce : 0; } // 第三层:到手价 = 当前价 - 满减,最低不小于 0 function finalPrice(model) { const cur = currentPrice(model); return Math.max(0, cur - matchReduce(cur, fullReduce)); }逻辑说明:三层分开写,好处是每一层都能单独测试。取较低价用Math.min而不是写 if-else,规则变了只改一行。满减匹配用「先过滤再按门槛降序取第一个」,比自己写循环和临时变量清楚得多,也不会出现「遍历到低档位就提前 break」的经典错误。
参数说明:所有门槛和立减额都是分,99900就是 999 元。规则数组不要求有序,因为函数内部做了排序,但加上注释说明档位关系仍然是好习惯。Math.max(0, ...)兜底很重要——极端情况下满减额可能大于当前价,负数价格显示出来就是事故。
4.2 渲染到手价:只在这一步除以 100
计算全程用分,渲染时才转成元,转换函数单独抽出来,全站统一。
// 分转元,固定两位小数,不做四舍五入以外的处理 function fmt(cents) { return (cents / 100).toFixed(2); } function renderPrice() { const wrap = document.querySelector('[data-price]'); const cur = currentPrice(priceModel); const fin = finalPrice(priceModel); const saved = priceModel.base - fin; wrap.innerHTML = ` <span class="price__now">¥${fmt(cur)}</span> <span class="price__base">¥${fmt(priceModel.base)}</span> ${saved > 0 ? `<span class="price__save">已优惠 ¥${fmt(saved)}</span>` : ''} `; // 促销条单独渲染,与价格区解耦,改文案不影响价格逻辑 document.querySelector('[data-promo]').innerHTML = `满 999 减 50,满 1999 减 150,满 2999 减 300;预计到手 <b>¥${fmt(fin)}</b>`; }逻辑说明:renderPrice只做渲染,不承担任何计算,计算全在上面三个纯函数里。这样后续要加「新人券」「跨店满减」,只改计算层,渲染层不动。促销条用另一段模板渲染,而不是塞进价格区,因为促销文案的更新频率远高于价格结构。
参数说明:toFixed(2)在极少数边界值上会有舍入争议(比如 1.005),如果项目对精度要求极高,可以在fmt里先Math.round(cents) / 100再格式化。saved为负或 0 时不显示优惠标签,避免出现「已优惠 ¥0.00」这种尴尬文案。
4.3 倒计时:不要用 setInterval 逐秒减一
限时促销必然带倒计时。最常见的写法是setInterval(() => sec--, 1000),本地看着没问题,用户切到别的标签页放十分钟再回来,倒计时少走了几十秒。原因很简单:浏览器会节流后台页面的定时器,而你的计数器假设每次回调都准时发生。
// 倒计时只依赖一个截止时间戳,每次渲染都重新算剩余量 function startCountdown(endTs, el) { const tick = () => { const left = endTs - Date.now(); // 永远用时间差,不做累减 if (left <= 0) { el.textContent = '活动已结束'; clearInterval(timer); return; } const s = Math.floor(left / 1000); const hh = String(Math.floor(s / 3600)).padStart(2, '0'); const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'); const ss = String(s % 60).padStart(2, '0'); el.textContent = `${hh}:${mm}:${ss}`; }; tick(); // 立即执行一次,避免首帧空白 const timer = setInterval(tick, 1000); return () => clearInterval(timer); // 返回清理函数,组件卸载时调用 } // 服务端下发截止时间戳,前端只管展示,不在前端算活动时长 startCountdown(Date.parse('2025-01-01T00:00:00+08:00'), document.querySelector('[data-countdown]'));逻辑说明:核心是「状态只存截止时间,剩余时间随时现算」。定时器只是触发重绘的节拍器,即使被浏览器节流了几十次回调,下一次回调算出来的剩余时间依然是准确的。返回清理函数是为了配合单页应用的路由切换,页面销毁时不清定时器,内存里会堆一堆跑着的计时器。
参数说明:endTs由服务端下发,不要在前端用Date.now() + 时长算,客户端时间可以被改。padStart(2, '0')保证两位数显示。如果活动超过 24 小时,hh会超过两位,需要改成「天 + 时:分:秒」的格式,这一点按业务时长决定。
5. 避坑与排查:五个「看着像、用着崩」的现场复盘
仿写页面最迷惑人的地方在于「还原度看着挺高」,但一交互就露馅。下面五条是这套资源在联调阶段真实撞出来的问题,按现象、原因、解决三段记录,能省掉不少重复排查。
5.1 布局与图片:三处高频错位
第一条:主图区在图片加载完成后整体下移一段距离。
现象:首屏打开时价格区贴着标题,图片加载完,价格区突然往下跳十几像素。原因:<img>没有预设高度,加载前高度为 0,加载后才撑开容器,父级布局跟着重排。解决:给图片容器设aspect-ratio或等比的 padding 占位,再配object-fit,让图片加载前后容器尺寸完全一致。这套做法对缩略图、推荐位同样适用,凡是图片位置固定、尺寸已知的地方,都应该提前占位。
第二条:吸顶导航滚到一半突然不吸了。
现象:往下滚到评价区,导航跟着页面一起滚走,或者滚到某个位置后突然脱离。原因:position: sticky的生效条件是「从它到滚动容器之间,没有任何祖先元素设置了overflow: hidden/auto/scroll」。详情页里为了裁切放大镜溢出,很容易在外层加个overflow: hidden,导航就废了。解决:把裁剪范围收紧到放大镜自己的容器上,不要加在公共祖先上;实在要加,就把导航挪到裁剪容器外面去。
第三条:放大镜在页面滚动后位置整体偏移。
现象:首屏不滚动时放大镜正常,往下滚一段再移上去,遮罩跑到了鼠标左上方。原因:位置计算用了offsetX/offsetY或者缓存的rect,没有扣掉滚动距离。解决:统一用e.clientX - el.getBoundingClientRect().left,并且每次mousemove都重新取 rect。这一条踩过一次之后,我在所有拖拽、放大、悬浮定位的场景里都强制走getBoundingClientRect,不再用任何相对坐标。
5.2 交互与状态:两处会崩的逻辑
第四条:来回切换规格后,加购的 SKU 和显示的价格对不上。
现象:先选「黑色 + 8+128」,价格显示 2999;改成「白色」,价格没变,但加购弹窗里出的是白色对应的 SKU。原因:选中态存在 DOM 的 class 上,价格渲染读的是另一份变量,两处不同步。解决:回到第 3 章的单一数据源,所有派生结果都从state.selected现算,渲染函数只读不写。判断标准很简单——页面刷新后把state打印出来,如果它能唯一确定页面当前显示的一切,就是对的。
第五条:倒计时在后台放一会儿再回来,时间明显变慢。
现象:切到别的标签页五分钟,回来发现倒计时只走了两分钟。原因:setInterval累减,后台标签页的定时器被浏览器节流到 1 秒以上甚至暂停,累减就少了。解决:改成截止时间戳现算剩余(见 4.3)。同样的坑还出现在「自动轮播」「定时刷新库存」上,凡是需要和真实时间对齐的,都不能靠回调次数。
提示:这五条里有四条跟「缓存了本该重算的值」有关。排查时的通用手法是——把所有缓存值临时改成实时计算,看问题是否消失,再决定要不要加回缓存。
6. 还原度验收:一张 20 分钟跑完的自查表和一个比对脚本
页面写完不算完,得有一套能反复跑的验收动作。我一般分两步:先跑一遍清单,确认功能边界;再用脚本扫一遍容易漏的细节。清单按「首屏、交互、异常、性能」四组排,20 分钟能过完。
| 验收项 | 检查方法 | 不通过的典型表现 |
|---|---|---|
| 首屏无横向滚动 | 窗口缩到 1280 宽看下方是否出滚动条 | 长标题或促销文案把右栏撑破 |
| 图片占位稳定 | 断网刷新,看布局是否跳动 | 价格区上下位移 |
| 规格禁用正确 | 逐个点选,检查无货组合是否置灰 | 无货组合仍可加购 |
| 价格一致性 | 规格切换后对比显示价与到手价 | 两处价格不同步 |
| 吸顶生效 | 滚动到评价区观察导航 | 导航跟随滚走 |
| 放大镜边界 | 鼠标贴四角移动 | 遮罩溢出舞台、大图露白边 |
| 倒计时准确 | 切标签页 3 分钟后回来对比 | 时间明显偏慢 |
| 键盘可达 | Tab 遍历规格与按钮 | 焦点丢失、无法选中 |
表格过完之后,我会跑一个小的比对脚本,专门抓「显示出来的到手价和计算出来的到手价是否一致」这类隐蔽问题。思路很简单:把页面上的规格组合全部枚举一遍,每次点选后读 DOM 里的价格文本,再和纯函数算出来的结果比。
// 遍历所有规格组合,比对页面显示的到手价与模型计算值 async function auditPrice(page, model, calcFinal) { const results = []; // 两两组合枚举,规格组多了要改成递归,这里按两组写 for (const c of model.groups[0].options) { for (const v of model.groups[1].options) { page.click(`[data-group="颜色"][data-option="${c}"]`); page.click(`[data-group="版本"][data-option="${v}"]`); const shown = page.textContent('.price__now'); // 页面显示值 const expect = calcFinal({ 颜色: c, 版本: v }); // 模型计算值 if (shown !== `¥${(expect / 100).toFixed(2)}`) { results.push({ combo: `${c}/${v}`, shown, expect }); } } } return results; // 返回空数组说明全部一致 }这段脚本的关键点在于「期望值从哪来」。如果期望值也从页面读,那就成了自己跟自己比,永远通过。期望值必须来自独立的纯函数,这也是第 4 章把计算层单独抽出来的另一个理由——它天生就是可测试的。参数上,page是一个极简的 DOM 操作封装,click和textContent各一行就够,不必引入完整框架。跑完输出为空数组,我才认为这一版可以交付。
从那以后,我每次交这类页面之前,都会强制走一遍「断网刷新 + 全组合点选 + 切后台三分钟」这三步,少一步心里都不踏实。希望帮到你。
本文还有配套的精品资源,点击获取