VueJS2-高级教程-二-

64 阅读26分钟

VueJS2 高级教程(二)

原文:Pro Vue.js 2

协议:CC BY-NC-SA 4.0

五、SportsStore:一个真正的应用

这本书的大部分章节都包含了专注于某个特定特性的小例子。这是一种向您展示 Vue.js 不同部分如何工作的有用方式,但有时缺乏上下文,并且很难将一章中的功能与其他章中的功能联系起来。为了帮助解决这个问题,我将在本章和后面的章节中创建一个更复杂的应用。

我的应用名为 SportsStore,将遵循各地在线商店采用的经典方法。我将创建一个客户可以按类别和页面浏览的在线产品目录,一个用户可以添加和删除产品的购物车,以及一个客户可以输入送货细节和下订单的收银台。我还将创建一个管理区域,其中包括用于管理目录的工具—我将保护它,以便只有登录的管理员才能进行更改。最后,我将向您展示如何准备应用,以便可以部署它。

我在这一章和后面几章的目标是通过创建尽可能真实的例子,让你对真正的 Vue.js 开发有所了解。当然,我想把重点放在 Vue.js 上,所以我简化了与外部系统的集成,比如后端数据服务器,并完全省略了其他部分,比如支付处理。

SportsStore 是我在几本书中使用的一个例子,尤其是因为它展示了使用不同的框架、语言和开发风格来实现相同结果的方法。你不需要阅读我的任何其他书籍来理解这一章,但如果你已经拥有我的Pro ASP.NET 核心 MVC 2 或 Pro Angular 书籍,你会发现这种对比很有趣。

我在 SportsStore 应用中使用的 Vue.js 特性将在后面的章节中详细介绍。我不会在这里重复所有的内容,我告诉您的内容足以让您理解示例应用,并让您参考其他章节以获得更深入的信息。你可以从头到尾阅读 SportsStore 章节,了解 Vue.js 的工作方式,也可以在详细章节之间跳转,深入了解。

警告

不要期望马上理解所有的东西——vue . js 有许多活动的部分,SportsStore 应用旨在向您展示它们是如何组合在一起的,而不会深入到本书其余部分描述的细节中。如果您陷入了困境,那么可以考虑阅读本书的第二部分,开始阅读各个特性,稍后再回到本章。

创建 SportsStore 项目

任何开发工作的第一步都是创建项目。打开一个新的命令提示符,导航到一个方便的位置,并运行清单 5-1 中所示的命令。

注意

在撰写本文时,@vue/cli包已经发布了测试版。在最终发布之前可能会有一些小的变化,但是核心特性应该保持不变。有关任何突破性变化的详细信息,请查看本书的勘误表,可在 https://github.com/Apress/pro-vue-js-2 获得。

vue create sportsstore --default

Listing 5-1Creating the SportsStore Project

将创建项目,并下载和安装应用和开发工具所需的包,这可能需要一些时间才能完成。

小费

你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书所有其他章节的示例项目。

添加附加包

Vue.js 专注于核心特性,并辅以可选的软件包,其中一些由主要的 Vue.js 团队开发,另一些由感兴趣的第三方开发。Vue.js 开发所需的大部分包都是自动添加到项目中的,但是 SportsStore 项目需要添加一些包。运行清单 5-2 中所示的命令,导航到sportsstore文件夹并添加所需的包。(npm工具可以用来在一个命令中添加多个包,但是我已经将每个包分开,以便更容易看到名称和版本。)

cd sportsstore
npm install axios@0.18.0
npm install vue-router@3.0.1
npm install vuex@3.0.1
npm install vuelidate@0.7.4
npm install bootstrap@4.0.0
npm install font-awesome@4.7.0
npm install --save-dev json-server@0.12.1
npm install --save-dev jsonwebtoken@8.1.1
npm install --save-dev faker@4.1.0

Listing 5-2Adding Packages

使用清单中显示的版本号很重要。在添加包时,您可能会看到关于未满足对等依赖关系的警告,但是这些可以忽略。表 5-1 中描述了每个包在 SportsStore 应用中的作用。有些包是使用--save-dev参数安装的,这表明它们是在开发过程中使用的,不会成为 SportsStore 应用的一部分。

表 5-1

SportsStore 项目所需的附加包

|

名字

|

描述

| | --- | --- | | axios | Axios 包用于向为 SportsStore 提供数据和服务的 web 服务发出 HTTP 请求。Axios 并不特定于 Vue.js,但它是处理 HTTP 的常见选择。我在第十九章描述了 Axios 在 Vue.js 应用中的使用。 | | vue-router | 这个包允许应用根据浏览器的当前 URL 显示不同的内容。这个过程被称为 URL 路由,我会在第 22–24 章中详细描述。 | | vuex | 此包用于创建共享数据存储,以简化 Vue.js 项目中的数据管理。我在第二十章中详细描述了 Vuex 数据存储。 | | veulidate | 这个包用于验证用户输入表单元素的数据,如第六章所示。 | | bootstrap | Bootstrap 包包含 CSS 样式,这些样式将用于样式化 SportsStore 应用呈现给用户的 HTML 内容。 | | font-awesome | 字体 Awesome 包包含一个图标库,SportsStore 将使用它向用户表示重要的功能。 | | json-server | 这个包为应用开发提供了一个易于使用的 RESTful web 服务,正是这个包将接收使用 Axios 发出的 HTTP 请求。本章“准备 RESTful Web 服务”一节中添加到项目中的 JavaScript 代码使用了这个包。 | | jsonwebtoken | 该包用于生成授权令牌,授权令牌将授予对 SportsStore 管理功能的访问权限,这些功能将添加到第七章的项目中。 | | faker | 这个包用于生成测试数据,我在第七章中使用它来确保 SportsStore 可以处理大量的数据。 |

注意

vue-routervuex包可以作为项目模板的一部分自动安装,但是我已经单独添加了它们,以便我可以演示如何配置它们并将其应用到 Vue.js 应用。使用项目工具快速启动项目并没有错,但重要的是您要了解 Vue.js 项目中的一切是如何工作的,这样当出现问题时,您就能很好地知道从哪里开始。

将 CSS 样式表合并到应用中

Bootstrap 和 Font Awesome 包需要将import语句添加到main.js文件中,这是执行 Vue.js 应用顶层配置的地方。清单 5-3 中显示的import语句确保这些包提供的内容被 Vue.js 开发工具整合到应用中。

小费

目前不要担心main.js文件中的其他语句。他们负责初始化 Vue.js 应用,这我会在第九章 ?? 中解释,但是理解他们是如何工作的对于开始 Vue.js 开发并不重要。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

import "bootstrap/dist/css/bootstrap.min.css";

import "font-awesome/css/font-awesome.min.css"

new Vue({
    render: h => h(App)
}).$mount('#app')

Listing 5-3Incorporating Packages in the main.js File in the src Folder

这些语句将让我在整个应用中使用软件包提供的 CSS 特性。

准备 RESTful Web 服务

SportsStore 应用将使用异步 HTTP 请求来获取由 RESTful web 服务提供的模型数据。正如我在第十九章中所描述的,REST 是一种设计 web 服务的方法,它使用 HTTP 方法或动词来指定操作和 URL 来选择操作所应用的数据对象。

我在上一节中添加到项目中的json-server包是从 JSON 数据或 JavaScript 代码快速生成 web 服务的优秀工具。为了确保项目可以重置到一个固定的状态,我将利用一个特性,该特性允许使用 JavaScript 代码为 RESTful web 服务提供数据,这意味着重新启动 web 服务将重置应用数据。我在sportsstore文件夹中创建了一个名为data.js的文件,并添加了清单 5-4 中所示的代码。

var data = [{ 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 }]

module.exports = function () {
    return {
        products: data,
        categories: [...new Set(data.map(p => p.category))].sort(),
        orders: []
    }
}

Listing 5-4The Contents of the data.js File in the sportsstore Folder

这个文件是一个 JavaScript 模块,它导出了一个默认函数,有两个集合将由 RESTful web 服务提供。products集合包含销售给客户的产品,categories 集合包含独特的category属性值,而orders集合包含客户已经下的订单(但目前为空)。

RESTful web 服务存储的数据需要受到保护,这样普通用户就不能修改产品或更改订单的状态。json-server包不包含任何内置的认证特性,所以我在sportsstore文件夹中创建了一个名为authMiddleware.js的文件,并添加了清单 5-5 中所示的代码。

const jwt = require("jsonwebtoken");

const APP_SECRET = "myappsecret";
const USERNAME = "admin";
const PASSWORD = "secret";

module.exports = function (req, res, next) {

    if ((req.url == "/api/login" || req.url == "/login")
            && req.method == "POST") {
        if (req.body != null && req.body.name == USERNAME
                && req.body.password == PASSWORD) {
            let token = jwt.sign({ data: USERNAME, expiresIn: "1h" }, APP_SECRET);
            res.json({ success: true, token: token });
        } else {
            res.json({ success: false });
        }
        res.end();
        return;
    } else if ((((req.url.startsWith("/api/products")
                || req.url.startsWith("/products"))
           || (req.url.startsWith("/api/categories")
                || req.url.startsWith("/categories"))) && req.method != "GET")
        || ((req.url.startsWith("/api/orders")
            || req.url.startsWith("/orders")) && req.method != "POST")) {
        let token = req.headers["authorization"];
        if (token != null && token.startsWith("Bearer<")) {
            token = token.substring(7, token.length - 1);
            try {
                jwt.verify(token, APP_SECRET);
                next();
                return;
            } catch (err) { }
        }
        res.statusCode = 401;
        res.end();
        return;
    }
    next();
}

Listing 5-5The Contents of the authMiddleware.js File in the sportsstore Folder

这段代码检查发送到 RESTful web 服务的 HTTP 请求,并实现一些基本的安全特性。这是与 Vue.js 开发没有直接关系的服务器端代码,所以如果它的目的不是很明显,也不用担心。我在第七章解释认证和授权过程。

警告

除了 SportsStore 应用之外,不要使用清单 5-5 中的代码。它包含硬连线到代码中的弱密码。这对于 SportsStore 项目来说很好,因为重点是在 Vue.js 的开发客户端,但这不适合真实的项目。

需要在package.json文件中添加一个文件,这样就可以从命令行启动json-server包,如清单 5-6 所示。

