随着前端应用的日渐复杂,状态和数据管理成为了构建大型应用的关键。受 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: [],
}
})
上面的代码可以分为三部分。
- 首先我们导入
Vue
和Vuex
- 然后我们调用
Vue.use
方法,告诉 Vue 我们将使用Vuex
,这和我们之前使用Vue.use(router)
的原理一样 - 最后我们导出
Vuex.Store
实例,并且传入了strict
和state
参数。这里strict
参数表示,我们必须使用 Vuex 的 Mutation 函数来改变state
,否则就会报错(关于 Mutation 我们将在 “使用 Vuex 进行状态管理” 一节讲解)。而state
参数用来存放我们全局的状态,比如我们这里定义了cart
、showLoader
等属性都是后面我们完善应用的内容需要的数据。
将 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
类,我们在实例化的过程中传递了两个参数:strict
和state
,strict
表示我们告诉 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
用于对应从视图层里面发出的事件或动作的名称,这个函数接收两个参数 state
和 payload
,state
就是我们 Vuex Store 中保存的 state
,payload
是被响应的那个事件或动作携带的参数,然后我们通过 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_CART
和REMOVE_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_CART
的mutation
中,在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_CART
的mutation
中,在mutation
中进行本地状态修改,具体修改操作我们可以在后面抽离出的mutations
文件中看到。 - 然后是
template
部分,我们通过v-for
遍历了购物车数组,将购物车中的所有商品信息展示在模板中。并在每个商品信息的最后添加了一个移除购物车的按钮,当用户希望移除购物车中指定商品时,会触发removeFromCart
事件。
查看效果
在项目根目录下运行 npm start
,进入开发服务器查看效果:
可以看到,一开始我们的购物车是空的,然后随便选了两款手机,点击“加入购物车”,然后就可以在购物车页面看到了!我们还可以将购物车中的商品移除。
小结
在这一部分中我们学习了如何发起修改本地状态的“通知”:
- 首先我们需要在
Vuex.Store
实例化的参数中添加一个mutations
属性,在该属性中添加对应的方法,比如ADD_TO_CART
和REMOVE_FROM_CART
。 - 然后我们需要通过用户不同的操作(比如点击添加购物车或者移除购物车)来发起“通知”,进而通过
this.$store.commit
的方式将需要操作的对象作为载荷提交到对应类型(也就是ADD_TO_CART
和REMOVE_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
用于对应从视图层里面分发出的事件或动作的名称 - 函数接收两个参数
context
和payload
context
指的是action
的上下文,与store
实例具有相同的方法和属性,因此我们可以调用context.commit
提交一个mutation
,或者通过context.state
和context.getters
来获取state
和getters
,但是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,
});
})
}
}
});
可以看到,我们做了以下几件事:
- 导入了
axios
,并定义了API_BASE
后端接口根路由; - 我们在
store
中去掉了之前硬编码的假数据,使products
默认值为空数组; - 然后在
mutations
属性中添加了ALL_PRODUCTS
和ALL_PRODUCTS_SUCCESS
方法,用来响应action
中提交的对应类型事件;ALL_PRODUCTS
将state.showLoader
设为true
,显示加载状态;ALL_PRODUCTS_SUCCESS
将action
中提交的数据保存到state
中,并取消加载状态; - 最后添加了
actions
属性,在actions
属性中定义了allProducts
函数用于响应视图层分发的对应类型的事件;我们首先提交了类型为ALL_PRODUCTS
的mutation
,接着在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 === 0
是true
还是false
,如果是true
则证明本地中还没有任何商品,需要向后端获取商品数据,于是通过this.$store.dispatch
的方式触发类型为allProducts
的action
中,在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.json 和 products.json,然后运行以下命令:
mongoimport -d test -c manufacturers manufacturers.json
mongoimport -d test -c products products.json
然后再进入前端测试,你应该就可以看到从后台获取到的数据,然后同样可以添加到购物车哦!
小结
在这一部分中我们学习了如何使用 Action
获取远程数据,并将获取的数据提交到对应的 Mutation
中:
- 首先我们需要导入相关依赖:
axios
和API_BASE
,由于发起网络请求。 - 其次我们需要在
store
实例中添加actions
属性,并在actions
属性定义对应的方法,用于响应视图层分发的对应类型的事件。 - 在不同的方法中发起不同的网络请求,你是需要从后端获取数据,还是修改后端数据等等。然后将后端响应的数据结果提交到对应类型的
mutation
中。
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。