从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(四)
发布于 2 天前 作者 huan1043269994 1072 次浏览 来自 分享

随着前端应用的日渐复杂,状态和数据管理成为了构建大型应用的关键。受 Redux 等项目的启发,Vue.js 团队也量身定做了状态管理库 Vuex。在这篇教程中,我们将带你熟悉 Store、Mutation 和 Action 三大关键概念,并升级迷你商城应用的前端代码。

使用 Vuex 进行状态管理

我们在第一篇第三篇中讲解了 Vue 的基础部分,利用这些知识你已经可以实现一些比较简单的应用了。但是针对复杂的应用,比如组件嵌套超过三级,我们前面讲解的知识处理起来就很费力了,还好 Vue 社区为我们打造了状态管理容器 Vuex,用来处理大型应用的数据和状态管理。

安装 Vuex 依赖

首先我们打开命令行,进入项目目录,执行如下命令安装 Vuex:

npm install vuex

创建 Vuex Store

Vuex 是一个前端状态管理工具,它致力于接管 Vue 的状态,使得 Vue 专心做好渲染页面的事情;它类似在前端建立了一个 “数据库”,然后将所有的前端状态都保存在这个 “数据库” 里面。这个 “数据库” 其实就是一个普通的 JavaScript 对象。

好了,讲述了 Vuex 是干什么之后,我们来看一下如何在 Vue 中运用 Vuex。Vuex 建立的这个 “数据库” 一般用术语 store 来表示,通常我们建立一个单独的 store 文件夹,用于保存和 store 有关的内容。我们在 src 文件夹下建立 store 文件夹,然后在里面创建 index.js 文件,代码如下:

// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  strict: true,
  state: {
    // bought items
    cart: [],
    // ajax loader
    showLoader: false,
    // selected product
    product: {},
    // all products
    products: [
      {
        name: '1',
      }
    ],
    // all manufacturers
    manufacturers: [],
  }
})

上面的代码可以分为三部分。

  • 首先我们导入 VueVuex
  • 然后我们调用 Vue.use 方法,告诉 Vue 我们将使用 Vuex,这和我们之前使用 Vue.use(router) 的原理一样
  • 最后我们导出 Vuex.Store 实例,并且传入了 strictstate 参数。这里 strict 参数表示,我们必须使用 Vuex 的 Mutation 函数来改变 state,否则就会报错(关于 Mutation 我们将在 “使用 Vuex 进行状态管理” 一节讲解)。而 state 参数用来存放我们全局的状态,比如我们这里定义了 cartshowLoader 等属性都是后面我们完善应用的内容需要的数据。

将 Vuex 和 Vue 整合

当我们创建并导出了 Vuex 的 store 实例之后,我们就可以使用它了。打开 src/main.js 文件,在开头导入之前创建的 store ,并将 store 添加到 Vue 初始化的参数列表里,代码如下:

// src/main.js
// ...

import App from './App';
import router from './router';
import store from './store';

Vue.config.productionTip = false;
Vue.component('ValidationProvider', ValidationProvider);
 // ...
new Vue({
  el: '#app',
  router,
  store,
  components: { App },
  template: '<App/>',
});

可以看到,在上面的文件中,我们一开头导入了我们之前在 src/store/index.js 里定义的 store 实例,接着,在 Vue 实例初始化时,我们将这个 store 实例使用对象属性简洁写法添加到了参数列表里。

当我们将 store 当做参数传给 Vue 进行初始化之后,Vue 就会将 Store 里面的 state 注入到所有的 Vue 组件中,这样所有的 Vue 组件共享同一个全局的 state ,它其实就是一个 JS 对象,应用中所有状态的变化都是对 state 进行操作,然后响应式的触发组件的重新渲染,所以这里的 state 也有 “数据的唯一真相来源” 的称谓。

这种将状态保存到一个全局的 JavaScript 对象 – state 中,然后所有的增、删、改、查操作都是对这个 JavaScript 对象进行,使得我们可以避免组件嵌套层级过深时,组件之间传递属性的复杂性,让属性的定义,获取,修改非常直观,方便开发大型应用和团队协作。

查看 Vuex 整合后的效果