{
  "name": "sportsstore",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "json": "json-server data.js -p 3500 -m authMiddleware.js"

  },
  "dependencies": {
    "axios": "⁰.18.0",
    "bootstrap": "⁴.0.0",
    "font-awesome": "⁴.7.0",
    "vue": "².5.16",
    "vue-router": "³.0.1",
    "vuex": "³.0.1"
  },

  // ...other configuration settings omitted for brevity...

}

Listing 5-6Adding a Script to the package.json File in the SportsStore Folder

package.json文件用于配置项目及其工具。scripts部分包含了可以使用已经添加到项目中的包来执行的命令。

启动项目工具

项目的所有配置都已完成,是时候启动将用于开发的工具并确保一切正常工作了。打开一个新的命令提示符,导航到sportsstore文件夹,运行清单 5-7 中所示的命令来启动 web 服务。

npm run json

Listing 5-7Starting the SportsStore Web Service

打开一个新的浏览器窗口并导航到 URL http://localhost:3500/products/1来测试 web 服务是否工作,这将产生如图 5-1 所示的结果。

img/465686_1_En_5_Fig1_HTML.jpg

图 5-1

测试 web 服务

在不停止 web 服务的情况下,打开第二个命令提示符,导航到sportsstore文件夹,运行清单 5-8 中所示的命令来启动 Vue.js 开发工具。

npm run serve

Listing 5-8Starting the Development Tools

将启动开发 HTTP 服务器,并执行初始准备过程,之后您将看到一条消息,表明应用正在运行。使用浏览器导航到http://localhost:8080,应该会看到如图 5-2 所示的内容,这是创建项目时添加的占位符。

img/465686_1_En_5_Fig2_HTML.jpg

图 5-2

运行应用

小费

端口 8080 是默认的,但是如果 8080 已经被使用,Vue.js 开发工具将选择另一个端口。如果发生这种情况,您可以停止使用该端口的进程,以便 Vue.js 可以使用该端口,或者导航到显示的 URL。

创建数据存储

任何新应用的最佳起点都是它的数据。在除了最简单的项目之外的所有项目中,Vuex 包用于创建数据存储,该数据存储用于在整个应用中共享数据,提供了一个公共存储库,确保应用的所有部分都使用相同的数据值。

小费

Vuex 不是唯一可用于管理 Vue.js 应用中的数据的包,但它是由核心 Vue.js 团队开发的,并很好地集成到 Vue.js 世界的其余部分。除非有特殊原因,否则应该在 Vue.js 项目中使用 Vuex。

Vuex 数据存储通常被定义为独立的 JavaScript 模块,在它们自己的目录中定义。我创建了src/store文件夹(这是约定俗成的名字)并在其中添加了一个名为index.js的文件,其内容如清单 5-9 所示。

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

const testData = [];

for (let i = 1; i <= 10; i++) {
    testData.push({
        id: i, name: `Product #${i}`, category: `Category ${i % 3}`,
        description: `This is Product #${i}`, price: i * 50
    })
}

export default new Vuex.Store({
    strict: true,
    state: {
        products: testData
    }
})

Listing 5-9The Contents of the index.js File in the src/store Folder

import语句声明了对 Vue.js 和 Vuex 库的依赖。Vuex 作为 Vue.js 插件发布,这使得在项目中提供应用范围的功能变得容易。我在第二十六章中解释了插件是如何工作的,但是对于 SportsStore 应用,知道插件必须使用Vue.use方法来启用就足够了。如果您忘记调用use方法,那么数据存储特性在应用的其余部分将不可用。

使用new关键字创建一个Vuex.Store对象,传递一个配置对象,从而创建一个数据存储。state属性的目的是用来定义存储中包含的数据值。为了启动数据存储,我使用了一个 for 循环来生成一个测试数据数组,并将其分配给一个名为products的状态属性。在本章后面的“使用 RESTful web 服务”一节中,我将用从 Web 服务获得的数据来替换它。

属性strict的目的不太明显,它与 Vuex 不同寻常的工作方式有关。数据值是只读的,只能通过突变来修改,突变只是改变数据的 JavaScript 方法。当我向 SportsStore 应用添加功能时,您将看到突变的示例,并且如果您忘记使用突变并直接修改数据值,则strict模式是一个有用的功能,它会生成警告——当您习惯 Vuex 的工作方式时,这种情况经常发生。

为了在应用中包含数据存储,我将清单 5-10 中所示的语句添加到了main.js文件中,这是应用配置的要点。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css"

import store from "./store";

new Vue({
    render: h => h(App),
    store

}).$mount('#app')

Listing 5-10Adding the Vuex Data Store in the main.js File in the src Folder

import语句声明了对数据存储模块的依赖,并为其分配了store标识符。将store属性添加到用于创建Vue对象的配置属性中,可以确保数据存储功能可以在整个应用中使用,正如您将看到的功能添加到 SportsStore 中一样。

警告

一个常见的错误是将import语句添加到main.js文件中,但是忘记将store属性添加到配置对象中。这会导致错误,因为数据存储功能不会添加到应用中。

创建产品商店

数据存储为应用提供了足够的基础设施,允许我开始开发最重要的面向用户的特性:产品存储。所有的网上商店都会给用户提供一些可供选择的商品,SportsStore 也不例外。商店的基本结构将是一个两列布局,带有允许过滤产品列表的类别按钮和一个包含产品列表的表格,如图 5-3 所示。

img/465686_1_En_5_Fig3_HTML.jpg

图 5-3

商店的基本结构

Vue.js 应用的基本构建块是组件。组件是在扩展名为.vue的文件中定义的,我首先在src/components文件夹中创建一个名为Store.vue的文件,其内容如清单 5-11 所示。

<template>
    <div class="container-fluid">
        <div class="row">
            <div class="col bg-dark text-white">
                <a class="navbar-brand">SPORTS STORE</a>
            </div>
        </div>
        <div class="row">
            <div class="col-3 bg-info p-2">
                <h4 class="text-white m-2">Categories</h4>
            </div>
            <div class="col-9 bg-success p-2">
                <h4 class="text-white m-2">Products</h4>
            </div>
        </div>
    </div>
</template>

Listing 5-11The Contents of the Store.vue File in the src/components Folder

这个组件目前只包含一个template元素,我已经用它定义了一个基本的布局,这个布局使用了引导类风格的 HTML 元素,我在第三章中简要描述了它。该内容目前没有什么特别之处,但它对应于图 5-3 所示的结构,并为我建立产品商店提供了基础。在清单 5-12 中,我已经替换了App.vue文件的内容,这允许我用清单 5-11 中创建的商店组件替换项目建立时创建的默认内容。

<template>
    <store />

</template>

<script>

import Store from "./components/Store";

export default {
    name: 'app',
    components: { Store }

}
</script>

Listing 5-12Replacing the Contents of the App.vue File in the src Folder

Vue.js 应用通常包含许多组件,在大多数项目中,App.vue文件中定义的App组件负责决定应该向用户显示哪些组件。当我在第六章中添加购物车和结帐功能时,我演示了这是如何完成的,但是Store组件是我迄今为止定义的唯一一个组件,所以这是唯一一个可以显示给用户的组件。

script元素中的import语句声明了对清单 5-11 中组件的依赖,并为其分配了Store标识符,该标识符被分配给components属性,告诉 vue . jsApp组件使用了Store组件。

当 Vue.js 处理App组件的模板时,会用清单 5-11 中template元素的 HTML 替换store元素,产生如图 5-4 所示的结果。

img/465686_1_En_5_Fig4_HTML.jpg

图 5-4

向应用添加自定义组件

创建产品列表

下一步是创建一个向用户显示产品列表的组件。我在src/components文件夹中添加了一个名为ProductList.vue的文件,内容如清单 5-13 所示。

<template>
    <div>
        <div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
            <h4>
                {{p.name}}
                <span class="badge badge-pill badge-primary float-right">
                    {{ p.price }}
                </span>
            </h4>
            <div class="card-text bg-white p-1">{{ p.description }}</div>
        </div>
    </div>
</template>

<script>

import { mapState } from "vuex";

export default {
    computed: {
        ...mapState(["products"])
    }
}
</script>

Listing 5-13The Contents of the ProductList.vue File in the src/components Folder

script元素从vuex包中导入mapState函数,用于提供对存储中数据的访问。不同类型的操作有不同的 Vuex 函数,而mapState用于创建数据存储中组件和状态数据之间的映射。mapState函数与 spread 运算符一起使用,因为它可以在单个操作中映射多个数据存储属性,即使在本例中只映射了products state 属性。数据存储状态属性被映射为组件computed属性,我将在第十一章中详细描述。

Vue.js 使用一个叫做指令的特性来操作 HTML 元素。在清单中,我使用了v-for指令,它为数组中的每一项复制一个元素及其内容。

...
<div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
...

使用mapState函数的结果是,我可以使用带有v-for指令的products属性来访问数据存储中的数据,从而为每个产品生成相同的元素集。每个产品都被临时分配给一个名为p的变量,我可以用它来定制为每个产品生成的元素,如下所示:

...
<div class="card-text bg-white p-1">{{ p.description }}</div>
...

双括号({{}}字符)表示一个数据绑定,它告诉 Vue.js 在向用户显示 HTML 元素时将指定的数据值插入到该元素中。我在第十一章解释了数据绑定是如何工作的,我在第十三章详细描述了v-for指令,但结果是当前product对象的description属性的值将被插入到div元素中。

小费

v-for指令与v-bind指令一起使用,后者用于定义一个属性,该属性的值通过一个数据值或一段 JavaScript 生成。在这种情况下,v-bind指令用来创建一个key属性,v-for指令用它来有效地响应应用数据的变化,如第十三章所述。

将产品列表添加到应用

添加到项目中的每个组件都必须先注册,然后才能用于向用户呈现内容。在清单 5-14 中,我已经在Store组件中注册了ProductList组件,这样我就可以删除占位符内容并用产品列表替换它。

<template>
    <div class="container-fluid">
        <div class="row">
            <div class="col bg-dark text-white">
                <a class="navbar-brand">SPORTS STORE</a>
            </div>
        </div>
        <div class="row">
            <div class="col-3 bg-info p-2">
                <h4 class="text-white m-2">Categories</h4>
            </div>
            <div class="col-9 p-2 ">

                <product-list />

            </div>

        </div>
    </div>
</template>

<script>

import ProductList from "./ProductList";

export default {
    components: { ProductList }

}
</script>

Listing 5-14Registering a Component in the Store.vue File in the src/components Folder

