简介:一份仿京东商品展示详情页的前端实战项目,适合正在学习电商页面开发的前端初学者与进阶开发者,也可作为商城项目的页面参考。项目基于 HTML、CSS 和 JavaScript 实现,涵盖商品主图轮播、价格与库存展示、规格选择切换、用户评价列表以及加入购物车等典型电商模块,并兼顾移动端适配。资源共 44 个文件,压缩包仅 398KB,入口页面、样式表和交互脚本齐全,同时包含大量用于商品图和界面元素的 png、gif 素材,便于直接运行查看效果,或集成到自己商城项目中进行个性化修改。目前已有 4705 人学习下载,对想通过完整示例理解电商详情页设计流程的人来说,是一个轻量且实用的参考。通过拆解该项目的结构,可以掌握响应式布局、懒加载图片、事件处理、动态价格计算和购物车交互等核心前端技巧,也能为后续结合 Vue 或 React 开发积累可复用的业务实现思路。
1. 仿京东的商品展示详情页面:先想清楚到底仿的是什么
假如你第一次接到“做一个仿京东的商品展示详情页面”的需求,第一反应大概率是打开京东 App 对着截图一比一复刻。等真正做完一轮就会发现,像素级还原只是这页面的最小一步,后面还排着商品数据、SKU 联动、促销展示、图片加载、楼层滚动、跨端复用等一串问题。所以这个标题里的“仿”字,关键不在“像”,而在每一块 UI 背后都要有数据结构和交互状态支撑。
下面按我实际做模拟项目X 的路子来写:先定商品详情页的数据结构与价格策略,再排 UI 骨架,再给一个最小可运行的 HTML/CSS/JS 实现,最后把最容易翻车的细节和验证方法列出来。新手跟着能落地,熟手可以直接跳到参数和避坑部分。
2. 商品展示详情页的数据结构:SKU、价格与促销规则怎么设计
在写任何标签和样式之前,第一件要做的事是把商品详情页的接口数据结构定下来。电商详情页看起来是一张长图,实际上它是多个数据块的拼接:头部商品信息、价格区块、规格选择、服务承诺、图文详情、评价、推荐。这些数据块的更新频率和缓存策略各不相同,前端不能把所有内容当成一个 JSON 整包去渲染。
2.1 商品基础信息:主图、标题、卖点与参数分区
商品基础信息一般分两层。第一层是首屏直接要展示的 pid、标题、主图列表、副标题、卖点标签;第二层是参数区,数据量大且用户通常翻到后半段才看。接口比较合理的方式是把首屏字段单独返回,参数区用另一个字段延后渲染,前端拿到后也不需要一次性全部插入 DOM。我的模拟项目X 里用的是这样一个结构:
{ "pid": "SPU123456", "title": "商品标题字段", "subtitle": "一句话卖点", "mainImages": [ { "url": "https://img.example.com/01.jpg", "width": 750, "height": 1000 } ], "attrs": [ { "name": "品牌", "value": "某品牌" }, { "name": "产地", "value": "中国" } ], "price": { "current": 12900, "original": 15900, "promoType": "direct" } }价格字段我特意写成整数 12900,而不是 129.00。原因很简单:商品价格涉及计算和比较,浮点数在 JavaScript 里会有精度问题,接口一律用“分”为单位传整数,前端负责展示格式化。这个习惯越早建立,后面做促销分摊时越省事。主图的高度也要传,前端拿到宽高比后再撑占位,能避免首屏图片加载时页面高度跳动。
参数区最容易被忽略的是分类。有的接口把全部参数平铺在一份数组里,后面 UI 要按“主体 / 规格 / 包装”分组时只能再做一次映射。我一般会要求接口直接返回分组结构attrGroups: [{ name: "主体", items: [...] }],前端少写一次转换逻辑。要注意的是,参数应该是数组而不是对象,因为参数的展示顺序也属于页面信息的一部分,对象无法保证顺序。
2.2 规格与 SKU:为什么不能把“颜色 / 版本”简单存成字符串
商品详情页最核心的交互是选规格。颜色、版本、套餐,任意一项变化都可能影响价格、库存和主图。如果把这些属性拼成一个字符串存起来,库存和价格的关联会非常痛苦。正确做法是给每个可售组合一个 skuId,组合下的属性用独立的规格列表表达。
{ "specs": [ { "specId": "color", "name": "颜色", "values": [ { "valueId": "red", "name": "红色", "image": "https://img.example.com/red.jpg" } ]}, { "specId": "version", "name": "版本", "values": [ { "valueId": "128g", "name": "128G" }, { "valueId": "256g", "name": "256G" } ]} ], "skus": [ { "skuId": "SKU001", "specs": ["red", "128g"], "price": 12900, "stock": 23 }, { "skuId": "SKU002", "specs": ["red", "256g"], "price": 14900, "stock": 10 } ] }前端拿到数据后的第一步,是建立“选中规格组合 → skuId”的映射。常见做法是用一个 Map,key 是排序后的规格值组合,比如"red|128g",value 是 sku 对象。用户点击规格时,把当前选中值按固定顺序拼 key,再去 Map 里查;查不到就表示组合不存在,需要把对应规格项置灰。有经验的开发还会在 sku 上扩展一个mainImage字段,选中“红色”时商品主图要跟着切换,这个联动应该在数据层准备好,而不是前端拿着颜色值猜图片 URL。
关于库存还有一个边界:详情页的stock只用于展示是否有货,真正下单时的扣减校验必须由后端再查一次。前端不能根据本地库存判断“可以下单”,否则并发场景下会出现超卖。反过来,前端也不要在库存为 0 时彻底隐藏购买入口,很多品类支持“到货通知”,入口要保留。
2.3 价格、促销与划线价:前端只展示,不计算
价格区基本由三块组成:到手价、划线价、促销标签。数据上我习惯拆成price.current、price.original和promo三部分。original不是拿来随便划线的,只有用户没有叠加任何优惠时才显示划线原价;只要存在满减、优惠券、限时直降,页面主价格就应展示计算后的到手价。
"promo": { "type": "coupon", "tag": "领券立减20", "amount": 2000, "quota": "满100可用", "validUntil": "2025-12-31 23:59:59" }这里的坑在于“金额由谁算”。我推荐由接口直接把到手价算好返回,前端不做二次计算。原因有三点:同一套页面将来要复用到小程序、App WebView 和 H5,后端计算规则只有一份;前端计算容易因为精度和规则不同步出现价格不一致;促销规则往往是逐级叠加的,比如先满减再打折再叠加优惠券,前端很难单独算对。促销标签的文案也应由后端下发,前端只负责样式,不要自己拼文案。
价格展示还有一个容易被产品追问的点:倒计时。限时促销到期后,价格要自动切回原价。这个变化不要依赖前端定时器单独驱动,最好由接口在下一次轮询或页面进入前台时返回新的price.current。如果在多个页面维护多个定时器,很容易出现详情页已恢复原价、购物车还在显示促销价的情况。
3. 商品展示详情页的 UI 骨架:楼层、吸顶与分层加载的排布逻辑
数据结构定完,开始搭页面。商品展示详情页是一个超长滚动页面,一上来就做精细视觉会让后期调整成本很高。我一般先做三层:第一层确认首屏信息密度,第二层确认楼层滚动方案,第三层确认长图懒加载和推荐流的裁断逻辑。
3.1 第一屏怎么排:图区、价格区、规格区、服务区
参考京东 App 的排列习惯,首屏从上到下一般是图片轮播区、价格区、标题区、卖点标签区、规格选择区、服务承诺区。需要注意的是,首屏高度尽量控制在两屏以内,否则用户滑半天看不到加入购物车和立即购买两个按钮。图区用横向轮播而不是全屏大图,图片比例统一为 1:1 或 3:4,首屏看起来更整齐。
规格选择区建议用“摘要 + 弹层”的方式:首屏只展示已选中的规格摘要,点击后弹起选择面板。京东 App 的规格选择也是这个思路,好处是首屏不会因为规格维度太多而拉长。服务承诺区应当做成可配置数组,因为不同类目的承诺文案完全不同,生鲜和数码是两个极端。
这里要提醒一个容易看漏的细节:首屏的布局顺序会直接影响转化。价格区必须在标题上方,因为用户第一眼需要确认价格;卖点标签不能做成整段文字,要预留一行图标能力,后续运营要往里面塞“限时秒杀”、“新品”这类角标时,不需要前端改版。
3.2 吸顶导航与楼层滚动:仿京东最容易处理砸的交互
详情页滚到图文详情附近时,顶部会出现吸顶导航,通常包含商品、详情、评价三个锚点。这个交互在原生端很常见,但在 H5 里经常出现锚点漂移和吸顶抖动。我常用的方案是给楼层容器设置scroll-margin-top,再结合滚动事件驱动导航状态。
.floor { scroll-margin-top: 44px; } .nav-sticky { position: sticky; top: 0; z-index: 10; background: #fff; }sticky 的生效条件是父容器不能有overflow: hidden。很多详情页项目为了做圆角或滚动容器,给 body 加了 overflow,粘性定位就会失效。楼层锚点建议用scrollIntoView而不是手写滚动距离,浏览器会自动处理 sticky 下的偏移。如果要做导航高亮,我更倾向用 IntersectionObserver 去观察每个楼层的进入比例,而不是在 scroll 回调里做大量比较运算。
很多人在仿京东时会把滚动容器从 window 换成某个 div,然后发现 sticky 失效。原因是 sticky 相对最近的滚动祖先生效,如果你给滚动容器设置了overflow: auto但没有把它设为 sticky 的祖先,浏览器仍然以 window 为准。所以,如果详情页外层有一个自定义滚动容器,吸顶导航要放在容器内部,而不是页面 body 层。
3.3 图文详情、评价区与推荐瀑布流:分层加载的节奏
图文详情通常是一组长图,图片体积大、数量多,如果首屏一次性加载完,首屏时间和流量都会爆掉。我一般把详情页拆成三个接口:基础信息接口、评价接口、推荐接口;图文详情按楼层懒加载,滚动到附近才开始加载图片。这样首屏接口最多两个,且数据量都控制在几十 KB 以内。
评价区的核心问题是排序和筛选。详情页的评价列表与评价详情共用同一套数据源,但展示维度不同。前端在接入时要确认接口是否返回“好评率”和“追评数”,否则 UI 上留了位置却没有数据,视觉会露馅。推荐瀑布流是另一个难点,双列瀑布流不能简单把数组平均切成两半,因为图片高度不同;常见做法是维护两列高度,把商品逐个插入较矮的一列。
长列表的渲染建议用分页而不是一次性渲染全部。推荐数据一次给 20 条,触底再要下一页;如果一次给 100 条,双列瀑布流的 DOM 节点太多,低端安卓机会明显卡顿。虚拟列表在详情页这种非纯列表场景里反而不实用,因为每个楼层高度不同,虚拟化成本很高,分页 + 懒加载是更可靠的做法。
4. 手写一个最小可运行的详情页骨架:HTML/CSS 结构与 JS 状态管理
下面按这个结构拆出来的核心骨架,每一步都能单独验证,合起来就是一份最小详情页。它不是一个完整商品详情页,而是把最容易被仿错的几个部分抽出来:首屏固定结构、响应式布局、规格联动、加购防重复、图片懒加载。我用原生 HTML/CSS/JavaScript 写,方便你移植到 React/Vue 或小程序。
4.1 最小 HTML 结构与两个 CSS 变量
先搭首屏骨架。商品图区、价格区、标题区、规格按钮、底部操作栏,这几个元素是所有商品展示详情页面的公共骨架。代码不需要实现很多交互,先把结构和样式稳定下来,后续所有功能都往这几个区块里填。
<div id="app"> <section class="gallery"> <img>:root { --brand-color: #e93b3d; --price-color: #e93b3d; --text-main: #222; --text-sub: #999; --footer-height: 50px; } * { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--text-main); } .gallery img { width: 100%; display: block; } .info { padding: 12px; } .price .origin { margin-left: 8px; font-size: 14px; color: var(--text-sub); text-decoration: line-through; } .footer-bar { position: fixed; bottom: 0; left: 0; right: 0; height: var(--footer-height); display: flex; background: #fff; border-top: 1px solid #eee; }用 CSS 变量定义品牌色和 footer 高度,换肤或做暗色模式时只需要改定义处。gallery img设置display: block是为了避免图片下方的几像素空隙,很多仿页面时发现图片下方多一条白线,就是这里没处理。底部操作栏使用position: fixed,页面内容底部需要预留同样高度,否则最后一屏会被底栏遮住,这个细节放到避坑章节再展开。
注意,这里的.price .origin使用了line-through表示划线价。如果你的需求里要求划线价有删除动画,用 CSS 文本装饰处理不了,需要把划线价改成 SVG 或伪元素绘制,但那属于视觉细节,不影响数据层。
4.2 交互状态:选规格、切换价格、加购物车防重复
规格选择做成小组件。点击主区域的“选择 规格”按钮,弹出半屏面板;点击面板中的规格项,更新选中态并同步价格;点击空白处关闭面板。下面是组件核心逻辑片段,DOM 绑定需要按你自己的框架补全。
const state = { selectedColor: null, selectedVersion: null, price: 12900 }; function buildSkuKey() { return `${state.selectedColor || ''}|${state.selectedVersion || ''}`.trim('|'); } function findSku() { if (!state.selectedColor || !state.selectedVersion) return null; return skuMap.get(buildSkuKey()) || null; } function selectSpec(type, value) { if (type === 'color') { state.selectedColor = value; } else { state.selectedVersion = value; } const sku = findSku(); if (sku) { state.price = sku.price; document.getElementById('currentPrice').textContent = formatPrice(sku.price); } updateSpecPanel(); } async function addToCart() { const sku = findSku(); if (!sku) { alert('请先选择规格'); return; } const addCartBtn = document.getElementById('addCart'); addCartBtn.disabled = true; addCartBtn.textContent = '加入中…'; try { await fakeRequest(sku.skuId); alert('已加入购物车'); } finally { addCartBtn.disabled = false; addCartBtn.textContent = '加入购物车'; } }buildSkuKey按固定顺序拼接选中规格,避免用户点击顺序不同导致 key 不一致。skuMap是在拿到 sku 列表后构建的 Map;formatPrice是纯格式化函数,内部用(price / 100).toFixed(2)转成“¥129.00”即可,不要在里面做任何金额计算。核心逻辑是,没有选中完整规格时不允许改价和加购;选中完整规格后,价格、库存、主图一次性切换到对应 SKU。
加购物车按钮还要处理重复提交。接口请求成功前不能重复点击,接口失败要把按钮恢复。代码里用disabled加文案双保险。需要注意的是,按钮的 loading 状态要留在组件内部,不要依赖调用方传参,否则多个地方复用时会出现状态不同步。
4.3 隐式参数:图片懒加载、骨架屏与滚动节流
详情页需要在一开始给用户“页面已经打开”的反馈,不能白屏等图片。常见做法是先渲染骨架屏,再在图片进入视口时做懒加载。原生可以直接用loading="lazy",但为了统一控制占位和加载失败状态,建议用 IntersectionObserver 控制>const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px 0px', threshold: 0.01 }); document.querySelectorAll('img[data-src]').forEach((img) => { observer.observe(img); });
rootMargin: '200px 0px'表示图片距离视口底部还有 200px 时就提前加载。threshold: 0.01表示图片出现 1% 就开始加载,避免图片完全可见后才触发。图片加载前,外层容器要保留占位高度,否则懒加载会导致滚动跳动,观感像页面在抖。图片加载失败时,要给一个可点击重试的占位图。
滚动节流建议用requestAnimationFrame配合滚动事件,而不是 setTimeout 写死 200ms。setTimeout 节流在快速滚动时会产生明显卡顿;rAF 让浏览器在绘制帧之前执行更新,滚动手感更顺。如果回调里只需要监听是否滚过某个位置,用 IntersectionObserver 能避免滚动事件本身的开销。
5. 避坑指南:商品展示详情页面最常翻车的 5 个细节
下面直接写我遇到过的五个问题,适合已经能跑通基础页面但总在细节处翻车的开发者对照排查。每条按现象、原因、解决三步展开,参数和代码可以直接照搬,但更建议先读懂原因再动手改。
5.1 首屏高度反复跳变:主图加载后整个页面被撑高
现象:页面打开时首屏空白,主图加载完成后整个页面向下跳一下,用户手指点到底部按钮却点到了其他区域。 原因:主图容器没有预设宽高比。图片下载完成前高度为 0,下载后浏览器按图片实际高度撑开容器,导致 reflow。这在弱网环境尤其明显,也是详情页黑匣子问题里最容易被忽略的一个。 解决:给图片外层容器设置aspect-ratio: 1 / 1,或者用 padding-top 百分比方案,因为老 WebView 对 aspect-ratio 支持不稳定。接口返回主图尺寸时用真实宽高生成占位;没返回时按默认 1:1 占位,等真实尺寸回来再修正。另一个习惯是首屏不要放自动播放视频,那会把首屏时间拉长几倍。
5.2 价格展示不一致:前端算一次,接口又算一次
现象:详情页看到到手价 89 元,加入购物车后变成 99 元,订单确认页又变成 89 元。客服查来查去,发现前端把优惠券金额减在商品单价上,后端减在总价上。 原因:价格计算逻辑在前端和后端各写了一份,而且计算口径不同。详情页为了秒开,前端先用估算规则算出到手价,订单接口走正式规则,两套规则对“是否满足优惠门槛”的判断不一致。 解决:详情页的到手价和订单页的到手价都用接口返回的最终值。前端只做格式化,不做计算。如果产品要求提前展示“立减 20 元”,让接口把减后价和减前价一并返回;倒计时结束后的价格变化也由接口轮询驱动,前端不要在本地开定时器改价。
5.3 底部操作栏被键盘顶起:移动端 fixed 定位的玄学
现象:在商品详情页搜索或填写优惠券时,底部固定操作栏被输入法顶到视口中间,布局完全乱掉,输入法收起后位置还不恢复。 原因:position: fixed元素在 WebView 键盘弹起时会跟随视口高度变化而重新定位。iOS 和安卓行为不一致,iOS 有时失焦后不恢复,安卓则可能压缩视口。 解决:底部操作栏不要直接放输入框,需要输入内容的场景改用全屏弹层操作,让弹层内容自己滚动,主页面底部栏不动。visualViewport事件可以修正 fixed 元素位置,但兼容性判断比较复杂,我一般从布局上绕开,不在这个细节里硬磕。
5.4 SKU 置灰逻辑:库存为零和组合不可选是两回事
现象:选中红色 128G 后,256G 选项仍然高亮,点击却提示无货;切成 256G 后,512G 又高亮但同样无货。用户来回点了几次,最后放弃下单。 原因:只判断单维度库存是否为零,没有判断组合是否可选。SKU 置灰的正确规则是:当前维度的某个值可不可点,取决于它与其他已选维度值组成的新组合是否存在且有库存。 解决:在拿到 skuMap 后,额外生成一个“可选值”矩阵。每次点击规格时,对当前维度里的每个值,尝试与已选的其他维度值拼 key,查 Map 中是否存在且有库存。存在则高亮,不存在则置灰。规格维度一般不超过四个,遍历性能没有压力。真正容易漏的是:当前维度还没有选过时,该维度所有值都应先保持可点,否则用户第一下就点不了。
5.5 详情页与列表页共享组件:样式污染互相打架
现象:列表页的商品卡片突然多了边距,详情页的标题被莫名缩小。排查很久才发现是同一个 class 在两个页面被共用,另一个页面的全局样式覆盖了组件样式。 原因:H5 详情页和列表页通常在一个工程里,组件复制后保留了相同的 class 名;全局样式选择器优先级更高,一个页面改了样式,另一个跟着变。 解决:每个独立页面加根 class,比如.detail-root和.list-root,样式写在根 class 作用域内。Vue 开 scoped,React 用 CSS Module。这个习惯在为小程序或 WebView 复用组件时也能减少样式冲突。如果团队足够大,把详情页独立成单页、与列表页完全隔离,是最彻底但工程成本最高的方案。
6. 仿京东的价值不在像素级还原:进阶玩法与验证方法
页面能跑起来之后,更重要的是验证它在大流量、弱网、多端环境下的表现。下面三个技巧是我最近在做类似页面时常用的,能帮你确认“仿”出来的页面不只是长得像,还能扛住真实场景。
6.1 用骨架屏 + 接口模拟验证弱网表现
开发时接口通常是本地 mock,网络延迟接近 0,首屏看什么都快。上线前我会给接口人为加延迟,比如 2 秒、5 秒、10 秒,再看骨架屏和懒加载表现。加延迟不需要改业务代码,只在请求层包一层 setTimeout:
async function requestWithDelay(url, delay = 2000) { const res = await fetch(url); await new Promise((resolve) => setTimeout(resolve, delay)); return res.json(); }用这个函数替换正常请求入口,就能模拟弱网。重点观察三个地方:首屏骨架屏是否正常、主图是否撑住占位、底部栏是否被内容顶开。如果弱网下白屏超过 1 秒,说明首屏依赖的网络请求太多,需要合并接口或把基础信息放到缓存。
6.2 WebView 与原生混合的验证组合
很多商品详情页实际跑在 App 的 WebView 里,页面一部分原生一部分 H5。验证结构时,我习惯在页面 URL 上加调试参数,例如?env=test&mock=1,让页面在测试环境与线上环境之间自由切换。同时要检查 WebView 缓存策略:详情页数据变化频繁,接口返回要带no-cache;静态资源又应该走长缓存,两种资源不能混用一个请求头。这个点经常被搞反,页面 HTML 设成长期缓存,结果商品下架后用户还能看到旧页面。
6.3 收尾:我给新手的一条习惯
我现在做任何详情页,都会先保留一份“最小闭环”模板:一次接口请求、一个商品、三个规格、两个 SKU,跑通选规格、加购、价格联动后再开始堆视觉。像素级还原最多占一半工作量,剩下的是数据边界、接口约定和状态管理。多留一点时间给弱网和缓存验证,比反复调一个像素更值。希望帮到你。
本文还有配套的精品资源,点击获取