VueJS2-高级教程-全-

VueJS2 高级教程(全)

原文:Pro Vue.js 2

协议:CC BY-NC-SA 4.0

一、您的第一个 Vue.js 应用

开始使用 Vue.js 的最佳方式是深入研究。在这一章中,我将带你通过一个简单的开发过程来创建一个跟踪待办事项的应用。在第五章第五章–第八章中,我将向你展示如何创建一个更加复杂和真实的应用,但是现在,一个简单的例子将足以展示 Vue.js 应用是如何创建的,以及基本特性是如何工作的。如果您不理解本章的所有内容,也不要担心——这是为了对 Vue.js 的工作方式有一个总体的了解,我会在后面的章节中详细解释所有内容。

注意

如果您想要对 Vue.js 特性的常规描述,那么您可以跳到本书的第二部分,在那里我将开始深入描述各个特性的过程。

准备开发环境

Vue.js 开发需要做一些准备。在接下来的部分中,我将解释如何设置和准备创建您的第一个项目。

安装 Node.js

用于 Vue.js 开发的工具依赖于 Node . js——也称为 Node——它创建于 2009 年,是用 JavaScript 编写的服务器端应用的简单高效的运行时。Node.js 基于 Chrome 浏览器中使用的 JavaScript 引擎,并提供了一个在浏览器环境之外执行 JavaScript 代码的 API。

Node.js 作为应用服务器已经取得了成功,但对于本书来说,它很有趣,因为它为新一代跨平台开发和构建工具提供了基础。Node.js 团队的一些聪明的设计决策和 Chrome JavaScript 运行时提供的跨平台支持创造了一个机会,被热情的工具作者抓住了。简而言之,Node.js 已经成为 web 应用开发的基础。

重要的是,您下载的 Node.js 版本与我在本书中使用的版本相同。尽管 Node.js 相对稳定,但仍不时会有突破性的 API 变化,这可能会使我在本章中包含的示例无法工作。

我使用的版本是 8.11.2,这是我撰写本文时的当前长期支持版本。在您阅读本文时,可能会有更高的版本,但是对于本书中的示例,您应该坚持使用 8.11.2 版本。在 https://nodejs.org/dist/v8.11.2 可以获得完整的 8.11.2 版本,包括 Windows 和 macOS 的安装程序以及其他平台的二进制包。

安装 Node.js 时,请确保选择了将 Node.js 可执行文件添加到路径的选项。安装完成后,运行清单 1-1 中所示的命令。

node -v

Listing 1-1Checking the Node Version

如果安装正常进行,您将会看到下面显示的版本号:

v8.11.2

Node.js 安装程序包括节点包管理器(NPM),用于管理项目中的包。运行清单 1-2 中所示的命令,确保 NPM 正在工作。

npm -v

Listing 1-2Checking That NPM Works

如果一切正常,您将看到以下版本号:

5.6.0

安装@vue/cli 包

@vue/cli包是开发期间创建和管理 Vue.js 项目的标准方式。您不必使用这个包,但是它提供了开始使用 Vue.js 所需的一切,我在本书中通篇都在使用它。

注意

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

要安装@vue/cli,打开一个新的命令提示符并运行清单 1-3 中所示的命令。如果使用的是 Linux 或者 macOS,可能需要使用sudo

npm install --global @vue/cli

Listing 1-3Installing the Vue Tools Package

安装 Git

需要 Git 修订版控制工具来管理 Vue.js 开发所需的一些包。如果您使用的是 Windows 或 macOS,那么从 https://git-scm.com/downloads 下载并运行安装程序。(在 macOS 上,您可能需要更改安全设置才能打开安装程序,开发人员尚未对该安装程序进行签名。)

Git 已经包含在大多数 Linux 发行版中。如果您想安装最新版本,请查阅 https://git-scm.com/download/linux 上的安装说明。举个例子,对于我使用的 Linux 发行版 Ubuntu,我使用了清单 1-4 中所示的命令。

sudo apt-get install git

Listing 1-4Installing Git

一旦完成安装,打开一个新的命令提示符并运行清单 1-5 中所示的命令,检查 Git 是否已安装并可用。

git --version

Listing 1-5Checking Git

这个命令打印出已经安装的 Git 包的版本。在撰写本文时,针对 Windows 和 Linux 的 Git 最新版本是 2.17,针对 macOS 的 Git 最新版本是 2.16.3。

安装编辑器

Vue.js 开发可以用任何程序员的编辑器来完成,从中有数不尽的选择。一些编辑器增强了对使用 Vue.js 的支持,包括突出显示关键字和表达式。如果您还没有 web 应用开发的首选编辑器,那么表 1-1 描述了一些流行的选项供您考虑。对于这本书,我不依赖任何特定的编辑器,你应该使用任何你觉得舒服的编辑器。

表 1-1

支持 Vue.js 的流行编辑器

|

名字

|

描述

|
| --- | --- |
| 崇高的文本 | Sublime Text 是一个商业跨平台编辑器,它有支持大多数编程语言、框架和平台的包。详见www.sublimetext.com。 |
| 原子 | Atom 是一个免费的、开源的、跨平台的编辑器,特别强调定制和可扩展性。详见atom.io。 |
| 括号 | 括号是 Adobe 开发的免费开源编辑器。详见brackets.io。 |
| Visual Studio 代码 | Visual Studio Code 是微软的一款免费、开源、跨平台的编辑器,强调可扩展性。详见code.visualstudio.com。 |
| 可视化工作室 | Visual Studio 是微软的旗舰开发工具。有免费版和商业版可用,它附带了大量集成到 Microsoft 生态系统中的附加工具。 |

安装浏览器

最后要选择的是在开发过程中用来检查工作的浏览器。所有当代浏览器都有良好的开发人员支持,并且与 Vue.js 配合良好,但 Chrome 和 Firefox 有一个名为vue-devtools的有用扩展,它提供了对 Vue.js 应用状态的洞察,在复杂项目中特别有用。安装扩展的细节见 https://github.com/vuejs/vue-devtools ,我在后面章节会用到。在这本书里,我一直使用谷歌浏览器,这是我推荐你使用的浏览器。

创建项目

从命令行创建和管理项目。打开一个新的命令提示符,导航到一个方便的位置,运行清单 1-6 中所示的命令,为本章创建项目。

vue create todo --default

Listing 1-6Creating the Project

在清单 1-3 中,vue命令作为@vue/cli包的一部分被安装,清单 1-6 中的命令创建一个名为todo的新项目,该项目将被创建在一个同名的文件夹中。将创建项目,并下载和安装 Vue.js 开发所需的所有包,这可能需要一段时间,因为即使是简单的项目也需要大量的包。

了解项目结构

使用您喜欢的编辑器打开todo文件夹,您将看到如图 1-1 所示的项目结构。该图显示了我首选的编辑器(Visual Studio)中的布局,如果您选择了不同的编辑器,您可能会看到项目内容的呈现略有不同。

img/465686_1_En_1_Fig1_HTML.jpg

图 1-1

项目结构

项目的布局可能会令人望而生畏,但在本书结束时,你会知道不同的文件和文件夹是做什么用的,以及它们是如何使用的。在表 1-2 中,我简要描述了本章中重要的文件。我在第十章中详细描述了 Vue.js 项目的结构。

表 1-2

项目中的重要文件

|

名字

|

描述

|
| --- | --- |
| public/index.html | 这是浏览器加载的 HTML 文件。它有一个显示应用的元素和一个加载应用文件的script元素。 |
| src/main.js | 这是负责配置 Vue.js 应用的 JavaScript 文件。它还用于注册应用所依赖的任何第三方包,这将在后面的章节中演示。 |
| src/App.vue | 这是 Vue.js 组件,它包含将向用户显示的 HTML 内容、HTML 所需的 JavaScript 代码和样式化元素的 CSS。组件是 Vue.js 应用的主要组成部分,你会在本书中看到它们的使用。 |
| src/assets/logo.png | assets文件夹用于存储静态内容,如图像。在一个新项目中,它包含一个名为logo.png的文件,该文件是 Vue.js 徽标的图像。 |

启动开发工具

当您使用vue命令创建一个项目时,会安装一套完整的开发工具,以便项目可以被编译、打包并交付给浏览器。使用命令提示符,运行清单 1-7 中所示的命令,导航到todo文件夹并启动开发工具。

cd todo
npm run serve

Listing 1-7Starting the Development Tools

开发工具启动时有一个初始准备过程,可能需要一段时间才能完成。不要因为准备所花费的时间而推迟,因为这个过程只有在您开始开发会话时才需要。

启动过程完成后,您将看到如下消息,确认应用正在运行,并告诉您要连接到哪个 HTTP 端口:

  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.0.77:8080/
  Note that the development build is not optimized.
  To create a production build, run npm run build.

默认端口是 8080,但是如果 8080 不可用,将选择不同的端口。打开一个新的浏览器窗口并导航到http://localhost:8080(或者如果选择了不同的端口,则导航到指定的 URL),您将看到如图 1-2 所示的占位符内容。(占位符内容会随着开发工具新版本的发布而变化,所以如果您没有看到完全相同的内容,也不用担心。)

img/465686_1_En_1_Fig2_HTML.jpg

图 1-2

运行示例应用

替换占位符内容

Vue.js 应用中的关键构建块被称为组件,它在扩展名为vue的文件中定义。下面是App组件的内容,您可以在src文件夹的App.vue文件中找到:

<template>
    <div id="app">
        <img src="./assets/logo.png">
        <HelloWorld msg="Welcome to Your Vue.js App" />
    </div>
</template>
<script>
    import HelloWorld from './components/HelloWorld.vue'

    export default {
        name: 'app',
        components: {
            HelloWorld
        }
    }
</script>
<style>

    #app {
        font-family: 'Avenir', Helvetica, Arial, sans-serif;
        -webkit-font-smoothing: antialiased;
        -moz-osx-font-smoothing: grayscale;
        text-align: center;
        color: #2c3e50;
        margin-top: 60px;
    }
</style>

该组件由一个包含呈现给用户的 HTML 内容的template元素、一个包含支持模板所需的 JavaScript 代码的script元素和一个包含 CSS 样式的style元素组成。Vue.js 结合了templatescriptstyle元素来创建如图 1-2 所示的占位符内容。

在清单 1-8 中,我替换了template元素中的内容,重置了script元素,并移除了style元素,所有这些将为示例应用提供一个干净的基础。

<template>
  <div id="app">
    <h4>
        To Do List
    </h4>
  </div>
</template>

<script>
  export default {
    name: 'app'
  }
</script>

Listing 1-8Removing the Placeholder Content in the App.vue File in the src Folder

当您保存更改时,应用将自动重新编译,浏览器将重新加载,产生如图 1-3 所示的结果。

img/465686_1_En_1_Fig3_HTML.jpg

图 1-3

移除占位符内容

添加 CSS 框架

新内容不会吸引很多用户。虽然一个组件可以包含 CSS 样式,但我更喜欢使用 CSS 框架,它将允许我在应用中一致地设计 HTML 内容的样式。在本书中,我使用了引导 CSS 框架。使用Control+C停止开发工具,运行todo文件夹中清单 1-9 所示的命令,将引导程序添加到项目中。

npm install bootstrap@4.0.0

Listing 1-9Adding Bootstrap to the Project

您可能会看到关于未满足对等依赖关系的警告,这些警告可以忽略。为了将引导 CSS 文件添加到项目中,我将清单 1-10 中所示的语句添加到了src文件夹中的main.js文件中。

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

Vue.config.productionTip = false

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

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

Listing 1-10Adding Bootstrap in the main.js File in the src Folder

import语句确保来自引导框架的 CSS 样式表将包含在应用中。(我在第四章中解释了import语句是如何工作的。)如果你不熟悉 Bootstrap,不要担心,因为我在第三章中描述了我在本书中使用的特性。

为 Vue.js 项目选择 CSS 框架

许多最流行的 CSS 框架——包括 Bootstrap——除了常规的 CSS 样式之外,还有对交互式组件的 JavaScript 支持。这些 JavaScript 特性通常依赖 jQuery 这样的包来访问 HTML 文档中的元素,这与 Vue.js 的工作方式相冲突。因此,要么只使用框架提供的 CSS 样式,要么选择专门为 Vue.js 编写的框架,这一点很重要。

第一种方法——仅使用 CSS 样式——是我在本书中一直使用的方法。这不仅允许我使用熟悉的框架,而且有助于将 Vue.js 提供的功能与内容样式分开。这对于特定于 Vue.js 的框架来说是不可能的,它们通过使用我在本书中描述的特性来工作,这将使解释许多例子变得复杂。

