小米商城加购动画JS代码拆解:事件委托与贝塞尔曲线实战
2026/9/14 23:29:31 网站建设 项目流程

简介:小米商城商品加入购物车js代码是一份聚焦电商前端交互的轻量示例资源,适合Web前端初学者、电商页面开发者以及想借鉴名站交互效果的从业者。它通过一个完整的HTML页面与配套商品图片,演示了用户点击“加入购物车”按钮后,商品信息如何被捕获、暂存并反馈到界面。资源包共5个文件,包括1个index.html主页面和4张jpg商品图,压缩后仅30KB,结构简洁,便于直接运行和阅读。已有1823人学习下载。代码涉及addEventListener事件绑定、通过属性或对象读取商品ID/名称/价格、利用数组维护购物车状态,以及操作DOM更新购物车数量或提示信息,同时可扩展淡入淡出、滑动等反馈动画,对理解现代电商前端基础技能很有帮助。这份资源既可作为课堂演示,也可作为前端交互的练手素材,帮助开发者快速掌握动态购物车的基础实现思路。

1. 名站特效拆解:为什么一个小小的加购动画值这么多讲究

打开小米商城的商品列表页,点击“加入购物车”,商品缩略图会以一条抛物线飞进右下角的购物车图标,角标数字随之跳动。这个看起来只是“好看”的动效,背后涉及事件委托、DOM 定位、贝塞尔曲线插值、状态同步四块基础能力。对于想进阶的前端开发者来说,与其去啃复杂框架,不如先把这个小场景彻底拆开:它体积小、边界清晰,但把 JavaScript 在浏览器里最常打交道的几件事——监听、取数、渲染、动画——全部覆盖了。本文要拆的这份“小米商城商品加入购物车 js 代码”,是一个完整的静态 demo:一个 index.html 配上四张商品图,没有任何框架依赖,打开就能跑。它适合刚学完 JS 语法、想看看真实交互是怎么串起来的初级开发者,也适合需要快速给后台管理系统或活动页加一个加购反馈动效的熟手。下面从页面结构、事件绑定、状态管理到动画实现,一层层拆开说清楚。

2. 先看骨架:商品卡片与购物车区域的 HTML 结构与选型

这份资源只有一个 index.html,所有结构、样式、逻辑都写在一个文件里。打开后能看到四个商品卡片,对应的图片分别是 img01.jpg 到 img04.jpg,右下角是一个固定的购物车图标区域。整体布局是典型的电商列表页:左侧商品网格区,右侧底部悬浮购物车入口。

2.1 商品卡片的最小结构

四个商品的 DOM 结构完全一致,只是图片、名称、价格不同。这是刻意为之的:便于用 JavaScript 统一遍历绑定,也便于后面用事件委托做优化。每个卡片的模板大致如下:

<div class="goods-item"><div class="cart-wrapper" id="cartTarget"> <span class="cart-icon">🛒</span> <span class="cart-badge" id="cartBadge">0</span> </div>

cartTarget是飞入动画的目标锚点,cartBadge是显示数量的角标。这两个元素在动画中会被频繁读写,单独设置 ID 便于用getElementById快速获取,避免在动画循环里做低效的 DOM 查询。实际项目中如果购物车不止一个入口,可以把目标元素改成类名加querySelector,但这份 demo 出于简化只保留了一个。

2.3 样式上埋的伏笔:飞入元素为什么用固定定位

看 CSS 的时候会发现里面定义了一个.fly-item类,初始是display: none。这个类不会出现在任何商品卡片上,它是给动态创建的飞入动画元素用的。飞入元素在动画期间要脱离文档流、从起点移动到终点,所以用了position: fixed,配合lefttop直接控制屏幕坐标,不受父级transformoverflow: hidden影响。这是一个很容易踩坑的细节:如果动态元素的父容器有filtertransformperspective属性,fixed定位会变成相对于父容器而不是视口,坐标就会算错。

3. 事件监听与信息提取:从点击按钮到拿到商品数据

结构搭好之后,接下来的核心问题是:用户点了“加入购物车”,代码怎么知道点的是哪一个商品?有两条路:给每个按钮单独绑定事件,或者利用事件冒泡做事件委托。这份代码选择的是后者,这也是大型电商页面更常见的做法。

3.1 事件委托:只绑一次监听,处理所有按钮

按传统写法,每个按钮都要addEventListener一次,四个按钮绑四次,四十个按钮绑四十次。事件委托的思路是:监听所有商品卡片的共同父容器,利用事件冒泡机制,在事件对象上判断当前点击的是不是目标元素。

document.querySelector('.goods-list').addEventListener('click', function(e) { const target = e.target.closest('.add-cart-btn'); if (!target) return; const item = target.closest('.goods-item'); const id = item.dataset.id; const name = item.dataset.name; const price = parseFloat(item.dataset.price); addToCart({ id, name, price }); });

