简介:这是一份面向前端初学者与商城项目开发者的仿京东商品详情页实战模板。它围绕商品主图、价格与规格选择等核心模块,还原真实电商详情页的结构与交互,可用于课程练习、毕业设计取材,也可用于自有商城项目的二次改造。压缩包共44个文件,以31个PNG、5个GIF与2个JPG图片素材为主;另含2个JavaScript脚本(jQuery、Modernizr)、1个HTML页面与1个CSS样式表;整体约398KB,解压后即可在HBuilder等IDE中运行预览。目前已有4705人学习。页面覆盖轮播主图、规格切换、价格联动、购物车交互与用户评价列表等模块,并采用响应式布局适配手机与桌面端。读者可据此拆解DOM结构组织、事件绑定与样式分层写法,理解懒加载、过渡动画等细节优化手段,再按业务替换素材与数据结构,形成可复用的商品详情页骨架。
1. 商品详情页是转化率的正面战场:这个项目到底在做什么
打开任何一款电商 App,从列表页点进商品,落地的就是详情页。主图、缩略图、价格、促销标签、规格选择、数量步进器、加入购物车和立即购买,再加上底部那几张长图和参数表,这几块拼起来,决定了用户是下单还是退回去继续比价。仿京东的商品展示详情页面这个前端项目,干的就是把这块页面从一张截图还原成能点、能选、能实时算价的真实页面,而不是一张静态图。它适合两类人:一类是把 HTML/CSS 学完、缺一个完整页面练手的前端新手,拿它练页面拆解、状态管理和事件委托;另一类是手上有个小商城后台、需要一个能直接套上去的商品详情模板的开发者。技术栈不重,原生三件套就能跑起来,但里面的交互细节,够写好几天的坑。
2. 技术选型与工程结构:不套框架,先把 HTML/CSS/JS 的分层立住
2.1 为什么这类还原项目更适合原生三件套起手
先把这个决定说清楚,后面所有目录和代码才有依据。仿京东详情页这种项目,本质上是在做一件很纯粹的事:把一个已知视觉稿,拆成一堆块,再给其中五六个块挂上交互。它没有路由,没有跨页数据流,没有复杂的状态共享,最重的逻辑也就是 SKU 联动和放大镜坐标换算。这种规模上 Vue 或 React,收益不在页面本身,而在于组件复用和工程化。所以常见做法是:先用原生 HTML + CSS + JS 把页面跑通,把每块交互的数学关系摸清楚,再决定要不要迁移到框架。
我一般会劝人先写原生版本,理由很实在。第一,调试成本低。放大镜的坐标偏了,你在控制台点一下getBoundingClientRect()就能看到真实值,不用去猜框架的 ref 到底挂上没挂上。第二,样式层不会被 scoped 或者 CSS-in-JS 干扰,京东那种密集的信息排布,行高、字重、间距全靠手调,样式隔离反而添乱。第三,也是最重要的一条,原生版本会把「状态」这件事暴露得很直接。SKU 选中了哪个颜色、仓库里还剩几件、当前价格是多少,这些变量就明晃晃摆在全局作用域里,你写的时候会难受,难受完就明白为什么框架要搞响应式了。
当然,原生不等于随便写。工程结构要先立住,否则写到第三天就会发现index.html有八百行,main.js里全是互相调用的函数,改一个地方不知道会崩哪。这个项目我建议按职责切成四层:结构层是 HTML,只负责语义和容器,不写内联样式也不写 onclick;表现层是 CSS,按 reset、base、layout、detail 四个文件分,基础变量和布局骨架分开;行为层是 JS,一个交互一个文件,gallery、sku、counter、tab 各自独立,最后由 main.js 统一初始化;数据层单独抽一个 mock 目录,假数据放 JSON 里,将来接接口只改这一处。
2.2 目录拆法与文件职责
按上面四层思路落下来,目录大概长这样:
| 路径 | 职责 | 改动频率 |
|---|---|---|
index.html | 页面骨架、语义标签、容器 id | 低 |
css/reset.css | 清默认样式、盒模型统一 | 极低 |
css/base.css | 设计变量、字体、通用工具类 | 中 |
css/layout.css | 页面区块的位置与响应式断点 | 中 |
css/detail.css | 商品区、SKU、Tab 的组件样式 | 高 |
js/gallery.js | 主图切换、放大镜 | 中 |
js/sku.js | 规格渲染、可用性判断、价格联动 | 高 |
js/counter.js | 数量步进、合计计算 | 中 |
js/tab.js | 详情 Tab 的锚点滚动与吸顶 | 低 |
js/main.js | 入口,按依赖顺序初始化各模块 | 低 |
mock/sku-data.json | 规格组合与库存假数据 | 高 |
images/product/ | 主图与缩略图,命名按main-1、thumb-1对齐 | 低 |
这张表最值得留意的是「改动频率」那一列。detail.css和sku.js是热的,说明这个项目的复杂度几乎全压在商品信息区和规格选择上,把这两个文件写清楚,项目就成了一半。main.js只做一件事:等 DOM 就绪,按 gallery → sku → counter → tab 的顺序调各自的init()。
// js/main.js —— 入口只做装配,不写业务 document.addEventListener('DOMContentLoaded', () => { // 顺序有讲究:SKU 要先算完初始可用性,counter 才能拿到正确的单价 Gallery.init(); Sku.init(); Counter.init(); Tab.init(); });这段代码没什么技术含量,但顺序不能乱。Counter依赖Sku算出来的当前单价,如果先初始化 Counter,第一次渲染的合计金额就是错的,得等用户点一下规格才会刷新回来,这种时序 bug 很难在测试里发现,用户却能第一眼看到。参数上,用DOMContentLoaded而不是window.onload,是因为页面里图片多,onload要等所有图下载完,首屏交互会延迟一两秒才可用。
2.3 设计还原的基准参数
还原类项目最容易翻车的地方不是逻辑,是「看起来差不多但就是不对」。差在哪?字号、行高、颜色值。与其每处手写,不如一开始就把基准值抽成 CSS 变量,改主题只动一处。
/* css/base.css —— 全站设计变量 */ :root { --color-primary: #e1251b; /* 主红:价格、按钮、选中态统一用它 */ --color-text-main: #1a1a1a; /* 商品标题 */ --color-text-sub: #666; /* 副文案、库存说明 */ --color-text-weak: #999; /* 销量、时间缀 */ --color-line: #eee; /* 分割线 */ --color-bg-page: #f4f4f4; /* 页面底色,用来衬托白色卡片 */ --radius-card: 8rpx; /* 卡片圆角,移动端别超过 12 */ --gap-block: 12px; /* 卡片之间的呼吸间距 */ --font-price: 22px; /* 到手价字号,比标题还大 */ --font-title: 16px; --font-body: 14px; --page-max: 1200px; /* PC 端内容区最大宽度,居中 */ }参数怎么调:--color-primary换成别的色值,价格、按钮、选中边框会同步变,这是变量最大的价值。--page-max只影响 PC 端,移动端靠媒体查询把容器切成 100% 宽加 12px 内边距。--font-price之所以要比标题大,是因为电商详情页的信息优先级里,价格永远排第一,字号就是视觉权重的直接手段,这个细节偷懒了,整页的「像不像」立刻就露馅。
还有两条不成文的经验:一是所有间距尽量用偶数,12、16、24 这样排下来,视觉上会有节奏感,奇数间距堆多了页面会显得毛躁;二是图片容器一定要给aspect-ratio或者写死宽高,商品图尺寸参差不齐,不锁比例的话,图片一加载完页面就往下窜一截,用户手指已经按下去了,按钮跑了。
3. 骨架拆解:从顶部导航到购买栏,一个商品页的 DOM 怎么分层
3.1 主图区:stage、lens、big 的三层结构
主图区看着简单,其实拆开有三层东西叠在一起。最底下是stage,一个正方形或者 3:4 的容器,里面放主图img;stage内部再套一个lens,就是跟随鼠标移动的那个半透明镜头框;真正显示放大结果的是stage旁边的big,一个独立的方块,用background-image表现。
<!-- 图集:stage 显示主图,lens 是镜头框,big 是右侧放大结果 --> <div class="gallery"> <div class="gallery-stage" id="galleryStage"> <img id="galleryMain" src="./images/product/main-1.webp" alt="商品主图"> <div class="gallery-lens" id="galleryLens"></div> </div> <ul class="gallery-thumbs" id="galleryThumbs"> <li class="is-active"><img src="./images/product/thumb-1.webp" alt=""></li> <li><img src="./images/product/thumb-2.webp" alt=""></li> <li><img src="./images/product/thumb-3.webp" alt=""></li> </ul> <!-- 放大结果层,默认透明度 0,鼠标进入 stage 才显示 --> <div class="gallery-big" id="galleryBig"></div> </div>为什么要把镜头框和放大层分开,而不是像某些教程那样直接在主图上transform: scale?因为 scale 缩放的是整个img,视觉上图片会溢出容器、遮挡相邻元素,还得额外加overflow: hidden和transform-origin去修正中心点。分成两层之后,镜头框只负责「取景」,放大层只负责「呈现」,两者靠坐标换算关联,逻辑干净,也不会出现图片覆盖 DOM 的问题。
对应的 CSS 关键就三条:stage设position: relative和overflow: hidden;lens设position: absolute、固定宽高、pointer-events: none(不设的话鼠标一进去就被镜头框接走,mousemove直接断掉);big设一个和stage等高的尺寸,background-repeat: no-repeat。
3.2 右侧信息区:价格、促销、SKU、数量按钮的层级
信息区的结构比图集还密,但真正的坑在于「谁是容器、谁是渲染结果」。我的做法是:标题、价格这些静态内容直接写死在 HTML 里,SKU 规格按钮全部由 JS 渲染,HTML 只留一个带><div class="info"> <h1 class="info-title" id="productTitle">商品标题占位</h1> <div class="info-price"> <span class="info-price__label">到手价</span> <em class="info-price__now" id="priceNow">¥1299.00</em> <del class="info-price__origin" id="priceOrigin">¥1699.00</del> </div> <!-- SKU 容器:JS 按><!-- 占位元素:吸顶后 Tab 脱离文档流,靠它撑住高度,避免下方内容上跳 --> <div id="tabsPlaceholder"></div> <nav class="detail-tabs" id="detailTabs"> <a href="#tab-intro" class="is-active">商品介绍</a> <a href="#tab-params">规格参数</a> <a href="#tab-service">售后保障</a> </nav> <section id="tab-intro" class="detail-section"><!-- 长图 --></section> <section id="tab-params" class="detail-section"><!-- 参数表 --></section> <section id="tab-service" class="detail-section"><!-- 服务说明 --></section>
吸顶的实现有两条路。一条是position: sticky,CSS 一行搞定,缺点是在某些嵌套滚动容器里会失效,而且要处理多个吸顶元素叠层时的top偏移。另一条是 JS 监听scroll,到阈值时给 Tab 加position: fixed,同时用占位元素补高度。项目里我倾向第二种,可控性强,将来加个「滚动渐变背景」也方便。
代价是占位元素必须补上。Tab 一旦变成fixed,就脱离了文档流,原本被它撑开的那几十像素高度瞬间消失,下方的长图会往上蹦一截,用户手指底下的内容直接位移。这个现象在真机上比在浏览器里明显得多,模拟器不一定会暴露。
4. 交互链路代码:主图切换、放大镜、SKU 联动与总价计算
4.1 缩略图切换与主图状态同步
缩略图切换看着简单,但要保证三处同步:高亮态、主图src、放大层的背景图。只改前两个是最常见的漏项。
// js/gallery.js const thumbs = document.querySelector('#galleryThumbs'); const mainImg = document.querySelector('#galleryMain'); const bigBox = document.querySelector('#galleryBig'); // 事件委托:只在父容器上绑一次,缩略图增删都不用重绑 thumbs.addEventListener('click', (e) => { const li = e.target.closest('li'); if (!li) return; // 点在 li 之间的空隙,直接忽略 const list = [...thumbs.children]; const index = list.indexOf(li); if (index === -1) return; list.forEach((item) => item.classList.remove('is-active')); li.classList.add('is-active'); // 命名约定:main-{n}.webp 与缩略图序号一一对应,不靠 data 属性传值 const nextSrc = `./images/product/main-${index + 1}.webp`; mainImg.src = nextSrc; // 关键:放大层是背景图,不跟着换的话,放大镜里还是上一张 bigBox.style.backgroundImage = `url("${nextSrc}")`; // 顺带把库存和价格按新的图集重置(如果规格和图集有关联) document.dispatchEvent(new CustomEvent('gallery:change', { detail: { index } })); });这段代码有两个参数点值得说。main-${index + 1}.webp是命名约定,好处是不用给每个li写>// js/gallery.js(续) const stage = document.querySelector('#galleryStage'); const lens = document.querySelector('#galleryLens'); const LENS_SIZE = 180; // 镜头框边长,必须与 .gallery-lens 的宽高完全一致 const ZOOM_RATIO = 2.5; // 放大倍数,越大越清晰,但背景图体积同步变大 stage.addEventListener('mousemove', (e) => { const rect = stage.getBoundingClientRect(); // 让镜头框中心对准鼠标,所以要减掉一半边长 let left = e.clientX - rect.left - LENS_SIZE / 2; let top = e.clientY - rect.top - LENS_SIZE / 2; // 边界钳制,越界会让放大图露白边 left = Math.max(0, Math.min(left, rect.width - LENS_SIZE)); top = Math.max(0, Math.min(top, rect.height - LENS_SIZE)); lens.style.left = `${left}px`; lens.style.top = `${top}px`; // 背景图整体放大,再反向平移,位移量 = 镜头位移 × 放大倍数 bigBox.style.backgroundSize = `${rect.width * ZOOM_RATIO}px ${rect.height * ZOOM_RATIO}px`; bigBox.style.backgroundPosition = `${-left * ZOOM_RATIO}px ${-top * ZOOM_RATIO}px`; }); stage.addEventListener('mouseenter', () => lens.classList.add('is-show')); stage.addEventListener('mouseleave', () => lens.classList.remove('is-show'));
参数说明:LENS_SIZE和 CSS 里.gallery-lens的宽高是一对,改了这边忘了那边,镜头框和实际取景范围就对不上,用户会觉得「放大的位置偏了」。稳妥做法是读 CSS 计算值getComputedStyle(lens).width,但那样每次移动都要读样式,性能不划算,我一般写死一个常量并在注释里标红。ZOOM_RATIO取 2.5 是经验值,低于 2 放大效果不明显,高于 4 背景图尺寸会是原图的十几倍,移动端内存吃不消。
-left * ZOOM_RATIO这个负号是坐标换算的核心。镜头往右移,放大图应该往左移,因为是「把那一小块区域铺满整个放大框」,位移方向和镜头方向相反。很多人的版本里忘了负号,结果是鼠标右移、放大图也跟着右移,看着像在平移而不是放大。
4.3 SKU 组合可用性与置灰
这一块是最容易写出「假可用」的地方。所谓假可用,就是按钮看着能点,点完发现没货。根因在于判断逻辑只看单一维度。
// js/sku.js // 规格组合表,真实项目里来自接口,字段名基本一致 const SKU_LIST = [ { id: 'C01', spec: { color: '曜石黑', storage: '8+128G' }, price: 1299, stock: 12 }, { id: 'C02', spec: { color: '曜石黑', storage: '8+256G' }, price: 1499, stock: 0 }, { id: 'C03', spec: { color: '冰川蓝', storage: '8+128G' }, price: 1299, stock: 5 }, { id: 'C04', spec: { color: '冰川蓝', storage: '8+256G' }, price: 1499, stock: 8 }, ]; const selected = { color: '', storage: '' }; // 空串表示该维度未选 /** * 判断某个规格值在「其他维度已选」的前提下是否有货 * @param {string} key 维度名,如 color * @param {string} value 维度值,如 曜石黑 * @returns {boolean} */ function isAvailable(key, value) { return SKU_LIST.some((sku) => { if (sku.stock <= 0) return false; // 该组合本身无货 if (sku.spec[key] !== value) return false; // 当前维度不匹配 // 其他已选维度也必须匹配,否则就是跨维度的假可用 return Object.keys(selected).every( (k) => k === key || !selected[k] || sku.spec[k] === selected[k] ); }); }isAvailable里那句Object.keys(selected).every(...)是整个函数的关键。举例:用户先选了「曜石黑」,这时候「8+256G」在C02里虽然存在,但库存是 0,sku.stock <= 0会把它挡掉,同时C02是唯一的「曜石黑 + 8+256G」组合,于是「8+256G」这个按钮就该置灰。如果忽略这一步,只判断「8+256G 在任意 SKU 里出现过」,那它就会亮着,用户点下去才被告知无货。
选完之后的联动也要一次算全:
// js/sku.js(续) function renderSku() { document.querySelectorAll('.sku-group').forEach((group) => { const key = group.dataset.spec; // color / storage // 从数据里去重取出该维度所有可选值,保持原始顺序 const values = [...new Set(SKU_LIST.map((s) => s.spec[key]))]; group.innerHTML = values.map((v) => { const active = selected[key] === v ? ' is-active' : ''; const disabled = isAvailable(key, v) ? '' : ' disabled'; return `<button type="button" class="sku-item${active}"${disabled} >// js/counter.js let num = 1; let unitPrice = 1299; // 由 SKU 选择结果同步过来 /** * @param {number} delta 增量,+1 或 -1 */ function step(delta) { const input = document.querySelector('#numInput'); const max = 99; // 单次加购上限,业务规则 // 用户可能在输入框里手打非数字,先兜底成 1 const current = parseInt(input.value, 10) || 1; num = Math.min(max, Math.max(1, current + delta)); input.value = num; renderTotal(); } function renderTotal() { // 先放大 100 倍取整再除回去,避开 1299 * 3 显示成 3896.9999 的浮点坑 const total = Math.round(unitPrice * num * 100) / 100; document.querySelector('#priceNow').textContent = `¥${total.toFixed(2)}`; } document.querySelector('#minusBtn').addEventListener('click', () => step(-1)); document.querySelector('#plusBtn').addEventListener('click', () => step(1)); // 手动输入时也要收敛,失焦再校验,避免打字打一半就被改掉 document.querySelector('#numInput').addEventListener('blur', (e) => { const v = parseInt(e.target.value, 10); e.target.value = Number.isNaN(v) ? 1 : Math.min(99, Math.max(1, v)); num = parseInt(e.target.value, 10); renderTotal(); });Math.round(x * 100) / 100这套写法在前端金额计算里属于常规操作。JS 的浮点数用的是双精度,1299 * 3可能有微小误差,toFixed(2)本身在某些边界值上还会四舍五入翻车(比如1.005.toFixed(2)得到1.00)。真要做严格的金额计算,可以上整数分或者decimal.js,但详情页这种只做展示的场景,先乘后除已经够用。
blur事件里做校正而不是input事件,是因为用户手打「12」的时候,中间会先经过「1」,如果每次输入就强制改值,光标会跳,打起来很难受。失焦再收敛,体验上顺得多。
5. 常见问题排查:图片抖动、SKU 假可用、吸顶错位这几处最容易翻车
5.1 图片与资源类问题
现象:鼠标第一次移进主图区,放大框是白的,鼠标再动两下才出图。
原因:放大层用background-image表现,背景图是在 JS 里动态设上去的,浏览器第一次看到这个 URL 才开始下载。下载期间渲染出来就是空白。
解决:在页面初始化时预热,把主图和放大图都提前建一个Image对象塞进去,浏览器就会走缓存。
// js/gallery.js —— 初始化阶段预热所有图集资源 function preload(src) { const img = new Image(); img.src = src; // 只要发起请求,浏览器就会缓存,不用等 onload } ['./images/product/main-1.webp', './images/product/main-2.webp', './images/product/main-3.webp'].forEach(preload);第二个图相关的问题是页面往下窜。商品长图没有写死宽高,加载完从 0 高度一下撑到 2000px,用户正在看的位置直接被推到不知道哪里去了。解决方式就是给图片容器锁比例,aspect-ratio: 3 / 4或者height写死,图片本身用object-fit: cover填充。这一条不解决,后面的吸顶、锚点滚动全都会跟着失准。
5.2 交互状态类问题
现象:某个规格按钮亮着,点下去弹「该规格暂时无货」。
原因:可用性判断只看了当前维度,没结合已选的其他维度,前面isAvailable那段已经拆过。还有一种变体是「库存字段类型不对」,接口返回的是字符串"0"而不是数字0,sku.stock <= 0对字符串做隐式转换虽然能成立,但如果是"0 "带空格就会翻车,所以拿到数据后统一Number(sku.stock)更保险。
现象:切换了商品图,放大镜里显示的还是上一张。
原因:切换函数只改了img.src,没有同步bigBox.style.backgroundImage。放大层的图源和主图是两个独立的东西,没有任何自动关联。
解决:把「设置主图」封装成一个函数,源地址只计算一次,主图和背景图都在这个函数里更新,任何人想换图都调它,不直接改src。这是消除状态不一致最笨也最有效的办法。
5.3 布局与适配类问题
现象:详情 Tab 吸顶之后位置偏了几十像素,或者返回顶部时下方内容往下跳一下。
原因:tabs.offsetTop在 DOM 布局还没稳定的时候取了值。图片没加载完、字体没加载完,元素的纵向位置都还在变,这时候记下来的偏移量是错的。另一个原因是吸顶后 Tab 脱离文档流,占位元素高度没有同步,导致下面的内容位置突变。
解决:偏移量延迟到window.load之后再取,或者干脆改用哨兵元素加IntersectionObserver,不依赖具体像素值。
// js/tab.js —— 用哨兵元素替代 offsetTop,规避布局未稳定的问题 const sentinel = document.querySelector('#tabsPlaceholder'); const tabs = document.querySelector('#detailTabs'); new IntersectionObserver( ([entry]) => { // 哨兵离开视口顶部,说明该吸顶了 tabs.classList.toggle('is-fixed', !entry.isIntersecting); }, { rootMargin: '-1px 0px 0px 0px', threshold: 0 } ).observe(sentinel);rootMargin设成-1px是为了让判定提前一点点触发,视觉上更贴合。threshold: 0表示只要露一点边就回调。
第三个适配问题是移动端的视口高度。首屏如果用height: 100vh,在部分手机浏览器上地址栏收起展开会导致vh变化,页面跟着抖。换成100dvh或者用 JS 读window.innerHeight写进 CSS 变量,稳定得多。
6. 二次开发:把静态页接到接口、抽成组件、补一个加购动效
静态页跑通之后,往下走通常有三个方向,难度递增。
第一个方向是接真实接口。现有代码里SKU_LIST是写死的常量,接接口时把它换成异步拉取就行,但要处理好三个状态:加载中、成功、失败。加载中按钮区应该显示骨架屏而不是空白,失败要有重试入口而不是静静躺着。
// js/sku.js —— 接口版初始化,保留同步版本的渲染逻辑 async function loadSku(productId) { const group = document.querySelectorAll('.sku-group'); group.forEach((g) => g.classList.add('is-loading')); // 骨架屏态 try { const res = await fetch(`/api/product/${productId}/sku`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // 接口字段转成内部结构,后续逻辑不用改 SKU_LIST = data.list.map((item) => ({ id: item.skuId, spec: item.specMap, // { color: '曜石黑', storage: '8+128G' } price: Number(item.salePrice) / 100, // 后端常以分为单位,这里转成元 stock: Number(item.stock), })); renderSku(); } catch (err) { // 失败降级:把所有规格按钮置灰,并给一个明确提示 group.forEach((g) => { g.innerHTML = '<span class="sku-error">规格加载失败,点击重试</span>'; }); console.warn('[sku] load failed', err); } finally { group.forEach((g) => g.classList.remove('is-loading')); } }这段里最容易忽略的是Number(item.salePrice) / 100。后端用「分」存价格是通用做法,前端忘了除 100,页面上就会出现「¥129900.00」这种天文数字,上线才发现就晚了。同理,stock一定转成数字,别信接口文档说它是 number。
第二个方向是组件化。Vue 版本里,把图集抽成<ProductGallery>,SKU 区抽成<SkuSelector>,用v-model或者defineModel双向绑定selected,父组件只关心最终选中的 skuId,内部的重绘逻辑全部封在子组件里。迁移的时候会有一个感受:原来手写的renderSku()全量重绘,在 Vue 里就是模板加响应式数据,代码量能少一半,但前提是你先写过原生版本,才知道哪些状态是真正需要响应式的。
第三个方向是加购动效,也就是商品图飞进购物车那个抛物线动画。用requestAnimationFrame手动插值比较麻烦,我更推荐 FLIP 思路:先拿到起始位置和目标位置的矩形,用transform做位移加缩放,transition里给一个贝塞尔曲线,动画结束再清理节点。
// js/cart-fly.js —— 加购抛物线,用两段 transition 拼出弧线 function flyToCart(fromEl, toEl) { const from = fromEl.getBoundingClientRect(); const to = toEl.getBoundingClientRect(); const clone = fromEl.cloneNode(true); Object.assign(clone.style, { position: 'fixed', left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px`, height: `${from.height}px`, zIndex: 9999, pointerEvents: 'none', transition: 'transform .6s cubic-bezier(.3,-0.2,.7,1), opacity .6s', }); document.body.appendChild(clone); // 强制重排,确保初始位置先被浏览器记录,否则 transition 不生效 void clone.offsetWidth; const dx = to.left - from.left; const dy = to.top - from.top; clone.style.transform = `translate(${dx}px, ${dy}px) scale(.1)`; clone.style.opacity = '0'; clone.addEventListener('transitionend', () => clone.remove(), { once: true }); }void clone.offsetWidth这行看着莫名其妙,但少了它动画会直接跳变。原因是元素刚插入 DOM 时浏览器还没做样式计算,紧接着设transform,浏览器认为「初始值就是最终值」,没有过渡过程。读一次offsetWidth强制同步重排,把初始状态坐实,动画才会跑起来。这个坑我在三个项目里踩过三次,每次都得盯着代码想一会儿。
cubic-bezier(.3,-0.2,.7,1)里第二个参数是负数,意味着动画中途会先往回缩一点点再冲出去,形成一条弧线,比直线飞过去自然。这个曲线值可以自己调,改大了会显得夸张。
从那以后我每次写完一个交互模块,都强制自己先手动把边界情况走一遍:数量输到 0、输到 999、输个字母、规格全不选就点购买、断网状态下刷新。这几步走完,能拦下八九成的低级问题。仿京东详情页这类项目,逻辑不难,难的是一堆看起来无关紧要的细节叠在一起,缺一个就露馅。希望帮到你。
本文还有配套的精品资源,点击获取