当组件一起使用时,它们形成一种关系。在这个例子中,Store组件是ProductList组件的父组件,反过来,ProductList组件是Store组件的子组件。在清单中,我按照与向应用添加Store组件时相同的模式来注册组件:我导入子组件并将其添加到父组件的components属性中,这允许我使用定制的 HTML 元素将子组件的内容插入到父组件的模板中。在清单 5-14 中,我使用product-list元素插入了ProductList组件的内容,Vue.js 认为这是表达多部分名称的一种常见方式(尽管我也可以使用ProductListproductList作为 HTML 元素标签)。

结果是,App组件将来自Store组件的内容插入到它的模板中,该模板包含来自ProductList组件的内容,产生如图 5-5 所示的结果。

img/465686_1_En_5_Fig5_HTML.jpg

图 5-5

显示产品列表

过滤价格数据

现在我已经有了基本的列表,我可以开始添加特性了。第一件事是将每个产品的price属性显示为货币金额,而不仅仅是一个数字。Vue.js 组件可以定义过滤器,这是用来格式化数据值的函数。在清单 5-15 中,我向名为currencyProductList组件添加了一个过滤器,将数据值格式化为美元金额。

<template>
    <div>
        <div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
            <h4>
                {{p.name}}
                <span class="badge badge-pill badge-primary float-right">
                    {{ p.price | currency }}

                </span>
            </h4>
            <div class="card-text bg-white p-1">{{ p.description }}</div>
        </div>
    </div>
</template>

<script>

import { mapState } from "vuex";

export default {
    computed: {
        ...mapState(["products"])
    },
    filters: {

        currency(value) {

            return new Intl.NumberFormat("en-US",

                { style: "currency", currency: "USD" }).format(value);

        }

    }

}
</script>

Listing 5-15Defining a Filter in the ProductList.vue File in the src/components Folder

使用在script元素中定义的对象中的属性将组件特征组合在一起。ProductList组件现在定义了两个这样的属性:computed属性,它提供对数据存储中数据的访问,以及filters属性,它用于定义过滤器。清单 5-15 中有一个名为currency的过滤器,它被定义为一个接受值的函数,该函数使用 JavaScript 本地化特性将数值格式化为美元金额,以美国使用的格式表示。

通过将数据值的名称与过滤器名称组合在一起,用竖线(|字符)分隔,在模板中应用过滤器,如下所示:

...
<span class="badge badge-pill badge-primary float-right">
    {{ p.price | currency }}
</span>
...

当您将更改保存到ProductList.vue文件时,浏览器将重新加载,价格将被格式化,如图 5-6 所示。

img/465686_1_En_5_Fig6_HTML.jpg

图 5-6

使用筛选器设置货币值的格式

添加产品分页

产品以连续列表的形式显示给用户,随着产品数量的增加,用户会感到不知所措。为了使产品列表更易于管理,我将添加对分页的支持,指定数量的产品将显示在一个页面上,用户可以从一个页面移动到另一个页面来浏览产品。第一步是扩展数据存储,以便存储页面大小和当前所选页面的细节,我已经在清单 5-16 中完成了。

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

const testData = [];

for (let i = 1; i <= 10; i++) {
    testData.push({
        id: i, name: `Product #${i}`, category: `Category ${i % 3}`,
        description: `This is Product #${i}`, price: i * 50
    })
}

export default new Vuex.Store({
    strict: true,
    state: {
        products: testData,
        productsTotal: testData.length,

        currentPage: 1,

        pageSize: 4

    },
    getters: {

        processedProducts: state => {

            let index = (state.currentPage -1) * state.pageSize;

            return state.products.slice(index, index + state.pageSize);

        },

        pageCount: state => Math.ceil(state.productsTotal / state.pageSize)

    },

    mutations: {

        setCurrentPage(state, page) {

            state.currentPage = page;

        },

        setPageSize(state, size) {

            state.pageSize = size;

            state.currentPage = 1;

        }

    }

})

Listing 5-16Preparing for Pagination in the index.js File in the src/store Folder

支持验证的数据存储的增加展示了 Vuex 包提供的一些关键特性。第一组变化是新的state属性,它定义了产品数量、当前选择的页面以及每页显示的产品数量的值。

getters部分用于使用state属性计算其值的属性。在清单 5-16 中,getters部分定义了一个processedProducts属性,它只返回当前页面所需的产品,以及一个pageCount属性,它计算出显示可用产品数据需要多少个页面。

清单 5-16 中的mutations部分用于定义改变一个或多个状态属性值的方法。清单中有两个突变:setCurrentPage突变改变了currentPage属性的值,而setPageSize突变设置了pageSize属性。

标准数据属性和计算属性之间的分离是贯穿 Vue.js 开发的主题,因为它允许有效的变更检测。当数据属性改变时,Vue.js 能够确定对计算属性的影响,并且在底层数据没有改变时不必重新计算值。Vuex 数据存储更进了一步,它要求通过突变来改变数据值,而不是直接分配一个新值。当您第一次开始使用数据存储时,这可能会感到尴尬,但它很快就会成为您的第二天性;此外,遵循这种模式提供了一些有用的特性,比如使用 Vue Devtools 浏览器插件跟踪变更和撤销/重做变更的能力,如第一章所述。

小费

注意,清单 5-16 中的 getters 和突变都被定义为接收一个state对象作为第一个参数的函数。该对象用于访问数据存储的state部分中定义的值,这些值不能被直接访问。更多细节和例子见第二十章。

现在我已经将数据和突变添加到数据存储中,我可以创建一个利用它们的组件。我在src/components文件夹中添加了一个名为PageControls.vue的文件,内容如清单 5-17 所示。

<template>
    <div v-if="pageCount > 1" class="text-right">
        <div class="btn-group mx-2">
            <button v-for="i in pageNumbers" v-bind:key="i"
                    class="btn btn-secpmdary"
                    v-bind:class="{ 'btn-primary': i == currentPage }">
                {{ i }}
            </button>
        </div>
    </div>
</template>

<script>

    import { mapState, mapGetters } from "vuex";

    export default {
        computed: {
            ...mapState(["currentPage"]),
            ...mapGetters(["pageCount"]),
            pageNumbers() {
                return [...Array(this.pageCount + 1).keys()].slice(1);
            }
        }
    }
</script>

Listing 5-17The Contents of the PageControls.vue File in the src/components Folder

并不是所有的分页特性都已经到位,但是这里有足够的功能可以开始使用。该组件使用mapStatemapGetters助手函数来提供对数据存储库currentPagepageCount属性的访问。并非所有内容都必须在数据存储中定义,组件定义了一个pageNumbers函数,该函数使用pageCount属性生成一系列数字,这些数字在template中用于显示产品页面的按钮,这是使用v-for指令完成的,该指令与我在清单 5-17 中使用的指令相同,用于在产品列表中生成一组重复的元素。

小费

应用的多个部分需要的数据应该放在数据存储中,而特定于单个组件的数据应该在其脚本元素中定义。我将分页数据放在存储中,因为我用它从第七章中的 web 服务请求数据。

前面,我解释过,v-bind指令用于定义 HTML 元素上的属性,该属性的值由一个数据值或一段 JavaScript 代码决定。在清单 5-17 中,我使用了v-bind指令来控制class属性的值,如下所示:

...
<button v-for="i in pageNumbers" v-bind:key="i" class="btn btn-secpmdary"
    v-bind:class="{ 'btn-primary': i == currentPage }">
...

Vue.js 为管理元素的类成员资格提供了有用的特性,这允许我将代表当前数据页面的button元素添加到btn-primary类中,正如我在第十二章中详细描述的。结果是代表活动按钮的按钮具有与其他页面按钮明显不同的外观,向用户指示正在显示哪个页面。

为了将分页组件添加到应用中,我使用 in import语句声明一个依赖项,并将其添加到父组件的属性中,如清单 5-18 所示。

<template>
    <div>
        <div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
            <h4>
                {{p.name}}
                <span class="badge badge-pill badge-primary float-right">
                    {{ p.price | currency }}
                </span>
            </h4>
            <div class="card-text bg-white p-1">{{ p.description }}</div>
        </div>
        <page-controls />

    </div>
</template>

<script>

import { mapGetters} from "vuex";

import PageControls from "./PageControls";

export default {
    components: { PageControls },

    computed: {
        ...mapGetters({ products: "processedProducts" })

    },
    filters: {
        currency(value) {
            return new Intl.NumberFormat("en-US",
                { style: "currency", currency: "USD" }).format(value);
        }
    }
}
</script>

Listing 5-18Applying Pagination in the ProductList.vue File in the src/components Folder

我还更改了由ProductList组件显示的数据源,使其来自数据存储的processedProducts getter,这意味着只有当前所选页面中的产品才会显示给用户。对mapGetters助手函数的使用允许我指定processedProducts getter 将使用名称products进行映射,这允许我更改数据源,而不必对模板中的v-for表达式进行相应的更改。当您保存更改时,浏览器将重新加载并显示如图 5-7 所示的分页按钮。

img/465686_1_En_5_Fig7_HTML.jpg

图 5-7

添加分页按钮

更改产品页面

为了允许用户更改应用显示的产品页面,我需要在他们单击其中一个页面按钮时做出响应。在清单 5-19 中,我使用了用于响应事件的v-on指令,通过调用数据存储的setCurrentPage变异来响应点击事件。

<template>
    <div class="text-right">
        <div class="btn-group mx-2">
            <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>
        </div>
    </div>
</template>

<script>

    import { mapState, mapGetters, mapMutations } from "vuex";

    export default {
        computed: {
            ...mapState(["currentPage"]),
            ...mapGetters(["pageCount"]),
            pageNumbers() {
                return [...Array(this.pageCount + 1).keys()].slice(1);
            }
        },
        methods: {

            ...mapMutations(["setCurrentPage"])

        }

    }
</script>

Listing 5-19Responding to Button Clicks in the PageControls.vue File in the src/components Folder

mapMutations助手将setCurrentPage映射到一个组件方法,当收到click事件时,v-on指令将调用该组件方法。

...
<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)">
...

事件的类型被指定为指令的参数,使用冒号与指令名称分隔开。该指令的表达式告诉 Vue.js 调用setCurrentPage方法并使用临时变量i,该变量指示用户想要显示的页面。setCurrentPage映射到同名的数据存储变异,效果是点击其中一个分页按钮改变产品的选择,如图 5-8 所示。

img/465686_1_En_5_Fig8_HTML.jpg

图 5-8

更改产品页面