在将 Vuex 和 Vue 整合好之后,我们马上来看一下 Vuex 带来的效果,不过在此之前我们先来讲一讲什么是计算属性(computed)。

计算属性(Computed)

首先我们新增了 script 部分,然后在导出的对象里面增加了一个 computed 属性,这个属性里面的内容用于申明一些可能要在 template 里面使用的复杂表达式。我们来看一个例子来讲解一下 computed 属性:

我们在模板中可能要获取一个多级嵌套对象里面的某个数据,或者要渲染的数据需要经过复杂的表达式来计算,比如我们要渲染这样一个数据 obj1.obj2.obj3.a + obj1.obj4.b,写在模板里就是这样的:

<template>
<div>
  {{ obj1.obj2.obj3.a + obj1.obj4.b }}
</div>
</template>
<script>
export default {
  data: {
    obj1: {
      obj2: {
        obj3: {
          a
        }
      },
      obj4: {
        b
      }
    }
  }
}
</script>

可以看到,我们一眼看上去,这个模板里面有这样一个复杂的表达式,很不容易反应出来它到底要渲染什么,这样代码的可读性就很差,所以 Vue 为我们提供了计算属性( computed ),用于用简单的变量来代表复杂的表达式结果,进而简化模板中插值的内容,让我们的模板看起来可读性更好,上面的代码使用计算属性来改进会变成下面这样:

<template>
<div>
  {{ addResult }}
</div>
</template>

<script>
export default {
  data: {
    obj1: {
      obj2: {
        obj3: {
          a
        }
      },
      obj4: {
        b
      }
    }
  },
  computed: {
    addResult() {
      return this.obj1.obj2.obj3.a + this.obj1.obj4.b
    }
  }
}
</script>

可以看到,当我们使用了计算属性 addResult 之后,我们在模板里面的写法就简化了很多,而且一目了然我们是渲染了什么。

了解了计算属性之后,我们打开 src/pages/admin/Products.vue,对内容作出如下改进以查看 Vuex 和 Vue 整合之后的效果:

<!-- src/pages/admin/Products.vue -->
<!-- ... -->
    <div class="title">
      <h1>This is Admin</h1>
    </div>
    <div class="body">
      {{ product.name }}
    </div>
  </div>
</template>

<script>
export default {
  computed: {
    product() {
      return this.$store.state.products[0];
    }
  }
}
</script>

可以看到,上面的内容改进主要分为两个部分:

  • 首先我们定义了一个 product 计算属性,它里面返回一个从 store 中保存的 state 取到的 products 数组的第一个元素,注意到当我们在 “将 Vuex 和 Vue 整合” 这一小节中将 store 作为 Vue 初始化实例参数,所以我们在所有的 Vue 组件中可以通过 this.$store.state 的形式取到 Vuex Store 中保存的 state
  • 接着我们使用了计算属性 product,取到了它的 name 属性进行渲染。

小结

在这一小节中,我们学习了如何将 Vuex 整合进 Vue 中:

  • 首先我们安装了 vuex 依赖
  • 接着我们在 src 下面创建了 store 文件夹,用于保存 Vuex 相关的内容,在 store 文件下之下,我们创建了 index.js 文件,在里面实例化了 Vuex.Store 类,我们在实例化的过程中传递了两个参数:strictstatestrict 表示我们告诉 Vue,只允许 Mutation 方法才能修改 state,确保修改状态的唯一性;state 是我们整个应用的状态,整个应用的状态都是从它获取,整个应用状态的改变都是修改它,所以这个 state 也有 “数据的唯一真相来源” 的称谓。
  • 然后我们通过在 main.js 里面导入实例化的 store,将它加入到初始化 Vue 实例的参数列表中,实现了 Vuex 和 Vue 的整合。
  • 最后我们讲解了计算属性,然后通过在计算属性中获取 this.$store.state 的方式展示了 Vuex 整合之后的效果。

好了,我们已经整合了 Vuex,并在 Vue 组件中获取了保存在 Vuex Store 中的状态(state),接下来我们来看一下如何修改这个状态。

使用 Mutation 修改本地状态

我们在上一节中定义了 Vuex Store,并在里面保存了全局的状态 state,这一节我们来学习如何修改这一状态。

理解 Mutation:修改状态的唯一手段

