VueJS2 高级教程(三)
原文:Pro Vue.js 2
七、SportsStore:缩放和管理
在本章中,我继续向我在第五章中创建的 SportsStore 应用添加特性。我添加了对处理大量数据的支持,并开始实现管理应用所需的特性。
小费
你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书所有其他章节的示例项目。
为本章做准备
为了准备本章,我将增加应用必须处理的产品数据量。我使用了我在第五章中添加到项目中的 Faker 包,通过替换data.js文件的内容来生成大量的产品对象,如清单 7-1 所示。
var faker = require("faker");
var data = [];
var categories = ["Watersports", "Soccer", "Chess", "Running"];
faker.seed(100);
for (let i = 1; i <= 500; i++) {
var category = faker.helpers.randomize(categories);
data.push({
id: i,
name: faker.commerce.productName(),
category: category,
description: `${category}: ${faker.lorem.sentence(3)}`,
price: faker.commerce.price()
})
}
module.exports = function () {
return {
products: data,
categories: categories,
orders: []
}
}
Listing 7-1Generating Data in the data.js File in the sportsstore Folder
Faker 包是一个在开发过程中产生随机数据的优秀工具,它是一种找到应用限制的有用方法,而不必手动创建真实的数据。Faker 包在 http://marak.github.io/faker.js 进行了描述,我用它生成了带有随机名称、描述和价格的产品数据。
要启动 RESTful web 服务,请打开命令提示符并在sportsstore文件夹中运行以下命令:
npm run json
打开第二个命令提示符,在sportsstore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:
npm run serve
一旦初始构建过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080以查看图 7-1 中显示的内容。
图 7-1
运行 SportsStore 应用
处理大量数据
您已经可以看到,应用需要做一些工作来处理 web 服务提供的大量产品数据,因为一行分页按钮太长了,以至于没有用。在接下来的小节中,我将修改 SportsStore 应用,以更有用的方式呈现数据,并减少从服务器请求的数据量。
改进页面导航
我将从最明显的问题开始,那就是向用户呈现一个很长的页码列表,这使得导航很困难。为了解决这个问题,我将向用户呈现一个页面按钮的受限列表,使导航更容易,尽管不能跳转到任何页面,如清单 7-2 所示。
<template>
<div class="row mt-2">
<div class="col-3 form-group">
<select class="form-control" v-on:change="changePageSize">
<option value="4">4 per page</option>
<option value="8">8 per page</option>
<option value="12">12 per page</option>
</select>
</div>
<div class="text-right col">
<button v-bind:disabled="currentPage == 1"
v-on:click="setCurrentPage(currentPage - 1)"
class="btn btn-secondary mx -1">Previous</button>
<span v-if="currentPage > 4">
<button v-on:click="setCurrentPage(1)"
class="btn btn-secondary mx-1">1</button>
<span class="h4">...</span>
</span>
<span class="mx-1">
<button v-for="i in pageNumbers" v-bind:key="i"
class="btn btn-secpmdary"
v-bind:class="{ 'btn-primary': i == currentPage }"
v-on:click="setCurrentPage(i)">{{ i }}</button>
</span>
<span v-if="currentPage <= pageCount - 4">
<span class="h4">...</span>
<button v-on:click="setCurrentPage(pageCount)"
class="btn btn-secondary mx-1">{{ pageCount}}</button>
</span>
<button v-bind:disabled="currentPage == pageCount"
v-on:click="setCurrentPage(currentPage + 1)"
class="btn btn-secondary mx-1">Next</button>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from "vuex";
export default {
computed: {
...mapState(["currentPage"]),
...mapGetters(["pageCount"]),
pageNumbers() {
if (this.pageCount < 4) {
return [...Array(this.pageCount + 1).keys()].slice(1);
} else if (this.currentPage <= 4) {
return [1, 2, 3, 4, 5];
} else if (this.currentPage > this.pageCount - 4) {
return [...Array(5).keys()].reverse()
.map(v => this.pageCount - v);
} else {
return [this.currentPage -1, this.currentPage,
this.currentPage + 1];
}
}
},
methods: {
...mapMutations(["setCurrentPage", "setPageSize"]),
changePageSize($event) {
this.setPageSize($event.target.value);
}
}
}
</script>
Listing 7-2Improving Navigation in the PageControls.vue File in the /src/components Folder
不需要新的 Vue.js 特性来改变现在呈现给用户的分页方式,这些改变向用户呈现了当前选择的页面以及选择之前和之后的页面以及第一页和最后一页的选项,产生了如图 7-2 所示的结果。
图 7-2
限制分页选项
我用于页面的导航模型是模仿 Amazon 的,因为它是许多用户已经熟悉的一种方法。然而,这是一个分页模型,假设用户更可能对前几页感兴趣,并使它们更容易访问。
减少应用请求的数据量
应用在启动时发出一个请求,并从 web 服务获取所有可用的数据。这不是一种可扩展的方法,尤其是因为大多数数据不太可能显示给用户,因为这些数据将用于不太可能被查看的页面。为了解决这个问题,我将在用户需要时请求数据,如清单 7-3 所示,预计大多数用户将从早期页面中选择产品。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
export default new Vuex.Store({
strict: true,
modules: { cart: CartModule, orders: OrdersModule },
state: {
//products: [],
categoriesData: [],
//productsTotal: 0,
currentPage: 1,
pageSize: 4,
currentCategory: "All",
pages: [],
serverPageCount: 0
},
getters: {
// productsFilteredByCategory: state => state.products
// .filter(p => state.currentCategory == "All"
// || p.category == state.currentCategory),
processedProducts: (state) => {
return state.pages[state.currentPage];
},
pageCount: (state) => state.serverPageCount,
categories: state => ["All", ...state.categoriesData]
},
mutations: {
_setCurrentPage(state, page) {
state.currentPage = page;
},
_setPageSize(state, size) {
state.pageSize = size;
state.currentPage = 1;
},
_setCurrentCategory(state, category) {
state.currentCategory = category;
state.currentPage = 1;
},
// setData(state, data) {
// state.products = data.pdata;
// state.productsTotal = data.pdata.length;
// state.categoriesData = data.cdata.sort();
// },
addPage(state, page) {
for (let i = 0; i < page.pageCount; i++) {
Vue.set(state.pages, page.number + i,
page.data.slice(i * state.pageSize,
(i * state.pageSize) + state.pageSize));
}
},
clearPages(state) {
state.pages.splice(0, state.pages.length);
},
setCategories(state, categories) {
state.categoriesData = categories;
},
setPageCount(state, count) {
state.serverPageCount = Math.ceil(Number(count) / state.pageSize);
},
},
actions: {
async getData(context) {
await context.dispatch("getPage", 2);
context.commit("setCategories", (await Axios.get(categoriesUrl)).data);
},
async getPage(context, getPageCount = 1) {
let url = `${productsUrl}?_page=${context.state.currentPage}`
+ `&_limit=${context.state.pageSize * getPageCount}`;
if (context.state.currentCategory != "All") {
url += `&category=${context.state.currentCategory}`;
}
let response = await Axios.get(url);
context.commit("setPageCount", response.headers["x-total-count"]);
context.commit("addPage", { number: context.state.currentPage,
data: response.data, pageCount: getPageCount});
},
setCurrentPage(context, page) {
context.commit("_setCurrentPage", page);
if (!context.state.pages[page]) {
context.dispatch("getPage");
}
},
setPageSize(context, size) {
context.commit("clearPages");
context.commit("_setPageSize", size);
context.dispatch("getPage", 2);
},
setCurrentCategory(context, category) {
context.commit("clearPages");
context.commit("_setCurrentCategory", category);
context.dispatch("getPage", 2);
}
}
})
Listing 7-3Requesting Data in the index.js File in the src/store Folder
这些变化比看起来要简单,反映了 Vuex 对数据存储特性的严格要求。只有动作可以执行异步任务,这意味着任何导致 HTTP 数据请求的活动都必须使用动作来执行,而动作使用突变来改变状态。因此,我创建了一些动作,这些动作的名称以前被变异使用过,并在变异名称中添加了一个下划线,这样我仍然可以修改存储中的数据。这可能看起来很尴尬,但是动作到状态的流程是一个很好的模型,并且执行它有助于调试,正如我在第二十章中解释的。
清单 7-3 中变化的核心是发送到 web 服务的 URL 的变化。以前,URL 请求所有产品数据,如下所示:
...
http://localhost:3500/products
...
提供 web 服务的json-server包支持请求的数据页面和过滤数据,使用如下 URL:
...
http://localhost:3500/products?_page=3&_limit=4&category=Watersports
...
_page和_limit参数用于请求数据页面,而category参数仅用于选择category属性为Watersports的对象。这是清单 7-3 中的更改引入到 SportsStore 应用中的 URL 格式。
我在清单中采用的方法是请求将显示给用户的页面中的数据。应用跟踪它从 web 服务收到的数据,并在用户导航到没有请求数据的页面时发送一个 HTTP 请求。当用户更改页面大小或选择类别时,从服务器请求的数据将被丢弃,当应用开始允许用户导航到一个页面而不触发网络请求时,将请求两个页面。
警告
实现复杂的方法来管理和缓存数据,以最大限度地减少应用发出的 HTTP 请求的数量,这很有诱惑力。缓存很难有效地实现,并且它给项目增加的复杂性通常会超过任何好处。我建议从一个简单的方法开始,比如清单 7-3 中的方法,并且只有当你确定你需要它的时候才更主动地管理数据。
确定项目的数量
当处理页面中的数据时,很难确定有多少对象可以显示分页按钮。为了帮助解决这个问题,json-server包在其响应中包含了一个X-Total-Count头,它指示集合中有多少对象。每次对一页数据发出 HTTP 请求时,我都会从响应中获取头的值,并使用它来更新页数,如下所示:
...
context.commit("setPageCount", response.headers["x-total-count"]);
...
不是所有的 web 服务都提供这个特性,但是通常有一些类似的特性,这样您就可以确定有多少对象是可用的,而不需要请求所有的对象。
将项目添加到页面数组中
Vue.js 和 Vuex 在跟踪变化和确保应用中的数据是动态的方面都做得很好。在大多数情况下,这种跟踪是自动的,不需要任何特殊的操作。但这并不是普遍正确的,JavaScript 的工作方式有一些限制,这意味着 Vue.js 需要一些特定操作的帮助,其中之一是将一个项目分配给一个数组索引,这不会触发变化检测过程。当我从服务器接收一页数据并将其添加到数组中时,我使用 Vue.js 为此提供的方法,如下所示:
...
Vue.set(state.pages, page.number + i, page.data.slice(i * state.pageSize,
(i * state.pageSize) + state.pageSize));
...
Vue.set方法接受三个参数:要修改的对象或数组、要赋值的属性或索引以及要赋值的值。正如我在第十三章中解释的那样,使用Vue.set可以确保变更被识别并作为更新处理。
更新组件以使用操作
我在清单 7-3 中所做的更改需要使用已经被动作替换的突变的组件进行相应的更改。清单 7-4 显示了CategoryControls组件所需的变更。
...
<script>
import { mapState, mapGetters, mapActions} from "vuex";
export default {
computed: {
...mapState(["currentCategory"]),
...mapGetters(["categories"])
},
methods: {
...mapActions(["setCurrentCategory"])
}
}
</script>
...
Listing 7-4Using Actions in the CategoryControls.vue File in the src/components Folder
为了更新组件,我用mapActions替换了mapMutations助手。这两个助手都向组件添加了方法,组件的其余部分不需要任何更改。在清单 7-5 中,我更新了PageControls组件。
...
<script>
import { mapState, mapGetters, mapActions } from "vuex";
export default {
computed: {
...mapState(["currentPage"]),
...mapGetters(["pageCount"]),
pageNumbers() {
if (this.pageCount < 4) {
return [...Array(this.pageCount + 1).keys()].slice(1);
} else if (this.currentPage <= 4) {
return [1, 2, 3, 4, 5];
} else if (this.currentPage > this.pageCount - 4) {
return [...Array(5).keys()].reverse()
.map(v => this.pageCount - v);
} else {
return [this.currentPage -1, this.currentPage,
this.currentPage + 1];
}
}
},
methods: {
...mapActions(["setCurrentPage", "setPageSize"]),
changePageSize($event) {
this.setPageSize($event.target.value);
}
}
}
</script>
...
Listing 7-5Using Actions in the PageControls.vue File in the src/components Folder
呈现给用户的内容在视觉上没有变化,但是如果您打开浏览器的 F12 开发人员工具,并在浏览产品时观察Network选项卡,您将看到发送的数据页面请求,如图 7-3 所示。
图 7-3
请求页面中的数据
添加搜索支持
我将在 SportsStore 应用中添加搜索产品的功能,这在处理大量数据时总是一个好主意,对于用户来说,这些数据太大了,很难完全浏览。在清单 7-6 中,我已经更新了数据存储,以便可以在用于请求数据的 URL 中包含一个搜索字符串,使用了我用于 web 服务的json-server包提供的搜索特性。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
export default new Vuex.Store({
strict: true,
modules: { cart: CartModule, orders: OrdersModule },
state: {
categoriesData: [],
currentPage: 1,
pageSize: 4,
currentCategory: "All",
pages: [],
serverPageCount: 0,
searchTerm: "",
showSearch: false
},
getters: {
processedProducts: (state) => {
return state.pages[state.currentPage];
},
pageCount: (state) => state.serverPageCount,
categories: state => ["All", ...state.categoriesData]
},
mutations: {
_setCurrentPage(state, page) {
state.currentPage = page;
},
_setPageSize(state, size) {
state.pageSize = size;
state.currentPage = 1;
},
_setCurrentCategory(state, category) {
state.currentCategory = category;
state.currentPage = 1;
},
addPage(state, page) {
for (let i = 0; i < page.pageCount; i++) {
Vue.set(state.pages, page.number + i,
page.data.slice(i * state.pageSize,
(i * state.pageSize) + state.pageSize));
}
},
clearPages(state) {
state.pages.splice(0, state.pages.length);
},
setCategories(state, categories) {
state.categoriesData = categories;
},
setPageCount(state, count) {
state.serverPageCount = Math.ceil(Number(count) / state.pageSize);
},
setShowSearch(state, show) {
state.showSearch = show;
},
setSearchTerm(state, term) {
state.searchTerm = term;
state.currentPage = 1;
},
},
actions: {
async getData(context) {
await context.dispatch("getPage", 2);
context.commit("setCategories", (await Axios.get(categoriesUrl)).data);
},
async getPage(context, getPageCount = 1) {
let url = `${productsUrl}?_page=${context.state.currentPage}`
+ `&_limit=${context.state.pageSize * getPageCount}`;
if (context.state.currentCategory != "All") {
url += `&category=${context.state.currentCategory}`;
}
if (context.state.searchTerm != "") {
url += `&q=${context.state.searchTerm}`;
}
let response = await Axios.get(url);
context.commit("setPageCount", response.headers["x-total-count"]);
context.commit("addPage", { number: context.state.currentPage,
data: response.data, pageCount: getPageCount});
},
setCurrentPage(context, page) {
context.commit("_setCurrentPage", page);
if (!context.state.pages[page]) {
context.dispatch("getPage");
}
},
setPageSize(context, size) {
context.commit("clearPages");
context.commit("_setPageSize", size);
context.dispatch("getPage", 2);
},
setCurrentCategory(context, category) {
context.commit("clearPages");
context.commit("_setCurrentCategory", category);
context.dispatch("getPage", 2);
},
search(context, term) {
context.commit("setSearchTerm", term);
context.commit("clearPages");
context.dispatch("getPage", 2);
},
clearSearchTerm(context) {
context.commit("setSearchTerm", "");
context.commit("clearPages");
context.dispatch("getPage", 2);
}
}
})
Listing 7-6Adding Search Support in the index.js File in the src/store Folder
SportsStore 搜索特性需要两个状态属性:showSearch属性将决定是否向用户显示搜索框,而searchTerm属性将指定传递给 web 服务进行搜索的术语。json-server包支持通过q参数进行搜索,这意味着应用在搜索时会生成这样的 URL:
...
http://localhost:3500/products?_page=3&_limit=4&category=Soccer&q=car
...
这个 URL 请求类别属性为Soccer的四个项目的第三个页面,并且具有包含术语car的任何字段。为了向用户呈现搜索特性,我在src/components文件夹中添加了一个名为Search.vue的文件,其内容如清单 7-7 所示。
<template>
<div v-if="showSearch" class="row my-2">
<label class="col-2 col-form-label text-right">Search:</label>
<input class="col form-control"
v-bind:value="searchTerm" v-on:input="doSearch"
placeholder="Enter search term..." />
<button class="col-1 btn btn-sm btn-secondary mx-4"
v-on:click="handleClose">
Close
</button>
</div>
</template>
<script>
import { mapMutations, mapState, mapActions } from "vuex";
export default {
computed: {
...mapState(["showSearch", "searchTerm"])
},
methods: {
...mapMutations(["setShowSearch"]),
...mapActions(["clearSearchTerm", "search"]),
handleClose() {
this.clearSearchTerm();
this.setShowSearch(false);
},
doSearch($event) {
this.search($event.target.value);
}
}
}
</script>
Listing 7-7The Contents of the Search.vue File in the src/components Folder
该组件响应数据存储属性,向用户显示一个可用于输入搜索词的input元素。通过触发搜索,v-on指令用于在每次用户编辑input的内容时做出响应。为了在应用中引入搜索特性,我对Store组件进行了清单 7-8 中所示的修改。
<template>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
<cart-summary />
</div>
</div>
<div class="row">
<div class="col-3 bg-info p-2">
<CategoryControls class="mb-5" />
<button class="btn btn-block btn-warning mt-5"
v-on:click="setShowSearch(true)">
Search
</button>
</div>
<div class="col-9 p-2">
<Search />
<ProductList />
</div>
</div>
</div>
</template>
<script>
import ProductList from "./ProductList";
import CategoryControls from "./CategoryControls";
import CartSummary from "./CartSummary";
import { mapMutations } from "vuex";
import Search from "./Search";
export default {
components: { ProductList, CategoryControls, CartSummary, Search },
methods: {
...mapMutations(["setShowSearch"])
}
}
</script>
Listing 7-8Enabling Search in the Store.vue File in the src/components Folder
我在类别列表旁边添加了一个按钮,将向用户显示搜索特性,并注册了我在清单 7-7 中定义的组件。结果是用户可以点击搜索按钮并输入文本进行搜索,如图 7-4 所示。结果显示在页面上,可以按类别过滤。
图 7-4
执行搜索
启动管理功能
任何向用户呈现内容的应用都需要某种程度的管理。对于 SportsStore,这意味着管理产品目录和客户下的订单。在接下来的小节中,我将开始向项目添加管理特性的过程,从身份验证开始,然后构建呈现管理特性的结构。
实施身份验证
RESTful web 服务的身份验证结果是一个 JSON Web 令牌(JWT ),它由服务器返回,并且必须包含在任何后续请求中,以表明应用被授权执行受保护的操作。您可以在 https://tools.ietf.org/html/rfc7519 阅读 JWT 规范,但是对于 SportsStore 应用来说,只要知道应用可以通过向/login URL 发送 POST 请求来验证用户就足够了,在请求体中包含一个 JSON 格式的对象,其中包含名称和密码属性。第五章使用的认证码只有一组有效凭证,如表 7-1 所示。
表 7-1
RESTful Web 服务支持的身份验证凭证
|名字
|
描述
|
| --- | --- |
| admin | secret |
正如我在第五章中提到的,您不应该在实际项目中硬编码凭证,但这是您在 SportsStore 应用中需要的用户名和密码。
如果正确的凭证被发送到/login URL,那么来自 RESTful web 服务的响应将包含一个 JSON 对象,如下所示:
{
"success": true,
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLCJleHBpcmVz
SW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtdWrz0312p_DG5tKypGv6cA
NgOyzlg8"
}
success属性描述认证操作的结果,而token属性包含 JWT,它应该包含在使用Authorization HTTP 头的后续请求中,格式如下:
Authorization: Bearer<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLC
JleHBpcmVzSW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtd
Wrz0312p_DG5tKypGv6cANgOyzlg8>
如果向服务器发送了错误的凭证,那么响应中返回的 JSON 对象将只包含一个设置为false的success属性,如下所示:
{
"success": false
}
我配置了服务器返回的 JWT 令牌,使它们在一小时后过期。
扩展数据存储
为了支持在 HTTP 请求中包含认证头,我在src/store文件夹中添加了一个名为auth.js的文件,其内容如清单 7-9 所示。
import Axios from "axios";
const loginUrl = "http://localhost:3500/login";
export default {
state: {
authenticated: false,
jwt: null
},
getters: {
authenticatedAxios(state) {
return Axios.create({
headers: {
"Authorization": `Bearer<${state.jwt}>`
}
});
}
},
mutations: {
setAuthenticated(state, header) {
state.jwt = header;
state.authenticated = true;
},
clearAuthentication(state) {
state.authenticated = false;
state.jwt = null;
}
},
actions: {
async authenticate(context, credentials) {
let response = await Axios.post(loginUrl, credentials);
if (response.data.success == true) {
context.commit("setAuthenticated", response.data.token);
}
}
}
}
Listing 7-9The Contents of the auth.js File in the src/store Folder
authenticate 动作使用 Axios 向 web 服务的/login URL 发送 HTTP POST 请求,如果请求成功,则设置authenticated和jwt状态属性。Axios create方法用于配置一个可用于发出请求的对象,我在authenticatedAxious getter 中使用这个特性来提供一个 Axios 对象,该对象将在它发出的所有请求中包含Authorization头。在清单 7-10 中,我将新模块添加到数据存储中。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
import AuthModule from "./auth";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
export default new Vuex.Store({
strict: true,
modules: { cart: CartModule, orders: OrdersModule, auth: AuthModule },
// ...data store features omitted for brevity...
})
Listing 7-10Adding a Module in the index.js File in the src/store Folder
添加管理组件
为了开始进行身份验证,我需要两个组件,其中一个将提示用户输入凭据,另一个将在用户通过身份验证后显示给用户,并提供管理功能。为了将管理功能与应用的其余部分分开,我创建了src/components/admin文件夹,并在其中添加了一个名为Admin.vue的文件,其内容如清单 7-11 所示。
<template>
<div class="bg-danger text-white text-center h4 p-2">Admin Features</div>
</template>
Listing 7-11The Contents of the Admin.vue File in the src/components/admin Folder
这只是一个占位符,以便我在获得基本特性的同时有一些东西可以使用,稍后我会用真正的管理特性替换这些内容。为了提示用户输入认证凭证,我在src/components/admin文件夹中添加了一个名为Authentication.vue的文件,其内容如清单 7-12 所示。
注意
在清单 7-12 中,我将用户名和密码属性的值设置为表 7-1 中的凭证,以简化开发过程。当您按照示例进行操作并对项目进行更改时,您经常需要重新进行身份验证,如果您每次都必须键入凭据,这很快就会变得令人沮丧。在第八章中,我将在准备部署应用时删除组件中的值。
<template>
<div class="m-2">
<h4 class="bg-primary text-white text-center p-2">
SportsStore Administration
</h4>
<h4 v-if="showFailureMessage"
class="bg-danger text-white text-center p-2 my-2">
Authentication Failed. Please try again.
</h4>
<div class="form-group">
<label>Username</label>
<input class="form-control" v-model="$v.username.$model">
<validation-error v-bind:validation="$v.username" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" class="form-control" v-model="$v.password.$model">
<validation-error v-bind:validation="$v.password" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="handleAuth">Log In</button>
</div>
</div>
</template>
<script>
import { required } from "vuelidate/lib/validators";
import { mapActions, mapState } from "vuex";
import ValidationError from "../ValidationError";
export default {
components: { ValidationError },
data: function() {
return {
username: "admin",
password: "secret",
showFailureMessage: false,
}
},
computed: {
...mapState({authenticated: state => state.auth.authenticated })
},
validations: {
username: { required },
password: { required }
},
methods: {
...mapActions(["authenticate"]),
async handleAuth() {
this.$v.$touch();
if (!this.$v.$invalid) {
await this.authenticate({ name: this.username,
password: this.password });
if (this.authenticated) {
this.$router.push("/admin");
} else {
this.showFailureMessage = true;
}
}
}
}
}
</script>
Listing 7-12The Contents of the Authentication.vue File in the src/components/admin Folder
该组件为用户提供了一对输入元素和一个按钮,该按钮使用清单 7-9 中的数据存储特性执行身份验证,并通过数据验证来确保用户提供用户名和密码的值。如果身份验证失败,则会显示一条警告消息。如果认证成功,则使用路由系统导航到/admin URL。为了配置路由系统来支持新的组件,我添加了清单 7-13 中所示的路由。
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
import Authentication from "../components/admin/Authentication";
import Admin from "../components/admin/Admin";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Store },
{ path: "/cart", component: ShoppingCart },
{ path: "/checkout", component: Checkout},
{ path: "/thanks/:id", component: OrderThanks},
{ path: "/login", component: Authentication },
{ path: "/admin", component: Admin},
{ path: "*", redirect: "/"}
]
})
Listing 7-13Adding Routes in the index.js File in the src/router Folder
要测试认证过程,导航至http://localhost:8080/login,输入用户名和密码,然后点击登录按钮。如果您使用表 7-1 中的凭证,认证将会成功,您将会看到占位符管理内容。如果您使用不同的凭据,身份验证将会失败,并且您会看到一个错误。两种结果如图 7-5 所示。
图 7-5
认证用户
添加路由保护
目前,没有什么可以阻止用户直接导航到/admin URL 并绕过认证。为了防止这种情况,我添加了一个路线守卫,这是一个在路线即将改变时进行评估的功能,可以阻止或改变导航。Vue 路由器包提供了一系列不同的路由保护选项,我会在第二十四章中描述。对于这一章,我需要一个特定于/admin URL 的路径的防护,如果用户没有被认证,它将阻止导航,如清单 7-14 所示。
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
import Authentication from "../components/admin/Authentication";
import Admin from "../components/admin/Admin";
import dataStore from "../store";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Store },
{ path: "/cart", component: ShoppingCart },
{ path: "/checkout", component: Checkout},
{ path: "/thanks/:id", component: OrderThanks},
{ path: "/login", component: Authentication },
{ path: "/admin", component: Admin,
beforeEnter(to, from, next) {
if (dataStore.state.auth.authenticated) {
next();
} else {
next("/login");
}
}
},
{ path: "*", redirect: "/"}
]
})
Listing 7-14Adding a Route Guard in the index.js File in the src/router Folder
当用户导航到/admin URL 并检查数据存储以查看用户是否已经被认证时,调用beforeEnter函数。导航卫士通过调用作为参数接收的next函数来工作。当守卫不带参数地调用next函数时,导航被批准。通过调用带有替换 URL 的next来阻止导航,在这个例子中是/login来提示用户输入凭证。要查看效果,导航到http://localhost:8080/admin,您会看到路由守卫将浏览器重定向到/login URL,如图 7-6 所示。
图 7-6
路线守卫的作用
注意,我使用了一个import语句来访问数据存储。我在前面的例子中使用的mapState助手函数和可用于直接访问数据存储的$store属性只能在组件中使用,在应用的其他地方不可用,比如在路由配置中。import语句为我提供了对数据存储的访问,通过它我可以读取state属性的值,route guard 需要这个值来确定用户是否已经过身份验证。
添加管理组件结构
现在,身份验证功能已经就绪,我将返回到主要的管理功能。管理员需要能够管理呈现给用户的产品集合,查看订单并将它们标记为已发货。我将首先为每个功能区域创建占位符组件,使用它们来设置向用户显示它们的结构,然后实现每个功能的细节。我在src/components/admin文件夹中添加了一个名为ProductAdmin.vue的文件,内容如清单 7-15 所示。
<template>
<div class="bg-danger text-white text-center h4 p-2">
Product Admin
</div>
</template>
Listing 7-15The Contents of the ProductAdmin.vue File in the src/components/admin Folder
接下来,我在同一个文件夹中添加了一个名为OrderAdmin.vue的文件,其内容如清单 7-16 所示。
<template>
<div class="bg-danger text-white text-center h4 p-2">
Order Admin
</div>
</template>
Listing 7-16The Contents of the OrderAdmin.vue File in the src/components/admin Folder
为了向用户展示这些新组件,我用清单 7-17 中所示的元素替换了Admin组件中的内容。
<template>
<div class="container-fluid">
<div class="row">
<div class="col bg-secondary text-white">
<a class="navbar-brand">SPORTS STORE Admin</a>
</div>
</div>
<div class="row">
<div class="col-3 bg-secondary p-2">
<router-link to="/admin/products" class="btn btn-block btn-primary"
active-class="active">
Products
</router-link>
<router-link to="/admin/orders" class="btn btn-block btn-primary"
active-class="active">
Orders
</router-link>
</div>
<div class="col-9 p-2">
<router-view />
</div>
</div>
</div>
</template>
Listing 7-17Presenting Components in the Admin.vue File in the src/components/admin Folder
应用可以包含多个router-view元素,我在这里使用了一个,这样我就可以使用 URL 路由在产品和订单管理组件之间进行切换,稍后我将对其进行配置。为了选择组件,我使用了被格式化为按钮的router-link元素。为了指示哪个按钮代表激活的选择,我使用了active-class属性,它指定了一个类,当由它的to属性指定的路线激活时,元素将被添加到这个类中,如第二十三章中所解释的。
为了配置新的路由器视图元素,我将清单 7-18 中所示的路由添加到应用的路由配置中。
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
import Authentication from "../components/admin/Authentication";
import Admin from "../components/admin/Admin";
import ProductAdmin from "../components/admin/ProductAdmin";
import OrderAdmin from "../components/admin/OrderAdmin";
import dataStore from "../store";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Store },
{ path: "/cart", component: ShoppingCart },
{ path: "/checkout", component: Checkout},
{ path: "/thanks/:id", component: OrderThanks},
{ path: "/login", component: Authentication },
{ path: "/admin", component: Admin,
beforeEnter(to, from, next) {
if (dataStore.state.auth.authenticated) {
next();
} else {
next("/login");
}
},
children: [
{ path: "products", component: ProductAdmin },
{ path: "orders", component: OrderAdmin },
{ path: "", redirect: "/admin/products"}
]
},
{ path: "*", redirect: "/"}
]
})
Listing 7-18Adding Routes in the index.js File in the src/router Folder
新增加的内容使用了子路由特性,我在第二十三章对此进行了描述,它允许使用嵌套的router-view元素。结果是导航到/admin/products将显示顶层的Admin组件,然后是ProductAdmin组件,而/admin/orders URL 将显示OrderAdmin组件,如图 7-7 所示。还有一个回退路由将/admin URL 重定向到/admin/products。
图 7-7
使用子路由和嵌套路由器视图元素
实施订单管理功能
为了让管理员管理订单,我需要提供来自服务器的对象列表,并提供将每个对象标记为已发货的方法。这些特性的起点是扩展数据存储,如清单 7-19 所示。
import Axios from "axios";
import Vue from "vue";
const ORDERS_URL = "http://localhost:3500/orders";
export default {
state: {
orders:[]
},
mutations: {
setOrders(state, data) {
state.orders = data;
},
changeOrderShipped(state, order) {
Vue.set(order, "shipped",
order.shipped == null || !order.shipped ? true : false);
}
},
actions: {
async storeOrder(context, order) {
order.cartLines = context.rootState.cart.lines;
return (await Axios.post(ORDERS_URL, order)).data.id;
},
async getOrders(context) {
context.commit("setOrders",
(await context.rootGetters.authenticatedAxios.get(ORDERS_URL)).data);
},
async updateOrder(context, order) {
context.commit("changeOrderShipped", order);
await context.rootGetters.authenticatedAxios
.put(`${ORDERS_URL}/${order.id}`, order);
}
}
}
Listing 7-19Adding Features in the orders.js File in the src/store Folder
您已经看到了如何使用 Vue.js 在按需请求的页面中呈现数据,所以我保持了简单的订单特性:所有数据都是通过getOrders动作请求的,订单可以使用updateOrder动作修改。
为了提供一个订单列表,并允许对它们进行标记和发货,我替换了来自OrderAdmin组件的占位符内容,并将其替换为清单 7-20 中所示的内容和代码。
<template>
<div>
<h4 class="bg-info text-white text-center p-2">Orders</h4>
<div class="form-group text-center">
<input class="form-check-input" type="checkbox" v-model="showShipped" />
<label class="form-check-label">Show Shipped Orders</label>
</div>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>ID</th><th>Name</th><th>City, Zip</th>
<th class="text-right">Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="displayOrders.length == 0">
<td colspan="5">There are no orders</td>
</tr>
<tr v-for="o in displayOrders" v-bind:key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.name }}</td>
<td>{{ `${o.city}, ${o.zip}` }}</td>
<td class="text-right">{{ getTotal(o) | currency }}</td>
<td class="text-center">
<button class="btn btn-sm btn-danger"
v-on:click="shipOrder(o)">
{{ o.shipped ? 'Not Shipped' : 'Shipped' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import { mapState, mapActions, mapMutations } from "vuex";
export default {
data: function() {
return {
showShipped: false
}
},
computed: {
...mapState({ orders: state => state.orders.orders}),
displayOrders() {
return this.showShipped ? this.orders
: this.orders.filter(o => o.shipped != true);
}
},
methods: {
...mapMutations(["changeOrderShipped"]),
...mapActions(["getOrders", "updateOrder"]),
getTotal(order) {
if (order.cartLines != null && order.cartLines.length > 0) {
return order.cartLines.reduce((total, line) =>
total + (line.quantity * line.product.price), 0)
} else {
return 0;
}
},
shipOrder(order) {
this.updateOrder(order);
}
},
created() {
this.getOrders();
}
}
</script>
Listing 7-20Managing Orders in the OrderAdmin.vue File in the src/components/admin Folder
该组件显示一个订单表,其中每个订单都有一个更改发货状态的按钮。要查看效果,导航到http://localhost:8080并使用商店功能创建一个或多个订单,然后导航到http://localhost:8080/admin,验证自己,并单击订单按钮查看和管理列表,如图 7-8 所示。
图 7-8
管理订单
摘要
在本章中,我继续构建 SportsStore 应用。我向您展示了如何使用 Vue.js 及其核心库来处理大量的数据,并且我实现了初始的管理特性,从认证和订单管理开始。在下一章中,我将完成管理特性并部署完整的应用。
八、SportsStore:管理和部署
在本章中,我将通过添加剩余的管理功能来完成 SportsStore 应用,并向您展示如何准备和部署项目。正如您将看到的,从开发到生产的转换相对简单且易于执行。
为本章做准备
本章使用第七章中的 SportsStore 项目,在准备本章时不需要做任何更改。
小费
你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书所有其他章节的示例项目。
要启动 RESTful web 服务,请打开命令提示符并在sportsstore文件夹中运行以下命令:
npm run json
打开第二个命令提示符,在sportsstore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:
npm run serve
一旦初始构建过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080以查看图 8-1 中显示的内容。
图 8-1
运行 SportsStore 应用
添加产品管理功能
为了完成管理功能,我需要让 SportsStore 应用能够创建、编辑和删除产品对象。首先,我将扩展数据存储,通过向 web 服务发送 HTTP 请求并更新产品数据来提供支持这些操作的操作,如清单 8-1 所示。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
import AuthModule from "./auth";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
export default new Vuex.Store({
strict: true,
modules: { cart: CartModule, orders: OrdersModule, auth: AuthModule },
state: {
// ...state properties omitted for brevity...
},
getters: {
processedProducts: (state) => {
return state.pages[state.currentPage];
},
pageCount: (state) => state.serverPageCount,
categories: state => ["All", ...state.categoriesData],
productById:(state) => (id) => {
return state.pages[state.currentPage].find(p => p.id == id);
}
},
mutations: {
_setCurrentPage(state, page) {
state.currentPage = page;
},
// ...other mutations omitted for brevity...
setSearchTerm(state, term) {
state.searchTerm = term;
state.currentPage = 1;
},
_addProduct(state, product) {
state.pages[state.currentPage].unshift(product);
},
_updateProduct(state, product) {
let page = state.pages[state.currentPage];
let index = page.findIndex(p => p.id == product.id);
Vue.set(page, index, product);
}
},
actions: {
async getData(context) {
await context.dispatch("getPage", 2);
context.commit("setCategories", (await Axios.get(categoriesUrl)).data);
},
// ...other actions omitted for brevity...
async addProduct(context, product) {
let data = (await context.getters.authenticatedAxios.post(productsUrl,
product)).data;
product.id = data.id;
this.commit("_addProduct", product);
},
async removeProduct(context, product) {
await context.getters.authenticatedAxios
.delete(`${productsUrl}/${product.id}`);
context.commit("clearPages");
context.dispatch("getPage", 1);
},
async updateProduct(context, product) {
await context.getters.authenticatedAxios
.put(`${productsUrl}/${product.id}`, product);
this.commit("_updateProduct", product);
}
}
})
Listing 8-1Adding Administration Features in the index.js File in the src/store Folder
组件将调用这些操作来存储、删除或更改产品,这需要向 web 服务发出 HTTP 请求,并对本地数据进行相应的更改。当一个产品被改变时,我定位现有的对象并替换它,但是对于其他的操作,我采取轻微的快捷方式。当添加一个产品时,我将它插入到产品的当前页面的开头,即使这意味着页面大小不正确,这样我就不必确定新对象应该显示在哪个页面上,也不必从服务器获取数据。当删除一个对象时,我会刷新数据,这样我就不必手动重新分页来填补空白。这些快捷方式是通过使用_addProduct和_updateProduct突变实现的,我在它们的名字前加了下划线,以表明它们不会被广泛使用。我还在清单 8-1 中添加了一个 getter,这样我就可以通过产品的id在当前页面中定位产品。这种类型的 getter 有一个参数,它像方法一样使用,这意味着我可以定义在数据存储中定位产品的逻辑,而不必获取页面中的所有对象并在组件中执行搜索。
展示产品列表
为了向用户展示产品,并提供创建、编辑和删除它们的方法,我从第七章中创建的ProductAdmin组件中移除了占位符内容,并用清单 8-2 中所示的 HTML 元素和代码替换了它。
<template>
<div>
<router-link to="/admin/products/create" class="btn btn-primary my-2">
Create Product
</router-link>
<table class="table table-sm table-bordered">
<thead>
<th>ID</th><th>Name</th><th>Category</th>
<th class="text-right">Price</th><th></th>
</thead>
<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 class="text-right">{{ p.price | currency }}</td>
<td class="text-center">
<button class="btn btn-sm btn-danger mx-1"
v-on:click="removeProduct(p)">Delete</button>
<button class="btn btn-sm btn-warning mx-1"
v-on:click="handleEdit(p)">Edit</button>
</td>
</tr>
</tbody>
</table>
<page-controls />
</div>
</template>
<script>
import PageControls from "../PageControls";
import { mapGetters, mapActions } from "vuex";
export default {
components: { PageControls },
computed: {
...mapGetters({
products: "processedProducts"
})
},
methods: {
...mapActions(["removeProduct"]),
handleEdit(product) {
this.$router.push(`/admin/products/edit/${product.id}`);
}
}
}
</script>
Listing 8-2Adding Features in the ProductAdmin.vue File in the src/components/admin Folder
该组件向用户呈现一个产品表,使用v-for指令从数据模型中填充。表格中的每一行都有删除和编辑按钮。单击一个删除按钮会调度添加到清单 8-1 中的数据存储中的removeProduct动作。单击“编辑”按钮或“创建产品”按钮会将浏览器重定向到一个 URL,我将使用该 URL 来显示可用于修改或创建产品的编辑器。
小费
注意,我能够使用前面章节中的PageControls组件来处理产品的分页。管理功能建立在前面章节中面向客户的功能所使用的相同数据存储功能的基础上,这意味着分页等常见功能可以很容易地重用。
添加编辑器占位符和 URL 路由
按照前几章中使用的模式,我将为编辑器创建一个占位符组件,并在它集成到应用中后返回添加它的特性。我在src/components/admin文件夹中添加了一个名为ProductEditor.vue的文件,内容如清单 8-3 所示。
<template>
<div class="bg-info text-white text-center h4 p-2">
Product Editor
</div>
</template>
Listing 8-3The Contents of the ProductEditor.vue File in the src/components/admin Folder
为了将编辑器组件集成到应用中,我添加了清单 8-4 中所示的路径,当用户单击编辑或创建产品按钮时,该路径将匹配我在ProductAdmin组件中使用的 URL。
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
import Authentication from "../components/admin/Authentication";
import Admin from "../components/admin/Admin";
import ProductAdmin from "../components/admin/ProductAdmin";
import OrderAdmin from "../components/admin/OrderAdmin";
import ProductEditor from "../components/admin/ProductEditor";
import dataStore from "../store";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Store },
{ path: "/cart", component: ShoppingCart },
{ path: "/checkout", component: Checkout},
{ path: "/thanks/:id", component: OrderThanks},
{ path: "/login", component: Authentication },
{ path: "/admin", component: Admin,
beforeEnter(to, from, next) {
if (dataStore.state.auth.authenticated) {
next();
} else {
next("/login");
}
},
children: [
{ path: "products/:op(create|edit)/:id(\\d+)?",
component: ProductEditor },
{ path: "products", component: ProductAdmin },
{ path: "orders", component: OrderAdmin },
{ path: "", redirect: "/admin/products"}
]
},
{ path: "*", redirect: "/"}
]
})
Listing 8-4Adding a Route in the index.js File in the src/router Folder
正如我在第二十二章中解释的,当 Vue 路由器包匹配 URL 并支持正则表达式时,它能够处理复杂的模式。我在清单 8-4 中添加的路由将匹配/admin/products/create URL 和/admin/products/edit/id URL,前者表明用户想要添加一个新产品,后者的最后一段是一个数值,对应于用户想要编辑的产品的id属性。
要查看产品管理功能,请导航至http://localhost:8080/login并完成认证过程。一旦通过认证,您将看到如图 8-2 所示的产品列表。如果您单击其中一个删除按钮,您选择的产品将从 web 服务中删除。如果您单击“创建产品”按钮或其中一个编辑按钮,您将看到占位符内容。
小费
请记住,您可以通过使用本章开头的命令重新启动json-server过程来重新创建所有的测试数据。这将重置数据并放弃您所做的任何更改、添加或删除。
图 8-2
产品管理功能
实现编辑器功能
编辑器组件将用于创建和编辑产品,并根据与当前 URL 匹配的路线确定用户需要的活动。在清单 8-5 中,我已经删除了占位符内容,并添加了创建和修改产品所需的内容和代码。
<template>
<div>
<h4 class="text-center text-white p-2" v-bind:class="themeClass">
{{ editMode ? "Edit" : "Create Product" }}
</h4>
<h4 v-if="$v.$invalid && $v.$dirty"
class="bg-danger text-white text-center p-2">
Values Required for All Fields
</h4>
<div class="form-group" v-if="editMode">
<label>ID (Not Editable)</label>
<input class="form-control" disabled 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>Description</label>
<input class="form-control" v-model="product.description" />
</div>
<div class="form-group">
<label>Category</label>
<select v-model="product.category" class="form-control">
<option v-for="c in categories" v-bind:key="c">
{{ c }}
</option>
</select>
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model="product.price" />
</div>
<div class="text-center">
<router-link to="/admin/products"
class="btn btn-secondary m-1">Cancel
</router-link>
<button class="btn m-1" v-bind:class="themeClassButton"
v-on:click="handleSave">
{{ editMode ? "Save Changes" : "Store Product"}}
</button>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from "vuex";
import { required } from "vuelidate/lib/validators";
export default {
data: function() {
return {
product: {}
}
},
computed: {
...mapState({
pages: state => state.pages,
currentPage: state => state.currentPage,
categories: state => state.categoriesData
}),
editMode() {
return this.$route.params["op"] == "edit";
},
themeClass() {
return this.editMode ? "bg-info" : "bg-primary";
},
themeClassButton() {
return this.editMode ? "btn-info" : "btn-primary";
}
},
validations: {
product: {
name: { required },
description: { required },
category: { required },
price: { required }
}
},
methods: {
...mapActions(["addProduct", "updateProduct"]),
async handleSave() {
this.$v.$touch();
if (!this.$v.$invalid) {
if (this.editMode) {
await this.updateProduct(this.product);
} else {
await this.addProduct(this.product);
}
this.$router.push("/admin/products");
}
}
},
created() {
if (this.editMode) {
Object.assign(this.product,
this.$store.getters.productById(this.$route.params["id"]))
}
}
}
</script>
Listing 8-5Adding Features in the ProductEditor.vue File in the src/components/admin Folder
该组件为用户提供一个 HTML 表单,其中包含创建或编辑产品所需的字段。创建组件的目的是通过获取活动路线的详细信息来确定的,当用户执行编辑操作时,会在数据存储中查询产品,这是使用created方法完成的,我在第十七章中对此进行了描述。如第四章中所述,我使用Object.assign从数据存储对象中复制属性,这样就可以在不更新数据存储的情况下进行更改,允许用户点击取消按钮并放弃更改。该表单具有基本的验证功能,并调用数据存储中的操作来存储或更新产品。
要编辑产品,导航至http://localhost:8080/admin,执行验证,并点击其中一个编辑按钮。使用表单进行更改,然后单击保存更改按钮,查看产品表中反映的更改,如图 8-3 所示。
图 8-3
编辑产品
要创建产品,请单击“创建产品”按钮,填充表单,然后单击“存储产品”按钮。你会在页面顶部看到新产品,如图 8-4 所示。
图 8-4
创造产品
部署 SportsStore
在接下来的小节中,我将介绍部署 SportsStore 应用的过程。我首先对配置进行更改,无论应用部署到哪个平台,这些更改都是必需的,并使 SportsStore 为生产使用做好准备。然后,我使用 Docker 创建一个容器,其中包含 SportsStore 及其所需的服务,以替换开发过程中用于运行应用的开发工具。
码头工人的替代品
我在本章中使用 Docker 是因为它简单且一致,并且您可以在一台功能相当强大的开发机器上遵循这个示例,而不需要单独的生产硬件。对于您自己的项目来说,有很多 Docker 的替代品可以考虑,Vue.js 可以以无数不同的方式部署,以满足不同应用的需求。您不必使用 Docker,但是不管您选择如何部署您的应用,请记住进行下一节中显示的配置更改。
为部署准备应用
为了准备部署应用,需要做一些小的更改。这些变化不会改变应用的行为,但它们很重要,因为它们禁用了对开发人员有用但对生产有影响的功能。
准备数据存储
第一个变化是在 Vuex 数据存储中禁用严格模式,如清单 8-6 所示。这在开发过程中是一个有用的特性,因为当您直接修改状态属性而不是通过突变修改时,它会向您发出警告,但是在生产过程中却没有用,并且会影响性能,尤其是在复杂的应用中。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
import AuthModule from "./auth";
Vue.use(Vuex);
const baseUrl = "/api";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
export default new Vuex.Store({
strict: false,
modules: { cart: CartModule, orders: OrdersModule, auth: AuthModule },
// ... data store features omitted for brevity...
})
Listing 8-6Preparing for Deployment in the index.js File in the src/store Folder
在开发过程中,我使用了一个单独的流程来处理 web 服务请求。对于已部署的应用,我将把应用及其数据的 HTTP 请求合并到一个服务器中,这需要更改数据存储用来获取数据的 URL,这就是为什么我更改了baseUrl值。在清单 8-7 中,我更改了认证模块使用的 URL。
import Axios from "axios";
const loginUrl = "/api/login";
export default {
state: {
authenticated: false,
jwt: null
},
// ... data store features omitted for brevity...
}
Listing 8-7Changing the URL in the auth.js File in the src/store Folder
用于管理订单的 URL 也必须更改,如清单 8-8 所示。
import Axios from "axios";
import Vue from "vue";
const ORDERS_URL = "/api/orders";
export default {
state: {
orders:[]
},
// ... data store features omitted for brevity...
}
Listing 8-8Changing the URL in the orders.js File in the src/store Folder
准备身份验证组件
下一个变化是从组件中移除用于管理认证的凭证,如清单 8-9 所示,这在开发期间很有用,但是不应该包含在生产中。
...
<script>
import { required } from "vuelidate/lib/validators";
import { mapActions, mapState } from "vuex";
import ValidationError from "../ValidationError";
export default {
components: { ValidationError },
data: function() {
return {
username: null,
password: null,
showFailureMessage: false,
}
},
computed: {
...mapState({authenticated: state => state.auth.authenticated })
},
validations: {
username: { required },
password: { required }
},
methods: {
...mapActions(["authenticate"]),
async handleAuth() {
this.$v.$touch();
if (!this.$v.$invalid) {
await this.authenticate({ name: this.username,
password: this.password });
if (this.authenticated) {
this.$router.push("/admin");
} else {
this.showFailureMessage = true;
}
}
}
}
}
</script>
...
Listing 8-9Removing Credentials in the Authentication.vue file in the src/components/admin Folder
按需加载管理功能
SportsStore 应用的每个功能都包含在由 Vue.js 构建工具创建的 JavaScript 包中,尽管管理功能可能会被一小部分用户使用。Vue.js 使得将应用分解成单独的包变得很容易,这些包只能在第一次需要时加载。为了将管理特性分离到它们自己的包中,我在src/components/admin文件夹中添加了一个名为index.js的文件,代码如清单 8-10 所示。
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
const Authentication = () =>
import(/* webpackChunkName: "admin" */ "../components/admin/Authentication");
const Admin = () =>
import(/* webpackChunkName: "admin" */ "../components/admin/Admin");
const ProductAdmin = () =>
import(/* webpackChunkName: "admin" */ "../components/admin/ProductAdmin");
const OrderAdmin = () =>
import(/* webpackChunkName: "admin" */ "../components/admin/OrderAdmin");
const ProductEditor = () =>
import(/* webpackChunkName: "admin" */ "../components/admin/ProductEditor");
import dataStore from "../store";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Store },
{ path: "/cart", component: ShoppingCart },
{ path: "/checkout", component: Checkout},
{ path: "/thanks/:id", component: OrderThanks},
{ path: "/login", component: Authentication },
{ path: "/admin", component: Admin,
beforeEnter(to, from, next) {
if (dataStore.state.auth.authenticated) {
next();
} else {
next("/login");
}
},
children: [
{ path: "products/:op(create|edit)/:id(\\d+)?",
component: ProductEditor },
{ path: "products", component: ProductAdmin },
{ path: "orders", component: OrderAdmin },
{ path: "", redirect: "/admin/products"}
]
},
{ path: "*", redirect: "/"}
]
})
Listing 8-10The Contents of the index.js File in the src/components/admin Folder
普通的import语句在一个模块上创建了一个静态依赖,其效果是导入一个组件确保它包含在发送给浏览器的 JavaScript 文件中。正如我在第二十一章中解释的,我在清单 8-10 中使用的import语句类型是动态的,这意味着管理特性所需的组件将被放入一个单独的 JavaScript 文件中,该文件在第一次需要这些组件时被加载。这确保了这些特性只对少数需要它们的用户可用,而不会被其他用户下载。
注意
您可能会发现,作为清单 8-10 的结果而创建的独立模块无论如何都会被加载,即使没有使用管理特性。这是因为 Vue.js 项目被配置为向浏览器提供预取提示,这些提示表明将来可能需要某些内容。浏览器可以忽略这些提示,但是仍然可以选择请求该模块。在第二十一章中,我演示了如何改变项目的配置,这样预取提示就不会发送到浏览器。
我在import语句中加入的笨拙的注释确保了组件被打包到一个单独的文件中。如果没有这些注释,每个组件将会以一个单独的文件结束,服务器会在第一次需要它的时候请求这个文件。因为这些组件提供了相关的特性,所以我将它们组合在一起。这是一个特定于 Vue.js 工具用来生成 JavaScript 代码束的工具的特性——被称为web pack——除非你已经像我在第五章中所做的那样使用 Vue.js 命令行工具创建了你的项目,否则它可能无法工作。
创建数据文件
我一直在处理 RESTful web 服务启动时以编程方式生成的测试数据,这在开发过程中非常有用,因为每次都会重新生成数据,丢弃任何修改。对于部署,我将切换到将被持久化的数据,确保更改得到保留。我在sportsstore文件夹中添加了一个名为data.json的文件,内容如清单 8-11 所示。
{
"products": [
{ "id": 1, "name": "Kayak", "category": "Watersports",
"description": "A boat for one person", "price": 275 },
{ "id": 2, "name": "Lifejacket", "category": "Watersports",
"description": "Protective and fashionable", "price": 48.95 },
{ "id": 3, "name": "Soccer Ball", "category": "Soccer",
"description": "FIFA-approved size and weight", "price": 19.50 },
{ "id": 4, "name": "Corner Flags", "category": "Soccer",
"description": "Give your playing field a professional touch",
"price": 34.95 },
{ "id": 5, "name": "Stadium", "category": "Soccer",
"description": "Flat-packed 35,000-seat stadium", "price": 79500 },
{ "id": 6, "name": "Thinking Cap", "category": "Chess",
"description": "Improve brain efficiency by 75%", "price": 16 },
{ "id": 7, "name": "Unsteady Chair", "category": "Chess",
"description": "Secretly give your opponent a disadvantage",
"price": 29.95 },
{ "id": 8, "name": "Human Chess Board", "category": "Chess",
"description": "A fun game for the family", "price": 75 },
{ "id": 9, "name": "Bling Bling King", "category": "Chess",
"description": "Gold-plated, diamond-studded King", "price": 1200 }
],
"categories": ["Watersports", "Soccer", "Chess"],
"orders": []
}
Listing 8-11The Contents of the data.json File in the sportsstore Folder
构建用于部署的应用
要构建可以部署的 SportsStore 应用,运行清单sportsstore文件夹中的 8-12 所示的命令。
npm run build
Listing 8-12Building the Project
构建过程会生成 JavaScript 文件,这些文件针对交付给浏览器进行了优化,并且排除了开发功能,例如当其中一个源文件发生更改时自动重新加载浏览器。构建过程可能需要一段时间,完成后,您会看到该过程的摘要,如下所示:
WARNING Compiled with 2 warnings
warning
asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
img/fontawesome-webfont.912ec66d.svg (434 KiB)
warning
entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
app (346 KiB)
css/chunk-vendors.291cfd91.css
js/chunk-vendors.56adf36a.js
js/app.846b07bf.js
File Size Gzipped
dist\js\chunk-vendors.56adf36a.js 160.98 kb 54.10 kb
dist\js\app.846b07bf.js 25.08 kb 6.57 kb
dist\js\admin.b43c91ef.js 11.62 kb 3.13 kb
dist\css\chunk-vendors.291cfd91.css 159.97 kb 26.60 kb
Images and other types of assets omitted.
DONE Build complete. The dist directory is ready to be deployed.
关于大小的警告可以忽略,构建过程的输出是一组 JavaScript 和 CSS 文件,包含应用需要的内容、代码和样式,所有这些都在dist文件夹中。(您可能会看到不同的警告,因为构建过程中使用的工具经常更新。)
测试部署就绪的应用
在打包应用进行部署之前,有必要进行一次快速测试,以确保一切正常。在开发过程中,对 HTML、JavaScript 和 CSS 文件的 HTTP 请求是由 Vue.js 开发工具处理的,不能在生产中使用。作为适合生产的替代方案,我将安装流行的Express包,这是一个广泛使用的运行在 Node.js 上的 web 服务器。运行清单 8-13 中所示的命令来安装 Express 包和支持 URL 路由所需的相关包。
npm install --save-dev express@4.16.3
npm install --save-dev connect-history-api-fallback@1.5.0
Listing 8-13Adding Packages
我在sportsstore项目文件夹中添加了一个名为server.js的文件,并添加了清单 8-14 中所示的语句,这些语句配置了清单 8-13 中安装的包,因此它们将服务于 SportsStore 应用。
const express = require("express");
const history = require("connect-history-api-fallback");
const jsonServer = require("json-server");
const bodyParser = require('body-parser');
const auth = require("./authMiddleware");
const router = jsonServer.router("data.json");
const app = express();
app.use(bodyParser.json());
app.use(auth);
app.use("/api", router);
app.use(history());
app.use("/", express.static("./dist"));
app.listen(80, function () {
console.log("HTTP Server running on port 80");
});
Listing 8-14The Contents of the server.js File in the sportsstore Folder
运行sportstore文件夹中清单 8-15 所示的命令来测试应用。
node server.js
Listing 8-15Testing the Deployment Build
这个命令执行清单 8-14 中 JavaScript 文件中的语句,这些语句在端口 80 上设置一个 web 服务器并监听请求。要测试应用,导航到http://localhost:80,您将看到应用正在运行,如图 8-5 所示。
图 8-5
测试应用
部署应用
第一步是在你的开发机器上下载并安装 Docker 工具,可以从 www.docker.com/products/docker 获得。有适用于 macOS、Windows 和 Linux 的版本,也有一些适用于 Amazon 和 Microsoft 云平台的专门版本。对于这一章,免费的社区版已经足够了。
警告
生产 Docker 软件的公司因做出突破性的改变而闻名。这意味着后面的示例可能无法在更高版本中正常工作。如果你有问题,检查这本书的更新( https://github.com/Apress/pro-vue-js-2 )。
创建包文件
为了将应用部署到 Docker,我需要创建一个版本的package.js文件,它将安装运行应用所需的包。我在sportsstore文件夹中添加了一个名为deploy-package.json的文件,内容如清单 8-16 所示。
{
"name": "sportsstore",
"version": "1.0.0",
"private": true,
"dependencies": {
"faker": "⁴.1.0",
"json-server": "⁰.12.1",
"jsonwebtoken": "⁸.1.1",
"express": "4.16.3",
"connect-history-api-fallback": "1.5.0"
}
}
Listing 8-16The Contents of the deploy-package.json File in the sportsstore Folder
创建 Docker 容器
为了定义容器,我在sportsstore文件夹中添加了一个名为Dockerfile(没有扩展名)的文件,并添加了清单 8-17 中所示的内容。
FROM node:8.11.2
RUN mkdir -p /usr/src/sportsstore
COPY dist /usr/src/sportsstore/dist
COPY authMiddleware.js /usr/src/sportsstore/
COPY data.json /usr/src/sportsstore/
COPY server.js /usr/src/sportsstore/server.js
COPY deploy-package.json /usr/src/sportsstore/package.json
WORKDIR /usr/src/sportsstore
RUN npm install
EXPOSE 80
CMD ["node", "server.js"]
Listing 8-17The Contents of the Dockerfile File in the sportsstore Folder
Dockerfile的内容使用一个用 Node.js 配置的基本映像,它复制运行应用所需的文件,包括包含应用的包文件和将用于安装在部署中运行应用所需的包的package.json文件。
运行sportsstore文件夹中清单 8-18 中的命令,创建一个包含 SportsStore 应用的映像,以及它需要的所有工具和包。
docker build . -t sportsstore -f Dockerfile
Listing 8-18Building the Docker Image
图像是容器的模板。当 Docker 处理 Docker 文件中的指令时,将下载并安装 NPM 包,并将配置和代码文件复制到映像中。
运行应用
一旦创建了映像,使用清单 8-19 中的命令创建并启动一个新的容器。
docker run -p 80:80 sportsstore
Listing 8-19Creating a Docker Container
您可以通过在浏览器中打开http://localhost来测试应用,这将显示运行在容器中的 web 服务器提供的响应,如图 8-6 所示。
小费
如果因为端口 80 不可用而收到错误,可以通过更改-p参数的第一部分来尝试不同的端口。例如,如果您想监听端口 500,那么参数应该是-p 500:80。
图 8-6
运行容器化 SportsStore 应用
要停止容器,运行清单 8-20 中所示的命令。
docker ps
Listing 8-20Stopping the Docker Container
您将看到一个正在运行的容器列表,如下所示(为简洁起见,我省略了一些字段):
CONTAINER ID IMAGE COMMAND CREATED
ecc84f7245d6 sportsstore "node server.js" 33 seconds ago
使用容器 ID 列中的值,运行清单 8-21 中所示的命令。
docker stop ecc84f7245d6
Listing 8-21Stopping the Docker Container
摘要
在本章中,我通过添加对管理产品目录和准备部署的支持来完成 SportsStore 应用,这说明了将 Vue.js 项目从开发转移到生产是多么容易。
这部分书到此结束。在第二部分中,我开始深入研究细节,并向您展示我用来创建 SportsStore 应用的特性是如何深入工作的。