更改页面大小

为了完成分页功能,我想让用户能够选择每页显示多少产品。在清单 5-20 中,我向组件的模板添加了一个select元素,并将其连接起来,这样当用户选择一个值时,它就会调用数据存储中的setPageSize变异。

<template>
    <div class="row mt-2">

        <div class="col 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">

            <div class="btn-group mx-2">
                <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>
            </div>
        </div>

    </div>

</template>

<script>

    import { mapState, mapGetters, mapMutations } from "vuex";

    export default {
        computed: {
            ...mapState(["currentPage"]),
            ...mapGetters(["pageCount"]),
            pageNumbers() {
                return [...Array(this.pageCount + 1).keys()].slice(1);
            }
        },
        methods: {
            ...mapMutations(["setCurrentPage", "setPageSize"]),

            changePageSize($event) {

                this.setPageSize(Number($event.target.value));

            }

        }
    }
</script>

Listing 5-20Changing Page Size in the PageControls.vue File in the src/components Folder

新的 HTML 元素将结构添加到组件的模板中,以便在分页按钮旁边显示一个select元素。select 元素显示改变页面大小的选项,v-on指令监听当用户选择一个值时触发的change事件。如果您在使用v-on指令时只指定了方法的名称,那么这些方法将接收一个事件对象,该对象可用于访问触发事件的元素的详细信息。我使用这个对象获取用户选择的页面大小,并将其传递给数据存储中的setPageSize变异,该变异已经使用mapMutations助手映射到组件。结果是页面大小可以通过从选择元素的列表中选择一个新值来改变,如图 5-9 所示。

小费

请注意,我只需调用突变来更改应用的状态。然后,Vuex 和 Vue.js 会自动处理更新的影响,以便用户可以看到所选页面或每页的产品数量。

img/465686_1_En_5_Fig9_HTML.jpg

图 5-9

更改页面大小

添加类别选择

产品列表已经开始成形,我将跳转话题并添加对按类别缩小产品列表的支持。我将遵循相同的模式来开发这个特性:扩展数据存储,创建一个新组件,并将这个新组件与应用的其余部分集成在一起。当您开始一个新的 Vue.js 项目,并且每个组件都向项目添加新的内容和特性时,您将会熟悉这种模式。在清单 5-21 中,我添加了一个 getter,它返回用户可以从中选择的类别列表。

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

const testData = [];

for (let i = 1; i <= 10; i++) {
    testData.push({
        id: i, name: `Product #${i}`, category: `Category ${i % 3}`,
        description: `This is Product #${i}`, price: i * 50
    })
}

export default new Vuex.Store({
    strict: true,
    state: {
        products: testData,
        productsTotal: testData.length,
        currentPage: 1,
        pageSize: 4,
        currentCategory: "All"

    },
    getters: {
        productsFilteredByCategory: state => state.products

            .filter(p => state.currentCategory == "All"

                || p.category == state.currentCategory),

        processedProducts: (state, getters) => {

            let index = (state.currentPage -1) * state.pageSize;

            return getters.productsFilteredByCategory

                .slice(index, index + state.pageSize);

        },

        pageCount: (state, getters) =>

            Math.ceil(getters.productsFilteredByCategory.length / state.pageSize),

        categories: state => ["All",

            ...new Set(state.products.map(p => p.category).sort())]

    },
    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;

        }

    }
})

Listing 5-21Adding a Category List in the index.js File in the src/store Folder

currentCategory state 属性表示用户选择的类别,默认为All,应用将使用它来显示所有产品,而不考虑类别。

getter 可以通过定义第二个参数来访问数据存储中其他 getter 的结果。这允许我定义一个productsFilteredByCategory getter 并在processedProductspageCountgetter 中使用它来反映结果中的类别选择。

我定义了categories getter,这样我就可以向用户呈现可用类别的列表。getter 处理products状态数组来选择category属性的值,并使用它们来创建一个Set,这具有删除任何重复的效果。Set被展开到一个数组中,该数组被排序,产生一个按名称排序的不同类别的数组。

setCurrentCategory突变改变了currentCategory状态属性的值,这将是用户改变所选类别和重置所选页面的方法。

为了管理类别选择,我在src/components文件夹中添加了一个名为CategoryControls.vue的文件,内容如清单 5-22 所示。

<template>
    <div class="container-fluid">
        <div class="row my-2" v-for="c in categories" v-bind:key="c">
            <button class="btn btn-block"
                    v-on:click="setCurrentCategory(c)"
                    v-bind:class="c == currentCategory
                        ? 'btn-primary' : 'btn-secondary'">
                {{ c }}
            </button>
        </div>
    </div>
</template>

<script>
    import { mapState, mapGetters, mapMutations} from "vuex";

    export default {
        computed: {
            ...mapState(["currentCategory"]),
            ...mapGetters(["categories"])
        },
        methods: {
            ...mapMutations(["setCurrentCategory"])
        }
    }
</script>

Listing 5-22The Contents of the CategoryControls.vue File in the src/components Folder

该组件向用户呈现一个按钮元素列表,该列表由v-for指令基于categories属性提供的值生成,该属性映射到数据存储中同名的 getter。v-bind指令用于管理button元素的类成员资格,以便将代表所选类别的button元素添加到btn-primary类中,并将所有其他的button元素添加到btn-secondary类中,确保用户可以很容易地看到选择了哪个类别。

v-on指令监听click事件并调用setCurrentCategory变异,这允许用户在类别之间导航。动态数据模型意味着变化将立即反映在向用户展示的产品中。

在清单 5-23 中,我导入了新的组件,并添加到其父组件的 components 属性中,这样我就可以使用定制的 HTML 元素显示新的特性。

<template>
    <div class="container-fluid">
        <div class="row">
            <div class="col bg-dark text-white">
                <a class="navbar-brand">SPORTS STORE</a>
            </div>
        </div>
        <div class="row">
            <div class="col-3 bg-info p-2">

                <CategoryControls />

            </div>
            <div class="col-9 p-2">
                <ProductList />
            </div>
        </div>
    </div>
</template>

<script>

    import ProductList from "./ProductList";
    import CategoryControls from "./CategoryControls";

    export default {
        components: { ProductList, CategoryControls }

    }

</script>

Listing 5-23Adding the Category Selection in the Store.vue File in the src/components Folder

结果是向用户呈现了一个按钮列表,这些按钮可用于按类别过滤产品,如图 5-10 所示。

img/465686_1_En_5_Fig10_HTML.jpg

图 5-10

添加对类别过滤的支持

使用 RESTful Web 服务

我喜欢使用测试数据开始一个项目,因为它让我定义初始特性,而不必处理网络请求。但是现在基本结构已经就绪,是时候用 RESTful web 服务提供的数据替换测试数据了。本章开始时安装并启动的json-server包将使用表 5-2 中列出的 URL 提供应用所需的数据。

表 5-2

获取应用数据的 URL

|

统一资源定位器

|

描述

| | --- | --- | | http://localhost:3500/products | 该 URL 将提供产品列表。 | | http://localhost:3500/categories | 该 URL 将提供类别列表。 |

Vue.js 不包含对 HTTP 请求的内置支持。处理 HTTP 的最常见的包选择是 Axios,它不是特定于 Vue.js 的,但是非常适合开发模型,并且设计良好,易于使用。

HTTP 请求是异步执行的。我想在数据存储中执行我的 HTTP 请求,Vuex 使用一个名为 actions 的特性支持异步任务。在清单 5-24 中,我添加了一个动作来从服务器获取产品和类别数据,并使用它来设置应用其余部分所依赖的状态属性。

import Vue from "vue";
import Vuex from "vuex";

import Axios from "axios";

Vue.use(Vuex);

const baseUrl = "http://localhost:3500";

const productsUrl = `${baseUrl}/products`;

const categoriesUrl = `${baseUrl}/categories`;

export default new Vuex.Store({
    strict: true,
    state: {
        products: [],
        categoriesData: [],

        productsTotal: 0,
        currentPage: 1,
        pageSize: 4,
        currentCategory: "All"
    },
    getters: {
        productsFilteredByCategory: state => state.products
            .filter(p => state.currentCategory == "All"
                || p.category == state.currentCategory),
        processedProducts: (state, getters) => {
            let index = (state.currentPage - 1) * state.pageSize;
            return getters.productsFilteredByCategory.slice(index,
                index + state.pageSize);
        },
        pageCount: (state, getters) =>
            Math.ceil(getters.productsFilteredByCategory.length / state.pageSize),
        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();

        }

    },
    actions: {

        async getData(context) {

            let pdata = (await Axios.get(productsUrl)).data;

            let cdata = (await Axios.get(categoriesUrl)).data;

            context.commit("setData", { pdata, cdata} );

        }

    }

})

Listing 5-24Requesting Data in the index.js File in the src/store Folder

Axios 包提供了一个用于发送 HTTP get 请求的get方法。我从两个 URL 请求数据,并使用asyncawait关键字等待数据。get方法返回一个对象,该对象的data属性返回一个 JavaScript 对象,该对象是从 web 服务的 JSON 响应中解析出来的。

Vuex 动作是接收上下文对象的函数,该对象提供对数据存储特征的访问。getData动作使用上下文来调用setData变异。我不能在数据存储内部使用mapMutation助手,所以我必须使用替代机制,即调用commit方法并指定变异的名称作为参数。

当应用初始化时,我需要调用动作数据存储动作。Vue.js 组件有一个明确定义的生命周期,我在第十七章中对此进行了描述。对于生命周期的每个部分,组件都可以定义将被调用的方法。在清单 5-25 中,我实现了created方法,该方法在创建组件时被调用,我用它来触发getData动作,该动作被映射到使用mapActions助手的方法。

<template>
    <store />
</template>

<script>
    import Store from "./components/Store";
    import { mapActions } from "vuex";

    export default {
        name: 'app',
        components: { Store },
        methods: {

            ...mapActions(["getData"])

        },

        created() {

            this.getData();

        }

    }
</script>

Listing 5-25Requesting Data in the App.vue File in the src Folder

结果是测试数据已经被从 RESTful web 服务获得的数据所取代,如图 5-11 所示。

img/465686_1_En_5_Fig11_HTML.jpg

图 5-11

使用来自 web 服务的数据

注意

您可能需要重新加载浏览器才能看到来自 web 服务的数据。如果您仍然没有看到新的数据,那么使用清单 5-25 中的命令停止并启动开发工具。

摘要

