
简介“仿京东的商品展示详情页面”是一份面向前端初学者与商城项目开发者的实战型页面源码可用于学习电商商品详情页的布局、样式与交互实现并快速集成到个人商城项目中做个性化修改。压缩包共44个文件约398KB以31个png、5个gif、2个jpg等图片素材为主配套2个JavaScript脚本、1个HTML页面、1个CSS样式表和说明文件便于直接查看页面结构与资源组织方式。该资源已有4707人学习下载具备一定参考热度。读者可从中了解商品主图轮播、规格选择、价格与库存联动、评价列表展示、购物车按钮交互及响应式布局等常见模块的实现思路也能借鉴jQuery插件与图片懒加载的用法适合作为前端页面练习与课程设计参考。1. 一屏商详页的交付现场这份资源替你省掉了哪些活上周有人问我接一个在微信里打开的商品详情页私活预算不高、工期五天能不能不用框架做完。这个需求太典型了前端页面就一屏但轮播、规格联动、加购动画、长图懒加载一项都不能少缺一个就显得假。这份仿头部电商平台的商品详情页资源做的就是把这屏从骨架到交互完整搭一遍纯前端实现拿到手能直接跑起来看效果。它适合三类人刚学完 CSS 想找个像样页面练手的新手、接了商详页外包要快速交付的开发者、以及想搞清楚 SKU 联动这类经典交互到底该怎么写的老手。下面按目录结构、核心模块、跑起来的命令、容易出错的地方依次拆开。2. 技术选型与目录结构为什么不上框架一套静态包怎么撑住整页交互拿到一份前端资源我第一件事不是跑而是先看package.json和目录树——依赖列表基本能暴露作者的取舍。这套商详页的选型很克制运行时不依赖任何框架构建层只挂了 Vite样式用原生 CSS 加变量交互全部由几个几十行的 ES module 各自负责。这个组合不是为了炫技是把「改起来成本低」放在第一位。2.1 技术栈判断路由、状态、首屏三笔账要不要上框架我一向看三个数页面有几个路由、状态要跨几层、首屏 JS 的预算是多少。商详页只有一个路由状态也就三份——当前选中的规格组合、购物车角标数字、轮播当前索引全都是单页内的局部状态没有任何跨页共享。这种情况下框架带来的组件复用和响应式更新收益很小反而要额外背一份运行时体积商详页对首屏时间又特别敏感账算下来不划算。但「不用框架」不等于「裸写一个 HTML 文件」。常见的失控写法是把三千行写进一个 index.htmlCSS 和 JS 全内联改一个颜色要全局搜。这套资源保留了工程化的底线Vite 负责开发服务器和打包ES module 把轮播、规格、加购、吸顶、懒加载拆成独立文件每个文件对外只暴露一个函数main.js负责装配。CSS 统一用g-前缀加类 BEM 命名避免和后续接入的第三方样式撞车。补充一句公道话如果这个页面之后还要接商品列表、购物车、订单这些页面那 Vue3 加组件库会更划算这套资源的价值就退化成一份视觉稿和高保真结构参考。选原生图的就是「打开就能读懂每一行、改起来不用先理解一套框架约定」。2.2 目录结构每个文件到底管什么detail-page/ ├─ index.html # 唯一入口页面骨架全在这 ├─ vite.config.js # 开发服务器 / 代理 / 构建配置 ├─ package.json └─ src/ ├─ main.js # 装配层取数据 → 渲染 → 绑事件 ├─ styles/ │ ├─ reset.css # 清默认样式与移动端点击高亮 │ ├─ detail.css # 骨架、区块间距、底部固定栏 │ └─ sku.css # 规格弹层与选中态 ├─ modules/ │ ├─ banner.js # 轮播触摸滑动 自动播放 首尾循环 │ ├─ sku.js # 规格联动与可选性判定 │ ├─ cart.js # 加购动画与角标同步 │ ├─ sticky.js # 吸顶导航与锚点高亮 │ └─ lazy.js # 详情长图懒加载 ├─ utils/ │ ├─ dom.js # $ / $$ / 事件委托封装 │ └─ rem.js # 视口适配 └─ mock/ └─ detail.json # 一份完整商品数据字段与真实接口对齐文件职责改动频率main.js装配顺序、数据入口中接真实接口时必改modules/sku.js规格可选性算法低逻辑稳定modules/banner.js轮播手势与循环低styles/detail.css主题色、间距高换皮必改mock/detail.json商品数据高换成你自己的这张表的意义在于接手一个陌生前端包先判断「我要改的东西落在哪一层」。换数据只动mock换皮只动styles加交互才动modules——三层的边界清楚才不会改一处崩三处。2.3 跑起来三条命令和一份代理配置# 安装依赖只有 vite 一个开发依赖 npm install # 本地起服务默认 http://localhost:5173 npm run dev # 真机调试把服务暴露到局域网手机连同一个 WiFi 访问终端打印的 IP npm run dev -- --host # 打包并本地预览产物验证 base 配的相对路径是否生效 npm run build npm run preview--host这个参数值得单独说。移动端页面在桌面浏览器里缩到 375px 看很多问题根本暴露不出来——100vh的差异、安全区、触摸手势的方向锁全要真机才现形。加了--host手机浏览器直接开终端打印的那个内网地址就行热更新一样生效。联调阶段还需要一份代理配置把/api转发到后端这样前端代码里写的请求路径不用跟着环境变// vite.config.js import { defineConfig } from vite export default defineConfig({ // 产物用相对路径dist 丢到任意子目录都能打开 base: ./, server: { host: 0.0.0.0, // 真机调试时手机可通过局域网 IP 访问 port: 5173, proxy: { // 前端一律请求 /api/xxx避免跨域也避免在代码里写死后端地址 /api: { target: http://127.0.0.1:8080, changeOrigin: true } } }, build: { outDir: dist, // 8KB 以下的小图直接内联成 base64少几次请求 assetsInlineLimit: 8192 } })base: ./是血泪经验默认打包出来是/assets/xxx.js这种绝对路径一旦部署到二级目录比如/activity/detail/就整页白屏。改相对路径后丢哪里都能跑。changeOrigin: true会改写请求头的 Host后端做域名校验时必须开。3. 骨架与适配从头部到详情长图七个区块怎么分层落地商详页的结构其实是高度标准化的难的不是排版而是让「滚动顺序、锚点导航、懒加载触发区」三者对齐。这套资源把页面切成七个区块每个区块给一个id导航高亮、懒加载、滚动定位全都复用这套id改结构时只需要动一处。3.1 HTML 骨架区块顺序就是用户浏览顺序!-- index.html 骨架区块顺序 浏览顺序 锚点顺序 -- main classpage header classg-header idsec-top导航栏 分享 / 返回/header !-- 1. 轮播图首屏视觉重心 -- section classg-banner idsec-banner div classbanner-track/div div classbanner-dots/div /section !-- 2. 价格区现价 / 原价 / 促销标签 -- section classg-price idsec-price/section !-- 3. 规格入口默认只显示已选摘要点击才起弹层 -- section classg-sku-entry idsec-sku/section !-- 4. 评价摘要只放两三条完整评价另起弹层 -- section classg-comment idsec-comment/section !-- 5. 图文详情长图区懒加载重点 -- section classg-detail idsec-detail/section !-- 6. 底部固定操作栏客服 / 购物车 / 加入购物车 / 立即购买 -- footer classg-bar button classbtn-cart idbtnAddCart加入购物车/button /footer /main这套骨架的关键在于把「视觉模块」和「滚动锚点」用同一批id绑死。轮播、价格区、规格入口各有自己的id吸顶导航的数据源直接读这几个id不用再维护一份映射表。代价是改结构时id不能随便改名——这是我踩过的坑改完发现导航不高亮了排查半天才想起来。3.2 视口适配vw 换算 rem1px 边框单独处理移动端适配这套资源用的是「750 设计稿 1rem 100px」的经典方案好处是拿到设计稿后数值除以 100 就能直接用心算得过来。// utils/rem.js —— 以 750 设计稿为基准1rem 100px 设计稿尺寸 const DESIGN_WIDTH 750 const BASE 100 function setRootFontSize () { // 用 clientWidth 而不是 innerWidth避开滚动条宽度带来的误差 // 上限锁死 750防止平板 / 折叠屏上字号被放大到离谱 const w Math.min(document.documentElement.clientWidth, DESIGN_WIDTH) document.documentElement.style.fontSize (w / DESIGN_WIDTH) * BASE px } setRootFontSize() // 只在横竖屏切换这类尺寸跳变时重算滚动过程中不触发避免抖动 window.addEventListener(orientationchange, () { setTimeout(setRootFontSize, 300) })这里有两个参数值得说明。DESIGN_WIDTH必须和设计稿宽度一致改成 640 或 375 后所有 rem 数值都要重算别只改一半。Math.min的上限锁死是关键不锁的话在 iPad 上1rem会算成 140px 以上整个页面像被放大镜照过。1px 边框是移动端的经典难题dpr2 或 3 的屏幕上border: 1px会渲染成 2 到 3 个物理像素看起来比设计稿粗一圈不少人会误以为是设计稿标注错了/* 用伪元素 scaleY1 个 CSS 像素缩放成 0.5 个物理像素级别 */ .hairline-bottom { position: relative; } .hairline-bottom::after { content: ; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: #e5e5e5; transform: scaleY(0.5); /* dpr3 的机型可以调成 0.33 */ transform-origin: 0 100%; /* 缩放基准贴底避免线飘到中间 */ }transform-origin这行容易漏。默认是元素中心scaleY之后那条线会浮到容器中间看着像多了一条分割线。3.3 轮播与吸顶导航两个必须自己写的交互轮播用的是「首尾各克隆一张」的循环方案滑到克隆图时瞬时跳回真图用户感知不到边界。核心难点在触摸手势纵向滚动和横向滑图要区分开否则用户上下滑页面时会误触下一张。// modules/banner.js —— 触摸滑动 自动播放 首尾无缝循环 export function createBanner (root, list, { autoPlay 3000 } {}) { const track root.querySelector(.banner-track) const dots root.querySelector(.banner-dots) const total list.length let index 0 let startX 0, startY 0 let locked null // x | y 方向锁锁定后本次手势不再改判 let animating false let timer null // 首尾各克隆一张滑到边界时不需要写一堆 if 判断 const loop [list[total - 1], ...list, list[0]] track.innerHTML loop.map(src div classbanner-itemimg src${src} alt/div).join() track.style.width ${loop.length * 100}% dots.innerHTML list.map((_, i) i class${i 0 ? on : }/i).join() function moveTo (i, animate true) { track.style.transition animate ? transform .3s ease-out : none // 位移百分比相对 track 自身宽度所以要除以 item 总数 track.style.transform translate3d(${-(i 1) * (100 / loop.length)}%, 0, 0) dots.querySelectorAll(i).forEach((d, k) d.classList.toggle(on, k (i total) % total)) } root.addEventListener(touchstart, e { if (animating) return startX e.touches[0].clientX startY e.touches[0].clientY locked null clearInterval(timer) }, { passive: true }) root.addEventListener(touchmove, e { const dx e.touches[0].clientX - startX const dy e.touches[0].clientY - startY // 首次移动超过 8px 才判定方向太灵敏会把手抖当成滑图 if (!locked (Math.abs(dx) 8 || Math.abs(dy) 8)) { locked Math.abs(dx) Math.abs(dy) ? x : y } if (locked x) moveTo(index (dx 0 ? 1 : -1), false) }, { passive: true }) root.addEventListener(touchend, e { const dx e.changedTouches[0].clientX - startX if (locked x Math.abs(dx) 40) { // 40px 阈值低于它视为误触 index dx 0 ? 1 : -1 } animating true moveTo(index) startAuto() }) // 滑到克隆图后瞬时跳回真图transitionend 里做位次校正 track.addEventListener(transitionend, () { animating false if (index 0) { index total - 1; moveTo(index, false); void track.offsetWidth } if (index total - 1) { index 0; moveTo(index, false); void track.offsetWidth } }) function startAuto () { clearInterval(timer) if (total 1) return timer setInterval(() { index 1; moveTo(index) }, autoPlay) } moveTo(0, false) startAuto() }locked这个方向锁是整套轮播里最关键的十来行。没有它用户在轮播区上下滑页面时touchmove里的横向位移判断会误触发翻页。40的滑动阈值和8的方向判定阈值都跟手感直接相关太大显得迟钝太小容易误触。void track.offsetWidth这一句是强制重排让浏览器立刻应用transition: none的位置否则连续两次改样式会被合并成一次跳转动画就出来了。吸顶导航用的是IntersectionObserver不用监听scroll自己算偏移省掉了节流那一套// modules/sticky.js —— 滚动时高亮当前区块对应的锚点 export function initSticky (navSelector, sectionSelector) { const navItems [...document.querySelectorAll(navSelector)] const sections [...document.querySelectorAll(sectionSelector)] const io new IntersectionObserver(entries { entries.forEach(entry { if (!entry.isIntersecting) return const id entry.target.id navItems.forEach(el el.classList.toggle(on, el.dataset.target id)) }) }, { // 顶部扣掉吸顶导航高度底部收掉 70%让「刚露头」的区块才算当前 rootMargin: -44px 0px -70% 0px, threshold: 0 }) sections.forEach(s io.observe(s)) }rootMargin的四个值顺序是「上右下左」-44px对应吸顶导航的高度。这个值改错了导航高亮会永远慢一个区块看着像卡住了。4. SKU 联动与加购链路规格选择这块最容易写乱的地方商详页里最容易写崩的不是样式是规格选择。用户点了「冰川白」哪些版本还能选、哪些要灰掉、主图和价格跟着怎么变——这套联动如果一开始数据结构没设计好后面会陷入无穷的补丁。4.1 数据结构把组合摊平成数组别用嵌套树{ goodsId: G10086, specs: [ { name: 颜色, values: [曜石黑, 冰川白, 暮光紫] }, { name: 版本, values: [8128, 12256] } ], skus: [ { id: S1, attrs: { 颜色: 曜石黑, 版本: 8128 }, price: 2999, stock: 12, img: s1.jpg }, { id: S2, attrs: { 颜色: 曜石黑, 版本: 12256 }, price: 3499, stock: 0, img: s2.jpg }, { id: S3, attrs: { 颜色: 冰川白, 版本: 8128 }, price: 2999, stock: 7, img: s3.jpg }, { id: S4, attrs: { 颜色: 冰川白, 版本: 12256 }, price: 3499, stock: 3, img: s4.jpg } ], detailImgs: [d1.webp, d2.webp] }有人喜欢把 SKU 组织成「颜色 → 版本 → 具体货品」的嵌套树写起来直观但一旦要判断「跨组组合是否可用」就得递归遍历而且规格组增减时整棵树要重建。摊平成skus数组后任何判定都退化成一次数组遍历加一个规格组只需要往specs里塞一项判定逻辑一行不用改。specs和skus是两份数据前者管展示顺序和可选项后者管价格库存。这两份数据的同步维护是后端的事前端不要自作聪明去从skus反推specs——反推出来的顺序是乱的。4.2 可选性判定一个函数解决灰化// modules/sku.js /** * 判断某个候选项在当前已选条件下是否可点 * param {Array} skus 扁平的 SKU 列表 * param {Object} selected 当前已选如 { 颜色: 曜石黑 } * param {Object} candidate 待判定的候选如 { name: 版本, value: 12256 } * returns {boolean} */ export function isSelectable (skus, selected, candidate) { // 判定某一组时先把它自己那一组的旧选择摘掉 // 否则「从曜石黑切到冰川白」这条路径永远算不出来 const rest { ...selected } delete rest[candidate.name] const merged { ...rest, [candidate.name]: candidate.value } return skus.some(sku { if (sku.stock 0) return false // 无货直接排除 return Object.entries(merged).every(([k, v]) sku.attrs[k] v) }) }delete rest[candidate.name]这一行是整个函数的核心。刚开始写的时候没删结果选中「曜石黑 8128」之后去点「12256」永远灰着——因为判定时把「版本 12256」和已选的「版本 8128」叠在一起了没有任何一个 SKU 能同时满足自然全灰。这个现象很容易被误判成数据问题实际是判定条件写错了。复杂度是O(规格组数 × SKU 数)一个商品几十个 SKU 的规模下完全够用不需要做索引优化。但如果你的场景是几百个 SKU 的多规格商品比如家具定制可以提前把每个组的可选值集合缓存起来选中变化时只重算受影响的那几组。选中之后统一走一次渲染主图、价格、库存文案、按钮可用性全从命中的那个 SKU 里取不要在点击事件里零散地改 DOM// main.js 片段选中态变化后统一重渲染 function onSelect (group, value) { selected[group] selected[group] value ? undefined : value // 再点一次取消 if (!selected[group]) delete selected[group] const hit skus.find(sku Object.entries(selected).every(([k, v]) sku.attrs[k] v)) // hit 为空说明组合不存在保持上一次的价格展示同时禁用加购 document.querySelector(.g-price .now).textContent hit ? ¥${hit.price} : ¥-- document.querySelector(.g-banner img).src hit ? hit.img : skus[0].img document.getElementById(btnAddCart).disabled !hit || hit.stock 0 renderSkuPanel() // 重算所有候选项的灰化状态 }selected[group] value ? undefined : value这个三元是「再点一次取消选择」的常见交互。注意取消之后要delete掉这个 key留一个undefined值会让Object.entries多出一条空记录判定时又全灰。4.3 加购动画抛物线的起点和终点怎么算加购的小球动画看着花哨其实就是一个二次贝塞尔插值难点全在坐标系。// modules/cart.js —— 小球从按钮飞向底部购物车角标 export function flyToCart (startEl, endEl, onArrive) { const start startEl.getBoundingClientRect() const end endEl.getBoundingClientRect() const ball document.createElement(i) ball.className cart-ball // 挂到 body 直接子级用视口坐标定位 // 父级一旦有 transformfixed 会被降级成相对该父级定位球就飞歪了 ball.style.cssText position:fixed;left:${start.left start.width / 2}px; top:${start.top}px;width:12px;height:12px;z-index:9999 document.body.appendChild(ball) // 二次贝塞尔起点 → 控制点抬高一点形成抛物感→ 终点 const ctrl { x: (start.left end.left) / 2 - 60, y: Math.min(start.top, end.top) - 120 } const dur 600 const t0 performance.now() function frame (now) { const t Math.min((now - t0) / dur, 1) const mt 1 - t const x mt * mt * start.left 2 * mt * t * ctrl.x t * t * end.left const y mt * mt * start.top 2 * mt * t * ctrl.y t * t * end.top ball.style.transform translate3d(${x - start.left}px, ${y - start.top}px, 0) if (t 1) return requestAnimationFrame(frame) ball.remove() onArrive onArrive() // 角标 1 并弹一下 } requestAnimationFrame(frame) }三个参数决定观感dur 600是总时长低于 400 毫秒看不清轨迹高于 800 毫秒用户会觉得卡ctrl.x减 60 是把控制点往左推让轨迹带一点侧向弧度而不是纯粹直上直下ctrl.y取两个元素的较高者再减 120保证控制点永远在起点上方抛物线才是拱起来的。小球用transform位移而不是直接改left/top是因为transform走合成层不触发重排600 毫秒里要跑三十多帧改left/top在中低端安卓上会掉帧。5. 排查清单五个让我返工的坑从白屏到飞歪的小球5.1 底部操作栏被系统栏盖住现象iPhone 上底部「加入购物车」按钮被地址栏遮掉一半要往上滑一下才露出来安卓机上又正常。原因页面容器用了height: 100vh撑满。移动端浏览器的100vh是「含工具栏」的视口高度跟实际可视区对不上差值能到 100px 以上。底部栏本身是position: fixed; bottom: 0又叠了一层安全区。解决容器改用100dvh底部栏补安全区内边距。老机型不支持dvh的话用 JS 读visualViewport.height写 CSS 变量兜底。.page { min-height: 100dvh; } .g-bar { position: fixed; left: 0; right: 0; bottom: 0; /* 安全区iPhone 全面屏底部那条横条的高度 */ padding-bottom: calc(8px env(safe-area-inset-bottom)); }5.2 轮播快速滑动后跳回第一张现象连着甩几下图会闪一下回到首图再滑走慢速滑完全正常。原因首尾克隆的循环方案靠transitionend把克隆位瞬时跳回真位。快速滑动时上一次的transitionend还没触发就被新的滑动覆盖位次和实际位移错开下一次校正就直接跳回首图。解决加一个animating锁动画期间忽略新的touchstarttransitionend里做位次校正时用transition: none瞬移再void track.offsetWidth强制重排让浏览器立刻应用新位置。track.addEventListener(transitionend, () { animating false // 解锁允许下一次手势 if (index 0) { index total - 1; moveTo(index, false); void track.offsetWidth } if (index total - 1) { index 0; moveTo(index, false); void track.offsetWidth } })5.3 换规格后主图不跟着变现象点了「冰川白」价格变了主图还是上一张黑色的。原因主图渲染写在了初始化里只在页面加载时跑一次选中态又是用数组下标存的selected[0] 1规格组顺序一调整下标和值就对不上了。解决选中态存「组名 → 值」的映射{ 颜色: 冰川白 }每次选中后统一走一次render()主图、价格、库存文案全部从命中的 SKU 里取不在点击事件里零散改 DOM。5.4 详情长图让页面白屏好几秒现象滑到图文详情区整屏空白等一两秒图才出来低端安卓上还会整页闪一下。原因详情区十几张 750×2000 的长图一次性全加载加起来几十兆。图片也没压缩容器没给固定高宽比图片加载完页面高度突变滚动位置就飞了。解决占位加IntersectionObserver懒加载图片统一转 WebP 并限制宽度不超过 750容器用aspect-ratio先占好位。// modules/lazy.js —— 详情长图懒加载 const io new IntersectionObserver(entries { entries.forEach(entry { if (!entry.isIntersecting) return const img entry.target img.src img.dataset.src // 真实地址放>/* styles/detail.css 顶部 */ :root { --brand: #e93b3d; /* 主色按钮和价格都用它 */ --brand-light: #fff1f0; /* 主色浅底促销标签背景 */ --radius: 8px; --gap: 12px; } .g-price .now { color: var(--brand); } .btn-cart { background: var(--brand); border-radius p a hrefhttps://download.csdn.net/download/qq_37517574/10121689 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p