使用vuex较为优雅的实现一个购物车功能

5,693 阅读16分钟

前言

最近使用Vue全家桶手撸了一个pc版小米商城的前端项目,对于组件通信和状态管理有了一个更加深刻的认识。因为组件划分的比较细,开始我使用的是基本的props和emit传值,后来发现一旦嵌套过深就变得很繁琐,同时考虑到有多个组件存在需要共同管理的状态,基本的传值已经没有办法满足需求了,所以使用到了vuex来划分模块管理状态。这里需要提一点就是,如果不存在多组件共同管理的状态,最好是不用vuex管理,vuex是用来管理多组件共同状态的,单单只需要实现跨组件、隔代组件通信的话,使用eventbus,provide/inject等就可以实现。

Vuex修改数据的一套基本流程

首先我们来弄清楚Vuex中管理数据的一套基本流程:

  • 修改state中数据的流程:

    在组件内派发一个action即dispatch(或者直接调用)一个action => action再commit一个mutation => mutation修改state

  • state中的数据都在action中请求,再通过commit一个mutation设置state中的数据

  • getter中存放着state的计算值,相当于组件中的计算属性(computed);同时getter中的值都是响应的,就是只要依赖的state一发生改变,getter中的值马上就能检测到,然后对应就会更新状态了

  • 注意点:action中的请求是异步的,mutation是同步的

小米官网购物车功能分析

官方效果:

我们可以从上图中看到购物车的功能,这里我简单总结一下,分为以下十点:

  1. 全选功能按钮:当全选按钮亮时,代表下面所有单选按钮全部为选中状态;点击一下全选,再点击一下,全部取消;同时下面单选按钮全部选中时,上面全选按钮会自动更新状态为全选,此时再点击全选按钮就会全部取消;
  2. 单选按钮:点击一下选中当前这条商品,点击两下取消选中这条商品,当所有单选按钮选中时,上面全选按钮会自动亮(全选状态),只要当前购物车商品一条未选择,上面全选按钮就不会亮;
  3. 减少商品数量按钮:点击加号减少商品的数量;
  4. 增加商品数量按钮:点击加号增加商品的数量;
  5. 每条商品的总价:计算当前这一条商品的总价;
  6. 删除商品按钮:点击删除按钮,将这条商品删除购物车;
  7. 所有商品数量:显示当前购物车内所有商品的数量;
  8. 选中商品数量:显示当前购物车内选中了商品的数量;
  9. 所有选中商品的总价:计算当前购物车内所有选中的商品总价,不包括未选择的商品;
  10. 结算按钮:有选中商品时显示,未选择商品不显示;

功能已经分析完毕,接下来思考一下该怎么管理状态,以及划分模块

Vuex模块思路

因为是购物车,所以这里我将这个购物车里的状态在Vuex中划分为了两个模块;products模块和cart模块,products模块用来存放所有的商品数据列表信息,cart模块放置了购物车内商品的列表信息;这里需要提的一点是,因为cart模块中的每条商品信息是不需要提供类似prodcuts中一条商品的所有字段的,只需要提供几个关键的字段,然后到prodcuts模块中去查询该条商品的信息即可。可能描述不清,但在下面我会用代码展示,大家就会清楚了。

Vuex模块结构设计

我的store目录如下:

我简单介绍一下:

  1. module文件夹放置着所有的模块,我这里暂时放置三个模块cart.js、products.js、user.js(可以不用看,和购物车的功能实现没有太大关系)
  2. index.js文件整合所有模块的内容,每个模块中都存放各自模块的state、mutations、actions、getters
  3. types.js存放着所有模块的mutations常量名,这里没有强制,就是Vuex也和Redux、Flux 中的状态管理一样,修改数据遵循一套流程。每次commit都是一个常量的函数。

types文件代码

