VueJS2 高级教程(九)
原文:Pro Vue.js 2
二十、使用数据存储
在这一章中,我将向您展示如何使用 Vuex 包来创建数据存储,它提供了一种在应用中共享数据和在组件之间安排协调的替代方法。表 20-1 将 Vuex 数据存储放在上下文中。
表 20-1
将 Vuex 数据存储放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | 数据存储是应用状态的公共存储库,由 Vuex 包管理,它是 Vue.js 项目的正式组成部分。 |
| 为什么有用? | 数据存储可以通过应用使数据易于使用,从而简化数据管理。 |
| 如何使用? | 使用 Vuex 包创建一个数据存储,并在main.js文件中注册,这样每个组件都可以通过一个特殊的$store属性访问数据存储。 |
| 有什么陷阱或限制吗? | 当您第一次开始使用 Vuex 数据存储时,它以一种特殊的方式工作,这是违反直觉的。在您习惯 Vuex 方法之前,最好启用严格模式,尽管您必须记住在部署应用之前禁用该特性。 |
| 有其他选择吗? | 如果你不能使用 Vuex,那么你可以使用依赖注入特性和事件总线模式,如第十八章所述,来达到类似的结果。 |
表 20-2 总结了本章内容。
表 20-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 创建数据存储 | 定义一个新模块,用它注册 Vuex 插件并创建一个Vuex.Store对象 | six |
| 注册数据存储 | 导入数据存储模块,并将存储属性添加到 Vue 对象的配置中 | seven |
| 访问组件中的数据存储 | 使用$store属性 | eight |
| 在数据存储中进行更改 | 引发突变 | nine |
| 在数据存储中定义计算的特性 | 使用吸气剂 | 10–13 |
| 在数据存储中执行异步任务 | 使用一个动作 | 14–16 |
| 观察数据存储的变化 | 使用观察器 | 17–19 |
| 将数据存储功能映射到组件计算的属性和方法 | 使用映射函数 | Twenty |
| 将数据存储分成单独的文件 | 创建其他数据存储模块 | 21–24 |
| 将模块中的特征与数据存储的其余部分分开 | 使用名称空间功能 | 25, 26 |
为本章做准备
在本章中,我继续使用第十九章中的 productapp 项目。要启动 RESTful web 服务,打开命令提示符并运行清单 20-1 中的命令。
小费
你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书其他章节的示例项目。
npm run json
Listing 20-1Starting the Web Service
打开第二个命令提示符,导航到productapp目录,运行清单 20-2 中所示的命令,下载并安装 Vuex 包。
npm install vuex@3.0.1
Listing 20-2Installing the Vuex Package
为了准备本章,我从管理产品数据和使用事件总线的ProductDisplay组件中删除了所有语句,只留下了当用户点击按钮时调用的空方法,如清单 20-3 所示。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm btn-danger"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew">
Create New
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) { }
}
}
</script>
Listing 20-3Simplifying the Contents of the ProductDisplay.vue File in the src/components Folder
我还从ProductEditor组件中移除了事件总线,如清单 20-4 所示。
<template>
<div>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<button class="btn btn-secondary" v-on:click="cancel">Cancel</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() { },
cancel() { }
}
}
</script>
Listing 20-4Simplifying the Contents of the ProductEditor.vue File in the src/components Folder
保存更改并运行productapp目录中清单 20-5 所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 20-5Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080,在那里您将看到示例应用,如图 20-1 所示。
图 20-1
运行示例应用
创建和使用数据存储
开始共享应用状态的过程包括几个步骤,但是一旦基本的数据存储就绪,应用就可以很容易地扩展,并且最初的时间投资是值得的。惯例是在名为store的文件夹中创建 Vuex 存储。我创建了src/store文件夹,并在其中添加了一个名为index.js的文件,其内容如清单 20-6 所示。
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
})
Listing 20-6The Contents of the index.js File in the src/store Folder
这是一个基本的 Vuex 数据存储。获得商店的基本结构很重要,所以我将一步一步地检查清单 20-6 中的代码。前两条语句从模块中导入 Vue.js 和 Vuex 功能。
...
import Vue from "vue";
import Vuex from "vuex";
...
下一条语句启用 Vuex 功能,如下所示:
...
Vue.use(Vuex);
...
Vuex 是作为 Vue.js 插件提供的,它允许核心 Vue.js 特性被扩展,正如我在第二十六章中所描述的,插件是用Vue.use方法安装的。下一条语句创建数据存储,并使其成为 JavaScript 模块的默认导出:
...
export default new Vuex.Store({
...
关键字new用于创建一个新的Vuex.Store对象,它接受一个配置对象作为它的参数。
理解分离状态和突变
使用数据存储最具挑战性的一个方面是,数据是只读的,所有的更改都是使用称为突变的独立函数进行的。当创建数据存储时,应用的数据是使用一个state属性定义的,可以对该数据进行的一组更改是使用一个mutations属性指定的。对于清单 20-6 中定义的数据存储,有一个数据项。
...
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
...
属性state已经被用来定义一个属性products,我已经给它分配了一个对象数组。清单 20-6 中的数据存储也定义了两个突变。
...
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
...
突变是接收数据存储的当前状态和一个可选的有效负载参数的函数,该有效负载参数提供进行更改的上下文。在这个例子中,saveProduct变异是一个接收对象并将其添加到products数组或替换数组中现有对象的函数,而deleteProduct变异是一个接收对象并使用id值从products数组中定位和移除相应对象的函数。
小费
注意,在清单 20-6 的saveProduct变异中,我使用了Vue.set来替换数组中的一个项目。如第十三章所述,数据存储与 Vue.js 应用的其他部分有相同的变化检测限制。
变异函数使用第一个函数参数访问数据存储的当前状态,如下所示:
...
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
...
在突变中不使用this关键字,只有通过第一个参数才能访问数据。大多数开发人员习惯于自由地读写数据,因此定义单独的函数来进行更改可能会感觉很笨拙。但是,正如您将了解到的,这种方法有一些好处,在大型复杂的应用中尤其有价值。
提供对 Vuex 数据存储的访问
一旦创建了数据存储,下一步就是让它对应用的组件可用。这是通过在main.js文件中配置Vue对象来完成的,如清单 20-7 所示。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import { RestDataSource } from "./restDataSource";
import store from "./store";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
data: {
eventBus: new Vue()
},
store,
provide: function () {
return {
eventBus: this.eventBus,
restDataSource: new RestDataSource(this.eventBus)
}
}
}).$mount('#app')
Listing 20-7Configuring the Vuex Data Store in the main.js File in the src Folder
import语句用于从store文件夹中导入模块(这将自动加载index.js文件)并为其指定名称store,然后用于在配置对象上定义一个属性,该属性用于创建Vue对象。这样做的效果是将在index.js文件中创建的 Vuex 存储对象作为特殊变量$store在所有组件中可用,如下一节所示。
使用数据存储
当您创建数据存储时,数据及其变体的分离感觉很笨拙,但是它非常适合组件的结构。在清单 20-8 中,我已经更新了ProductDisplay组件,以便它从数据存储中读取数据,并在点击Delete按钮时使用deleteProduct变异。
...
<script>
export default {
//data: function () {
// return {
// products: []
// }
//},
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-8Using the Data Store in the ProductDisplay.vue File in the src/components Folder
访问商店中的数据是通过添加到清单 20-7 中所示的Vue配置对象中创建的$store属性来完成的。因为数据存储值是只读的,所以它们作为computed属性集成到组件中,在本例中,我用从数据存储返回值的同名计算属性替换了products数据属性。
...
products() {
return this.$store.state.products;
}
...
$store属性返回数据存储,state属性用于访问单个数据属性,例如本例中使用的products属性。
使用严格模式来避免直接的状态改变
默认情况下,Vuex 不强制分离状态和操作,这意味着组件能够访问和修改在商店的state部分中定义的属性。人们很容易忘记不应该直接进行更改,这样做意味着像 Vue Devtools 这样的调试工具不会检测到所做的更改。
为了帮助避免意外的更改,Vuex 提供了一个strict设置来监控存储的状态属性,如果直接进行了更改,就会抛出一个错误。通过向用于创建存储的配置对象添加一个strict属性来启用该特性,如下所示:
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
strict: true,
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products
.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products
.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
})
此设置只应在开发期间使用,因为它依赖于可能会影响应用性能的昂贵操作。
对数据存储应用突变是通过$store.commit方法完成的,类似于触发事件。comment方法的第一个参数是应该应用的变异的名称,表示为一个字符串,后面跟着一个可选的有效载荷参数,为变异提供上下文。在这个例子中,我已经设置了组件的deleteProduct方法的主体,当用户点击一个Delete方法时触发,以应用同名的变异:
...
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
...
一旦理解了如何读取和修改值,就可以将数据存储集成到应用中。在清单 20-9 中,我已经更新了ProductEditor组件,以便它修改数据存储来创建新产品。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.commit("saveProduct", this.product);
this.product = {};
},
cancel() { }
}
}
</script>
...
Listing 20-9Using the Data Store in the ProductEditor.vue File in the src/components Folder
对save方法进行了更新,以应用数据存储的addProduct变异,这将向数据存储的state部分的products数组中添加一个新产品。组件更改的结果是用户可以填写表单字段,点击创建按钮添加新产品,点击删除按钮删除新产品,如图 20-2 所示。
图 20-2
使用数据存储
访问组件外部的数据存储
可以使用应用中任何组件的$store属性来访问数据存储。如果您想访问应用中非组件部分的数据存储,那么您可以使用import语句,如下所示:
...
import dataStore from "../store";
...
这句话来自第二十四章的后面一个例子,我在一个不包含组件的代码文件中使用数据存储。该语句将数据存储分配给名称dataStore,跟随from关键字的表达式是包含index.js文件的store文件夹的路径。一旦使用了import语句,就可以像这样访问数据存储功能:
...
dataStore.commit("setComponentLoading", true);
...
这是第二十四章中同一示例的另一个陈述,您可以看到该功能在上下文中的使用。
检查数据存储更改
使用数据存储的主要优点是,它将应用的数据与组件分离开来,这使组件保持简单,允许它们轻松地进行交互,并使整个项目更容易理解和测试。
仅通过突变来执行更改使得跟踪对存储数据所做的更改成为可能。Vue Devtools 浏览器插件已经集成了对使用 Vuex 的支持,如果你打开浏览器的 F12 工具并导航到 Vue 选项卡,你会看到一个带有时钟图标的按钮,点击它会显示 Vuex 商店中的数据,如图 20-3 所示。
图 20-3
检查数据存储
您可以看到,state部分包含一个products属性,其值是三个对象的数组。您可以浏览数组中的每个对象,并查看它定义的属性。
保持 F12 工具窗口打开,使用主浏览器窗口通过输入表 20-3 中所示的详细信息创建三个新项目。在输入每组详细信息后,单击 Create 按钮,这样一个新项目就会显示在由ProductDisplay组件显示的产品表中。
表 20-3
用于测试 Vuex 商店的产品
|身份
|
名字
|
种类
|
价格
| | --- | --- | --- | --- | | One hundred | 运动鞋 | 运转 | One hundred | | One hundred and one | 滑雪板 | 冬季运动 | Five hundred | | One hundred and two | 手套 | 冬季运动 | Thirty-five |
创建完所有三个产品后,单击手套产品的删除按钮,这会将其从列表中删除。
当您进行每个更改时,Vue Devtools 选项卡的 Vuex 部分将显示应用于数据的每个突变的效果,以及商店数据的快照和为每个更改提供的有效负载参数的详细信息,如图 20-4 所示。
图 20-4
数据存储的发展状态
可以撤消每个更改,以展开数据存储的状态并将其返回到早期阶段,并且可以导出整个状态,然后稍后再重新导入。能够看到对存储进行的每个更改以及这些更改产生的影响是一个强大的工具,可以简化复杂问题的调试,尽管它需要一定程度的规则来确保更改仅通过突变应用,因为直接对状态属性进行的更改不会被检测到(如果您发现很难记住使用突变,请参见关于 Vuex 严格模式的“使用严格模式避免直接状态更改”侧栏)。
在数据存储中定义计算的特性
当您将应用的数据移动到一个存储中时,您经常会发现几个组件必须对一个状态值执行相同的操作才能获得它们需要的数据。与其在每个组件中复制相同的代码,不如使用 Vuex getters 特性,它相当于组件中的计算属性。在清单 20-10 中,我在数据存储中添加了两个 getters 来转换产品数据。
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
},
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return getters.orderedProducts.filter(p => p.price > 100);
}
}
})
Listing 20-10Adding Getters in the index.js File in the src/store Folder
通过向存储的配置对象添加一个getters属性来定义 Getters。每个 getter 都是一个接收对象的函数,该对象提供对数据存储的状态属性的访问,其结果是计算出的值。我定义的第一个 getter 通过 state 参数访问 products 数组来对产品数据进行排序。
...
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
...
一个 getter 可以通过定义第二个参数来构建另一个 getter 的结果,这允许 getter 的函数接收一个对象,该对象提供对存储中其他 getter 的访问。我定义的第二个 getter 获取由orderedProducts getter 产生的排序后的产品,然后过滤它们,只选择那些price属性大于 100 的产品。
...
filteredProducts(state, getters) {
return getters.orderedProducts.filter(p => p.price > 100);
}
...
能够编写 getters 来创建更复杂的结果是一个有用的特性,有助于减少代码重复。
警告
Getters 不应更改存储区中的数据。正是因为这个原因,我在orderedProducts getter 中使用了concat方法,因为sort方法对数组中的对象进行了排序。concat方法生成一个新数组,并确保读取 getter 的值不会导致存储的状态数据发生变化。
在组件中使用 Getter
读取一个 getter 是通过this.$store.getters属性来完成的,并使用 getter 的名称来读取,这样就可以使用this.$store.getters.myGetter来读取一个名为myGetter的 getter。在清单 20-11 中,我已经更改了由ProductDisplay组件定义的 computed 属性,这样它就可以从filteredProducts getter 获取数据。
...
<script>
export default {
computed: {
products() {
return this.$store.getters.filteredProducts;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-11Using a Getter in the ProductDisplay.vue File in the src/components Folder
组件模板中的v-for绑定读取products计算属性的值,该属性从数据存储中的 getter 获取其值。结果是向用户显示一组经过过滤和排序的产品,如图 20-5 所示。
图 20-5
使用数据存储 getter
向 Getters 提供参数
Getters 还可以接收组件提供的附加参数,这些参数可以用来影响组件生成的值。在清单 20-12 中,我修改了filteredProducts getter,这样用于过滤产品对象的数量可以作为一个参数指定。
...
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return (amount) => getters.orderedProducts.filter(p => p.price > amount);
}
}
...
Listing 20-12Using a Getter Argument in the index.js File in the src/store Folder
语法有些笨拙,但是要接收参数,getter 必须返回一个函数,当 getter 被读取并由组件提供参数时,该函数将被调用。在这个例子中,getter 的结果是一个函数,它接收一个在过滤产品对象时应用的amount参数。在清单 20-13 中,我更新了由ProductDisplay组件定义的 computed 属性,将一个值传递给 getter。
...
<script>
export default {
computed: {
products() {
return this.$store.getters.filteredProducts(175);
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-13Using a Getter Argument in the ProductDisplay.vue File in the src/components Folder
结果是只显示一个示例产品对象,因为它的价格是唯一超过组件指定值的价格,如图 20-6 所示。
图 20-6
使用 getter 参数
执行异步操作
变异执行同步操作,这意味着它们非常适合处理本地数据,但不适合处理 RESTful web 服务。对于异步任务,Vuex 提供了一个名为 actions 的特性,它允许执行任务,并通过突变将更改反馈到数据存储中。在清单 20-14 中,我向数据存储添加了消费 RESTful web 服务的动作。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
state: {
products: []
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
},
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return (amount) => getters.orderedProducts.filter(p => p.price > amount);
}
},
actions: {
async getProductsAction(context) {
(await Axios.get(baseUrl)).data
.forEach(p => context.commit("saveProduct", p));
},
async saveProductAction(context, product) {
let index = context.state.products.findIndex(p => p.id == product.id);
if (index == -1) {
await Axios.post(baseUrl, product);
} else {
await Axios.put(`${baseUrl}${product.id}`, product);
}
context.commit("saveProduct", product);
},
async deleteProductAction(context, product) {
await Axios.delete(`${baseUrl}${product.id}`);
context.commit("deleteProduct", product);
}
}
})
Listing 20-14Adding Actions in the index.js File in the src/store Folder
通过向存储的配置对象添加一个actions属性来定义动作,每个动作都是一个接收上下文对象的函数,该对象提供对状态、getters 和突变的访问。动作不允许直接修改状态数据,必须使用commit方法通过突变来工作。在清单中,我已经从products数组中移除了虚拟数据,并定义了三个动作,它们使用 Axios 与 RESTful web 服务通信,并使用其变体更新数据存储。
小费
你不必在给你的行为起的名字中包含Action这个词。我喜欢确保我的行为和突变是明确区分的,但这只是个人偏好,并不是一个要求。
在清单 20-15 中,我已经更新了ProductDisplay组件,这样当用户点击删除按钮时,它就可以使用商店的动作从 web 服务获取初始数据。
...
<script>
export default {
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.dispatch("deleteProductAction", product);
}
},
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
...
Listing 20-15Using Actions in the ProductDisplay.vue File in the src/components Folder
使用dispatch方法调用动作,该方法接受要使用的参数的名称和将传递给动作的可选参数,类似于使用突变的方式。没有直接支持在创建 Vuex 商店时填充它,所以我使用了组件的created方法,在第十七章中描述,来调用getProductsAction动作并从 web 服务获取初始数据。(我还修改了用于products computed 属性的数据,使其从状态数据而不是 getters 中读取,确保显示从服务器接收的所有数据。)
在清单 20-16 中,我已经更新了ProductEditor组件,以便它使用动作来存储或更新 web 服务中的数据。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() { }
}
}
</script>
...
Listing 20-16Using Action in the ProductEditor.vue File in the src/components Folder
结果是从 web 服务中获取数据,如图 20-7 所示,创建或删除对象会导致服务器端发生相应的变化。
图 20-7
使用数据存储操作
接收变更通知
Vuex 数据存储可用于应用的所有状态,而不仅仅是显示给用户的数据。在示例应用中,用户通过单击由ProductDisplay组件提供的按钮来选择要编辑的产品,但是ProductEditor组件提供编辑特性。为了在组件之间实现这种类型的协调,Vuex 提供了当数据值改变时触发通知的观察器,相当于 Vue.js 组件提供的观察器(在第十七章中描述)。在清单 20-17 中,我在数据存储中添加了一个状态属性,表明用户选择了哪个产品,同时添加了一个设置其值的变异。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
state: {
products: [],
selectedProduct: null
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
},
selectProduct(currentState, product) {
currentState.selectedProduct = product;
}
},
getters: {
// ...getters omitted for brevity...
},
actions: {
// ...actions omitted for brevity...
}
})
Listing 20-17Adding a State Property and Mutation in the index.js File in the src/store Folder
在清单 20-18 中,我已经更新了ProductDisplay组件,这样当用户单击 Create New 按钮或其中一个编辑按钮时,它就会调用selectProduct变异。
...
<script>
export default {
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() {
this.$store.commit("selectProduct");
},
editProduct(product) {
this.$store.commit("selectProduct", product);
},
deleteProduct(product) {
this.$store.dispatch("deleteProductAction", product);
}
},
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
...
Listing 20-18Using a Mutation in the ProductDisplay.vue File in the src/components Folder
当用户单击 Create New 按钮时,组件的createNew方法被调用,这将触发没有参数的selectProduct变异,将selectedProduct状态属性设置为null,这表明用户没有选择要编辑的现有产品。当用户点击一个编辑按钮时,editProduct方法被调用,这触发了同样的变异,但提供了一个产品对象,表明这是用户想要编辑的对象。
为了响应ProductDisplay组件通过数据存储发送的信号,我给ProductEditor组件添加了一个 Vuex 观察器,如清单 20-19 所示。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
}
},
created() {
this.$store.watch(state => state.selectedProduct,
(newValue, oldValue) => {
if (newValue == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, newValue);
}
});
}
}
</script>
...
Listing 20-19Watching a Data Store Value in the ProductEditor.vue File in the src/components Folder
我使用组件的created生命周期方法,在第十七章中描述,来创建观察器;这是通过使用watch方法完成的,该方法以this.$store.watch的形式访问。watch方法接受两个函数:第一个函数用于为watch选择数据值,第二个函数在所选数据发生变化时调用,使用组件观察器使用的相同函数样式。
小费
watch方法的结果是一个可以被调用来停止接收通知的函数。你可以在第二十一章中看到该功能的使用示例。
其效果是,组件通过更改其本地状态并复制选定产品(如果有)中的值来对通过数据存储发送的信号做出反应。您可以通过点击“新建”或“编辑”按钮并检查表单域是否响应来测试更改,如图 20-8 所示。
图 20-8
使用数据存储观察器
了解间接和本地组件状态
清单 20-19 中的代码有一点值得注意:我使用Object.assign将数据存储中对象的值复制到本地product属性,如下所示:
...
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, newValue);
}
...
您可能想知道为什么我不直接在数据存储中处理 state 属性。第一个原因是使用v-bind指令在 Vuex 数据值上创建双向绑定是不方便的。这是可以做到的,但是需要使用单独的数据和事件绑定,或者创建既有 setters 又有 getters 的计算属性。结果并不理想,我倾向于在组件中使用本地状态,如本例所示。
第二个原因是,直接使用数据存储中的对象会给用户带来困惑。由于数据存储中的这个对象与由ProductDisplay组件使用的v-for指令显示的对象相同,因此在input元素中所做的任何更改都会立即反映在表格中。如果用户在没有点击 Save 按钮的情况下取消操作,更改不会被发送到 web 服务,而是由应用显示出来,导致用户认为更改已经保存,而实际上并没有保存。
当您第一次开始使用 Vuex 时,很想使用存储中的数据来完成应用中的所有工作,但这并不总是最好的方法,有些应用功能最好通过将数据存储与本地状态数据相结合来处理。
将数据存储功能映射到组件
在组件中使用数据存储特性可以产生大量类似的代码,这些代码具有几个访问状态变量的计算属性和触发突变和动作的方法。Vuex 提供了一组函数,这些函数生成的函数提供了对数据存储功能的自动访问,并可用于简化组件。表 20-4 描述了映射功能。
表 20-4
Vuex 特征映射功能
|名字
|
描述
|
| --- | --- |
| mapState | 该函数生成计算属性,提供对数据存储的state值的访问。 |
| mapGetters | 此函数生成计算的属性,这些属性提供对数据存储的 getters 的访问。 |
| mapMutations | 该函数生成提供对数据存储的变化的访问的方法。 |
| mapActions | 此函数生成提供对数据存储操作的访问的方法。 |
这些函数将数据存储中的特性与组件的本地属性和方法一起添加。在清单 20-20 中,我使用了表 20-4 中的函数来提供对ProductDisplay组件中数据存储的访问。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm btn-danger"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from "vuex";
export default {
computed: {
...mapState(["products"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
}
}
</script>
Listing 20-20Generating Members in the ProductDisplay.vue File in the src/components Folder
表 20-4 中描述的函数从vuex模块中导入,并应用 JavaScript spread 运算符(...表达式)来创建映射到数据存储的属性和方法。
有两种方法可以创建数据存储映射。第一种方法是向映射函数传递一个字符串数组,它告诉 Vuex 创建属性或方法,其名称与数据存储中使用的名称相匹配,如下所示:
...
computed: {
...mapState(["products"])
},
...
这告诉 Vuex 创建一个名为products的计算属性,该属性返回products数据存储状态属性的值。如果您不想使用数据存储中的名称,那么您可以将一个 map 对象传递给表 20-4 中的函数,它为 Vuex 提供应该在组件中使用的名称,如下所示:
...
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
...
这个例子告诉 Vuex 创建名为getProducts和deleteProduct的方法,这些方法调用名为getProductsAction和deleteProductAction的数据存储动作。
调用不带参数的映射方法
由映射函数为突变和动作生成的方法将接受参数,然后这些参数被传递给数据存储。如果您想在没有参数的情况下从事件绑定器中直接调用这些方法之一,就需要小心了。在清单 20-20 中,我必须对 Create New 按钮的事件绑定进行更改。
...
<button class="btn btn-primary" v-on:click="createNew()">
...
以前,v-on指令的表达式只指定了方法名。这具有调用 Vuex 创建的映射方法的效果,该方法带有指令提供的事件对象,该对象被传递给映射的变异。示例应用依赖于在没有参数的情况下调用的突变,所以我在v-on指令的表达式中添加了空括号,以防止提供参数。
使用数据存储模块
随着应用复杂性的增加,存储中的数据和代码量也在增加。Vuex 提供了对模块的支持,这使得数据存储可以分解为独立的部分,更易于编写、理解和管理。
每个模块都在一个单独的文件中定义。为了演示,我在src/store文件夹中添加了一个名为preferences.js的文件,其内容如清单 20-21 所示。
export default {
state: {
stripedTable: true,
primaryEditButton: false,
dangerDeleteButton: false
},
getters: {
editClass(state) {
return state.primaryEditButton ? "btn-primary" : "btn-secondary";
},
deleteClass(state) {
return state.dangerDeleteButton ? "btn-danger" : "btn-secondary";
},
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
},
mutations: {
setEditButtonColor(currentState, primary) {
currentState.primaryEditButton = primary;
},
setDeleteButtonColor(currentState, danger) {
currentState.dangerDeleteButton = danger;
}
}
}
Listing 20-21The Contents of the preferences.js File in the src/store Folder
当你定义一个模块时,你就定义了一个 JavaScript 对象,它具有与你在本章前面章节中看到的相同的状态、getters、mutations 和 actions 属性。不同之处在于,传递给模块中定义的函数的上下文对象只提供对本地模块中的状态数据和其他特性的访问。如果要访问主模块中的数据存储功能,可以定义一个附加参数,如下所示:
...
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
...
这个 getter 定义了一个用于访问products属性的rootState参数,允许根据数组中第一个产品的price值来确定 getter 的结果。
小费
当访问根数据存储模块时,即使不打算使用它,也必须定义 payload 参数,如清单 20-21 所示。
注册和使用数据存储模块
要将一个模块合并到数据存储中,必须使用import语句并使用modules属性配置数据存储,如清单 20-22 所示。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import PrefsModule from "./preferences";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
modules: {
prefs: PrefsModule
},
state: {
products: [],
selectedProduct: null
},
// ...other data store features omitted for brevity...
})
Listing 20-22Registering a Module in the index.js File in the src/store Folder
Vuex 将模块中定义的状态、getters、突变和动作与直接在index.js文件中定义的相结合,这意味着组件不必担心数据存储的不同部分是在哪里定义的。在清单 20-23 中,我已经更新了ProductDisplay组件,以便它使用模块中定义的数据和突变。
<template>
<div>
<table class="table table-sm table-bordered" v-bind:class="tableClass">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm"
v-bind:class="editClass"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapGetters(["tableClass", "editClass", "deleteClass"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapMutations(["setEditButtonColor", "setDeleteButtonColor"]),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
Listing 20-23Using Module Features in the ProductDisplay.vue File in the src/components Folder
我使用了映射函数来映射数据存储模块提供的 getters 和 mutations,并使用v-bind指令将它们的值绑定到组件的模板中。我调用组件的created方法中的突变,通过将表中第一个产品的price p属性更改为大于 500,您可以看到访问根数据存储状态的 getter 的效果,如图 20-9 所示。当该值小于 500 时,表行不进行分条;当该值大于 500 时,行以交替颜色显示。
图 20-9
使用由数据存储模块提供的特征
访问模块状态
如果您想要访问模块中定义的状态属性,则需要一种不同的方法。尽管 getters、mutations 和 actions 与数据存储的其余部分无缝合并,但状态数据保持独立,并且必须使用导入时分配给模块的名称来访问,如清单 20-24 所示。
<template>
<div>
<table class="table table-sm table-bordered"
v-bind:class="'table-striped' == useStripedTable">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm"
v-bind:class="editClass"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters(["tableClass", "editClass", "deleteClass"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapMutations(["setEditButtonColor", "setDeleteButtonColor"]),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
Listing 20-24Accessing Module State Data in the ProductDisplay.vue File in the src Folder
向mapState方法传递一个对象,该对象的属性是将用作本地属性的名称,其值是接收状态对象并选择所需数据存储值的函数。在这个例子中,我使用这个特性创建了一个名为useStripedTable的computed属性,它被映射到在prefs模块中定义的stripedTable状态属性。
使用模块命名空间
模块的默认行为是将它们提供的 getters、mutations 和 actions 合并到数据存储中,以便组件不知道存储结构,但保持状态数据独立,以便必须使用前缀来访问它。如果您启用了名称空间特性,如清单 20-25 所示,那么所有的特性都必须使用前缀来访问。
export default {
namespaced: true,
state: {
stripedTable: true,
primaryEditButton: true,
dangerDeleteButton: false
},
getters: {
editClass(state) {
return state.primaryEditButton ? "btn-primary" : "btn-secondary";
},
deleteClass(state) {
return state.dangerDeleteButton ? "btn-danger" : "btn-secondary";
},
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
},
mutations: {
setEditButtonColor(currentState, primary) {
currentState.primaryEditButton = primary;
},
setDeleteButtonColor(currentState, danger) {
currentState.dangerDeleteButton = danger;
}
}
}
Listing 20-25Enabling the Namespace Feature in the preferences.js File in the src/store Folder
当您将模块定义的特性映射到一个组件时,您想要的特性的名称必须以用于将模块注册到数据存储的名称为前缀,如清单 20-26 所示。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
striped: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 20-26Using a Module Namespace in the ProductDisplay.vue File in the src/components Folder
为了选择 getters、mutations 和 actions,使用名称空间,后跟一个正斜杠(/字符)和函数名,如下所示:
...
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
...
这个片段映射了prefs名称空间中的tableClass、editClass和deleteClassgetter。
摘要
在本章中,我向您展示了如何使用 Vuex 为 Vue.js 应用创建数据存储。我向您展示了如何使用突变修改状态数据,如何使用 getters 合成值,以及如何使用 actions 执行异步任务。我还演示了用于将数据存储特性映射到组件的 Vuex 函数,以及如何使用模块和名称空间构建数据存储。在下一章,我将描述 Vue.js 动态组件特性。
二十一、动态组件
简单的应用可以一次向用户呈现所有的内容,但是更复杂的项目需要更有选择性,在不同的时间向用户显示不同的组件。在这一章中,我解释了内置的 Vue.js 特性,这些特性允许组件基于用户交互动态显示,并仅在需要时加载组件,这有助于减少用户消耗的数据量。表 21-1 将动态组件放在上下文中。
小费
本章中描述的特性经常与 URL 路由一起使用,我在第二十二章中对此进行了描述。
表 21-1
将动态组件放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 动态组件仅在需要时向用户显示。 |
| 它们为什么有用? | 复杂的应用有太多的功能,无法一次呈现给用户。能够改变显示给用户的组件允许应用呈现复杂的内容而不会让用户不知所措。 |
| 它们是如何使用的? | Vue.js is指令可用于动态选择组件。 |
| 有什么陷阱或限制吗? | 必须注意确保动态组件不会对其生命周期做出假设,这在将动态组件引入现有项目时可能是一个问题。有关详细信息,请参见“为动态生命周期准备组件”一节。 |
| 有其他选择吗? | 应用不必动态显示它们的组件。简单的应用可以一次显示所有的内容,如前面章节中的示例应用所示。 |
表 21-2 总结了本章内容。
表 21-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 动态选择一个组件 | 使用is属性和v-bind指令 | 6–12 |
| 仅在需要时加载组件 | 定义异步组件 | 13–14 |
| 禁用预取提示 | 更改应用配置 | Fifteen |
| 微调异步组件 | 使用延迟加载配置选项 | 16, 17 |
为本章做准备
我继续使用第二十一章中的 productapp 例子。要启动 RESTful web 服务,打开命令提示符并运行清单 21-1 中的命令。
npm run json
Listing 21-1Starting the Web Service
打开第二个命令提示符,导航到productapp目录,运行清单 21-2 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 21-2Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080,在那里你将看到示例应用,如图 21-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书其他章节的示例项目。
图 21-1
运行示例应用
为动态生命周期准备组件
编写ProductDisplay和ProductEditor组件时,假设它们将在应用初始化时创建,并一直存在到应用终止。当动态显示组件时,这可能是一个问题,因为 Vue.js 不会在需要时创建组件,一旦用户看不到它们,就会销毁它们。结果是,观察器和事件处理程序可能会错过重要的通知,并且每次创建组件时,即使应用已经收到了所需的数据,也要重复执行代价高昂的任务,例如从 web 服务获取数据。
获取应用数据
在清单 21-3 中,我删除了从 web 服务获取初始数据的ProductDisplay组件的created方法中的语句。当我开始向用户动态显示组件时,每当用户想要查看产品表时,Vue.js 将创建该组件的一个新实例,并且创建的实例将有自己的生命周期,包括调用它的created方法。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
//getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
//this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 21-3Avoiding Duplicate Requests in the ProductDisplay.vue File in the src/components Folder
应该只执行一次的任务必须由不会动态显示的组件来处理。在大多数 Vue.js 项目中,顶层组件用于协调应用其他组件的可见性,并且对用户始终可见。在清单 21-4 中,我让App组件负责从 web 服务获取初始数据。
<template>
<div class="container-fluid">
<div class="row">
<div class="col"><error-display /></div>
</div>
<div class="row">
<div class="col-8 m-3"><product-display /></div>
<div class="col m-3"><product-editor /></div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 21-4Getting Data in the App.vue File in the src Folder
我使用了 Vuex dispatch方法来调用名为getProductsAction的动作,我在第二十章中定义了这个动作。由于我不会动态显示App组件,我可以相信这个任务只被执行一次。
管理观察事件
ProductEditor组件使用数据存储观察器来确定用户何时单击新建或编辑按钮。当动态创建组件时,可能很难确保在 Vue.js 创建它需要的组件之前处理应用状态的更改,结果是组件可能会错过配置它以供使用的事件。在清单 21-5 中,我修改了ProductEditor组件,在它的created方法中使用现有的数据存储值。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
},
selectProduct(selectedProduct) {
if (selectedProduct == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, selectedProduct);
}
}
},
created() {
unwatcher = this.$store.watch(state =>
state.selectedProduct, this.selectProduct);
this.selectProduct(this.$store.state.selectedProduct);
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 21-5Preparing for Dynamic Display in the ProductEditor.vue File in the src/components Folder
created方法处理数据存储中的当前数据值,并使用watch方法创建一个观察器来观察未来的变化。watch方法的结果是一个可以用来停止接收通知的函数,我在beforeDestroy生命周期方法中使用了这个函数,以确保当 Vue.js 销毁组件时,观察器不会逗留。
动态显示组件
现在组件已经更新了,所以它们可以被创建和销毁而不会引起任何问题,我可以改变应用显示其内容的方式。在这一节中,我将演示如何直接使用 Vue.js 支持来动态选择组件,这将为解释如何使用流行的Vue-Router包提供基础。
在 HTML 元素中呈现不同的组件
Vue.js 支持 HTML 元素上的一个特殊属性来指定组件,当应用运行时,该组件的内容将用于替换该元素。这个特殊属性叫做is,它可以用在任何元素上,尽管惯例是使用component元素,如清单 21-6 所示。
小费
当您保存清单 21-6 中的更改时,您将会看到一个 linter 警告。这将在下一节中解决。
<template>
<div class="container-fluid">
<div class="row">
<div class="col"><error-display /></div>
</div>
<div class="row">
<div class="col">
<component is="ProductDisplay"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 21-6Using the is Attribute in the App.vue File in the src Folder
在运行时,Vue.js 将遇到component元素上的is属性,并评估该属性的值以确定应该显示哪个组件。在清单中,is属性被设置为ProductDisplay,这是由script元素中的import语句分配的名称,并且已经与 components configuration属性一起使用,结果是向用户呈现产品表,如图 21-2 所示。
图 21-2
显示组件
使用数据绑定选择组件
当与数据绑定一起使用时,is属性变得很有趣,它允许在应用运行时改变呈现给用户的组件。在清单 21-7 中,我添加了一个数据绑定到App组件的模板,它根据用户的选择设置is属性的值。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group btn-group-toggle">
<label class="btn btn-info"
v-bind:class="{active: (selected == 'table') }">
<input type="radio" v-model="selected" value="table" />
Table
</label>
<label class="btn btn-info"
v-bind:class="{active: (selected == 'editor') }">
<input type="radio" v-model="selected" value="editor" />
Editor
</label>
</div>
</div>
</div>
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
},
data: function() {
return {
selected: "table"
}
},
computed: {
selectedComponent() {
return this.selected == "table" ? ProductDisplay : ProductEditor;
}
}
}
</script>
Listing 21-7Using a Data Binding in the App.vue File in the src Folder
本例中的input元素允许用户通过使用v-model指令更改名为selected的数据属性来选择显示哪个组件。component元素上的is属性的值反映了使用v-bind指令选择的值,该指令读取一个computed属性的值,该属性使用selected值来标识用户需要的组件,产生如图 21-3 所示的结果。
图 21-3
使用数据绑定选择组件
向用户呈现一个按钮组,允许选择应用显示的组件:单击 Table 按钮显示ProductDisplay组件,单击 Editor 按钮显示ProductEditor按钮。
了解组件生命周期
值得花点时间检查一下应用的状态,看看 Vue.js 是如何处理动态变化的。在使用 Vue Devtools 查看应用的组件时,点击表格和编辑器按钮,你会看到组件在需要时被创建和销毁,结果是App组件在任何给定时刻都只有一个子组件,如图 21-4 所示。
图 21-4
创建和销毁组件
重用动态组件
Vue.js 提供了另一种管理动态组件的方法,不需要在每次需要时创建它们,不需要时销毁它们。如果用一个keep-alive元素包围显示组件的元素(具有is属性的元素), Vue.js 会在不需要组件时使它们不活动,而不是销毁它们。下面是一个应用keep-alive元素的例子:
...
<div class="row">
<div class="col">
<keep-alive>
<component v-bind:is="selectedComponent"></component>
</keep-alive>
</div>
</div>
...
这个特性的优点是您不必担心更新组件,以避免重复一次性任务或错过重要事件。缺点是资源被不活动的并且可能不再需要的组件消耗。
我的建议是让 Vue.js 在需要的时候创建和销毁组件,但是如果你确实使用了keep-alive元素,那么你可以通过实现activated和deactivated生命周期方法在组件激活和非激活时接收通知(关于组件生命周期的细节,请参见第十七章)。
在应用中自动导航
让用户选择组件使我能够演示组件是如何动态显示的,但这不是大多数应用需要的工作方式。我希望应用能够根据用户的操作自动向用户呈现适当的组件,这意味着应用中的所有组件都能够更改向用户显示的组件,例如,单击编辑按钮将自动选择编辑器组件。
我首先用清单 21-8 中所示的代码向src/store文件夹添加一个名为navigation.js的文件。
export default {
namespaced: true,
state: {
selected: "table"
}
,
mutations: {
selectComponent(currentState, selection) {
currentState.selected = selection;
}
}
}
Listing 21-8The Contents of the navigation.js File in the src/store Folder
这个 Vuex 模块将扩展数据存储,以便我可以在应用中导航。我将使用selected状态属性作为is属性的值,并使用selectComponent变异来改变它的值。在清单 21-9 中,我已经将模块导入到主数据存储中。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import PrefsModule from "./preferences";
import NavModule from "./navigation";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
modules: {
prefs: PrefsModule,
nav: NavModule
},
state: {
products: [],
selectedProduct: null
},
// ...other data store features omitted for brevity...
})
Listing 21-9Adding a Module in the index.js File in the src/store Folder
下一步是使用新的数据存储状态属性作为App组件模板中is属性的值,如清单 21-10 所示。除了使用数据存储,我还删除了允许用户显式选择要显示的组件的按钮元素。
<template>
<div class="container-fluid">
<!-- <div class="row">
<div class="col text-center m-2">
<div class="btn-group btn-group-toggle">
<label class="btn btn-info"
v-bind:class="{active: (selected == 'table') }">
<input type="radio" v-model="selected" value="table" />
Table
</label>
<label class="btn btn-info"
v-bind:class="{active: (selected == 'editor') }">
<input type="radio" v-model="selected" value="editor" />
Editor
</label>
</div>
</div>
</div> -->
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
import { mapState } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
},
// data: function() {
// return {
// selected: "table"
// }
// },
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
return this.selected == "table" ? ProductDisplay : ProductEditor;
}
}
}
</script>
Listing 21-10Using the Data Store in the App.vue File in the src Folder
剩下的工作就是调用数据存储突变来改变向用户显示的组件。在清单 21-11 中,我已经更新了ProductDisplay组件,这样当用户点击一个新建或编辑按钮时,就会看到编辑器。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
editProduct(product) {
this.selectProduct(product);
this.selectComponent("editor");
},
createNew() {
this.selectProduct();
this.selectComponent("editor");
},
...mapMutations({
selectProduct: "selectProduct",
selectComponent: "nav/selectComponent",
//editProduct: "selectProduct",
//createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
deleteProduct: "deleteProductAction"
})
},
created() {
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 21-11Adding Navigation in the ProductDisplay.vue File in the src/components Folder
我已经重新定义了editProduct和createNew方法,因此它们不再直接映射到selectProduct数据存储变异。相反,我创建了本地方法,调用selectProduct和selectComponent突变来识别将要编辑的对象,然后显示编辑器组件。在清单 21-12 中,我已经更新了ProductEditor组件,这样一旦用户完成或取消了编辑任务,它就会导航到表格显示。
将导航与其他操作分开
请注意,我在示例应用的组件中处理了导航,而不是将其直接合并到数据存储变异中,如selectProduct。将导航作为其他状态变化的一部分来执行可能很诱人,但是这是假设当执行给定的突变或动作时,组件将总是显示。当您开始动态显示组件时可能是这种情况,但随着项目变得更加复杂,用户会看到应用功能的不同路径,这种情况经常会发生变化。在每个组件中执行导航可能看起来更复杂,但它会使应用更容易适应新功能。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$store.commit("nav/selectComponent", "table");
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
this.$store.commit("nav/selectComponent", "table");
},
selectProduct(selectedProduct) {
if (selectedProduct == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, selectedProduct);
}
}
},
created() {
unwatcher = this.$store.watch(state =>
state.selectedProduct, this.selectProduct);
this.selectProduct(this.$store.state.selectedProduct);
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 21-12Adding Navigation in the ProductEditor.vue File in the src/components Folder
该组件直接使用数据存储功能,而不使用 Vuex 映射功能。我在save方法中添加了async关键字,这允许我在保存产品时使用await关键字,这样在 HTTP 操作完成之前不会执行导航。
结果是用户的动作自动选择将要显示的组件,自动在表格和编辑器之间切换,如图 21-5 所示。
图 21-5
在组件之间导航
使用异步组件
在较大的应用中,通常有些功能不是所有用户都需要的,或者只是偶尔需要,例如高级设置或管理工具。默认情况下,这些未使用的组件包含在发送给浏览器的 JavaScript 包中,这会浪费带宽并增加应用启动的时间。为了避免这个问题,Vue.js 提供了异步组件特性,用于将组件的加载推迟到需要的时候——这个特性也被称为延迟加载。为了演示异步组件特性,我在src/components文件夹中添加了一个名为DataSummary.vue的文件,其内容如清单 21-13 所示。
<template>
<div>
<h3 class="bg-success text-center text-white p-2">
Summary
</h3>
<table class="table">
<tr><th>Number of Products:</th><td> {{ products.length}} </td></tr>
<tr><th>Number of Categories:</th><td> {{ categoryCount }} </td></tr>
<tr>
<th>Highest Price:</th><td> {{ highestPrice | currency }} </td>
</tr></table>
</div>
</template>
<script>
import { mapState, } from "vuex";
export default {
computed: {
...mapState(["products"]),
categoryCount() {
if (this.products.length > 0) {
return this.products.map(p => p.category)
.filter((cat, index, arr) => arr.indexOf(cat)
== index).length;
} else {
return 0;
}
},
highestPrice() {
if (this.products.length == 0) {
return 0;
} else {
return Math.max(...this.products.map(p => p.price));
}
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD" }).format(value);
}
}
}
</script>
Listing 21-13The Contents of the DataSummary.vue File in the src/components Folder
该组件显示商店中数据的摘要,对于本章来说,这是一个我不希望浏览器在需要时才加载的特性。在清单 21-14 中,我已经注册了新的组件,因此它将被延迟加载。
了解延迟加载的成本
术语 lazy 指的是组件直到被需要时才会从 HTTP 服务器加载。这与默认的急切加载策略形成对比,在默认策略中,组件作为主应用包的一部分被加载,即使它们可能并不需要。
这两种方法都代表了一种妥协。急切加载需要更大的初始下载量,并以带宽和启动速度换取更流畅的用户体验,因为用户可能需要的所有代码和内容总是可用的。延迟加载减少了初始下载的大小,但是如果需要的话,需要对组件进行额外的 HTTP 请求。
您可以根据您对应用使用方式的预期来初步评估哪些组件应该延迟加载,但是一旦您部署了应用,验证您的预期是非常重要的。如果您发现大多数用户都在执行延迟加载组件的操作,那么您应该更改应用的配置,因为这两种方法都有不利的一面,这样就不会节省带宽,并且用户必须等待延迟加载的执行。
<template>
<div class="container-fluid">
<div class="col">
<div class="col text-center m-2">
<button class="btn btn-primary"
v-on:click="selectComponent('table')">
Standard Features
</button>
<button class="btn btn-success"
v-on:click="selectComponent('summary')">
Advanced Features
</button>
</div>
</div>
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
const DataSummary = () => import("./components/DataSummary");
import { mapState, mapMutations } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay, DataSummary },
created() {
this.$store.dispatch("getProductsAction");
},
methods: {
...mapMutations({
selectComponent: "nav/selectComponent"
})
},
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
switch (this.selected) {
case "table":
return ProductDisplay;
case "editor":
return ProductEditor;
case "summary":
return DataSummary;
}
}
}
}
</script>
Listing 21-14Lazily Loading a Component in the App.vue File in the src/components Folder
清单中有许多变化,但这一变化告诉 Vue.js,这是一个应该只在需要时才加载的组件:
...
const DataSummary = () => import("./components/DataSummary");
...
关键字import的标准用法创建了一个静态依赖,webpack 通过将组件包含在它创建的 JavaScript 包中来处理这个静态依赖。但是这种形式的import,其中它被用作一个函数,组件被指定为参数,创建了一个动态依赖,webpack 通过将组件放入它自己的包中来处理它。import函数的结果是一个 JavaScript Promise,当组件被加载时,这个 JavaScript 就会被执行。一旦调用了import函数,加载过程就开始了,所以分配组件引用是很重要的,在这个例子中是DataSummary,这个函数又调用了import。
...
const DataSummary = () => import("./components/DataSummary");
...
当选择DataSummary与is属性一起使用时,Vue.js 检测该函数,这表示一个异步组件。调用该函数来启动加载过程,一旦完成就显示组件。
禁用预取提示
默认情况下,项目被配置为向浏览器提供预取提示,指示将来可能需要有应用内容。这个特性的目的是让浏览器决定在需要之前获取内容是否有意义。这往往会破坏 Vue.js 模块的惰性加载的想法,因为 JavaScript 文件将被下载,然后在不使用的情况下被丢弃,而这可能是少数用户所需要的。为了禁用预取提示特性,我在productapp文件夹中添加了一个名为vue.config.js的文件,并添加了清单 21-15 中所示的语句。
module.exports = {
chainWebpack: config => {
config.plugins.delete('prefetch');
}
}
Listing 21-15Disabling Prefetch Hints in the vue.config.js File in the productapp Folder
要应用配置更改,请停止开发工具,并通过运行productapp文件夹中清单 21-16 中所示的命令来重新启动它们。
npm run serve
Listing 21-16Starting the Vue.js Development Tools
为了测试异步组件,导航到http://localhost:8080并点击高级功能按钮,这将显示清单 21-13 中定义的组件,如图 21-6 所示。
图 21-6
延迟加载组件
您不太可能在加载组件时看到任何延迟,因为浏览器和 HTTP 服务器运行在同一个工作站上。但是如果你打开浏览器的 F12 开发工具,切换到网络选项卡,当你点击高级功能按钮时,你会看到有一个对 JavaScript 文件的 HTTP 请求。这是包含DataSummary组件的文件,对我来说它叫做1.js,尽管你可能会看到一个不同的名字。
配置延迟加载
Vue.js 提供了一组配置选项,可用于微调异步组件的加载过程,如表 21-3 所述。
表 21-3
惰性加载配置选项
|名字
|
描述
|
| --- | --- |
| component | 该属性用于定义将加载异步组件的import函数。 |
| loading | 此属性用于指定在加载过程中向用户显示的组件。 |
| delay | 该属性用于指定在向用户显示loading组件之前的延迟,以毫秒表示。默认值为 200。 |
| error | 此属性用于指定在加载操作失败或超时时向用户显示的组件。 |
| timeout | 此属性用于指定加载操作的超时时间,以毫秒为单位。默认值永远等待。 |
这些配置选项中最有用的是loading属性,它指定在加载异步组件时应该向用户显示的组件,以及delay选项,它指定在向用户显示加载组件之前的延迟,并确保用户不会看到快速完成的操作的加载消息。
警告
您不应该使用loading属性来指定延迟加载的组件,因为它可能在需要的时候还没有被加载。
为了准备演示表 21-3 中描述的属性,我需要一个可以在加载过程中显示的组件,所以我在src/components文件夹中添加了一个名为LoadingMessage.vue的文件,其内容如清单 21-17 所示。
<template>
<h3 class="bg-info text-white text-center m-2 p-2">
Lazily Loading Component...
</h3>
</template>
Listing 21-17The Contents of the LoadingMessage.vue File in the src/components Folder
该组件只包含一个向用户显示消息的模板。在清单 21-18 中,我已经使用这个组件来配置DataSummary组件的延迟加载。
...
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
import LoadingMessage from "./components/LoadingMessage";
const DataSummary = () => ({
component: import("./components/DataSummary"),
loading: LoadingMessage,
delay: 100
});
import { mapState, mapMutations } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay, DataSummary },
created() {
this.$store.dispatch("getProductsAction");
},
methods: {
...mapMutations({
selectComponent: "nav/selectComponent"
})
},
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
switch (this.selected) {
case "table":
return ProductDisplay;
case "editor":
return ProductEditor;
case "summary":
return DataSummary;
}
}
}
}
</script>
...
Listing 21-18Configuring Lazy Loading in the App.vue File in the src Folder
component属性被赋予了加载组件的import函数,我已经指定如果加载操作超过 100 毫秒,用户将看到LoadingMessage组件。要查看效果,导航到http://localhost:8080并点击高级功能按钮,这将产生如图 21-7 所示的加载序列。
注意
在开发过程中很难测试配置特性,因为异步组件加载得太快,以至于看不到由component属性指定的组件。我的方法是使用 Google Chrome 开发工具创建一个网络配置文件,这会给 HTTP 请求增加几秒钟的延迟,并在触发延迟加载之前启用这个配置文件。
图 21-7
配置延迟加载过程
将组件组合成一个共享包
默认情况下,每个异步组件都将被放入自己的文件中,只有在需要时才加载。另一种方法是对相关组件进行分组,以便在第一次需要它们中的任何一个时,它们都被加载。这是通过添加 webpack 在构建过程中检测到的注释来实现的,如下所示:
...
const DataSummary = () => ({
component:
import(/* webpackChunkName: "advanced" */ "./components/DataSummary"),
loading: LoadingMessage,
delay: 100
});
...
该注释设置了一个名为webpackChunkName的属性的值,webpack 会将所有具有相同webpackChunkName值的异步组件打包成一个包。您指定的名称不会用作包文件的名称,包文件是在构建过程中动态选择的。
摘要
在这一章中,我演示了如何动态显示组件。我向您展示了如何使用is属性来选择一个组件,这对于更简单的项目来说是一个很好的方法。我还向您展示了如何按需加载组件,这是处理并非所有用户都需要的组件的好方法。在下一章,我将介绍 URL 路由,它建立在本章描述的特性之上。