在这一章中,我开始了 SportsStore 项目的开发。我从定义数据源开始,它提供了对整个应用中共享数据的访问。我还开始了商店的工作,它向用户展示产品,支持分页和按类别过滤。我通过使用 Axios 包使用 HTTP 从 RESTful web 服务请求数据来完成本章,这允许我删除测试数据。在下一章中,我将继续开发 SportsStore 应用,添加对购物车、结账和创建订单的支持。

六、SportsStore:结帐和订单

在本章中,我继续向我在第五章中创建的 SportsStore 应用添加特性。我添加了对购物车和结帐过程的支持,允许用户向 web 服务提交订单。

小费

你可以从 https://github.com/Apress/pro-vue-js-2 下载本章以及本书所有其他章节的示例项目。

为本章做准备

本章使用第五章中的 SportsStore 项目,在准备本章时不需要做任何更改。要启动 RESTful web 服务,请打开命令提示符并在sportsstore文件夹中运行以下命令:

npm run json

打开第二个命令提示符,在sportsstore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:

npm run serve

一旦初始构建过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080以查看图 6-1 中显示的内容。

img/465686_1_En_6_Fig1_HTML.jpg

图 6-1

运行 SportsStore 应用

创建购物车占位符

SportsStore 应用的下一个特性是购物车,它将允许用户收集他们想要购买的产品。我将通过向应用添加一个带有一些占位符内容的新组件来创建购物车,然后添加对向用户显示的支持。一旦完成,我将返回并实现购物车。首先,我在src/components文件夹中创建了一个名为ShoppingCart.vue的文件,其内容如清单 6-1 所示。

<template>
    <h4 class="bg-primary text-white text-center p-2">
        Placeholder for Cart
    </h4>
</template>

Listing 6-1The Contents of the ShoppingCart.vue File in the src/components Folder

这个新组件目前不提供任何功能,但是它清楚地表明了购物车何时显示。

配置 URL 路由

简单的应用始终向用户显示相同的内容,但是当您添加更多的功能时,就需要向用户显示不同的组件。在示例应用中,我想让用户能够轻松地在产品列表和购物车之间导航。Vue.js 支持一个叫做动态组件的特性,它允许应用改变用户看到的内容。该功能内置在 Vue 路由器包中,以便使用 URL 来确定内容,这被称为 URL 路由。为了设置 SportsStore 应用所需的配置,我创建了src/router文件夹,并向其中添加了一个名为index.js的文件,其内容如清单 6-2 所示。

注意

URL 路由在章节 23–25 中有详细描述。动态组件特性在第二十一章中描述。

import Vue from "vue";
import VueRouter  from "vue-router";

import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";

Vue.use(VueRouter);

export default new VueRouter({
    mode: "history",
    routes: [
        { path: "/", component: Store },
        { path: "/cart", component: ShoppingCart },
        { path: "*", redirect: "/"}
    ]
})

Listing 6-2The Contents of the index.js File in the src/router Folder

Vue 路由器包必须以与第五章中 Vuex 包相同的方式用Vue.use方法注册。index.js文件导出一个新的VueRouter对象,该对象被传递一个配置对象,该对象设置 URL 和相关组件之间的映射。

注意

在清单 6-2 中,我将mode属性设置为history,这告诉 Vue 路由器使用最近的浏览器 API 来处理 URL。这产生了一个更有用的结果,但是老的浏览器不支持,正如我在第二十二章中解释的。

routes属性包含一组将 URL 映射到组件的对象,这样应用的默认 URL 将显示Store组件,而/cart URL 将显示ShoppingCart组件。routes a rray 中的第三个对象是一个 catchall route,它将任何其他 URL 重定向到/,这将Store显示为一个有用的后备。

我将清单 6-3 中所示的语句添加到了main.js文件中,这确保了路由特性被初始化,并且将在整个应用中可用。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css"

import store from "./store";

import router from "./router";

new Vue({
  render: h => h(App),
  store,
  router

}).$mount('#app')

Listing 6-3Enabling URL Routing in the main.js File in the src Folder

新语句从清单 6-3 中导入模块,并将其添加到用于创建Vue对象的配置对象中。如果没有这一步,路由功能将无法启用。

显示布线元件

既然路由配置已经添加到应用中,我可以让它管理 SportsStore 应用中组件的显示。在清单 6-4 中,我删除了显示Store组件的定制 HTML 元素,并用一个内容由当前 URL 决定的元素来替换它。

<template>
    <router-view />

</template>

<script>

  //import Store from "./components/Store";

  import { mapActions } from "vuex";

  export default {
      name: 'app',
      //components: { Store },

      methods: {
          ...mapActions(["getData"])
      },
      created() {
          this.getData();
      }
  }
</script>

Listing 6-4Adding a Routed View in the App.vue File in the src Folder

元素基于清单 6-4 中定义的配置显示一个组件。我注释掉了添加了Store组件的语句,因为路由系统将负责管理由App组件显示的内容,所以不再需要这些语句。

如果您导航到http://localhost:8080,您将看到由Store组件显示的内容,但是如果您导航到http://localhost:8080/cart,您将看到购物车的占位符内容,如图 6-2 所示。

img/465686_1_En_6_Fig2_HTML.jpg

图 6-2

使用 URL 路由

实现购物车功能

现在应用可以显示购物车组件了,我可以添加提供购物车功能的特性了。在接下来的小节中,我将扩展数据存储,向Cart组件添加特性以显示用户的选择,并添加导航特性以便用户可以选择产品并在购物车中查看这些选择的摘要。

向数据存储中添加模块

我将从扩展数据存储开始,为此我将定义一个特定于购物车的 JavaScript 模块,这样我就可以将这些新增功能与现有的数据存储功能分开。我在src/store文件夹中添加了一个名为cart.js的文件,内容如清单 6-5 所示。

export default {
    namespaced: true,
    state: {
        lines: []
    },
    getters: {
        itemCount: state => state.lines.reduce((total, line) =>
            total + line.quantity, 0),
        totalPrice: state => state.lines.reduce((total, line) =>
                total + (line.quantity * line.product.price), 0),

    },
    mutations: {
        addProduct(state, product) {
            let line  = state.lines.find(line => line.product.id == product.id);
            if (line != null) {
                line.quantity++;
            } else {
                state.lines.push({ product: product, quantity:1 });
            }
        },
        changeQuantity(state, update) {
            update.line.quantity = update.quantity;
        },
        removeProduct(state, lineToRemove) {
            let index  = state.lines.findIndex(line => line == lineToRemove);
            if (index > -1) {
                state.lines.splice(index, 1);
            }
        }
    }
}

Listing 6-5The Contents of the cart.js File in the src/store Folder

为了用一个模块扩展数据存储,我创建了一个默认导出,它返回一个具有stategetters,mutations属性的对象,遵循我在第五章将数据存储添加到项目中时使用的相同格式。我将namespaced属性设置为true,以保持这些特性在数据存储中是独立的,这意味着它们将通过前缀进行访问。如果没有这个设置,在cart.js文件中定义的特性将会被合并到主数据存储中,这可能会引起混淆,除非您确保用于属性和函数的名称不会混淆。

为了将模块合并到数据存储中,我将清单 6-6 中所示的语句添加到了index.js文件中。

import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";

import CartModule from "./cart";

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 },

    state: {
        products: [],
        categoriesData: [],
        productsTotal: 0,
        currentPage: 1,
        pageSize: 4,
        currentCategory: "All"
    },

    // ...other data store features omitted for brevity...

})

Listing 6-6Adding a Module in the index.js File in the src/store Folder

我使用了一个import语句来声明对 cart 模块的依赖,并将其标识为CartModule。为了将模块包含在数据存储中,我添加了modules属性,该属性被赋予一个对象,其属性名指定了将用于访问模块中的特性的前缀,其值是模块对象。在这个清单中,将使用前缀cart来访问新数据存储模块中的特性。

添加产品选择功能

下一步是添加允许用户将产品添加到购物车的功能。在清单 6-7 中,我在每个产品清单中添加了一个按钮,当它被点击时会更新购物车。

<template>
    <div>
        <div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
            <h4>
                {{p.name}}
                <span class="badge badge-pill badge-primary float-right">
                    {{ p.price | currency }}
                </span>
            </h4>
            <div class="card-text bg-white p-1">
                {{ p.description }}
                <button class="btn btn-success btn-sm float-right"

                        v-on:click="handleProductAdd(p)">

                    Add To Cart

                </button>

            </div>
        </div>
        <page-controls />
    </div>
</template>

<script>

import { mapGetters, mapMutations } from "vuex";

import PageControls from "./PageControls";

export default {
    components: { PageControls },
    computed: {
        ...mapGetters({ products: "processedProducts" })
    },
    filters: {
        currency(value) {
            return new Intl.NumberFormat("en-US",
                { style: "currency", currency: "USD" }).format(value);
        }
    },
    methods: {
        ...mapMutations({ addProduct: "cart/addProduct" }),

        handleProductAdd(product) {

            this.addProduct(product);

            this.$router.push("/cart");

        }

    }
}
</script>

Listing 6-7Adding Product Selection in the ProductList.vue File in the src/components Folder

我在模板中添加了一个button元素,并通过调用handleProductAdd方法使用v-on指令来响应click事件,我将该方法添加到了script元素中。这个方法调用数据存储上的cart/addProduct变异,当namespaced属性为true时,我在清单 6-7 中指定的前缀允许我访问模块中的特性。

在调用了addProduct突变之后,handleProductAdd方法使用路由系统通过以下语句导航到/cart URL:

...
this.$router.push("/cart");
...

Vue 路由器包提供的功能是通过使用$router属性提供给组件的(访问组件中的所有属性和方法需要使用this关键字)。push方法告诉路由器改变浏览器的 URL,其效果是显示一个不同的组件。结果是,当您单击其中一个添加到购物车按钮时,就会显示购物车组件,如图 6-3 所示。

img/465686_1_En_6_Fig3_HTML.jpg

图 6-3

使用 URL 导航

显示购物车内容

现在应该显示用户的产品选择,而不是当前显示的占位符内容。为了使内容更容易管理,我将使用一个单独的组件来显示单个产品选择。我在src/components文件夹中添加了一个名为ShoppingCartLine.vue的文件,内容如清单 6-8 所示。

<template>
    <tr>
        <td>
        <input type="number" class="form-control-sm"
                style="width:5em"
                v-bind:value="qvalue"
                v-on:input="sendChangeEvent"/>
        </td>
        <td>{{ line.product.name }}</td>
        <td class="text-right">
            {{ line.product.price | currency }}
        </td>
        <td class="text-right">
            {{ (line.quantity * line.product.price) | currency }}
        </td>
        <td class="text-center">
            <button class="btn btn-sm btn-danger"
                    v-on:click="sendRemoveEvent">
                Remove
            </button>
        </td>
    </tr>
</template>
<script>
    export default {
        props: ["line"],
        data: function() {
            return {
                qvalue: this.line.quantity
            }
        },
        methods: {
            sendChangeEvent($event) {
                if ($event.target.value > 0) {
                    this.$emit("quantity", Number($event.target.value));
                    this.qvalue = $event.target.value;
                } else {
                    this.$emit("quantity", 1);
                    this.qvalue = 1;
                    $event.target.value = this.qvalue;
                }
            },
            sendRemoveEvent() {
                this.$emit("remove", this.line);
            }
        }
    }
</script>

Listing 6-8The Contents of the ShoppingCartLine.vue File in the src/components Folder

这个组件使用了props特性,该特性允许父组件向其子组件提供数据对象。在这种情况下,清单 6-8 中的组件定义了一个名为prop的行,它的父组件将使用它来提供购物车中的行,并显示给用户。该组件还发送自定义事件,用于与其父组件通信。当用户更改显示数量的input元素的值或单击 Remove 按钮时,组件调用this.$emit方法向其父组件发送一个事件。这些功能是一种连接组件的有用方式,可以创建应用某一部分的本地功能,而无需使用数据存储等全局功能。

为了向用户显示购物车的内容,我替换了ShoppingCart组件中的占位符元素,并将其替换为清单 6-9 中所示的 HTML 和 JavaScript 代码。

<template>
    <div class="container-fluid">
        <div class="row">
            <div class="col bg-dark text-white">
                <a class="navbar-brand">SPORTS STORE</a>
            </div>
        </div>
        <div class="row">
            <div class="col mt-2">
            <h2 class="text-center">Your Cart</h2>
            <table class="table table-bordered table-striped p-2">
                <thead>
                <tr>
                    <th>Quantity</th><th>Product</th>
                    <th class="text-right">Price</th>
                    <th class="text-right">Subtotal</th>
                </tr>
                </thead>
                <tbody>
                    <tr v-if="lines.length == 0">
                        <td colspan="4" class="text-center">
                            Your cart is empty
                        </td>
                    </tr>
                    <cart-line v-for="line in lines" v-bind:key="line.product.id"
                        v-bind:line="line"
                        v-on:quantity="handleQuantityChange(line, $event)"
                        v-on:remove="remove" />
                </tbody>
                <tfoot v-if="lines.length > 0">
                    <tr>
                        <td colspan="3" class="text-right">Total:</td>
                        <td class="text-right">
                            {{ totalPrice | currency }}
                        </td>
                    </tr>
                </tfoot>
            </table>
            </div>
        </div>
        <div class="row">
            <div class="col">
                <div class="text-center">
                    <router-link to="/" class="btn btn-secondary m-1">
                        Continue Shopping
                   </router-link>
                   <router-link to="/checkout" class="btn btn-primary m-1"
                            v-bind:disabled="lines.length == 0">
                        Checkout
                    </router-link>
                </div>
            </div>
        </div>
    </div>
</template>

<script>

import { mapState, mapMutations, mapGetters } from "vuex";
import CartLine from "./ShoppingCartLine";

export default {
    components: { CartLine },
    computed: {
        ...mapState({ lines: state => state.cart.lines }),
        ...mapGetters({  totalPrice : "cart/totalPrice"  })
    },
    methods: {
        ...mapMutations({
            change: "cart/changeQuantity",
            remove: "cart/removeProduct"
        }),
        handleQuantityChange(line, $event) {
            this.change({ line, quantity: $event});
        }
    }
}
</script>

Listing 6-9Displaying Products in the ShoppingCart.vue File in the src/components Folder

该组件中的大部分内容和代码都使用了您已经看到的特性,但是有几点需要注意。第一个是这个组件配置其子组件ShoppingCartLine的方式,如下所示:

...
<cart-line v-for="line in lines" v-bind:key="line.product.id"
    v-bind:line="line"
    v-on:quantity="handleQuantityChange(line, $event)"
    v-on:remove="remove" />
...

cart-line元素用于应用CartLine指令,您可以从父子关系的另一面看到组件所依赖的本地连接特性。v-bind指令用于设置line属性的值,CartLine指令通过该属性接收其显示的对象,v-on指令用于接收CartLine指令发出的自定义事件。

清单 6-9 中模板中的一些内容只有在购物车中有产品时才会显示。使用v-if指令,可以基于 JavaScript 表达式在 HTML 文档中添加或删除元素,我在第十二章中对此进行了描述。

清单 6-9 中的模板包含了一个以前没有见过的新元素。

...
<router-link to="/" class="btn btn-primary m-1">Continue Shopping</router-link>
...

router-link元素由 Vue Router 包提供,用于生成导航元素。当组件的模板被处理时,router-link元素被替换为锚元素(一个标签为a的元素),它将导航到由to属性指定的 URL。router-link元素是基于代码的导航的对应物,当它的位置与当前 URL 匹配时,它可以被配置成产生不同的元素并应用类到它的元素,所有这些我在第二十三章中描述。在清单 6-9 中,我使用了router-link元素来允许用户导航回产品列表并前进到/checkout URL,我将在本章的后面把它添加到应用中,以增加对结帐过程的支持。

小费

引导 CSS 框架可以将a元素设计成按钮的样子。我在清单 6-9 中添加了router-link元素的类被带到它们产生的a元素中,并作为继续购物和结账按钮呈现给用户,如图 6-4 所示。

清单 6-9 中需要注意的最后一个特性是使用我在清单 6-5 中定义的数据存储状态属性。我选择将数据存储模块中定义的特性与数据存储的其余部分分开,这意味着必须使用前缀。当映射 getters、mutations 和 actions 时,前缀包含在名称中,但是访问状态属性需要不同的方法。

...
    ...mapState({ lines: state => state.cart.lines }),
...

通过定义接收状态对象并选择所需属性的函数来映射状态属性。在这种情况下,选择的属性是lines,它是使用前缀cart访问的。

创建全局过滤器

当我在第五章中介绍currency过滤器时,我在一个单独的组件中定义了它。现在我已经向项目添加了特性,有更多的值需要格式化为货币值,但我不会复制同一个过滤器,我将全局注册过滤器,以便它可用于所有组件,如清单 6-10 所示。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css"

import store from "./store";
import router from "./router";

Vue.filter("currency", (value) =>  new Intl.NumberFormat("en-US",

      { style: "currency", currency: "USD" }).format(value));

new Vue({
  render: h => h(App),
  store,
  router
}).$mount('#app')

Listing 6-10Creating a Global Filter in the main.js File in the src Folder

正如您将在后面的章节中了解到的,许多组件特性可以被全局定义以避免代码重复。全局过滤器是使用Vue.filter方法定义的,该方法必须在Vue对象创建之前调用,如清单 6-10 所示。参见第十一章了解更多使用过滤器的细节。

测试购物车的基本功能

要测试购物车,导航至http://localhost:8080并点击您想要选择的产品的添加至购物车按钮。每次点击该按钮,数据存储将被更新,浏览器将导航到/cart URL,它将显示您选择的摘要,如图 6-4 所示。您可以增加和减少每个产品的数量,删除产品,然后单击继续购物按钮返回商店。(单击 Checkout 按钮还会让您返回到商店,因为我还没有为/cart URL 设置路由,我在清单 6-10 中定义的 catchall route 会将浏览器重定向到默认的 URL。)

img/465686_1_En_6_Fig4_HTML.jpg

图 6-4

购物车摘要

使购物车持久

如果您重新加载浏览器或试图通过在浏览器栏中输入 URL 来导航到http://localhost:8080/cart,您将会丢失您所选择的任何产品,并会出现如图 6-5 所示的空购物车。

img/465686_1_En_6_Fig5_HTML.jpg

图 6-5

空车

使用路由系统执行的 URL 更改的处理方式不同于用户所做的更改。当应用执行导航时,更改被解释为在该应用中移动的请求,例如从产品列表移动到商店。但是当用户执行导航时,该改变被解释为改变页面的请求。当前应用知道如何处理新的 URL 这一事实是不相关的——更改会终止当前应用,并触发对应用的 HTML 文档 JavaScript 的新 HTTP 请求,从而导致创建应用的新实例。在此期间,任何状态数据都会丢失,这就是您看到空购物车的原因。

没有办法改变浏览器的行为,这意味着处理这个问题的唯一方法是使购物车持久化,以便在导航完成后,新创建的应用实例可以使用它。有许多不同的方式来持久化购物车数据,一种常见的技术是在用户每次进行更改时将数据存储在服务器上。我希望将重点放在 Vue.js 开发上,而不是创建一个后端来存储数据,所以我将使用我在第一章中使用的相同方法,并使用本地存储功能在客户端存储数据。在清单 6-11 中,我已经更新了数据存储,这样当发生更改时,产品选择会被持久存储。

export default {
    namespaced: true,
    state: {
        lines: []
    },
    getters: {
        itemCount: state => state.lines.reduce((total, line) =>
            total + line.quantity, 0),
        totalPrice: state => state.lines.reduce((total, line) =>
                total + (line.quantity * line.product.price), 0),

    },
    mutations: {
        addProduct(state, product) {
            let line  = state.lines.find(line => line.product.id == product.id);
            if (line != null) {
                line.quantity++;
            } else {
                state.lines.push({ product: product, quantity:1 });
            }
        },
        changeQuantity(state, update) {
            update.line.quantity =  update.quantity;
        },
        removeProduct(state, lineToRemove) {
            let index  = state.lines.findIndex(line => line == lineToRemove);
            if (index > -1) {
                state.lines.splice(index, 1);
            }
        },
        setCartData(state, data) {

            state.lines = data;

        }

    },
    actions: {

        loadCartData(context) {

            let data = localStorage.getItem("cart");

            if (data != null) {

                context.commit("setCartData", JSON.parse(data));

            }

        },

        storeCartData(context) {

            localStorage.setItem("cart", JSON.stringify(context.state.lines));

        },

        clearCartData(context) {

            context.commit("setCartData", []);

        },

        initializeCart(context, store) {

            context.dispatch("loadCartData");

            store.watch(state => state.cart.lines,

                () => context.dispatch("storeCartData"), { deep: true});

        }

    }
}

Listing 6-11Storing Data Persistently in the cart.js File in the src/store Folder

我添加到数据存储模块的操作使用本地存储 API 加载、存储和清除购物车数据。不允许动作直接在数据存储中修改状态数据,所以我还添加了一个设置lines属性的变异。清单 6-11 中最重要的添加是initializeCart动作,它负责在应用启动时处理购物车。当动作被调用时,第一条语句调用dispatch方法,这就是以编程方式调用动作的方式。为了观察数据存储对lines状态属性的更改,我使用了watch方法,如下所示:

...
store.watch(state => state.cart.lines,
   () => context.dispatch("storeCartData"), { deep: true});
...

watch 方法的参数是一个选择状态属性的函数和一个在检测到更改时调用的函数。该语句选择了lines属性,并使用dispatch方法在发生变化时调用storeCartData动作。还有一个配置对象将deep属性设置为true,它告诉 Vuex 当lines数组中的任何属性发生变化时,我希望收到通知,默认情况下不会这样做。正如我在第十三章中解释的,如果没有这个选项,我只会在用户添加或删除购物车中的一行时收到通知,而不会在现有产品选择的数量发生变化时收到通知。

Vuex 没有提供在数据存储首次初始化时调用任何特性的方法,所以我在调用initializeCart动作的App组件中添加了一条语句,以及从 RESTful web 服务请求初始数据的现有语句,如清单 6-12 所示。

<template>
    <router-view />
</template>

<script>
  import { mapActions } from "vuex";

  export default {
      name: 'app',
      methods: {
          ...mapActions({
            getData: "getData",
            initializeCart: "cart/initializeCart"

          })
      },
      created() {
          this.getData();
          this.initializeCart(this.$store);

      }
  }
</script>

Listing 6-12Initializing the Cart in the App.vue File in the src Folder

这些变化的结果是,用户的购物车存储在本地,当用户直接导航到/cart URL 或重新加载浏览器时,产品选择不会丢失,如图 6-6 所示。

img/465686_1_En_6_Fig6_HTML.jpg

图 6-6

存储购物车数据

添加购物车摘要小部件

完成购物车的最后一步是创建一个摘要,显示在产品列表的顶部,这样用户可以看到他们选择的概述,并直接导航到/cart URL,而不必将产品添加到购物车。我在src/components文件夹中添加了一个名为CartSummary.vue的文件,并添加了清单 6-13 中所示的内容来创建一个新组件。

<template>
    <div class="float-right">
        <small>
            Your cart:
            <span v-if="itemCount > 0">
                {{ itemCount }} item(s) {{ totalPrice | currency }}
            </span>
            <span v-else>
                (empty)
            </span>
        </small>
        <router-link to="/cart" class="btn btn-sm bg-dark text-white"
                v-bind:disabled="itemCount == 0">
            <i class="fa fa-shopping-cart"></i>
        </router-link>
    </div>
</template>

<script>
    import { mapGetters } from 'vuex';

    export default {
        computed: {
            ...mapGetters({
                itemCount: "cart/itemCount",
                totalPrice: "cart/totalPrice"
            })
        }
    }
</script>

Listing 6-13The Contents of the CartSummary.vue File in the src/components Folder

该组件使用了v-else指令,它是v-if的有用伴侣,如果v-if表达式是false,则显示一个元素,如第十二章所述。这允许我显示购物车的摘要,如果它包含商品,如果不包含,则显示占位符消息。

小费

清单 6-13 中的router-link元素包含一个i元素,它使用由字体 Awesome 定义的类进行样式化,这是我在第五章中添加到项目中的。这个开源包为 web 应用中的图标提供了出色的支持,包括我在 SportsStore 应用中需要的购物车。详见 http://fontawesome.io

为了将新组件合并到应用中,我更新了Store组件,如清单 6-14 所示。

<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 />
            </div>
            <div class="col-9 p-2">
                <ProductList />
            </div>
        </div>
    </div>
</template>

<script>

    import ProductList from "./ProductList";
    import CategoryControls from "./CategoryControls";
    import CartSummary from "./CartSummary";

    export default {
        components: { ProductList, CategoryControls, CartSummary }

    }

</script>

Listing 6-14Enabling the Component in the Store.vue File in the src/components Folder

其效果是向用户呈现购物车的简洁摘要,如图 6-7 所示,并允许他们通过单击购物车图标直接导航到购物车摘要。

img/465686_1_En_6_Fig7_HTML.jpg

图 6-7

购物车摘要小部件

添加结帐和订单功能

组装购物车后的下一步是让用户结账并生成订单。为了扩展数据存储以支持订单,我在src/store文件夹中添加了一个名为orders.js的文件,代码如清单 6-15 所示。

import Axios from "axios";

const ORDERS_URL = "http://localhost:3500/orders";

export default {
    actions: {
        async storeOrder(context, order) {
            order.cartLines = context.rootState.cart.lines;
            return (await Axios.post(ORDERS_URL, order)).data.id;
        }
    }
}

Listing 6-15The Contents of the orders.js File in the src/store Folder

新的数据存储模块只包含一个存储订单的操作,尽管我将在实现一些管理特性时添加一些特性。storeOrder动作使用 Axios 包向 web 服务发送 HTTP POST 请求,web 服务将订单存储在数据库中。

为了从另一个模块中获取数据,我使用了动作的上下文对象的rootState属性,这让我可以导航到购物车模块的lines属性,以便将客户选择的产品与用户在结账过程中提供的详细信息一起发送到 web 服务。

我用作 SportsStore 应用的 RESTful web 服务的json-server包用包含一个id属性的对象的 JSON 表示来响应 POST 请求。id属性是自动分配的,用于唯一标识数据库中存储的对象。在清单 6-15 中,我使用asyncawait关键字等待 POST 请求完成,然后返回服务器提供的id属性的值。

出于多样性,我没有启用名称空间特性,这意味着该模块的 getters、mutations 和 actions 将与那些在index.js文件中的合并,并且不会使用前缀来访问(尽管,正如我在第二十章中解释的那样,state属性总是带有前缀,即使没有使用名称空间特性,您也可以在第七章中看到这样的例子)。在清单 6-16 中,我将orders m模块导入到主数据存储文件中,并将其添加到modules p属性中,就像我在本章前面对cart m模块所做的那样。

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 },

    // ...data store features omitted for brevity...

})

Listing 6-16Importing a Module in the index.js File in the src/store Folder

创建和注册签出组件

为了帮助用户完成结账过程,我在src/components文件夹中添加了一个名为Checkout.vue的文件,其内容如清单 6-17 所示。

<template>
    <div>
        <div class="container-fluid">
                <div class="row">
                    <div class="col bg-dark text-white">
                        <a class="navbar-brand">SPORTS STORE</a>
                    </div>
                </div>
        </div>
        <div class="m-2">
            <div class="form-group m-2">
                <label>Name</label>
                <input v-model="name" class="form-control "/>
            </div>
        </div>
        <div class="text-center">
            <router-link to="/cart" class="btn btn-secondary m-1">
                Back
            </router-link>
            <button class="btn btn-primary m-1" v-on:click="submitOrder">
                Place Order
            </button>
        </div>
    </div>
</template>

<script>

export default {
    data: function() {
        return {
            name: null
        }
    },
    methods: {
        submitOrder() {
            // todo: save order
        }
    }
}
</script>

Listing 6-17The Contents of the Checkout.vue File in the src/components folder

我从添加一个表单元素开始,它允许用户输入他们的名字。我将很快添加剩余的表单元素,但是我从小的开始,这样我就可以设置好一切,而不必在清单中重复相同的代码。

该组件使用了两个我以前没有使用过的功能,因为 SportsStore 应用是围绕 Vuex 数据存储构建的。第一个特点是清单 6-17 中的组件有自己的本地数据,不与任何其他组件共享。这是使用script元素中的data属性定义的,必须以不寻常的方式表达。

...
data: function() {
    return {
        name: null
    }
},
...

这段代码定义了一个名为name的本地数据属性,它的初始值是null。当你在 Vue.js 开发中变得有经验时,你会习惯这种表达,并且它很快成为你的第二天性。正如我在第十一章中解释的,如果你忘记正确设置data属性,你会收到警告。在本书的第二部分和第三部分中,我依靠数据属性来演示不同的特性,而不需要添加数据存储,您将会看到很多关于它们如何工作的例子。

清单 6-17 中的第二个特性是对input元素使用了v-model指令,这创建了一个与name属性的双向绑定,保持了name属性的值和input元素的内容同步。这是一个处理表单元素的便利特性,我将在第十五章中详细描述。但是,我没有在早期的 SportsStore 组件中使用过它。这是因为它不能与来自数据存储的值一起使用,因为 Vuex 要求使用突变来执行更改,而v-model指令不支持,正如我在第二十章中详细解释的。

我还需要一个组件,将显示一条消息时,订单已提交。我在src/components文件夹中添加了一个名为OrderThanks.vue的文件,内容如清单 6-18 所示。

<template>

    <div class="m-2 text-center">
        <h2>Thanks!</h2>
        <p>Thanks for placing your order, which is #{{orderId}}.</p>
        <p>We'll ship your goods as soon as possible.</p>
        <router-link to="/" class="btn btn-primary">Return to Store</router-link>
    </div>

</template>

<script>
    export default {
        computed: {
            orderId() {
                return this.$route.params.id;
            }
        }
    }
</script>

Listing 6-18The Contents of the OrderThanks.vue File in the src/components Folder

该组件显示由当前路由的 URL 获得的值,它通过this.$route属性访问该值,当启用 Vue 路由器包时,该属性在所有组件中都可用。在这种情况下,我从包含用户订单号的路由中获得一个参数,该参数将是来自清单 6-15 的 HTTP POST 请求返回的值。

为了将组件合并到应用中,我定义了新的路线,如清单 6-19 所示。

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";

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: "*", redirect: "/"}
    ]
})

Listing 6-19Adding Routes in the index.js File in the src/router Folder

/checkout URL 将显示清单 6-17 中的组件,该组件对应于我在购物车中显示的router-link元素中使用的 URL,该元素作为结帐按钮呈现给用户。

/thanks/:id URL 显示感谢消息,由清单 6-18 中创建的组件呈现。URL 第二段中的冒号告诉路由系统该路由应该匹配任何两段 URL,其中第一段是thanks。第二个 URL 段的内容将被分配给一个名为id的变量,然后由OrderThanks组件显示给用户,我在清单 6-18 中定义了这个组件。我在第 23–25 章解释了如何创建复杂的路由并控制它们匹配的 URL。