这段代码有三层逻辑。e.target.closest('.add-cart-btn')解决的是“点到按钮里的文字或 padding 区域”的情况:closest会向上查找最近的匹配选择器的祖先元素,只要点在按钮内部,无论精确命中哪个子节点,都能找到按钮本身。如果点击的是按钮以外的区域,closest 返回 null,直接跳出。找到了按钮之后再向上找.goods-item,拿到商品所在卡片,再从dataset里取 ID、名称、价格。价格用parseFloat转成数字类型,因为在>let cart = []; function addToCart(goods) { const existed = cart.find(item => item.id === goods.id); if (existed) { existed.count += 1; } else { cart.push({ id: goods.id, name: goods.name, price: goods.price, count: 1 }); } updateCartBadge(); }

find方法按 id 查找购物车里是否已有同款商品。找到则 count 加一,没找到则追加一个包含 count 字段的新对象。这里有一个大多数初学 JS 的人会犯的错误:直接把goods对象 push 进去,而不是创建新对象。如果直接在原对象上挂 count 字段,下一次点击同一个商品时,dataset里的数据已经被污染,而且多次添加之间会互相影响。正确做法是把值拷贝进新对象,保住原始数据的干净。

4.2 角标数字:是商品件数还是商品种类数

updateCartBadge函数负责把购物车数量同步到页面上的角标。这里要做一个产品决策:角标显示的是“加入了几件商品”还是“加了几种商品”。小米商城实际显示的是件数,也就是上面代码里所有商品 count 字段的总和:

function updateCartBadge() { const total = cart.reduce((sum, item) => sum + item.count, 0); document.getElementById('cartBadge').textContent = total; }

reduce在这里的作用是把数组里每个商品的 count 累加成总量。如果用cart.length,拿到的是种类数,重复加同一商品时角标不会变,用户体验上不够直观。很多面试题会拿这个做文章:给你一个购物车数组,怎么算总件数和总种类数。前者是 reduce 累加 count,后者是直接取 length。两种逻辑在真实场景里都有业务含义,电商页面两种数字甚至可能同时出现在不同位置——购物车角标显示件数,结算页列出商品行数。

4.3 用 localStorage 做持久化的可选增强

原 demo 没有做刷新后保持购物车状态的功能,属于可接受的简化。生产环境的购物车状态要么在后端,要么在 localStorage,纯前端内存数组刷新即失。如果想在这个 demo 上做扩展,可以在每次购物车变化后执行一次序列化存储:

function saveCart() { localStorage.setItem('mi-cart', JSON.stringify(cart)); } function loadCart() { const saved = localStorage.getItem('mi-cart'); if (saved) { cart = JSON.parse(saved); updateCartBadge(); } } loadCart();

一个容易踩的坑是 JSON.parse 的容错。如果 localStorage 里的数据被用户手动改坏,或者上一个版本的存储结构不兼容,JSON.parse会直接抛异常,导致整个页面脚本崩溃。稳妥写法是包一层 try-catch,解析失败时清空旧数据、重置为空数组。页面上没有明显异常时,刷新后购物车空了,问题往往出在这里而不是 JS 逻辑。

5. 飞入动画与坐标计算的完整实现

加购反馈的视觉核心是商品图飞入购物车。这个效果的实现路径是:动态创建一个固定定位的图片副本,从起点移动到终点,到达后移除。实现链路里最难的是坐标计算和动画插值,这两块都强依赖浏览器渲染机制。

5.1 获取起点与终点坐标:getBoundingClientRect 的正确用法

起点是商品缩略图的位置,终点是#cartTarget的位置。需要的是相对于视口的坐标,而不是相对于文档的坐标,因为飞入元素用的是position: fixed,它的 left 和 top 天然相对于视口。

function createFlyElement(imgSrc, startEl, endEl) { const startRect = startEl.getBoundingClientRect(); const endRect = endEl.getBoundingClientRect(); const flyImg = new Image(); flyImg.src = imgSrc; flyImg.className = 'fly-item'; flyImg.style.left = startRect.left + 'px'; flyImg.style.top = startRect.top + 'px'; flyImg.style.width = startRect.width + 'px'; flyImg.style.height = startRect.height + 'px'; document.body.appendChild(flyImg); const endX = endRect.left + endRect.width / 2 - startRect.width / 2; const endY = endRect.top + endRect.height / 2 - startRect.height / 2; animateFly(flyImg, startRect.left, startRect.top, endX, endY); }