如果你想尝试一个特定于 Vue.js 的框架,那么 Veutify 是一个很好的起点。( https://vuetifyjs.com/ )。还有一个改编 Bootstrap 用于 Vue.js 项目的项目,叫做 Bootstrap-Vue ( https://bootstrap-vue.js.org )。

HTML 元素的样式

既然 Bootstrap 是应用的一部分,我可以对组件的template元素中的内容进行样式化以改善外观,如清单 1-11 所示。

<template>
    <div id="app">
        <h4 class="bg-primary text-white text-center p-2">

            To Do List
        </h4>
    </div>
</template>

<script>
    export default {
        name: 'app'
    }
</script>

Listing 1-11Styling Content in the App.vue File in the src Folder

运行todo文件夹中清单 1-12 中所示的命令来重启应用。

npm run serve

Listing 1-12Restarting the Application

使用浏览器窗口导航至http://localhost:8080,您将看到如图 1-4 所示的内容。如果您看到文本,但它没有样式,然后手动重新加载浏览器窗口。

img/465686_1_En_1_Fig4_HTML.jpg

图 1-4

HTML 内容的样式

添加动态内容

下一步是向应用添加一些数据,并使用它向用户显示动态内容。在清单 1-13 中,我修改了App组件,这样它将显示一个数据值。

<template>
    <div id="app">
        <h4 class="bg-primary text-white text-center p-2">
            {{name}}'s To Do List

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

<script>
    export default {
        name: 'app',
        data() {

            return {

                name: "Adam"

            }

        }

    }
</script>

Listing 1-13Displaying Data in the App.vue File in the src Folder

添加到template元素的是一个数据绑定,它告诉 Vue.js 在显示 HTML 内容时插入一个数据值。这种类型的数据绑定被称为文本插值绑定,因为它将数据值显示为 HTML 元素的文本内容。它也被称为小胡子捆绑,因为表示捆绑的双花括号({{}}字符)看起来像车把小胡子。

数据绑定告诉 Vue.js 在显示 HTML 时将name值插入到h4元素中。绑定使用的name值由我对清单 1-13 中的script元素所做的更改提供。data属性为模板提供了data值,当数据绑定被处理时,Vue.js 检查data属性来寻找name值。

小费

不要担心清单 1-13 中数据函数的奇怪语法。当使用 Vue.js 时,这很快成为一种熟悉的模式,我在第十一章解释了它是如何工作的。

当您保存App.vue文件时,Vue.js 开发工具将检测到更改,更新应用,并使浏览器窗口自动重新加载,这将显示如图 1-5 所示的内容。

img/465686_1_En_1_Fig5_HTML.jpg

图 1-5

显示数据值

请注意,当您对项目中的文件进行更改时,不必手动重新加载浏览器窗口。Vue.js 开发工具监控项目的变更,并在检测到变更时自动触发浏览器重新加载。我在第十章更详细地描述了开发工具。

显示任务列表

除了我在前一章中使用的文本插值绑定之外,Vue.js 还支持一系列数据绑定,这些数据绑定可以用来以不同的方式产生动态内容。示例应用的下一步是为用户定义表示待办任务的对象集合并显示它们,如清单 1-14 所示。

<template>
    <div id="app">
        <h4 class="bg-primary text-white text-center p-2">
            {{name}}'s To Do List
        </h4>
        <div class="container-fluid p-4">

            <div class="row">

                <div class="col font-weight-bold">Task</div>

                <div class="col-2 font-weight-bold">Done</div>

            </div>

            <div class="row" v-for="t in tasks" v-bind:key="t.action">

                <div class="col">{{t.action}}</div>

                <div class="col-2">{{t.done}}</div>

            </div>

        </div>

    </div>
</template>

<script>
    export default {
        name: 'app',
        data() {
            return {
                name: "Adam",
                tasks: [{ action: "Buy Flowers", done: false },

                        { action: "Get Shoes", done: false },

                        { action: "Collect Tickets", done: true },

                        { action: "Call Joe", done: false }]

            }
        }
    }
</script>

Listing 1-14Displaying Tasks in the App.vue File in the src Folder

为了显示任务列表,我使用了 Vue.js 特性,该特性为数组中的每个对象重复一组元素。以下是应用了该功能的元素,在创建布局所需的其他元素中很难发现该元素:

...
<div class="row" v-for="t in tasks" v-bind:key="t.action">
    <div class="col">{{t.action}}</div>
    <div class="col-2">{{t.done}}</div>
</div>
...

这是一个指令的例子,这些指令是特殊的属性,它们的名字以v-开头,应用于 HTML 元素以应用 Vue.js 功能。这是v-for指令,它为数组中的每个对象复制它所应用到的元素——以及任何包含的元素,将每个对象分配给一个变量,以便可以在数据绑定中访问它。

分配给v-for指令的值指定了对象的来源和变量的名称,每个对象在被处理时将被分配给该变量。示例中的表达式是t in tasks,它将一个名为tasks的属性标识为对象的源,将t标识为变量的名称,当枚举对象时,每个对象将被依次分配给该变量。

v-for指令包含的元素中,可以在数据绑定中使用t变量来访问当前正在枚举的对象,如下所示:

...
<div class="row" v-for="t in tasks" v-bind:key="t.action">
    <div class="col">{{t.action}}</div>
    <div class="col-2">{{t.done}}</div>
</div>
...

有两个文本插值绑定,它们将把div元素的内容设置为被处理对象的actiondone属性。v-bind元素是指令的另一个例子,它在这个例子中的作用是帮助v-for指令跟踪与每个对象相关的元素。

为了给模板提供对象的源,我给数据函数返回的对象添加了一个tasks属性,如下所示:

...
data() {
    return {
        name: "Adam",
        tasks: [{ action: "Buy Flowers", done: false },

                { action: "Get Shoes", done: false },

                { action: "Collect Tickets", done: true },

                { action: "Call Joe", done: false }]

      }
...

结果是 Vue.js 将枚举包含在tasks a rray 中的对象,并为每个对象添加一组div元素,数据绑定显示actiondone属性的值。

当您保存对App.vue文件的更改时,开发工具将更新应用并重新加载浏览器,以在图 1-6 中显示结果。附加的div元素和它们被分配到的类为内容创建了一个网格布局,并且不是 Vue.js 特性的一部分。

img/465686_1_En_1_Fig6_HTML.jpg

图 1-6

显示任务列表

添加复选框

示例应用已经初具规模,但是使用true / false值来指示任务是否已经完成并不是用户所期望的。Vue.js 包含一些指令,可以用来配置使用数据值的表单元素,在清单 1-15 中,我添加了一个input元素,显示每个tasks对象的done属性的值。

<template>
  <div id="app">
    <h4 class="bg-primary text-white text-center p-2">
      {{name}}'s To Do List
    </h4>
    <div class="container-fluid p-4">
      <div class="row">
        <div class="col font-weight-bold">Task</div>
        <div class="col-2 font-weight-bold">Done</div>
      </div>
      <div class="row" v-for="t in tasks" v-bind:key="t.action">
        <div class="col">{{t.action}}</div>
        <div class="col-2">
          <input type="checkbox" v-model="t.done" class="form-check-input" />

          {{t.done}}
        </div>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'app',
    data() {
      return {
        name: "Adam",
        tasks: [{ action: "Buy Flowers", done: false },
                { action: "Get Shoes", done: false },
                { action: "Collect Tickets", done: true },
                { action: "Call Joe", done: false }]
      }
    }
  }
</script>

Listing 1-15Adding a Form Element in the App.vue File in the src Folder

v-model指令配置一个input元素,使其显示由表达式指定的值。例如,Vue.js 根据已经应用了v-model指令的input元素的类型来调整其行为,以便在text输入元素中显示值。当输入元素的类型是checkbox时,v-model指令根据它被配置为显示的值来切换复选框。当您将更改保存到App.vue文件时,您将看到复选框如何匹配文本值,如图 1-7 所示。

img/465686_1_En_1_Fig7_HTML.jpg

图 1-7

使用指令显示复选框

这种类型的指令会创建一个双向数据绑定,这意味着当您更改 input 元素时,Vue.js 会更新相应的数据值。通过选中和取消选中其中一个复选框,您可以看到这是如何工作的。每做一次改变,相邻文本数据绑定显示的文本也会改变,如图 1-8 所示。

img/465686_1_En_1_Fig8_HTML.jpg

图 1-8

双向数据绑定的效果

这展示了 Vue.js 应用最重要的一个方面,即应用的数据是“动态的”,当数据发生变化时,所有使用该数据的数据绑定都会更新以反映这种变化。在这种情况下,我添加到input元素的双向数据绑定更新了Collect Tickets待办事项对象的done属性。这种变化反映在显示done属性的文本数据绑定中,说明了数据绑定如何与它们所关联的数据保持关系。

过滤已完成的任务

动态数据模型意味着您可以轻松地添加元素,允许用户管理应用数据的表示。在示例应用中,用户最关心的是未完成的任务,在清单 1-16 中,我添加了一个特性,允许用户过滤掉已经完成的任务。

<template>
  <div id="app">
    <h4 class="bg-primary text-white text-center p-2">
      {{name}}'s To Do List
    </h4>
    <div class="container-fluid p-4">
      <div class="row">
        <div class="col font-weight-bold">Task</div>
        <div class="col-2 font-weight-bold">Done</div>
      </div>
      <div class="row" v-for="t in filteredTasks" v-bind:key="t.action">

        <div class="col">{{t.action}}</div>
        <div class="col-2 text-center">

          <input type="checkbox" v-model="t.done" class="form-check-input" />
        </div>
      </div>
      <div class="row bg-secondary py-2 mt-2 text-white">

        <div class="col text-center">

          <input type="checkbox" v-model="hideCompleted" class="form-check-input" />

          <label class="form-check-label font-weight-bold">

            Hide completed tasks

          </label>

        </div>

      </div>

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

<script>
  export default {
    name: 'app',
    data() {
      return {
        name: "Adam",
        tasks: [{ action: "Buy Flowers", done: false },
                { action: "Get Shoes", done: false },
                { action: "Collect Tickets", done: true },
                { action: "Call Joe", done: false }],
        hideCompleted: true

      }
    },
    computed: {

      filteredTasks() {

        return this.hideCompleted ?

          this.tasks.filter(t => !t.done) : this.tasks

      }

    }

  }
</script>

Listing 1-16Filtering Tasks in the App.vue File in the src Folder

script元素中,我使用一个名为filteredTasks的函数添加了一个computed属性。computed属性用于定义对应用数据进行操作的属性,这允许 Vue.js 有效地检测应用数据的变化,这在复杂的应用中很重要。filteredTask属性使用data部分中新定义的hideCompleted属性的值来确定用户是想要查看所有的任务还是只查看那些未完成的任务。

为了管理hideCompleted属性的值,我给template元素添加了一个复选框,并使用了v-model指令,如下所示:

...
<input type="checkbox" v-model="hideCompleted" class="form-check-input" />
...

为了确保用户看到他们选择的数据,我修改了v-for指令的表达式,使其使用filteredTasks属性,如下所示:

...
<div class="row" v-for="t in filteredTasks" v-bind:key="t.action">
...

当用户切换复选框时,v-model绑定会更新hideCompleted属性,这会改变由filteredTasks属性产生的结果,并向用户呈现他们需要的任务集。保存更改,浏览器将重新加载,产生如图 1-9 所示的结果。

img/465686_1_En_1_Fig9_HTML.jpg

图 1-9

过滤任务

创建新任务

一个不允许用户创建新任务的待办应用没有多大用处。在清单 1-17 中,我向template元素添加了新内容,允许用户输入新待办事项的细节。

<template>
  <div id="app">
    <h4 class="bg-primary text-white text-center p-2">
      {{name}}'s To Do List
    </h4>
    <div class="container-fluid p-4">
      <div class="row">
        <div class="col font-weight-bold">Task</div>
        <div class="col-2 font-weight-bold">Done</div>
      </div>
      <div class="row" v-for="t in filteredTasks" v-bind:key="t.action">
        <div class="col">{{t.action}}</div>
        <div class="col-2 text-center">
          <input type="checkbox" v-model="t.done" class="form-check-input" />
        </div>
      </div>
      <div class="row py-2">

        <div class="col">

          <input v-model="newItemText" class="form-control" />

        </div>

        <div class="col-2">

          <button class="btn btn-primary" v-on:click="addNewTodo">Add</button>

        </div>

      </div>

      <div class="row bg-secondary py-2 mt-2 text-white">
        <div class="col text-center">
          <input type="checkbox" v-model="hideCompleted" class="form-check-input" />
          <label class="form-check-label font-weight-bold">
            Hide completed tasks
          </label>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'app',
    data() {
      return {
        name: "Adam",
        tasks: [{ action: "Buy Flowers", done: false },
                { action: "Get Shoes", done: false },
                { action: "Collect Tickets", done: true },
                { action: "Call Joe", done: false }],
        hideCompleted: true,
        newItemText: ""

      }
    },
    computed: {
      filteredTasks() {
        return this.hideCompleted ?
          this.tasks.filter(t => !t.done) : this.tasks
      }
    },
    methods: {

      addNewTodo() {

        this.tasks.push({

          action: this.newItemText,

          done: false

        });

        this.newItemText = "";

      }

    }
  }
</script>

Listing 1-17Creating New Tasks in the App.vue File in the src Folder

input元素使用v-model指令创建与名为newItemText的变量的绑定,当用户编辑 contents 元素时,Vue.js 将更新该变量的值。为了触发新数据项的创建,我对button元素应用了v-on指令,如下所示:

...
<button class="btn btn-primary" v-on:click="addNewTodo">
...

v-on指令用于响应事件,这些事件通常在用户执行动作时触发。在本例中,我使用了v-on指令来告诉 Vue.js 在button元素上触发click事件时调用一个名为addNewTodo的方法,这将在用户单击按钮时发生。

为了支持template元素中的更改,我对script元素中的 JavaScript 代码做了相应的更改。我添加了一个methods属性,Vue.js 在这里寻找被指令表达式调用的方法。在methods中,我定义了一个名为addNewTodo的方法,使用newItemText属性的值作为action值向tasks数组添加一个新对象。一旦新对象被添加到数组中,我就重置newItemText值。当input元素的内容改变时,更新newItemText值的v-model绑定也在另一个方向工作,这意味着将newItemText设置为空字符串将清除input元素的内容。

保存更改,当浏览器重新加载时,您将看到如图 1-10 所示的内容。在input元素中输入一个任务描述,然后点击 Add 按钮,您会看到一个新的待办事项出现在列表中。

img/465686_1_En_1_Fig10_HTML.jpg

图 1-10

创建新任务

持久存储数据

示例应用中没有持久数据存储,这意味着重新加载浏览器会重置待办事项列表,用户所做的任何更改都会丢失。我将使用现代浏览器中可用的本地存储功能,这样我就可以存储数据,而不必设置服务器,并且还可以演示您可以在 Vue.js 应用中直接使用浏览器提供的功能。我将在后面的章节中向您展示如何使用服务器,但是我将在这个项目中保持简单。在清单 1-18 中,我在组件的script元素中添加了语句来存储和检索待办事项列表数据。(我没有在清单中显示template元素,因为它没有改变。我在第二章中解释了这个惯例。)

...
<script>

    export default {
        name: 'app',
        data() {
            return {
                name: "Adam",
                tasks: [],

                hideCompleted: true,
                newItemText: ""
            }
        },
        computed: {
            filteredTasks() {
                return this.hideCompleted ?
                    this.tasks.filter(t => !t.done) : this.tasks
            }
        },
        methods: {
            addNewTodo() {
                this.tasks.push({
                    action: this.newItemText,
                    done: false
                });
                localStorage.setItem("todos", JSON.stringify(this.tasks));

                this.newItemText = "";
            }
        },
        created() {

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

            if (data != null) {

                this.tasks = JSON.parse(data);

            }

        }

    }
</script>
...

Listing 1-18Using Local Storage in the App.vue File in the src Folder

本地存储特性是通过名为localStorage的全局对象提供的,该对象定义了getItemsetItem方法,并且由浏览器提供。

小费

localStorage对象并不特定于 Vue.js 开发。它是一个标准的 JavaScript 对象,适用于所有的 web 应用,不管它们是如何编写的。参见 https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage 了解本地存储如何工作的详细描述。

添加到组件中的storeData方法使用setItem方法来存储待办事项,并在用户创建新的待办事项或切换现有复选框时被调用。本地存储特性只能存储字符串值,这意味着我必须先将数据对象序列化为 JSON,然后才能存储它们。

我在清单 1-18 中添加到组件中的created方法在 Vue.js 创建组件时被调用,它为我提供了一个在应用的内容呈现给用户之前从本地存储加载数据的机会。清单 1-18 中的最后一个变化是删除了占位符待办事项,因为用户的数据已经永久存储,所以不再需要这些事项。

当您保存更改时,浏览器将重新加载,应用将永久存储您创建的任何待办事项,这意味着当您重新加载浏览器窗口或导航到不同的 URL(如 Apress 网站)时,它们仍然可用,然后返回到http://localhost:8080,如图 1-11 所示。

img/465686_1_En_1_Fig11_HTML.jpg

图 1-11

存储数据

添加最后的润色

我将添加两个特性来完成示例应用。第一个特性是能够删除已完成的待办任务,这一点很重要,因为它们会被永久保存。第二个特性是当没有待办事项时显示一条消息。我将这两个特性都添加到了App组件中,如清单 1-19 所示。

<template>
    <div id="app">
        <h4 class="bg-primary text-white text-center p-2">
            {{name}}'s To Do List
        </h4>
        <div class="container-fluid p-4">
            <div class="row" v-if="filteredTasks.length == 0">

                <div class="col text-center">

                    <b>Nothing to do. Hurrah!</b>

                </div>

            </div>

            <template v-else>

                <div class="row">
                    <div class="col font-weight-bold">Task</div>
                    <div class="col-2 font-weight-bold">Done</div>
                </div>
                <div class="row" v-for="t in filteredTasks" v-bind:key="t.action">
                    <div class="col">{{t.action}}</div>
                    <div class="col-2 text-center">
                        <input type="checkbox" v-model="t.done"
                             class="form-check-input" />
                    </div>
                </div>
            </template>

            <div class="row py-2">
                <div class="col">
                    <input v-model="newItemText" class="form-control" />
                </div>
                <div class="col-2">
                    <button class="btn btn-primary"
                        v-on:click="addNewTodo">Add</button>
                </div>
            </div>
            <div class="row bg-secondary py-2 mt-2 text-white">
                <div class="col text-center">
                    <input type="checkbox" v-model="hideCompleted"
                        class="form-check-input" />
                    <label class="form-check-label font-weight-bold">
                        Hide completed tasks
                    </label>
                </div>
                <div class="col text-center">

                    <button class="btn btn-sm btn-warning"

                            v-on:click="deleteCompleted">

                        Delete Completed

                    </button>

                </div>

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

<script>

    export default {
        name: 'app',
        data() {
            return {
                name: "Adam",
                tasks: [],
                hideCompleted: true,
                newItemText: ""
            }
        },
        computed: {
            filteredTasks() {
                return this.hideCompleted ?
                    this.tasks.filter(t => !t.done) : this.tasks
            }
        },
        methods: {
            addNewTodo() {
                this.tasks.push({
                    action: this.newItemText,
                    done: false
                });
                this.storeData();

                this.newItemText = "";
            },
            storeData() {

                localStorage.setItem("todos", JSON.stringify(this.tasks));

            },

            deleteCompleted() {

                this.tasks = this.tasks.filter(t => !t.done);

                this.storeData();

            }

        },
        created() {
            let data = localStorage.getItem("todos");
            if (data != null) {
                this.tasks = JSON.parse(data);
            }
        }
    }
</script>

Listing 1-19Adding Finishing Touches in the App.vue File in the src Folder

v-ifv-else指令用于有条件地显示元素,我用它们在tasks数组中没有项目时显示一条消息,或者显示任务列表。我还添加了一个button元素,并使用v-on指令来处理click事件,过滤掉已完成的待办事项,然后存储剩余的对象。当您将更改保存到App.vue文件时,应用将重新加载。如果选中隐藏已完成任务复选框并点击删除已完成按钮,您将看到如图 1-12 所示的结果。

img/465686_1_En_1_Fig12_HTML.jpg

图 1-12

添加最后的润色

摘要

在本章中,我创建了一个简单的示例应用,向您介绍 Vue.js 项目和 Vue.js 开发过程。虽然这个例子很简单,但它让我演示了一些重要的 Vue.js 概念。

您已经看到 Vue.js 附带了开发所需的所有工具,当您创建一个项目时,准备和打包应用并将其交付给浏览器进行测试所需的一切都包括在内。

您了解了 Vue.js 应用是围绕组件构建的,这些组件结合了 HTML 和 JavaScript 代码。JavaScript 代码被分成几个部分,比如数据和方法,通过指令和数据绑定进行计算和访问。在所有这些特性的基础上,是反应式数据模型的思想,它允许应用自动反映变化。

Vue.js 还有更多的可用特性,你可以从这本书的篇幅中看出,但是我在本章中创建的基本应用已经向你展示了 Vue.js 开发的最基本的特征,并将为后面的章节提供基础。在下一章,我将 Vue.js 放在上下文中,描述这本书的结构和内容。

二、了解 Vue.js

Vue.js 是一个用于开发客户端应用的灵活而强大的开源框架,它采用了服务器端开发领域的设计原则,并将这些原则应用于 HTML 元素,从而为构建丰富的 web 应用提供了一个基础。在本书中,我解释了 Vue.js 是如何工作的,并演示了它提供的不同特性。

这本书和 Vue.js 发布时间表

Vue.js 开发团队尽最大努力确保 Vue.js API 的变化尽可能小,这是一个令人耳目一新的变化,不同于其他客户端框架不断出现的重大更新。Vue.js 有频繁的更新,但是你应该发现它们通常不会阻止现有的应用工作,这对你的项目和本书中的例子来说是个好消息。