// cart模块
export const CART_ADD_PRODUCT_TO_CART = 'CART_ADD_PRODUCT_TO_CART' // 添加购物车
export const CART_DEL_PRODUCT_TO_CART = 'CART_DEL_PRODUCT_TO_CART' // 删除购物车
export const CART_CHANGE_LOGIN_STATUS = 'CART_CHANGE_LOGIN_STATUS'  // 切换登陆状态
export const CART_ADD_PRODUCT_QUANTITY = 'CART_ADD_PRODUCT_QUANTITY'  // 添加商品数量
export const CART_DEL_PRODUCT_QUANTITY = 'CART_DEL_PRODUCT_QUANTITY'  // 减少商品数量
export const CART_SET_CHECKOUT_STATUS_ALL = 'CART_SET_CHECKOUT_STATUS_ALL'   // 一键改变所有商品购买状态的方法

// products模块
export const PRODUCTS_SET_PRODUCT = 'PRODUCTS_SET_PRODUCT' // 获取所有商品的列表

这段代码没有什么逻辑可言,就是把所有模块中的mutations中的函数都用一个大写的常量名,简而言之就是按一个大写的规范,把每个模块中mutations中的函数命名,就是一套命名规范。

Vue官方文档的解释:

使用常量替代 mutation 事件类型在各种 Flux 实现中是很常见的模式。这样可以使 linter 之类的工具发挥作用,同时把这些常量放在单独的文件中可以让你的代码合作者对整个 app 包含的 mutation 一目了然; 用不用常量取决于你——在需要多人协作的大型项目中,这会很有帮助。但如果你不喜欢,你完全可以不这样做。

products模块代码

import { fetchGet } from "@/api/index" // api文件夹下封装的axios.get请求函数
import * as types from '../types' // types目录下的mutations函数的常量名
const state = {
  recommendList: [] // 存放所有商品的信息
}

const getters = {}

const mutations = {
  [types.PRODUCTS_SET_PRODUCT](state, products) { // 第一个参数是state 可以修改state 将请求回来的数据保存在state中
    state.recommendList = products
  }
}

const actions = {
  getAllProducts({ commit }) { // 所有的api请求都放在actions中
    fetchGet("/cart").then(res => {
      let allProducts = res.data.list.list
      commit(types.PRODUCTS_SET_PRODUCT, allProducts)
    })
  }
}

export default {
  namespaced: true, // 添加命名空间
  state,
  getters,
  mutations,
  actions
}
这里我放上recommendList中每一条数据的字段 例如其中一条为:
  /*
  {
    productid: "11137",
    name: "小米CC9 Pro 6GB+128GB", 
    price: 2799, 
    image: "//i1.mifile.cn/a1/pms_1572941393.18077211.jpg",
    comments: 0
  }
  */

上面代码的逻辑就是在actions中的getAllProducts方法中调用封装在api目录下index.js中的fetchGet()函数请求到数据,commit提交给mutations中的types.PRODUCTS_SET_PRODUCT函数(设置state),然后去设置所有的商品信息列表recommendList

这里需要注意几点:

  1. products模块中存放着state、getters(这个模块暂时未用到)、mutations、actions,这是分模块每个模块都存在的,最后导出这个模块的四部分;

  2. 导出的时候使用了命名空间namespaced: true,命名空间是啥,就是可以让我们模块module分的更加仔细,每个模块中都存放着state、getters、mutations、actions;使用Vue devtools调试工具查看一下Vuex中的状态就很清楚的,就是注意一点,这里使用了命名空间,所有模块都请使用,同时在组件中调用getters、actions等方法时都需要添加模块名称

    • 比如调用actions的时候:
    1. this.$store.dispatch("products/getAllProducts"); 
    2. methods: mapActions("cart", ["addProductToCart"])
    
    • 调用getters的时候:
    computed: mapGetters("user", ["loginStatus"])
    

    就是需要添加一个模块的前缀名,才能正确执行操作

  3. 所有数据的请求都请放在actions中

cart模块代码(核心模块)

我先放一下代码吧,下面再来慢慢解释

import * as types from '../types'

const state = { // 购物车需要自己的状态 购物列表
  items: [ 
    { productid: "11137", quantity: 1, checkoutStatus: false },
    { productid: "8750", quantity: 1, checkoutStatus: false }
  ]
}