Vuex 为我们提供了 Mutation,它是修改 Vuex Store 中保存状态的唯一手段。

Mutation 是定义在 Vuex Store 的 mutation 属性中的一系列形如 (state, payload) => newState 的函数,用于响应从 Vue 视图层发出来的事件或动作,例如:

ACTION_NAME(state, payload) {
  // 对 `state` 进行操作以返回新的 `state`
  return newState;
}

其中方法名 ACTION_NAME 用于对应从视图层里面发出的事件或动作的名称,这个函数接收两个参数 statepayloadstate 就是我们 Vuex Store 中保存的 statepayload 是被响应的那个事件或动作携带的参数,然后我们通过 payload 的参数来操作现有的 state,返回新的 state,通过这样的方式,我们就可以响应修改 Vuex Store 中保存的全局状态。

了解了 Mutation 的概念之后,我们马上来看一下如何运用它。

初始化状态(硬编码)

我们打开 src/store/index.js 文件,修改其中的 state 并加上 mutations 如下:

// src/store/index.js
// ...
    // all products
    products: [
      {
        _id: '1',
        name: 'iPhone',
        description: 'iPhone是美国苹果公司研发的智能手机系列,搭载苹果公司研发的iOS操作系统',
        image: 'https://i.gadgets360cdn.com/large/iPhone11_leak_1567592422045.jpg',
        price: 2000,
        manufacturer: 'Apple Inc'
      },
      {
        _id: '2',
        name: '荣耀20',
        description: '李现同款 4800万超广角AI四摄 3200W美颜自拍 麒麟Kirin980全网通版8GB+128GB 蓝水翡翠 全面屏手机',
        image: 'https://article-fd.zol-img.com.cn/t_s640x2000/g4/M08/0E/0E/ChMlzF2myueILMN_AAGSPzoz23wAAYJ3QADttsAAZJX090.jpg',
        price: 2499,
        manufacturer: '华为'
      },
      {
        _id: '3',
        name: 'MIX2S',
        description: '骁龙845 全面屏NFC 游戏智能拍照手机 白色 全网通 6+128',
        image: 'http://himg2.huanqiu.com/attachment2010/2018/0129/08/39/20180129083933823.jpg',
        price: 1688,
        manufacturer: '小米'
      },
      {
        _id: '4',
        name: 'IQOO Pro',
        description: '12GB+128GB 竞速黑 高通骁龙855Plus手机 4800万AI三摄 44W超快闪充 5G全网通手机',
        image: 'https://www.tabletowo.pl/wp-content/uploads/2019/08/vivo-iqoo-pro-5g-blue-1.jpg',
        price: 4098,
        manufacturer: 'Vivo'
      },
      {
        _id: '5',
        name: 'Reno2',
        description: '【12期免息1年碎屏险】4800万变焦四摄8+128G防抖6.5英寸全面屏新 深海夜光(8GB+128GB) 官方标配',
        image: 'https://news.maxabout.com/wp-content/uploads/2019/08/OPPO-Reno-2-1.jpg',
        price: 2999,
        manufacturer: 'OPPO'
      }
    ],
    // all manufacturers
    manufacturers: [],
  },
  mutations: {
    ADD_TO_CART(state, payload) {
      const { product } = payload;
      state.cart.push(product)
    },
    REMOVE_FROM_CART(state, payload) {
      const { productId } = payload
      state.cart = state.cart.filter(product => product._id !== productId)
    }
  }
});

可以看到上面的代码改进分为两个部分:

  • 首先我们扩充了 state 中的 products 属性,在里面保存一开始我们的迷你电商平台的初始数据,这里我们是硬编码到代码中的,在下一节 “使用 Action 获取远程数据”中,我们将动态获取后端服务器的数据。
  • 接着我们在 Vuex.Store 实例化的参数中添加了一个 mutations 属性,在里面定义了两个函数 ADD_TO_CARTREMOVE_FROM_CART,分别代表响应从视图层发起的对应将商品添加至购物车和从购物车移除商品的动作。

创建 ProductList 组件

接着创建 src/components/products/ProductList.vue 文件,它是商品列表组件,用来展示商品的详细信息,代码如下:

<!-- src/components/products/ProductList.vue -->
<template>
  <div>
    <div class="products">
      <div class="container">
        This is ProductList
      </div>
      <template v-for="product in products">
        <div :key="product._id" class="product">
          <p class="product__name">产品名称:{{product.name}}</p>
          <p class="product__description">介绍:{{product.description}}</p>
          <p class="product__price">价格:{{product.price}}</p>
          <p class="product.manufacturer">生产厂商:{{product.manufacturer}}</p>
          <img :src="product.image" alt="" class="product__image">
          <button @click="addToCart(product)">加入购物车</button>
        </div>
      </template>
    </div>
  </div>
</template>

<style>
.product {
  border-bottom: 1px solid black;
}

.product__image {
  width: 100px;
  height: 100px;
}
</style>

<script>
export default {
  name: 'product-list',
  computed: {
    // a computed getter
    products() {
      return this.$store.state.products;
    }
  },
  methods: {
    addToCart(product) {
      this.$store.commit('ADD_TO_CART', {
        product
      });
    }
  }
}
</script>

我们首先来看该组件的 script 部分:

  • 先定义了一个计算属性 products,通过 this.$store.state.products 从本地状态中获取到了 products 数组,并作为计算属性 products 的返回值
  • 然后定义了一个点击事件 addToCart,并且传入了当前处于激活状态的 product 参数。当用户点击“添加购物车”时,触发 addToCart 事件,也就是上面所说的视图层发出的事件。这里是通过 this .$store.commit 将携带当前商品的对象 {product} 作为载荷提交到类型为ADD_TO_CARTmutation 中,在 mutation 中进行本地状态修改,我们会在后面抽离出的 mutations 文件中看到具体的操作。

再看该组件的 template 部分,使用 v-for 将从本地获取到的 products 数组进行遍历,每个 product 对象的详细信息都会显示在模板中。此外,我们还在每个 product 对象信息的最后添加了一个“加入购物车”的按钮,允许我们将指定商品添加到购物车。

在页面中接入数据

Store 和组件都搞定之后,我们就可以在之前的页面中接入数据了。修改主页 src/pages/Home.vue,代码如下:

<!-- src/pages/Home.vue -->
<template>
  <div>
    <div class="title">
      <h1>In Stock</h1>
    </div>
    <product-list></product-list>
  </div>
</template>

<script>
import ProductList from '@/components/products/ProductList.vue';
  export default {
    name: 'home',
    data () {
      <!-- ... -->
        msg: 'Welcome to Your Vue.js App'
      };
    },
    components: {
      'product-list': ProductList
    }
  }
</script>

可以看到,我们在导入 ProductList 组件后,将其注册到 components 中,然后在模板中使用这个组件。

接着修改购物车页面 src/pages/Cart.vue 文件,将购物车中的商品信息展示出来,添加代码如下:

<!-- src/pages/Cart.vue -->
<!-- ... -->
    <div class="title">
      <h1>{{msg}}</h1>
    </div>
    <template v-for="product in cart">
      <div :key="product._id" class="product">
        <p class="product__name">产品名称:{{product.name}}</p>
        <p class="product__description">介绍:{{product.description}}</p>
        <p class="product__price">价格:{{product.price}}</p>
        <p class="product.manufacturer">生产厂商:{{product.manufacturer}}</p>
        <img :src="product.image" alt="" class="product__image">
        <button @click="removeFromCart(product._id)">从购物车中移除</button>
      </div>
    </template>
  </div>
</template>

<style>
.product {
  border-bottom: 1px solid black;
}

.product__image {
  width: 100px;
  height: 100px;
}
</style>

<script>
  export default {
    name: 'home',
    <!-- ... -->
      return {
        msg: 'Welcome to the Cart Page'
      }
    },
    computed: {
      cart() {
        return this.$store.state.cart;
      }
    },
    methods: {
      removeFromCart(productId) {
        this.$store.commit('REMOVE_FROM_CART', {
          productId
        });
      }
    }
  }
</script>