直接感兴趣的是结帐组件,您可以通过导航到http://localhost:8080/checkout或导航到http://localhost:8080,选择一个产品,然后单击结帐按钮来看到这一点。无论您选择哪条路径,您都会看到图 6-8 中的内容。

小费

在线商店允许顾客直接跳转到结账环节是不常见的,我在第七章中演示了如何限制导航。

img/465686_1_En_6_Fig8_HTML.jpg

图 6-8

初始结帐组件

添加表单验证

验证用户提供的数据以确保应用以它能够处理的格式接收到它需要的所有数据是很重要的。在第十五章中,我演示了如何创建你自己的表单验证代码,但是在实际项目中,使用一个为你处理验证的包更容易,比如 Veulidate,我在第五章中把它添加到了 SportsStore 项目中。在清单 6-20 中,我向main.js文件添加了声明,以声明对 Veulidate 包的依赖,并将其功能添加到应用中。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css"

import store from "./store";
import router from "./router";

import Vuelidate from "vuelidate";

Vue.filter("currency", (value) =>  new Intl.NumberFormat("en-US",
      { style: "currency", currency: "USD" }).format(value));

Vue.use(Vuelidate);

new Vue({
  render: h => h(App),
  store,
  router
}).$mount('#app')

Listing 6-20Enabling the Veulidate Package in the main.js File in the src Folder

Vuelidate 作为一个 Vue.js 插件发布,在创建 Vue 对象之前必须用Vue.use方法注册,如清单所示。我会在第二十六章中解释插件如何工作以及如何创建你自己的插件。

数据验证的一个关键部分是当一个值不能被接受或没有被提供时向用户提供一条消息。这需要大量的测试来确保消息是有用的,并且在用户提交数据之前不会显示,这可能导致需要验证的每个input元素重复相同的代码和内容。为了避免这种重复,我在src/components文件夹中添加了一个名为ValidationError.vue的文件,用来创建清单 6-21 中所示的组件。

<template>
    <div v-if="show" class="text-danger">
        <div v-for="m in messages" v-bind:key="m">{{ m }}</div>
    </div>
</template>

<script>

export default {
    props: ["validation"],
    computed: {
        show() {
            return this.validation.$dirty && this.validation.$invalid
        },
        messages() {
            let messages = [];
            if (this.validation.$dirty) {
                if (this.hasValidationError("required")) {
                    messages.push("Please enter a value")
                } else if (this.hasValidationError("email")) {
                    messages.push("Please enter a valid email address");
                }
            }
            return messages;
        }
    },
    methods: {
        hasValidationError(type) {
            return this.validation.$params.hasOwnProperty(type)
                && !this.validation[type];
        }
    }
}

</script>

Listing 6-21The Contents of the ValidationError.vue File in the src/components Folder

Vuelidate 包通过一个对象提供了关于数据验证的细节,该对象将由这个组件的validation prop 接收。对于 SportsStore 应用,我需要两个验证器——required验证器确保用户提供了一个值,而email验证器确保该值是一个格式正确的电子邮件地址。

小费

Vuelidate 支持多种验证器。详见 https://monterail.github.io/vuelidate

为了确定ValidationError组件应该显示什么消息,我使用了表 6-1 中显示的属性,这些属性是在将通过 prop 接收的对象上定义的。

表 6-1

验证对象属性

|

名字

|

描述

| | --- | --- | | $invalid | 如果该属性为true,则元素内容违反了已应用的验证规则之一。 | | $dirty | 如果这个属性是true,那么这个元素已经被用户编辑过了。 | | required | 如果这个属性存在,那么required验证器已经被应用到元素中。如果属性是false,那么元素不包含值。 | | email | 如果这个属性存在,那么email验证器已经被应用到元素中。如果属性是false,那么元素不包含有效的电子邮件地址。 |

清单 6-21 中的组件使用表 6-1 中的属性来决定它通过 prop 接收到的对象是否报告了验证错误,如果是,应该向用户显示哪些消息。

当您看到如何应用ValidationError组件时,这种方法会更有意义。在清单 6-22 中,我向Checkout组件添加了数据验证,并将报告任何问题的任务委托给了ValidationError组件。

<template>
    <div>
        <div class="container-fluid">
                <div class="row">
                    <div class="col bg-dark text-white">
                        <a class="navbar-brand">SPORTS STORE</a>
                    </div>
                </div>
        </div>
        <div class="m-2">
            <div class="form-group m-2">
                <label>Name</label>
                <input v-model="$v.name.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.name" />

            </div>
        </div>
        <div class="text-center">
            <router-link to="/cart" class="btn btn-secondary m-1">
                Back
            </router-link>
            <button class="btn btn-primary m-1" v-on:click="submitOrder">
                Place Order
            </button>
        </div>
    </div>
</template>

<script>

import { required } from "vuelidate/lib/validators";

import ValidationError  from "./ValidationError";

export default {
    components: { ValidationError },

    data: function() {
        return {
            name: null
        }
    },
    validations: {

        name: {

            required

        }

    },

    methods: {
        submitOrder() {
            this.$v.$touch();

            // todo: save order
        }
    }
}
</script>

Listing 6-22Validating Data in the Checkout.vue File in the src/components Folder

使用script元素中的validations属性应用 Vuelidate 数据验证,该属性具有与将被验证的data值的名称相对应的属性。在清单中,我添加了一个name属性并应用了required验证器,它必须从veulidate/lib/validators位置导入。

要将验证特性连接到input元素,必须更改 v-model 指令的目标,如下所示:

...
<input v-model="$v.name.$model" class="form-control "/>
...

验证特性是通过一个名为$v的属性来访问的,该属性具有与验证配置相对应的属性。在这种情况下,有一个name属性,它对应于名称数据值,并且使用$model属性来访问它的值。

我传递给ValidationError组件的是由$v.name属性返回的对象,如下所示:

...
<validation-error v-bind:validation="$v.name" />
...

这种方法提供了对表 6-1 中描述的属性的访问,这些属性用于显示验证错误消息,而不需要正在处理的数据值的任何具体细节。

$v对象定义了一个$touch方法,该方法将所有元素标记为脏的,就像用户编辑过它们一样。这是一个有用的特性,可以触发验证作为用户操作的结果,而通常在用户与input元素交互之前不会显示验证消息。要查看效果,导航到http://localhost:8080/checkout并单击 Place Order 按钮,不要在input元素中输入任何内容。您将看到要求您提供一个值的错误消息,当您在字段中输入文本时,该消息将消失,如图 6-9 所示。验证是实时执行的,如果从 input 元素中删除所有文本,您将再次看到错误消息。

img/465686_1_En_6_Fig9_HTML.jpg

图 6-9

验证数据

添加剩余的字段和验证

在清单 6-23 中,我向组件添加了订单所需的剩余数据字段,以及每个字段所需的验证设置和当所有数据字段都有效时提交订单所需的代码。

<template>
    <div>
        <div class="container-fluid">
                <div class="row">
                    <div class="col bg-dark text-white">
                        <a class="navbar-brand">SPORTS STORE</a>
                    </div>
                </div>
        </div>
        <div class="m-2">
            <div class="form-group m-2">
                <label>Name</label>
                <input v-model="$v.order.name.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.order.name" />

            </div>
        </div>

        <div class="m-2">

            <div class="form-group m-2">

                <label>Email</label>

                <input v-model="$v.order.email.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.order.email" />

            </div>

        </div>

        <div class="m-2">

            <div class="form-group m-2">

                <label>Address</label>

                <input v-model="$v.order.address.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.order.address" />

            </div>

        </div>

        <div class="m-2">

            <div class="form-group m-2">

                <label>City</label>

                <input v-model="$v.order.city.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.order.city" />

            </div>

        </div>

        <div class="m-2">

            <div class="form-group m-2">

                <label>Zip</label>

                <input v-model="$v.order.zip.$model" class="form-control "/>

                <validation-error v-bind:validation="$v.order.zip" />

            </div>

        </div>

        <div class="text-center">
            <router-link to="/cart" class="btn btn-secondary m-1">
                Back
            </router-link>
            <button class="btn btn-primary m-1" v-on:click="submitOrder">
                Place Order
            </button>
        </div>
    </div>
</template>

<script>

import { required, email } from "vuelidate/lib/validators";

import ValidationError  from "./ValidationError";

import { mapActions } from "vuex";

export default {
    components: { ValidationError },
    data: function() {
        return {
            order: {

                name: null,
                email: null,

                address: null,

                city: null,

                zip: null

            }
        }
    },
    validations: {
        order: {

            name: { required },
            email: { required, email },

            address: { required },

            city: { required },

            zip: { required }

        }
    },
    methods: {
        ...mapActions({

            "storeOrder": "storeOrder",

            "clearCart": "cart/clearCartData"

        }),

        async submitOrder() {
            this.$v.$touch();
            if (!this.$v.$invalid) {

                let order = await this.storeOrder(this.order);

                this.clearCart();

                this.$router.push(`/thanks/${order}`);

            }

        }
    }
}
</script>

Listing 6-23Completing the Form in the Checkout.vue File in the src/components Folder

我将数据属性组合在一起,使它们嵌套在一个order对象下,并且我添加了emailaddresscityzip字段。在submitOrder方法中,我检查$v.$isvalid属性,该属性报告所有验证器的有效性,如果表单有效,我调用storeOrder动作将订单发送到 web 服务,清空购物车,并导航到/thanks URL,该 URL 显示我在清单 6-18 中定义的组件。图 6-10 显示了检验顺序。

img/465686_1_En_6_Fig10_HTML.jpg

图 6-10

通过结账流程创建订单

完成结帐过程后,您可以单击“返回商店”按钮重新开始。您目前看不到已创建的订单,因为对该数据的访问受到限制,但我将在第七章中向 SportsStore 应用添加所需的功能。

摘要

在本章中,我向 SportsStore 应用添加了 URL 路由,并使用它来导航到不同的功能区域。我添加了一个购物车,允许用户选择产品,并持久化购物车数据,这样手动导航或重新加载浏览器就不会导致数据丢失。我还创建了 checkout 流程,该流程验证用户提供的数据,并将订单数据发送到 web 服务进行存储。在下一章中,我将增加应用必须处理的数据量,并开始管理功能的工作。