const getters = {
  // 返回购物车商品列表完整信息
  cartProducts: (state, getters, rootState) => {
    if (!state.items.length) return [] // map不会对空数组进行检测 map不会改变原始数组
    return state.items.map(({ productid, quantity, checkoutStatus }) => { // map()方法返回一个新数组,数组中的元素为原始数组元素调用函数处理的后值。
      const product = rootState.products.recommendList.find(product => product.productid === productid) // 拿到items中的数据去查阅products中的数据, rootState(根节点状态)参数可以拿到别的模块的state状态
      if (!product) return {} // action请求异步,如果此时的数据还没有请求回来 就返回空对象
      return {
        src: product.image, // product的图片地址
        name: product.name, // product的名字
        price: product.price, // product的单价
        productid, // product的id
        quantity, // product的数量,默认为1
        simpleTotal: quantity * product.price, // 单项product的总价价
        checkoutStatus: checkoutStatus // product的选中状态
      }
    })
  },
  // 返回选中商品的总价
  cartTotalPrice: (state, getters) => {
    return getters.cartProducts.reduce((total, product) => {
      if (product.checkoutStatus) {
        return total + product.simpleTotal
      }
      return total
    }, 0)
  },
  // 返回所有商品总价,不管有没有选中
  allPrice: (state, getters) => {
    return getters.cartProducts.reduce((total, product) => {
      return total + product.simpleTotal
    }, 0)
  },
  // 返回所有商品总数量,不管有没有选中
  allProducts: (state, getters) => {
    return getters.cartProducts.reduce((total, product) => {
      return total + product.quantity
    }, 0)
  },
  // 返回所有选中的商品数量
  allSelectProducts: (state, getters) => {
    return getters.cartProducts.reduce((total, product) => {
      if (product.checkoutStatus) {
        return total + product.quantity
      }
      return total
    }, 0)
  },
  // 返回所有商品条数
  allProductsItem: (state) => {
    return state.items.length
  },
  // 返回商品是否全选 是返回true 否则false
  isSelectAll: (state) => {
    if (!state.items.length) return false
    return state.items.every(item => { // every() 不会对空数组进行检测
      return item.checkoutStatus === true
    })
  },
  // 返回是否有选中的商品 是返回true 否则false
  hasSelect: (state) => {
    if (!state.items.length) return false
    return state.items.some(item => { // some() 不会对空数组进行检测
      return item.checkoutStatus === true
    })
  }
}

const mutations = {
  // 添加一条商品的方法
  [types.CART_ADD_PRODUCT_TO_CART](state, { productid }) {
    state.items.push({
      productid,
      quantity: 1,
      checkoutStatus: false
    })
  },
  // 删除一条商品的方法
  [types.CART_DEL_PRODUCT_TO_CART](state, productid) {
    state.items.forEach((item, index) => {
      if (item.productid === productid) {
        state.items.splice(index, 1)
      }
    });
  },
  // 增加一条商品中商品数量的方法
  [types.CART_ADD_PRODUCT_QUANTITY](state, productid) {
    const cartItem = state.items.find(item => item.productid == productid)
    cartItem.quantity++
  },
  // 减少一条商品中商品数量的方法
  [types.CART_DEL_PRODUCT_QUANTITY](state, productid) {
    const cartItem = state.items.find(item => item.productid == productid)
    if (cartItem.quantity > 1) { // 商品数量大于1时才能减少
      cartItem.quantity--
    }
    else cartItem.quantity = 1
  },
  // 改变单条商品的选中不选中状态的方法(单选按钮)
  [types.CART_SET_CHECKOUT_STATUS](state, productid) {
    const cartItem = state.items.find(item => item.productid == productid)
    cartItem.checkoutStatus = !cartItem.checkoutStatus
  },
  // 改变所有商品的选中不选中状态的方法(全选按钮)
  [types.CART_SET_CHECKOUT_STATUS_ALL](state, status) {
    state.items.forEach(item => {
      if (!item.checkoutStatus === status) {
        item.checkoutStatus = status
      }
    })
  }
}