我们在该组件中主要增加了两部分代码:

  • 首先是 script 部分,我们增加了一个计算属性和一个点击事件。同样是通过 this.$store.state.cart 的方式从本地状态中获取购物车数组,并作为计算属性 cart 的返回值;当用户点击购物车中的某个商品将其移除购物车时就会触发 removeFromCart 事件,并且将要移除的商品 id 作为参数传入,然后也是通过 this.$store.commit 的方式将包含 productId 的对象作为载荷提交到类型为 REMOVE_FROM_CARTmutation 中,在 mutation 中进行本地状态修改,具体修改操作我们可以在后面抽离出的 mutations 文件中看到。
  • 然后是 template 部分,我们通过 v-for 遍历了购物车数组,将购物车中的所有商品信息展示在模板中。并在每个商品信息的最后添加了一个移除购物车的按钮,当用户希望移除购物车中指定商品时,会触发 removeFromCart 事件。

查看效果

在项目根目录下运行 npm start,进入开发服务器查看效果:

可以看到,一开始我们的购物车是空的,然后随便选了两款手机,点击“加入购物车”,然后就可以在购物车页面看到了!我们还可以将购物车中的商品移除。

小结

在这一部分中我们学习了如何发起修改本地状态的“通知”:

  • 首先我们需要在 Vuex.Store 实例化的参数中添加一个 mutations 属性,在该属性中添加对应的方法,比如 ADD_TO_CARTREMOVE_FROM_CART
  • 然后我们需要通过用户不同的操作(比如点击添加购物车或者移除购物车)来发起“通知”,进而通过 this.$store.commit 的方式将需要操作的对象作为载荷提交到对应类型(也就是 ADD_TO_CARTREMOVE_FROM_CART)的 mutation 中,在 mutation 中进行本地状态修改。

使用 Action 获取远程数据

我们在上一节中学习了如何在视图层发起本地状态修改的“通知”,这一节我们来学习如何从后端获取远程数据。请求库我们采用的是 axios,通过以下命令安装依赖:

npm install axios

理解 Action:异步操作

Vuex 为我们提供了 Action,它是用来进行异步操作,我们可以在这里向后端发起网络数据请求,并将请求到的数据提交到对应的 mutation 中。

Action 是定义在 Vuex Store 的 action 属性中的一系列方法,用于响应从 Vue 视图层分发出来的事件或动作,一个 Action 是形如 (context, payload) => response.data 的函数:

productById(context, payload) {
  // 进行异步操作,从后端获取远程数据并返回
  return response.data;
}

其中:

  • 函数名 productById 用于对应从视图层里面分发出的事件或动作的名称
  • 函数接收两个参数 contextpayload
  • context 指的是 action 的上下文,与 store 实例具有相同的方法和属性,因此我们可以调用 context.commit 提交一个 mutation,或者通过context.statecontext.getters 来获取 stategetters,但是 context 对象又不是 store 实例本身
  • payload 是分发时携带的参数,然后我们通过 payload 中的参数来进行异步操作,从而获取后端响应数据并返回。这样我们就可以根据用户的操作同步更新后端数据,并将后端响应的数据提交给 mutation,然后利用mutation 进行本地数据更新。

实现第一个 Action

让我们趁热打铁,实现第一个 Action。再次来到 src/store/index.js 文件,修改代码如下:

// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';

const API_BASE = 'http://localhost:3000/api/v1';

Vue.use(Vuex);

// ...
    // selected product
    product: {},
    // all products
    products: [],
    // all manufacturers
    manufacturers: [],
  },
  // ...
    REMOVE_FROM_CART(state, payload) {
      const { productId } = payload
      state.cart = state.cart.filter(product => product._id !== productId)
    },
    ALL_PRODUCTS(state) {
      state.showLoader = true;
    },
    ALL_PRODUCTS_SUCCESS(state, payload) {
      const { products } = payload;

      state.showLoader = false;
      state.products = products;
    }
  },
  actions: {
    allProducts({ commit }) {
      commit('ALL_PRODUCTS')

      axios.get(`${API_BASE}/products`).then(response => {
        console.log('response', response);
        commit('ALL_PRODUCTS_SUCCESS', {
          products: response.data,
        });
      })
    }
  }
});

