简介:“仿京东的商品展示详情页面”是一份面向前端初学者与商城项目开发者的实战型页面模板,聚焦商品详情页的布局还原与交互实现,可用于学习商品主图轮播、规格选择、价格与库存联动、评价列表展示、加入购物车反馈及响应式适配等常见模块。压缩包共44个文件,约398KB,以31个png、5个gif和2个jpg图片素材为主,辅以2个JavaScript脚本、1个HTML页面、1个CSS样式表及说明文件,整体轻量,解压后可在HBuilder等IDE中直接运行与二次修改。已有4707人学习,说明其在同类前端练习中具有一定参考价值。读者可借此观察商品详情页的目录组织与静态资源搭配,理解jQuery插件、轮播图与加载动画的接入方式,并在此基础上替换商品数据、调整样式或迁移到Vue、React等框架,积累从页面结构到交互细节的完整实践思路。
1. 仿京东的商品展示详情页面,真正难的是这套规格状态
仿京东的商品展示详情页面,接手的人第一反应往往是照着截图页面结构,觉得还原度就是全部工作量。真动手才发现视觉只占三成,剩下七成是那套规格选择:选了「深空黑」之后哪些容量还能点、没选满时价格怎么显示区间、库存为零的组合为什么是置灰而不是藏起来、加购按钮在什么状态下才允许点击。这套方案要解决的就是这件事——把详情页当成一个有数据契约、有状态机、有验收标准的前端工程来做,而不是一张会滚动的静态长图。适合拿它做毕业设计、课程设计,也适合作品集里需要一个能讲清楚「为什么这么做」的项目。
2. 页面结构拆解与技术选型:把一张详情页拆成七个模块
2.1 从首屏到推荐位:七个模块的职责与优先级
拿到设计稿不要急着写样式,先在纸上把页面切成块。电商详情页的模块划分几乎已经形成共识,切完大概是七块,每块的职责和加载优先级不一样。
| 模块 | 主要职责 | 是否首屏 | 数据依赖 |
|---|---|---|---|
| 顶部导航 | 返回、分享、购物车入口 | 是 | 无(购物车角标除外) |
| 主图区 | 轮播、缩略图切换、放大镜 | 是 | 商品图片数组 |
| 价格与促销 | 现价、划线价、优惠标签 | 是 | SKU 价格 |
| 规格选择 | 颜色 / 版本 / 套餐的联动选择 | 是 | 规格树 + 叶子 SKU |
| 购买动作区 | 数量步进器、加入购物车、立即购买 | 是 | 库存 |
| 详情与评价 | Tab 切换、图文详情、评论分页 | 否 | 详情富文本、评论分页接口 |
| 推荐位 | 猜你喜欢、横向滑动列表 | 否 | 推荐接口 |
首屏的范围要卡死:从导航到购买动作区,这五块必须在首屏渲染完成之前拿到数据,因为它们共享同一份 SKU 数据。详情富文本和评论、推荐位属于第二屏之后,可以延后请求。这个切分直接影响后面的接口设计——如果一开始把所有数据塞进一个大接口,首屏就会被富文本里的十几张大图拖死,用户盯着白屏等三秒,后面做得再好也救不回来。我一般的做法是把详情接口和推荐接口拆开,详情接口只返回首屏必需字段,富文本和评论走独立的懒请求。
优先级排完之后还要标一个东西:哪些模块在数据没回来时必须有占位。主图区、价格区、规格区这三个位置如果没有骨架屏,接口一慢页面就会跳一下,滚动位置全乱,这是最容易被忽略的体验问题。
2.2 技术选型:原生三件套、Vue 还是 React,按交付周期定
选型不是选最时髦的,是选你两周内能交出来的。三种常见路线各有适用场景,把它摊开对比一下就不纠结了。
| 方案 | 上手成本 | 状态管理 | 适合谁 | 主要风险 |
|---|---|---|---|---|
| 原生 HTML + CSS + JS | 低 | 手写,靠闭包和事件 | 课程设计、单页面交付 | 规格联动写多了会变成一团面条 |
| Vue 3 + Vite | 中 | ref / reactive / computed | 毕设、需要快速出效果 | 容易把逻辑全堆在模板里 |
| React + Vite | 中偏高 | useState / useMemo | 想要组件化讲清楚架构 | 渲染粒度控制不好会多余重渲染 |
这套页面的核心是「派生状态」——已选规格组合决定了价格、库存、按钮可用性,全都是算出来的,不需要额外存储。这种场景天然适合带 computed 的方案,因为把selected数组当唯一数据源,价格和库存全部从它推导,就不会出现「价格改了但按钮没跟着变」的脏状态。原生方案也能做,只是你得自己写一套发布订阅,纯属给自己加班。
如果目标是把项目讲清楚、顺便练工程化,我会选 Vue 3 + Vite,理由很实在:单文件组件让「模板 / 逻辑 / 样式」在一个文件里对齐,答辩的时候翻代码不用来回跳;Vite 的冷启动快,改一行立刻看到效果,调试规格联动的试错成本低。如果你要投的岗位明确写 React,那就用 React,别为了项目好看去换栈。
2.3 移动端适配与样式规范:rem/vw、1px 边框与安全区
详情页九成流量来自手机,适配方案必须一开始就定死,不能写到一半再回头改。常见三种:固定 375px 宽加缩放、rem 动态根字号、vw 直接换算。我倾向 vw,因为不需要运行时算根字号,写起来是纯 CSS,调试时也不会出现「改了 JS 但页面没反应」的情况。
关键点是三件事。第一,设计稿按 375px 出,1px在 CSS 里换成calc(100vw / 375)的比例,或者简单点用 PostCSS 插件自动换算,别手工算。第二,1px 边框在高清屏上会变成 2~3 个物理像素,视觉上偏粗,用 transform 缩放或者box-shadow模拟,比死磕border稳妥。第三,底部购买栏固定在屏幕底部时,一定要加安全区适配:
/* 底部购买栏:避开刘海屏底部的横条区域 */ .buy-bar { position: fixed; left: 0; right: 0; bottom: 0; height: 50px; padding-bottom: env(safe-area-inset-bottom); /* 关键,不加会被系统手势条挡住 */ background: #fff; z-index: 100; } /* 页面主体要留出购买栏的高度,否则最后一段内容永远看不全 */ .detail-page { padding-bottom: calc(50px + env(safe-area-inset-bottom)); }env(safe-area-inset-bottom)在不支持的浏览器里会退化成 0,不影响布局,属于安全的渐进增强。主体 padding 这一句很多人会漏,结果评论区最后一条永远被购买栏压住,验收的时候一眼就能看出来。
3. 工程架构与数据契约:组件树、SKU 数据结构和 Mock 接口
3.1 目录结构与组件职责边界
页面拆成七个模块,代码就得有七个组件对应,一一对齐最省心。目录按「页面 / 组件 / 逻辑 / 数据」四层分,不要按文件类型分(把所有 js 丢一个文件夹是新手最容易踩的坑)。
src/ ├── pages/ │ └── detail/ # 详情页容器,只负责取数和拼装 │ ├── index.vue │ └── skeleton.vue # 骨架屏,和真实结构一一对应 ├── components/ # 可复用展示组件,不含业务状态 │ ├── Gallery.vue # 主图轮播 + 放大镜 │ ├── PriceBlock.vue # 价格与促销标签 │ ├── SkuSelector.vue # 规格选择(业务最重的一块) │ ├── BuyBar.vue # 数量 + 加购 + 立即购买 │ └── CommentList.vue ├── composables/ # 可复用的状态逻辑 │ ├── useSku.js # 规格联动核心 │ └── useLazyImage.js # 图片懒加载 └── mock/ └── goods.json # 与后端约定的接口结构职责边界有一条硬规矩:SkuSelector只负责「渲染规格、抛出选择事件」,不负责算价格;价格的推导放在useSku里,由页面容器统一持有。这样做的直接好处是,价格区和购买栏拿的是同一份派生结果,不会出现价格显示 5999、加购时按 6499 下单的血泪事故。展示组件保持无状态之后,单独拿去别的页面复用也不用改。
3.2 SKU 数据契约:规格树、叶子 SKU 与库存字段
规格联动做不出来,八成是数据结构没设计好。真实电商的规格不是一个二维表,而是「规格维度」和「叶子 SKU」两张表:维度决定页面渲染几行按钮,叶子 SKU 决定每种组合价格多少、有没有货。前端只要拿到这两份数据,联动就变成了纯计算。
// 商品详情接口的核心结构,Mock 与线上字段保持一致 const goodsDetail = { goodsId: '100012043978', title: '示例商品标题', // 规格树:数组顺序 = 页面从上到下的渲染顺序 specTree: [ { specId: 'color', name: '颜色', values: [ { valueId: 'c1', text: '深空黑' }, { valueId: 'c2', text: '远峰蓝' } ]}, { specId: 'storage', name: '存储', values: [ { valueId: 's1', text: '128G' }, { valueId: 's2', text: '256G' } ]} ], // 叶子 SKU:每个规格组合一条,specPath 顺序与 specTree 严格一致 skus: [ { skuId: 'k1', specPath: ['c1', 's1'], price: 5999, originPrice: 6499, stock: 12, image: 'a.jpg' }, { skuId: 'k2', specPath: ['c1', 's2'], price: 6799, originPrice: 7299, stock: 0 }, { skuId: 'k3', specPath: ['c2', 's1'], price: 5999, originPrice: 6499, stock: 5 }, { skuId: 'k4', specPath: ['c2', 's2'], price: 6799, originPrice: 7299, stock: 8 } ] };三个字段的约束必须写进接口文档:specPath的数组下标要和specTree一一对应,否则联动算法一定错位;stock为 0 的组合也要返回,不能过滤掉,因为前端的置灰逻辑依赖「这个组合存在但没货」这个信息,直接删掉会导致整行按钮消失;image可选,有值时切规格要跟着切主图,没有就沿用第一张。这三条约定如果一开始没说清楚,等到联调才发现,改起来是两头受罪。
3.3 状态管理与 Mock 接口:selected 数组与派生计算
整个页面只有一份真状态:selected数组,长度等于规格维度数,未选的维度用undefined占位。这个「占位而不是缺失」的设计很关键——如果按已选长度动态变长,selected[1]在只选了颜色的时候就是undefined,判断逻辑会到处写if (selected.length > 1),最后变成一团乱麻。
// useSku.js:唯一数据源 + 派生结果 import { ref, computed } from 'vue'; export function useSku(goods) { // 长度固定,未选维度占位 undefined const selected = ref(new Array(goods.specTree.length).fill(undefined)); const skuIndex = new Map(goods.skus.map(s => [s.specPath.join('_'), s])); // 是否选满:决定价格显示区间还是确定值 const isComplete = computed(() => selected.value.every(v => v !== undefined)); // 当前命中的叶子 SKU,未选满时为 null const currentSku = computed(() => isComplete.value ? (skuIndex.get(selected.value.join('_')) || null) : null ); // 价格:未选满时给区间,选满给确定值 const priceInfo = computed(() => { if (currentSku.value) { return { type: 'fixed', price: currentSku.value.price }; } const prices = goods.skus.map(s => s.price); return { type: 'range', min: Math.min(...prices), max: Math.max(...prices) }; }); function pick(dimIndex, valueId) { // 再次点击已选中的值 = 取消选择,这是电商页面的通用交互 const next = selected.value.slice(); next[dimIndex] = next[dimIndex] === valueId ? undefined : valueId; selected.value = next; } return { selected, currentSku, isComplete, priceInfo, pick }; }skuIndex用Map而不是每次遍历数组,是为了让「选满后取价格」变成 O(1)。prices每次都重新 map 一遍看着有点浪费,但 SKU 数量一般在几十条量级,性能完全可以接受;真遇到上千条 SKU 的极端情况,再把这个区间缓存到初始化阶段就行,别提前优化。
Mock 阶段建议直接放一份goods.json,用本地静态服务跑起来,字段结构和正式接口一个字节都别差。等真接后端时只改请求路径,其余逻辑一行不动,这是最省事的做法。
4. 核心交互落地:SKU 规格联动、价格计算与主图放大镜
4.1 规格矩阵与可选性判定算法
规格联动的本质是一个判定问题:在当前已选状态下,某个规格值还能不能点。这里有个常见误区——很多人写的是「找出所有包含当前值的组合,如果都没货就置灰」。这个逻辑在只选了一个维度时是对的,选了三个维度以后就翻车:它会忽略其他已选维度的约束,导致「明明有货的组合却灰了」。
正确的判定是反向的:假设用户点了这个值,看是否存在至少一条叶子 SKU 同时满足「该维度等于这个值」「其他已选维度完全匹配」「库存大于 0」。
/** * 判断某个规格值在当前状态下是否可选 * @param {Array} skus 叶子 SKU 列表 * @param {Array} selected 已选值 id 数组,未选维度为 undefined * @param {number} dim 当前要判定的规格维度下标 * @param {string} valueId 当前要判定的规格值 id * @returns {boolean} */ function canPick(skus, selected, dim, valueId) { return skus.some(sku => { // 1. 这条 SKU 在当前维度上必须等于待判定值 if (sku.specPath[dim] !== valueId) return false; // 2. 其他维度:已选的必须完全匹配,未选的跳过 const matched = sku.specPath.every((pid, i) => { if (i === dim) return true; const chosen = selected[i]; return chosen === undefined || chosen === pid; }); // 3. 有货才算真的能点,库存为 0 的组合同样要置灰 return matched && sku.stock > 0; }); }这段代码的时间复杂度是 O(SKU 数量 × 维度数),几十条数据完全无感。它的价值在于语义清晰——「存在一条可行 SKU」这个判断和人在页面上的直觉是一致的,写完之后不需要再补一堆特判。
关于交互还有一条容易被忽略的规则:置灰的按钮不能直接disabled加pointer-events: none。用户其实想知道「为什么不能点」,所以要么保留点击但只弹一个提示,要么在按钮上标一个「缺货」小标。纯灰掉不给反馈是最容易被验收时挑出来的细节。
4.2 价格、库存与禁用态的联动
价格有三种显示形态,必须和规格选择状态严格对应,否则用户会觉得页面在乱跳。
第一种,一个都没选:显示价格区间5999 - 7299,划线价取区间上限的原价。第二种,选了一部分:仍然显示区间,但要把已经缩小的范围算出来——选完颜色后只剩两个存储版本,区间就该是这两个版本的价格范围,而不是全量。第三种,选满:显示确定价格,同时把库存、加购按钮可用性一起更新,库存为 0 时按钮文案直接变成「该规格暂时缺货」。
// 未选满时的动态区间:只统计与当前已选维度兼容的 SKU function computeRange(skus, selected) { const valid = skus.filter(sku => sku.specPath.every((pid, i) => selected[i] === undefined || selected[i] === pid) ); if (!valid.length) return { min: 0, max: 0 }; const prices = valid.map(s => s.price); return { min: Math.min(...prices), max: Math.max(...prices) }; }库存还有一个衍生需求:数量步进器的上限。它应该等于当前 SKU 的剩余库存,而不是固定写死 99。上限没跟着库存变,用户点加号能加到 200 件,提交时被后端打回,这就是典型的「前端看起来没问题、一联调全崩」。
购买按钮的可用性条件是三个同时成立:选满、命中 SKU、库存大于 0。这三个条件最好收敛成一个 computed,模板里只判断单一布尔值,不要写一长串&&,改起来容易漏。
4.3 主图放大镜与缩略图切换
放大镜是详情页里最容易做成「看着能用、细节全错」的模块。核心逻辑只有一句话:鼠标在主图上的相对位置,按比例映射到大图作为背景定位。
// 放大镜:主图尺寸 stage,放大倍数 SCALE,大图容器 bigW / bigH const SCALE = 2; function onMove(e) { const rect = stage.getBoundingClientRect(); // 用 clientX 减去 rect.left,自动兼容页面滚动和容器偏移 let x = e.clientX - rect.left; let y = e.clientY - rect.top; // 边界收敛:让放大框始终完整落在主图内,不贴边溢出 x = Math.min(Math.max(x, lensW / 2), rect.width - lensW / 2); y = Math.min(Math.max(y, lensH / 2), rect.height - lensH / 2); lens.style.transform = `translate(${x}px, ${y}px)`; // 背景图按 SCALE 放大,再把定位点拉回大图中心 big.style.backgroundPosition = `-${x * SCALE - bigW / 2}px -${y * SCALE - bigH / 2}px`; }两个参数要注意:SCALE取 2 到 2.5 之间视觉最自然,太大图像糊成马赛克;background-size必须同步设置成图片原始宽 * SCALE,否则背景定位算得再准也对不上。用clientX - rect.left而不是offsetX,是因为offsetX在带transform的父容器里返回值不受控,属于玄学 bug 的高发区。
移动端没有 hover,放大镜要换成「长按预览大图」或者点击进入全屏查看。缩略图切换则要处理一个细节:切换后第一件事是重置放大镜位置,不然上一次的transform还留在元素上,图片换了但放大框停在原处,看着像坏了。
4.4 首屏与图片加载:骨架屏和懒加载摆在哪一层
详情页动辄二三十张图,不做加载策略,3G 网络下首屏能白三秒。策略分两层:首屏用骨架屏顶住,第二屏之后用懒加载。
骨架屏的结构必须和真实内容严格对齐,主图区的骨架高度要和真实轮播图一致,规格区要画出对应数量的灰条。结构不对齐就会出现「骨架消失、内容撑开、页面整体往下跳」的抖动,比白屏还难受。做法上建议直接把骨架写成组件,和真实组件共用同一套尺寸变量。
懒加载用IntersectionObserver最省事,不需要监听 scroll:
// 图片先把真实地址存在 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />