Skip to content

购物车效果

此案例采用的是面向对象的思想,需要有一定的编码基础,重点在于体会。 其中,用到了洋葱圈模型。 难点是,购物车点击加号商品飞入的动画。

思路

  • 数据 准备好数据,此数据是原始数据,需要处理成我们需要的数据 将单件商品数据封装成一个类,类中有添加/删除/计算商品单价/是否选中的方法 将整个 UI 需要的数据封装成一个类,类中有添加/删除/计算商品单价/是否选中的方法...
  • UI 逻辑 将整个 UI 的逻辑封装成一个类,类中主要是初始化 dom 和对 dom 操作的一些方法。

扩展-洋葱圈模型

所谓洋葱圈模型是指,从外部到内部,每一个层都有自己的逻辑,如果想要访问到内部的东西,直接访问外部的这个类预留的接口即可。 开发技巧

扩展-购物车飞入动画

小球做抛物线图示

代码

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>丑团外卖</title>
    <link rel="shortcut icon" href="./assets/favicon.ico" type="image/x-icon" />
    <link rel="stylesheet" href="./css/common.css" />
    <link rel="stylesheet" href="./css/container.css" />
    <link rel="stylesheet" href="./css/footer.css" />
    <link rel="stylesheet" href="./css/add-to-car.css" />
  </head>

  <body>
    <div class="container">
      <div class="menu">
        <div class="menu-item active"><span>推荐</span></div>
        <div class="menu-item"><span>热销</span></div>
        <div class="menu-item"><span>折扣</span></div>
        <div class="menu-item"><span>夏日冰咖必喝榜</span></div>
        <div class="menu-item"><span>进店必喝</span></div>
        <div class="menu-item"><span>只喝美式</span></div>
        <div class="menu-item"><span>酷爽特调水果冰萃</span></div>
        <div class="menu-item"><span>经典奶咖</span></div>
        <div class="menu-item"><span>创意奶咖</span></div>
        <div class="menu-item"><span>瑞纳冰季</span></div>
        <div class="menu-item"><span>不喝咖啡</span></div>
        <div class="menu-item"><span>轻食甜品</span></div>
        <div class="menu-item"><span>热卖套餐</span></div>
      </div>
      <div class="goods-list"></div>
    </div>
    <div class="footer">
      <div class="footer-car-container">
        <div class="footer-car">
          <i class="iconfont i-gouwuchefill"></i>
          <span class="footer-car-badge">0</span>
        </div>
        <div class="footer-car-price">
          <span class="footer-car-unit">¥</span>
          <span class="footer-car-total">0.00</span>
        </div>
        <div class="footer-car-tip">配送费¥0</div>
      </div>
      <div class="footer-pay">
        <a href="">去结算</a>
        <span>还差¥0元起送</span>
      </div>
    </div>

    <!-- <div class="add-to-car">
    <i class="iconfont i-jiajianzujianjiahao"></i>
  </div> -->

    <script src="./js/data.js"></script>
    <script src="./js/index.js"></script>
  </body>
</html>
js
var goods = [
  {
    pic: "./assets/g1.png",
    title: "椰云拿铁",
    desc: `1人份【年度重磅,一口吞云】

    √原创椰云topping,绵密轻盈到飞起!
    原创瑞幸椰云™工艺,使用椰浆代替常规奶盖
    打造丰盈、绵密,如云朵般细腻奶沫体验
    椰香清甜饱满,一口滑入口腔
    
    【饮用建议】请注意不要用吸管,不要搅拌哦~`,
    sellNumber: 200,
    favorRate: 95,
    price: 32,
  },
  {
    pic: "./assets/g2.png",
    title: "生椰拿铁",
    desc: `1人份【YYDS,无限回购】
    现萃香醇Espresso,遇见优质冷榨生椰浆,椰香浓郁,香甜清爽,带给你不一样的拿铁体验!
    
    主要原料:浓缩咖啡、冷冻椰浆、原味调味糖浆
    图片及包装仅供参考,请以实物为准。建议送达后尽快饮用。到店饮用口感更佳。`,
    sellNumber: 1000,
    favorRate: 100,
    price: 19.9,
  },
  {
    pic: "./assets/g3.png",
    title: "加浓 美式",
    desc: `1人份【清醒加倍,比标美多一份Espresso】
    口感更佳香醇浓郁,回味持久
    图片仅供参考,请以实物为准。建议送达后尽快饮用。`,
    sellNumber: 200,
    favorRate: 93,
    price: 20.3,
  },
  {
    pic: "./assets/g4.png",
    title: "瓦尔登蓝钻瑞纳冰",
    desc: `1人份【爆款回归!蓝色治愈力量】
    灵感来自下澄明、碧蓝之境---瓦尔登湖。含藻蓝蛋白,梦幻蓝色源自天然植物成分,非人工合成色素,融入人气冷榨生椰浆,椰香浓郁,清冽冰爽;底部添加Q弹小料,0脂原味晶球,光泽剔透,如钻石般blingbling。搭配奶油顶和彩虹色棉花糖,满足你的少女心~
    【去奶油小提示】由于去掉奶油后顶料口味会受影响,为保证口感,选择“去奶油”选项时将同时去掉奶油及顶料,请注意哦!【温馨提示】瑞纳冰系列产品形态为冰沙,无法进行少冰、去冰操作,请您谅解。【图片仅供参考,请以实物为准】`,
    sellNumber: 17,
    favorRate: 80,
    price: 38,
  },
  {
    pic: "./assets/g5.png",
    title: "椰云精萃美式",
    desc: `1人份【不用吸管 大口吞云!】

    1杯热量*≈0.6个苹果!
    原创瑞幸椰云™工艺,将「椰浆」变成绵密、丰盈的“云朵”,口感绵密顺滑!0乳糖植物基,清爽轻负担!
    
    *数据引自《中国食物成分表》第六版,苹果每100克可食部分中能量约为53千卡,以每个苹果250克/个计,1杯椰云精萃美式约80千卡,相当于约0.6个苹果。
    【图片仅供参考,请以实物为准】`,
    sellNumber: 50,
    favorRate: 90,
    price: 21.12,
  },
];
js
console.log(goods);

// 单件商品的数据
class Goods {
  constructor(g) {
    this.data = g;
    this.chooseCount = 0;
  }

  //  增加单个商品
  increase() {
    this.chooseCount++;
  }
  // 减少单个商品
  decrease() {
    if (this.chooseCount === 0) return;
    this.chooseCount--;
  }
  //单个商品的总价
  getTotalPrice() {
    return this.chooseCount * this.data.price;
  }
  //是否选中了此商品
  isChoose() {
    return this.chooseCount > 0;
  }
}

// let ugoods=new Goods(goods[0]);

// 页面数据
class UIData {
  constructor() {
    //构造页面需要的全部数据--由单个数据的对象构造生成的集合
    this.goodsData = goods.map((item) => new Goods(item));
    this.posefee = 5; //配送费
    this.postThreshold = 20; //多少起送
  }

  // 增加商品
  increase(index) {
    this.goodsData[index].chooseCount++;
  }

  // 减少商品
  decrease(index) {
    if (this.goodsData[index].chooseCount === 0) return;
    this.goodsData[index].chooseCount--;
  }
  // 购物车中的商品数量
  getCartCount() {
    return this.goodsData.reduce((total, current) => {
      return (total += current.chooseCount);
    }, 0);
  }
  //购物车总价
  getTotalPrice() {
    return this.goodsData.reduce((total, current) => {
      return (total += current.getTotalPrice());
    }, 0);
  }
  //购物车是否有商品
  hasGoodsInCart() {
    return this.getCartCount() > 0;
  }
  //是否跳过起送门槛
  isSkipPostThreshold() {
    return this.getTotalPrice() >= this.postThreshold;
  }

  //是否选中了商品
  isChoose(index) {
    return this.goodsData[index].isChoose();
  }
}

const uidata = new UIData();
console.log(uidata);

//页面UI
class UI {
  constructor() {
    //页面数据
    this.uidata = new UIData();
    //页面DOOMS
    this.doms = {
      goodsList: document.querySelector(".goods-list"),
      //
      footerCarTip: document.querySelector(".footer-car-tip"),
      footerPay: document.querySelector(".footer-pay"),
      footThreshold: document.querySelector(".footer-pay span"),
      footTotalPrice: document.querySelector(".footer-car-total"),
      //
      cart: document.querySelector(".footer-car"),
      badge: document.querySelector(".footer-car-badge"),
    };
    this.initDOM();
    this.updateFootDom();
    //购物车坐标
    this.targetPoint = this.getCartPoint();
  }

  // 初始化dom
  initDOM() {
    let html = "";
    this.uidata.goodsData.forEach((item, index) => {
      html += `<div class="goods-item">
          <img src="${item.data.pic}" alt="" class="goods-pic" />
          <div class="goods-info">
            <h2 class="goods-title">椰云拿铁</h2>
            <p class="goods-desc">
              ${item.data.desc}
            </p>
            <p class="goods-sell">
              <span>月售 ${item.data.sellNumber}</span>
              <span>好评率${item.data.favorRate}</span>
            </p>
            <div class="goods-confirm">
              <p class="goods-price">
                <span class="goods-price-unit">¥</span>
                <span>${item.data.price}</span>
              </p>
              <div class="goods-btns">
                <i data-index=${index} class="iconfont i-jianhao"></i>
                <span>${item.chooseCount}</span>
                <i data-index=${index} class="iconfont i-jiajianzujianjiahao"></i>
              </div>
            </div>
          </div>
        </div>`;
    });
    this.doms.goodsList.innerHTML = html;
  }