可以看到,我们做了以下几件事:

  1. 导入了 axios,并定义了 API_BASE 后端接口根路由;
  2. 我们在 store 中去掉了之前硬编码的假数据,使 products 默认值为空数组;
  3. 然后在 mutations 属性中添加了 ALL_PRODUCTSALL_PRODUCTS_SUCCESS 方法,用来响应 action 中提交的对应类型事件;ALL_PRODUCTSstate.showLoader 设为 true,显示加载状态;ALL_PRODUCTS_SUCCESSaction 中提交的数据保存到 state 中,并取消加载状态;
  4. 最后添加了 actions 属性,在 actions 属性中定义了 allProducts 函数用于响应视图层分发的对应类型的事件;我们首先提交了类型为 ALL_PRODUCTSmutation ,接着在 axios 请求成功后提交 ALL_PRODUCTS_SUCCESS,并附带 products 数据体(payload

提示

我们可以看到 allProducts 方法中传入了 { commit } 参数,这是采用了解构赋值的方式 const { commit } = context,避免后面使用 context.commit 过于繁琐。

更新 ProductList 组件

再来看 src/components/products/ProductList.vue 文件,我们对其做了修改,主要添加了生命周期函数 created,在该组件刚被创建时首先判断本地 products 中是否有商品,如果没有就向后端发起网络请求获取数据。代码如下:

<!-- src/components/products/ProductList.vue -->
<!-- ... -->
          <p class="product__name">产品名称:{{product.name}}</p>
          <p class="product__description">介绍:{{product.description}}</p>
          <p class="product__price">价格:{{product.price}}</p>
          <p class="product.manufacturer">生产厂商:{{product.manufacturer.name}}</p>
          <img :src="product.image" alt="" class="product__image">
          <button @click="addToCart(product)">加入购物车</button>
        </div>
      <!-- ... -->
<script>
export default {
  name: 'product-list',
  created() {
    if (this.products.length === 0) {
      this.$store.dispatch('allProducts')
    }
  },
  computed: {
    // a computed getter
    products() {
      <!-- ... -->

注意到我们修改了两个地方:

  • 调整模板中“生产厂商”字段,把 {{product.manufacturer}} 修改为 {{product.manufacturer.name}}
  • 添加 created 生命周期方法,在该组件刚被创建时判断 this.products.length === 0true 还是 false,如果是 true 则证明本地中还没有任何商品,需要向后端获取商品数据,于是通过 this.$store.dispatch 的方式触发类型为 allProductsaction 中,在 action 中进行异步操作,发起网络请求向后端请求商品数据并返回;如果是 false 则证明本地中存在商品,所以可以直接从本地获取然后进行渲染。

最后我们也同样需要调整一下 src/pages/Cart.vue 中的“生产厂商”字段,修改其模板代码如下:

<!-- src/pages/Cart.vue -->
<!-- ... -->
        <p class="product__name">产品名称:{{product.name}}</p>
        <p class="product__description">介绍:{{product.description}}</p>
        <p class="product__price">价格:{{product.price}}</p>
        <p class="product.manufacturer">生产厂商:{{product.manufacturer.name}}</p>
        <img :src="product.image" alt="" class="product__image">
        <button @click="removeFromCart(product._id)">从购物车中移除</button>
      </div>
    <!-- ... -->

同样把 {{product.manufacturer}} 修改为 {{product.manufacturer.name}}

查看效果

在测试这一步效果之前,首先确保 MongoDB 和后端 API 服务器已经开启。同时,如果你之前没有在第二篇教程中测试过,很有可能你的数据库是空的,那么可以下载我们提供的 MongoDB JSON 数据文件 manufacturers.jsonproducts.json,然后运行以下命令:

mongoimport -d test -c manufacturers manufacturers.json
mongoimport -d test -c products products.json

然后再进入前端测试,你应该就可以看到从后台获取到的数据,然后同样可以添加到购物车哦!

小结

在这一部分中我们学习了如何使用 Action 获取远程数据,并将获取的数据提交到对应的 Mutation 中:

  • 首先我们需要导入相关依赖:axiosAPI_BASE,由于发起网络请求。
  • 其次我们需要在 store 实例中添加 actions 属性,并在 actions 属性定义对应的方法,用于响应视图层分发的对应类型的事件。
  • 在不同的方法中发起不同的网络请求,你是需要从后端获取数据,还是修改后端数据等等。然后将后端响应的数据结果提交到对应类型的 mutation 中。

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。

回到顶部