即便如此,Vue.js 版本仍有可能阻止某些示例按预期运行。每当这种情况发生时,要求读者购买这本书的新版本似乎是不公平或不合理的,特别是因为 Vue.js 的大多数功能即使在主要版本中也不太可能改变。取而代之的是,我将在本书 https://github.com/Apress/pro-vue-js-2 的 GitHub 资源库中发布主要版本的更新。

这是我(和 press)正在进行的实验,这些更新可能采取的形式还不确定——尤其是因为我不知道 Vue.js 的主要版本将包含什么——但目标是通过更新书中包含的示例来延长这本书的寿命。

我不承诺更新会是什么样的,它们会采取什么形式,或者在我把它们折叠成这本书的新版本之前,我会花多长时间来制作它们。当新的 Vue.js 版本发布时,请保持开放的态度并检查这本书的存储库。如果您对如何改进更新有任何想法,请发电子邮件至adam@adam-freeman.com告诉我。

该不该用 Vue.js?

Vue.js 不是所有问题的解决方案,知道什么时候应该使用 Vue.js,什么时候应该寻求替代方案是很重要的。Vue.js 提供了过去只有本地应用开发人员才能使用的功能,并使其完全在浏览器中可用。这对浏览器提出了很多要求,浏览器必须运行 Vue.js 应用,处理 HTML 元素,执行 JavaScript 代码,处理事件,并执行启动 Vue.js 应用所需的所有其他任务,就像您在第二章中看到的那样。

这种工作需要时间来执行,时间的长短取决于 Vue.js 应用的复杂程度、浏览器的质量以及设备的处理能力。在功能强大的台式机上使用最新的浏览器时,你不会注意到任何延迟,但功能不足的智能手机上的旧浏览器确实会减慢 Vue.js 应用的初始设置。

因此,目标是尽可能少地执行这种设置,并在执行时向用户交付尽可能多的应用。这意味着仔细考虑您构建的 web 应用的类型。从广义上讲,web 应用有两种:往返单页

了解往返应用

很长一段时间以来,web 应用的开发都遵循一个往返模型。浏览器向服务器请求一个初始的 HTML 文档。用户交互——比如单击一个链接或提交一个表单——使浏览器请求并接收一个全新的 HTML 文档。在这种应用中,浏览器本质上是 HTML 内容的呈现引擎,所有的应用逻辑和数据都驻留在服务器上。浏览器发出一系列无状态的 HTTP 请求,服务器通过动态生成 HTML 文档来处理这些请求。

许多当前的 web 开发仍然是针对往返应用的,尤其是业务线项目,尤其是因为它们对浏览器的要求很少,并且拥有尽可能广泛的客户端支持。但是往返应用也有一些严重的缺点:它们让用户在请求和加载下一个 HTML 文档时等待,它们需要一个大型的服务器端基础设施来处理所有请求和管理所有应用状态,并且它们需要更多的带宽,因为每个 HTML 文档都必须是自包含的,这可能导致服务器的每个响应中都包含相同的内容。Vue.js 不太适合往返应用,因为浏览器必须为从服务器接收的每个新 HTML 文档执行初始设置过程。

理解单页应用

单页应用采取了不同的方法。一个初始的 HTML 文档被发送到浏览器,但是用户交互会导致 Ajax 请求将小的 HTML 片段或数据插入到向用户显示的现有元素集中。初始的 HTML 文档永远不会被重新加载或替换,当 Ajax 请求被异步执行时,用户可以继续与现有的 HTML 交互,即使这只是意味着看到一个“数据加载”消息。

单页面应用非常适合 Vue.js 和其他客户端框架,包括 Angular 和 React,因为浏览器初始化应用的工作只需执行一次,之后应用在浏览器中运行,响应用户交互并请求后台所需的数据或内容。

将 Vue.js 与反应和角度进行比较

Vue.js 有两个主要的竞争对手:React 和 Vue.js。它们之间存在差异,但是,在大多数情况下,所有这些框架都很优秀,它们都以相似的方式工作,并且它们都可以用于创建丰富和流畅的客户端应用。

一个关键的区别是 Vue.js 只专注于向用户呈现 HTML 内容,并不直接包括其他功能,如异步 HTTP 请求或 URL 路由。但是这种差异很大程度上是学术性的,因为这些特性是通过由 Vue.js 开发团队认可甚至开发的包提供的,并且大多数 Vue.js 项目使用相同的一组包。

这些框架之间的真正区别在于开发人员的体验。例如,Angular 需要使用 TypeScript 才能有效,而它只是 Vue.js 项目的一个选项。Vue.js 和 React 倾向于混合 HTML 和 JavaScript,这不是每个人都喜欢的。

我的建议很简单:选择你最喜欢的框架,如果你不喜欢,就换一个。这可能看起来是一种不科学的方法,但是这是一个不错的选择,并且您会发现许多核心概念会在框架之间延续,即使您改变了您使用的框架。

如果你想要仔细比较特性,那么请看 https://vuejs.org/v2/guide/comparison.html ,它对 Vue.js 和其他框架进行了全面的比较,尽管它是由 Vue.js 团队编写的。但是不要陷入比较底层特性的泥潭,因为所有这些框架都是好的;它们都可以用来编写大型复杂的项目,最好的框架总是适合您的个人开发风格,并且您在其中最有效率。

了解服务器端呈现

服务器端渲染(也称为 SSR)是另一个与 SPAs 相关的术语,旨在使 web 应用在用户首次导航到 URL 时响应更快,并允许搜索引擎更好地索引 web 应用内容。

SSR 使用 Node.js 作为服务器端 JavaScript 运行时,在服务器上执行 web 应用,以生成呈现给用户的内容。此时,应用是一个往返应用,每次交互都会向服务器发出一个新的 HTTP 请求,服务器继续代表用户执行应用,并将生成的 HTML 发送回浏览器,以便向用户显示。同时,在后台下载执行该应用所需的代码和内容,并用于初始化该应用,此时该应用成为单页应用,用户交互由浏览器中运行的代码处理。

有支持 Vue.js 应用 SSR 的可用包,但我不在本书中描述它们。服务器端的渲染很复杂,仅限于能够执行 JavaScript 的服务器,因为它们负责代表用户运行应用。应用必须注意不要依赖需要浏览器的特性或 API,因为这些特性在服务器上是不可用的。在服务器端呈现和客户端呈现之间无缝切换对用户来说也是困难和困惑的,尤其是在出现问题的时候。这些困难和限制意味着 SSR 不适合大多数 Vue.js 项目,应该谨慎对待。参见 https://vuejs.org/v2/guide/ssr.html 了解关于 Vue.js 的 SSR 支持的更多细节

了解应用的复杂性

在决定 Vue.js 是否适合某个项目时,应用的类型并不是唯一的考虑因素。项目的复杂性也很重要,我经常听到一些读者说,他们已经开始使用客户端框架(如 Vue.js、Angular 或 React)进行项目,而简单得多的框架就足够了。像 Vue.js 这样的框架需要投入大量的时间来掌握(正如本书的篇幅所展示的),如果你只需要验证一个表单或编程填充一个选择元素,这种努力是不值得的。

在围绕客户端框架的兴奋中,很容易忘记浏览器提供了一组丰富的可以直接使用的 API,这些 API 也是 Vue.js 所有功能所依赖的 API。如果您有一个简单且独立的问题,那么您应该考虑直接使用浏览器 API,从文档对象模型(DOM) API 开始。你会看到本书中的一些例子直接使用了浏览器 API,但是如果你是浏览器开发新手,那么 https://developer.mozilla.org 是一个很好的起点,它包含了浏览器支持的所有 API 的良好文档。

浏览器 API 的缺点,尤其是 DOM API,是它们可能很难使用,并且旧的浏览器倾向于以不同的方式实现特性。jQuery ( https://jquery.org )是直接使用浏览器 API 的一个很好的替代方法,尤其是如果您必须支持旧的浏览器。jQuery 简化了 HTML 元素的使用,并为处理事件、动画和异步 HTTP 请求提供了出色的支持。

像 Vue.js 这样的富客户端框架对于简单的项目需要太多的投资和太多的资源。它们在复杂的项目中发挥了自己的作用,在复杂的项目中,要实现复杂的工作流,要处理不同类型的用户,还要处理大量的数据。在这些情况下,您可以直接使用浏览器 API 或 jQuery,但是管理代码和扩展应用会变得很困难。Vue.js 提供的特性使得构建大型复杂的应用变得更加容易,并且不会陷入大量不可读的代码中,而不采用框架的复杂项目往往会陷入这种困境。

我需要知道什么?

如果您认为 Vue.js 是您项目的正确选择,那么您应该熟悉 web 开发的基础知识,了解 HTML 和 CSS 的工作原理,最好还能掌握 JavaScript 的工作知识。如果你对这些细节有些模糊,我在第三章 3 和第四章 4 中提供了我在本书中使用的 HTML、CSS 和 JavaScript 特性的入门知识。但是,你不会找到关于 HTML 元素和 CSS 属性的全面参考。一本关于 Vue.js 的书没有足够的空间来涵盖所有的 HTML。如果你想温习 HTML、CSS 和 JavaScript 的基础知识,我推荐从 https://developer.mozilla.org 开始。

如何设置我的开发环境?

Vue.js 开发所需的唯一开发工具是您在第一章创建第一个应用时安装的工具。后面的一些章节需要额外的软件包,但是提供了完整的说明。如果你在第一章成功地构建了应用,那么你就为 Vue.js 开发和本书的其余章节做好了准备。

这本书的结构是什么?

这本书分为三部分,每一部分都涵盖了一系列相关的主题。

第一部分:Vue.js 入门

本书的第一部分提供了 Vue.js 开发入门所需的信息。它包括本章和 Vue.js 开发中使用的关键技术的入门/复习章节,包括 HTML、CSS 和 JavaScript。我还将向您展示如何构建您的第一个 Vue.js 应用,并带您完成构建一个更真实的应用(称为 SportsStore)的过程。

第二部分:使用 Vue.js

本书的第二部分将带您了解大多数 Vue.js 项目所需的特性。Vue.js 包括许多内置功能,我将深入描述这些功能,以及将自定义代码和内容添加到项目中以创建定制功能的方式。

第三部分:高级 Vue.js 特性

本书的第三部分解释了如何使用高级 Vue.js 特性来创建更大更复杂的应用。我描述了 Vue.js 应用的生命周期,向您展示了如何创建一个公共数据存储,并解释了如何根据用户的操作显示应用的各个部分。我还将向您展示如何扩展内置的 Vue.js 特性,以及如何在您的项目中执行单元测试。

有很多例子吗?

个载荷的例子。学习 Vue.js 的最好方法是通过例子,我已经尽可能多地将它们打包到本书中,并附有截图,以便您可以看到每个特性的效果。为了最大限度地增加本书中的示例数量,我采用了一个简单的约定来避免重复列出相同的代码或内容。当我创建一个文件时,我会显示它的全部内容,就像我在清单 2-1 中所做的那样。我在清单的标题中包含了文件及其文件夹的名称,并且用粗体显示了我所做的更改。

<template>
    <div class="container-fluid">
        <div class="row">
            <div class="col"><error-display /></div>
        </div>
        <div class="row">
            <div class="col-8 m-3"><product-display /></div>
            <div class="col m-3"><product-editor /></div>
        </div>
    </div>
</template>

<script>
    import ProductDisplay from "./components/ProductDisplay";
    import ProductEditor from "./components/ProductEditor";
    import ErrorDisplay from "./components/ErrorDisplay";

    export default {
        name: 'App',
        components: { ProductDisplay, ProductEditor, ErrorDisplay },
        created() {

            this.$store.dispatch("getProductsAction");

        }

    }
</script>

Listing 2-1Getting Data in the App.vue File in the src Folder

这是第二十一章的列表,显示了在src文件夹中可以找到的一个名为App.vue的文件的内容。不要担心清单的内容或文件的目的;请注意,这种类型的清单包含文件的完整内容,您需要按照示例进行的更改以粗体显示。

Vue.js 应用中的一些文件可能很长,但是我描述的特性只需要一点小小的改变。我没有列出完整的文件,而是使用省略号(三个句点串联)来表示部分列表,它只显示了文件的一部分,如清单 2-2 所示。

...
<script>

    let unwatcher;

    export default {
        data: function () {
            return {
                editing: false,
                product: {}
            }
        },
        methods: {
            save() {
                this.$store.dispatch("saveProductAction", this.product);
                this.product = {};
            },
            cancel() {
                this.$store.commit("selectProduct");
            },
            selectProduct(selectedProduct) {

                if (selectedProduct == null) {

                    this.editing = false;

                    this.product = {};

                } else {

                    this.editing = true;

                    this.product = {};

                    Object.assign(this.product, selectedProduct);

                }

            }

        },
        created() {
            unwatcher = this.$store.watch(state =>

                state.selectedProduct, this.selectProduct);

            this.selectProduct(this.$store.state.selectedProduct);

        },
        beforeDestroy() {

           unwatcher();

        }

    }
</script>
...

Listing 2-2Preparing for Dynamic Display in the ProductEditor.vue File in the src/components Folder

这是第二十一章的后续清单,它显示了一组只应用于一个大得多的文件的一部分的更改。当您看到部分清单时,您会知道文件的其余部分不必更改,只有粗体部分不同。

在某些情况下,需要在文件的不同部分进行更改,这使得很难显示为部分列表。在这种情况下,我省略了文件的部分内容,如清单 2-3 所示。

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

import PrefsModule from "./preferences";

import NavModule from "./navigation";

Vue.use(Vuex);

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

export default new Vuex.Store({
    modules: {
        prefs: PrefsModule,
        nav: NavModule

    },
    state: {
        products: [],
        selectedProduct: null
    },

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

})

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

更改仍然用粗体标记,清单中省略的文件部分不受此示例的影响。

从哪里可以获得示例代码?

你可以从 https://github.com/Apress/pro-vue-js-2 下载本书所有章节的范例项目。该下载是免费的,它包含了您学习示例所需的一切,而不必键入所有的代码。

你在哪里可以得到这本书的修改?

你可以在 https://github.com/Apress/pro-vue-js-2 找到这本书的勘误表。

你怎么联系我?

如果你在使用本章中的例子时有问题,或者你在书中发现了问题,那么你可以发电子邮件到adam@adam-freeman.com给我,我会尽力帮助你。在联系我之前,请检查这本书的勘误表,看看它是否包含您的问题的解决方案。

摘要

在这一章中,我解释了 Vue.js 何时是项目的好选择,并概述了替代方案和竞争对手。我还概述了这本书的内容和结构,解释了从哪里获得更新,以及如果您对本书中的示例有问题,如何联系我。在下一章中,我将介绍本书中用来演示 Vue.js 开发的 HTML 和 CSS 特性。

三、HTML 和 CSS 入门

开发人员通过许多途径进入 web 应用开发的世界,并不总是基于 web 应用所依赖的基本技术。在这一章中,我提供了一个 HTML 的简单入门,并介绍了引导 CSS 库,我用它来设计本书中的例子。在第四章中,我介绍了 JavaScript 的基础知识,并给出了理解本书其余部分中的例子所需的信息。如果你是一个有经验的开发人员,你可以跳过这些初级章节,直接跳到第五章,在那里我使用 Vue.js 创建了一个更复杂和真实的应用。

小费

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

为本章做准备

对于这一章,我需要一个简单的 Vue.js 项目。我首先运行清单 3-1 中所示的命令来创建一个名为htmlcssprimer的项目。

vue create htmlcssprimer --default

Listing 3-1Creating the Example Project

创建项目的过程可能需要一些时间,因为有大量的软件包需要下载和安装。

注意

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

一旦创建了项目,运行清单 3-2 中所示的命令,导航到项目文件夹并安装 Bootstrap CSS 框架,我在本章(以及整本书)中使用它来管理 Vue.js 应用中内容的外观。

cd htmlcssprimer
npm install bootstrap@4.0.0

Listing 3-2Installing the Bootstrap Package

为了在项目中包含 Bootstrap,我将清单 3-3 中所示的语句添加到了src文件夹中的main.js文件中。我在第四章中解释了import语句是如何工作的,但是现在,简单地添加清单中所示的语句就足够了。

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

Vue.config.productionTip = false

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

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

Listing 3-3Adding a Statement in the main.js File in the src Folder

接下来,我用清单 3-4 中的templatescript元素替换了App.vue文件中的占位符内容,并完全删除了style元素。

<template>
    <div>
        <h4 class="bg-primary text-white text-center p-2">
            Adam's To Do List
        </h4>
        <div class="container-fluid p-4">
            <div class="row">
                <div class="col font-weight-bold">Task</div>
                <div class="col-2 font-weight-bold">Done</div>
            </div>
            <div class="row" v-for="t in tasks" v-bind:key="t.action">
                <div class="col">{{t.action}}</div>
                <div class="col-2">{{t.done}}</div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    data: function () {
        return {
            tasks: [{ action: "Buy Flowers", done: false },
                    { action: "Get Shoes", done: false },
                    { action: "Collect Tickets", done: true },
                    { action: "Call Joe", done: false }]
        }
    }
}
</script>

Listing 3-4Replacing the Content of the App.vue File in the src Folder