const actions = {
  // 添加购物车的方法,如果此时购物车内有该条商品,就添加商品数量,否则添加商品
  addProductToCart({ state, commit }, product) {
    const cartItem = state.items.find(item => item.productid === product.productid)
    if (!cartItem) {
      commit(types.CART_ADD_PRODUCT_TO_CART, { productid: product.productid })
    } else {
      commit(types.CART_ADD_PRODUCT_QUANTITY, cartItem.productid)
    }
  },
  // 购物车内删除一条商品的方法
  delProductToCart({ commit }, productid) {
    commit(types.CART_DEL_PRODUCT_TO_CART, productid)
  },
  // 添加商品数量的方法
  addProductQuantity({ commit }, productid) {
    commit(types.CART_ADD_PRODUCT_QUANTITY, productid)
  },
  // 减少商品数量的方法
  delProductQuantity({ commit }, productid) {
    commit(types.CART_DEL_PRODUCT_QUANTITY, productid)
  },
  // 切换一条商品的选中状态的方法
  setCheckoutStatus({ commit }, productid) {
    commit(types.CART_SET_CHECKOUT_STATUS, productid)
  },
  // 切换所有商品选中状态的方法
  setCheckoutStatusAll({ commit }, status) {
    commit(types.CART_SET_CHECKOUT_STATUS_ALL, status)
  }
}

export default {
  namespaced: true, // 添加命名空间
  state,
  getters,
  mutations,
  actions
}

上面一大堆的方法,其实最核心的还是getters中的第一个方法cartProducts的返回值;其实这里cartProducts的返回值就是拿到页面上渲染的所有购物车中的商品数据;而购物车中的items中的每一条数据中只存在着三个字段,这里我在items中放置了两条默认的数据。

按照尤大大购物车的demo的思路:一个购物车中的每条数据中是不需要存储到每条商品数据的所有字段的,只需要存在一些关键的字段即可,然后拿着这些字段去products中的查询对应的商品数据就可以了,然后返回这些数据。刚好Vuex中的getter就可以完成这项任务,getter可以维护好这些数据,并且自动更新响应你在购物车页面上对商品数据的一些操作。

我在cartProducts中是使用map方法拿到items中每条商品信息的id,然后拿每一条商品的id到products模块中的存放所有商品信息列表的recommendList中去查询,查询到一项,我就返回一个对象,对象格式如下:

{
	src: product.image, // product的图片地址
    name: product.name, // product的名字
    price: product.price, // product的单价
    productid, // product的id
    quantity, // product的数量,默认为1
    checkoutStatus: checkoutStatus, // product的选中状态
    simpleTotal: quantity * product.price, // 单项product的总价格
}

这上面一个对象就是页面上购物车展示的整条商品的所有信息内容,前面三项都是拿到items中的id到products模块中查询到的字段,接着三项都是items中每条数据的字段,最后一项就是计算了一下当前这条商品的总价,就是拿这件商品的单价乘以这条商品内商品的数量。这个对象严格来说是合并了状态的,因为你拿到的数据是不可能满足购物车中所有的要求的,所以还是有些字段需要你自己定义添加,为什么一些公用的字段不在products中的recommendList每一项添加呢?比如checkoutStatus字段,因为我的商品数据是直接拿小米的部分返回的数据的,我直接放在了本地mock.js模块中,我就没有对那些官方的数据作修改。所以我就把checkoutStatus这个字段添加到items中了,效果也是不影响逻辑的。

这里有必要讲一下就是getters中每个方法的参数,官方定义这些方法可以有四个参数state·、 getters、rootState,rootGetters

  • state:代表当前模块内的state

  • getters:代表当前这个模块内的getters,即getters中的每个方法的第二个参数可以访问到getters中其他函数的返回值

  • rootState:开启了namespace命名空间之后,一个模块可以访问到另外模块的state数据

  • rootGetters:开启了namespace命名空间之后,一个模块可以访问到另外模块的Getters数据

既然讲到了getters的参数,索性就把另外的actions,mutations的中方法的参数讲一下吧

官方定义actions中每个方法接收的参数:

**1. context (一个对象)包含着 { state, commit, rootState,rootGetters,getters ... }等 **