  //增加商品
  increase(index) {
    this.uidata.increase(index);
    this.updateGoodsItemDom(index);
    this.updateFootDom();
    this.jumpAnimate(index);
  }

  //减少商品
  decrease(index) {
    this.uidata.decrease(index);
    this.updateGoodsItemDom(index);
    this.updateFootDom(index);
  }

  //增加和删除的更新dom
  updateGoodsItemDom(index) {
    const currGoods = this.doms.goodsList.children[index];
    //显示加减框
    if (this.uidata.isChoose(index)) {
      currGoods.classList.add("active");
    } else {
      currGoods.classList.remove("active");
    }
    //更新数量框
    const span = currGoods.querySelector(".goods-btns span");
    span.textContent = this.uidata.goodsData[index].chooseCount;
  }

  //更新页脚
  updateFootDom() {
    const total = this.uidata.getTotalPrice().toFixed(2);
    //配送费
    this.doms.footerCarTip.textContent = `配送费 ¥${this.uidata.posefee}`;
    //设置配送费还差多少
    if (this.uidata.isSkipPostThreshold()) {
      this.doms.footerPay.classList.add("active");
    } else {
      this.doms.footerPay.classList.remove("active");
      //门槛
      this.doms.footThreshold.textContent = `还差¥${Math.round(
        this.uidata.postThreshold - total
      )}元起送`;
    }
    //总价
    this.doms.footTotalPrice.textContent = `${total}`;
    //购物车
    if (this.uidata.hasGoodsInCart()) {
      this.doms.cart.classList.add("active");
    } else {
      this.doms.cart.classList.remove("active");
    }
    //购物车徽标
    this.doms.badge.textContent = this.uidata.getCartCount();
  }

  //动画-购物车抖动
  ceateAnimate() {
    this.doms.cart.classList.add("animate");
  }
  //动画-'+'抛物线动画
  jumpAnimate(index) {
    //起始点
    const addDom = this.doms.goodsList.children[index].querySelector(
      ".goods-btns .i-jiajianzujianjiahao"
    );
    const addRect = addDom.getBoundingClientRect();
    const startPoint = {
      x: addRect.left,
      y: addRect.top,
    };
    //创建元素
    const div = document.createElement("div");
    div.className = "add-to-car";
    const i = document.createElement("i");
    i.className = "iconfont i-jiajianzujianjiahao";
    div.style.transform = `translateX(${startPoint.x}px)`;
    i.style.transform = `translateY(${startPoint.y}px)`;
    div.appendChild(i);
    document.body.appendChild(div);
    //跳动
    /* 强行渲染动画,如果不强行渲染的话,主线程只会执行完js之后,才开始执行渲染流程,
      所以只会有下面的效果,而初始位置的效果没有被渲染。 
      requestAnimationFrame();
    */
    div.clientWidth;
    div.style.transform = `translateX(${this.targetPoint.x}px)`;
    i.style.transform = `translateY(${this.targetPoint.y}px)`;
    //动画结束
    div.addEventListener(
      "transitionend",
      () => {
        div.remove();
        this.ceateAnimate();
      },
      {
        once: true, //因为i标签也有过度效果,如果不加once,会冒泡到div,从而触发两次结束了回调函数
      }
    );
  }
  //获取购物车坐标
  getCartPoint() {
    // 购物车终点坐标
    const cartRect = this.doms.cart.getBoundingClientRect();
    const targetPoint = {
      x: cartRect.left + cartRect.width / 2,
      y: cartRect.top + cartRect.height / 5, //高度随意,这里取得是五分之一
    };
    return targetPoint;
  }
}

const ui = new UI();

// 事件
// 利用事件委托,来进行事件的绑定
ui.doms.goodsList.addEventListener("click", function (e) {
  console.log(e);
  if (e.target.classList.contains("i-jiajianzujianjiahao")) {
    //此处的index可以通过H5的自定义属性来获取,data-index
    // console.log(e.target.dataset.index);
    ui.increase(e.target.dataset.index);
  } else if (e.target.classList.contains("i-jianhao")) {
    ui.decrease(e.target.dataset.index);
  }
});

/* 
抛物线原理:
1.实际上是小球在x轴上匀速运动,y轴上变速运动形成的抛物线
2.使用方法是div在x轴上匀速运动,让i标签在y轴上变速,div带动着i标签进行移动
3.利用transition的贝塞尔曲线,可以设置i在y轴上的运动动画效果
*/

/* 
开发思路:洋葱模型
开发分三层:数据处理层(类) UI数据层(类)  界面逻辑层(类)
这样的话,就确保形成了三个环,每个环都可以独立的运行,即便没有页面的情况下,只使用函数也能实现页面的各种逻辑
每层独立,程序也健壮易扩展,日后业务需求改变的时候,只需要去改变事件的绑定即可,函数的功能都是独立的,也能方便日后的单测
*/

MIT License