这是第一章中待办事项应用的简化版本,有一些基本的动态内容,但没有完成或添加新项目等功能。保存所有的修改并运行清单 3-5 中的命令来启动 Vue.js 开发工具。

npm run serve

Listing 3-5Starting the Vue.js Development Tools

项目的初始准备需要一段时间,之后您会看到一条消息,告诉您应用已经准备好了。打开一个新的浏览器窗口并导航至http://localhost:8080以查看图 3-1 所示的内容。

img/465686_1_En_3_Fig1_HTML.jpg

图 3-1

运行示例应用

理解 HTML 元素

HTML 的核心是元素,它告诉浏览器 HTML 文档的每个部分代表什么样的内容。以下是示例 HTML 文档中的一个元素:

...
<h4 class="bg-primary text-white text-center p-2">
    Adam's To Do List
</h4>
...

如图 3-2 所示,这个元素有几个部分:开始标签、结束标签、属性和内容。

img/465686_1_En_3_Fig2_HTML.jpg

图 3-2

HTML 元素的剖析

这个元素的名称(也称为标签名称或者仅仅是标签)是h4,它告诉浏览器标签之间的内容应该被当作一个头。有一系列的头元素,从h1h6,其中h1通常用于最重要的内容,h2用于稍微不太重要的内容,等等。

在定义 HTML 元素时,首先将标记名放在尖括号中(<>字符),然后以类似的方式使用标记结束元素,除了在左尖括号(<)后添加一个/字符,以创建开始标记结束标记

标签表明元素的用途,HTML 规范定义了大量的元素类型。在表 3-1 中,我描述了我在清单 3-4 中使用的元素,以及来自后面章节示例中的一些最常见的元素。要获得标签类型的完整列表,您应该查阅 HTML 规范。

表 3-1

示例中使用的常见 HTML 元素

|

元素

|

描述

|
| --- | --- |
| a | 表示一个链接(更正式的说法是一个),用户单击它可以导航到当前文档中的新 URL 或新位置。 |
| button | 表示一个按钮;通常用于向服务器提交表单。 |
| div | 通用元素;通常用于为文档添加结构,以用于演示目的。 |
| h1-h6 | 降级标题。 |
| input | 表示用于从用户处收集单个数据项的字段。 |
| table | 表示表格,用于将内容组织成行和列。 |
| tbody | 表示表格的正文(与页眉或页脚相对)。 |
| td | 降级表格行中的内容单元格。 |
| template | 表示将使用 JavaScript 处理的内容。Vue.js 组件使用一个template元素来包含它们的 HTML 内容,该元素还用于应用 Vue.js 特性以避免创建无效的 HTML 文档。 |
| th | 降级表格行中的标题单元格。 |
| thead | 降级表格的标题。 |
| tr | 降级表格中的行。 |

了解元素内容

出现在开始和结束标签之间的就是元素的内容。一个元素可以包含文本(比如本例中的Adam's To Do List)或其他 HTML 元素。下面是清单 3-4 中包含其他 HTML 元素的元素示例:

...
<div class="row">
    <div class="col font-weight-bold">Task</div>
    <div class="col-2 font-weight-bold">Done</div>
</div>
...

外部元素被称为父元素,而它包含的元素被称为子元素。能够创建元素的层次结构是 HTML 的一个基本特性,它允许创建复杂的布局。父子关系在一个大型 HTML 文档中展开,这样一个元素可以是许多其他元素的前身,所有其他元素都是它的后代。

了解元素内容限制

有些元素对可以成为其子元素的元素类型有限制。上一节中显示的div元素可以包含任何其他元素,并用于添加结构,通常这样可以很容易地对内容进行样式化。其他元素具有更具体的角色,需要将特定类型的元素用作子元素。例如,一个tbody元素,你将在后面的章节中看到,它代表一个表格的主体,可以只包含一个或多个tr元素,每个元素代表一个表格行。

小费

不要担心学习所有的 HTML 元素和它们之间的关系。当你按照后面章节中的例子学习时,你会学到你需要知道的一切,如果你试图创建无效的 HTML,大多数代码编辑器会显示一个警告。

了解空元素

有些元素根本不允许包含任何内容。这些被称为 void自闭元素,它们没有单独的结束标记,就像这样:

...
<input />
...

在单个标记中定义了一个 void 元素,并在最后一个尖括号(>字符)前添加了一个/字符。这里显示的元素是 void 元素最常见的例子,它用于在 HTML 表单中收集来自用户的数据。在后面的章节中,你会看到很多关于 void 元素的例子。

了解属性

通过向元素添加属性,可以向浏览器提供额外的信息。下面是应用于图 3-2 中所示的h4元素的属性:

...
<h4 class="bg-primary text-white text-center p-2">
    Adam's To Do List
</h4>
...

属性总是被定义为开始标签的一部分,并且大多数属性都有一个名称和一个值,用等号分隔,如图 3-3 所示。

img/465686_1_En_3_Fig3_HTML.jpg

图 3-3

属性的名称和值

这个属性的名称是class,用于将相关的元素组合在一起,这样它们的外观就可以得到一致的管理。这就是为什么在这个例子中使用了class属性,并且属性值将h4元素与许多类相关联,这些类与引导 CSS 包提供的样式相关,我将在本章后面描述。

在属性中引用文字值

Vue.js 依靠 HTML 元素属性来应用它的许多功能。大多数时候,属性的值被作为 JavaScript 表达式来计算,比如这个元素,取自清单 3-4 :

...
<div class="row" v-for="t in tasks" v-bind:key="t.action">
...

属性值包含 JavaScript 片段。属性v-for的值是一个表达式,它将枚举一个名为tasks的数组中的对象,并将每个对象分配给一个名为t的临时变量。清单 3-4 中的script元素中提供了tasks数组,但是有时您需要提供一个特定的值,而不是让 Vue.js 从script元素中读取一个值,这需要额外的引号字符来告诉 JavaScript 它正在处理一个文字值,如下所示:

...
<h3 v-on:click="name = 'Clicked'">{{ name }}</h3>
...

这个元素来自第十四章,属性的值是一个表达式,它将文字字符串Clicked分配给一个名为name的属性。为了表示文字值,Clicked用单引号('字符)括起来,这可以防止 Vue.js 在script元素中查找Clicked值。

应用不带值的属性

并非所有属性都需要值;仅仅定义它们就向浏览器发送了一个信号,表明您需要与该元素相关联的某种行为。下面是一个具有这种属性的元素的例子,你可以在第二十五章中找到:

...
<transition enter-active-class="animated fadeIn"
    leave-active-class=" animated fadeOut" mode="out-in"
    appear appear-active-class="animated zoomIn">
        <router-view />
</transition>
...

这是一个应用了许多属性的元素,但是请注意,appear属性没有值。有效果的是属性的存在,不需要值。

检查实时 HTML 文档

如果你想看到一个网页所使用的底层 HTML,你可能习惯于在浏览器窗口中右击并从弹出窗口中选择 View Page Source(如果你没有使用 Google Chrome,则选择一个类似名称的菜单项)。但是如果您在浏览器显示示例应用时这样做,您将看不到待办事项列表的 HTML。相反,您将看到以下内容:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="/favicon.ico">
    <title>htmlcssprimer</title>
    <link as="script" href="/app.js" rel="preload">
</head>
  <body>
    <noscript>
      <strong>
          We're sorry but htmlcssprimer doesn't work properly without
          JavaScript enabled. Please enable it to continue.
      </strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  <script type="text/javascript" src="/app.js"></script></body>
</html>

您需要使用浏览器的开发人员工具来查看 Vue.js 应用生成的 HTML 元素。当应用运行时,它的 JavaScript 代码被执行,并产生您在浏览器窗口中看到的内容。

大多数浏览器在按下 F12 键时会打开它们的开发工具(这就是为什么它们通常被称为 F12 工具),但是通常会有一个菜单选项或弹出菜单上的一个项目,当您在浏览器窗口中右键单击时会出现。对于 Google Chrome,这是 Inspect 菜单项,它打开开发者工具并聚焦于你点击的 HTML 元素。

图 3-4 显示了我在浏览器窗口中右键单击 Collect Tickets 文本并从弹出菜单中选择 Inspect 后的开发人员工具显示。它显示了应用生成的 HTML 元素,包括它们的内容、属性以及影响它们的 CSS 样式的细节。

img/465686_1_En_3_Fig4_HTML.jpg

图 3-4

检查 HTML 元素

F12 开发人员工具呈现的视图是实时的,这意味着对 Vue.js 应用生成的内容的更改将反映在您看到的元素中。这是了解应用行为的好方法,尤其是当您没有得到预期的结果时。

了解引导程序

HTML 元素告诉浏览器它们代表什么样的内容,但是它们不提供任何关于内容应该如何显示的信息。关于如何显示元素的信息是使用级联样式表 (CSS)提供的。CSS 由一组全面的属性和一组选择器组成,前者可用于配置元素外观的各个方面,后者允许应用这些属性。

CSS 的一个主要问题是,一些浏览器对属性的解释略有不同,这可能导致 HTML 内容在不同设备上的显示方式有所不同。跟踪和纠正这些问题可能很困难,CSS 框架已经出现,以帮助 web 应用开发人员以简单和一致的方式设计他们的 HTML 内容。

最流行的 CSS 框架是 Bootstrap,它最初是在 Twitter 上开发的,但已经成为一个广泛使用的开源项目。Bootstrap 由一组 CSS 类和一些可选的 JavaScript 代码组成,这些 CSS 类可以应用于元素以保持一致的样式,这些可选的 JavaScript 代码可以执行额外的增强功能(但我不会在本书中使用)。我在自己的项目中使用 Bootstrap 它跨浏览器运行良好,并且使用简单。我在本书中使用了 Bootstrap CSS 样式,因为它们让我不必在每一章中定义和列出我自己的定制 CSS 就可以设计我的例子。Bootstrap 提供了比我在本书中使用的更多的特性;参见 getbootstrap。有关详细信息,请访问。

关于 Bootstrap,我不想说得太详细,因为这不是本书的主题,但是我想给你足够的信息,这样你就可以知道例子的哪些部分是 Vue.js 特性,哪些与 Bootstrap 相关。

应用基本引导类

引导样式是通过class属性应用的,该属性用于将相关元素组合在一起。class属性不仅用于应用 CSS 样式,而且是最常见的用法,它支持 Bootstrap 和类似框架的操作方式。下面是一个带有class属性的 HTML 元素,取自清单 3-4 :

...
<h4 class="bg-primary text-white text-center p-2">
    Adam's To Do List
</h4>
...

class属性将h4元素分配给四个类,它们的名称由空格分隔:bg-primarytext-whitetext-centerp-2。这些类对应于 Bootstrap 定义的样式集合,如表 3-2 所述。

表 3-2

h4 元素类

|

名字

|

描述

|
| --- | --- |
| bg-primary | 该类应用样式上下文来提供关于元素用途的视觉提示。请参见“使用上下文类”一节。 |
| text-white | 这个类应用一种样式,将元素内容的文本颜色设置为白色。 |
| text-center | 这个类应用一种水平居中元素内容的样式。 |
| p-2 | 该类应用一种样式,在元素内容周围增加间距,如“使用边距和填充”一节所述。 |

使用上下文类

使用像 Bootstrap 这样的 CSS 框架的主要优点之一是简化了在整个应用中创建一致主题的过程。Bootstrap 定义了一组样式上下文,用于一致地设计相关元素的样式。这些上下文在表 3-3 中描述,用于将引导样式应用于元素的类的名称中。

表 3-3

自举风格的上下文

|

名字

|

描述

|
| --- | --- |
| primary | 该上下文用于指示主要动作或内容区域。 |
| secondary | 该上下文用于指示内容的支持区域。 |
| success | 此上下文用于指示成功的结果。 |
| info | 该上下文用于呈现附加信息。 |
| warning | 该上下文用于显示警告。 |
| danger | 此上下文用于表示严重警告。 |
| muted | 这种语境是用来淡化内容的。 |
| dark | 该上下文通过使用深色来增加对比度。 |
| white | 该上下文用于通过使用白色来增加对比度。 |

Bootstrap 提供了允许样式上下文应用于不同类型元素的类。我在开始本节时使用的h4元素已经被添加到了bg-primary类中,该类设置元素的背景颜色,以表明它与应用的主要目的相关。其他类特定于某一组元素,例如btn-primary,它用于配置buttona元素,使它们显示为按钮,其颜色与主上下文中的其他元素一致。其中一些上下文类必须与配置元素基本样式的其他类结合使用,比如与btn-primary类结合使用的btn类。

使用边距和填充

Bootstrap 包括一组实用程序类,用于添加填充,即元素边缘与其内容之间的空间,以及边距,即元素边缘与其周围元素之间的空间。使用这些类的好处是它们在整个应用中应用一致的间距。

这些类的名称遵循一种定义良好的模式。下面是清单 3-4 中的h4元素:

...
<h4 class="bg-primary text-white text-center p-2">
    Adam's To Do List
</h4>
...

将边距和填充应用于元素的类遵循一个定义良好的命名模式:首先是字母m(用于边距)或p(用于填充),接着是一个可选的字母,用于选择特定的边缘(t用于顶部、b用于底部、l用于左侧、或r用于右侧),然后是一个连字符,最后是一个数字,用于指示应该应用多少空间(0用于无间距,或12345用于增加数量)。如果没有字母来指定边缘,则边距或填充将应用于所有边缘。为了帮助将这个模式放在上下文中,添加了h4元素的p-2类将填充级别 2 应用于元素的所有边缘。

使用引导程序创建网格

Bootstrap 提供了样式类,可用于创建不同种类的网格布局,从 1 列到 12 列不等,并支持响应式布局,其中网格的布局根据屏幕的宽度而变化。我在本书的许多例子中使用了网格布局,包括清单 3-4 中的组件,它使用这种布局显示其待办事项,如下所示:

...
<div class="container-fluid p-4">
    <div class="row">
        <div class="col font-weight-bold">Task</div>
        <div class="col-2 font-weight-bold">Done</div>
    </div>
<div class="row" v-for="t in tasks" v-bind:key="t.action">
    <div class="col">{{t.action}}</div>
        <div class="col-2">{{t.done}}</div>
    </div>
</div>
...

自举网格布局系统易于使用。一个顶级的div元素被分配给container类(或者是container-fluid类,如果你想让它跨越可用空间的话)。通过将row类应用到div元素来指定列,这具有为div元素包含的内容设置网格布局的效果。

每行定义 12 列,您可以通过指定一个名为col-后跟列数的类来指定每个子元素将占用多少列。例如,类col-1指定一个元素占据一列,col-2指定两列,依此类推,直到col-12,它指定一个元素填充整个行。如果您省略了列数,而只是将一个元素分配给了col类,那么 Bootstrap 将分配等量的剩余列。

使用引导程序设计表格

Bootstrap 包括对样式化table元素及其内容的支持,这是我在后面章节的一些例子中使用的一个特性。表 3-4 列出了使用表的关键引导类。

表 3-4

表格的引导 CSS 类

|

名称

|

描述

|
| --- | --- |
| table | 对一个table元素及其行应用常规样式 |
| table-striped | 对table正文中的行应用隔行条带化 |
| table-bordered | 将边框应用于所有行和列 |
| table-sm | 减少表格中的间距以创建更紧凑的布局 |

所有这些类都直接应用于table元素,如清单 3-6 所示,其中我用表格替换了网格布局。

<template>
    <div>
        <h4 class="bg-primary text-white text-center p-2">
            Adam's To Do List
        </h4>

        <table class="table table-striped table-bordered table-sm">

            <thead>

                <tr><th>Task</th><th>Done</th></tr>

            </thead>

            <tbody>

                <tr v-for="t in tasks" v-bind:key="t.action">

                    <td>{{t.action}}</td>

                    <td>{{t.done}}</td>

                </tr>

            </tbody>

        </table>

    </div>
</template>

<script>
    export default {
        data: function () {
            return {
                tasks: [{ action: "Buy Flowers", done: false },
                { action: "Get Shoes", done: false },
                { action: "Collect Tickets", done: true },
                { action: "Call Joe", done: false }]
            }
        }
    }
</script>

Listing 3-6Using a Table Layout in the App.vue File in the src Folder

小费

注意,在定义清单 3-6 中的表格时,我使用了thead元素。如果一个tbody元素没有被使用,浏览器会自动添加任何tr元素,这些元素是table元素的直接后代。如果在使用 Bootstrap 时依赖于这种行为,您将会得到奇怪的结果,并且在定义表时使用完整的元素集总是一个好主意。

图 3-5 显示用表格代替网格显示待办事项的结果。

img/465686_1_En_3_Fig5_HTML.jpg

图 3-5

样式化 HTML 表格

使用引导程序设计表单

Bootstrap 包括表单元素的样式,允许它们与应用中的其他元素保持一致。在清单 3-7 中,我向示例应用添加了表单元素。

<template>
    <div>
        <h4 class="bg-primary text-white text-center p-2">
            Adam's To Do List
        </h4>

        <table class="table table-striped table-bordered table-sm">
            <thead>
                <tr><th>Task</th><th>Done</th></tr>
            </thead>
            <tbody>
                <tr v-for="t in tasks" v-bind:key="t.action">
                    <td>{{t.action}}</td>
                    <td>{{t.done}}</td>
                </tr>
            </tbody>
        </table>

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

            <label>New Item:</label>

            <input v-model="newItemText" class="form-control" />

        </div>

        <div class="text-center">

            <button class="btn btn-primary" v-on:click="addNewTodo">

                Add

            </button>

        </div>

    </div>
</template>

<script>
    export default {
        data: function () {
            return {
                tasks: [{ action: "Buy Flowers", done: false },
                { action: "Get Shoes", done: false },
                { action: "Collect Tickets", done: true },
                { action: "Call Joe", done: false }],
                newItemText: ""

            }
        },
        methods: {

            addNewTodo() {

                this.tasks.push({

                    action: this.newItemText,

                    done: false

                });

                this.newItemText = "";

            }

        }

    }
</script>

Listing 3-7Adding Form Elements in the App.vue File in the src Folder

表单的基本样式是通过将form-group类应用于包含labelinput元素的div元素来实现的,其中input元素被分配给form-control类。Bootstrap 对元素进行样式化,使label显示在input元素上方,而input元素占据 100%的可用水平空间,如图 3-6 所示。

img/465686_1_En_3_Fig6_HTML.jpg

图 3-6

样式表单元素

摘要

在这一章中,我提供了 HTML 和引导 CSS 框架的简要概述。您需要很好地掌握 HTML 和 CSS,以便在 web 应用开发中真正有效,但最好的学习方法是通过第一手经验,本章中的描述和示例将足以让您入门,并为前面的示例提供足够的背景信息。在下一章,我将继续初级主题,介绍我在本书中使用的最重要的 JavaScript。

四、基本 JavaScript 入门

在这一章中,我快速浏览了 JavaScript 语言应用于 Vue.js 开发的最重要的特性。我没有足够的空间来完整地描述 JavaScript,所以我把重点放在了你需要快速掌握并遵循本书中的例子的要点上。表 4-1 总结了本章内容。

表 4-1

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 提供将由浏览器执行的指令 | 使用 JavaScript 语句 | five |
| 将语句的执行延迟到需要的时候 | 使用 JavaScript 函数 | 6–8, 11, 12 |
| 定义参数数量可变的函数 | 使用默认和 rest 参数 | 9, 10 |
| 简洁地表达功能 | 使用粗箭头功能 | Fourteen |
| 定义变量和常数 | 使用letconst关键字 | 15, 16 |
| 使用 JavaScript 基本类型 | 使用stringnumberboolean关键字 | 17, 18, 20 |
| 定义包含其他值的字符串 | 使用模板字符串 | Nineteen |
| 有条件地执行语句 | 使用ifelseswitch关键字 | Twenty-one |
| 比较价值观和身份 | 使用等式和标识运算符 | 22, 23 |
| 转换类型 | 使用类型转换关键字 | 24–26 |
| 分组相关项目 | 定义一个数组 | 27, 28 |
| 读取或更改数组中的值 | 使用索引访问器符号 | 29, 30 |
| 枚举数组的内容 | 使用for循环或forEach方法 | Thirty-one |
| 展开数组的内容 | 使用扩展运算符 | 32, 33 |
| 处理数组的内容 | 使用内置数组方法 | Thirty-four |
| 将相关值收集到一个单元中 | 定义一个对象 | 35–37 |
| 定义可以对对象的值执行的操作 | 定义一种方法 | 38, 39 |
| 将属性和值从一个对象复制到另一个对象 | 使用Object.assign方法 | Forty |
| 群组相关功能 | 定义一个 JavaScript 模块 | 41–49 |
| 观察异步操作 | 定义一个Promise并使用asyncawait关键字 | 50–54 |

为本章做准备

对于这一章,我需要一个简单的 Vue.js 项目。我首先运行清单 4-1 中所示的命令来创建一个名为jsprimer的项目。

小费

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

vue create jsprimer --default

Listing 4-1Creating the Example Project

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

注意

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

一旦创建了项目,使用您喜欢的编辑器将src文件夹中的main.js文件的内容替换为清单 4-2 中所示的语句。对于本章,重点是 JavaScript 而不是 Vue.js,并且不需要现有的代码,因为它初始化一个 Vue.js 应用。

console.log("Hello");

Listing 4-2Replacing the Content of the main.js File in the src Folder

此项目需要更改配置以覆盖项目的默认设置。将清单 4-3 中显示的语句添加到project.json文件中,该文件负责配置项目,我在第十章中对此进行了描述。

...
"eslintConfig": {
  "root": true,
  "env": {
    "node": true
  },
  "extends": [
    "plugin:vue/essential",
    "eslint:recommended"
  ],
  "rules": {
    "no-console": "off",

    "no-declare": "off",

    "no-unused-vars": "off"

  },
  "parserOptions": {
    "parser": "babel-eslint"
  }
},
"postcss": {
  "plugins": {
    "autoprefixer": {}
  }
},
...

Listing 4-3Changing the Project Configuration in the package.json File in the jsprimer Folder

这些改变禁用了我在本章的例子中使用的 JavaScript 特性的警告,由 JavaScript linter 产生,我在第十章描述了它。

保存对main.jspackage.json文件的更改;然后打开一个新的命令提示符,运行清单 4-4 中所示的命令,导航到项目文件夹,启动 Vue.js 开发工具。尽管我在本章中没有使用 Vue.js 特性,但我将利用 Vue.js 工具链,它简化了在浏览器中执行 JavaScript 代码的过程。

cd jsprimer
npm run serve

Listing 4-4Starting the Vue.js Development Tools

项目的初始准备需要一段时间,之后您会看到一条消息,告诉您应用已经准备好了。打开一个新的浏览器窗口并导航到http://localhost:8080,这将产生如图 4-1 所示的空窗口。

img/465686_1_En_4_Fig1_HTML.jpg

图 4-1

运行示例应用

如果你打开浏览器的 F12 开发工具并检查控制台选项卡,你会看到清单 4-2 中的语句产生了一个简单的结果,如图 4-2 所示。

img/465686_1_En_4_Fig2_HTML.jpg

图 4-2

浏览器控制台中的一条消息

本章中的所有示例都产生文本输出,所以我将只使用文本,而不是显示控制台选项卡的屏幕截图,如下所示:

[HMR] Waiting for update signal from WDS...
Hello

第一行是来自开发工具的消息,当检测到src文件夹中的更改时,它会自动重新加载浏览器。我不会在后续示例的输出中包含这一点。

使用语句

基本的 JavaScript 构建块是语句。每条语句代表一条命令,语句通常以分号(;)结束。分号是可选的,但是使用分号会使代码更容易阅读,并且允许在一行中有多个语句。在清单 4-5 中,我向 JavaScript 文件添加了语句。

console.log("Hello");

console.log("Apples");

console.log("This is a statement");

console.log("This is also a statement");

Listing 4-5Adding JavaScript Statements in the main.js File in the src Folder

浏览器依次执行每条语句。在本例中,所有语句都只是将消息写入控制台。结果如下:

Hello
Apples
This is a statement
This is also a statement

定义和使用函数

当浏览器收到 JavaScript 代码时,它会按照定义的顺序执行其中包含的语句。这就是上一个示例中发生的情况。main.js文件中的语句被逐一执行,所有语句都向控制台写入一条消息,所有语句都按照它们在main.js中定义的顺序执行。您还可以将语句打包到一个函数中,直到浏览器遇到一个调用该函数的语句,该函数才会被执行,如清单 4-6 所示。

const myFunc = function () {
    console.log("This statement is inside the function");
};

console.log("This statement is outside the function");

myFunc();

Listing 4-6Defining a JavaScript Function in the main.js File in the src Folder

定义一个函数很简单:使用const关键字,后跟您想要给函数起的名字,再加上等号(=)和function关键字,再加上括号(()字符)。您希望函数包含的语句用大括号括起来(字符{})。

在清单中,我使用了名称myFunc,该函数包含一个向 JavaScript 控制台写入消息的语句。在浏览器到达另一个调用myFunc函数的语句之前,函数中的语句不会被执行,如下所示:

...
myFunc();
...

当您保存对main.js文件的更改时,更新的 JavaScript 代码将被发送到浏览器,在浏览器中执行并产生以下输出:

This statement is outside the function
This statement is inside the function

您可以看到函数内部的语句并没有立即执行,但是除了演示函数是如何定义的以外,这个例子并不是特别有用,因为函数是在定义后立即被调用的。当响应某种变化或事件(如用户交互)而调用函数时,函数会更有用。

您还可以定义函数,这样就不必显式地创建和分配变量,如清单 4-7 所示。

function myFunc() {

    console.log("This statement is inside the function");
}

console.log("This statement is outside the function");

myFunc();

Listing 4-7Defining a Function in the main.js File in the src Folder

代码的工作方式与清单 4-6 相同,但大多数开发人员更熟悉,这也是我在本书中通常定义函数的方式,因为它非常适合 Vue.js 开发。

使用现代 JavaScript 特性

近年来,JavaScript 已经现代化,增加了方便的语言特性,并对常见任务(如数组处理)可用的实用函数进行了大量扩展。并不是所有的浏览器都支持最新的特性,因此 Vue.js 开发工具包括 Babel 包,它负责将使用最新特性编写的 JavaScript 转换为可以依赖于在大多数主流浏览器中工作的代码。这意味着您能够享受现代开发体验,而无需关注处理浏览器之间的差异和跟踪每个浏览器支持的功能。这种翻译是特定于 JavaScript 语言的,它没有扩展到您可能想在应用中使用的更广泛的 API,比如我在第一章中使用的本地存储特性。对于这种类型的特性,您需要考虑您的应用必须到达的浏览器,以及您想要的 API 是否可用。一个很好的起点是caniuse.com,它将让您深入了解对一组广泛的 API 和相关特性的支持级别。

用参数定义函数

JavaScript 允许您为函数定义参数,如清单 4-8 所示。

function myFunc(name, weather) {
    console.log("Hello" + name + ".");
    console.log("It is" + weather + "today.");
}

myFunc("Adam", "sunny");

Listing 4-8Defining Functions with Parameters in the main.js File in the src Folder

我给myFunc函数添加了两个参数,称为nameweather。JavaScript 是一种动态类型语言,这意味着在定义函数时不必声明参数的数据类型。当我在本章后面讲述 JavaScript 变量时,我会回到动态类型。要调用带参数的函数,需要在调用函数时提供值作为参数,如下所示:

...
myFunc("Adam", "sunny");
...

该清单的结果如下:

Hello Adam.
It is sunny today.

使用默认和 Rest 参数

调用函数时提供的参数数量不需要与函数中的参数数量相匹配。如果调用函数时使用的参数少于它拥有的参数,那么任何没有提供值的参数的值都是undefined,这是一个特殊的 JavaScript 值。如果调用函数时使用的参数多于实际参数,那么多余的参数将被忽略。

这样做的结果是,您不能创建两个具有相同名称和不同参数的函数,并期望 JavaScript 根据您在调用函数时提供的参数来区分它们。这被称为多态性,尽管它在 Java 和 C#等语言中受支持,但在 JavaScript 中不可用。相反,如果您定义了两个同名的函数,那么第二个定义将替换第一个定义。

有两种方法可以修改函数,以响应函数定义的参数数量和用于调用函数的参数数量之间的不匹配。默认参数处理实参比参数少的情况,它们允许你为没有实参的参数提供默认值,如清单 4-9 所示。

function myFunc(name, weather = "raining") {

    console.log("Hello" + name + ".");
    console.log("It is" + weather + "today.");
}

myFunc("Adam");

Listing 4-9Using a Default Parameter in the main.js File in the src Folder

函数中的weather参数已被赋予默认值raining,如果仅使用一个参数调用该函数,将使用该值,产生以下结果:

Hello Adam.
It is raining today.

Rest 参数用于在用附加参数调用函数时捕获任何附加参数,如清单 4-10 所示。

function myFunc(name, weather, ...extraArgs) {

    console.log("Hello" + name + ".");
    console.log("It is" + weather + "today.");
    for (let i = 0; i < extraArgs.length; i++) {

        console.log("Extra Arg:" + extraArgs[i]);

    }

}

myFunc("Adam", "sunny", "one", "two", "three");

Listing 4-10Using a Rest Parameter in the main.js File in the src Folder

rest 参数必须是函数定义的最后一个参数,其名称以省略号为前缀(三个句点,...)。rest 参数是一个数组,任何额外的参数都将被赋给它。在清单中,该函数将每个额外的参数打印到控制台,产生以下结果:

Hello Adam.
It is sunny today.
Extra Arg: one
Extra Arg: two
Extra Arg: three

定义返回结果的函数

您可以使用return关键字从函数中返回结果。清单 4-11 显示了一个返回结果的函数。

function myFunc(name) {

    return ("Hello" + name + ".");

}

console.log(myFunc("Adam"));

Listing 4-11Returning a Result from a Function in the main.js File in the src Folder

这个函数定义了一个参数,并用它来产生一个结果。我调用函数并将结果作为参数传递给console.log函数,如下所示:

...
console.log(myFunc("Adam"));
...

请注意,您不必声明该函数将返回一个结果或表示结果的数据类型。该清单的结果如下:

Hello Adam.

将函数用作其他函数的参数

JavaScript 函数可以被视为对象,这意味着您可以使用一个函数作为另一个函数的参数,如清单 4-12 所示。

function myFunc(nameFunction) {

    return ("Hello" + nameFunction() + ".");

}

console.log(myFunc(function () {

    return "Adam";

}));

Listing 4-12Using a Function as an Arguments in the main.js File in the src Folder

myFunc函数定义了一个名为nameFunction的参数,它调用这个参数来获取插入到它返回的字符串中的值。我将一个返回Adam作为参数的函数传递给myFunc,它产生以下输出:

Hello Adam.

函数可以链接在一起,从小而容易测试的代码片段中构建更复杂的功能,如清单 4-13 所示。

function myFunc(nameFunction) {
    return ("Hello" + nameFunction() + ".");
}

function printName(nameFunction, printFunction) {

    printFunction(myFunc(nameFunction));

}

printName(function () { return "Adam" }, console.log);

Listing 4-13Chaining Functions Calls in the main.js File in the src Folder

此示例产生以下输出:

Hello Adam.

使用箭头功能

箭头函数——也称为胖箭头函数λ表达式——是定义函数的另一种方式,通常用于定义仅用作其他函数参数的函数。清单 4-14 用箭头函数替换了前面例子中的函数。

const myFunc = (nameFunction) => ("Hello" + nameFunction() + ".");

const printName = (nameFunction, printFunction) =>

    printFunction(myFunc(nameFunction));

printName(function () { return "Adam" }, console.log);

Listing 4-14Using Arrow Functions in the main.js File in the src Folder

这些函数与清单 4-13 中的函数执行相同的工作。箭头函数有三个部分:输入参数、等号和大于号(“箭头”),最后是函数结果。只有当 arrow 函数需要执行多条语句时,才需要关键字return和花括号。在这一章的后面有更多的箭头函数的例子,你会在整本书中看到它们的使用。

警告

箭头功能不能用于所有 Vue.js 功能。请密切注意后面章节中的示例,并使用所示的函数类型。

使用变量和类型

let关键字用于声明变量,也可以在一条语句中为变量赋值——与我在前面的例子中使用的const关键字相反,它创建一个不可修改的常量值。

当您使用letconst时,您创建的变量或常量只能在定义它们的代码区域中被访问,这被称为变量或常量的范围,如清单 4-15 所示。

function messageFunction(name, weather) {

    let message = "Hello, Adam";

    if (weather == "sunny") {

        let message = "It is a nice day";

        console.log(message);

    } else {

        let message = "It is" + weather + "today";

        console.log(message);

    }

    console.log(message);

}

messageFunction("Adam", "raining");

Listing 4-15Using let to Declare Variables in the main.js File in the src Folder

在这个例子中,有三个语句使用let关键字来定义一个名为message的变量。每个变量的范围限于定义它的代码区域,产生以下结果:

It is raining today
Hello, Adam

这似乎是一个奇怪的例子,但是还有另一个关键字可以用来声明变量:varletconst关键字是 JavaScript 规范中相对较新的补充,旨在解决var行为方式中的一些奇怪之处。清单 4-16 以清单 4-15 为例,将let替换为var

使用 Let 和 Const

对于您不希望更改的任何值,使用const关键字是一个很好的实践,这样,如果试图进行任何修改,您都会收到一个错误。然而,这是我很少遵循的一种做法——一部分是因为我仍然在努力适应不使用var关键字,另一部分是因为我用一系列语言编写代码,并且有一些我避免的功能,因为当我从一种语言切换到另一种语言时它们会绊倒我。如果你是 JavaScript 新手,那么我建议你试着正确使用constlet,避免步我后尘。

function messageFunction(name, weather) {
    var message = "Hello, Adam";

    if (weather == "sunny") {
        var message = "It is a nice day";

        console.log(message);
    } else {
        var message = "It is" + weather + "today";

        console.log(message);
    }
    console.log(message);
}

messageFunction("Adam", "raining");

Listing 4-16Using var to Declare Variables in the main.js File in the src Folder

当您保存列表中的更改时,您将看到以下结果:

It is raining today
It is raining today

问题是var关键字创建的变量的作用域是包含函数,这意味着所有对message的引用都是指同一个变量。这甚至会给有经验的 JavaScript 开发人员带来意想不到的结果,这也是引入更传统的let关键字的原因。

使用可变闭包

如果你在另一个函数内部定义一个函数——创建内部外部函数——那么内部函数能够访问外部函数的变量,使用一个叫做闭包的特性,就像这样:

function myFunc(name) {
    let myLocalVar = "sunny";
    let innerFunction = function () {

        return ("Hello" + name + ". Today is" + myLocalVar + ".");

    }

    return innerFunction();

}

console.log(myFunc("Adam"));

这个例子中的内部函数能够访问外部函数的局部变量,包括它的参数。这是一个强大的特性,意味着您不必在内部函数上定义参数来传递数据值,但是需要小心,因为当使用像counterindex这样的普通变量名时,很容易得到意外的结果,您可能没有意识到您正在重用外部函数中的变量名。

使用基本类型

JavaScript 定义了一组基本的原语类型:stringnumberboolean。这似乎是一个很短的列表,但是 JavaScript 设法将很多灵活性融入到这些类型中。

小费

我在这里简化。您可能会遇到另外三种原语。已经声明但没有赋值的变量是undefined,而null值用来表示一个变量没有值,就像其他语言一样。最后一个原语类型是Symbol,它是一个不可变的值,表示一个惟一的 ID,但是在编写本文时还没有广泛使用。

使用布尔值

boolean类型有两个值:truefalse。清单 4-17 显示了正在使用的两个值,但是这种类型在条件语句中使用时最有用,比如一个if语句。该清单中没有控制台输出。

let firstBool = true;
let secondBool = false;

Listing 4-17Defining boolean Values in the main.js File in the src Folder

使用字符串

您可以使用双引号或单引号字符来定义string值,如清单 4-18 所示。

let firstString = "This is a string";
let secondString = 'And so is this';

Listing 4-18Defining string Variables in the main.js File in the src Folder

您使用的引号字符必须匹配。例如,你不能用单引号开始一个字符串,然后用双引号结束。此列表没有控制台输出。JavaScript 为string对象提供了一组基本的属性和方法,其中最有用的在表 4-2 中有描述。

表 4-2

有用的字符串属性和方法

|

名字

|

描述

|
| --- | --- |
| length | 此属性返回字符串中的字符数。 |
| charAt(index) | 此方法返回包含指定索引处的字符的字符串。 |
| concat(string) | 此方法返回一个新字符串,该字符串将调用该方法的字符串和作为参数提供的字符串连接在一起。 |
| indexOf(term, start) | 该方法返回第一个索引,在该索引处term出现在字符串中,如果没有匹配,则返回-1。可选的start参数指定搜索的起始索引。 |
| replace(term, newTerm) | 该方法返回一个新字符串,其中所有的term实例都被替换为newTerm。 |
| slice(start, end) | 此方法返回包含起始和结束索引之间的字符的子字符串。 |
| split(term) | 这个方法将一个字符串分割成一个由term分隔的值数组。 |
| toUpperCase()``toLowerCase() | 这些方法返回所有字符都是大写或小写的新字符串。 |
| trim() | 此方法返回一个新字符串,其中所有的前导和尾随空白字符都已被删除。 |

使用模板字符串

一个常见的编程任务是将静态内容与数据值结合起来,以生成可以呈现给用户的字符串。传统的方法是通过字符串连接,这是我在本章的例子中一直使用的方法,如下所示:

...
let message = "It is" + weather + "today";
...

JavaScript 还支持模板字符串,它允许内联指定数据值,这有助于减少错误,带来更自然的开发体验。清单 4-19 展示了模板字符串的使用。

function messageFunction(weather) {
    let message = `It is ${weather} today`;
    console.log(message);
}

messageFunction("raining");

Listing 4-19Using a Template String in the main.js File in the src Folder

模板字符串以反斜杠(```js 字符)开始和结束,数据值由花括号表示,前面有一个美元符号。例如,这个字符串将变量weather的值合并到模板字符串中:

...
let message = `It is ${weather} today`;
...

```js

此示例产生以下输出:

It is raining today


#### 使用数字

`number`类型用于表示*整数*和*浮点*(也称为*实数*)。清单 4-20 提供了一个演示。

let daysInWeek = 7;
let pi = 3.14;
let hexValue = 0xFFFF;

Listing 4-20Defining number Values in the main.js File in the src Folder


您不必指定使用哪种号码。您只需表达您需要的值,JavaScript 就会相应地执行。在清单中,我定义了一个整数值、一个浮点值,并在一个值前面加上了`0x`来表示一个十六进制值。

## 使用 JavaScript 运算符

JavaScript 定义了一组非常标准的操作符。我在表 4-3 中总结了最有用的。

表 4-3

有用的 JavaScript 运算符

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作员

 | 

描述

 |
| --- | --- |
| `++, --` | 前或后递增和递减 |
| `+, -, *, /, %` | 加法、减法、乘法、除法、余数 |
| `<, <=, >, >=` | 小于,小于等于,大于,大于等于 |
| `==, !=` | 平等和不平等测试 |
| `===, !==` | 同一性和非同一性测试 |
| `&&, &#124;&#124;` | 逻辑 AND 和 OR (&#124;&#124;用于合并空值) |
| `=` | 分配 |
| `+` | 串并置 |
| `?:` | 三操作数条件语句 |

### 使用条件语句

许多 JavaScript 操作符与条件语句一起使用。在本书中,我倾向于使用`if/else`和`switch`语句。清单 4-21 展示了两者的用法,这对大多数开发者来说都是熟悉的。

let name = "Adam";

if (name == "Adam") {
console.log("Name is Adam");
} else if (name == "Jacqui") {
console.log("Name is Jacqui");
} else {
console.log("Name is neither Adam or Jacqui");
}

switch (name) {
case "Adam":
console.log("Name is Adam");
break;
case "Jacqui":
console.log("Name is Jacqui");
break;
default:
console.log("Name is neither Adam or Jacqui");
break;
}

Listing 4-21Using Conditional Statements in the main.js File in the src Folder


此示例产生以下结果:

Name is Adam
Name is Adam


### 相等运算符与相同运算符

等式和等式运算符特别值得注意。相等运算符将尝试将操作数强制(转换)为相同的类型来评估相等性。这是一个方便的特性,只要你意识到它正在发生。清单 4-22 展示了等式操作符的作用。

let firstVal = 5;
let secondVal = "5";

if (firstVal == secondVal) {
console.log("They are the same");
} else {
console.log("They are NOT the same");
}

Listing 4-22Using the Equality Operator in the main.js File in the src Folder


该示例的输出如下:

They are the same


JavaScript 将两个操作数转换成相同的类型,并对它们进行比较。本质上,相等运算符测试值是否相同,而不管它们的类型如何。如果你想测试确保值*和*的类型是相同的,那么你需要使用恒等运算符(`===`,三个等号,而不是两个等号的运算符),如清单 4-23 所示。

let firstVal = 5;
let secondVal = "5";

if (firstVal === secondVal) {

console.log("They are the same");

} else {
console.log("They are NOT the same");
}

Listing 4-23Using the Identity Operator in the main.js File in the src Folder


在本例中,identity 运算符将认为这两个变量是不同的。该运算符不强制类型。结果如下:

They are NOT the same


### 显式转换类型

字符串连接操作符(`+`)优先于加法操作符(还有`+`),这意味着 JavaScript 将优先于加法连接变量。这可能会造成混乱,因为 JavaScript 也会自由地转换类型以产生结果——而不总是预期的结果,如清单 4-24 所示。

let myData1 = 5 + 5;

let myData2 = 5 + "5";

console.log("Result 1:" + myData1);
console.log("Result 2:" + myData2);

Listing 4-24String Concatentation Operator Precedence in the main.js File in the src Folder


这些语句会产生以下结果:

Result 1: 10
Result 2: 55


第二种结果是引起混乱的那种。通过运算符优先级和过急类型转换的组合,原本应该是加法运算的操作被解释为字符串串联。为了避免这种情况,可以显式转换值的类型,以确保执行正确的操作,如以下部分所述。

#### 将数字转换为字符串

如果您正在处理多个数字变量,并希望将它们连接成字符串,那么您可以使用`toString`方法将数字转换成字符串,如清单 4-25 所示。

let myData1 = (5).toString() + String(5);

console.log("Result:" + myData1);

Listing 4-25Using the number.toString Method in the main.js File in the src Folder


注意,我将数值放在括号中,然后调用了`toString`方法。这是因为在调用`number`类型定义的方法之前,您必须允许 JavaScript 将文字值转换成`number`。我还展示了实现相同效果的另一种方法,即调用`String`函数,并将数值作为参数传入。这两种技术具有相同的效果,都是将一个`number`转换成一个`string`,这意味着`+`操作符用于字符串连接而不是加法。该脚本的输出如下:

Result: 55


还有一些其他的方法可以让你更好地控制一个数字如何被表示成一个字符串。我在表 4-4 中简要描述了这些方法。表格中显示的所有方法都由`number`类型定义。

表 4-4

有用的数字到字符串的方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `toString()` | 此方法返回一个表示以 10 为基数的数字的字符串。 |
| `toString(2)``toString(8)``toString(16)` | 此方法返回以二进制、八进制或十六进制表示法表示数字的字符串。 |
| `toFixed(n)` | 该方法返回一个表示小数点后有`n`位的实数的字符串。 |
| `toExponential(n)` | 该方法返回一个字符串,该字符串使用指数表示法表示一个数字,小数点前有一位数字,小数点后有`n`位数字。 |
| `toPrecision(n)` | 该方法返回一个字符串,该字符串表示一个具有`n`个有效数字的数字,如果需要,可以使用指数符号。 |

#### 将字符串转换为数字

补充技术是将字符串转换为数字,这样您就可以执行加法而不是连接。你可以用`Number`函数来实现,如清单 4-26 所示。

let firstVal = "5";
let secondVal = "5";

let result = Number(firstVal) + Number(secondVal);
console.log("Result:" + result);

Listing 4-26Converting Strings to Numbers in the main.js File in the src Folder


该脚本的输出如下:

Result: 10


`Number`函数解析字符串值的方式很严格,但是您可以使用另外两个更灵活的函数,它们会忽略后面的非数字字符。这些功能是`parseInt``parseFloat`。我已经在表 4-5 中描述了所有三种方法。

表 4-5

对数字方法有用的字符串

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `Number(str)` | 此方法分析指定的字符串以创建整数或实数值。 |
| `parseInt(str)` | 此方法分析指定的字符串以创建整数值。 |
| `parseFloat(str)` | 此方法分析指定的字符串以创建整数或实数值。 |

## 使用数组

JavaScript 数组的工作方式类似于大多数其他编程语言中的数组。清单 4-27 展示了如何创建和填充一个数组。

let myArray = new Array();

myArray[0] = 100;

myArray[1] = "Adam";

myArray[2] = true;

Listing 4-27Creating and Populating an Array in the main.js File in the src Folder


我通过调用`new Array()`创建了一个新数组。这创建了一个空数组,我将它赋给了变量`myArray`。在随后的语句中,我为数组中的不同索引位置赋值。(这个清单没有输出。)

在这个例子中有一些事情需要注意。首先,在创建数组时,我不需要声明数组中的项数。JavaScript 数组会自动调整大小以容纳任意数量的项目。第二点是,我不必声明数组将保存的数据类型。任何 JavaScript 数组都可以包含任何混合的数据类型。在这个例子中,我给数组分配了三个项目:一个`number`、一个`string`和一个`boolean`。

### 使用数组文本

array literal 样式允许您在一条语句中创建和填充一个数组,如清单 4-28 所示。

let myArray = [100, "Adam", true];

Listing 4-28Using the Array Literal Style in the main.js File in the src Folder


在这个例子中,我通过在方括号(`[``]`)之间指定我想要的数组中的项目,指定了应该给`myArray`变量分配一个新的数组。(这个清单中没有控制台输出。)

### 读取和修改数组的内容

使用方括号(`[``]`)读取给定索引处的值,将所需的索引放在括号之间,如清单 4-29 所示。

let myArray = [100, "Adam", true];

console.log(Index 0: ${myArray[0]});

Listing 4-29Reading the Data from an Array Index in the main.js File in the src Folder


只需给索引赋值,就可以修改 JavaScript 数组中任何位置的数据。就像常规变量一样,您可以在索引处切换数据类型,不会有任何问题。清单的输出如下所示:

Index 0: 100


清单 4-30 展示了如何修改一个数组的内容。

let myArray = [100, "Adam", true];

myArray[0] = "Tuesday";

console.log(Index 0: ${myArray[0]});

Listing 4-30Modifying the Contents of an Array in the main.js File in the src Folder


在这个例子中,我将一个`string`赋值给数组中的位置`0`,这个位置以前是由一个`number`持有的,并产生以下输出:

Index 0: Tuesday


### 枚举数组的内容

使用一个`for`循环或者使用`forEach`方法来枚举数组的内容,该方法接收一个被调用来处理数组中每个元素的函数。两种方法如清单 4-31 所示。

let myArray = [100, "Adam", true];

for (let i = 0; i < myArray.length; i++) {
console.log(Index ${i}: ${myArray[i]});
}

console.log("---");

myArray.forEach((value, index) => console.log(Index ${index}: ${value}));

Listing 4-31Enumerating the Contents of an Array in the main.js File in the src Folder


JavaScript `for`循环的工作方式与许多其他语言中的循环一样。使用`length`属性确定数组中有多少个元素。

传递给`forEach`方法的函数有两个参数:要处理的当前项的值和该项在数组中的位置。在这个清单中,我使用了一个 arrow 函数作为`forEach`方法的参数,这是它们擅长的一种用法(您将在本书中看到这种用法)。清单的输出如下所示:

Index 0: 100
Index 1: Adam
Index 2: true

Index 0: 100
Index 1: Adam
Index 2: true


### 使用扩展运算符

spread 运算符用于扩展数组,以便其内容可以用作函数参数。清单 4-32 定义了一个函数,该函数接受多个参数,并使用数组中的值调用它,使用或不使用 spread 运算符。

function printItems(numValue, stringValue, boolValue) {
console.log(Number: ${numValue});
console.log(String: ${stringValue});
console.log(Boolean: ${boolValue});
}

let myArray = [100, "Adam", true];

printItems(myArray[0], myArray[1], myArray[2]);

printItems(...myArray);

Listing 4-32Using the Spread Operator in the main.js File in the src Folder


spread 操作符是一个省略号(三个句点的序列),它导致数组被解包并作为单独的参数传递给`printItems`函数。

...
printItems(...myArray);
...


spread 操作符还可以很容易地将数组连接在一起,如清单 4-33 所示。

let myArray = [100, "Adam", true];
let myOtherArray = [200, "Bob", false, ...myArray];

myOtherArray.forEach((value, index) => console.log(Index ${index}: ${value}));

Listing 4-33Concatenating Arrays in the main.js File in the src Folder


使用 spread 操作符,我可以在定义`myOtherArray`时将`myArray`指定为一个项,结果是第一个数组的内容将被解包并作为项添加到第二个数组中。此示例产生以下结果:

Index 0: 200
Index 1: Bob
Index 2: false
Index 3: 100
Index 4: Adam
Index 5: true


### 使用内置数组方法

JavaScript `Array`对象定义了许多可以用来处理数组的方法,表 4-6 中描述了其中最有用的方法。

表 4-6

有用的数组方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

描述

 |
| --- | --- |
| `concat(otherArray)` | 此方法返回一个新数组,该数组将调用它的数组与指定为参数的数组连接起来。可以指定多个数组。 |
| `join(separator)` | 该方法将数组中的所有元素连接起来形成一个字符串。该参数指定用于分隔各项的字符。 |
| `pop()` | 此方法移除并返回数组中的最后一项。 |
| `shift()` | 此方法移除并返回数组中的第一个元素。 |
| `push(item)` | 此方法将指定的项追加到数组的末尾。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `reverse()` | 此方法返回一个新数组,该数组包含逆序排列的项。 |
| `slice(start,end)` | 此方法返回数组的一部分。 |
| `sort()` | 此方法对数组进行排序。可选的比较功能可用于执行自定义比较。 |
| `splice(index, count)` | 该方法从指定的`index`开始,从数组中移除`count`项。移除的项作为方法的结果返回。 |
| `unshift(item)` | 此方法在数组的开头插入一个新项。 |
| `every(test)` | 该方法为数组中的每一项调用`test`函数,如果函数为所有项返回`true`,则返回`true`,否则返回 false。 |
| `some(test)` | 如果为数组中的每一项调用`test`函数至少返回一次`true`,则该方法返回`true`。 |
| `filter(test)` | 该方法返回一个新数组,其中包含了`test`函数返回的`true`项。 |
| `find(test)` | 该方法返回数组中第一个项目,对于该项目,`test`函数返回`true`。 |
| `findIndex(test)` | 该方法返回数组中第一项的索引,对于该数组,`test`函数返回`true`。 |
| `forEach(callback)` | 这个方法为数组中的每一项调用`callback`函数,如前一节所述。 |
| `includes(value)` | 如果数组包含指定的值,这个方法返回`true`。 |
| `map(callback)` | 该方法返回一个新数组,其中包含为数组中的每一项调用`callback`函数的结果。 |
| `reduce(callback)` | 该方法返回通过调用回调函数为数组中的每一项生成的累计值。 |

由于表 4-6 中的许多方法返回一个新数组,这些方法可以链接在一起处理数据,如清单 4-34 所示。

let products = [
{ name: "Hat", price: 24.5, stock: 10 },
{ name: "Kayak", price: 289.99, stock: 1 },
{ name: "Soccer Ball", price: 10, stock: 0 },
{ name: "Running Shoes", price: 116.50, stock: 20 }
];

let totalValue = products
.filter(item => item.stock > 0)
.reduce((prev, item) => prev + (item.price * item.stock), 0);

console.log(Total value: $${totalValue.toFixed(2)});

Listing 4-34Processing an Array in the main.js File in the src Folder


我使用`filter`方法选择数组中`stock`值大于零的项目,并使用`reduce`方法确定这些项目的总值,产生以下输出:

Total value: $2864.99


## 使用对象

有几种方法可以在 JavaScript 中创建对象。清单 4-35 给出了一个简单的例子。

let myData = new Object();
myData.name = "Adam";
myData.weather = "sunny";

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-35Creating an Object in the main.js File in the src Folder


我通过调用`new Object()`创建一个对象,并将结果(新创建的对象)赋给一个名为`myData`的变量。一旦创建了对象,我就可以通过赋值来定义对象的属性,就像这样:

...

myData.name = "Adam";

...


在这个语句之前,我的对象没有名为`name`的属性。当语句执行后,该属性确实存在,并被赋予了值`Adam`。您可以通过将变量名和属性名与句点组合来读取属性值,如下所示:

...
console.log(Hello ${myData.name}.);
...


清单的结果如下:

Hello Adam.
Today is sunny.


### 使用对象文字

您可以使用对象文字格式在一个步骤中定义一个对象及其属性,如清单 4-36 所示。

let myData = {

name: "Adam",

weather: "sunny"

};

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-36Using the Object Literal Format in the main.js File in the src Folder


使用冒号(`:`)将您要定义的每个属性与其值分开,使用逗号(`,`)将属性分开。效果与前面的示例相同,清单的结果如下:

Hello Adam.
Today is sunny.


#### 使用变量作为对象属性

如果使用变量作为对象属性,JavaScript 将使用变量名作为属性名,变量值作为属性值,如清单 4-37 所示。

let name = "Adam"

let myData = {
name,

weather: "sunny"
};

console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);

Listing 4-37Using a Variable in an Object Literal in the main.js File in the src Folder


`name`变量用于给`myData`对象添加一个属性;该房产名为`name`,其价值为`Adam`。当您想要将一组数据值组合成一个对象时,这是一种有用的技术,您将在后面章节的示例中看到它的使用。清单 4-37 中的代码产生以下输出:

Hello Adam.
Today is sunny.


### 将函数用作方法

我最喜欢 JavaScript 的一个特性是可以向对象添加函数。定义在对象上的函数被称为*方法*。清单 4-38 展示了如何以这种方式添加方法。

let myData = {
name: "Adam",
weather: "sunny",
printMessages: function () {

    console.log(`Hello ${myData.name}.`);

    console.log(`Today is ${myData.weather}.`);

}

};

myData.printMessages();

Listing 4-38Adding Methods to an Object in the main.js File in the src Folder


在这个例子中,我使用了一个函数来创建一个名为`printMessages`的方法。注意,为了引用对象定义的属性,我必须使用`this`关键字。当一个函数作为一个方法使用时,该函数通过特殊变量`this`被隐式传递给调用该方法的对象作为参数。清单的输出如下所示:

Hello Adam.
Today is sunny.


您也可以不使用`function`关键字来定义方法,如清单 4-39 所示。

let myData = {
name: "Adam",
weather: "sunny",
printMessages() {

    console.log(`Hello ${myData.name}.`);

    console.log(`Today is ${myData.weather}.`);

}

};

myData.printMessages();

Listing 4-39Defining a Method in the main.js File in the src Folder


至少在我看来,这是一种更自然的定义方法的方式,我在本书的许多例子中都使用了这种方法。该清单的输出如下:

Hello Adam.
Today is sunny.


### 将属性从一个对象复制到另一个对象

在后面章节的例子中,我需要将所有属性从一个对象复制到另一个对象,以演示不同的 Vue.js 特性。JavaScript 为此提供了`Object.assign`方法,如清单 4-40 所示。

let myData = {
name: "Adam",
weather: "sunny",
printMessages() {
console.log(Hello ${myData.name}.);
console.log(Today is ${myData.weather}.);
}
};

let secondObject = {};

Object.assign(secondObject, myData);

secondObject.printMessages();

Listing 4-40Copying Object Properties in the main.js File in the src Folder


这个示例创建一个没有属性的新对象,并使用`Object.assign`方法从`myData`对象中复制属性及其值。此示例产生以下输出:

Hello Adam.
Today is sunny.


## 理解 JavaScript 模块

前一章中的例子包含在一个 JavaScript 文件中。这对于简单的例子来说没问题,但是复杂的 web 应用可能包含大量的代码和内容,这是不可能在一个文件中管理的。

为了将应用分成更易管理的块,JavaScript 支持*模块*,其中包含应用其他部分所依赖的 JavaScript 代码。在接下来的部分中,我将解释定义和使用模块的不同方式。

### 创建和使用简单的 JavaScript 模块

模块通常在它们自己的文件夹中定义,所以我创建了`src/maths`文件夹,并在其中添加了一个名为`sum.js`的文件,内容如清单 4-41 所示。

export default function(values) {
return values.reduce((total, val) => total + val, 0);
}

Listing 4-41The Contents of the sum.js File in the src/maths Folder


`sum.js`文件包含一个函数,该函数接受一组值,并使用 JavaScript array `reduce`方法对它们求和并返回结果。这个例子的重要之处不在于它做了什么,而在于函数是在自己的文件中定义的,这是模块的基本构造块。

清单 4-41 中使用了两个你在定义模块时会经常遇到的关键字。`export`关键字用于表示模块外部可用的特性。默认情况下,JavaScript 文件的内容是私有的,必须使用关键字`export`显式共享,然后才能在应用的其余部分使用。当模块包含单个特性时,使用`default`关键字,例如清单 4-41 中使用的函数。`export``default`关键字一起用于指定`sum.js`文件中的唯一函数可用于应用的其余部分。

#### 使用简单的 JavaScript 模块

使用模块需要另一个关键字:`import`关键字。在清单 4-42 中,我使用了`import`关键字来访问上一节中定义的函数,以便它可以在`main.js`文件中使用。

import additionFunction from "./maths/sum";

let values = [10, 20, 30, 40, 50];

let total = additionFunction(values);

console.log(Total: ${total});

Listing 4-42Using a Simple JavaScript Module in the main.js File in the src Folder


`import`关键字用于声明对模块的依赖。`import`关键字可以有多种用法,但这是您在处理自己创建的模块时最常使用的格式。图 4-3 示出了关键零件。

![img/465686_1_En_4_Fig3_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-vue2/img/465686_1_En_4_Fig3_HTML.jpg)4-3

声明对模块的依赖

`import`关键字后面是一个标识符,它是函数被使用时的名字;这个例子中的标识符是`additionFunction`。

### 小费

请注意,应用标识符的是`import`语句,这意味着使用模块中函数的代码选择它将被识别的名称,并且应用不同部分中同一模块的多个`import`语句可以使用不同的名称来引用同一函数。

`from`关键字跟在标识符后面,然后是模块的位置。密切关注位置很重要,因为不同的位置格式会产生不同的行为,如侧栏中所述。

在构建过程中,Vue.js 工具将检测到`import`语句,并将`sum.js`文件中的函数包含在发送给浏览器的 JavaScript 文件中,以便浏览器可以执行应用。在`import`语句中使用的标识符可以用来访问模块中的函数,就像使用本地定义的函数一样。

...
let total = additionFunction(values);
...


如果您检查浏览器的 JavaScript 控制台,您会看到清单 4-42 中的代码使用该模块的函数产生以下结果:

Total: 150


### 了解模块位置

模块的位置改变了构建工具在创建发送到浏览器的 JavaScript 文件时查找模块的方式。对于您自己定义的模块,位置被指定为相对路径,以一个或两个句点开始,表示该路径相对于当前文件或当前文件的父目录。在清单 4-42 中,位置以句点开始。

...
import additionFunction from "./maths/sum";
...


这个位置告诉构建工具在`sum`模块上有一个依赖项,这个模块可以在`maths`文件夹中找到,这个文件夹与包含导入语句的文件在同一个目录中(注意文件扩展名没有包含在这个位置中)。

指定相对于当前文件的路径的另一种方法是相对于项目文件夹导航,如下所示:

...
import additionFunction from "@/maths/sum";
...


当位置以`@`字符为前缀时,模块相对于`src`文件夹定位。

如果您省略了初始句点和`@`字符,那么`import`语句声明了对`node_modules`文件夹中的一个模块的依赖,该文件夹是在项目设置期间安装的包的安装位置。这种位置用于访问第三方包提供的功能,包括 Vue.js 包,这就是为什么您会在 Vue.js 项目中看到这样的语句:

...
import Vue from "vue";
...


这个`import`语句的位置不是以句点开始的,它将被解释为对项目的`node_modules`文件夹中的`vue`模块的依赖,该文件夹是提供核心 Vue.js 应用特性的包。

### 在模块中定义多个特征

模块可以包含一个以上的函数或值,这对相关特性的分组很有用。为了演示,我在`src/maths`文件夹中创建了一个名为`operations.js`的文件,并添加了清单 4-43 中所示的代码。

export function multiply(values) {
return values.reduce((total, val) => total * val, 1);
}

export function subtract(amount, values) {
return values.reduce((total, val) => total - val, amount);
}

export function divide(first, second) {
return first / second;
}

Listing 4-43The Contents of the operations.js File in the src/maths Folder


该模块定义了三个应用了关键字`export`的函数。与前面的例子不同,没有使用`default`关键字,每个函数都有自己的名称。当使用包含多个功能的模块时,需要不同的方法,如清单 4-44 所示。

import additionFunction from "./maths/sum";

import { multiply, subtract } from "./maths/operations";

let values = [10, 20, 30, 40, 50];

console.log(Sum: ${additionFunction(values)});

console.log(Multiply: ${multiply(values)});

console.log(Subtract: ${subtract(1000, values)});

Listing 4-44Using a Module in the main.js File in the src Folder


`import`关键字后面的括号包围了我要使用的函数列表,在本例中是用逗号分隔的`multiply``subtract`函数。我只声明对我需要的函数的依赖,对`divide`函数没有依赖,它在模块中定义,但在清单 4-44 中没有使用。此示例产生以下输出:

Sum: 150
Multiply: 12000000
Subtract: 850


#### 更改模块功能名称

这种方法的一个不同之处是,函数使用的名称现在是由模块定义的,而不是由使用函数的代码定义的。如果您不想使用模块提供的名称,那么您可以使用`as`关键字指定一个名称,如清单 4-45 所示。

import additionFunction from "./maths/sum";

import { multiply, subtract as minus } from "./maths/operations";

let values = [10, 20, 30, 40, 50];

console.log(Sum: ${additionFunction(values)});
console.log(Multiply: ${multiply(values)});

console.log(Subtract: ${minus(1000, values)});

Listing 4-45Using a Module Alias in the main.js File in the src Folder


我使用了`as`关键字来指定`subtract`函数在导入到`main.js`文件时应该被命名为`minus`。该清单产生与清单 4-44 相同的输出。

#### 导入整个模块

列出一个模块中所有函数的名称对于复杂的模块来说是无法控制的。一种更优雅的方法是导入一个模块提供的所有特性,并只使用您需要的特性,如清单 4-46 所示。

import additionFunction from "./maths/sum";

import * as ops from "./maths/operations";

let values = [10, 20, 30, 40, 50];

console.log(Sum: ${additionFunction(values)});

console.log(Multiply: ${ops.multiply(values)});

console.log(Subtract: ${ops.subtract(1000, values)});

Listing 4-46Importing an Entire Module in the main.js File in the src Folder


星号用于导入模块中的所有内容,后跟关键字`as`和一个标识符,通过它可以访问模块函数和值。在这种情况下,标识符是`ops`,这意味着`multiply``subtract``divide`功能可以作为`ops.multiply``ops.subtract``ops.divide`来访问。该清单产生与清单 4-44 相同的输出。

### 在一个模块中组合多个文件

模块可以跨越多个文件,并通过定义一个`index.js`文件来组合,该文件集合了模块将提供给应用其余部分的特性。我在`src/maths`文件夹中添加了一个`index.js`文件,代码如清单 4-47 所示。

import addition from "./sum";

export function mean(values) {
return addition(values)/values.length;
}

export { addition };
export * from "./operations";

Listing 4-47The Contents of the index.js File in the src/maths Folder


这个文件以与前面的例子相同的方式开始,用一个`import`语句声明对`sum.js`文件中的函数的依赖,这个函数用在名为`mean`的导出函数中。

该语句从`sum.js`文件中导出函数,以便可以在模块外部使用。我不需要为这个函数指定位置,因为它已经被导入了,在这种情况下,函数包含在大括号中。

最后一条语句导出在`operations.js`文件中定义的所有特征,而不需要先导入它们。当您想在模块外部使用特性,但不需要在`index.js`文件中直接使用它们时,这很有用。

使用一个`index.js`文件允许所有的特性在一条语句中被导入到`main.js`文件中,如清单 4-48 所示。

import * as math from "./maths";

let values = [10, 20, 30, 40, 50];

console.log(Sum: ${math.addition(values)});

console.log(Multiply: ${math.multiply(values)});

console.log(Subtract: ${math.subtract(1000, values)});

console.log(Mean: ${math.mean(values)});

Listing 4-48Importing an Entire Module in the main.js File in the src Folder


`import`的位置没有指定文件,这是一个简化的语句。在这个例子中,所有的模块特性都可以通过`math`标识符来访问,而不需要知道模块中的哪个文件定义了每个特性。此示例产生以下输出:

Sum: 150
Multiply: 12000000
Subtract: 850
Mean: 30


#### 从多文件模块导入单个特征

您不必从一个模块中导入所有的特性,即使它是用几个文件定义的。在清单 4-49 中,我修改了`import`语句,以便只导入示例中使用的函数,并且我使用了`as`关键字来演示函数可以被重命名。

import { addition as add, multiply, subtract, mean as average} from "./maths";

let values = [10, 20, 30, 40, 50];

console.log(Add: ${add(values)});

console.log(Multiply: ${multiply(values)});

console.log(Subtract: ${subtract(1000, values)});

console.log(Average : ${average(values)});

Listing 4-49Importing Specific Features in the main.js File in the src Folder


这个例子结合了前面清单中的特性,从模块中导入四个函数,并重命名其中的两个。此示例产生以下输出:

Add: 150
Multiply: 12000000
Subtract: 850
Average : 30


## 理解 JavaScript 承诺

一个*承诺*是一个将在未来某个时间点完成的后台活动。在本书中,承诺最常见的用法是使用 HTTP 请求来请求数据,这是异步执行的,当从 web 服务器收到响应时会产生一个结果。

### 理解异步操作问题

web 应用的经典异步操作是 HTTP 请求,通常用于获取用户需要的数据和内容。我将很快讨论 HTTP 请求,但是我需要一些更简单的东西来开始,所以我在`maths`模块的`index.js`文件中添加了一个函数来异步执行任务,如清单 4-50 所示。

import addition from "./sum";

export function mean(values) {
return addition(values)/values.length;
}

export { addition };
export * from "./operations";

export function asyncAdd(values) {

setTimeout(() => {

    let total = addition(values);

    console.log(`Async Total: ${total}`);

    return total;

}, 500);

}

Listing 4-50Adding a Function in the index.js File in the src/maths Folder


`setTimeout`函数在指定的延迟后异步调用一个函数。在清单中,`asyncAdd`函数接收一个参数,该参数在 500 毫秒的延迟后被传递给`addition`函数,为本章中的示例创建一个不会立即完成的后台操作,并表示更有用的操作,比如发出一个 HTTP 请求。在清单 4-51 中,我已经更新了`main.js`文件以使用`asyncAdd`函数。

import { asyncAdd } from "./maths";

let values = [10, 20, 30, 40, 50];

let total = asyncAdd(values);

console.log(Main Total: ${total});

Listing 4-51Performing Background Work in the main.js File in the src Folder


这个例子说明的问题是,`asyncAdd`函数的结果直到`main.js`文件中的语句被执行后才产生,这可以在浏览器的 JavaScript 控制台的输出中看到:

Main Total: undefined
Async Total: 150


浏览器执行`main.js`文件中的语句,并按照指示调用`asyncAdd`函数。浏览器移动到`main.js`文件中的下一条语句,该语句使用`asyncAdd`提供的结果向控制台写入一条消息——但这发生在异步任务完成之前,这就是为什么输出是`undefined`。异步任务随后完成,但是结果被`main.js`文件使用已经太晚了。

### 使用 JavaScript Promise

为了解决上一节中的问题,我需要一种机制,允许我观察异步任务,以便我可以等待它完成,然后写出结果。这就是 JavaScript promise 的作用,我已经将它应用于清单 4-52 中的`asyncAdd`函数。

import addition from "./sum";

export function mean(values) {
return addition(values)/values.length;
}

export { addition };
export * from "./operations";

export function asyncAdd(values) {
return new Promise((callback) => {

    setTimeout(() => {
        let total = addition(values);
        console.log(`Async Total: ${total}`);
        callback(total);

    }, 500);
});

}

Listing 4-52Using a Promise in the index.js File in the src/maths Folder


在这个例子中很难解开函数。`new`关键字用于创建一个`Promise`,它接受要观察的函数。观察到的函数提供了一个回调,当异步任务完成时调用该回调,并接受任务的结果作为参数。调用回调函数被称为*解析*承诺。

已经成为`asyncAdd`函数结果的`Promise`对象允许观察异步任务,以便在任务完成时执行后续工作,如清单 4-53 所示。

import { asyncAdd } from "./maths";

let values = [10, 20, 30, 40, 50];

asyncAdd(values).then(total => console.log(Main Total: ${total}));

Listing 4-53Observing a Promise in the main.js File in the src Folder


`then`方法接受一个函数,该函数将在使用回调时被调用。传递给回调的结果被提供给`then`函数。在这种情况下,这意味着在异步任务完成并产生以下输出之前,总数不会写入浏览器的 JavaScript 控制台:

Async Total: 150
Main Total: 150


### 简化异步代码

JavaScript 提供了两个关键字——`async``await`——支持异步操作,而不必直接使用承诺。在清单 4-54 中,我在`main.js`文件中应用了这些关键字。

### 警告

理解使用`async` / `await`不会改变应用的行为方式是很重要的。操作仍然是异步执行的,直到操作完成,结果才可用。这些关键字只是为了简化异步代码的工作,这样你就不必使用`then`方法了。

import { asyncAdd } from "./maths";

let values = [10, 20, 30, 40, 50];

async function doTask() {

let total = await asyncAdd(values);

console.log(`Main Total: ${total}`);

}

doTask();

Listing 4-54Using async and await in the main.js File in the src Folder


这些关键字只能应用于函数,这就是为什么我在清单中添加了`doTask`函数。`async`关键字告诉 JavaScript 这个函数依赖于需要承诺的功能。在调用返回承诺的函数时使用`await`关键字,其作用是将提供的结果分配给`Promise`对象的回调,然后执行后面的语句,产生以下结果:

Async Total: 150
Main Total: 150


## 摘要

在这一章中,我提供了一个关于 JavaScript 的简单入门,重点放在核心功能上,它将帮助你开始 Vue.js 开发。在下一章中,我将开始构建一个更加复杂和现实的项目,名为 SportsStore。

# 五、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`](https://github.com/Apress/pro-vue-js-2) 获得。

```js
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 服务进行存储。在下一章中,我将增加应用必须处理的数据量,并开始管理功能的工作。

七、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 中显示的内容。

img/465686_1_En_7_Fig1_HTML.jpg

图 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 所示的结果。

img/465686_1_En_7_Fig2_HTML.jpg

图 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 所示。

img/465686_1_En_7_Fig3_HTML.jpg

图 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 所示。结果显示在页面上,可以按类别过滤。

img/465686_1_En_7_Fig4_HTML.jpg

图 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 对象将只包含一个设置为falsesuccess属性,如下所示:

{
  "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 请求,如果请求成功,则设置authenticatedjwt状态属性。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 所示。

img/465686_1_En_7_Fig5_HTML.jpg

图 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 所示。

img/465686_1_En_7_Fig6_HTML.jpg

图 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

img/465686_1_En_7_Fig7_HTML.jpg

图 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 所示。

img/465686_1_En_7_Fig8_HTML.jpg

图 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 中显示的内容。

img/465686_1_En_8_Fig1_HTML.jpg

图 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过程来重新创建所有的测试数据。这将重置数据并放弃您所做的任何更改、添加或删除。

img/465686_1_En_8_Fig2_HTML.jpg

图 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 所示。

img/465686_1_En_8_Fig3_HTML.jpg

图 8-3

编辑产品

要创建产品,请单击“创建产品”按钮,填充表单,然后单击“存储产品”按钮。你会在页面顶部看到新产品,如图 8-4 所示。

img/465686_1_En_8_Fig4_HTML.jpg

图 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 所示。

img/465686_1_En_8_Fig5_HTML.jpg

图 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

img/465686_1_En_8_Fig6_HTML.jpg

图 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 应用的特性是如何深入工作的。

九、了解 Vue.js

当你开始使用 Vue.js 时,很容易被淹没,因为有很多事情正在进行,并且不是所有的事情都有直接的意义。这一章解释了 Vue.js 是如何工作的,并演示了其中没有魔法——当我在接下来的章节中深入研究各个 Vue.js 特性的细节时,记住这一点会很有帮助。表 9-1 总结了本章内容。

表 9-1

章节总结

|

问题

|

解决办法

|

列表

|
| --- | --- | --- |
| 创建一个没有 Vue.js 的简单 web 应用 | 使用 DOM API | six |
| 使用 Vue.js 创建一个简单的 web 应用 | 创建一个 Vue 对象并用eltemplate属性对其进行配置 | seven |
| 向用户呈现数据值 | 定义数据属性并使用数据绑定 | eight |
| 通过事件响应用户交互 | 使用v-on指令 | nine |
| 生成需要计算的数据值 | 定义计算属性 | Ten |
| 创建可重用的应用功能单元 | 定义和应用组件 | 11–13 |
| 将组件的模板定义为 HTML | 使用模板元素 | 14–16 |

为本章做准备

为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 9-1 中所示的命令。这个命令依赖于我在第一章中描述的工具,在你能够创建项目之前,你需要完成那一章中的步骤。

小费

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

vue create nomagic

Listing 9-1Creating the Example Project

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

注意

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

一旦创建了项目,将名为vue.config.js的文件添加到nomagic文件夹中,其内容如清单 9-2 所示。这个文件用于配置 Vue.js 开发工具,我会在第十章中描述。

module.exports = {
    runtimeCompiler: true
}

Listing 9-2The Contents of the vue.config.js File in the nomagic Folder

添加引导 CSS 框架

nomagic文件夹中运行清单 9-3 中所示的命令,将引导 CSS 包添加到项目中。这是 CSS 框架,我将用它来设计本章中 HTML 内容的样式。

npm install bootstrap@4.0.0

Listing 9-3Adding the Bootstrap Package

一次安装完成后,打开src文件夹中的main.js文件,添加清单 9-4 所示的语句。

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

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

Vue.config.productionTip = false

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

Listing 9-4Adding Bootstrap in the main.js File in the src Folder

该语句在发送到浏览器的内容中包含引导 CSS 样式。

运行示例应用

清单 9-1 中的命令创建的项目包括 Vue.js 开发所需的工具。我将在第十章中更详细地解释如何使用这些工具,但是要开始开发过程,运行nomagic文件夹中清单 9-5 中所示的命令。

npm run serve

Listing 9-5Starting the Development Tools

开发 HTTP 服务器将在初始设置阶段后启动。打开一个新的浏览器窗口并导航到http://localhost:8080,您将看到如图 9-1 所示的占位符内容。

img/465686_1_En_9_Fig1_HTML.jpg

图 9-1

运行示例应用

使用 DOM API 创建应用

我将从根本不使用 Vue.js 创建一个简单的 web 应用开始,然后演示如何使用基本的 Vue.js 特性创建相同的功能。

为此,我将使用main.js文件,它通常包含初始化 Vue.js 应用的 JavaScript 代码。main.js文件只是一个 JavaScript 文件,这意味着我可以删除配置 Vue.js 的代码,用其他语句替换。

在清单 9-6 中,我用一系列使用域对象模型(DOM) API 的 JavaScript 语句替换了main.js文件的默认内容。浏览器提供的 DOM API 允许 JavaScript 访问 HTML 文档及其内容,它是所有 web 应用的基础。

require('../node_modules/bootstrap/dist/css/bootstrap.min.css')

let counter = 1;

let container = document.createElement("div");

container.classList.add("text-center", "p-3");

let msg = document.createElement("h1");

msg.classList.add("bg-primary", "text-white", "p-3");

msg.textContent = "Button Not Pressed";

let button = document.createElement("button");

button.textContent = "Press Me";

button.classList.add("btn", "btn-secondary");

button.onclick = () => msg.textContent = `Button Presses: ${counter++}`;

container.appendChild(msg);

container.appendChild(button);

let app = document.getElementById("app");

app.parentElement.replaceChild(container, app);

Listing 9-6Replacing the Contents of the main.js File in the src Folder

不要担心理解这个代码的细节。在大多数 Vue.js 项目中,您不需要直接使用 DOM API。这个例子的目的是证明main.js文件是一个普通的 JavaScript 文件,它包含的代码可以访问浏览器提供的标准特性。

您用清单 9-6 中的命令启动的开发工具会自动检测项目文件的变更。这些更改被编译、打包成一个包含应用所有功能的文件,并自动发送到浏览器,这意味着只要你将更改保存到main.js文件,浏览器就会更新,你会看到如图 9-2 所示的内容。向用户显示初始消息,当按钮被按下时,该消息被计数器代替。每按一次按钮,计数器就增加一次。

img/465686_1_En_9_Fig2_HTML.jpg

图 9-2

直接使用域对象模型

理解 DOM API 应用如何工作

当执行main.js文件中的代码时,我使用 DOM API 创建一个包含一个h1元素和一个button元素的div元素。我将这些元素添加到对应于由引导 CSS 框架定义的样式的类中,引导 CSS 框架设置它们的外观。然后,我设置了一个事件监听器,通过更新计数器并在h1元素中显示一条消息来响应对button元素的点击。

一旦所有这些都完成了,我在 HTML 文档中找到 ID 为app的现有元素,并用我创建的div元素替换它。

...
let app = document.getElementById("app");
app.parentElement.replaceChild(container, app);
...

这就是我向用户展示示例应用的方式。当浏览器向http://localhost:8000发送请求时,开发 HTTP 服务器用项目的index.html文件的内容进行响应,该文件包含以下元素:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>nomagic</title>
  </head>
  <body>
    <div id="app"></div>

  </body>
</html>

我突出显示了 JavaScript 代码替换的元素,这是内容显示给用户的方式。

当开发服务器对浏览器的 HTTP 请求产生响应时,它会自动插入一个script元素,该元素加载包含所有项目 JavaScript 代码的 JavaScript 文件,您可以通过在浏览器窗口中右键单击并从弹出菜单中选择 View Page Source 来查看该文件。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>nomagic</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="text/javascript" src="/app.js"></script>

  </body>
</html>

提供的 JavaScript 文件包含main.js代码和它所依赖的任何代码。浏览器执行 JavaScript 文件中的语句,这导致我以编程方式生成的内容被插入到 HTML 文档中。

index.html文件中的元素和由main.js文件中的代码生成的元素组成的组合 HTML,可以通过在浏览器窗口中右键单击并从弹出窗口中选择 Inspect 来查看,这将显示使用 DOM API 创建的 HTML 文档的实时视图。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>nomagic</title>
  </head>
  <body>
    <div class="text-center p-3">

      <h1 class="bg-primary text-white p-3">Button Not Pressed</h1>

      <button class="btn btn-secondary">Press Me</button>

    </div>

    <script type="text/javascript" src="/app.js"></script>

  </body>
</html>

结果并不特别令人印象深刻,但是它证明了 JavaScript 代码可以用来访问 DOM API 来替换 HTML 文档中的元素、创建新内容以及响应用户交互。正如您将看到的,这些是任何 web 应用的基础——包括那些使用 Vue.js 创建的应用。

创建 Vue 对象

Vue.js 应用也使用 DOM API 来创建 HTML 内容、响应事件和更新数据值。不同的是,Vue.js 采用的方法更优雅,更容易理解,可伸缩性也更好。

在上一节中,我使用了main.js文件作为让浏览器执行 JavaScript 代码的便捷方式,但它在 Vue.js 应用中的常规用途是创建一个Vue对象,这是 Vue.js 提供的特性的入口点。在清单 9-7 中,我用创建Vue对象的语句替换了 main.js 文件中的 DOM API 代码,将main.js文件返回到其预期用途。

require('../node_modules/bootstrap/dist/css/bootstrap.min.css')

import Vue from "vue"

new Vue({

    el: "#app",

    template: `<div class="text-center p-3">

                   <h1 class="bg-secondary text-white p-3">

                       Vue: Button Not Pressed

                   </h1>

                   <button class="btn btn-secondary">

                        Press Me

                   </button>

               </div>`

});

Listing 9-7Creating a Vue Object in the main.js File in the src Folder

vue模块导入Vue对象需要使用import语句,该模块是在创建项目时下载并安装到node_modules文件夹中的。使用new关键字创建一个Vue对象,构造函数接受一个配置对象,该对象的属性提供控制应用行为的设置,并定义它呈现给用户的内容。

在这个例子中,有两个配置属性:eltemplateVue对象使用el属性来标识index.html中的元素,该元素将被替换以显示应用内容。template属性用于向 Vue.js 提供 HTML 内容,该内容将替换由el属性匹配的元素。正如您所记得的,这些是我在上一节中直接使用 DOM API 时必须手动执行的任务。

在清单 9-7 中,我将el属性设置为#app,这将选择id属性设置为app的元素。我设置了template属性,这样它就包含了我在清单 9-6 中以编程方式创建的 HTML 元素的相同结构,优点是我可以直接编写 HTML,而不是使用 JavaScript 语句创建它们。配置对象是使用 JavaScript 定义的,这意味着我必须将 HTML 内容表示为 JavaScript 字符串。我使用了反勾字符(```js 字符),这样我可以将字符串分割成多行,使其更容易阅读。

警告

只有当您覆盖了清单 9-2 中所示的项目设置时,您才可以使用template属性。默认情况下,处理模板字符串的功能在 Vue.js 项目中是禁用的。

当保存对main.js文件的更改时,浏览器将重新加载并显示如图 9-3 所示的内容。如图所示,我更改了显示在h1元素中的消息及其背景颜色,使其明显地显示出已经发生了变化。

img/465686_1_En_9_Fig3_HTML.jpg

图 9-3

使用 Vue 对象

向 Vue 对象添加数据

我的Vue对象向用户显示 HTML 内容,但这只是我需要的功能的一部分。下一步是添加一些数据,并让 Vue.js 显示给用户。在清单 9-8 中,我在应用中添加了一个名为counter的变量,并修改了template字符串,以便显示给用户。

require('../node_modules/bootstrap/dist/css/bootstrap.min.css')

import Vue from "vue"

new Vue({
    el: "#app",
    template: `<div class="text-center p-3">
                   <h1 class="bg-secondary text-white p-3">
                       Button Presses: {{ counter }}

                   </h1>
                   <button class="btn btn-secondary">
                        Press Me
                   </button>
               </div>`,
    data: {

        counter: 0

    }

});

Listing 9-8Adding a Variable in the main/js File in the src Folder

```js

我向`Vue`对象的配置对象添加了一个`data`属性。这个对象定义了一个`counter`属性,初始值为零。为了向用户显示计数器值,我在`h1`元素中使用了一个简单的数据绑定。

...

Button Presses: {{ counter }}

...

数据绑定是 Vue.js 的一个重要特性,它提供了`Vue`对象的`template`内容和它的`data`对象之间的联系。当 Vue.js 显示`template`内容时,它会查找数据绑定,将它们作为 JavaScript 表达式进行评估,并将结果包含在显示给用户的 HTML 中。在这种情况下,数据绑定只是一个`data`对象属性的名称,结果是`counter`属性的值被添加到`h1`元素的内容中。

数据是*活动的*或*反应的*,这意味着`counter`属性的值的变化将自动反映在`h1`元素的内容中。当`Vue`对象被创建时,它处理`data`对象并替换属性,这样它就可以检测到何时有变化。

您可以使用 Vue Devtools 查看数据反应的效果。打开 F12 开发者工具窗口,选择 Vue 选项卡,点击`<Root>`项。在右窗格中,将鼠标移动到`counter`项目上,点击`+`符号以增加数值。Vue.js 检测新的`counter`值并再次评估模板中的代码,产生如图 9-4 所示的结果。(如果您尚未安装 Vue Devtools 扩展,请参见 [`https://github.com/vuejs/vue-devtools`](https://github.com/vuejs/vue-devtools) 获取安装说明。)

![img/465686_1_En_9_Fig4_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-frontend-framework-zh/raw/master/docs/pro-vue2/img/465686_1_En_9_Fig4_HTML.jpg)9-4

更改反应数据变量

### 添加事件处理程序

在 Vue.js 中重新创建 DOM API 应用的下一步是在单击`button`时自动增加`counter`的值。当我使用 DOM API 时,我能够使用 JavaScript 语句直接设置事件处理函数,但是对于`Vue`对象需要不同的方法,如清单 9-9 所示。

require('../node_modules/bootstrap/dist/css/bootstrap.min.css')

import Vue from "vue"

new Vue({
el: "#app",
template: `



Button Presses: {{ counter }}


Listing 9-15The Contents of the App.html File in the src Folder


在清单 9-16 中,我删除了`template`元素的内容,并添加了一个`src`属性,告诉 Vue.js 可以在 HTML 文件中找到组件的内容。

posted @ 2024-10-05 17:10  绝不原创的飞龙  阅读(23)  评论(0)    收藏  举报
努力加载评论中...
点击右上角即可分享
微信分享提示