2. 调用时候传进来的参数payload( 载荷 )

  • context: action 函数接受一个与 store 实例具有相同方法和属性的 context 对象 ,可以通过调用context .commit提交一个mutation; context.statecontext.getters 来获取 state 和 getters
  • payload:就是调用action时传进来的参数,多数情况下传进来的参数是一个对象,官方叫这个参数为载荷。

mutations中每个方法接收的参数,state, payload( 载荷 )

  • state:代表当前模块内的state
  • payload( 载荷 ): 其实就是commit时传进来的参数,只官方文档上说 在大多数情况下,载荷应该是一个对象,这样可以包含多个字段并且记录的 mutation 会更易读。意思就是说大多数情况下,提交的参数是一个对象更好一些,也没有强制要求啥的。

然后再重点讲解一下添加购物车的方法和购物车内删除一条商品的方法

添加购物车的方法: addProductToCart({ state, commit }, product)

在第二个参数中传进来一个product是当前页面上每条商品的信息,是一个对象,然后在addProductToCart中用当前这条商品product的id查询一下items中有没有该条商品,如果有该条商品我就commit一个添加商品数量的mutation,如果没有,我就commit一个添加一条商品的方法;在添加该条商品进购物车的mutation中,我每次都是默认添加一条三个字段的对象,和items中每条数据一样的格式,只要state中items一产生变化,getters中的cartProducts就会自动检测到,然后重新计算,重新更新数据,就导致页面上出现该条数据

**购物车内删除一条商品的方法:**delProductToCart({ commit }, productid)

这个方法逻辑上没有什么特别之处,不过我这个方法调用的时候是在一个我自定义的弹窗内调用的,这个自定义弹窗类似于一个plugin,但是又没有那么优雅,我最后只是使用了Vue.extend()封装了两个全局的方法,挂载在Vue.prototype上,一个点击弹窗,(往body中添加一段DOM),一个点击关闭(移除该段DOM),在实现的时候也有一些小坑,可能会在下篇文章分享一下。

至于其他的一些功能,点击添加商品数量减少商品数量,点击全选切换状态,单选切换状态,等都放在mutation中由对应的action触发;每条商品的总价,所有商品数量,选中商品数,所有选中商品的总价,结算按钮显示等。我都放在了getters中,逻辑也不是很难,可以看cart模块中的代码,都有比较详细的注释。

index.js代码

import Vue from 'vue'
import Vuex from 'vuex'
import cart from './module/cart'
import products from './module/products'
import user from './module/user'
Vue.use(Vuex)

export default new Vuex.Store({
  // 设计数据中心 模块
  modules: {  // 分模块
    user, 
    cart, 	  // 购物车 cart 
    products // 商品 products
  }
})

这里就是整合了一下所有模块,合并成一个store。最后在main.js里面全局引入就可以了。

最后实现的效果

总结

最后再理一下整体的流程思路:首先应该分模块,所有商品数据应该放在一个模块,在action中请求回来;购物车中应该存放着自己的商品列表状态,拿购物车中每条商品的id去商品的模块中查询到相应的信息,再结合实际的需求计算出相应的值,一起合并成一个对象,这个对象就是一条商品基本上所有需要显示在页面上的东西了。在组件中取就好了。然后其他对应的一些功能可以分别通过getters和mutations来实现。实现之后就是在组件中去调用这些方法就好了。

一个相对功能还比较健全的购物车就此完成,其实没有很难的代码。但是对还是小白的我来说,我觉得还是不错了,很开心,所以用心写下了这篇文章。然后在写这些方法的时候,用到了数组中的forEach、map、reduce、every、some等方法,个人感觉还是写的比较优雅的。这是我写的第一篇文章,所以写的时候也一直是战战兢兢的,怕自己描述不清,讲错概念什么的,总之也是比较艰辛吧。不过总算是写出来了,也希望自己以后能坚持写一些东西出来,让自己更快的成长。

项目暂时还没开发完,但也放个地址供大家参考:项目地址

希望大家能给个赞,也希望大佬们指出不足的地方,虚心接受,非常感谢观看。