这里有一个细节:终点坐标并不是endRect.leftendRect.top,而是购物车中心点减去飞入元素自身宽度和高度的一半,否则图片到达终点时会偏到购物车右下角。起点不用做这种处理,因为图片就是从原商品位置原地复制出来的,起始坐标直接沿用原图的位置和尺寸即可。还有一个微妙的时序问题:如果页面发生了滚动,getBoundingClientRect拿到的坐标会随之变化,所以必须在创建飞入元素的同一帧内读取坐标和设置初始位置,不能先读取坐标再等图片加载完再设置位置,中间一旦滚动,坐标就错位了。图片加载通常走缓存,但严谨的写法是在onload之后再启动动画,或者直接用已有的 DOM 节点而不是新建 Image。

5.2 贝塞尔曲线与 requestAnimationFrame 动画推进

直线飞入的效果太平淡,小米商城用的是先上抛后下落的弧线效果。实现弧线的常见做法是使用二次贝塞尔曲线,把运动轨迹拆成 X 轴和 Y 轴两条路径:X 轴做匀速或缓动插值,Y 轴先减速上升再加速下降。下面是简化后的核心动画循环:

let startTime = null; const DURATION = 600; const startX = startLeft; const startY = startTop; const controlX = (startX + endX) / 2; const controlY = Math.min(startY, endY) - 120; function animateFly(el, sx, sy, ex, ey) { const bezierX = (t) => (1 - t) * (1 - t) * sx + 2 * (1 - t) * t * controlX + t * t * ex; const bezierY = (t) => (1 - t) * (1 - t) * sy + 2 * (1 - t) * t * controlY + t * t * ey; function step(timestamp) { if (!startTime) startTime = timestamp; const progress = Math.min((timestamp - startTime) / DURATION, 1); el.style.left = bezierX(progress) + 'px'; el.style.top = bezierY(progress) + 'px'; if (progress < 1) { requestAnimationFrame(step); } else { el.remove(); updateCartBadge(); } } requestAnimationFrame(step); }

progress被限制在 0 到 1 之间,代表动画从开始到结束的进度,乘以时间不会超过设定的 600 毫秒。贝塞尔公式里的controlY决定了弧线上抛的高度,数值越小,弧顶越高;如果设成比起点和终点都大的值,弧线会变成先下沉再上浮,视觉上像一个“U”型。controlX取中点意味着水平方向上没有额外偏移,轨迹是一条平滑的抛物线。整个动画不用 CSStransition,因为topleft的 transition 只能做线性或预设的缓动曲线,无法表达贝塞尔路径这样 X 和 Y 独立变化的轨迹,而且连续修改lefttop在动画期间不会触发页面布局重排,只要不读取布局属性,性能是安全的。

5.3 动画结束与角标刷新的衔接时机

动画代码里el.remove()updateCartBadge()放在同一个分支里,这个顺序有讲究:先移除飞入元素,再更新角标。反过来先更新角标再移除元素,用户会看到角标已经跳了数字,但飞入的图片还悬在半空,视觉上不同步。如果购物车旁边有弹出的小气泡提示“加入成功”,也建议在同一个回调里触发,不要另起 setTimeout,避免时间差造成闪烁。

对于动画性能,还有两个边界情况值得注意:连续快速点击多个商品时,会同时存在多个飞入元素,每个元素各自持有一份requestAnimationFrame循环,元素数量多、帧率充足时没有问题,但如果同一个商品连点了二十次,DOM 里会出现二十张飞行的图片。生产环境合理的约束是:同一商品的飞入动画做节流,1 秒内重复点击不再创建新元素,更新角标即可。用时间戳判断就能实现,不需要引入 lodash 的 throttle:

let lastFlyTime = 0; function handleFly(goods, imgSrc, btn) { const now = Date.now(); if (now - lastFlyTime < 800) return; lastFlyTime = now; createFlyElement(imgSrc, btn, document.getElementById('cartTarget')); }

这里的800是节流窗口,小于动画时长。如果节流窗口大于动画时长,动画还没飞完就点了第二次,第二次点击会直接丢弃,用户感知不到任何反馈,所以节流时间应略小于动画时长。

5.4 验证动效正确性的三个检查点

写完这份代码后,用浏览器的开发者工具可以做三次验证。第一次:点击商品加入购物车后,切换到 Elements 面板,确认.fly-item元素被创建又移除,整个生命周期大约 600 毫秒。第二次:在控制台输入cart,确认数组结构和预期一致,重复添加同一商品时 count 是在累加而不是新增记录。第三次:手动把页面往下滚一段距离再点加购,观察飞入元素的起始位置是否与商品图当前位置重合——如果滚动后起始位置偏移,说明坐标读取时机有问题,常见原因是读取坐标的时机在图片延迟加载完成之前。

验证坐标还有一个更便捷的技巧:在createFlyElement里临时给飞入图片加一个高亮边框和background: rgba(255,0,0,0.3),这样每个飞入元素的轨迹会非常直观,检查起点是否贴合商品缩略图、终点是否落在购物车中心附近。排查完确认后再去掉调试样式即可。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询