简介:一份面向前端初学者与电商项目开发者的仿京东商品详情页静态页面实战包,聚焦商品主图轮播、价格与规格联动、用户评价列表、购物车入口等典型模块,帮助读者理解商城详情页从结构搭建到交互实现的关键环节。压缩包共44个文件、约398KB,以31个PNG及少量GIF、JPG商品图和图标素材为主,另含1个HTML页面、1个CSS样式表与2个JavaScript文件(jQuery库及特性检测脚本),素材与代码分离,便于直接替换商品图或改造布局。目前已有4705人学习下载。解压后可在HBuilder等IDE中打开运行,适合作为课程练习、毕业设计原型或个人商城项目的基础模板;页面涉及响应式布局、图片懒加载、规格切换与价格计算等知识点,读者可据此拆解DOM结构、复用样式组织方式,并在此基础上接入Vue或React等框架继续扩展。
1. 商品详情页前端最容易被跳过的一步:把“能看”做成“能用”
商品详情页前端是很多人练手的第一个完整页面,也是最容易做成“截图级还原”的页面:静态图看着没问题,鼠标一上去放大镜糊成马赛克,规格点完价格不更新,手机端点加购没反应。这套方案要解决的不是“画得像”,而是把详情页拆成可交互、可验收的前端工程:主图切换与放大镜、SKU 规格联动、价格与数量计算、加购按钮状态、移动端适配。适合两类人:课程设计或毕设选题需要一份完整前端页面的学生,以及想补 DOM 事件与状态计算基本功的入门开发者。下面按结构、交互、样式、排查、验收的顺序推,每一章都落到能直接改的参数和能复现的代码上。
2. 页面结构拆解:六个区块与原生三件套的选型理由
先把页面拆成区块,再决定用什么写。详情页的信息密度高,但层级是固定的,拆错一次后面所有交互都会挤在一个文件里。我一般先画一张区块图,把首屏拆成六块:顶部通栏负责 logo、搜索框、购物车入口;面包屑回答“我在哪”;商品主区是核心,左边画廊右边购买面板;服务条放发货、运费、售后说明;详情与评价用 Tab 收起长内容;推荐楼层放在页面底部,用横向滚动或四列网格。六块里只有商品主区需要复杂交互,其余是静态结构和轻交互,先把边界划清楚,后面写 JS 就不会全堆在index.html的script标签里。
2.1 首屏骨架:主图区与购买面板的职责边界
主图区只负责“看图”:主图容器、遮罩层、放大镜大图容器、缩略图列表。购买面板只负责“选和买”:标题、价格、规格组、数量、加购按钮。两个区域通过一个共享的selected对象通信,主图区不关心价格,购买面板不关心图片坐标,这样放大镜写崩了也不会影响加购逻辑。
<!-- 商品主区:左侧画廊 + 右侧购买面板,职责分离 --> <main class="product"> <section class="gallery"> <div class="gallery-viewer"> <img id="mainImage" src="assets/images/p1.jpg" alt="商品主图"> <div class="gallery-mask" hidden></div> </div> <div class="gallery-zoom" hidden> <img id="zoomImage" src="assets/images/p1.jpg" alt=""> </div> <ul class="gallery-thumbs"> <li><img src="assets/images/p1.jpg" alt="主图 1">// main.js:模块只暴露 init,入口统一调度,避免脚本之间互相依赖 const modules = [Gallery, Sku, Cart]; document.addEventListener('DOMContentLoaded', () => { modules.forEach((mod) => { // 某个模块初始化失败时,其余模块仍然可以工作 try { mod.init?.(); } catch (err) { console.error(`[init fail] ${mod.name}`, err); } }); });modules数组就是页面的功能清单,新增一个交互就加一个模块,顺序按依赖排:画廊先初始化,SKU 再初始化,购物车最后读 SKU 的状态。try/catch不是摆设,放大镜模块因为拿不到 DOM 报错时,加购按钮仍然能点,排查时看控制台就知道是哪个模块挂了。模块之间不直接互相调用,共享状态放在一个store.js里,规模小的时候直接用一个普通对象也行,但不要在每个模块里各自维护一份selected。
2.3 目录组织与开发顺序:先静态后交互,每步都能跑
目录按“样式、逻辑、数据、资源”四类分,不要按页面区块分文件夹,否则找一张图要翻三层。开发顺序也有讲究:先写 HTML 结构,再写tokens.css定颜色和间距,接着写page.css把布局撑起来,然后按画廊、SKU、购物车的顺序写 JS,最后做响应式和懒加载。每一步结束都刷新一次页面,确保当前步骤可运行,不要一口气写完再调,那样出错时不知道是哪一层的问题。
goods-detail/ ├── index.html ├── css/ │ ├── reset.css # 统一默认样式,清掉 margin 和列表符号 │ ├── tokens.css # 颜色、字号、间距变量,改主题只动这里 │ └── page.css # 布局与组件样式 ├── js/ │ ├── mock.js # SKU、图片、评价等静态数据 │ ├── store.js # 共享的 selected 与数量状态 │ ├── gallery.js # 主图切换与放大镜 │ ├── sku.js # 规格选择与价格计算 │ ├── cart.js # 数量加减与加购状态 │ └── main.js # 统一初始化入口 └── assets/ └── images/mock.js单独放数据,答辩时想换商品,只改这个文件里的标题、价格和 SKU 列表,页面结构不用动。store.js只放状态和读写方法,不放 DOM 操作,这样在控制台里能直接打印当前选中了什么规格,排查状态问题时省很多事。图片目录按p1.jpg、p2.jpg命名,缩略图和大图共用一套源文件,避免两套图对不上。
3. 核心交互实现:放大镜、SKU 联动与加购状态机
详情页的交互看起来多,真正需要写逻辑的只有三处:鼠标在主图上移动时的坐标映射、规格选择后的可点判断、价格和按钮的状态切换。这三处写稳了,页面就从“静态排版”变成“可操作的前端页面”。下面每段代码都按“结构、计算、边界”三层拆,参数放在代码上方说明,改的时候知道改哪个数。
3.1 主图放大镜:坐标映射与边界钳制
放大镜的本质是坐标映射,不是 CSS 特效。结构三件套:小图容器、遮罩层、右侧大图容器。小图容器设position: relative,遮罩绝对定位;大图容器设overflow: hidden,内部图片的宽度设为小图宽度乘以放大倍数。鼠标移动时,先算鼠标相对小图容器的坐标,再让遮罩中心跟随鼠标,最后把遮罩位移乘以放大倍数、取反,作用到大图上。大图容器一般放在主图右侧,宽度等于小图宽度,高度等于小图高度,这样视觉上不会把购买面板挤走。
// gallery.js:放大镜核心逻辑 const viewer = document.querySelector('.gallery-viewer'); const mask = viewer.querySelector('.gallery-mask'); const zoomImg = document.querySelector('.gallery-zoom img'); // 放大倍数:大图容器宽度 / 小图容器宽度,一般取 2~3 const ZOOM = 2.5; viewer.addEventListener('mousemove', (e) => { const rect = viewer.getBoundingClientRect(); // 鼠标相对小图容器的坐标,clientX 不受页面滚动影响 const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 遮罩一半宽高,用于把鼠标放在遮罩中心 const halfW = mask.offsetWidth / 2; const halfH = mask.offsetHeight / 2; // 边界钳制:遮罩不能超出小图容器 const left = Math.min(Math.max(x - halfW, 0), rect.width - mask.offsetWidth); const top = Math.min(Math.max(y - halfH, 0), rect.height - mask.offsetHeight); mask.style.left = `${left}px`; mask.style.top = `${top}px`; // 大图位移 = 遮罩位移 × 放大倍数,方向相反 zoomImg.style.transform = `translate(${-left * ZOOM}px, ${-top * ZOOM}px)`; }); viewer.addEventListener('mouseenter', () => { mask.hidden = false; zoomImg.parentElement.hidden = false; }); viewer.addEventListener('mouseleave', () => { mask.hidden = true; zoomImg.parentElement.hidden = true; });ZOOM取 2.5 时,遮罩宽高应该设成小图宽高的四成左右,这样大图刚好填满容器,不会露出空白。rect.width用的是getBoundingClientRect(),页面滚动后坐标仍然正确,换成offsetLeft就要额外处理滚动偏移。viewer不能加 padding,否则鼠标坐标的基准会和图片边缘错开,遮罩看起来永远偏一截。切换缩略图时,除了改mainImage.src,还要把zoomImage.src改成同一张图,并把zoomImg.style.transform重置为translate(0, 0),否则上一张图的位移会残留。
3.2 SKU 规格选择:先判断“还能不能点”,再谈选中态
规格选择的难点不在样式,在“哪些值还能点”。不能因为某个颜色没货就把颜色按钮全禁掉,因为它可能和另一个尺码组合有货。正确做法是:每选一个值,就把它代入当前选择,看看是否存在stock > 0的完整组合,存在就允许点。数据结构用扁平数组,每个 SKU 是一条可售组合,字段名和 HTML 上的>// mock.js:每个 SKU 是一条可售组合,stock 为 0 表示不可售 const skuList = [ { id: 'S1', color: '深空黑', size: '128G', price: 4299, stock: 12 }, { id: 'S2', color: '深空黑', size: '256G', price: 4799, stock: 0 }, { id: 'S3', color: '月光白', size: '128G', price: 4299, stock: 5 }, { id: 'S4', color: '月光白', size: '256G', price: 4799, stock: 8 }, ]; // store.js:只存已选中的规格,初始为空 const selected = {}; // 找出完全匹配当前选择的 SKU,未选全时返回 undefined function findSku() { return skuList.find((sku) => Object.keys(selected).every((key) => selected[key] === sku[key]) ); } // 判断某个规格值是否可点:把它代入当前选择,看有没有有货组合 function isOptionEnabled(group, value) { const draft = { ...selected, [group]: value }; return skuList.some((sku) => sku.stock > 0 && Object.keys(draft).every((key) => draft[key] === sku[key]) ); }
findSku()里的Object.keys(selected).every只比较已经选中的字段,所以选了一个规格时也能找到对应的价格区间,不会直接返回undefined。isOptionEnabled()是渲染按钮时调用的,对每个值单独判断,禁用态按钮加disabled和灰色样式,但仍然渲染出来,让用户知道有这个选项、只是当前组合不可售。点击按钮时先写selected[group] = value,再重渲染所有规格组和价格,不要只改当前按钮的样式,否则其他组的禁用态不会跟着变。
3.3 价格、数量与加购按钮:用一个状态函数收口
价格和按钮最容易出现“各改各的”问题:选规格时改一次按钮文字,数量变化时又改一次,最后出现“规格已选但按钮还写着请选择规格”。解决办法是只留一个updateBuyBar(),任何状态变化都调它,里面按“未选全、选全但无货、选全有货”三种情况分别设置按钮文案和禁用态。价格同理,选全显示具体价,没选全显示区间价。
// cart.js:所有状态变化都走这一个函数,避免按钮文案不同步 const buyBtn = document.getElementById('buyBtn'); const priceText = document.getElementById('priceText'); const qtyInput = document.getElementById('qtyInput'); const groups = ['color', 'size']; // 规格组数量,和 HTML 上的>/* tokens.css:颜色和间距只在这里定义 */ :root { --color-primary: #d94a3d; /* 主色,用于加购按钮和选中态 */ --color-price: #c8342a; /* 价格色,比主色深一档 */ --color-text: #1f2329; /* 正文 */ --color-text-weak: #6b7280; /* 辅助说明 */ --color-border: #e5e7eb; /* 分割线和边框 */ --color-bg: #f5f6f7; /* 页面底色 */ --font-xs: 12px; --font-sm: 14px; --font-md: 16px; --font-lg: 24px; --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --radius-sm: 4px; --radius-md: 8px; }价格色比主色深一档,是为了在白色背景上更醒目,但不要用纯红,纯红在灰色边框旁边会显得刺眼。间距只用--space-*这几档,不要在样式里随手写margin: 13px,后面做响应式时很难统一调整。字号三到四档足够,详情页正文用--font-sm,标题用--font-lg,辅助说明用--font-xs,层级拉开但不混乱。
4.2 栅格与断点:桌面两栏,移动端单列
桌面端用grid把商品主区分成两栏,左栏固定 400px 放画廊,右栏1fr放购买面板,中间留 24px。容器最大宽度 1200px,居中。屏幕宽度小于 960px 时改成单列,画廊在上、购买面板在下;小于 600px 时缩略图改成横向滚动,加购按钮吸底,避免用户选完规格还要往下找按钮。图片用aspect-ratio固定比例,加载过程中不会把下面的内容顶来顶去。
/* page.css:主区栅格与断点 */ .product { display: grid; grid-template-columns: 400px 1fr; gap: var(--space-6); max-width: 1200px; margin: 0 auto; padding: var(--space-4); } .gallery-viewer { position: relative; aspect-ratio: 1 / 1; /* 固定比例,图片加载时不抖动 */ overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); } .gallery-mask { position: absolute; width: 40%; height: 40%; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.6); pointer-events: none; /* 遮罩不拦截鼠标事件 */ } @media (max-width: 960px) { .product { grid-template-columns: 1fr; /* 单列,画廊在上 */ } } @media (max-width: 600px) { .gallery-thumbs { display: flex; overflow-x: auto; /* 缩略图横向滚动 */ gap: var(--space-2); } }.gallery-mask上的pointer-events: none很关键,遮罩跟着鼠标移动时如果拦截事件,mousemove会在遮罩和容器之间来回触发,表现就是放大镜一抖一抖。断点只设两个,960px 和 600px,再多会增加维护成本,课程设计里这两个足够覆盖笔记本、平板和手机。移动端加购按钮吸底用position: sticky; bottom: 0,不要用fixed,否则会盖住页面底部的推荐楼层。
4.3 骨架屏与图片懒加载:首屏不卡,长图不抢带宽
详情页的图片数量多,首屏主图、缩略图、详情长图加起来十几张。首屏主图不要懒加载,它是第一视觉;详情长图用>/* 骨架屏:加载中的占位块,不要用真实图片做模糊 */ .skeleton { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } @keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
// 详情长图懒加载:提前 200px 开始加载,避免用户滚到才闪出来 const io = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const img = entry.target; img.src = img.dataset.src; // 真实地址写在 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />