VueJS2-高级教程-全-
VueJS2 高级教程(全)
原文:Pro Vue.js 2
一、您的第一个 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)中的布局,如果您选择了不同的编辑器,您可能会看到项目内容的呈现略有不同。
图 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 所示的占位符内容。(占位符内容会随着开发工具新版本的发布而变化,所以如果您没有看到完全相同的内容,也不用担心。)
图 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 结合了template
、script
和style
元素来创建如图 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 所示的结果。
图 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 所示的内容。如果您看到文本,但它没有样式,然后手动重新加载浏览器窗口。
图 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 所示的内容。
图 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
元素的内容设置为被处理对象的action
和done
属性。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
元素,数据绑定显示action
和done
属性的值。
当您保存对App.vue
文件的更改时,开发工具将更新应用并重新加载浏览器,以在图 1-6 中显示结果。附加的div
元素和它们被分配到的类为内容创建了一个网格布局,并且不是 Vue.js 特性的一部分。
图 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 所示。
图 1-7
使用指令显示复选框
这种类型的指令会创建一个双向数据绑定,这意味着当您更改 input 元素时,Vue.js 会更新相应的数据值。通过选中和取消选中其中一个复选框,您可以看到这是如何工作的。每做一次改变,相邻文本数据绑定显示的文本也会改变,如图 1-8 所示。
图 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 所示的结果。
图 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 按钮,您会看到一个新的待办事项出现在列表中。
图 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
的全局对象提供的,该对象定义了getItem
和setItem
方法,并且由浏览器提供。
小费
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 所示。
图 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-if
和v-else
指令用于有条件地显示元素,我用它们在tasks
数组中没有项目时显示一条消息,或者显示任务列表。我还添加了一个button
元素,并使用v-on
指令来处理click
事件,过滤掉已完成的待办事项,然后存储剩余的对象。当您将更改保存到App.vue
文件时,应用将重新加载。如果选中隐藏已完成任务复选框并点击删除已完成按钮,您将看到如图 1-12 所示的结果。
图 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 中的template
和script
元素替换了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 所示的内容。
图 3-1
运行示例应用
理解 HTML 元素
HTML 的核心是元素,它告诉浏览器 HTML 文档的每个部分代表什么样的内容。以下是示例 HTML 文档中的一个元素:
...
<h4 class="bg-primary text-white text-center p-2">
Adam's To Do List
</h4>
...
如图 3-2 所示,这个元素有几个部分:开始标签、结束标签、属性和内容。
图 3-2
HTML 元素的剖析
这个元素的名称(也称为标签名称或者仅仅是标签)是h4
,它告诉浏览器标签之间的内容应该被当作一个头。有一系列的头元素,从h1
到h6
,其中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 所示。
图 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 样式的细节。
图 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-primary
、text-white
、text-center
和p-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
,它用于配置button
和a
元素,使它们显示为按钮,其颜色与主上下文中的其他元素一致。其中一些上下文类必须与配置元素基本样式的其他类结合使用,比如与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
用于无间距,或1
、2
、3
、4
或5
用于增加数量)。如果没有字母来指定边缘,则边距或填充将应用于所有边缘。为了帮助将这个模式放在上下文中,添加了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 显示用表格代替网格显示待办事项的结果。
图 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
类应用于包含label
和input
元素的div
元素来实现的,其中input
元素被分配给form-control
类。Bootstrap 对元素进行样式化,使label
显示在input
元素上方,而input
元素占据 100%的可用水平空间,如图 3-6 所示。
图 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 |
| 定义变量和常数 | 使用let
和const
关键字 | 15, 16 |
| 使用 JavaScript 基本类型 | 使用string
、number
或boolean
关键字 | 17, 18, 20 |
| 定义包含其他值的字符串 | 使用模板字符串 | Nineteen |
| 有条件地执行语句 | 使用if
、else
和switch
关键字 | 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
并使用async
和await
关键字 | 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.js
和package.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 所示的空窗口。
图 4-1
运行示例应用
如果你打开浏览器的 F12 开发工具并检查控制台选项卡,你会看到清单 4-2 中的语句产生了一个简单的结果,如图 4-2 所示。
图 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
函数添加了两个参数,称为name
和weather
。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
关键字相反,它创建一个不可修改的常量值。
当您使用let
或const
时,您创建的变量或常量只能在定义它们的代码区域中被访问,这被称为变量或常量的范围,如清单 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
这似乎是一个奇怪的例子,但是还有另一个关键字可以用来声明变量:var
。let
和const
关键字是 JavaScript 规范中相对较新的补充,旨在解决var
行为方式中的一些奇怪之处。清单 4-16 以清单 4-15 为例,将let
替换为var
。
使用 Let 和 Const
对于您不希望更改的任何值,使用const
关键字是一个很好的实践,这样,如果试图进行任何修改,您都会收到一个错误。然而,这是我很少遵循的一种做法——一部分是因为我仍然在努力适应不使用var
关键字,另一部分是因为我用一系列语言编写代码,并且有一些我避免的功能,因为当我从一种语言切换到另一种语言时它们会绊倒我。如果你是 JavaScript 新手,那么我建议你试着正确使用const
和let
,避免步我后尘。
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"));
这个例子中的内部函数能够访问外部函数的局部变量,包括它的参数。这是一个强大的特性,意味着您不必在内部函数上定义参数来传递数据值,但是需要小心,因为当使用像counter
或index
这样的普通变量名时,很容易得到意外的结果,您可能没有意识到您正在重用外部函数中的变量名。
使用基本类型
JavaScript 定义了一组基本的原语类型:string
、number
、boolean
。这似乎是一个很短的列表,但是 JavaScript 设法将很多灵活性融入到这些类型中。
小费
我在这里简化。您可能会遇到另外三种原语。已经声明但没有赋值的变量是undefined
,而null
值用来表示一个变量没有值,就像其他语言一样。最后一个原语类型是Symbol
,它是一个不可变的值,表示一个惟一的 ID,但是在编写本文时还没有广泛使用。
使用布尔值
boolean
类型有两个值:true
和false
。清单 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>
|
操作员
|
描述
|
| --- | --- |
| `++, --` | 前或后递增和递减 |
| `+, -, *, /, %` | 加法、减法、乘法、除法、余数 |
| `<, <=, >, >=` | 小于,小于等于,大于,大于等于 |
| `==, !=` | 平等和不平等测试 |
| `===, !==` | 同一性和非同一性测试 |
| `&&, ||` | 逻辑 AND 和 OR (||用于合并空值) |
| `=` | 分配 |
| `+` | 串并置 |
| `?:` | 三操作数条件语句 |
### 使用条件语句
许多 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 示出了关键零件。

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

图 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 文件中找到组件的内容。
Listing 9-16Specifying an HTML File in the App.vue File in the src Folder
## 摘要
在这一章中,我解释了 Vue.js 使用相同的 DOM API 来创建 web 应用,任何使用标准 JavaScript 的开发人员都可以访问这些应用,强调了 Vue.js 的工作方式没有任何神秘或神奇之处。正如您将在接下来的章节中看到的,虽然您可以直接使用 DOM API,但 Vue.js 提供了一组引人注目的特性和出色的开发人员体验,从而带来了更优雅、可管理和可伸缩的特性。在下一章,我将解释 Vue.js 项目的结构,并解释开发工具是如何工作的。
# 十、了解 Vue.js 项目和工具
在第九章中,我简要描述了 Vue.js 应用是如何工作的,以便为本书的其余部分提供上下文,作为该过程的一部分,我使用`@vue-cli`包创建了一个项目,然后使用它包含的工具。在这一章中,我将解释这种 Vue.js 项目是如何构建的,以及每个工具的作用。表 10-1 将这一章放在上下文中。
表 10-1
将 Vue.js 项目放在上下文中
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
问题
|
回答
|
| --- | --- |
| 它们是什么? | 用`@vue-cli`包创建的项目是为复杂应用的开发而设计的。 |
| 它们为什么有用? | 这种项目包括一组工具,这些工具简化了 Vue.js 的开发,并使得轻松使用 Vue.js 提供的一些高级功能成为可能。 |
| 它们是如何使用的? | 使用`@vue/cli`包创建项目,并回答一系列问题以确定项目的初始内容。 |
| 有什么陷阱或限制吗? | 如果你只是在用 Vue.js 做实验,这种项目可能是多余的。 |
| 有其他选择吗? | 您可以不使用`@vue/cli`包创建自己的项目,在这种情况下,您可以自由组装自己的工具链,尽管这可能是一个耗时的过程。 |
表 10-2 总结了本章内容。
表 10-2
章节总结
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"></colgroup>
|
问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 创建新项目 | 使用`vue create`命令,选择您的应用需要的特性 | one |
| 启动开发工具 | 使用`npm run serve`命令 | 2-8 |
| 避免常见的代码和内容错误 | 使用棉绒功能 | 9–11 |
| 调试应用 | 使用浏览器 JavaScript 调试器的 Vue Devtools 浏览器扩展 | Twelve |
| 更改开发工具的配置 | 将包含所需设置的`.vue.config.js`文件添加到项目中 | Thirteen |
| 为部署准备应用 | 使用`npm run build`命令 | 14–19 |
## 创建 Vue.js 开发项目
现代的客户端开发框架都有自己的开发工具,Vue.js 也不例外。这意味着您可以创建一个项目,并使用专门为 Vue.js 应用创建的工具开始开发,这些工具已经过大型活跃社区的全面测试。
您在第一章中安装的`@vue/cli`包是由 Vue.js 团队提供的,用于简化 Vue.js 项目的创建并安装所有需要的开发和构建工具。运行清单 10-1 中的命令,使用`vue create`命令创建一个新项目,该命令由`@vue/cli`包提供。
### 注意
在撰写本文时,`@vue/cli`包已经发布了测试版。在最终发布之前可能会有一些小的变化,但是核心特性应该保持不变。有关任何突破性变化的详细信息,请查看本书的勘误表,可在 [`https://github.com/Apress/pro-vue-js-2`](https://github.com/Apress/pro-vue-js-2) 获得。
```js
vue create projecttools
Listing 10-1Creating a New Project
vue create
命令使用一个交互过程来选择创建项目的选项,并会提示您如图 10-1 所示。
图 10-1
选择项目设置
文字可能很难从图像中读出,但是vue create
命令会要求您选择一个预设。有一个可用的预置——称为默认——选择在前面章节中使用的相同配置。这是我在本书剩余部分使用的配置,因为它让我演示不同的组件是如何添加到项目中的。在方便的时候使用项目工具没有错,只要你明白它们在幕后为你做了什么。另一个选项是手动选择您需要的功能。使用箭头键选择手动选择功能选项并按 Enter 键,您将看到如图 10-2 所示的功能列表。
图 10-2
可供手动选择的项目功能
使用箭头键在功能列表中上下导航,使用空格键可以打开和关闭这些功能。表 10-3 描述了可用的特性。我会在本章或后面的章节中描述与 Vue.js 开发直接相关的特性。对于其他功能,我已经包括了一个提供更多细节的 URL。
表 10-3
Vue.js 项目功能
|名字
|
描述
|
| --- | --- |
| 巴比伦式的城市 | Babel 特性负责将 JavaScript 源代码翻译成一种可以被旧浏览器执行的形式,这在“使用开发工具”一节中有描述。 |
| 以打字打的文件 | TypeScript 是 JavaScript 的超集,增加了静态类型等有用的功能,这使得 JavaScript 更像 C#或 Java。Vue.js 开发不需要 TypeScript,但是如果您发现 JavaScript 很难使用,它会很有帮助。详见 https://vuejs.org/v2/guide/typescript.html
。 |
| PWA 支持 | 渐进式 web 应用是与常规应用一起呈现给用户的 web 应用,它依赖于一种称为服务工作者的 JavaScript 功能,在没有连接时提供功能。pwa 不是 Vue.js 特有的,虽然对 pwa 的支持正在改善,但这是一项我在本书的这个版本中没有提到的技术,因为它对大多数项目来说还不够成熟。详见developer . Mozilla . org/en-US/Apps/Progressive
。 |
| 路由器 | 此功能安装 Vue 路由器包,该包用于使用浏览器的 URL 向大型应用添加结构。你可以在本书第一部分的 SportsStore 应用中看到正在使用的路由,我在第 23–25 章中详细描述了 Vue Router 提供的特性。 |
| 武契特 | 此功能安装用于创建共享数据存储的 Vuex 包。你可以在本书第一部分的 SportsStore 应用中看到 Vuex 的使用,我在第二十章详细描述了它的特性。 |
| CSS 预处理程序 | CSS 预处理程序,如 Sass 和 Less,使编写复杂的 CSS 样式变得更加容易,如果您没有使用 CSS 框架或想要扩展一个框架,这将非常有用。即使你选择不使用 CSS 框架,你也不必使用 CSS 处理器,正如我在后面的章节中解释的那样。 |
| 棉绒/格式器 | Linter/Formatter 特性安装了一个包,该包检查您的代码和内容是否符合最佳实践标准,如“使用 Linter”一节中所述。 |
| 单元测试 | 该功能安装单元测试工具,如 https://cli.vuejs.org/config/#unit-testing
所述。 |
| E2E 测试 | 该功能安装端到端测试工具,如CLI . vuejs . org/config/# e2e 测试
所述。 |
出于本章目的,选择 Babel、Router、Vuex 和 Linter/Formatter 特性,如图 10-3 所示。
图 10-3
为示例项目选择功能
选择功能后,按 Enter 键。还提供了其他选项来配置您选择的一些功能。
配置棉绒机
第一个选项配置棉绒,该选项通过棉绒/格式器功能选择,如图 10-4 所示。
图 10-4
配置棉绒机
选择“仅带错误预防的 ESLint”选项。linter 负责检查项目以确保它符合编码标准,这个问题选择应用于项目的编码规则。我将在本章后面的“使用 Linter”部分解释每个选项的含义。下一个选项是选择何时将 linter 应用于项目中的源代码,如图 10-5 所示。
图 10-5
配置何时应用棉绒
此选项配置何时使用 linter 检查项目。选择“保存时 Lint”选项,每次保存项目中的文件时都会应用 Lint。
完成项目配置
一旦您选择了项目特性,将询问您想要什么风格的配置,如图 10-6 所示。
图 10-6
选择配置文件的样式
有两个选择。您可以选择为每个需要配置的特性使用单独的配置文件,或者您可以选择在package.json
文件中包含所有的配置设置,该文件也用于跟踪应用所需的包。
选择In package.json
选项,这会将项目特性的配置设置添加到package.json
文件中,这与本书其他章节中使用的默认配置采用的方法相同。
按 Enter 键,最后一步是决定是否要保存您已经创建的配置,以便它可以用于在将来创建其他项目。对于这个问题,请按 Enter 键选择“否”,因为我在本书中创建的任何其他项目都不需要这个配置。
现在您已经回答了所有问题,项目将被创建,所需的包将被下载和安装。一个 Vue.js 项目需要很多包,初始设置可能需要一段时间才能完成。
了解项目结构
使用您喜欢的编辑器打开projecttools
文件夹,您将看到如图 10-7 所示的项目结构。图中显示了我的首选编辑器显示由vue create
命令创建的文件和文件夹的方式,但是您可能会看到一些细微的差异,这取决于您所选择的编辑器以及自编写本文以来对项目模板所做的任何更改。
图 10-7
示例项目的结构
表 10-4 描述了项目中最重要的文件和文件夹及其支持的特性。
表 10-4
项目文件和文件夹
|名字
|
描述
|
| --- | --- |
| node_modules
| 该文件夹包含应用和开发工具所需的包,如“了解包文件夹”一节中所述。 |
| public
| 该文件夹包含静态项目资源,例如没有合并到发送到浏览器的包文件中的图像,如“使用开发工具”一节中所述。 |
| src
| 该文件夹包含 Vue.js 应用及其资源,它是开发过程中的主要焦点,如“了解源代码文件夹”一节所述。 |
| .gitignore
| 该文件包含使用 Git 时从版本控制中排除的文件和文件夹的列表。 |
| .babel.config.js
| 该文件包含 Babel 编译器的配置设置,我在“使用开发工具”一节中对此进行了描述。 |
| package.json
| 该文件包含 Vue.js 开发所需的包列表以及用于开发工具的命令,这些在“了解包文件夹”一节中有所描述。 |
| package-lock.json
| 该文件包含项目所需的包及其依赖项的完整列表,这确保了当您运行npm install
命令时,您将会收到相同的一组包。 |
了解源代码文件夹
src
文件夹是任何 Vue.js 项目最重要的部分,包含应用的 HTML 内容、源代码和其他资源。这个文件夹是大多数开发会议的焦点,图 10-8 显示了使用本章开头选择的特性创建的项目的src
文件夹的内容。
图 10-8
src 文件夹的内容
当您开始开发一个项目时,src
文件夹的结构会变得更加复杂,但是占位符内容提供了足够的功能来开始并确保开发工具正常工作。我不打算详细描述src
文件夹的内容,因为它是本书这一部分所有其他章节的主题,但是,为了完整起见,表 10-5 描述了创建新项目时添加的文件。
表 10-5
src 文件夹的初始内容
|名字
|
描述
|
| --- | --- |
| assets
| 该文件夹用于存放应用所需的静态资源,这些资源包含在绑定过程中,如“使用开发工具”一节中所述。vue create
命令将包含 Vue.js 徽标的图像文件添加到该文件夹中。 |
| components
| 该文件夹用于应用的组件。一个应用可以包含许多组件,附加文件夹通常用于对相关组件进行分组。vue create
命令向这个名为HelloWorld
的文件夹添加一个占位符组件。 |
| views
| 此文件夹用于包含使用 URL 路由功能显示的组件。在本书的例子中,我没有遵循这个惯例,我更喜欢将所有组件分组到components
文件夹中。 |
| App.vue
| 这是根组件,通常用作自定义应用内容的起始点。 |
| main.js
| 这是创建Vue
对象的 JavaScript 文件,如第九章所述。 |
| router.js
| 该文件用于配置 URL 路由系统,该系统用于选择向用户显示的组件。我在第 22–24 章中详细描述了路由特性,在这里,我遵循不同的惯例创建一个包含一个index.js
文件的router
文件夹,该文件包含配置语句,这样可以很容易地将配置分割成多个文件,作为单个 JavaScript 模块来处理。 |
| store.js
| 该文件用于配置数据存储,数据存储用于在整个应用中共享数据。我在第二十章中描述了数据存储,在那里我遵循了创建包含index.js
文件的store
文件夹的不同惯例,这使得在大型应用中将配置分割成多个文件变得容易。 |
了解包文件夹
JavaScript 应用开发依赖于丰富的包生态系统,从包含将被发送到浏览器的代码的包,到在特定任务的开发过程中在后台使用的小包。Vue.js 项目中需要很多包。例如,本章开头创建的示例项目需要 900 多个包。
这些包之间有一个复杂的依赖层次结构,手工管理起来太困难了,只能用一个包管理器来处理。Vue.js 项目可以使用两个不同的包管理器来创建:NPM,它是节点包管理器,在第一章中与 Node.js 一起安装 Yarn,它是最近的一个竞争对手,旨在改进包管理。为简单起见,我在本书中通篇使用 NPM。
小费
你应该按照书中的例子使用 NPM,但如果你想在自己的项目中使用它,你可以在 https://yarnpkg.com
找到纱的细节。
当创建一个项目时,包管理器会得到一个 Vue.js 开发所需的包的初始列表。包管理器检查每个包以获得它所依赖的包的集合。再次执行该过程以获得这些包的依赖项,并重复该过程,直到构建了项目所需的包的完整列表。包管理器下载并安装所有的包,并将它们安装到node_modules
文件夹中。
使用dependencies
和devDependencies
属性在package.json
文件中定义初始的一组包。dependencies
属性用于列出应用运行所需的包。您可能会在您的项目中看到不同的细节,但这里是我的示例项目中的package.json
文件的dependencies
部分:
...
"dependencies": {
"vue": "².5.16",
"vue-router": "³.0.1",
"vuex": "³.0.1"
},
...
Vue.js 项目的dependencies
部分只需要三个包:vue
包包含主要特性,vue-router
包包含我在第二十二章–24 章中描述的导航特性,vuex
包包含我在第二十章中描述的数据存储特性。对于每个包,package.json
文件包括可接受版本号的详细信息,使用表 10-6 中描述的格式。
表 10-6
软件包版本编号系统
|格式
|
描述
|
| --- | --- |
| 2.5.16
| 直接表示版本号将只接受具有精确匹配版本号的包,例如 2.5.16。 |
| *
| 使用星号表示接受要安装的任何版本的软件包。 |
| >2.5.16 >=2.5.16
| 在版本号前面加上>或> =接受任何大于或等于给定版本的软件包版本。 |
| <2.5.16 <= 2.5.16
| 在版本号前加上 |
| ~2.5.16
| 在版本号前加一个波浪号(字符)接受要安装的版本,即使修补程序级别号(三个版本号中的最后一个)不匹配。例如,指定2.5.16 将接受版本 2.5.17 或 2.5.18(将包含版本 2.5.16 的修补程序),但不接受版本 2.6.0(将是新的次要版本)。 |
| ².5.16
| 在版本号前加一个插入符号(^字符)将接受版本,即使次要版本号(三个版本号中的第二个)或补丁号不匹配。例如,指定².5.16 将允许版本 2.5.17 和 2.6.0,但不允许版本 3.0.0。 |
在package.json
文件的dependencies
部分指定的版本号将接受较小的更新和补丁。当涉及到文件的devDependencies
部分时,这种版本灵活性更加重要,该部分包含开发所需的包的列表,但这些包不是最终应用的一部分。这是我项目的devDependencies
部分:
...
"devDependencies": {
"@vue/cli-plugin-babel": "³.0.0-beta.15",
"@vue/cli-plugin-e2e-cypress": "³.0.0-beta.15",
"@vue/cli-plugin-eslint": "³.0.0-beta.15",
"@vue/cli-plugin-unit-mocha": "³.0.0-beta.15",
"@vue/cli-service": "³.0.0-beta.15",
"@vue/test-utils": "¹.0.0-beta.16",
"chai": "⁴.1.2",
"vue-template-compiler": "².5.16"
},
...
这些包提供了开发工具集。正如我在本章开始时提到的,在撰写本文时,@vue/cli
包还处于测试阶段,你可以从包的版本号中看到这一点。
了解全球和本地包
软件包管理员可以安装软件包,使它们特定于单个项目(称为本地安装)或者可以从任何地方访问它们(称为全局安装)。很少有软件包需要全局安装,但有一个例外,那就是我在第一章安装的@vue/cli
软件包,它是本书准备工作的一部分。@vue/cli
包需要全局安装,因为它用于创建新的 Vue.js 项目。项目所需的单个包被本地安装到node_modules
文件夹中。
当你创建一个 Vue.js 项目时,开发所需的所有包都被自动下载并安装到node_modules
文件夹中,但是表 10-7 列出了一些你可能会发现在开发过程中有用的 NPM 命令。所有这些命令都应该在项目文件夹中运行,这个文件夹包含了package.json
文件。
表 10-7
有用的 NPM 命令
|命令
|
描述
|
| --- | --- |
| npm install
| 该命令执行在package.json
文件中指定的包的本地安装。 |
| npm install package@version
| 该命令执行包的特定版本的本地安装,并更新package.json
文件以将包添加到dependencies
部分。 |
| npm install --save-dev package@version
| 该命令执行包的特定版本的本地安装,并更新package.json
文件以将包添加到devDependencies
部分。 |
| npm install --global package@version
| 此命令执行特定版本软件包的全局安装。 |
| npm list
| 该命令列出了所有本地包及其依赖项。 |
| npm run
| 该命令执行在package.json
文件中定义的脚本之一,如下所述。 |
表 10-7 中描述的最后一个命令很奇怪,但是包管理器传统上包括对运行在package.json
文件的scripts
部分中定义的命令的支持。在 Vue.js 项目中,此功能用于提供对工具的访问,这些工具在开发过程中使用,并为应用的部署做准备。下面是示例项目中package.json
文件的scripts
部分:
...
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
...
这些命令总结在表 10-8 中,我将在后面的章节中演示它们的用法。
表 10-8
package.json 文件的脚本部分中的命令
|名字
|
描述
|
| --- | --- |
| serve
| 该命令启动开发工具,如“使用开发工具”一节所述。 |
| build
| 该命令执行构建过程,如“构建用于部署的应用”一节中所述。 |
| lint
| 这个命令启动 JavaScript linter,如“使用 linter”一节所述。 |
表 10-8 中的命令通过使用npm run
后跟您需要的命令名来运行,并且这必须在包含package.json
文件的文件夹中完成。因此,如果您想在示例项目中运行lint
命令,您可以导航到projecttools
文件夹并键入npm run lint
。
使用开发工具
添加到项目中的开发工具会自动检测更改,编译应用,并将文件打包以备浏览器使用。这些任务可以手动执行,但是自动更新会带来更愉快的开发体验。要启动开发工具,打开命令提示符,导航到projecttools
文件夹,运行清单 10-2 中所示的命令。
npm run serve
Listing 10-2Starting the Development Tools
开发工具使用的关键包叫做 webpack ,它是许多 JavaScript 开发工具和框架的主干。Webpack 是一个模块捆绑器,这意味着它打包了供浏览器使用的 JavaScript 模块,尽管这对于一个重要的功能来说是一个平淡无奇的描述,并且它是开发 Vue.js 应用时您将依赖的关键工具之一。
当您运行清单 10-2 中的命令时,当 webpack 准备运行示例应用所需的包时,您会看到一系列消息。Webpack 从main.js
文件开始,加载所有有import
或require
语句的模块,以创建一组依赖关系。对main.js
依赖的每个模块重复这个过程,webpack 继续在应用中工作,直到它拥有整个应用的一组完整的依赖项,然后将这些依赖项组合成一个文件,称为包。
在捆绑过程中,webpack 会报告其在模块中的工作过程,并找到需要包含在捆绑包中的模块,如下所示:
...
10% building modules 4/7 modules 3 active ...\node_modules\webpack\hot\emitter.js
...
绑定过程可能需要一点时间,但是只需要在启动开发工具时执行。一旦完成了初始准备,您将会看到一条类似这样的消息,它告诉您应用已经被编译和绑定了:
...
DONE Compiled successfully in 2099ms
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.
...
打开一个新的浏览器选项卡并导航到http://localhost:8080
以查看示例应用,如图 10-9 所示。
小费
你会注意到浏览器显示的网址是http://localhost:8080/#/
。额外的角色是本章开始时添加到项目中的vue-router
包的结果,我在第 22–24 章中详细描述了这个包。
图 10-9
使用开发工具
了解编译和转换过程
虽然 webpack 专注于纯 JavaScript,但它的功能可以通过名为 loaders 的扩展来处理其他类型的内容。Vue.js 开发工具包括几个加载器,但是有两个特别值得注意。您不必直接使用这些加载器,因为它们会在开发过程中自动使用,但是了解它们的功能有助于理解 Vue.js 开发工具的功能。
第一个重要的加载器是vue-loader
,它负责转换.vue
文件中的混合内容,以便可以编译和打包供浏览器使用,这是能够在混合 HTML、JavaScript 和 CSS 内容的单个文件中定义组件的基础。另一个值得注意的加载器集成了 Babel 编译器,它负责将使用最新语言功能的 JavaScript 代码编译成在最广泛的浏览器上运行的 JavaScript,其中许多浏览器不支持这些功能。为了演示巴别塔的效果,我在main.js
文件中添加了依赖于最新 JavaScript 特性的语句,如清单 10-3 所示。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
let first = "Hello";
const second = "World";
console.log(`Message ${first},${second}`);
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
Listing 10-3Adding Statements in the main.js File in the src Folder
这些语句使用三个最新的 JavaScript 特性向浏览器控制台写入消息:let
关键字、const
关键字和一个模板字符串。并非所有浏览器都支持这些特性,因此 Babel 用于转换 JavaScript 代码。
小费
当您保存对main.js
文件的更改时,您将会在命令提示符下看到一条警告。这来自于 linter,我在“使用 Linter”一节中描述了它,现在可以忽略它。
由于 webpack 捆绑应用代码的方式,查看结果需要一些工作,但是如果您使用浏览器的 F12 开发人员工具的 Sources 选项卡,您应该能够浏览应用捆绑包的内容,这些内容在webpack-internal
部分中提供。
小费
如果找不到编译好的源代码也不用担心。不是所有的浏览器都支持这个特性,也不是所有支持的浏览器都支持这个特性。需要理解的重要一点是,编译器会转换 JavaScript,以便即使不是所有的目标浏览器都支持现代特性,也可以使用它们。
在浏览器窗口中找到main.js
文件,您将会看到清单 10-3 中的语句已经被转换成如下内容:
...
var first = "Hello";
var second = "World";
console.log('Message' + first + ',' + second);
...
编译过程用var
替换了let
和const
关键字,并用传统的字符串连接替换了模板字符串。结果是 JavaScript 代码将在 Vue.js 支持的所有浏览器中运行。
理解巴别塔的极限
Babel 是一个优秀的工具,但是它只处理 JavaScript 语言特性。Babel 不能在不支持最新 JavaScript APIs 的浏览器上增加对这些 API 的支持。您仍然可以使用这些 API——正如我在第一部分中使用本地存储 API 时所演示的那样——但是这样做限制了可以运行应用的浏览器的范围。
在清单 10-4 中,我注释掉了我在清单 10-3 中添加的语句,以防止项目工具显示警告消息。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
//let first = "Hello";
//const second = "World";
//console.log(`Message ${first},${second}`);
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
Listing 10-4Commenting Statements in the main.js File in the src Folder
了解开发 HTTP 服务器
为了简化开发过程,该项目包含了webpack-dev-server
包,这是一个与 webpack 集成的 HTTP 服务器。开发 HTTP 服务器的默认端口是 8080,初始绑定过程一完成,服务器就开始监听 HTTP 请求。
当接收到 HTTP 请求时,开发 HTTP 服务器返回public/index.html
文件的内容。在处理index.html
文件时,开发服务器做了一个重要的添加,您可以通过在浏览器窗口中右键单击并从弹出菜单中选择 View Page Source 来查看。
<!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>projecttools</title>
<link as="script" href="/app.js" rel="preload">
</head>
<body>
<noscript>
<strong>
We're sorry but projecttools 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>
开发服务器添加了一个script
元素,告诉浏览器加载一个名为app.js
的文件,这是 webpack 在开发工具启动过程中创建的包的名称。然而,这个包并不仅仅包含应用代码,还有附加的特性被发送到浏览器,这些特性是 Vue.js 开发体验不可或缺的一部分,下面几节将对此进行描述。
了解热模型替换
webpack 创建的包包括对一个称为热模块替换(HMR)的特性的支持。当您对应用的源文件或内容文件进行更改时,webpack 及其加载程序会对更改后的文件进行处理、打包并发送给浏览器。在大多数情况下,只有很小的更改被发送到浏览器,并且应用被更新而不重置应用的状态。为了演示,我向src
文件夹中的App.vue
文件添加了一些 HTML 元素和一些 JavaScript 代码,如清单 10-5 所示。
<template>
<div id="app">
<div>Button Clicks: {{ counter }}</div>
<button v-on:click="incrementCounter">Press Me</button>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
</div>
</template>
<script>
export default {
data() {
return {
counter: 0
}
},
methods: {
incrementCounter() {
this.counter++;
}
}
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style>
Listing 10-5Changing Content in the App.vue File in the src Folder
发送到浏览器的原始包包含打开一个返回到服务器的持久 HTTP 连接并等待指令的代码。当您保存对文件的更改时,您会看到在组件文件被转换、编译和绑定时,命令行上会显示一些消息。
然后使用持久 HTTP 连接告诉浏览器有一个替换模块可用,该模块被发送到浏览器并用于更新应用,如图 10-10 所示。
图 10-10
热模块更换功能
对于某些更改,HMR 功能可以更新应用,而无需重置其状态。这最适用于 HTML 元素的更改。在清单 10-6 中,我修改了用于向用户显示消息的元素。
...
<template>
<div id="app">
<h1>Button Clicks: {{ counter }}</h1>
<button v-on:click="incrementCounter">Press Me</button>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
</div>
</template>
...
Listing 10-6Making an HTML Change in the App.vue File in the src Folder
在将更改保存到App.vue
文件之前,在浏览器中点击几次按钮,你会看到应用被更新,而不会丢失应用的状态,确保counter
属性的值被保留,如图 10-11 所示。当您处理需要导航过程或要填写的表单的应用功能时,保留应用的状态非常有用,否则每次进行更改时都需要重新创建所需的状态。
图 10-11
保持状态的更新
在不重置应用状态的情况下,并非所有更改都可以应用。如果你改变一个组件的script
部分,那么 Vue.js 必须销毁现有的组件对象并创建一个新的。应用中其他组件的状态不受影响,但正在编辑的组件的状态会丢失。而且,在某些情况下,HMR 功能完全弄错了,您必须重新加载应用才能看到更改的效果。
了解错误显示
HMR 特性提供的即时性的一个影响是,在开发过程中,你会倾向于停止观看控制台输出,因为你的注意力会自然地被吸引到浏览器窗口。风险在于,当代码包含错误时,浏览器显示的内容保持不变,因为编译过程无法生成新的模块通过 HMR 功能发送给浏览器。
为了解决这个问题,webpack 开发的包中包含了一个集成的错误显示,可以在浏览器窗口中显示问题的详细信息。为了演示处理错误的方式,我将清单 10-7 中所示的语句添加到了App.vue
文件的script
元素中。
...
<script>
export default {
not a valid statement
data() {
return {
counter: 0
}
},
methods: {
incrementCounter() {
this.counter++;
}
}
}
</script>
...
Listing 10-7Creating an Error in the App.vue File in the src Folder
加法不是有效的 JavaScript 语句。保存对文件的更改后,构建过程会尝试编译代码,并在命令提示符下生成以下错误信息:
...
ERROR Failed to compile with 1 errors
error in ./src/App.vue
Syntax Error: Unexpected token, expected , (12:8)
14 | export default {
> 15 | not a valid statement
| ^
16 | data() {
17 | return {
18 | counter: 0
...
浏览器窗口中显示相同的错误消息,如图 10-12 所示,因此即使您没有注意命令行消息,也不可能意识不到存在问题。
图 10-12
在浏览器窗口中显示错误
在清单 10-8 中,我已经注释掉了导致错误的语句,这使得应用的构建没有问题。
...
<script>
export default {
//not a valid statement
data() {
return {
counter: 0
}
},
methods: {
incrementCounter() {
//debugger;
this.counter++;
}
}
}
</script>
...
Listing 10-8Commenting Out a Problem Statement in the App.vue File in the src Folder
使用棉绒
我在本章开始时为示例项目选择的特性包括一个 linter——ESLint 包——它负责检查项目以确保代码和内容符合一组规则。我为 linter 选择的配置意味着,每当检测到src
文件夹中的文件发生变化时,它都会执行检查。
当您选择在设置 Vue.js 项目的过程中添加 linter 时,您会看到一组选项,这些选项决定了用于评估项目的一组规则。这些选项在表 10-9 中描述。
表 10-9
Vue.js Linter 规则选项
|[计]选项
|
描述
|
| --- | --- |
| 仅错误预防 | 该选项仅适用于“基本的”Vue.js 规则和“推荐的”ESLint 规则,这两种规则都在表后描述。 |
| 爱彼迎(美国短租平台) | 该选项使用 Airbnb 开发的一套规则,这些规则在 https://github.com/airbnb/javascript
有所描述。这些规则是对表后描述的“基本”Vue.js 规则的补充。 |
| 标准 | 该选项使用一组称为“标准”的规则在 https://github.com/standard/standard
对规则进行了描述。这些规则是对表后描述的“基本”Vue.js 规则的补充。 |
| 较美丽 | 该选项使用更漂亮的工具,用于强制代码格式化,如 https://prettier.io
所述。除了应用表后描述的“基本”Vue.js 规则之外,还执行格式化。 |
我在本章开始时选择了仅错误预防选项,这是可以选择的最宽松的规则集,它专注于识别可能导致错误的问题。其他选项超越了错误预防,加强了样式问题,这是我不喜欢也不愿意使用的东西。(我发现“标准”规则尤其令人沮丧,因为它们包括一长串格式规则;我对这种林挺的看法见《林挺的欢乐与痛苦》。)
林挺的快乐和痛苦
Linters 可能是一个强大的工具,特别是在一个技术和经验水平参差不齐的开发团队中。Linters 可以检测导致意外行为或长期维护问题的常见问题和细微错误。我喜欢这种林挺,我喜欢在完成一个主要的应用特性之后,或者在将代码提交到版本控制之前,通过林挺过程运行我的代码。
但是棉绒也可能成为分裂和冲突的工具。除了检测编码错误之外,linters 还可以用于执行关于缩进、括号放置、分号和空格的使用以及许多其他样式问题的规则。大多数开发人员都有自己坚持的风格偏好,并且相信其他人也应该如此。我当然喜欢:我喜欢缩进和左括号的四个空格在与它们相关的表达式的同一行上。我知道这些是编写代码的“唯一正确的方法”的一部分,例如,其他程序员喜欢两个空格的事实,自从我第一次开始编写代码以来,一直是我安静的惊奇的来源。
Linters 允许对格式有强烈看法的人强加给别人,通常打着“固执己见”的旗号。逻辑是开发人员花了太多时间争论不同的编码风格,每个人被迫以相同的方式编写会更好。我的经验是,开发人员会找到其他的东西来争论,而强制一种代码风格通常只是一个借口,使一个人的偏好对整个开发团队来说是强制性的。
我经常在读者无法获得书籍示例时帮助他们(如果你需要帮助,我的电子邮件地址是adam@adam-freeman.com
),我每周都会看到各种各样的编码风格。我知道,在我内心深处,任何不遵循我个人编码偏好的人都是完全错误的。但我没有强迫他们按照我的方式编码,而是让我的代码编辑器重新格式化代码,这是每个有能力的编辑器都提供的功能。
我的建议是少用林挺,把注意力放在会引起真正问题的问题上。将格式化决策留给个人,当您需要阅读由具有不同偏好的团队成员编写的代码时,依靠代码编辑器进行重新格式化。
当选择表 10-9 中描述的仅防误选项时,有两组规则适用。第一组规则由 ESLint 开发人员提供,另一组由 Vue.js 开发人员提供。
ESLint 规则在 https://eslint.org/docs/rules
有所描述,主要针对 JavaScript 编程中常见的一般性错误。正如您可能想象的那样,Vue.js 规则是特定于 Vue.js 开发的。有三套规则可用,如表 10-10 所述。每组规则的详细描述见 https://github.com/vuejs/eslint-plugin-vue
。
表 10-10
Vue.js 过磅规则级别
|名字
|
描述
|
| --- | --- |
| 必要的 | 这些规则在很大程度上与正确使用指令有关。指令在第 12–15 章中描述。 |
| 强烈推荐 | 这些规则旨在帮助提高代码的可读性。它们主要与格式和布局问题有关。 |
| 被推荐的 | 这些规则旨在确保一致性。 |
默认情况下,基本规则是启用的,这意味着强烈建议和推荐的规则将被忽略。您可以通过更改package.json
文件的esLintConfig
部分来更改用于林挺的规则。这是本章开始时创建的package.json
文件的一部分:
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
配置文件的extends
部分用于选择将要应用的规则集。Vue.js 特定规则的三个值是plugin:vue/recommended
、plugin:vue/strongly-recommended
和plugin:vue/essential
,对应于表 10-10 中描述的规则。在清单 10-9 中,我启用了强烈推荐的规则。当您选择一组规则时,也会应用优先级较高的规则,因此选择推荐的规则也会强制执行强烈推荐的重要规则。
小费
如果您在创建项目时选择了将特性设置存储在单独的配置文件中的选项,那么您可以在.eslintrc.json
文件而不是package.json
文件中进行这些更改。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/strongly-recommended",
"eslint:recommended"
],
"rules": {},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
Listing 10-9Changing Vue.js Linting Rules in the package.json File in the projecttools Folder
对 linter 配置的更改只有在重新启动开发工具后才会生效。使用Control+C
停止开发工具,并通过运行projecttools
文件夹中清单 10-10 所示的命令再次启动它们。
npm run serve
Listing 10-10Starting the Development Tools
linter 将作为构建过程的一部分运行。我在清单 10-9 中启用的一些规则与用作缩进的空格数有关,如果您遵循了我对清单的编码偏好,您将会看到如下警告:
...
Module Warning (from ./node_modules/eslint-loader/index.js):
error: Expected indentation of 2 spaces but found 4 spaces (vue/html-indent) at src\App.vue:2:1:
1 | <template>
> 2 | <div id="app">
| ^
3 | <h1>Button Clicks: {{ counter }}</h1>
4 | <button v-on:click="incrementCounter">Press Me</button>
...
根据您的编码风格,您可能看不到这个错误,但是您会看到这个错误,它涉及到我对清单 10-5 中的App.vue
文件所做的更改:
...
error: Expected '@' instead of 'v-on:' (vue/v-on-style) at src\App.vue:4:17:
2 | <div id="app">
3 | <h1>Button Clicks: {{ counter }}</h1>
> 4 | <button v-on:click="incrementCounter">Press Me</button>
| ^
5 |
6 | <div id="nav">
7 | <router-link to="/">Home</router-link> |
...
正如我在第十四章中解释的那样,有两种方式来登记对某个事件的兴趣——短表和长表。强烈推荐的规则集中的一个规则强制使用短格式,并在使用长格式时发出警告。
小费
您可以使用npm run lint
命令在正常构建过程之外运行 linter。
定制过磅规则
我收到警告的规则都不适合我的编码偏好。我是一个专门的四空格编码者,我更喜欢用于将 Vue.js 特性应用于 HTML 元素的长格式。但是,我可以重新配置或禁用我不想要的规则,而不是禁用整个规则集,并且仍然可以从我认为有用的规则中获益。在清单 10-11 中,我禁用了缩进规则,并更改了指示规则,以便它检查长格式而不是短格式。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/strongly-recommended",
"eslint:recommended"
],
"rules": {
"vue/html-indent": "off",
"vue/v-on-style": [ "warn", "longform" ]
},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
Listing 10-11Configuring Rules in the package.json File in the projecttools Folder
每个规则都有自己的配置选项,可以在规则组的概要页面中看到,我在上一节中为这些规则组提供了 URL。在本例中,我配置了两个特定于 Vue.js 的规则。我将vue/html-indent
规则设置为off
,这将对组件的template
元素中的 HTML 元素禁用缩进检查。我启用了vue/v-on-style
规则,但是更改了它的配置,以便它接受我喜欢的长格式,并在使用短格式时生成警告。要查看更改的效果,请停止并再次启动开发工具。
禁用单个语句和文件的林挺
您可能会发现有个别语句会导致 linter 报告错误,但您无法更改。您可以在代码中添加一个注释,告诉 linter 忽略下一行的规则,而不是完全禁用该规则,如下所示:
...
<!-- eslint-disable-next-line vue/v-on-style -->
...
如果想禁用下一条语句的所有规则,可以省略规则名,如下所示:
...
<!-- eslint-disable-next-line -->
...
如果您想要禁用整个文件的规则,那么您可以在 Vue.js 特定规则的文件顶部和 ESLint 规则的script
元素顶部添加这样的注释:
...
<!-- eslint-disable vue/v-on-style -->
...
如果您希望对单个文件的所有规则禁用林挺,则可以在注释中省略规则名称,如下所示:
...
<!-- eslint-disable -->
...
这些注释允许您忽略那些不符合规则但不能更改的代码,同时仍然林挺项目的其余部分。您还可以将文件夹和文件类型添加到package.json
文件中,以将其从林挺进程中排除。
调试应用
并不是所有的问题都能被编译器或 linter 检测到,能够完美编译的代码可能会以意想不到的方式运行。有两种方法可以理解您的应用的行为,如下面几节所述。
探索应用状态
Vue Devtools 浏览器扩展是探索 Vue.js 应用状态的优秀工具。谷歌 Chrome 和 Mozilla Firefox 都有可用的版本,你可以在 https://github.com/vuejs/vue-devtools
找到该项目的细节——包括自撰写本文以来对任何其他平台的支持。安装完扩展后,您将在浏览器的“开发人员工具”窗口中看到一个附加选项卡,可通过按 F12 按钮访问该选项卡(这也是这些工具也称为 F12 工具的原因)。
F12 工具窗口中的 Vue 选项卡允许您浏览和更改应用的结构和状态。您可以看到提供应用功能的一组组件以及每个组件提供的数据。对于示例应用,如果您打开 Vue 选项卡并在左窗格中展开应用结构,您将在右窗格中看到App
组件的数据,包括我在清单 10-5 中定义的counter
属性的值,如图 10-13 所示。
图 10-13
探索应用状态
您可以单击这些值来更改它们。这些更改会更新应用的动态数据模型,如果您更改的值通过数据绑定显示,这些更改会立即反映在浏览器中。
浏览器扩展还显示关于应用的 Vuex 商店的信息(我在第二十章中描述过),应用触发的自定义事件的详细信息(我在第十四章中描述过),以及 URL 路由系统的详细信息(我在第 22–24 章中描述过)。
使用浏览器调试器
现代浏览器包括复杂的调试器,可用于控制应用的执行并检查其状态。Vue.js 开发工具包括对创建源映射的支持,这允许浏览器将它正在执行的缩小和捆绑的代码与高效调试所需的开发人员友好的源代码相关联。
有些浏览器允许您使用这些源代码映射来浏览应用的源代码,并创建断点,当到达断点时,断点将暂停应用的执行,并将控制权交给调试器。当我写这篇文章时,创建断点的能力是一个脆弱的功能,在 Chrome 上不起作用,在其他浏览器上也有混合的可靠性。因此,将应用控制权传递给调试器的最可靠方式是使用 JavaScript debugger
关键字,如清单 10-12 所示。
<template>
<div id="app">
<h1>Button Clicks: {{ counter }}</h1>
<button v-on:click="incrementCounter">Press Me</button>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
</div>
</template>
<script>
export default {
data() {
return {
counter: 0
}
},
methods: {
incrementCounter() {
debugger;
this.counter++;
}
}
}
</script>
<style>
/* ...styles omitted for brevity... */
</style>
Listing 10-12Triggering the Debugger in the App.vue File in the src Folder
应用将正常执行,但是当单击按钮并调用incrementCounter
方法时,浏览器将遇到debugger
关键字并停止执行。然后你可以使用 F12 工具窗口中的控件来检查执行停止点的变量及其值,并手动控制执行,如图 10-14 所示。浏览器正在执行由开发工具创建的缩小和捆绑的代码,但是显示来自源地图的相应代码。
图 10-14
使用浏览器调试器
大多数浏览器会忽略debugger
关键字,除非 F12 工具窗口是打开的,但是在调试会话结束时删除它是一个好习惯。默认的 linter 配置包括一个规则,当您为生产构建应用时,如果代码中留下了debugger
关键字,就会发出警告,这将在下一节中描述。
配置开发工具
默认配置包含合理的设置,但是您可以更改配置以满足您的开发需求。通过在项目文件夹中添加一个名为.vue.config.js
的文件或者在package.json
文件中添加一个vue
部分来定义配置更改。为了演示配置更改,我在projecttools
文件夹中创建了vue.config.js
文件,并添加了清单 10-13 中所示的内容。
module.exports = {
runtimeCompiler: true
}
Listing 10-13The Contents of the vue.config.js File in the projecttools Folder
这和我在第九章中所做的配置改变是一样的,它允许组件将它们的模板定义为字符串,而不是使用template
元素。整套配置选项在 https://cli.vuejs.org/config/#vue-config-js
中描述,但表 10-11 描述了对大多数项目最有用的选项。
表 10-11
有用的配置选项
|名字
|
描述
|
| --- | --- |
| baseUrl
| 此选项用于指定 URL 前缀,当应用被部署为较大站点的一部分时,该前缀用于访问应用。默认的网址是/
。 |
| outputDir
| 该选项用于指定用于构建应用生产版本的目录,如“为部署构建应用”一节中所述默认目录是dist
。 |
| devServer
| 该选项用于使用 https://webpack.js.org/configuration/dev-server
中描述的选项配置开发 HTTP 服务器。 |
| runtimeCompiler
| 该选项启用运行时编译器,允许组件使用template
属性定义它们的模板,但是增加了发送到浏览器的代码量。 |
| chainWebpack
| 此选项用于配置 webpack。我在第二十一章中使用该配置选项来禁用 webpack 功能。 |
构建用于部署的应用
开发工具创建的文件不适合生产使用,并且包含额外的功能,例如支持热模块替换。当您准备好部署您的应用时,您必须创建一组只包含应用的代码和内容以及它们所依赖的模块的包,以便这些文件可以部署到生产 HTTP 服务器上。
根据您选择的特性,您可能需要执行一些配置更改来准备应用的部署,正如我在第八章中为 SportsStore 应用演示的那样。
对于本章的示例项目,如前一节所述,在部署之前移除关键字debugger
是个好主意,因此在清单 10-14 中,我注释掉了App.vue
文件中的debugger
语句。
...
<script>
export default {
data() {
return {
counter: 0
}
},
methods: {
incrementCounter() {
//debugger;
this.counter++;
}
}
}
</script>
...
Listing 10-14Commenting Out the debugger Keyword in the App.vue File in the src Folder
我在清单 10-9 中启用的 linter 规则执行额外的检查,这些检查只有在您为生产而构建时才会被标记。你可能会发现这些警告很有用,但是我个人倾向于禁用它们,在清单 10-15 中,我已经返回到只使用基本的 Vue.js 林挺规则。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"vue/html-indent": "off",
"vue/v-on-style": [ "warn", "longform" ]
},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
Listing 10-15Changing Linter Rules in the package.json File in the projecttools Folder
要创建生产版本,运行projecttools
文件夹中清单 10-16 所示的命令。
npm run build --modern
Listing 10-16Creating the Production Build
--modern
参数是一个可选特性,它创建了应用的两个版本,其中一个版本专门用于支持最新 JavaScript 特性的现代浏览器,另一个版本用于需要额外代码和库来处理这些特性的旧浏览器。除了在构建应用时指定该选项之外,不需要任何进一步的操作,适当版本的选择会自动执行,如 https://cli.vuejs.org/guide/browser-compatibility.html#modern-mode
所述。
图 10-15
dist 文件夹的内容
构建过程可能需要一段时间才能完成,尤其是对于大型应用。构建过程的结果是一个dist
文件夹,其中包含应用在部署时需要的所有文件,如图 10-15 所示。在您的项目中,各个文件的名称会有所不同。
dist
文件夹包含将成为应用入口点的index.html
文件,该文件包含将加载 JavaScript 和 CSS 文件的link
和script
元素,如下所示:
<!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">
<link rel=icon href=/favicon.ico>
<title>projecttools</title>
<link as= style href=/css/app.b938236b.css rel=preload>
<link as=script href=/js/app.b671844a.js rel=preload>
<link as=script href=/js/chunk-vendors.02da9ab1.js rel=preload>
<link href=/css/app.b938236b.css rel=stylesheet>
</head>
<body>
<noscript><strong>We're sorry but projecttools doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div>
<script src=/js/chunk-vendors.02da9ab1.js></script>
<script src=/js/app.b671844a.js></script>
</body>
</html>
JavaScript 文件包括应用代码和它所依赖的 Vue.js 模块,不需要对 HTML 文件做进一步的添加。
安装和使用 HTTP 服务器
在一个真实的项目中,dist
文件夹中的文件将被复制到生产 HTTP 服务器,该服务器可能运行在托管环境中或本地数据中心中。为了完成这一章,我将使用一个简单但功能强大的用 JavaScript 编写的 HTTP 服务器,它可以通过 NPM 下载和安装。
警告
开发过程中使用的 HTTP 服务器不适合生产使用。您必须使用不同的 web 服务器将您的应用交付给用户。
运行清单 10-17 中所示的命令来安装 Express 包和支持 URL 路由所需的相关包。
npm install --save-dev express@4.16.3
npm install --save-dev connect-history-api-fallback@1.5.0
Listing 10-17Adding Packages for the Production Build
我在projecttools
文件夹中添加了一个名为server.js
的文件,并添加了清单 10-18 中所示的语句,这些语句配置了清单 10-17 中安装的包,因此它们将服务于示例应用。
const express = require("express");
const history = require("connect-history-api-fallback");
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.use(history());
app.use("/", express.static("./dist"));
app.listen(80, function () {
console.log("HTTP Server running on port 80");
});
Listing 10-18The Contents of the server.js File in the projecttools Folder
运行projecttools
文件夹中清单 10-19 所示的命令来测试应用。
图 10-16
测试部署的应用
node server.js
Listing 10-19Testing the Deployment Build
这个命令执行清单 10-18 中 JavaScript 文件中的语句,这些语句在端口 80 上设置一个 web 服务器并监听请求。要测试应用,导航到http://localhost
,您将看到应用正在运行,如图 10-16 所示。
摘要
在本章中,我向您展示了如何使用@vue/cli
包创建一个 Vue.js 项目,并解释了它提供的开发工具。我解释了如何构建项目,如何编译和捆绑应用,以及如何从编译器和可选的 linter 中报告错误。在本章的最后,我描述了可用于调试应用的工具,并演示了如何构建项目以准备部署。在下一章,我将描述 Vue.js 对数据绑定的支持。
十一、了解数据绑定
在这一章中,我将解释如何执行 web 应用开发中最基本的任务之一:使用数据绑定来显示数据值。我将解释如何向组件的模板添加一个基本的数据绑定,称为文本插值绑定,以及如何为该绑定创建数据值。我解释了 Vue.js 如何评估数据绑定的表达式,并演示了在向用户显示值之前生成和格式化值的不同方法。表 11-1 将文本插值数据绑定放在上下文中。
表 11-1
将文本插值数据绑定放入上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 数据绑定将组件的数据与其模板中的 HTML 元素连接起来。文本插值是 Vue.js 支持的最简单的绑定。 |
| 它们为什么有用? | 数据绑定使 Vue.js 应用具有交互性。用户执行的操作会改变应用的状态,并通过数据绑定反映给用户。 |
| 它们是如何使用的? | 文本插值绑定是使用双花括号字符{{
和}}
创建的,这就是为什么它们通常被称为小胡子绑定。 |
| 有什么陷阱或限制吗? | Vue.js 允许在数据绑定中使用复杂的表达式。这是一个有用的特性,但是很容易失去控制,在数据绑定中嵌入太多的逻辑,这样项目很难测试和维护。 |
| 有其他选择吗? | 文本插值绑定只是 Vue.js 支持的绑定之一,其他绑定将在后面的章节中介绍。 |
表 11-2 总结了本章内容。
表 11-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 定义新组件 | 创建一个扩展名为.vue
的文件,并添加template
、script
和style
元素。 | 6, 7 |
| 显示数据值 | 添加数据值和文本插值绑定 | 8, 9 |
| 计算价值 | 使用计算的属性或方法 | 10–14 |
| 格式化数据属性 | 使用一个或多个过滤器 | 15–18 |
为本章做准备
对于本章中的例子,在一个方便的位置运行清单 11-1 中所示的命令来创建一个新的 Vue.js 项目。
vue create templatesanddata --default
Listing 11-1Creating a New Project
这个命令创建了一个名为templatesanddata
的项目。一旦设置过程完成,运行templatesanddata
文件夹中清单 11-2 所示的命令,将引导 CSS 包添加到项目中。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
npm install bootstrap@4.0.0
Listing 11-2Adding the Bootstrap CSS Package
将清单 11-3 中所示的语句添加到src
文件夹中的main.js
文件中,将引导 CSS 文件合并到应用中。
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 11-3Incorporating the Bootstrap Package in the main.js File in the src Folder
添加清单 11-4 中所示的语句来禁用 linter 规则,该规则在浏览器的 JavaScript 控制台被使用时发出警告,我将在本章后面用到它。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"no-console": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
...
Listing 11-4Configuring the Linter in the package.json File in the templatesanddata Folder
运行templatesanddata
文件夹中清单 11-5 所示的命令,启动开发工具。
npm run serve
Listing 11-5Navigating to the Project Folder and Starting the Development Tools
将执行初始构建过程,之后您将看到一条消息,告诉您项目已经成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口,导航到http://localhost:8080
查看项目的占位符内容,如图 11-1 所示。
图 11-1
运行示例应用
了解组件的元素
组件是 Vue.js 应用中的主要构建块,除了最简单的项目之外,所有项目都包含几个组件。组件在.vue
文件中定义,这些文件包含显示给用户的 HTML 内容、支持内容的数据和 JavaScript 代码以及 CSS 样式。Vue.js 项目中的约定是定义一个根组件来编排应用的其余部分;这是App
组件,它被定义在src
文件夹中的一个名为App.vue
的文件中。我用来创建示例项目的默认项目选项添加了一个根组件,其内容如清单 11-6 所示。
<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>
Listing 11-6The Initial Contents of the App.vue File in the src Folder
组件是使用三个元素定义的:template
元素、script
元素和style
元素,我将在接下来的小节中简要描述这些元素。
了解模板元素
元素包含组件的 HTML 内容。模板包含一个顶级元素(清单 11-6 中模板中的一个div
元素),它替换了应用组件的元素,如第九章所述。组件的模板包含常规 HTML 元素和 Vue.js 增强的混合,它们应用应用功能,如数据绑定、指令和应用其他组件的自定义元素。您已经在前面的章节中看到了所有这些特性的例子,但是我将在本章和本书的其余部分详细描述它们。
了解脚本元素
script
元素包含组件的 JavaScript 模块,其属性配置组件,定义其数据模型,并提供支持其特性的逻辑。script
元素的不同角色意味着当你开始 Vue.js 开发时可能很难理解,但是你很快就会熟悉最有用的配置属性。
了解样式元素
style
元素定义了 CSS 样式,可以对其进行配置,使其仅适用于template
元素,或者在应用中具有更广泛的效果。并不是所有的组件都需要自己的 CSS 样式,所以你会经常遇到只有template
和script
元素的组件,尤其是在使用像 Bootstrap 这样的 CSS 框架时,就像我在本书中所做的那样。但是script
元素提供了一些有用的 CSS 特性,即使在使用 CSS 框架时,也有必要了解如何应用它们。
正如您将了解到的,正是组合template
、script
和style
元素的方式使得组件变得有用,并且这些元素是 Vue.js 开发的显著特征。
重置示例应用中的组件
文件App.vue
中的组件并没有做太多事情,但是我仍然打算把它剥离回基础,这样我就可以一个一个地引入特性。在清单 11-7 中,我简化了template
和script
元素,并删除了style
元素,为本章的剩余部分留下了一张白纸。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>This is the component</h3>
</div>
</template>
<script>
export default {
name: "MyComponent"
}
</script>
Listing 11-7Resetting the Contents of the App.vue File in the src Folder
template
现在包含了一个顶级的div
元素,我已经将它分配给了几个应用引导 CSS 样式的类,这让我可以在这个组件中不使用style
元素的情况下应用样式。div
元素包含一个带有简单文本消息的h3
元素。
当您将更改保存到App.vue
文件时,项目将被编译,浏览器将重新加载,您将看到如图 11-2 所示的内容。
图 11-2
简化示例应用中的组件
在script
元素中定义的唯一属性是name
。Vue Devtools 浏览器扩展(在第十章中描述)使用可选的name
属性来显示应用的结构,选择一个与众不同且有意义的名称有助于理解检查应用状态时发生的事情。打开浏览器的 F12 开发者工具,切换到 Vue 选项卡;你会看到组件的名称显示在布局中,如图 11-3 所示。显示器本身目前不是特别有用,但随着功能的增加,它会提供更多信息。
图 11-3
检查应用的结构
显示数据值
组件可以做的最重要的工作之一是向用户显示数据。显示一个数据值需要两个步骤:在script
元素中定义一个数据属性,并在template
元素中添加一个数据绑定来将值呈现给用户,如清单 11-8 所示。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak"
}
}
}
</script>
Listing 11-8Displaying a Data Value in the App.vue File in the src Folder
使用 JavaScript 模块中的data
属性定义数据值。必须遵循特定的模式来定义组件的数据值:data
属性返回一个函数,函数返回一个对象,由对象定义的属性可用于向用户显示数据值。
定义数据值的分步指南
添加对数据值的支持需要一个笨拙的模式,而出错是 Vue.js 开发中最常见的错误之一。首先定义一个返回如下函数的data
属性:
...
<script>
export default {
name: "MyComponent",
data: function() {
}
}
</script>
...
使用data
,后跟一个冒号,后跟function
关键字,后跟一个左括号和一个右括号((
和)
字符),然后是一个左大括号和一个右大括号({
和}
字符)。下一步是返回一个对象,使用第四章中描述的 JavaScript 对象文本形式。
...
<script>
export default {
name: "MyComponent",
data: function() {
return {
}
}
}
</script>
...
使用return
关键字,后跟左花括号和右花括号。最后一步是定义您需要的数据值。使用名称,后跟冒号(:
字符),再后跟值来指定值。
...
<script>
export default {
name: "MyComponent",
data: function() {
return {
myValue: 10
}
}
}
</script>
...
通过在前一行的末尾放置一个逗号,然后定义一个新值来分隔多个数据值,如下所示:
...
<script>
export default {
name: "MyComponent",
data: function() {
return {
myValue: 10, // <-- notice the comma here
myOtherValue: "Hello, World"
}
}
}
</script>
...
这只对data
值有要求,一旦你获得了一点 Vue.js 开发的经验,你就会开始自动遵循这个模式。如果您忘记定义一个返回对象的函数,那么您将在浏览器的 JavaScript 控制台中看到一条警告,如下所示:
[Vue warn]: The "data" optionz should be a function that returns a per-instance value in component definitions.
这个警告不会像我在第十章中演示的编译器错误那样显示在主浏览器窗口中,但它通常会导致其他警告并阻止数据绑定正常工作。
使用数据绑定将script
元素中的数据值链接到template
元素中的 HTML 元素。有许多不同类型的数据绑定可用,但是为了开始,我使用最简单的,它是文本插值绑定,就像这样:
...
<h3>Product: {{ name }}</h3>
...
这种类型的绑定将数据值插入到 HTML 元素的文本内容中,并用双花括号({{
和}}
)表示。据说双牙套很像小胡子,这就是为什么它也被称为小胡子捆绑。将更改保存到App.vue
文件,您将会看到如图 11-4 所示的内容。
图 11-4
显示数据值
浏览器的 F12 开发人员工具窗口中的 Vue Devtools 选项卡也将被更新,以显示组件的数据值。数据值是实时的,这意味着值的更改会自动反映在整个应用中。对于示例应用,这意味着更改组件的name
属性的值将自动更新template
元素中的文本插值数据绑定。应用本身还不能改变name
的值,但是如果你将鼠标指针移到 Vue Devtools 面板中的值上,你会看到一个铅笔图标,点击它可以激活一个编辑器。将值更改为“Green Kayak”(注意保留引号字符),然后单击磁盘图标保存更改。Vue.js 会检测到数据属性的变化,并更新显示给用户的 HTML,如图 11-5 所示。
图 11-5
使用 Vue Devtools 更改数据属性
在数据绑定中使用更复杂的表达式
当 Vue.js 显示一个数据绑定时,它会将其内容作为一个 JavaScript 表达式进行评估。当数据绑定的内容只是一个data
属性的名称时,那么计算表达式就会产生属性的值,这就是我在示例中使用的{{ name }}
绑定能够显示名为name
的data
属性的值的方式。
将数据绑定视为表达式意味着您可以在数据绑定中使用更复杂的 JavaScript 语句。在清单 11-9 中,我向script
元素添加了另外两个data
属性,并向template
元素添加了一个更复杂的数据绑定。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h3>Price: ${{ (price + (price * (taxRate / 100))).toFixed(2) }}</h3>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
taxRate: 12
}
}
}
</script>
Listing 11-9Using a More Complex Data Binding Expression in the App.vue File in the src Folder
我添加了名为price
和taxRate
的数据属性,我在新的数据绑定表达式中使用它们来计算总价并对结果进行格式化,使其小数点后有两位数,当项目被编译并且浏览器重新加载时,就会产生如图 11-6 所示的结果。
图 11-6
将复杂数据表达式放入数据绑定中
表达式只能包含一条语句,并且必须产生一个结果,这意味着不能在数据绑定中调用函数或执行复杂的任务。表达式的上下文是组件,这意味着您只能访问其模板包含数据绑定的组件定义的数据。这就是我能够引用name
、price
和taxRate
属性而不需要以任何方式限定名称的方式,但是这也阻止了我访问浏览器为 JavaScript 代码提供的任何全局对象(例如window
和document
)或由其他组件定义的数据。
表达式还可以访问常用的 JavaScript 全局对象和函数,比如用于访问数学函数和处理 JSON 数据的Math
和JSON
对象。表 11-3 中描述了可以从模板中访问的最有用的全局对象和函数。
表 11-3
可在绑定表达式中访问的有用的全局对象和函数
|名字
|
描述
|
| --- | --- |
| parseFloat
| 这个函数解析一个浮点数值。 |
| parseInt
| 这个函数解析一个整数值。 |
| Math
| 这个对象提供数学函数。 |
| Number
| 该对象提供了用于处理数值的方法。 |
| Date
| 该对象提供了用于处理日期的方法。 |
| Array
| 该对象提供了用于处理数组的方法。 |
| Object
| 该对象提供了用于处理对象的方法。 |
| String
| 该对象提供与字符串相关的方法。 |
| RegExp
| 该对象用于执行正则表达式搜索。 |
| Map
| 该对象用于表示键值对的集合。 |
| Set
| 该对象用于表示唯一值或对象的集合。 |
| JSON
| 该对象用于序列化和反序列化 JSON 数据。 |
| Intl
| 该对象提供对特定于地区的格式的访问,如清单 11-15 所示。该对象提供的特征的细节在 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl
。 |
表 11-3 中的全局对象和函数列表提供了对 JavaScript 开发最常用特性的访问,但不包括对可能用于执行危险操作的对象的访问,例如操作 DOM。
了解未定义的数据属性
将数据绑定评估为表达式的一个结果是很难发现错误。JavaScript 有一种宽松的方法来访问不存在的属性,如果您读取一个尚未定义的属性,它将返回特殊的undefined
值。
Vue.js 对表达式求值,但不将结果插入 HTML 元素,而是在浏览器的 JavaScript 控制台中显示一条警告,如下所示:
...
[Vue warn]: Property or method "category" is not defined on the instance but referenced during render.
...
这是打字错误最常见的问题,其中,data
属性的名称与用于显示其值的数据绑定中使用的名称不匹配,值得在开发过程中观察 JavaScript 控制台,因为这个问题不会产生编译器错误,也不会显示在浏览器错误覆盖中。
使用计算的属性计算值
保持数据绑定中的表达式简单是很好的实践,因为它使模板更容易阅读,更容易维护,并最大化代码重用。为了帮助保持模板简单,Vue.js 提供了 computed property 特性,该特性用于基于data
属性生成值,这样您就不必在数据绑定中包含复杂的表达式。在清单 11-10 中,我定义了一个计算属性,它使用price a
和taxRate
值计算产品的总价。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h3>Price: ${{ totalPrice.toFixed(2) }}</h3>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
taxRate: 12
}
},
computed: {
totalPrice: function() {
return this.price + (this.price * (this.taxRate / 100));
}
}
}
</script>
Listing 11-10Using a Computed Property in the App.vue File in the src Folder
一个computed
属性被添加到组件的 JavaScript 模块中,并被赋予一个 literal 对象,该对象的属性名是计算属性的名称。有些组件特性很难描述,因为它们非常依赖术语属性,你可能需要再读一遍前面的句子才能理解它。
注意
注意,我必须在 computed property 的函数中使用this
关键字来访问data
属性。在我描述不同的组件特性时,您会反复看到这个需求,如果您在script
元素的语句中省略了this
关键字,您会收到一个错误,告诉您没有定义属性。
在清单中,我用一个函数定义了一个名为totalPrice
的计算属性,该函数执行之前包含在数据绑定中的计算。计算属性的使用就像数据绑定中常规的data
属性一样,这意味着我可以像这样简化数据绑定:
...
<h3>Price: ${{ totalPrice.toFixed(2) }}</h3>
...
了解反应性和计算属性
Vue.js 优化了更新过程,只在它所依赖的一个值发生变化时才重新计算一个计算属性。为了演示,我在 computed 属性的函数中添加了一些语句来指示它何时被调用,如清单 11-11 所示。
小费
在大多数项目中,您不需要担心优化反应性,但是了解可用的不同特性以及它们如何协同工作是很有用的,这就是为什么我包含了这个示例。有关 Vue.js 应用中反应性的更多详细信息,请参见第十七章。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h3>Price: ${{ totalPrice.toFixed(2) }}</h3>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
taxRate: 12
}
},
computed: {
totalPrice: function () {
let tp = this.price + (this.price * (this.taxRate / 100));
console.log(`Calculated: ${tp} (${this.taxRate})`);
return tp;
}
}
}
</script>
Listing 11-11Monitoring a Computed Property in the App.vue File in the src Folder
我使用console.log
方法在每次调用该函数时向浏览器的 JavaScript 控制台写入一条消息。
应用还不能让用户更改数据值,所以将更改保存到文件中,并使用 Vue Devtools 增加taxRate
属性的值。每次增加属性时,您都会看到浏览器的 JavaScript 控制台中显示一条消息,反映您所做的更改。
...
Calculated: 310.75 (13)
Calculated: 313.5 (14)
Calculated: 316.25 (15)
...
Vue.js 知道totalPrice
计算的属性依赖于taxRate
的值,并在taxRate
改变时调用该函数获取新值,这样它就可以评估数据绑定表达式。
计算属性中的副作用
在计算属性的函数中包含进行更改的语句是一种不好的做法,这就是所谓的副作用。副作用使得应用更难理解,并且会降低 Vue.js 更新过程的效率。
因此,我在第十章中描述的 Vue.js linter 规则包含了对计算属性的副作用的检查。下面是一个包含副作用的组件:
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h3>Price: ${{ totalPrice.toFixed(2) }}</h3>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
taxRate: 12,
counter: 0
}
},
computed: {
totalPrice: function () {
let tp = this.price + (this.price * (this.taxRate / 100));
console.log(`Calculated: (${this.counter++})
${tp}(${this.taxRate})`);
return tp;
}
}
}
</script>
我添加了一个counter
变量,它的值包含在传递给console.log
方法的字符串中。这种情况下的副作用是counter
属性随着++
值的增加而增加。linter 检测到副作用并报告以下错误:
...
Unexpected side effect in "totalPrice" computed property
console.log(`Calculated: (${this.counter++})
^
...
您可以禁用这个名为vue/no-side-effects-in-computed-properties
的 linter 规则,但是需要小心,因为副作用会产生意想不到的行为,应该避免。
使用方法计算数据值
计算属性的主要限制是不能改变用于产生结果的值。例如,如果我需要使用两个税率计算总成本,我必须使用两个计算属性,每个属性执行类似的计算,如清单 11-12 所示。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h4>Price: ${{ lowTotalPrice.toFixed(2) }} (Low Rate)</h4>
<h4>Price: ${{ highTotalPrice.toFixed(2) }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
lowTaxRate: 12,
highTaxRate: 20
}
},
computed: {
lowTotalPrice: function () {
let tp = this.price + (this.price * (this.lowTaxRate / 100));
return tp;
},
highTotalPrice: function () {
let tp = this.price + (this.price * (this.highTaxRate / 100));
return tp;
}
}
}
</script>
Listing 11-12Computing Similar Values in the App.vue File in the src Folder
现在有两个税率,对每个税率进行计算,产生如图 11-7 所示的结果。
图 11-7
执行多重计算
清单 11-12 中的方法是可行的,但是它的伸缩性不好。根据经验,我不喜欢任何添加值的自然方式是剪切和粘贴现有代码的方法,因为复制代码但无法更新代码以产生正确的值只是时间问题。
更好的方法是定义一个方法,这将允许我重用相同的代码来计算不同的价格,而不需要任何重复。在清单 11-13 中,我定义了一个计算产品价格的方法。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h4>Price: ${{ lowTotalPrice.toFixed(2) }} (Low Rate)</h4>
<h4>Price: ${{ highTotalPrice.toFixed(2) }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
lowTaxRate: 12,
highTaxRate: 20
}
},
computed: {
lowTotalPrice: function () {
return this.getTotalPrice(this.lowTaxRate);
},
highTotalPrice: function () {
return this.getTotalPrice(this.highTaxRate);
}
},
methods: {
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
}
}
</script>
Listing 11-13Defining a Method in the App.vue File in the src Folder
为了定义一个方法,我向组件的配置对象添加了一个methods
属性,并使用它来定义一个函数。与计算属性不同,方法能够定义参数。这个例子中的函数叫做getTotalPrice
,它定义了一个taxRate
参数,用来提供结果。然后,我可以从计算出的属性的函数中调用该方法,如下所示:
...
lowTotalPrice: function () {
return this.getTotalPrice(this.lowTaxRate);
},
...
注意,我必须用关键字this
作为方法名称的前缀,就像我使用数据属性一样。this
关键字不需要访问方法中的参数值,可以直接读取,但是需要访问data
属性值。
...
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
...
直接从数据绑定中调用方法
使用一个方法允许我合并计算总价所需的代码,但是我可以更进一步,通过完全移除计算的属性并直接从数据绑定中调用方法来简化组件,如清单 11-14 所示。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h4>Price: ${{ getTotalPrice(lowTaxRate).toFixed(2) }} (Low Rate)</h4>
<h4>Price: ${{ getTotalPrice(highTaxRate).toFixed(2) }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
lowTaxRate: 12,
highTaxRate: 20
}
},
methods: {
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
}
}
</script>
Listing 11-14Calling Methods Directly in the App.vue File in the src Folder
这种变化显示了不同组件功能协同工作的灵活性,但也是一种倒退,因为它增加了数据绑定中表达式的复杂性。您会发现,通常需要做出一些选择来平衡不同的 Vue.js 特性。
用筛选器格式化数据值
为了向用户显示美元金额,我将一个文本字符(美元符号)与 JavaScript toFixed
方法结合起来,如下所示:
...
<h4>Price: ${{ getTotalPrice(lowTaxRate).toFixed(2) }} (Low Rate)</h4>
...
我可以通过使用过滤器来改进这种方法,过滤器是一种用于格式化表达式结果的函数。在清单 11-15 中,我添加了一个过滤器,将数字值格式化为货币金额。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h4>Price: {{ getTotalPrice(lowTaxRate) | currency }} (Low Rate)</h4>
<h4>Price: {{ getTotalPrice(highTaxRate) | currency }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
lowTaxRate: 12,
highTaxRate: 20
}
},
methods: {
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD" }).format(value);
}
}
}
</script>
Listing 11-15Adding a Filter in the App.vue File in the src Folder
过滤器被定义为组件配置对象中一个filters
属性下的函数。过滤函数通过它们的参数接收值,并返回格式化的结果。在清单中,我定义了一个使用全局Intl
对象格式化值的currency
过滤器,它提供了对本地化格式的访问。我使用了NumberFormat
方法来指定en-US
地区(代表在美国使用的英语),并提供了一个配置对象,指示我想要格式化一个美元货币金额。
应用本地化
我在本书中不描述本地化,因为它不是 Vue.js 特有的功能。但我的建议是仔细考虑本地化,并给予它应有的时间、资源和关注。
本地化是一个复杂的话题,通常处理不好,许多应用只是假设用户会理解并接受在美国常用的惯例。作为一个生活在英国的人,我已经习惯于看到以错误的格式表示的日期和以美元货币符号显示的英镑金额。至少,我可以期望能够阅读和理解大多数书面文本,这对于非英语母语的人来说是不可能的。
但是什么都不做总比本地化应用的工作质量差要好,如果你没有一个能流利地使用每一种目标语言的人,或者如果你不能投入所需的时间和金钱来维护所有目标语言的应用,就会发生这种情况。访问本地化 API(比如我在清单 11-15 中使用的Intl
对象提供的 API)只是本地化应用所需的一小部分,还有一些文化和语言问题无法通过 Google Translate 运行应用的内容来处理。
使用条形符号(|
字符)应用过滤器,如下所示:
...
<h4>Price: {{ getTotalPrice(lowTaxRate) | currency }} (Low Rate)</h4>
...
这个表达式告诉 Vue.js 使用currency
过滤器格式化来自getTotalPrice
方法的结果。向用户显示的内容没有明显的变化,但是使用筛选器简化了数据绑定表达式,有助于确保值的格式一致,从而使组件更加健壮。过滤器可能看起来像配置对象中的其他特性,但是它们有独特的特征,正如我在下面的部分中解释的那样。
使用参数配置过滤器
用于筛选的函数不能访问组件的其余数据,这意味着格式化结果不能基于另一个值。隔离过滤器可以确保它们不会破坏变化检测过程,这样 Vue.js 就不必像方法调用那样,在调用过滤器函数后检查变化。
但是格式化可能是一项复杂的任务,所以 Vue.js 过滤器被允许接受参数。在清单 11-16 中,我给currency
过滤器添加了一个参数,允许指定小数位数。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name }}</h3>
<h4>Price: {{ getTotalPrice(lowTaxRate) | currency(3) }} (Low Rate)</h4>
<h4>Price: {{ getTotalPrice(highTaxRate) | currency }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Kayak",
price: 275,
lowTaxRate: 12,
highTaxRate: 20
}
},
methods: {
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
},
filters: {
currency(value, places) {
return new Intl.NumberFormat("en-US",
{
style: "currency", currency: "USD",
minimumFractionDigits: places || 2,
maximumFractionDigits: places || 2
}).format(value);
}
}
}
</script>
Listing 11-16Adding a Filter Argument in the App.vue File in the src Folder
我向currency
函数添加了一个名为places
的参数,我在格式化表达式中使用该参数来设置传递给Intl.NumberFormat
方法的配置对象的minimumFractionDigits
和maximumFractionDigits
属性,该方法固定了结果中的小数位数。
在为过滤函数定义参数时,使用默认值是一个好主意,在本例中,如果使用不带参数的过滤器,我默认使用值2
。
参数被传递给数据绑定表达式中的筛选器。我修改了清单中的一个绑定来指定三个小数,产生了如图 11-8 所示的结果。
图 11-8
向筛选器添加参数
将过滤器链接在一起
可以组合过滤器,使一个过滤器的输出成为另一个过滤器的输入,形成一个过滤器链。这允许格式化由几个步骤组成,并允许对格式化过程进行细粒度控制。在清单 11-17 中,我定义了两个新的过滤器,并在模板中将它们链接在一起。我还更改了产品细节,以使过滤器的效果更容易看到(因为Kayak
是一个回文,其中一个过滤器反转了字符串中的字符)。
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name | reverse | capitalize }}</h3>
<h4>Price: {{ getTotalPrice(lowTaxRate) | currency(3) }} (Low Rate)</h4>
<h4>Price: {{ getTotalPrice(highTaxRate) | currency }} (High Rate)</h4>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
price: 48.95,
lowTaxRate: 12,
highTaxRate: 20
}
},
methods: {
getTotalPrice(taxRate) {
return this.price + (this.price * (taxRate / 100));
}
},
filters: {
currency(value, places) {
return new Intl.NumberFormat("en-US",
{
style: "currency", currency: "USD",
minimumFractionDigits: places || 2,
maximumFractionDigits: places || 2
}).format(value);
},
capitalize(value) {
return value[0].toUpperCase() + value.slice(1);
},
reverse(value) {
return value.split("").reverse().join("");
}
}
}
</script>
Listing 11-17Chaining Filters in the App.vue File in the src Folder
新的过滤器是capitalize
,它使字符串的第一个字母大写,以及reverse
,它颠倒字符串字符的顺序。使用管道字符将过滤器链接在一起,如下所示:
...
<h3>Product: {{ name | reverse | capitalize }}</h3>
...
这个表达式告诉 Vue.js 使用reverse
过滤器格式化name
值,然后将结果通过capitalize
过滤器,产生如图 11-9 所示的结果。
图 11-9
将过滤器链接在一起
过滤器是按照指定的顺序应用的,更改顺序通常会产生不同的结果。为了演示,我改变了清单 11-18 中链式过滤器的顺序
...
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3>Product: {{ name | capitalize | reverse }}</h3>
<h4>Price: {{ getTotalPrice(lowTaxRate) | currency(3) }} (Low Rate)</h4>
<h4>Price: {{ getTotalPrice(highTaxRate) | currency }} (High Rate)</h4>
</div>
</template>
...
Listing 11-18Changing Filter Order in the App.vue File in the src Folder
name
值被传递到capitalize
滤波器,然后传递到reverse
滤波器,产生如图 11-10 所示的结果。当过滤器按此顺序排列时,capitalize
过滤器没有可辨别的效果,因为name
值的第一个字母已经是大写字母。
图 11-10
更改过滤器排序的效果
定义全局过滤器
当一个过滤器由一个组件定义时,它可以在那个组件的模板及其任何子组件的模板中使用(子组件在第十六章中有解释)。如果您想定义一个过滤器,使其在整个应用中都可用,那么您可以在创建Vue
对象之前使用Vue.filter
方法注册过滤器,如下所示:
...
Vue.filter("currency", (value) => new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD" }).format(value));
...
第一个参数指定应用过滤器的名称,第二个参数是格式化数据值的函数。
摘要
在本章中,我介绍了文本插值绑定,它用于向用户显示数据值。Vue.js 支持将数据模型连接到模板元素的更高级的方法,但是文本插值绑定是最容易使用的,并且为理解 Vue.js 的工作方式提供了基础。在下一章,我将介绍 Vue.js 指令特性。
十二、使用基本指令
指令是将 Vue.js 功能应用于组件模板中 HTML 元素的特殊属性。在这一章中,我将解释如何使用 Vue.js 提供的基本内置指令,这些指令提供了一些 web 应用开发中最常用的特性。在第十三章–15 章中,我描述了更复杂的指令,在第二十六章中,我解释了当内置指令不提供您需要的特性时,如何创建自定义指令。表 12-1 将内置指令放在上下文中。
表 12-1
将内置指令放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 内置指令提供了 web 应用开发中通常需要的特性。我在本章中描述的过滤器用于管理元素的文本或 HTML 内容,决定元素是否对用户可见,以及管理元素的属性。还有用于响应用户交互、重复内容和管理表单元素的指令,将在后面的章节中介绍。 |
| 它们为什么有用? | 指令使得将组件的script
元素中的数据和代码与其template
中的内容联系起来变得容易。 |
| 它们是如何使用的? | 指令作为名称以v-
开头的特殊属性应用于 HTML 元素,例如v-text
和v-bind
。 |
| 有什么陷阱或限制吗? | 有些内置指令很难使用,可能会产生意想不到的结果。对于后面章节中描述的指令来说,情况更是如此。 |
| 还有其他选择吗? | 不。指令是连接组件的 HTML 元素和 JavaScript 代码的 Vue.js 构建块。 |
表 12-2 总结了本章内容。
表 12-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 设置元素的文本内容 | 使用文本插值绑定或v-text
指令 | three |
| 显示原始 HTML | 使用v-html
指令 | 4–5 |
| 选择性显示元素 | 使用v-if
、v-else
或v-show
指令 | 6, 9–13 |
| 选择性显示对等元素 | 将指令应用于一个template
元素 | 7–8 |
| 设置属性和特性 | 使用v-bind
指令 | 14–19 |
为本章做准备
在本章中,我继续使用在第十一章中创建的templatesanddata
项目。为了准备本章,我简化了应用的根组件,如清单 12-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: {{ name }}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 12-1Simplifying the Content of the App.vue File in the src Folder
我已经使用了文本插值绑定来显示名为name
的data
属性的值,如第十一章所述。我还添加了一个button
元素,并对其应用了v-on
指令,如下所示:
...
<button v-on:click="handleClick" class="btn btn-primary">
...
正如我在第十四章中详细描述的那样,v-on
指令用于处理事件。我在本章中描述的一些指令有一些有用的特性,这些特性只有在应用的状态改变时才能看到,我需要一种机制来触发这些改变。该指令被配置为通过调用一个名为handleClick
的方法来响应click
事件,该事件在用户单击button
元素时被触发。我已经在组件的script
元素的methods
部分定义了这个方法,但是目前它不包含任何语句。在本章的后面,我将使用handleClick
方法来演示一些有用的指令特性。保存对App.vue
文件的更改,并运行templatesanddata
文件夹中清单 12-2 所示的命令,启动 Vue.js 开发工具。
npm run serve
Listing 12-2Starting the Development Tools
打开一个新的浏览器窗口并导航至http://localhost:8080
以查看图 12-1 所示的内容。
图 12-1
运行示例应用
设置元素的文本内容
一个好的起点是最基本的指令,它执行您已经熟悉的任务:设置元素的文本内容。在清单 12-3 中,我用一个指令替换了组件模板中的文本插值绑定。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: <span v-text="name"></span></h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 12-3Using a Directive in the App.vue File in the src Folder
这是v-text
指令,以用于将指令应用于 HTML 元素的属性命名。图 12-2 显示了该指令及其应用的元素的剖析。
图 12-2
v-text 指令的剖析
使用一个v-text
属性来应用该指令。该属性的值是一个表达式,Vue.js 对其求值以获得应该向用户显示的内容。这与我在第十一章中描述的文本插值绑定使用的表达式类型相同,这个绑定的结果是显示名为name
的data
属性的值。
与文本插值绑定不同,v-text
指令完全替换了它所应用的元素的内容,这就是为什么我在清单 12-3 的模板中添加了一个span
元素。
当检测到变化时,Vue.js 重新评估指令的表达式,这可以通过使用 Vue Devtools 在浏览器中改变name
属性的值来测试,如图 12-3 所示。
图 12-3
值的变化对指令的影响
显示原始 HTML
文本插值绑定和v-text
指令自动清理它们显示的内容,删除浏览器可能解释为 HTML 文档结构一部分的任何字符。净化数据值有助于防止跨站点脚本(XSS)攻击,在这种攻击中,浏览器将数据值解释为 HTML,并允许攻击者在浏览器中插入内容或代码。(你可以在 https://en.wikipedia.org/wiki/Cross-site_scripting
了解更多关于 XSS 攻击的工作原理。)为了演示,我添加了一个data
属性,其内容是一个script
元素,如清单 12-4 所示。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: <span v-text="name"></span></h3>
<span v-text="fragment"></span>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
fragment: `<div class="form-group">
Password
<input class="form-control" />
</div>`
}
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 12-4Adding a Data Property in the App.vue File in the src Folder
新的data
属性被称为fragment
,它的值是一组包含一个input
元素的 HTML 元素。在组件的模板中,我添加了一个span
元素并应用了v-text
绑定,该绑定将用fragment
值替换元素的内容。当您保存更改时,应用将被更新,您将看到 HTML 的片段已经变得安全,如图 12-4 所示。
图 12-4
净化数据值
净化数据值是一个好主意,并且在处理用户提供的数据时非常重要。但是,当您处理可信任的内容时,有时您可能希望将其视为 HTML,而清理会阻止数据正确显示。对于这些情况,Vue.js 提供了v-html
指令,我在清单 12-5 中使用了这个指令。
警告
除非您信任所显示数据的来源,否则不要使用此功能。
...
<template>
<div class="bg-primary text-white text-center m-2 p-3">
<h3 v-text="name" >Product:<span v-text="name"></span></h3>
<span v-html="fragment"></span>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</template>
...
Listing 12-5Displaying HTML Content in the App.vue File in the src Folder
v-html
指令的应用方式与v-text
相同,但显示的数据值未经净化,如图 12-5 所示。如果没有净化,浏览器会将数据值解释为 HTML 元素,并向用户呈现一个input
元素。
图 12-5
显示 HTML 数据值
选择性显示元素
组件显示的元素集经常需要更改,以适应组件状态的变化。Vue.js 包括一组指令,这些指令根据数据绑定表达式的计算结果来更改应用它们的 HTML 元素的可见性。在清单 12-6 中,我使用了v-if
指令来控制 HTML 元素的可见性。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: <span v-text="name"></span></h3>
<h4 v-if="showElements">{{ price }}</h4>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
price: 275,
showElements: true
}
},
methods: {
handleClick() {
this.showElements = !this.showElements;
}
}
}
</script>
Listing 12-6Selectively Displaying Content in the App.vue File in the src Folder
在这个例子中,我将v-if
指令应用于一个h4
元素,如下所示:
...
<h4 v-if="showElements">{{ price }}</h4>
...
该指令将评估其表达式,并使用结果来控制h4
元素的可见性。如果表达式结果为真,则元素可见,否则隐藏(参见 JavaScript 真值的“理解真值和假值”侧栏)。结果是,当名为showElements
的数据属性为true
时,h4
元素将可见,当其为false
时,元素将隐藏。
我在handleClick
方法中添加了一条语句,当点击按钮时,该语句切换showElements
值,这演示了当表达式结果改变时,v-if
指令改变了它所应用的元素的可见性,如图 12-6 所示。
小费
为了控制可见性,v-if
指令销毁并重新创建元素及其内容,或者,如果元素是相同的类型,重用单个元素来显示不同的内容。这意味着只有可见的元素才是 DOM 的一部分。使用本章稍后描述的v-show
指令在 DOM 中保留一个元素,并使用 CSS 属性管理其可见性。
图 12-6
控制元素的可见性
理解真理和谬误
像v-if
这样的指令评估它们的表达式,以确定它们是真还是假,这是一个奇怪的 JavaScript 特性,经常导致混乱,并为粗心的人提供了一个陷阱。以下结果总是假的:
-
–值
false
(boolean
) -
–
0
(数字)值 -
–空字符串(
""
) -
–
null
-
–
undefined
-
–
NaN
(特殊数值)
所有其他值都是真实的,这可能会令人困惑。例如,"false"
(内容为单词false
的字符串)为 truthy。避免混淆的最好方法是只使用评估为boolean
值true
和false
的表达式。
选择性地显示相邻的对等元素
使用v-if
指令的标准方式是将它直接应用于可见性被管理的顶层元素。如果在同一个层次上有几个元素的可见性由同一个表达式控制,那么这种方法就变得很笨拙,如清单 12-7 所示。
...
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: <span v-text="name"></span></h3>
<ul class="text-left">
<li>List item</li>
<li v-if="showElements">{{name}}</li>
<li v-if="showElements">{{price}}</li>
<li>Other list item</li>
</ul>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
...
Listing 12-7Applying the Same Directive to Peer Elements in the App.vue File in the src Folder
我想基于相同的数据绑定表达式来控制列表中四个li
元素中的两个的可见性。重复应用指令是重复且容易出错的,最好避免。对于某些元素,这个问题可以通过添加一个中性元素作为公共父元素来解决,比如一个div
或span
元素,但这在这里不起作用,因为结果将是非法的 HTML ( ul
元素不允许包含div
或span
元素)。
在这些情况下,template
元素可以被用作公共父元素,如清单 12-8 所示。
...
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3>Product: <span v-text="name"></span></h3>
<ul class="text-left">
<li>List item</li>
<template v-if="showElements">
<li>{{name}}</li>
<li>{{price}}</li>
</template>
<li>Other list item</li>
</ul>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
...
Listing 12-8Using a Template Element in the App.vue File in the src Folder
该指令应用于template
元素,该元素在编译过程中被删除,不会导致非法的 HTML。结果是使用v-if
指令的单个实例来管理相邻的li
元素,结果如图 12-7 所示。
小费
您可以使用v-for
指令和一个 computed 属性对不相邻的元素实现类似的效果。第十三章中描述了v-for
指令。
图 12-7
使用模板元素将对等元素分组
在内容部分之间选择
如果你想显示基于数据值的替代内容,那么你可以重复v-if
指令并否定其中一个表达式,如清单 12-9 所示。
...
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-if="showElements">Product: {{name}}</h3>
<h3 v-if="!showElements">Price: {{price}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
...
Listing 12-9Choosing Content to Display in the App.vue File in the src Folder
这种方法可行,但是很笨拙,当显示元素的标准改变时,您必须记住更新两个表达式。当表达式比检查值是否为true
更复杂时,这种方法也会变得复杂。为了避免这种表达式,Vue.js 提供了v-else
指令,它与v-if
一起工作,不需要自己的表达式,如清单 12-10 所示。
...
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-if="showElements">Product: {{name}}</h3>
<h3 v-else>Price: {{price}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
...
Listing 12-10Simplifying Content Selection in the App.vue File in the src Folder
在v-if
之后立即应用v-else
指令,与v-if
指令相反,它会自动改变它所应用到的元素的可见性,如图 12-8 所示。
图 12-8
在内容部分之间选择
执行更复杂的选择
如果一个基本的 if/else 方法不够,那么您还可以使用v-else-if
指令,它与v-if
和v-else
结合使用来选择元素,并且有自己的表达式。如果对v-if
表达式求值的结果是false
,则检查v-else-if
指令的表达式,看其元素是否应该显示,如果不是,则显示v-else
元素。可以使用v-else-if
指令的多个实例来管理复杂的选择,如清单 12-11 所示。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-if="counter % 3 == 0">Product: {{name}}</h3>
<h3 v-else-if="counter % 3 == 1">Price: {{price}}</h3>
<h3 v-else>Category: {{category}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
price: 275,
category: "Watersports",
counter: 0
}
},
methods: {
handleClick() {
this.counter++;
}
}
}
</script>
Listing 12-11Performing a Complex Content Selection in the App.vue File in the src Folder
v-if
和v-else-if
指令的表达式依赖于一个counter
属性,该属性的值在按钮被单击时递增。还有一个v-else
指令,当其他两个指令表达式都为假时,将显示其元素,产生如图 12-9 所示的结果。
图 12-9
执行更复杂的选择
使用 CSS 有选择地显示元素
v-if
、v-else-if
和v-else
指令隐藏元素,将它们从文档对象模型(DOM)中移除,并再次重新创建它们以使它们可见。结果是 DOM 只包含用户可见的元素,这可能是一个问题,尤其是在使用基于元素在父元素中的位置来选择元素的 CSS 样式时。清单 12-12 展示了可能出现的问题类型。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-if="counter % 2 == 0">Product: {{name}}</h3>
<h3 v-else>Price: {{price}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
price: 275,
counter: 0
}
},
methods: {
handleClick() {
this.counter++;
}
}
}
</script>
<style>
h3:first-child { background-color: aquamarine; padding: 10px; color: black; }
</style>
Listing 12-12Adding Position-Specific CSS in the App.vue File in the src Folder
我已经简化了模板,所以只有两个 header 元素,我对它们应用了v-if
和v-else
指令。我还添加了一个style
元素,它包含一个带有h3:first-child
选择器的样式,该样式匹配作为其父元素的第一个子元素的h3
元素。
当您保存更改并单击“按我”按钮时,您可以看到出现的问题。这些指令确保只有一个h3
元素是可见的,但是由于不可见的元素被从 DOM 中移除,可见的元素是其父元素的第一个也是唯一的子元素,并且总是被 CSS 选择器匹配,如图 12-10 所示。
小费
您可能需要重新加载浏览器才能看到style
元素的效果。
图 12-10
由位置 CSS 选择器匹配的元素
当 CSS 的意图是只改变显示产品名称的元素的样式属性时,这种行为是一个问题。这些样式不会影响 price 元素,因为它是模板中其父元素的第二个子元素,但是从 DOM 中移除不可见元素的方式会导致意外的结果。
在清单 12-12 中,我可以通过改变我的 CSS 选择器来解决这个问题,但是当处理应用于整个应用的全局样式或者使用第三方 CSS 框架比如 Bootstrap 时,这并不总是可能的。在这些情况下,v-show
指令是一个合适的选择,因为它具有与v-if
相同的效果,但是不会从 DOM 中删除不可见的元素。在清单 12-13 中,我已经对组件应用了v-show
指令。
...
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-show="counter % 2 == 0">Product: {{name}}</h3>
<h3 v-show="counter % 2 != 0">Price: {{price}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
...
Listing 12-13Leaving Invisible Elements in the DOM in the App.vue File in the src Folder
v-show
指令的应用方式与v-if
相同,可用作直接替换。保存更改并点击按我按钮来增加计数器并使v-show
指令显示和隐藏它们的内容。通过使用浏览器的 F12 工具在 DOM 中检查元素,您可以看到v-show
是如何隐藏元素的,这将显示元素保留在 DOM 中,并且它们的 display 属性设置为 none,如下所示:
...
<h3 style="display: none;">Price: 275</h3>
...
由于元素只是被隐藏,而不是被删除,组件的样式被应用,如图 12-11 所示。
图 12-11
隐藏元素
设置display
属性可能比从 DOM 中移除一个元素更有效,但是v-show
指令不能与template
元素一起使用,也没有与v-else-if
和v-else
指令等价的指令,这就是为什么我不得不将v-show
指令应用于两个h3
元素。
设置元素的属性和特性
v-bind
指令用于设置元素的属性或特性。我从关注属性开始这一部分,之后我将解释为什么属性是不同的。在清单 12-14 中,我使用了v-bind
指令将元素分配给对应于引导 CSS 样式的类,这是v-bind
指令最常见的用法。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind:class="elemClasses">Product: {{name}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight: false
}
},
computed: {
elemClasses() {
return this.highlight
? ["bg-light", "text-dark", "display-4"]
: ["bg-dark", "text-light", "p-2"];
}
},
methods: {
handleClick() {
this.highlight = !this.highlight;
}
}
}
</script>
Listing 12-14Assigning Elements to Classes in the App.vue File in the src Folder
这个例子看起来比实际更复杂。起点是指令,我将它应用于h3
元素,如下所示:
...
<h3 v-bind:class="elemClasses">Product: {{name}}</h3>
...
v-bind
指令配置了一个参数和一个表达式,如图 12-12 所示。参数指定指令将配置的元素属性,对表达式求值将提供该属性的值。
图 12-12
v-bind 指令的剖析
在清单 12-14 中,指令的表达式获取一个计算属性的值,该属性返回一个样式数组,该数组的内容由名为highlight
的data
属性的值决定。这看起来像是一种间接的设置样式的方式,但是它展示了一种灵活的方式,可以将 Vue.js 特性结合起来管理呈现给用户的内容。要查看效果,保存对App.vue
文件的更改并点击按我按钮切换highlight
属性的值,产生如图 12-13 所示的结果。
图 12-13
设置元素的 class 属性
单击该按钮可以在两组类之间更改主体元素的成员资格。当highlight
属性为true
时,宿主元素是bg-light
、text-dark
和display-4
类的成员(浅背景色、深色文本和大字体)。当highlight
属性为false
时,宿主元素是bg-dark
、text-light
和p-2
类的成员(深色背景色、浅色文本和额外填充)。
使用指令速记
v-bind
指令有两种形式。我在前面的例子中使用的是手写形式,它结合了指令名、冒号和要配置的属性名。简写形式省略了指令名,这样v-bind:class
也可以表示为:class
。这意味着像这样的指令:
...
<h3 v-bind:class="elemClasses">Product: {{name}}</h3>
...
也可以这样应用:
...
<h3 :class="elemClasses">Product: {{name}}</h3>
...
在长格式和简写格式之间的选择是个人喜好,不会改变指令的行为方式。
使用对象配置类
如果有多个输入来决定主机元素应该属于哪一组类,那么我在上一节中使用的数组语法可能会变得难以管理。指令v-bind
也可以使用一个对象,其属性名对应于类,其值决定了主机元素的类成员资格,如清单 12-15 所示。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind:class="elemClasses" class="display-4">Product: {{name}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
<button v-on:click="handleOtherClick" class="btn btn-primary">
Or Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight1: false,
highlight2: false
}
},
computed: {
elemClasses() {
return {
"text-dark": this.highlight1,
"bg-light": this.highlight2
}
}
},
methods: {
handleClick() {
this.highlight1 = !this.highlight1;
},
handleOtherClick() {
this.highlight2 = !this.highlight2;
}
}
}
</script>
Listing 12-15Using an Object to Control Class Membership with the App.vue File in the src Folder
我添加了另一个button
元素和一个切换data
属性值的方法。elemClasses
计算属性返回这样一个对象:
...
return {
"text-dark": this.highlight1,
"bg-light": this.highlight2
}
...
对象属性名根据两个数据属性的值控制主机元素的bg-light
和text-dark
类的成员资格。v-bind
指令将其主机元素添加到那些对应于值为true
的属性的类中,并从其他类中移除该元素。
小费
我将清单 12-15 中的计算属性返回的对象中的属性名放在引号中。Bootstrap CSS 框架使用的类名包含连字符,只有用引号括起来时,才允许在对象属性名中使用连字符。
我仍然能够在模板中为那些类使用class
属性,主机元素应该总是这些类的成员。
...
<h3 v-bind:class="elemClasses" class="display-4">Product: {{name}}</h3>
...
结果是h3
元素将始终是display-4
类的成员,并根据按钮切换的值属于bg-light
和text-dark
类,如图 12-14 所示。
图 12-14
使用对象配置类成员资格
设置个人风格
v-bind
属性为设置style
属性提供了与类相同的特性,这意味着可以管理单个 CSS 样式属性。在清单 12-16 中,我使用了该指令来控制不同样式属性的值。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind:style="elemStyles" class="display-4">Product: {{name}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight: false,
}
},
computed: {
elemStyles() {
return {
"border": "5px solid red",
"background-color": this.highlight ? "coral": ""
}
}
},
methods: {
handleClick() {
this.highlight = !this.highlight;
}
}
}
</script>
Listing 12-16Managing the Style Attribute in the App.vue File in the src Folder
elemStyles
computed 属性返回一个属性名为 CSS 属性名的对象。border
属性是一个常量值,但是background-color
属性是由highlighted
属性的值决定的,该值在单击按钮时会改变。随着数据属性的改变,h3
元素上的background-color
属性的值也会改变,如图 12-15 所示。
图 12-15
设置单个样式属性
设置其他属性
v-bind
指令可以用来设置任何属性的值,尽管没有对允许使用对象和数组的class
和style
属性的特殊支持。在清单 12-17 中,我使用了v-bind
指令来设置与style
元素中定义的选择器相匹配的自定义属性的值。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind:data-size="size" class="display-4">Product: {{name}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight: false,
}
},
computed: {
size() {
return this.highlight ? "big" : "small";
}
},
methods: {
handleClick() {
this.highlight = !this.highlight;
}
}
}
</script>
<style>
[data-size=big] { font-size: 40pt; }
[data-size=small] { font-size: 20pt; }
</style>
Listing 12-17Setting a Custom Attribute in the App.vue File in the src Folder
HTML 规范允许名称以data-
开头的定制属性应用于任何元素。当使用 Vue.js 时,您不需要在自定义属性前加上前缀data-
,但是当容易地识别特定于我的应用的属性很重要时,这是我遵循的惯例。
在这个例子中,我使用了v-bind
指令来管理一个自定义data-size
属性的值,该属性的值取自一个名为size
的计算属性,该属性返回big
或small
。这些值对应于style
元素中的选择器,它改变字体大小,产生如图 12-16 所示的结果。
小费
您可能需要重新加载浏览器才能看到清单 12-17 中style
元素的效果。
图 12-16
设置自定义属性
设置多个属性
单个v-bind
指令可以设置多个属性。将指令应用于其宿主元素时,不使用任何参数。相反,表达式必须产生一个对象,其属性名代表要配置的属性,如清单 12-18 所示。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind="attrValues">Product: {{name}}</h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight: false,
}
},
computed: {
attrValues() {
return {
class: this.highlight ? ["bg-light", "text-dark"] : [],
style: {
border: this.highlight ? "5px solid red": ""
},
"data-size": this.highlight ? "big" : "small"
}
}
},
methods: {
handleClick() {
this.highlight = !this.highlight;
}
}
}
</script>
<style>
[data-size=big] { font-size: 40pt; }
[data-size=small] { font-size: 20pt; }
</style>
Listing 12-18Setting Multiple Attributes in the App.vue File in the src Folder
由attrValues
computed 属性返回的对象定义了class
、style
和data-size
属性,这些属性的值由名为highlight
的数据属性的值决定,单击按钮即可切换。当highlight
值为false
时,指令所应用的h3
元素配置如下:
...
<h3 class="" style="" data-size="small">Product: Lifejacket</h3>
...
当highlight
值为true
时,v-bind
指令修改元素如下:
...
<h3 class="bg-light text-dark" style="border: 5px solid red;" data-size="big">
Product: Lifejacket
</h3>
...
一个绑定管理多个属性,产生如图 12-17 所示的结果。
图 12-17
用单个绑定管理多个属性
设置 HTMLElement 属性
默认情况下,v-bind
指令配置主机元素的属性,如前面的示例所示。也可以使用v-bind
指令来设置文档对象模型中表示元素的对象的属性值。
当浏览器处理 HTML 文档时,它创建文档对象模型并用表示 HTML 元素的对象填充它。这些对象定义了与 HTML 元素支持的属性不对应的属性,或者是因为它们提供了专门的特性,或者是因为 HTML 和 DOM 规范中的一些奇怪之处,这些并没有得到很好的管理。
在大多数项目中,你并不需要这个特性,但是如果你发现设置一个属性并不能得到需要的结果,那么你可以在v-bind
指令中使用prop
修饰符,如清单 12-19 所示。
<template>
<div class="container-fluid text-center">
<div class="bg-primary text-white m-2 p-3">
<h3 v-bind:text-content.prop="textContent"></h3>
</div>
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket",
highlight: false,
}
},
computed: {
textContent() {
return this.highlight ? "Highlight!" : `Product: ${this.name}`;
}
},
methods: {
handleClick() {
this.highlight = !this.highlight;
}
}
}
</script>
Listing 12-19Setting an Element Property in the App.vue File in the src Folder
将指示词套用至元素时,修饰词会用在属性名称之后,以句点分隔,如下所示:
...
<h3 v-bind:text-content.prop="textContent">Product: {{name}}</h3>
...
这个配置告诉v-bind
指令管理名为text-content
的对象属性的值。text-content
属性提供了对元素文本内容的访问,本例设置了h3
元素的内容,如图 12-18 所示。
了解元素属性
Mozilla Foundation 为所有用于在 DOM 中表示 HTML 元素的对象提供了一个有用的参考。对于每个元素,Mozilla 提供了可用属性的摘要以及每个属性的用途。从HTMLElement
( developer.mozilla.org/en-US/docs/Web/API/HTMLElement
)开始,它提供了所有元素共有的功能。然后,您可以分支到特定元素的对象中,比如用于表示input
元素的HTMLInputElement
。
图 12-18
设置元素属性
摘要
在这一章中,我描述了 Vue.js 为处理 HTML 元素提供的一些内置指令。我向您展示了如何使用v-text
和v-html
指令管理元素的内容,如何使用v-if
和v-show
指令选择性地显示内容,以及如何使用v-bind
指令设置元素的属性。在下一章中,我将描述用于为数组中的每一项重复内容的指令。
十三、使用重复器指令
在本章中,我将继续描述内置的 Vue.js 指令,并将重点放在v-for
指令上,它通常用于填充列表并为表格和网格布局生成行。表 13-1 将v-for
指令置于上下文中。
表 13-1
将 v-for 指令放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | v-for 指令用于为数组中的每个项目或对象定义的每个属性复制一组 HTML 元素。 |
| 为什么有用? | v-for 指令定义了一个变量,该变量提供对正在处理的对象的访问,可以在数据绑定中使用该变量来自定义重复的 HTML 元素。 |
| 如何使用? | v-for 指令应用于要复制的顶级元素,它的表达式指定了对象的源和变量名,通过它们可以在数据绑定中引用每个对象。 |
| 有什么陷阱或限制吗? | v-for 指令不支持 Set 和 Map 等 JavaScript 集合,必须注意对象定义的属性的枚举顺序。 |
| 还有其他选择吗? | 你可以编写一个自定义指令,如第二十六章所述,来执行类似的任务,但是 v-for 指令包含了许多优化,使得它在处理大型数据集时更有效。 |
表 13-2 总结了本章内容。
表 13-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 对数组中的每个对象或对象定义的每个属性重复相同的元素集 | 使用v-for
指令 | 3, 13, 15–16 |
| 引用复制的元素集中的当前对象 | 使用v-for
指令的别名功能 | four |
| 将 HTML 元素与特定对象相关联 | 使用v-bind
指令定义一个key
属性 | 5–7 |
| 引用当前对象在数组中的位置 | 使用v-for
指令的索引功能 | eight |
| 确保检测到对数组索引的更改 | 使用Vue.set
方法 | 9–11 |
| 没有数据源的重复元素 | 在v-for
指令的表达式中使用一个数字值代替数据源 | Fourteen |
为本章做准备
在这一章中,我继续使用第十二章中的templatesanddata
项目。为了准备本章,我简化了应用的根组件,如清单 13-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3>Product: {{ name }}</h3>
</div>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 13-1Simplifying the Content of the App.vue File in the src Folder
保存对App.vue
文件的修改,运行templatesanddata
文件夹中清单 13-2 所示的命令,启动 Vue.js 开发工具。
npm run serve
Listing 13-2Starting the Development Tools
打开一个新的浏览器窗口并导航至http://localhost:8080
以查看如图 13-1 所示的内容。
图 13-1
运行示例应用
枚举数组
大多数应用处理必须呈现给用户的相关对象的数组,通常是为了在表格或网格布局中创建行。指令用于为数组中的每个对象重复一组 HTML 元素。在清单 13-3 中,我使用了v-for
指令来枚举对象数组以填充一个表。
注意
对于本节中的示例,您将看到一些 linter 警告。当我介绍由v-for
指令提供的特性时,这些警告将被处理。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered table-striped text-left">
<tr><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="p in products">
<td>Name</td>
<td>Category</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 }]
}
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 13-3Enumerating an Array in the App.vue File in the src Folder
名为products
的data
属性的值是一个对象数组,v-for
指令处理数组中的每个对象以产生如图 13-2 所示的内容。这还不是一个特别有用的结果,但是v-for
指令会引起混淆,需要仔细解释。
图 13-2
枚举对象以创建表格行
我在模板中应用了v-for
指令,如下所示:
...
<tr v-for="p in products">
<td>Name</td>
<td>Category</td>
</tr>
...
v-for
指令的表达式必须是特定的形式: <别名>在<源> 中。 source 项是要被处理的对象的源,并且在本例中指定了名为products
的数据属性。in
关键字将数组与别名分开,后者是一个临时变量,在处理时分配给数组中的每个对象。
在示例中,别名是p
,源是products
。应用该指令告诉v-for
为 products 数组中的每个对象复制tr
元素和它包含的两个td
元素,如下所示:
...
<tbody>
<tr><td>Name</td><td>Category</td></tr>
<tr><td>Name</td> <td>Category</td></tr>
<tr><td>Name</td> <td>Category</td></tr>
</tbody>
...
product
数组包含三个对象,因此v-for
指令复制了三次tr
和td
元素。
使用别名
前面的示例为源中的每个对象重复了相同的内容。大多数应用需要定制为每个对象生成的内容,这就是别名的作用,别名是数组中的每个对象在被v-for
指令处理时被赋予的变量。别名很有用,因为它可以用在由v-for
指令重复的元素内的数据绑定中,如清单 13-4 所示。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered table-striped text-left">
<tr><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="p in products">
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 }]
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
// do nothing
}
}
}
</script>
Listing 13-4Using the v-for Alias in the App.vue File in the src Folder
本例中别名变量的名称是p
,我使用了文本插值绑定,通过别名显示每个产品的name
和price
属性的值,绑定表达式为p.name
和p.price
。
...
<tr v-for="p in products">
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
</tr>
...
我在第十一章中描述的所有文本插值特性都可以与一个v-for
别名一起使用,我恢复了currency
过滤器并使用它来格式化价格属性,产生了如图 13-3 所示的结果。
图 13-3
使用 v-for 指令别名
在数据绑定中包含分配给别名的对象的能力使得v-for
指令非常有用,允许为每个被处理的对象生成不同的内容。
识别钥匙
如果您查看开发工具的输出——无论是在命令行上还是在浏览器的 JavaScript 控制台上——您将会看到 linter 正在报告一个警告。
...
error: Elements in iteration expect to have 'v-bind:key' directives (vue/require-v-for-key)
5 | <tr><th>Name</th><th>Price</th></tr>
6 | <tbody>
> 7 | <tr v-for="p in products">
| ^
8 | <td>{{ p.name }}</td>
9 | <td>{{ p.price | currency }}</td>
10 | </tr>
...
这个警告与v-for
指令处理对象顺序变化的方式有关。默认情况下,v-for
指令通过更新它所创建的每个元素显示的内容来响应它所处理的对象顺序的变化。在本例中,这意味着重新访问已经创建的每个tr
元素,并更新包含在td
元素中的文本。
要了解这些变化是如何进行的,需要做一些工作。在清单 13-5 中,我在handleClick
方法中添加了一条语句,从数组中移除第一项,并在末尾再次插入它。我还向组件添加了一个style
元素,其样式选择了一个id
为tagged
的元素,并设置了它的背景色。
...
<script>
export default {
name: "MyComponent",
data: function () {
return {
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 }]
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
this.products.push(this.products.shift());
}
}
}
</script>
<style>
#tagged { background-color: coral; }
</style>
...
Listing 13-5Adding a Style in the App.vue File in the src Folder
保存更改,应用更新后,打开浏览器的 F12 开发工具,切换到控制台面板,执行清单 13-6 中所示的语句。
document.querySelector("tbody > tr").id = "tagged"
Listing 13-6Marking an Element
该语句使用 DOM API 选择第一个tr
元素,它是tbody
元素的子元素,并将其 ID 设置为tagged
,这对应于清单 13-6 中样式的选择器。该命令一执行,表体的第一行就以不同的背景色显示。
点击按我按钮,查看v-for
指令处理对象顺序变化的默认方式。每次handleClick
方法改变数组中对象的顺序,v-for
指令就会更新它所创建的元素的内容,如图 13-4 所示。
图 13-4
就地更新元素的内容
v-for
指令必须更新它创建的所有元素,因为它不知道如何将它们与对象关联起来,因为数组已经被修改了。
给指令提供一个关于哪个对象与哪个元素相关的提示意味着选择一个属性并把它作为唯一键,然后使用v-bind
指令来识别它,如清单 13-7 所示。
...
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered table-striped text-left">
<tr><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.name">
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
...
Listing 13-7Selecting a Key in the App.vue File in the src Folder
v-bind
指令用于设置一个名为key
的属性,其值使用在v-for
指令表达式中定义的别名来表示。在本例中,我使用了p
作为v-for
别名,并且我想使用name
属性作为数据对象的键,所以我使用了p.name
作为v-bind
表达式。
保存对组件的更改,一旦应用更新,再次执行清单 13-6 中所示的语句来标记table
主体中的第一个tr
元素。背景颜色改变后,点击按钮改变products
数组中对象的顺序。既然v-for
指令知道如何计算出哪个对象与每组元素相关联,它就能够通过移动元素来响应数组顺序的变化,如图 13-5 所示。
小费
如果您想要默认行为,可以禁用需要键的 linter 规则,这对于少量对象来说会更快。参见清单 13-14 中的示例。
图 13-5
移动元素以响应更改
获取项目索引
v-for
指令支持一个附加变量,该变量被分配给数组中当前对象的索引,并可用于多种用途,如在表格中显示行号或用样式定位元素。在清单 13-8 中,我向显示索引的表格中添加了一个新列,并使用v-bind
指令在我应用了样式的表格行上设置了一个属性。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Index</th><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="(p, i) in products"
v-bind:key="p.name" v-bind:odd="i % 2 == 0">
<td>{{ i + 1 }}</td>
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 },
{ name: "Corner Flags", price: 39.95 },
{ name: "Stadium", price: 79500 },
{ name: "Thinking Cap", price: 16 }
]
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
this.products.push(this.products.shift());
}
}
}
</script>
<style>
[odd]{ background-color: lightblue; }
</style>
Listing 13-8Using a v-for Index in the App.vue File in the src Folder
要使用索引功能,在应用v-for
表达式时定义一个附加变量,如下所示:
...
<tr v-for="(p, i) in products" v-bind:key="p.name" v-bind:odd="i % 2 == 0">
...
使用逗号(,
字符)将索引变量与别名分开,两个变量都用括号括起来。当v-for
指令枚举对象时,它将当前对象分配给p
,并将i
设置为当前对象在数组中的位置,从零开始。index 变量可以在数据绑定中使用,就像任何其他数据值一样。在清单中,我使用文本插值绑定来设置一个新表列的内容,使用一个表达式向用户呈现一个以 1 而不是零开始的序列:
...
<td>{{ i + 1 }}</td>
...
我还使用索引在创建的每个tr
元素上设置一个名为odd
的自定义属性,并使用模运算符来指示一行是否为奇数。
...
<tr v-for="(p, i) in products" v-bind:key="p.name" v-bind:odd="i % 2 == 0">
...
其效果是奇数行成为odd
类的成员,该类匹配style
元素中的选择器,并对表中的行进行条带化,如图 13-6 所示。我在清单 13-8 的products
数组中添加了更多的项目来强调结果。
V-FOR 指令的扩展形式
看起来很奇怪,我能够使用一个应用于与v-for
相同元素的v-bind
指令来访问清单 13-8 中的索引变量。这之所以有效,是因为我使用了简洁形式的v-for
指令,它应用于应该为每个数据对象复制的最外层元素。这相当于使用一个template
项目,就像这样:
...
<tbody>
<template v-for="(p, i) in products" >
<tr v-bind:key="p.name" v-bind:odd="i % 2 == 0">
<td>{{ i + 1 }}</td>
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
</tr>
</template>
</tbody>
...
这等同于清单 13-8 中应用的指令,但更清楚地显示了指令及其内容交互的方式。除非需要为每个数据对象复制多个对等元素,否则不需要将template
元素与v-for
指令一起使用。如果您确实使用了一个template
元素,请记住,key
属性的v-bind
指令必须应用于复制的元素,而不是模板。
图 13-6
使用 v-for 指令的索引功能
使用 CSS 对表格行进行条带化
在表格或网格布局中交替颜色是一种常见的需求,但是有一种比依赖于由v-for
指令提供的索引特性更简单的方法。大多数 CSS 框架,包括 Bootstrap,都实现了表分条,但是如果你依赖于自定义样式,那么你可以在你的组件的style
元素中使用 CSS 选择器:
...
<style>
tbody > tr:nth-child(even) { background-color: coral; }
tbody > tr:nth-child(odd) { background-color: lightblue; }
</style>
...
这些样式的选择器匹配odd
甚至是tr
元素,它们是tbody
元素的子元素。这些选择器的重要部分是:n-child(odd)
和:nth-child(even)
,可以用来选择任意奇数和偶数元素。
了解数组更改检测
Vue.js 将使用以下方法检测对数组的更改:push
、pop
、shift
、unshift
、splice
、sort
和reverse
。这些被称为可变数组方法,因为它们改变数组的内容,这允许 Vue.js 保持对数组对象的引用并观察变化。这是我在清单 13-8 中所做的更改类型,以展示关键特性:
...
handleClick() {
this.products.push(this.products.shift());
}
...
我使用shift
方法从数组中移除一个对象,然后使用push
方法再次将它添加回来。尽管数组的内容发生了变化,但是相同的数组对象仍然被分配给名为products
的data
属性。
其他操作会生成一个新数组,反映它们所做的更改。例如,filter
和slice
方法都返回新的数组,这导致一个新的数组对象被分配给data
属性,如清单 13-9 所示。
...
handleClick() {
this.products = this.products.filter(p => p.price > 20);
}
...
Listing 13-9Creating a New Array in the App.vue File in the src Folder
我用一个使用filter
方法创建新数组的语句替换了handleClick
方法中的语句,该数组只包含那些price
值大于 20 的对象。我将filter
方法返回的新数组赋给了products
属性,但这是 Vue.js 设计来应对的操作,如果旧数组和新数组中的对象有重叠,现有内容将被重用以提高效率。但是,不管有多少重叠,用一个新的对象替换数组将会更新呈现给用户的内容,如图 13-7 所示。
图 13-7
分配新数组
了解更新问题
有两种类型的数组变化 Vue.js 不能检测到,也不会响应。第一种情况是数组中的一个项目被替换,如清单 13-10 所示。
...
handleClick() {
this.products[1] = { name: "Running Shoes", price: 100 };
}
...
Listing 13-10Replacing an Array Item in the App.vue File in the src Folder
方法使用数组索引表示法将新对象分配给数组中的位置 1。Vue.js 不会检测到更改,对新对象属性值的任何更改也不会被检测到。
为了解决这一限制,Vue.js 提供了一种替换数组中的对象并触发变化检测过程的方法。这个方法必须从它的模块中导入才能使用,如清单 13-11 所示。
...
<script>
import Vue from "vue";
export default {
name: "MyComponent",
data: function () {
return {
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 },
{ name: "Corner Flags", price: 39.95 },
{ name: "Stadium", price: 79500 },
{ name: "Thinking Cap", price: 16 }]
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
Vue.set(this.products, 1, { name: "Running Shoes", price: 100 });
}
}
}
</script>
...
Listing 13-11Safely Replacing an Array Item in the App.vue File in the src Folder
Vue.set
方法接受三个参数:要修改的数组、要替换的对象的索引和新对象。其效果是执行更新并触发变化检测过程,从而更新呈现给用户的内容,如图 13-8 所示。
图 13-8
安全替换数组中的对象
小费
在组件中,Vue.set
方法也可以作为this.$set
来访问。我更喜欢使用Vue.set
,因为不是所有的数组都在组件中执行,我喜欢保持更新的一致性。
Vue.js 无法检测到的对数组的另一个更改是通过更改length
属性的值来缩短数组。通过使用 array splice
方法以 Vue.js 可以看到的方式移除不需要的元素,可以避免这个问题。
枚举对象属性
虽然v-for
指令最常见的用途是枚举数组的内容,但它也可以用于枚举对象的属性,这比它第一次出现时更有用。在清单 13-12 中,我用一个对象替换了对象数组,这个对象的属性名和值包含了我需要的信息。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Index</th><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="(p, key, i) in products" v-bind:key="p.name">
<td>{{ i + 1 }}</td>
<td>{{ p.name }}</td>
<td>{{ p. price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
name: "MyComponent",
data: function () {
return {
products: {
1: { name: "Kayak", price: 275 },
2: { name: "Lifejacket", price: 48.95 },
3: { name: "Soccer Ball", price: 19.50 },
4: { name: "Corner Flags", price: 39.95 }
}
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
Vue.set(this.products, 5, { name: "Running Shoes", price: 100});
}
}
}
</script>
Listing 13-12Using an Object for Enumeration in the App.vue File in the src Folder
在这个清单中,我修改了products
属性,使它返回一个对象。这个对象定义了一些属性,这些属性的值本身就是带有name
和price
属性的对象。为了枚举products
对象的属性,我像这样应用了v-for
指令:
...
<tr v-for="(p, key, i) in products" v-bind:key="p.name">
...
处理对象时,指令提供别名、包含键的新变量和索引变量。Vue.js 可以检测属性何时被修改,但不能判断新属性何时被添加到对象中,这就是为什么我在handleClick
方法中使用了Vue.set
方法,如下所示:
...
Vue.set(this.products, 5, { name: "Running Shoes", price: 100});
...
结果是v-for
指令将枚举对象的属性并为每个属性复制内容,如图 13-9 所示。
图 13-9
枚举对象的属性
了解对象属性排序
属性按照 JavaScript Object.keys
方法返回的顺序进行枚举,该方法通常对属性进行如下排序:
-
具有整数值的键,包括可以解析为整数的值,以升序排列
-
具有字符串值的键,按照它们被定义的顺序
-
所有其他键,按照它们被定义的顺序
警告
这是您通常会遇到的顺序,但是 JavaScript 实现之间可能会有差异。使用计算属性对对象进行排序,以确保一致性,如下一节所示。
为了演示属性排序的方式,我更改了products
对象的键,并在 HTML 表中添加了一列来显示每个键值,如清单 13-13 所示。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Index</th><th>Key</th><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="(p, key, i) in products" v-bind:key="p.name">
<td>{{ i + 1 }}</td>
<td>{{ key }}</td>
<td>{{ p.name }}</td>
<td>{{ p. price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button v-on:click="handleClick" class="btn btn-primary">
Press Me
</button>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
name: "MyComponent",
data: function () {
return {
products: {
"kayak": { name: "Kayak", price: 275 },
22: { name: "Lifejacket", price: 48.95 },
3: { name: "Soccer Ball", price: 19.50 },
"4": { name: "Corner Flags", price: 39.95 }
}
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
handleClick() {
Vue.set(this.products, 5, { name: "Running Shoes", price: 100 });
}
}
}
</script>
Listing 13-13Working with Object Keys in the App.vue File in the src Folder
当v-for
指令枚举products
对象的属性时,将按以下顺序处理:3
、4
、22
、kayak
。点击按钮,使用5
作为键向对象添加一个新的属性,在4
和22
键之间会显示一个新的表格行,如图 13-10 所示。
图 13-10
对象属性排序的效果
没有数据源的重复 HTML 元素
指令可以用来复制 HTML 元素特定的次数,而不需要使用数据数组或对象作为数据源。这个特性对于创建与特定数据项无关的内容很有用,比如分页按钮,如清单 13-14 所示。
...
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Index</th><th>Key</th><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="(p, key, i) in products" v-bind:key="p.name">
<td>{{ i + 1 }}</td>
<td>{{ key }}</td>
<td>{{ p.name }}</td>
<td>{{ p. price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<button v-for="i in 5" v-on:click="handleClick(i)"
class="btn btn-primary m-1">
{{ i }}
</button>
</div>
</div>
</template>
...
Listing 13-14Repeating Content in the App.vue File in the src Folder
当v-for
表达式中的源是一个整数值时,该指令将 HTML 元素重复指定的次数,并将当前值赋给别名。在这个清单中,当将v-for
指令应用于按钮元素时,我指定了数字 5,并使用别名作为元素的内容。分配给别名的第一个值是 1,产生如图 13-11 所示的结果。
小费
请注意,我禁用了要求标识密钥的 linter 规则。因为值的序列是由指令生成的,所以不需要担心处理数组项顺序变化的策略。
图 13-11
没有数据源的重复元素
将计算属性与 v-for 指令一起使用
到目前为止,所有的例子都使用了data
属性,但是v-for
指令也将使用计算的属性和方法,这使得使用 JavaScript 来过滤或排序内容被复制的对象成为可能。在接下来的几节中,我将展示管理由v-for
指令处理的数据的不同方法。
分页数据
大多数应用需要向用户呈现可用数据的子集,这通常是通过呈现数据页面来完成的。将计算出的属性与无数据源重复内容的特性结合起来,可以很容易地实现分页,如清单 13-15 所示。
小费
在本书的第一部分,您可以在 SportsStore 应用中看到一个更复杂的分页示例。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="p in pageItems" v-bind:key="p.name">
<td>{{ p.name }}</td>
<td>{{ p. price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<button v-for="i in pageCount" v-on:click="selectPage(i)"
class="btn btn-secondary m-1"
v-bind:class="{'bg-primary': currentPage == i}">
{{ i }}
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
pageSize: 3,
currentPage: 1,
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 },
{ name: "Corner Flags", price: 39.95 },
{ name: "Stadium", price: 79500 },
{ name: "Thinking Cap", price: 16 },
{ name: "Unsteady Chair", price: 29.95 },
{ name: "Human Chess Board", price: 75 },
{ name: "Bling Bling King", price: 1200 }
]
}
},
computed: {
pageCount() {
return Math.ceil(this.products.length / this.pageSize);
},
pageItems() {
let start = (this.currentPage - 1) * this.pageSize;
return this.products.slice(start, start + this.pageSize);
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
selectPage(page) {
this.currentPage = page;
}
}
}
</script>
Listing 13-15Paging Data in the App.vue File in the src Folder
在这个例子中,我又使用了一个数组,并添加了一些对象,这样就有更多的数据可以处理了。有两个新的data
属性:pageSize
属性指定每页的项数,而currentPage
属性用于跟踪向用户显示的页面。还有两个新的计算属性:pageCount
属性返回应用需要的页数,而pageItems
属性只返回当前页面需要的数据对象。
为了生成分页按钮,我在v-for
表达式中使用了pageItems
属性,这允许我为用户可以选择的每个页面重复使用button
元素。我想突出显示代表当前页面的按钮,这是通过使用v-bind
指令将元素分配给基于v-for
别名值的类来实现的。最后,我使用v-on
指令——我在第十四章中描述了该指令——在用户点击按钮时调用一个名为selectPage
的方法,该方法允许我更改currentPage
值,允许用户在页面间导航,如图 13-12 所示。
图 13-12
分页数据
过滤和排序数据
计算出的属性也可用于在数据被v-for
指令接收之前对其进行过滤和排序。在清单 13-16 中,我添加了select
元素,它们的值用于改变显示给用户的数据。
<template>
<div class="container-fluid">
<h2 class="bg-primary text-white text-center p-3">Products</h2>
<table class="table table-sm table-bordered text-left">
<tr><th>Name</th><th>Price</th></tr>
<tbody>
<tr v-for="p in pageItems" v-bind:key="p.name">
<td>{{ p.name }}</td>
<td>{{ p. price | currency }}</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-secondary m-1" v-on:click="toggleSort"
v-bind:class="{'bg-primary': sort}">
Toggle Sort
</button>
<button class="btn btn-secondary m-1" v-on:click="toggleFilter"
v-bind:class="{'bg-primary': filter}">
Toggle Filter
</button>
<!-- eslint-disable-next-line vue/require-v-for-key -->
<button v-for="i in pageCount" v-on:click="selectPage(i)"
class="btn btn-secondary m-1"
v-bind:class="{'bg-primary': currentPage == i}">
{{ i }}
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
pageSize: 3,
currentPage: 1,
filter: false,
sort: false,
products: [
{ name: "Kayak", price: 275 },
{ name: "Lifejacket", price: 48.95 },
{ name: "Soccer Ball", price: 19.50 },
{ name: "Corner Flags", price: 39.95 },
{ name: "Stadium", price: 79500 },
{ name: "Thinking Cap", price: 16 },
{ name: "Unsteady Chair", price: 29.95 },
{ name: "Human Chess Board", price: 75 },
{ name: "Bling Bling King", price: 1200 }
]
}
},
computed: {
pageCount() {
return Math.ceil(this.dataItems.length / this.pageSize);
},
pageItems() {
let start = (this.currentPage - 1) * this.pageSize;
return this.dataItems.slice(start, start + this.pageSize);
},
dataItems() {
let data = this.filter
? this.products.filter(p => p.price > 100) : this.products;
return this.sort
? data.concat().sort((p1, p2) => p2.price - p1.price) : data;
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD", }).format(value);
},
},
methods: {
selectPage(page) {
this.currentPage = page;
},
toggleFilter() {
this.filter = !this.filter
this.currentPage = 1;
},
toggleSort() {
this.sort = !this.sort;
this.currentPage = 1;
}
}
}
</script>
Listing 13-16Filtering and Sorting Data in the App.vue File in the src Folder
这个例子使用了一个名为dataItems
的新计算属性,根据名为sort
和filter
的data
属性来准备要显示的数据。通过点击新的button
元素来切换data
属性值,分页按钮的数量根据用户的选择进行更新,如图 13-13 所示。
图 13-13
分页和排序数据
摘要
在这一章中,我解释了v-for
指令的用法,并演示了它如何枚举数组、对象的属性和数字序列。我向您展示了如何使用别名,如何使用键使更新更有效,以及如何获取正在处理的项目的索引。我还演示了如何将v-for
指令用于计算属性,以便对数据进行分页、排序和过滤。在下一章,我将向您展示如何使用 Vue.js 指令处理事件。
十四、处理事件
在这一章中,我继续描述内置的 Vue.js 指令,重点放在用于事件处理的v-on
指令,以及表 14-1 放在上下文中的指令。
表 14-1
将 v-on 指令放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | v-on
指令用于监听和响应事件。 |
| 为什么有用? | 该指令使访问组件数据或在响应事件时调用方法变得容易,并使事件处理成为 Vue.js 开发的一个集成部分。 |
| 如何使用? | v-on
指令应用于您感兴趣的事件的 HTML 元素,当您指定的事件被触发时,它的表达式被求值。 |
| 有什么陷阱或限制吗? | 正如在“管理事件传播”一节中所描述的,只要您在应用指令时牢记 DOM 事件传播模型,v-on
指令就能一致地工作,并且通常很容易使用。 |
| 有其他选择吗? | 如果你对表单元素触发的事件感兴趣,那么在第十五章中描述的v-model
指令可能更合适。 |
表 14-2 总结了本章内容。
表 14-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 处理由元素发出的事件 | 使用v-on
指令 | 3, 7 |
| 获取事件的详细信息 | 使用事件对象 | four |
| 响应指令表达式之外的事件 | 使用方法处理事件,并接收事件对象作为参数 | 5, 6 |
| 处理来自同一元素的多个事件 | 对您想要接收的每个事件应用v-on
指令,或者使用事件对象检测事件类型 | 8, 9 |
| 管理事件传播 | 使用事件传播修饰符 | 10–14 |
| 基于按键或鼠标活动过滤事件 | 使用鼠标和键盘修饰符 | 15–17 |
为本章做准备
在这一章中,我继续使用第十四章中的templatesanddata
项目。为了准备本章,我简化了应用的根组件,如清单 14-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3>{{ name }}</h3>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
}
}
</script>
Listing 14-1Simplifying the Content of the App.vue File in the src Folder
保存对App.vue
文件的修改,运行templatesanddata
文件夹中清单 14-2 所示的命令,启动 Vue.js 开发工具。
npm run serve
Listing 14-2Starting the Development Tools
打开一个新的浏览器窗口并导航至http://localhost:8080
以查看如图 14-1 所示的内容。
图 14-1
运行示例应用
处理事件
Vue.js 提供了v-on
指令,用于为事件创建绑定。用户与 HTML 元素交互的结果会触发事件。所有元素都支持一组核心事件,这些事件由特定于特定元素所特有的特性的事件来补充。在清单 14-3 中,我使用了v-on
指令来告诉 Vue.js 当用户点击组件模板中的h3
元素时我希望它如何响应。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3 v-on:click="name = 'Clicked!'">{{ name }}</h3>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
}
}
</script>
Listing 14-3Handling an Event in the App.vue File in the src Folder
v-on
指令的应用遵循前面章节建立的模式,我在图 14-2 中对其进行了分解。
图 14-2
v-on 指令的剖析
指令的名称后跟一个冒号,然后是一个指定事件名称的参数。当事件被触发时,表达式将被调用,本例中的表达式是一段 JavaScript 代码,它更改了name
属性的值。要查看结果,保存更改并在浏览器窗口中点击h3
元素的内容,产生如图 14-3 所示的效果。
图 14-3
处理事件
单击元素触发click
事件,Vue.js 通过评估指令的表达式来响应。只有第一次单击时才会有明显的变化,您必须重新加载浏览器才能将应用重置为其原始状态。
了解事件和事件对象
有许多不同类型的事件可用,我在本章中使用的事件在表 14-3 中描述。
注意
参见 https://developer.mozilla.org/en-US/docs/Web/Events
了解所有可用事件的详细信息。
表 14-3
本章中使用的事件
|事件
|
描述
|
| --- | --- |
| click
| 当在元素边界内按下并释放鼠标按钮时,会触发此事件。 |
| mousedown
| 当在元素边界内按下鼠标按钮时,会触发此事件。 |
| mousemove
| 当鼠标指针在元素边界内移动时,会触发事件。 |
| mouseleave
| 当鼠标指针离开元素边界时,会触发此事件。 |
| keydown
| 按下按键时会触发此事件。 |
当浏览器触发事件时,它们产生一个描述事件的对象,称为事件对象。事件对象定义了提供事件信息的属性和方法,可用于控制事件的处理方式。在表 14-4 中,我描述了对 Vue.js 开发最有用的事件对象属性。(如果您熟悉 web 开发,您可能想知道由事件对象定义的方法和其他属性。正如您将了解到的,您不需要将它们直接与v-on
指令一起使用,它会处理大量的事件处理细节。)
表 14-4
有用的事件对象属性
|财产
|
描述
|
| --- | --- |
| target
| 该属性返回表示触发事件的 HTML 元素的 DOM 对象 |
| currentTarget
| 该属性返回表示处理事件的 HTML 元素的 DOM 对象。与target
属性的区别在“管理事件传播”一节中解释。 |
| type
| 此属性返回事件类型。 |
| key
| 对于键盘事件,此属性返回与事件相关的键。 |
v-on
指令通过名为$event
的变量使事件对象可用。在清单 14-4 中,我已经更新了指令的表达式,以便在事件被触发时显示type
属性的值。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3 v-on:click="name = $event.type">{{ name }}</h3>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
}
}
</script>
Listing 14-4Using an Event Object in the App.vue File in the src Folder
v-on
指令通过点击h3
元素来处理click
事件原因,并在表达式求值前将浏览器创建的事件对象赋给$event
变量,产生如图 14-4 所示的结果。
图 14-4
使用事件对象
使用方法处理事件
正如前面的例子所展示的,当事件被触发时,v-on
指令将评估 JavaScript 的片段,但是更常见的方法是调用方法。使用方法可以最大限度地减少模板中的代码量,并允许一致地处理事件。在清单 14-5 中,我为组件添加了一个方法,并更新了v-on
指令的绑定,这样当h3
元素的click
事件被触发时,该方法将被调用。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3 v-on:click="handleEvent">{{ name }}</h3>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
},
methods: {
handleEvent($event) {
this.name = $event.type;
}
}
}
</script>
Listing 14-5Using a Method in the App.vue File in the src Folder
我已经将该指令的表达式设置为handleEvent
,它告诉v-on
在click
事件被触发时调用该方法并向其传递$event
对象,这产生了如图 14-5 所示的结果。(您不必使用$event
作为方法参数的名称,但我倾向于使用,因为它使参数的目的很明显。)
小费
Vue.js 通常对事件触发时调用的方法名称不太严格,但是如果您使用的方法名称也是内置的 JavaScript 关键字,比如delete
,您将会收到一个错误。
只指定一个方法名是有用的,但是使用方法的真正好处是当您有多个相同事件类型的源产生不同的结果时。在这些情况下,v-for
指令可以调用一个带有参数的方法来决定事件的处理方式,如清单 14-6 所示。
<template>
<div class="container-fluid">
<div class="bg-primary text-white m-2 p-3 text-center">
<h3 v-on:click="handleEvent('Soccer Ball', $event)">{{ name }}</h3>
</div>
<div class="bg-primary text-white m-2 p-3 text-center">
<h3 v-on:click="handleEvent('Stadium', $event)">{{ name }}</h3>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Lifejacket"
}
},
methods: {
handleEvent(name, $event) {
this.name = `${name} - ${$event.type}`;
}
}
}
</script>
Listing 14-6Using Method Arguments in the App.vue File in the src Folder
我添加了另一个绑定了v-on
的h3
元素。两个绑定表达式都调用了handleEvent
方法,但是它们为第一个参数提供了不同的值。结果是根据点击的元素向用户显示不同的消息,如图 14-5 所示。
图 14-5
使用参数调用方法
使用指令速记
v-on
指令有两种形式。如图 14-2 所示,手写形式由指令名、冒号和事件名组成。简短形式结合了@
符号和事件名称,因此v-on:click
也可以表示为@click
。这意味着像这样的指令:
...
<h3 v-on:click="handleEvent('Soccer Ball', $event)">{{ name }}</h3>
...
也可以这样表达:
...
<h3 @click="handleEvent('Soccer Ball', $event)">{{ name }}</h3>
...
在长格式和简写格式之间的选择是个人喜好,不会改变指令的行为方式。
组合事件、方法和重复元素
当调用v-on
指令时提供参数的能力在与v-for
指令结合使用时变得更加有用。在清单 14-7 中,我使用了v-for
指令为数组中的对象重复一组元素,并使用v-on
指令为每个元素设置事件处理程序。
<template>
<div class="container-fluid">
<h3 class="bg-primary text-white text-center mt-2 p-2">{{message}}</h3>
<table class="table table-sm table-striped table-bordered">
<tr><th>Index</th><th>Name</th><th>Actions</th></tr>
<tr v-for="(name, index) in names" v-bind:key="name">
<td>{{index}}</td>
<td>{{name}}</td>
<td>
<button class="btn btn-sm bg-primary text-white"
v-on:click="handleClick(name)">
Select
</button>
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
message: "Ready",
names: ["Kayak", "Lifejacket", "Soccer Ball", "Stadium"]
}
},
methods: {
handleClick(name) {
this.message = `Select: ${name}`;
}
}
}
</script>
Listing 14-7Repeating Elements in the App.vue File in the src Folder
v-for
指令为names
数组中的每个对象复制表中的行。该副本包括所有的数据绑定和指令,这意味着在每个表行中有一个带有针对click
事件的v-on
绑定的button
元素,如下所示:
...
<button class="btn btn-sm bg-primary text-white" v-on:click="handleClick(name)">
Select
</button>
...
使用v-for
别名的值设置v-on
绑定表达式,这意味着每个button
元素将使用创建时正在处理的对象调用handleClick
方法。handleClick
方法使用其参数值来设置message
属性的值,该值在h3
元素中显示给用户。其效果是点击表格中的按钮显示 names 数组中的相应值,如图 14-6 所示。
图 14-6
重复内容中的事件处理
监听来自同一元素的多个事件
有两种方法可以处理同一元素上不同类型的事件。第一种方法是对每个事件分别应用v-on
指令,如清单 14-8 所示。
<template>
<div class="container-fluid">
<h3 class="bg-primary text-white text-center mt-2 p-2">{{message}}</h3>
<table class="table table-sm table-striped table-bordered">
<tr><th>Index</th><th>Name</th><th>Actions</th></tr>
<tr v-for="(name, index) in names" v-bind:key="name">
<td>{{index}}</td>
<td>{{name}}</td>
<td>
<button class="btn btn-sm bg-primary text-white"
v-on:click="handleClick(name)"
v-on:mousemove="handleMouseEvent(name, $event)"
v-on:mouseleave="handleMouseEvent(name, $event)">
Select
</button>
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
counter: 0,
message: "Ready",
names: ["Kayak", "Lifejacket", "Soccer Ball", "Stadium"]
}
},
methods: {
handleClick(name) {
this.message = `Select: ${name}`;
},
handleMouseEvent(name, $event) {
if ($event.type == "mousemove") {
this.message = `Move in ${name} ${this.counter++}`;
} else {
this.counter = 0;
this.message = "Ready";
}
}
}
}
</script>
Listing 14-8Handling Multiple Event Types in the App.vue File in the src Folder
我添加了v-on
指令来处理mousemove
和mouseleave
事件。当鼠标指针移动到一个元素上时触发mousemove
事件,当指针离开该元素时触发mouseleave
事件。这两个事件都由handleMouseEvent
方法处理,该方法使用$event
变量来确定事件类型,并更新message
和counter
属性。结果是,当指针移动到button
元素上时,显示一条消息和计数器,然后当指针离开按钮占据的屏幕区域时,这些消息和计数器被重置,如图 14-7 所示。
图 14-7
响应几种类型的事件
监听多个事件的第二种方法是应用不带事件参数的v-on
指令,并将指令的表达式设置为一个对象,该对象的属性名是事件类型,其值指定应该调用的方法,如清单 14-9 所示。
<template>
<div class="container-fluid">
<h3 class="bg-primary text-white text-center mt-2 p-2">{{message}}</h3>
<table class="table table-sm table-striped table-bordered">
<tr><th>Index</th><th>Name</th><th>Actions</th></tr>
<tr v-for="(name, index) in names" v-bind:key="name">
<td>{{index}}</td>
<td>{{name}}</td>
<td>
<button class="btn btn-sm bg-primary text-white"
v-on="buttonEvents"
v-bind:data-name="name">
Select
</button>
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
buttonEvents: {
click: this.handleClick,
mousemove: this.handleMouseEvent,
mouseleave: this.handleMouseEvent
},
counter: 0,
message: "Ready",
names: ["Kayak", "Lifejacket", "Soccer Ball", "Stadium"]
}
},
methods: {
handleClick($event) {
let name = $event.target.dataset.name;
this.message = `Select: ${name}`;
},
handleMouseEvent($event) {
let name = $event.target.dataset.name;
if ($event.type == "mousemove") {
this.message = `Move in ${name} ${this.counter++}`;
} else {
this.counter = 0;
this.message = "Ready";
}
}
}
}
</script>
Listing 14-9Handling Multiple Events in a Single Directive in the App.vue File in the src Folder
名为buttonEvents
的data
属性返回一个具有click
、mousemove,
和mouseleave
属性的对象,对应我要处理的事件,这些属性的值就是每个事件应该调用的方法。这种方法的局限性是它不支持向方法传递参数,方法只接收$event
参数。为了解决这个问题,我使用了v-bind
指令为每个button
元素添加一个data-name
属性,如下所示:
...
<button class="btn btn-sm bg-primary text-white" v-on="buttonEvents"
v-bind:data-name="name">
...
$event
对象提供了对触发事件的 HTML 元素的访问,我使用dataset
属性来访问定制的data-
属性,以便在处理这样的事件时获得data-name
值:
...
let name = $event.target.dataset.name;
...
结果与清单 14-8 相同,但是不需要在button
元素上定义多个v-on
指令,这会产生一个难以阅读的模板。
小费
如果您愿意,可以在v-on
指令的表达式中直接将事件映射对象定义为字符串文字,尽管仍然会产生难以理解的模板。
使用事件处理修饰符
为了保持事件处理方法简单和集中,v-on
指令支持一组修饰符,用于执行通常需要 JavaScript 语句的常见任务。表 14-5 描述了一组v-on
事件处理修饰符。
表 14-5
v-on 事件处理修饰符
|修饰语
|
描述
|
| --- | --- |
| stop
| 这个修饰符相当于在事件对象上调用stopPropagation
方法,如“停止事件传播”一节所述。 |
| prevent
| 这个修饰符相当于在事件对象上调用preventDefault
方法。这个修改器在第十五章中演示。 |
| capture
| 该修饰符启用事件传播的捕获模式,如“在捕获阶段接收事件”一节所述。 |
| self
| 只有当事件来源于指令所应用的元素时,这个修饰符才会调用 handler 方法,如“只处理目标阶段事件”一节中所演示的。 |
| once
| 这个修饰符将阻止相同类型的后续事件调用 handler 方法,如“防止重复事件”一节中所示。 |
| passive
| 此修改器将启用被动事件侦听,这将提高触摸事件的性能,在移动设备上尤其有用。参见 https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
了解何时使用该选项的详细说明。 |
管理事件传播
stop
、capture
和self
修饰符用于通过 HTML 元素的层次结构管理事件的传播。当事件被触发时,浏览器通过三个阶段来定位事件的处理程序:捕获、目标和冒泡阶段。为了演示这些阶段如何工作——以及如何使用v-on
修饰符来控制它们——我已经更新了示例应用中的组件,如清单 14-10 所示。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:click="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:click="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:click="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-10Handling Events in the App.vue File in the src Folder
该模板包含三个嵌套的div
元素,一个id
属性和一个v-on
指令被配置为使用handleClick
方法处理click
事件。该方法接收$event
对象,并使用target
属性获取触发事件的元素的详细信息,并使用currentTarget
属性获取其v-on
指令正在处理事件的元素的详细信息。
为了理解为什么在处理一个事件时会涉及到两个属性,保存对组件的修改,然后点击内部元素所占据的浏览器窗口部分,如图 14-8 所示。
图 14-8
触发一组嵌套元素中的事件
默认情况下,v-on
指令将接收目标和冒泡阶段的事件,这意味着事件将首先由触发事件的元素(目标)上的v-on
元素接收,然后依次由它的每个前身接收,从直接的父元素一直到body
元素。您可以在已经写入 JavaScript 控制台的消息中看到这一点。
...
handleClick target: inner-element currentTarget: inner-element
handleClick target: inner-element currentTarget: middle-element
handleClick target: inner-element currentTarget: outer-element
...
第一条消息显示了事件的目标阶段,触发事件的元素上的v-on
指令调用了handleClick
方法。在目标阶段,$event
对象的target
和currentTarget
属性是相同的。
第二条和第三条消息显示了冒泡阶段,其中事件沿着 HTML 文档向上传播到每个父元素,导致该事件类型的每个元素的v-on
指令调用其方法。在这个阶段,$event
对象的target
属性返回触发事件的元素,currentTarget
属性返回其v-on
指令当前正在处理事件的元素。图 14-9 显示了目标阶段和气泡阶段的事件流程。
小费
并非所有类型的事件都有泡沫阶段。根据经验,特定于单个元素的事件——比如获得和失去焦点——不会冒泡。应用于多个元素的事件(例如单击被多个元素占据的屏幕区域)将冒泡。您可以通过读取$event
对象的bubbles
属性来查看特定事件是否将经历冒泡阶段。
图 14-9
目标和气泡阶段
在捕获阶段接收事件
捕获阶段为元素提供了在目标阶段之前处理事件的机会。在捕获阶段,浏览器从body
元素开始,沿着与冒泡阶段相反的路径,沿着元素的层次结构向目标前进,并给予每个元素处理事件的更改,如图 14-10 所示。
图 14-10
捕获阶段
除非应用了capture
修饰符,否则v-on
指令不会在捕获阶段接收事件,如清单 14-11 所示。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:click.capture="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:click.capture="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:click="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-11Using the Capture Phase in the App.vue File in the src Folder
应用修饰符时,先使用句点,然后使用修饰符名称,如下所示:
...
v-on:click.capture="handleClick"
...
将capture
修饰符添加到v-on
指令意味着元素将在捕获阶段而不是冒泡阶段接收事件。您可以通过单击内部元素并检查浏览器的 JavaScript 控制台中显示的消息来查看效果。
...
handleClick target: inner-element currentTarget: outer-element
handleClick target: inner-element currentTarget: middle-element
handleClick target: inner-element currentTarget: inner-element
...
消息显示事件已经沿着 HTML 文档向下进行,触发了外层元素和中间元素上的v-on
指令。
仅处理目标阶段事件
您可以限制v-on
指令,使其只响应目标阶段的事件。self
修饰符确保只有被应用了指令的元素触发的事件才会被处理,如清单 14-12 所示。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:click.capture="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:click.self="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:click="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-12Selecting Target Phase Events in the App.vue File in the src Folder
我已经在中间元素的v-on
指令上应用了self
修饰符。如果单击内部元素,您将看到浏览器的 JavaScript 控制台中显示以下消息:
...
handleClick target: inner-element currentTarget: outer-element
handleClick target: inner-element currentTarget: inner-element
...
第一条消息是从外部元素接收的,它的v-on
指令使用 capture 修饰符,因此在捕获阶段接收事件。第二条消息来自内部元素,它是目标,在目标阶段接收事件。没有来自中间元素的消息,因为self
修饰符已经阻止它在冒泡阶段接收事件。如果您单击中间的元素,将会看到这些消息:
...
handleClick target: middle-element currentTarget: outer-element
handleClick target: middle-element currentTarget: middle-element
...
这些消息中的第一条同样来自外部元素,它在捕获阶段首先获取事件。第二条消息来自目标阶段的中间元素,这是由self
修饰符允许的。
停止事件传播
stop
修饰符中止一个事件的传播,防止它被任何后续元素的v-on
指令处理。在清单 14-13 中,我将stop
指令应用于中间元素的v-on
指令。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:click.capture="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:click.stop="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:click="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-13Stopping Event Propagation in the App.vue File in the src Folder
stop
修饰符阻止一个事件继续它通常的进程,但是不会停止传播,直到它到达它所应用的元素。在本例中,这意味着由中间元素触发的元素在目标阶段停止之前仍将经历捕获阶段。因为外部元素的v-on
指令是用capture
修饰符配置的,所以浏览器的 JavaScript 控制台中会显示以下消息:
...
handleClick target: middle-element currentTarget: outer-element
handleClick target: middle-element currentTarget: middle-element
...
组合事件处理修饰符
您可以将多个修饰符应用于单个v-on
指令。修改器按照指定的顺序进行处理,这意味着通过在不同的组合中使用相同的修改器可以获得不同的效果。这种组合将在目标阶段处理事件,然后阻止它们进一步传播。
...
v-on:click.self.stop="handleClick"
...
但是如果这些修改器被颠倒,那么事件将在目标和气泡阶段停止(因为停止修改器是第一个)。
...
v-on:click.stop.self="handleClick"
...
因此,仔细考虑组合修饰符的后果,彻底测试组合,并一致地应用它们是很重要的。
防止重复事件
once
修饰符阻止一个v-on
指令多次调用它的方法。这不会停止正常的事件传播过程,但会阻止一个元素在第一个事件被处理后参与其中。在清单 14-14 中,我将once
修饰符应用于组件模板的内部元素。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:click.capture="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:click.stop="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:click.once="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-14Stopping Duplicate Events in the App.vue File in the src Folder
第一次单击内部元素时,您会看到所有的v-on
指令都响应,如下所示:
...
handleClick target: inner-element currentTarget: outer-element
handleClick target: inner-element currentTarget: inner-element
handleClick target: inner-element currentTarget: middle-element
...
外部元素配置有capture
修饰符,因此它在捕获阶段获取事件。这是第一次由内部元素处理click
事件,因此事件的目标阶段就像平常一样。最后,中间的元素在冒泡阶段接收事件,此时stop
修改器阻止它继续前进。
当您再次单击内部元素时,您将看到一组不同的消息:
...
handleClick target: inner-element currentTarget: outer-element
handleClick target: inner-element currentTarget: middle-element
...
once
修饰符阻止内部元素的v-on
指令调用handleClick
方法,但不阻止事件传播到其他元素。
使用鼠标事件修改器
当您需要明确触发事件的条件时,v-on
指令提供了一组修饰符来简化鼠标事件的处理。表 14-6 描述了一组v-on
鼠标事件修改器。
表 14-6
v-on 鼠标事件修饰符
|修饰语
|
描述
|
| --- | --- |
| left
| 此修改器仅选择由鼠标左键触发的事件。 |
| middle
| 该修改器仅选择由鼠标中键触发的事件。 |
| right
| 此修改器仅选择由鼠标右键触发的事件。 |
当应用于v-on
指令时,这些修饰符将被处理的事件限制为那些由指定的鼠标按钮触发的事件,如清单 14-15 所示。
<template>
<div class="container-fluid">
<div id="outer-element" class="bg-primary p-4 text-white h3"
v-on:mousedown="handleClick">
Outer Element
<div id="middle-element" class="bg-secondary p-4"
v-on:mousedown="handleClick">
Middle Element
<div id="inner-element" class="bg-info p-4"
v-on:mousedown.right="handleClick">
Inner Element
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
methods: {
handleClick($event) {
console.log(`handleClick target: ${$event.target.id}`
+ ` currentTarget: ${$event.currentTarget.id}`);
}
}
}
</script>
Listing 14-15Using a Mouse Modifier in the App.vue File in the src Folder
在清单中,我修改了v-on
指令,以便它们监听mousedown
事件,当任何鼠标按钮被按在一个元素上时就会触发该事件,并对内部元素上的v-on
指令应用了right
修饰符,以便它只接收鼠标右键的事件。
描述的修饰符不会阻止事件的传播。相反,它们阻止应用它们的v-on
指令处理使用其他鼠标按钮触发的事件。如果您左键单击内部元素,您将在浏览器的 JavaScript 控制台中看到以下消息,这些消息表明应用于中间和外部元素的v-on
指令接收到该事件,即使它不是由内部元素的指令处理的:
...
handleClick target: inner-element currentTarget: middle-element
handleClick target: inner-element currentTarget: outer-element
...
如果右键单击内部元素,那么所有三个v-on
指令都将处理该事件,产生以下消息:
...
handleClick target: inner-element currentTarget: inner-element
handleClick target: inner-element currentTarget: middle-element
handleClick target: inner-element currentTarget: outer-element
...
使用键盘事件修饰符
Vue.js 提供了一组键盘事件修饰符,用于限制由一个v-on
指令处理的键盘事件,其方式类似于上一节描述的鼠标修饰符。在清单 14-16 中,我修改了组件,使其具有一个我用来接收键盘事件的input
元素。
<template>
<div class="container-fluid">
<div class="bg-primary p-4 text-white h3">
{{message}}
</div>
<input class="form-control bg-light" placeholder="Type here..."
v-on:keydown.ctrl="handleKey" />
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
message: "Ready"
}
},
methods: {
handleKey($event) {
this.message = $event.key;
}
}
}
</script>
Listing 14-16Receiving Keyboard Events in the App.vue File in the src Folder
这个例子将ctrl
修饰符应用于keydown
事件,这意味着当控制键被按下时,只有keydown
事件才会调用handleKey
事件。您可以通过键入input
元素并观察显示在div
元素的文本插值绑定中的字符来查看效果。数据绑定显示当控制键单独按下或与另一个键组合按下时调用handleKey
方法,否则不调用,如图 14-11 所示。
图 14-11
使用修改器过滤关键事件
表 14-7 显示了 Vue.js 提供的一组按键事件修饰符。其中一些修饰符用于可以与其他修饰符结合使用的按键,如 Shift 和 Control 键,其余的则便于指定通常需要的按键,如 Tab 和空格键。
表 14-7
v-on 键盘事件修饰符
|修饰语
|
描述
|
| --- | --- |
| enter
| 该修改器选择回车键。 |
| tab
| 此修饰符选择 Tab 键。 |
| delete
| 此修改器选择删除键。 |
| esc
| 该修饰符选择退出键。 |
| space
| 该修改器选择空格键。 |
| up
| 此修改器选择向上箭头键。 |
| down
| 此修改器选择向下箭头键。 |
| left
| 该修改器选择左箭头键。 |
| right
| 该修改器选择右箭头键。 |
| ctrl
| 该修改器选择控制键。 |
| alt
| 该修改器选择 Alt 键。 |
| shift
| 该修改器选择 Shift 键。 |
| meta
| 该修改器选择元键。 |
| exact
| 该修饰键仅选择指定的修饰键,如表后所述。 |
exact
修饰符进一步限制了一个事件绑定,这样只有当指定的键被按下时它才会被调用。例如,清单 14-17 中的修饰符将只在只按下控制键时调用handleKey
方法,而不是在控制键和 Shift 键一起被按下时。
<template>
<div class="container-fluid">
<div class="bg-primary p-4 text-white h3">
{{message}}
</div>
<input class="form-control bg-light" placeholder="Type here..."
v-on:keydown.ctrl.exact="handleKey" />
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
message: "Ready"
}
},
methods: {
handleKey($event) {
this.message = $event.key;
}
}
}
</script>
Listing 14-17Matching Keys Exactly in the App.vue File in the src Folder
摘要
在这一章中,我演示了使用v-on
指令响应事件的不同方式。我向您展示了如何在应用指令时指定一个或多个事件,如何在生成重复内容时调用方法和接收参数,以及如何使用修饰符来更改事件行为和选择特定的鼠标按钮或键。在下一章中,我将描述使用表单元素的 Vue.js 特性。
十五、使用表单元素
在这一章中,我描述了v-model
,它是用于 HTML 表单元素的内置指令。v-model
指令在表单元素和数据值之间创建了一个双向数据绑定,并确保应用保持一致,不管数据如何变化。我还将向您展示如何将v-model
指令与前面章节中描述的一些内置指令结合起来,以验证用户输入到表单中的数据。表 15-1 将v-model
指令置于上下文中。
表 15-1
将 v-model 指令放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | v-model 指令在 HTML 表单元素和数据属性之间创建双向绑定,确保两者保持一致。 |
| 为什么有用? | 使用表单元素是大多数 web 应用的重要组成部分,v-model
指令负责创建数据绑定,而无需担心不同表单元素工作方式的差异。 |
| 如何使用? | v-model
指令应用于input
、select
和textarea
元素,其表达式是绑定应该创建到的数据属性的名称。 |
| 有什么陷阱或限制吗? | 指令不能和 Vuex 数据存储一起使用,我在第二十章中描述过。 |
| 还有其他选择吗? | 如果愿意,您可以手动创建所需的绑定,如本章的“创建双向模型绑定”一节所述。 |
表 15-2 总结了本章内容。
表 15-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 在数据属性和表单元素之间创建双向数据绑定 | 使用v-model
指令 | 1–9 |
| 将输入值格式化为数字 | 使用number
修改器 | 10, 11 |
| 延迟绑定更新 | 使用lazy
修改器 | Twelve |
| 修剪输入值中的空白 | 使用trim
修改器 | Thirteen |
| 用用户选择的表单填充数组 | 使用v-model
指令绑定到一个数组 | Fourteen |
| 间接绑定到值 | 使用带有v-model
指令的自定义值 | 15–17 |
| 确保用户提供了应用可以使用的值 | 使用v-model
指令验证收集的表单数据 | 18–21 |
为本章做准备
在这一章中,我继续使用第十四章中的templatesanddata
项目。为了准备本章,我简化了应用的根组件,如清单 15-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
Value: {{ dataValue }}
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check">
<label class="form-check-label">
<input class="form-check-input" type="checkbox"
v-on:change="handleChange" />
Data Value
</label>
</div>
</div>
<div class="text-center m-2">
<button class="btn btn-secondary" v-on:click="reset">
Reset
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
dataValue: false
}
},
methods: {
reset() {
this.dataValue= false;
},
handleChange($event) {
this.dataValue = $event.target.checked;
}
}
}
</script>
Listing 15-1Simplifying the Content of the App.vue File in the src Folder
保存对App.vue
文件的修改,运行templatesanddata
文件夹中清单 15-2 所示的命令,启动 Vue.js 开发工具。
npm run serve
Listing 15-2Starting the Development Tools
打开一个新的浏览器窗口并导航至http://localhost:8080
以查看图 15-1 所示的内容。
图 15-1
运行示例应用
创建双向模型绑定
到目前为止,我在本书的这一部分中创建的所有数据绑定都是单向的,这意味着数据从组件的script
元素流向template
元素,以便显示给用户。
示例应用展示了数据的单向流动。当复选框的状态改变时,v-on
指令调用handleChange
方法,设置dataValue
属性。该数据变化触发更新,通过文本插值数据绑定显示,如图 15-2 所示。
图 15-2
使用单向数据绑定
当表单元素是应用中唯一的更改源时,单向数据绑定工作得很好。当用户有其他方式进行更改时,它们就不那么有效了,比如例子中的 Reset 按钮,它的v-on
指令调用reset
方法,将dataValue
设置为false
。文本插值绑定正确地反映了新值,但是input
元素不知道这个变化,并且失去同步,如图 15-3 所示。
图 15-3
单向数据绑定的局限性
添加双向绑定
表单元素需要数据双向流动。当用户操作元素时,数据必须从表单流向数据模型,比如在字段中键入内容或选中复选框。当通过其他方式修改数据模型时,数据也必须向另一个方向流动,例如示例中的 Reset 按钮,以确保始终向用户呈现一致的数据。在清单 15-3 中,我在复选框和data
属性之间创建了一个绑定。
...
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
Value: {{ dataValue }}
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check">
<label class="form-check-label">
<input class="form-check-input" type="checkbox"
v-on:change="handleChange"
v-bind:checked="dataValue" />
Data Value
</label>
</div>
</div>
<div class="text-center m-2">
<button class="btn btn-secondary" v-on:click="reset">
Reset
</button>
</div>
</div>
</template>
...
Listing 15-3Creating a Binding in the App.vue File in the src Folder
我使用了v-bind
指令来设置元素的checked
属性,这确保了点击重置按钮具有取消选中复选框的效果,如图 15-4 所示。
图 15-4
附加数据绑定的效果
现在在dataValue
属性和复选框之间有了一个双向绑定。当复选框被选中和取消选中时,input
元素发送change
事件,这导致v-on
指令调用handleChange
方法,该方法设置dataValue
值。在另一个方向,当dataValue
由于点击 Reset 按钮而改变时,v-bind
指令设置input
元素的checked
属性,该属性选中或取消选中复选框。
双向数据绑定是有效使用 HTML 表单的基础。在 Vue.js 应用中,数据模型是权威的,对数据模型的更改可以以不同的方式产生,所有这些都必须准确地反映在用户看到的表单元素中。
添加另一个输入元素
表单元素的 HTML 和 DOM 规范并不一致,不同元素类型的工作方式也有差异,这必须反映在用于创建双向数据绑定的v-on
和v-bind
指令中。在清单 15-4 中,我添加了一个文本input
元素,它展示了两个不同表单元素之间的区别。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Data Value: {{ dataValue }}</div>
<div>Other Value: {{ otherValue || "(Empty)" }}</div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check">
<label class="form-check-label">
<input class="form-check-input" type="checkbox"
v-on:change="handleChange"
v-bind:checked="dataValue" />
Data Value
</label>
</div>
</div>
<div class="bg-primary m-2 p-2">
<input type="text" class="form-control"
v-on:input="handleChange"
v-bind:value="otherValue" />
</div>
<div class="text-center m-2">
<button class="btn btn-secondary" v-on:click="reset">
Reset
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
dataValue: false,
otherValue: ""
}
},
methods: {
reset() {
this.dataValue = false;
this.otherValue = "";
},
handleChange($event) {
if ($event.target.type == "checkbox") {
this.dataValue = $event.target.checked;
} else {
this.otherValue = $event.target.value;
}
}
}
}
</script>
Listing 15-4Expanding the Form Elements in the App.vue File in the src Folder
此示例显示了为不同类型的元素创建双向绑定所需的差异。在处理复选框时,我必须监听change
事件并绑定到checked
属性,但对于文本输入,我监听input
事件并绑定到value
属性。我必须在handleChange
事件中做类似的修改,为复选框设置checked
属性,为文本输入设置value
属性。结果是现在有两个表单元素,每个都有一个带有data
属性的双向绑定,如图 15-5 所示。
图 15-5
添加另一个输入元素
简化双向绑定
创建绑定所需的差异使设置绑定的过程变得复杂,并且很容易混淆不同元素类型的需求,最终使用错误的事件、属性或特性。
Vue.js 提供了v-model
指令;它简化了双向绑定,自动处理元素类型之间的差异,可以用在input
、select
和textarea
元素上。在清单 15-5 中,我使用v-model
指令简化了绑定。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Data Value: {{ dataValue }}</div>
<div>Other Value: {{ otherValue || "(Empty)" }}</div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check">
<label class="form-check-label">
<input class="form-check-input" type="checkbox"
v-model="dataValue" />
Data Value
</label>
</div>
</div>
<div class="bg-primary m-2 p-2">
<input type="text" class="form-control" v-model="otherValue" />
</div>
<div class="text-center m-2">
<button class="btn btn-secondary" v-on:click="reset">
Reset
</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
dataValue: false,
otherValue: ""
}
},
methods: {
reset() {
this.dataValue = false;
this.otherValue = "";
}
//handleChange($event) {
// if ($event.target.type == "checkbox") {
// this.dataValue = $event.target.checked;
// } else {
// this.otherValue = $event.target.value;
// }
//}
}
}
</script>
Listing 15-5Simplifying Two-Way Bindings in the App.vue File in the src Folder
v-model
指令的表达式是为其创建双向绑定的属性。不需要在方法中接收事件,这允许我移除handleChange
方法,效果是将组件重新聚焦于其数据和内容,而不是连接它们的管道。
绑定到表单元素
在继续之前,我将演示如何使用v-model
指令为不同类型的表单元素创建绑定。元素之间的大部分差异由v-model
指令处理,但是一个简单的绑定目录意味着您可以复制并粘贴到您自己的项目中,而不必找出每个项目所需的确切方法。
绑定到文本字段
最简单的绑定是创建到配置为允许用户输入文本的input
元素。在清单 15-6 中,我使用了为纯文本和密码设置的input
元素,以及使用v-model
指令的双向绑定。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Name: {{ name }} </div>
<div>Password: {{ password }}</div>
<div>Details: {{ details }}</div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="name" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" class="form-control" v-model="password" />
</div>
<div class="form-group">
<label>Details</label>
<textarea class="form-control" v-model="details" />
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Bob",
password: "secret",
details: "Has admin access"
}
}
}
</script>
Listing 15-6Binding to Text Fields in the App.vue File in the src Folder
我创建了两个input
元素,其中一个默认为常规文本字段,另一个配置为密码字段,还有一个textarea
元素。这三个元素都使用v-model
指令来创建一个双向数据绑定,绑定到由组件定义的数据属性,产生如图 15-6 所示的结果。
图 15-6
绑定到文本字段
绑定到单选按钮和复选框
在清单 15-7 中,我用复选框和单选按钮替换了前一个例子中的元素,为用户提供了一组受限的选项。和前面的例子一样,每个元素都使用v-model
指令创建一个带有数据属性的双向数据绑定。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Name: {{ name }} </div>
<div>Has Admin Access: {{ hasAdminAccess }}</div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check">
<input class="form-check-input" type="radio"
v-model="name" value="Bob" />
<label class="form-check-label">Bob</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio"
v-model="name" value="Alice" />
<label class="form-check-label">Alice</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox"
v-model="hasAdminAccess" />
<label class="form-check-label">Has Admin Access?</label>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Bob",
hasAdminAccess: true
}
}
}
</script>
Listing 15-7Binding to Checkboxes and Radio Buttons in the App.vue File in the src Folder
本例中的关键区别在于,当使用单选按钮时,每个元素都必须配置一个value
属性,以便v-model
指令知道如何更新数据属性的值。清单 15-7 中的元素产生如图 15-7 所示的结果。
图 15-7
绑定到单选按钮和复选框
可以将v-model
指令与v-for
和v-bind
指令结合起来,从一组值中生成表单元素,如清单 15-8 所示,当呈现给用户的选项只有在运行时才知道时,这很有用。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Name: {{ name }} </div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-check" v-for="n in allNames" v-bind:key="n">
<input class="form-check-input" type="radio"
v-model="name" v-bind:value="n" />
<label class="form-check-label">{{ n }}</label>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
allNames: ["Bob", "Alice", "Joe"],
name: "Bob"
}
}
}
</script>
Listing 15-8Generating Radio Buttons in the App.vue File in the src Folder
必须使用v-bind
指令来设置input
元素的value
属性;否则,Vue.js 不会将属性值作为表达式进行计算。图 15-8 显示了列表 15-8 的结果。
图 15-8
从数据值生成表单元素
绑定到选择元素
选择元素允许以紧凑的方式向用户呈现有限数量的选择,如清单 15-9 所示。定义用户可用选项的option
元素可以静态定义,或者使用v-for
指令定义,或者如清单所示,混合使用两者。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Name: {{ name }} </div>
</div>
<div class="bg-primary m-2 p-2 text-white">
<div class="form-group">
<label>Selected Names</label>
<select class="form-control" v-model="name">
<option value="all">Everyone</option>
<option v-for="n in allNames" v-bind:key="n"
v-bind:value="n">Just {{ n}}</option>
</select>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
allNames: ["Bob", "Alice", "Joe"],
name: "Bob"
}
}
}
</script>
Listing 15-9Binding to a Select Element in the App.vue File in the src Folder
与单选按钮一样,v-bind
指令必须用于设置value
属性。图 15-9 显示了列表 15-9 的结果。
图 15-9
绑定到选择的元素
使用垂直模型修改器
v-model
指令提供了三个绑定来改变它创建的双向绑定。这些修改器在表 15-3 中描述,并在以下章节中演示。
表 15-3
v-model 指令的修饰符
|修饰语
|
描述
|
| --- | --- |
| number
| 这个修饰符将输入的值解析成一个数字,然后将它赋给数据属性。 |
| trim
| 这个修饰符在将输入赋给 data 属性之前,从输入中删除任何前导和尾随空白。 |
| lazy
| 此修饰符更改 v-model 指令侦听的事件,以便仅当用户离开 input 元素时更新 data 属性。 |
将值格式化为数字
number
修饰符解决了当类型属性被设置为number
时input
元素工作方式的奇怪之处,并在清单 15-10 中演示。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Amount: {{ amount }}, Amount + 10 = {{ amount + 10 }}</div>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" class="form-control" v-model="amount" />
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
amount: 100
}
}
}
</script>
Listing 15-10Using a Numeric Input Element in the App.vue File in the src Folder
HTML5 规范为type
属性添加了一系列值,而number
值告诉浏览器只接受数字和小数点的击键。但是用户输入的值被浏览器显示为字符串。这与动态 JavaScript 输入系统结合起来产生了一个问题,通过将 Amount 字段中的值更改为任意数字,比如 101,就可以看到这个问题。当值改变时,v-model
指令响应由input
元素生成的事件,并使用它接收的字符串值更新名为amount
的data
属性,产生如图 15-10 左侧所示的效果。
图 15-10
数字修饰符的效果
这个问题出现在文本插值绑定中,它给amount
值加 10。由于v-model
指令已经用一个字符串更新了 amount,JavaScript 将数据绑定的表达式解释为字符串连接,而不是加法,这意味着101 + 10
产生一个结果10110
。在清单 15-11 中,我在v-model
指令中添加了number
修饰符,避免了这个问题。
...
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Amount: {{ amount }}, Amount + 10 = {{ amount + 10 }}</div>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" class="form-control" v-model.number="amount" />
</div>
</div>
</template>
...
Listing 15-11Applying a Modifier in the App.vue File in the src Folder
number
修饰符告诉v-model
指令将用户输入的值转换成一个数字,然后用它来更新应用。
警告
number
修饰符不对input
元素允许的字符施加任何限制,如果用户输入的值包含非数字字符,它将允许指令用字符串更新数据模型。当使用这个修饰符时,您必须通过将input
元素的type
属性设置为number
来确保用户只能输入数字。
延迟更新
默认情况下,v-model
指令会在每次按键后更新数据模型,并生成input
或textarea
元素。lazy
修饰符改变了v-model
指令监听的事件,因此只有当导航到另一个组件时才执行更新。在清单 15-12 中,我将修饰符应用于组件模板中的input
元素。
...
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Amount: {{ amount }}, Amount + 10 = {{ amount + 10 }}</div>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" class="form-control" v-model.number.lazy="amount" />
</div>
</div>
</template>
...
Listing 15-12Applying a Modifier in the App.vue File in the src Folder
在input
元素失去焦点之前,lazy
修饰符将阻止amount
属性被更新,通常是当用户切换到另一个表单元素或者在元素外单击鼠标按钮时。
删除空白字符
trim
修饰符从用户输入的文本中删除开头和结尾的空白字符,有助于避免用户很难看到的验证错误。在清单 15-13 中,我向组件添加了一个文本input
元素,并在其v-model
指令上使用了trim
修饰符。
注意
trim
修饰符只影响用户输入到元素中的值。如果应用的另一部分将data
属性设置为有前导或尾随空格的字符串,这些字符将通过input
元素显示给用户。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Name: **{{name}}** </div>
</div>
<div class="form-group">
<label>Name</label>
<input type="text" class="form-control" v-model.trim="name" />
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "Bob"
}
}
}
</script>
Listing 15-13Trimming Whitespace in the App.vue File in the src Folder
我用星号包围了显示值的文本插值绑定,以帮助强调任何前导或尾随空白。要测试修饰符,请保存对组件的更改,并输入以空格开头或结尾的字符串。这些字符将从分配给data
属性的字符串中删除,产生如图 15-11 所示的结果。
图 15-11
修剪空白字符
绑定到不同的数据类型
v-model
指令能够调整它绑定到数据模型的方式,这使得以通常对 web 应用开发有用的方式使用表单元素成为可能,如以下部分所述。
选择一组项目
如果将v-model
指令应用于复选框并绑定到作为数组的数据属性,那么选中和取消选中该框将在数组中添加和删除一个值。这比解释更容易演示,在清单 15-14 中,我使用了v-for
指令来生成一组复选框,并使用v-model
指令将它们绑定到一个数组。
<template>
<div class="container-fluid">
<div class="bg-info m-2 p-2 text-white">
<div>Selected Cities: {{ cities }}</div>
</div>
<div class="form-check m-2" v-for="city in cityNames" v-bind:key="city">
<label class="form-check-label">
<input type="checkbox" class="form-check-input"
v-model="cities" v-bind:value="city" />
{{city}}
</label>
</div>
<div class="text-center">
<button v-on:click="reset" class="btn btn-info">Reset</button>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
cityNames: ["London", "New York", "Paris", "Berlin"],
cities: []
}
},
methods: {
reset() {
this.cities = [];
}
}
}
</script>
Listing 15-14Creating a Binding to an Array in the App.vue File in the src Folder
v-for
指令为cityName
数组中的每个值创建一个 checkbox 元素,它提供了一组值,用户可以从中进行选择。每个input
元素都配置了一个value
属性,该属性指定了城市名,当它被选中时将被添加到cities
数组中,当它被取消选中时将被删除。v-model
指令被配置为创建一个到cities
数组的双向数据绑定,该数组将由所选的值填充。双向绑定意味着,如果应用的另一部分——比如本例中的reset
方法——从数组中删除值,那么v-model
指令将自动取消选中该框。我使用了一个文本插值绑定来显示选中的值,如图 15-12 所示。
图 15-12
创建到数组的数据绑定
使用 SELECT 元素绑定到数组
使用配置为允许多重选择的 select 元素可以实现类似的效果,如下所示:
...
<div class="form-control">
<label>City</label>
<select multiple class="form-control" v-model="cities">
<option v-for="city in cityNames" v-bind:key="city">
{{city}}
</option>
</select>
</div>
...
v-for
指令应用于option
元素,并用将呈现给用户的选项填充select
元素。v-model
指令被配置为绑定到一个数组,其工作方式与清单 15-14 中展示的复选框组相同。
我更喜欢复选框,因为大多数用户并不知道需要按住修饰键——比如 Windows 上的 Shift 或 Control 键来进行连续和非连续的选择。向用户呈现一组复选框是一种更明显的方法,我更喜欢在select
元素旁边显示解释性文本。
为表单元素使用自定义值
复选框的一个常见模式是间接切换一个值,以便由input
元素及其v-model
绑定提供的真/假值被计算属性或数据绑定表达式转换为不同的值。在清单 15-15 中,我通过使用v-bind
指令将一个元素分配给对应于引导 CSS 类的类,演示了这种模式。
<template>
<div class="container-fluid">
<div class="m-2 p-2 text-white" v-bind:class="elemClass">
<div>Value: {{ elemClass }}</div>
</div>
<div class="form-check m-2">
<label class="form-check-label">
<input type="checkbox" class="form-check-input"
v-model="dataValue" />
Dark Color
</label>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
dataValue: false,
}
},
computed: {
elemClass() {
return this.dataValue ? "bg-primary" : "bg-info";
}
}
}
</script>
Listing 15-15Indirect Values in the App.vue File in the src Folder
复选框上的v-model
指令设置名为dataValue
的数据属性,该属性仅由elemClass
计算属性使用,div
元素上的v-bind
指令使用该属性设置类成员。切换复选框的效果是在bg-primary
和bg-info
类之间移动div
元素,Bootstrap 使用它来设置背景颜色,如图 15-13 所示。
图 15-13
使用复选框设置的间接值
当复选框设置的data
属性以这种方式使用时,转换true
/ false
值的计算属性可以通过应用true-value
和false-value
属性来消除,如清单 15-16 所示。
<template>
<div class="container-fluid">
<div class="m-2 p-2 text-white" v-bind:class="dataValue">
<div>Value: {{ dataValue }}</div>
</div>
<div class="form-check m-2">
<label class="form-check-label">
<input type="checkbox" class="form-check-input"
v-model="dataValue" true-value="bg-primary"
false-value="bg-info" />
Dark Color
</label>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
dataValue: "bg-info"
}
}
}
</script>
Listing 15-16Simplifying Bindings in the App.vue File in the src Folder
true-value
和false-value
属性被v-model
指令用来设置dataValue
,这意味着不需要一个计算属性来将用户的选择转换成可以被v-bind
指令使用的类名。结果是一样的——切换复选框改变了div
元素的类成员资格——但是代码更简单、更干净。
为单选按钮和选择元素使用自定义值
v-model
指令可以与v-bind
指令结合使用,以支持也可用于单选按钮和选择元素的值。在清单 15-17 中,我添加了这两种类型的元素,并对它们进行了配置,使它们使用上一节中介绍的类名。
<template>
<div class="container-fluid">
<div class="m-2 p-2 text-white" v-bind:class="dataValue">
<div>Value: {{ dataValue }}</div>
</div>
<div class="form-check m-2">
<label class="form-check-label">
<input type="checkbox" class="form-check-input"
v-model="dataValue" v-bind:true-value="darkColor"
v-bind:false-value="lightColor" />
Dark Color
</label>
</div>
<div class="form-group m-2 p-2 bg-secondary">
<label>Color</label>
<select v-model="dataValue" class="form-control">
<option v-bind:value="darkColor">Dark Color</option>
<option v-bind:value="lightColor">Light Color</option>
</select>
</div>
<div class="form-check-inline m-2">
<label class="form-check-label">
<input type="radio" class="form-check-input"
v-model="dataValue" v-bind:value="darkColor" />
Dark Color
</label>
</div>
<div class="form-check-inline m-2">
<label class="form-check-label">
<input type="radio" class="form-check-input"
v-model="dataValue" v-bind:value="lightColor" />
Light Color
</label>
</div>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
darkColor: "bg-primary",
lightColor: "bg-info",
dataValue: "bg-info"
}
}
}
</script>
Listing 15-17Using Custom Values for Other Elements in the App.vue File in the src Folder
为了避免在每个元素上重复类名,我使用了v-bind
指令,通过名为darkColor
和lightColor
的数据属性为input
和option
元素设置value
属性。结果是一组管理div
元素的类成员资格的表单元素,而不需要计算属性来转换true
/ false
值,如图 15-14 所示。
图 15-14
对其他元素使用自定义值
验证表单数据
一旦开始使用表单元素,就需要开始考虑数据验证。用户将在文本字段中输入任何内容,确保您的应用接收到运行所需的数据非常重要。在接下来的部分中,我将解释如何验证表单数据,为了做好准备,我已经更新了组件,使其包含一个简单的表单,如清单 15-18 所示。
注意
在这一节中,我将向您展示如何编写您自己的验证代码,因为这很有趣,并且它演示了如何将本章和前面章节中描述的一些功能组合起来,以产生有用的功能。在实际项目中,我建议你依靠一个优秀的、广泛使用的开源包来进行 Vue.js 表单验证,比如vuelidate
( https://github.com/monterail/vuelidate
),我在第一部分中用于 SportsStore 项目的,或者VeeValidate
( http://vee-validate.logaretm.com
)。
<template>
<div class="container-fluid">
<div class="btn-primary text-white my-2 p-2">
Name: {{ name }}, Category: {{ category }}, Price: {{ price }}
</div>
<form v-on:submit.prevent="handleSubmit">
<div class="form-group">
<label>Name</label>
<input v-model="name" class="form-control" />
</div>
<div class="form-group">
<label>Category</label>
<input v-model="category" class="form-control" />
</div>
<div class="form-group">
<label>Price</label>
<input type="number" v-model.number="price" class="form-control" />
</div>
<div class="text-center">
<button class="btn btn-primary" type="submit">Submit</button>
</div>
</form>
</div>
</template>
<script>
export default {
name: "MyComponent",
data: function () {
return {
name: "",
category: "",
price: 0
}
},
methods: {
handleSubmit() {
console.log(`FORM SUBMITTED: ${this.name} ${this.category} `
+ ` ${this.price}`);
}
}
}
</script>
Listing 15-18Creating a Form in the App.Vue File in the src Folder
该组件定义了名为name
、category
和price
的data
属性,这些属性通过input
元素呈现给用户,这些元素已经应用了v-model
指令。输入值的详细信息显示在表单元素上方,如图 15-15 所示。
图 15-15
向用户呈现表单
input
元素包含在一个form
元素中,该元素应用了v-on
指令,如下所示:
...
<form v-on:submit.prevent="handleSubmit">
...
当用户点击类型被设置为submit
的button
元素时,触发submit
事件。需要使用 prevent 修饰符来阻止浏览器向 HTTP 服务器提交表单,这是该事件的默认操作。该指令的表达式调用了一个名为handleSubmit
的方法,该方法会将数据发送到实际应用中的 web 服务,但在本例中只是将一条消息写到浏览器的 JavaScript 控制台。单击 Submit 按钮,您将在 JavaScript 控制台中看到如下消息:
...
FORM SUBMITTED: Running Shoes Running 100
...
我在第十九章中解释了如何使用 web 服务,但是对于这一章,重点是验证用户输入的数据。本章这一部分的目标是控制由handleSubmit
方法显示的消息,以便它只在用户为所有表单域输入有效数据时才显示。当执行验证时,有一套清晰的要求是很重要的,对于这个例子,每个属性的验证要求在表 15-4 中描述。
表 15-4
验证要求
|名字
|
描述
|
| --- | --- |
| name
| 用户必须提供至少包含三个字符的值。 |
| category
| 用户必须提供一个仅包含字母的值。 |
| price
| 用户必须提供一个仅包含数字且介于 1 和 1,000 之间的值。 |
定义验证规则
当您验证数据时,您会发现同一组规则被重复应用于用户提供的不同数据值。为了减少代码重复,定义一次验证规则并重用它们是一个好主意。为了定义表 15-4 中所需的验证规则,我在src
文件夹中添加了一个名为validationRules.js
的 JavaScript 文件,并添加了清单 15-19 中所示的代码。
理解用户输入错误数据的原因
用户在应用中输入错误数据需要数据验证有几个原因。第一种类型的坏数据发生在用户不理解所需的数据或数据必须以何种格式表达时。我遇到的一个常见例子是要求信用卡在数字组之间有或没有空格。如果您需要用特定的格式来表示公共数据值,您应该让用户清楚。更好的是,完全避免这个问题,接受所有的通用格式,并自动将它们转换成您喜欢的格式。
第二类坏数据发生在用户不关心过程,只想得到结果的时候。如果你让用户忍受冗长而复杂的表单,或者一遍又一遍地重复一个过程,那么你会看到很多毫无意义的结果。为了最大限度地减少这种问题,请只向用户询问您真正需要的数据,尽可能提供合理的默认值,并且请记住,很少有用户会像您希望的那样关心您的产品和服务。
对于坏数据的最终原因,你无能为力,这是用户试图颠覆应用的地方。总会有恶意用户,你的产品和服务越有价值,吸引的恶意用户就越多。数据验证本身不足以避免这类问题,您必须付出适当的努力来进行端到端的安全审查、监控和补救。
function required(name) {
return {
validator: (value) => value != "" && value !== undefined && value !== null,
message: `A value is required for ${name}`
}
}
function minLength(name, minlength) {
return {
validator: (value) => String(value).length >= minlength,
message: `At least ${minlength} characters are required for ${name}`
}
}
function alpha(name) {
return {
validator: (value) => /^[a-zA-Z]*$/.test(value),
message: `${name} can only contain letters`
}
}
function numeric(name) {
return {
validator: (value) => /^[0-9]*$/.test(value),
message: `${name} can only contain digits`
}
}
function range(name, min, max) {
return {
validator: (value) => value >= min && value <= max,
message: `${name} must be between ${min} and ${max}`
}
}
export default {
name: [minLength("Name", 3)],
category: [required("Category"), alpha("Category")],
price: [numeric("Price"), range("Price", 1, 1000)]
}
Listing 15-19The Contents of the validationRules.js File in the src Folder
这个 JavaScript 文件定义了验证函数,我将使用这些函数来检查用户输入的值以及这些函数的组合,以验证每个属性。
执行验证
下一步是添加将执行验证的代码。在清单 15-20 中,我添加了使用下一节定义的验证规则所需的方法和属性。
<template>
<div class="container-fluid">
<div class="bg-danger text-white my-2 p-2" v-if="errors">
<h5>The following problems have been found:</h5>
<ul>
<template v-for="(errors) in validationErrors">
<li v-for="error in errors" v-bind:key="error">{{error}}</li>
</template>
</ul>
</div>
<form v-on:submit.prevent="handleSubmit">
<div class="form-group">
<label>Name</label>
<input v-model="name" class="form-control" />
</div>
<div class="form-group">
<label>Category</label>
<input v-model="category" class="form-control" />
</div>
<div class="form-group">
<label>Price</label>
<input type="number" v-model.number="price" class="form-control" />
</div>
<div class="text-center">
<button class="btn btn-primary" type="submit">
Submit
</button>
</div>
</form>
</div>
</template>
<script>
import validation from "./validationRules";
import Vue from "vue";
export default {
name: "MyComponent",
data: function () {
return {
name: "",
category: "",
price: 0,
validationErrors: {},
}
},
computed: {
errors() {
return Object.values(this.validationErrors).length > 0;
}
},
methods: {
validate(propertyName, value) {
let errors = [];
Object(validation)[propertyName].forEach(v => {
if (!v.validator(value)) {
errors.push(v.message);
}
});
if (errors.length > 0) {
Vue.set(this.validationErrors, propertyName, errors);
} else {
Vue.delete(this.validationErrors, propertyName);
}
},
validateAll() {
this.validate("name", this.name);
this.validate("category", this.category);
this.validate("price", this.price);
return this.errors;
},
handleSubmit() {
if (this.validateAll()) {
console.log(`FORM SUBMITTED: ${this.name} ${this.category} `
+ ` ${this.price}`);
}
}
}
}
</script>
Listing 15-20Performing Validation in the App.vue File in the src Folder
使用清单 15-20 中定义的规则,validate
方法用于验证单个data
属性,这些规则通过import
语句访问。验证错误的详细信息存储在名为validationErrors
的data
属性中,每个input
字段都有一个属性设置为需要呈现给用户的验证消息数组。
小费
我在清单 15-20 中使用的Vue.delete
方法是在第十三章中描述的Vue.set
方法的对应物,用于从对象中移除一个属性,这样 Vue.js 就能意识到变化。
当用户点击提交按钮时,handleSubmit
方法调用validateAll
方法,后者调用每个data
属性的validate
方法,并执行动作——在本例中记录一条消息——只有在没有验证问题的情况下。
在组件的模板中,我使用v-if
指令来控制div
元素的可见性,然后依赖v-for
指令来枚举validationErrors
对象的属性,然后再次为每条消息创建li
元素。
结果是,当点击提交按钮时,用户已经输入到表单中的值被检查,如图 15-16 所示,该图显示了在没有改变任何输入字段的情况下点击按钮时显示的错误。
图 15-16
验证表单数据
响应实时变化
基本的验证工作正常,但是只有当用户单击提交按钮时才执行检查。更平滑的方法是在用户向input
元素中输入数据时验证数据,提供更直接的反馈。我不想立即开始显示错误,所以我会等到用户点击一次提交后再响应更改,如清单 15-21 所示。
...
<script>
import validation from "./validationRules";
import Vue from "vue";
export default {
name: "MyComponent",
data: function () {
return {
name: "",
category: "",
price: 0,
validationErrors: {},
hasSubmitted: false
}
},
watch: {
name(value) { this.validateWatch("name", value) },
category(value) { this.validateWatch("category", value) },
price(value) { this. validateWatch("price", value) }
},
computed: {
errors() {
return Object.values(this.validationErrors).length > 0;
}
},
methods: {
validateWatch(propertyName, value) {
if (this.hasSubmitted) {
this.validate(propertyName, value);
}
},
validate(propertyName, value) {
let errors = [];
Object(validation)[propertyName].forEach(v => {
if (!v.validator(value)) {
errors.push(v.message);
}
});
if (errors.length > 0) {
Vue.set(this.validationErrors, propertyName, errors);
} else {
Vue.delete(this.validationErrors, propertyName);
}
},
validateAll() {
this.validate("name", this.name);
this.validate("category", this.category);
this.validate("price", this.price);
return this.errors;
},
handleSubmit() {
this.hasSubmitted = true;
if (this.validateAll()) {
console.log(`FORM SUBMITTED: ${this.name} ${this.category} `
+ ` ${this.price}`);
}
}
}
}
</script>
...
Listing 15-21Responding to Changes in the App.vue File in the src Folder
我在script
元素中添加了一个watch
部分,用于定义观察者。我在第十七章解释了观察器,但是这个特性允许一个组件在它的一个数据值改变时接收通知。在这个例子中,我已经向watch
部分添加了函数,这样当name
、category
和price
属性发生变化时,我将收到通知,并使用这个通知来调用一个名为validateWatch
的方法,该方法仅在提交按钮至少被单击一次后才验证属性值,这通过一个名为hasSubmitted
的新data
属性来管理。其结果是,一旦点击提交按钮,显示给用户的错误信息会在编辑时立即更新,如图 15-17 所示。
图 15-17
验证错误的即时反馈
摘要
在本章中,我解释了双向数据绑定的使用,并描述了如何使用v-model
指令创建它们。我还演示了如何使用 v-model 指令和 Vue.js 的其他关键特性来验证用户输入到 HTML 表单中的数据。在下一章,我将描述如何使用组件来给应用添加结构。
十六、使用组件
在这一章中,我将解释组件如何在 Vue.js 应用中形成构建块,并允许将相关内容和代码组合在一起,以使开发更容易。我将向您展示如何向项目中添加组件,如何在组件之间进行通信,以及组件如何协同工作来向用户呈现内容。表 16-1 将组件放在上下文中。
表 16-1
将组件放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 可以组合组件,从较小的构建块中创建复杂的功能。 |
| 它们为什么有用? | 使用单个组件构建复杂的应用,很难区分哪些内容和代码与每个功能相关。将应用分成几个组件意味着每个组件都可以单独开发和测试。 |
| 它们是如何使用的? | 使用script
元素中的components
属性声明组件,并使用自定义 HTML 元素进行应用。 |
| 有什么陷阱或限制吗? | 默认情况下,组件是相互隔离的,并且允许它们通信的特性可能很难掌握。 |
| 有其他选择吗? | 您不必使用多个组件来构建一个应用,单个组件对于简单的项目来说可能是可以接受的。 |
表 16-2 总结了本章内容。
表 16-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 群组相关功能 | 将组件添加到项目中 | 1–9 |
| 与子组件通信 | 使用道具功能 | 10–12 |
| 与父组件通信 | 使用自定义事件功能 | 13–14 |
| 混合父组件和子组件内容 | 使用插槽功能 | 15–20 |
为本章做准备
我继续从事第十五章的templatesanddata
项目。为了准备本章,我简化了应用的根组件,如清单 16-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
Listing 16-1Simplifying the Content of the App.vue File in the src Folder
运行templatesanddata
文件夹中清单 16-2 所示的命令,启动开发工具。
npm run serve
Listing 16-2Navigating to the Project Folder and Starting the Development Tools
将执行初始绑定过程,之后您将看到一条消息,告诉您项目已成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口,导航到http://localhost:8080
查看项目的占位符内容,如图 16-1 所示。
图 16-1
运行示例应用
将组件理解为构建块
随着应用复杂性的增加,使用单个组件变得越来越困难。你在第十五章看到了这样一个例子,一个表单和它的验证逻辑并存,结果是很难确定模板和脚本元素的哪些部分负责处理表单,哪些与验证相关。具有多重职责的组件很难理解、测试和维护。
组件是 Vue.js 应用的构建块,在一个应用中使用多个组件允许更小的功能单元,这些单元在整个应用中更容易编写、维护和重用。
当使用组件构建应用时,结果是一种父子关系,其中一个组件(父组件)将其模板的一部分委托给另一个组件(子组件)。了解其工作原理的最佳方式是创建一个演示这种关系的示例。组件通常定义在src/components
文件夹中扩展名为.vue
的文件中,我在该文件夹中添加了一个名为Child.vue
的文件,内容如清单 16-3 所示。
注意
这个项目是用src/components
中的HelloWorld.vue
文件创建的,但是我在本章中不使用那个文件,您可以忽略或者删除这个文件。
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
Child Component
</div>
</template>
Listing 16-3The Contents of the Child.vue File in the src/components Folder
组件必须至少提供一个template
元素,这是委托过程的核心。下一步是建立委托并创建父和子之间的关系,如清单 16-4 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<ChildComponent></ChildComponent>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
ChildComponent
}
}
</script>
Listing 16-4Delegating to a Child Component in the App.vue File in the src Folder
在父组件中设置子组件需要三个步骤。第一步是为子组件使用一个import
语句,如下所示:
...
import ChildComponent from "./components/Child";
...
import
语句中使用的源代码必须以句点开始,这表明这是一个本地导入语句,而不是一个针对使用包管理器安装的包的语句。import 语句最重要的部分是子组件被分配的名称,我用粗体标记了这个名称,在本例中是ChildComponent
。
在import
语句中的名字用于注册组件,在script
元素中使用一个名为components
的属性,如下所示:
...
export default {
name: 'App',
components: {
ChildComponent
}
}
...
使用一个对象来分配components
属性,该对象的属性是它所使用的组件,确保在components
对象中使用与在import
语句中相同的名称是很重要的。最后一步是将一个 HTML 元素添加到父组件的模板中,该元素带有一个与在import
语句和components
对象中使用的名称相匹配的标签,如下所示:
...
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<ChildComponent></ChildComponent>
</div>
...
当 Vue.js 处理父组件的模板时,它找到自定义 HTML 元素并用子组件的template
元素中的内容替换它,产生如图 16-2 所示的结果。
注意
这个例子展示了父子关系的一个重要方面:父组件决定了定义子组件的名称。这乍一看似乎很奇怪,但是它允许使用有意义的名称,反映了子组件的应用方式。正如您将在后面的示例中看到的,单个组件可以在应用的不同部分中使用,并且允许父组件命名其子组件意味着组件的每次使用都可以被赋予一个建议其用途的名称。
图 16-2
向示例应用添加组件
如果您使用浏览器的 F12 工具来检查 HTML 文档,您将会看到子组件是如何被用来替换ChildComponent
元素的。
...
<body>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<div class="bg-primary text-white text-center m-2 p-3 h6">
Child Component
</div>
</div>
<script type="text/javascript" src="/app.js"></script>
</body>
...
结果是App.vue
文件中定义的App
组件呈现的部分内容被委托给了Child.vue
文件中定义的Child
组件,如图 16-3 所示。
图 16-3
父子组件关系
了解子组件名称和元素
在清单 16-4 中,我使用了import
语句中的名称来设置子组件,这导致了这个看起来很笨拙的定制 HTML 元素:
...
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<ChildComponent></ChildComponent>
</div>
...
这是一个很有用的方法来证明是父组件命名了它的子组件,但是 Vue.js 有一个更复杂的命名方法,这可以产生更优雅的 HTML。
第一个名称特性是 Vue.js 在寻找要使用的子组件时会自动重新格式化定制 HTML 元素标签名称,如清单 16-5 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<ChildComponent></ChildComponent>
<child-component></child-component>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
ChildComponent
}
}
</script>
Listing 16-5Tag Name Reformatting in the App.vue File in the src Folder
模板中新的定制 HTML 元素演示了 Vue.js 将接受带连字符的标记名,然后这些标记名将被转换为组件名通常使用的 camel case 格式,这样标记child-component
将被识别为使用ChildComponent
的指令。清单 16-5 中的两个定制 HTML 元素都告诉 Vue.js 将父组件模板的一部分委托给子组件的一个实例,产生如图 16-4 所示的结果。
图 16-4
灵活的自定义元素标记格式
components
属性是一个映射,Vue.js 使用它将定制的 HTML 元素标记名转换为子组件名,这意味着当你注册一个组件时,你可以指定一个完全不同的标记名,如清单 16-6 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
Root Component
<MyFeature></MyFeature>
<my-feature></my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
MyFeature: ChildComponent
}
}
</script>
Listing 16-6Specifying a Tag Name in the App.vue File in the src Folder
当您为子组件指定属性和值时,属性名称将用于自定义 HTML 元素。在这个例子中,我已经将属性的名称设置为MyFeature
,这意味着我可以使用MyFeature
和my-feature
标签来应用ChildComponent
。
全局注册组件
如果您有一个在整个应用中都需要的组件,那么您可以全局注册它。这种方法的优点是您不必配置每个父组件;然而,缺点是使用相同的 HTML 元素来应用子组件,这可能会导致模板意义不大。要全局注册一个组件,可以在main.js
文件中添加一个导入语句,并使用Vue.component
方法,如下所示:
import Vue from 'vue'
import App from './App'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import ChildComponent from "./components/Child";
Vue.config.productionTip = false
Vue.component("child-component", ChildComponent);
new Vue({
render: h => h(App)
}).$mount('#app')
在创建应用的Vue
对象之前,必须调用Vue.component
方法,它的参数是 HTML 元素标签,将用于应用组件和在import
语句中命名的组件对象。结果是在整个应用中可以使用child-component
元素来应用组件,而无需任何进一步的配置。
在子组件中使用组件功能
我在清单 16-6 中定义的子组件只包含一个template
元素,但是 Vue.js 支持子组件中前面章节描述的所有特性,包括单向和双向数据绑定、事件处理程序、data
和computed
属性以及方法。在清单 16-7 中,我向子组件添加了一个script
元素,并使用它来支持模板中的数据绑定。
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
{{ message }}
<div class="form-group m-1">
<input v-model="message" class="form-control" />
</div>
</div>
</template>
<script>
export default {
data: function() {
return {
message: "This is the child component"
}
}
}
</script>
Listing 16-7Adding Features in the Child.vue File in the src/components Folder
我添加的script
元素定义了一个名为message
的数据属性,我在带有文本插值绑定和v-model
指令的template
元素中使用了这个属性。结果是子组件显示一个input
元素,其内容反映在文本数据绑定中,如图 16-5 所示。
图 16-5
向子组件添加功能
了解元件隔离
组件是相互隔离的,这意味着您不必担心选择唯一的属性和方法名,也不必担心绑定到不同组件所拥有的值。
在图 16-5 中,你可以看到编辑一个input
元素的内容对另一个子组件没有影响,即使它们都定义并使用了一个message
属性。这种隔离也适用于父组件和子组件,这可以通过在示例应用中向父组件添加一个message
属性来演示,如清单 16-8 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
{{ message }}
<MyFeature></MyFeature>
<my-feature></my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
MyFeature: ChildComponent
},
data: function() {
return {
message: "This is the parent component"
}
}
}
</script>
Listing 16-8Adding a Data Property in the App.vue File in the src Folder
现在在应用中有三个名为message
的data
属性,但是 Vue.js 将它们中的每一个都保持隔离,这样对其中一个的更改就不会影响到另一个,如图 16-6 所示。
图 16-6
父组件与子组件之间的隔离
理解 CSS 范围
如果您在组件中定义了自定义样式,您会发现它们会应用于任何组件定义的元素。例如,这种风格:
...
<style>
div { border: 5px solid red ; }
</style>
...
将匹配应用中的任何div
元素,并应用红色实心边框。如果您想将您的自定义 CSS 样式限制在定义它们的组件中,您可以将scoped
属性添加到style
元素中,如下所示:
...
<style scoped>
div { border: 5px solid red ; }
</style>
...
scoped
属性告诉 Vue.js,样式应该只应用于当前组件的template
元素中的元素,而不应该应用于其他组件的模板中的元素。
使用组件道具
保持组件隔离是一个很好的默认策略,因为它避免了意外的交互。如果组件没有相互隔离,对一个message
属性的改变会影响所有的组件。另一方面,在大多数应用中,组件必须协同工作才能为用户提供功能,这意味着要突破组件之间的障碍。组件协作的一个特性是 prop ,它允许父母为孩子提供数据值。在清单 16-9 中,我给子组件添加了一个道具。
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
{{ message }}
<div class="form-group m-1 text-left">
<label>{{ labelText }}</label>
<input v-model="message" class="form-control" />
</div>
</div>
</template>
<script>
export default {
props: ["labelText"],
data: function () {
return {
message: "This is the child component"
}
}
}
</script>
Listing 16-9Adding a Prop in the Child.vue File in the src/components Folder
使用分配给组件的script
元素中的props
属性的字符串数组来定义 Props。在这种情况下,道具名称是labelText
。一旦定义了一个属性,就可以在组件的其他地方使用它,比如在文本插值绑定中。如果您需要修改从父组件接收到的值,那么您必须使用一个data
或computed
属性,其初始值是从 prop 获得的,如清单 16-10 所示。
注意
这种方法是必需的,因为属性中的数据流是单向的:从父组件到子组件。如果修改属性值,所做的更改可能会被父组件覆盖。
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
{{ message }}
<div class="form-group m-1 text-left">
<label>{{ labelText }}</label>
<input v-model="message" class="form-control" />
</div>
</div>
</template>
<script>
export default {
props: ["labelText", "initialValue"],
data: function () {
return {
message: this.initialValue
}
}
}
</script>
Listing 16-10Setting a Mutable Value from a Prop in the Child.vue File in the src/components Folder
该组件定义了第二个属性,名为initialValue
,用于设置message
属性的值。
在父组件中使用道具
当一个组件定义一个属性时,它的父组件可以通过使用定制 HTML 元素上的属性向它发送数据值,如清单 16-11 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
{{ message }}
<MyFeature labelText="Name" initialValue="Kayak"></MyFeature>
<my-feature label-text="Category" initial-value="Watersports"></my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
MyFeature: ChildComponent
},
data: function () {
return {
message: "This is the parent component"
}
}
}
</script>
Listing 16-11Using a Prop in the App.vue File in the src Folder
Vue.js 在将属性名与属性匹配时,与将定制 HTML 元素与组件匹配时一样灵活。例如,这意味着我可以使用labelText
或label-text
来设置道具的值。清单 16-11 中的属性配置子组件以产生如图 16-7 所示的结果。
小费
您可能需要重新加载浏览器才能看到此示例的结果。
图 16-7
使用道具配置子组件
小费
Prop 属性值是文字,这意味着该值不作为表达式计算。如果你想传递一个字符串给子组件,那么你可以这样做:my-attr="Hello"
。不需要使用双引号:my-attr="'Hello'"
。如果你想让一个属性的值作为一个表达式来计算,那么使用v-bind
指令。如果你想让子组件响应数据绑定的适当变化,那么你可以使用一个观察器,如第十七章所述。
当使用道具时,重要的是要记住数据流只从父组件流向子组件,如图 16-8 所示。如果你试图修改一个属性值,你会收到一个警告,提醒你这个属性应该被用来初始化一个数据属性,如清单 16-10 所示。
图 16-8
使用道具时的数据流
在自定义 HTML 元素上设置常规属性
当 Vue.js 用子组件的模板替换自定义 HTML 元素时,它会将任何非 prop 属性转移到顶级模板元素,这可能会导致混淆的结果,尤其是如果子组件的模板中的元素已经具有该属性。例如,如果这是子模板元素:
...
<template>
<div id="childIdValue">This is the child's element</div>
</template>
...
这是父模板元素:
...
<template>
<my-feature id="parentIdValue"></my-feature>
</template>
...
然后,父属性应用的属性将覆盖子属性,在浏览器中生成如下所示的 HTML:
...
<div id="parentIdValue">This is the child's element</div>
...
属性class
和style
的行为是不同的,浏览器通过组合这两个属性值来处理它们。如果这是子模板元素:
...
<template>
<div class="bg-primary">This is the child's element</div>
</template>
...
这是父模板元素:
...
<template>
<my-feature class="text-white"></my-feature>
</template>
...
然后,浏览器将组合class
属性值,在浏览器中生成以下 HTML:
...
<div class="bg-primary text-white">This is the child's element</div>
...
当父组件和子组件都设置相同的属性时,必须小心,理想情况下应该避免这种情况。如果您想让父元素负责指定子元素呈现的 HTML 内容,那么就使用插槽特性,我在“使用组件插槽”一节中对此进行了描述。
使用属性值表达式
除非使用了v-bind
指令,否则属性的值不会被计算为表达式,如清单 16-12 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
<div class="form-group">
<input class="form-control" v-model="labelText" />
</div>
<my-feature v-bind:label-text="labelText" initial-value="Kayak"></my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
MyFeature: ChildComponent
},
data: function () {
return {
message: "This is the parent component",
labelText: "Name"
}
}
}
</script>
Listing 16-12Using an Expression in the App.vue File in the src Folder
父组件的模板包括一个使用v-model
指令绑定到labelText
属性的input
元素。在子指令的自定义元素上指定了相同的属性,这告诉 Vue.js 在子组件的labelText
属性和父组件的data
属性之间建立一个单向绑定。
...
<my-feature v-bind:label-text="labelText" initial-value="Kayak"></my-feature>
...
结果是当父组件的input
元素被编辑时,新值被子组件接收,并通过文本插值绑定显示,如图 16-9 所示。
警告
变更流仍然是单向的,即使使用了v-bind
指令。父组件不会收到子组件对属性值所做的更改,当v-bind
指令所使用的属性发生更改时,这些更改将被丢弃。有关如何将数据从子组件发送到其父组件的详细信息,请参见下一节。
图 16-9
使用适当的值表达式
创建自定义事件
props 特性的对应部分是自定义事件,它允许子组件向其父组件发送数据。为了演示自定义事件的使用,我扩展了子组件的功能,以便它提供一些自包含的功能,如清单 16-13 所示,这是组件在实际项目中使用的更典型方式。
结合道具和自定义事件
在某种程度上,大多数刚接触 Vue.js 开发的开发人员会尝试通过组合定制事件、道具和v-model
指令,让父组件和子组件同时显示和更改相同的数据值。只需一点点努力,您就能让某些东西工作起来,但是它违背了这些特性的目的,并且总是一个脆弱的解决方案,偶尔会不可预测地以迷惑用户的方式运行。如第二十章所述,如果你希望多个组件能够显示和改变相同的数据值,使用共享应用状态。对于更简单的应用,事件总线可能就足够了,如第十八章所述
。
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
<div class="form-group m-1 text-left">
<label>Name</label>
<input v-model="product.name" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Category</label>
<input v-model="product.category" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Price</label>
<input v-model.number="product.price" class="form-control" />
</div>
<div class="mt-2">
<button class="btn btn-info" v-on:click="doSubmit">Submit</button>
</div>
</div>
</template>
<script>
export default {
props: ["initialProduct"],
data: function () {
return {
product: this.initialProduct || {}
}
},
methods: {
doSubmit() {
this.$emit("productSubmit", this.product);
}
}
}
</script>
Listing 16-13Adding Features to the Child.vue File in the src/components Folder
该组件是产品对象的基本编辑器,其中的input
元素编辑分配给名为product
的data
属性的对象的name
、category
和price
属性,这些属性的初始数据值是使用名为initialProduct
的属性接收的。
还有一个button
元素,它通过调用一个叫做doSubmit
的方法,使用v-on
指令来响应click
事件。正是这种方法允许组件与其父组件通信,它是这样做的:
...
doSubmit() {
this.$emit("productSubmit", this.product);
}
...
使用关键字this
调用的$emit
方法用于发送自定义事件。第一个参数是事件类型,表示为一个字符串,可选的第二个参数是事件的有效负载,可以是父级可能发现有用的任何值。在本例中,我发送了一个名为productSubmit
的事件,并将product
对象作为有效载荷。
从子组件接收自定义事件
父组件使用v-on
指令从其子组件接收事件,就像常规的 DOM 事件一样。在清单 16-14 中,我已经更新了App.vue
文件,以便为子组件提供初始数据进行编辑,并在事件被触发时对其做出响应。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
<h6>{{ message }}</h6>
<my-feature v-bind:initial-product="product"
v-on:productSubmit="updateProduct">
</my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
export default {
name: 'App',
components: {
MyFeature: ChildComponent
},
data: function () {
return {
message: "Ready",
product: {
name: "Kayak",
category: "Watersports",
price: 275
}
}
},
methods: {
updateProduct(newProduct) {
this.message = JSON.stringify(newProduct);
}
}
}
</script>
Listing 16-14Responding to Child Component Events in the App.vue File in the src Folder
v-on
指令用于监听子组件的定制事件,使用作为第一个参数传递给$emit
方法的名称,在本例中是productSubmit
:
...
<my-feature v-bind:initial-product="product" v-on:productSubmit="updateProduct">
...
在这种情况下,v-on
绑定用于通过调用updateProduct
方法来响应productSubmit
事件。父组件使用的方法接收子组件用作$emit
方法的第二个参数的可选有效负载,在本例中,有效负载的 JSON 表示被分配给名为message
的data
属性,该属性通过文本插值绑定显示给用户。结果是您可以编辑子组件显示的值,点击提交按钮,并查看父组件接收的数据,如图 16-10 所示。
注意
定制事件的行为不像常规的 DOM 事件,即使使用了v-on
指令来处理它们。自定义事件只传递给父组件,不会通过 DOM 中 HTML 元素的层次结构传播,并且没有捕获、目标和冒泡阶段。如果你想超越父子关系进行交流,那么你可以使用事件总线,如第十八章所述,或者共享状态,如第二十章所述。
图 16-10
从子组件处理自定义事件
使用组件插槽
如果您在应用的不同部分使用一个组件,您可能希望定制它呈现给用户的 HTML 元素的外观以适应上下文。
对于简单的内容更改,可以使用 prop,或者父组件可以直接样式化用于应用子组件的自定义 HTML 元素。
对于更复杂的内容更改,Vue.js 提供了一个名为 slots 的特性,它允许父组件提供内容,通过这些内容子组件提供的特性将被显示。在清单 16-15 中,我给子组件添加了一个插槽,它将用于显示父组件提供的内容。
...
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
<slot>
<h4>Use the form fields to edit the data</h4>
</slot>
<div class="form-group m-1 text-left">
<label>Name</label>
<input v-model="product.name" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Category</label>
<input v-model="product.category" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Price</label>
<input v-model.number="product.price" class="form-control" />
</div>
<div class="mt-2">
<button class="btn btn-info" v-on:click="doSubmit">Submit</button>
</div>
</div>
</template>
...
Listing 16-15Adding a Slot in the Child.vue File in the src/components Folder
slot
元素表示组件模板的一个区域,该区域将被替换为父组件在用于应用子组件的定制元素的开始和结束标记之间包含的任何内容。如果父组件不提供任何内容,那么 Vue.js 将忽略 slot 元素,这是你保存Child.vue
文件并在浏览器中检查内容时会看到的,如图 16-11 所示。
小费
您可能需要重新加载浏览器才能看到这个示例的效果。
图 16-11
在槽中显示默认内容
这提供了一个回退,允许子组件向用户显示有用的内容。为了覆盖默认内容,父组件必须在其模板中包含元素,如清单 16-16 所示。
...
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
<h6>{{ message }}</h6>
<my-feature v-bind:initial-product="product"
v-on:productSubmit="updateProduct">
<div class="bg-warning m-2 p-2 h3 text-dark">Product Editor</div>
</my-feature>
</div>
</template>
...
Listing 16-16Providing Elements for a Slot in the App.vue File in the src Folder
出现在my-feature
元素的开始和结束标记之间的div
元素被用作子组件模板中slot
元素的内容,产生如图 16-12 所示的结果。
图 16-12
为子组件的插槽提供内容
使用命名插槽
如果一个子组件可以从它的父组件接收几个区域的内容,那么它可以给它的槽分配名称,如清单 16-17 所示。
...
<template>
<div class="bg-primary text-white text-center m-2 p-3 h6">
<slot name="header">
<h4>Use the form fields to edit the data</h4>
</slot>
<div class="form-group m-1 text-left">
<label>Name</label>
<input v-model="product.name" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Category</label>
<input v-model="product.category" class="form-control" />
</div>
<div class="form-group m-1 text-left">
<label>Price</label>
<input v-model.number="product.price" class="form-control" />
</div>
<slot name="footer"></slot>
<div class="mt-2">
<button class="btn btn-info" v-on:click="doSubmit">Submit</button>
</div>
</div>
</template>
...
Listing 16-17Adding Named Slots in the Child.vue File in the src/components Folder
name
属性用于为每个slot
元素指定名称。在这个例子中,我给出现在input
元素上面的元素指定了名称header
,给出现在它们下面的slot
指定了名称 footer。footer
槽不包含任何元素,这意味着除非父组件提供内容,否则不会显示任何内容。
为了使用一个命名的 slot,父元素将一个 slot 属性添加到包含在自定义开始和结束标记之间的一个元素中,如清单 16-18 所示。
...
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
<h6>{{ message }}</h6>
<my-feature v-bind:initial-product="product"
v-on:productSubmit="updateProduct">
<div slot="header" class="bg-warning m-2 p-2 h3 text-dark">
Product Editor
</div>
<div slot="footer" class="bg-warning p-2 h3 text-dark">
Check Details Before Submitting
</div>
</my-feature>
</div>
</template>
...
Listing 16-18Using Names Slots in the App.vue File in the src Folder
父元素已经为每个指定的槽提供了div
元素,这产生了如图 16-13 所示的结果。
小费
如果子组件定义了一个未命名的槽,那么它将显示父模板中没有分配给带有slot
属性的槽的任何元素。如果子组件没有定义未命名的插槽,那么父组件的未分配元素将被丢弃。
图 16-13
使用命名插槽
使用作用域插槽
作用域槽允许父组件提供一个模板,子组件可以在其中插入数据,这在子组件使用从父组件接收的数据执行转换,并且父组件需要控制格式时非常有用。为了演示作用域插槽特性,我在src/components
文件夹中添加了一个名为ProductDisplay.vue
的文件,并添加了清单 16-19 中所示的内容。
<template>
<ul>
<li v-for="prop in Object.keys(product)" v-bind:key="prop">
<slot v-bind:propname="prop" v-bind:propvalue="product[prop]">
{{ prop }}: {{ product[prop] }}
</slot>
</li>
</ul>
</template>
<script>
export default {
props: ["product"]
}
</script>
Listing 16-19The Contents of the ProductDisplay.vue File in the src/components Folder
这个组件通过一个名为product
的 prop 接收一个对象,它的属性和值使用v-for
指令在模板中被枚举。slot
元素为父元素提供了一个替换显示属性名和值的内容的机会,但是增加了以下重要属性:
...
<slot v-bind:propname="prop" v-bind:propvalue="product[prop]">
...
propname
和propvalue
属性允许父组件将分配给它们的值合并到插槽内容中,如清单 16-20 所示。
<template>
<div class="bg-secondary text-white text-center m-2 p-2 h5">
<product-display v-bind:product="product">
<div slot-scope="data" class="bg-info text-left">
{{data.propname}} is {{ data.propvalue }}
</div>
</product-display>
<my-feature v-bind:initial-product="product"
v-on:productSubmit="updateProduct">
<div slot="header" class="bg-warning m-2 p-2 h3 text-dark">
Product Editor
</div>
<div slot="footer" class="bg-warning p-2 h3 text-dark">
Check Details Before Submitting
</div>
</my-feature>
</div>
</template>
<script>
import ChildComponent from "./components/Child";
import ProductDisplay from"./components/ProductDisplay";
export default {
name: 'App',
components: {
MyFeature: ChildComponent,
ProductDisplay
},
data: function () {
return {
message: "Ready",
product: {
name: "Kayak",
category: "Watersports",
price: 275
}
}
},
methods: {
updateProduct(newProduct) {
this.message = JSON.stringify(newProduct);
}
}
}
</script>
Listing 16-20Using a Scoped Slot in the App.vue File in the src Folder
slot-scope
属性用于选择在处理模板时创建的临时变量的名称,并且将为子元素在其slot
元素上定义的每个属性分配一个属性,然后可以在插槽内容中的数据绑定和指令中使用该属性,如下所示:
...
<product-display v-bind:product="product">
<div slot-scope="data" class="bg-info text-left">
{{data.propname}} is {{ data.propvalue }}
</div>
</product-display>
...
结果是来自父组件的 HTML 元素与子组件提供的数据混合,产生如图 16-14 所示的结果。
小费
作用域 slot 内容中的表达式和数据绑定是在父组件的上下文中计算的,这意味着 Vue.js 将查找您引用的任何值,只要它没有以父组件的script
元素中的slot-scope
属性中命名的变量为前缀。
图 16-14
使用作用域插槽
摘要
在这一章中,我描述了组件作为 Vue.js 应用的构建块的使用方式,允许相关的内容和代码被分组以便于开发和维护。我解释了组件在默认情况下是如何被隔离的,prop 和 custom event 特性如何允许组件进行通信,以及 slots 特性如何允许父组件向子组件提供内容。在本书的第三部分,我描述了高级的 Vue.js 特性。
十七、了解组件生命周期
当 Vue.js 创建一个组件时,它就开始了一个定义良好的生命周期,包括准备数据值、在文档对象模型(DOM)中呈现 HTML 内容以及处理任何更新。在这一章中,我描述了组件生命周期中的不同阶段,并演示了组件对这些阶段做出响应的各种方式。
组件生命周期值得您关注有两个原因。首先,你对 Vue.js 的工作原理了解得越多,当你没有得到你期望的结果时,你对诊断问题的准备就越充分。第二个原因是,我在后面的章节中描述的一些高级特性在您理解它们的工作环境时会更容易理解和应用。表 17-1 将组件生命周期放在上下文中。
表 17-1
将组件生命周期放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | 每个组件都遵循一个明确定义的生命周期,从 Vue.js 创建它开始,到它被销毁结束。 |
| 为什么有用? | 定义良好的生命周期使得 Vue.js 组件可预测,并且有通知方法允许组件响应不同的生命周期阶段。 |
| 如何使用? | Vue.js 自动遵循生命周期,不需要任何直接操作。如果一个组件想要接收关于其生命周期的通知,那么它可以实现表 17-3 中描述的方法。 |
| 有什么陷阱或限制吗? | 实现生命周期通知方法的一个主要原因是使用 DOM API 直接访问组件的 HTML 内容,而不是使用指令或其他 Vue.js 特性。这可能会破坏应用的设计,并使其更难测试和维护。 |
| 还有其他选择吗? | 您不必关注组件的生命周期,许多项目根本不需要实现通知方法。 |
表 17-2 总结了本章内容。
表 17-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 创建组件时收到通知 | 实施beforeCreate
或created
方法 | eight |
| 当允许访问 DOM 时接收通知 | 实施beforeMount
或mounted
方法 | 9, 10 |
| 当数据属性更改时接收通知 | 实施beforeUpdate
或updated
方法 | 11–12 |
| 更新后执行任务 | 使用Vue.nextTick
方法 | Thirteen |
| 接收单个数据属性的通知 | 使用观察器 | Fourteen |
| 当组件被销毁时收到通知 | 实施beforeDestroy
或destroyed
方法 | 15, 16 |
| 出现错误时收到通知 | 实现errorCaptured
方法 | 17, 18 |
为本章做准备
对于本章中的例子,在一个方便的位置运行清单 17-1 中所示的命令来创建一个新的 Vue.js 项目。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
vue create lifecycles --default
Listing 17-1Creating a New Project
这个命令创建了一个名为生命周期的项目。项目创建完成后,将名为vue.config.js
的文件添加到lifecycles
文件夹中,内容如清单 17-2 所示。这个文件用于启用将模板定义为字符串的能力,如第十章所述,我在本章的一个例子中使用了它。
module.exports = {
runtimeCompiler: true
}
Listing 17-2The Contents of the vue.config.js File in the nomagic Folder
将清单 17-3 中所示的语句添加到package.json
文件的 linter 部分,以禁用在使用 JavaScript 控制台时发出警告的规则。本章中的许多例子我都依赖于控制台。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"no-console": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
Listing 17-3Disabling a Linter Rule in the package.json File in the lifecycles Folder
运行lifecycles
文件夹中清单 17-4 所示的命令,将引导 CSS 包添加到项目中。
npm install bootstrap@4.0.0
Listing 17-4Adding the Bootstrap CSS Package
将清单 17-5 中所示的语句添加到src
文件夹中的main.js
文件中,将引导 CSS 文件合并到应用中。
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 17-5Incorporating the Bootstrap Package in the main.js File in the src Folder
最后的准备步骤是替换根组件的内容,如清单 17-6 所示。
<template>
<div class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
</div>
</template>
<script>
export default {
name: 'App',
data: function () {
return {
checked: true
}
}
}
</script>
Listing 17-6Replacing the Contents of the App.vue File in the src Folder
运行productapp
文件夹中清单 17-7 所示的命令,启动开发工具。
npm run serve
Listing 17-7Starting the Development Tools
将执行初始绑定过程,之后您将看到一条消息,告诉您项目已成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口,导航到http://localhost:8080
查看项目的占位符内容,如图 17-1 所示。
图 17-1
运行示例应用
了解组件生命周期
组件生命周期从 Vue.js 第一次初始化组件时开始。生命周期包括准备数据属性、处理模板、处理数据更改,以及最终销毁不再需要的组件。对于生命周期中的每个阶段,Vue.js 都提供了方法,如果由组件实现,它将调用这些方法。在表 17-3 中,我描述了每一种组件生命周期方法。
表 17-3
组件生命周期方法
|名字
|
描述
|
| --- | --- |
| beforeCreate
| 这个方法在 Vue.js 初始化组件之前被调用,如“理解创建阶段”一节所述。 |
| created
| 这个方法在 Vue.js 初始化一个组件后被调用,如“理解创建阶段”一节所述。 |
| beforeMount
| 这个方法在 Vue.js 处理组件的模板之前被调用,如“理解安装阶段”一节所述。 |
| mounted
| 这个方法在 Vue.js 处理组件的模板之后被调用,如“理解安装阶段”一节所述。 |
| beforeUpdate
| 这个方法在 Vue.js 处理组件数据更新之前调用,如“理解更新阶段”一节所述。 |
| updated
| 这个方法在 Vue.js 处理组件数据更新后调用,如“理解更新阶段”一节所述。 |
| activated
| 如第二十二章所述,当一个用keep-alive
元素保持活动的组件被激活时,该方法被调用。 |
| deactivated
| 如第二十二章所述,当通过keep-alive
元素保持活动的组件被停用时,该方法被调用。 |
| beforeDestroy
| 这个方法在 Vue.js 销毁组件之前被调用,如“理解销毁阶段”一节所述。 |
| destroyed
| 这个方法在 Vue.js 销毁一个组件后被调用,如“理解销毁阶段”一节所述。 |
| errorCaptured
| 这个方法允许组件处理由它的一个子组件抛出的错误,如“处理组件错误”一节所述。 |
了解创建阶段
这是生命周期的初始阶段,Vue.js 创建一个组件的新实例并准备使用,包括处理script
元素中的属性,比如data
和computed
属性。在创建组件之后——但在处理其配置对象之前——vue . js 调用beforeCreate
方法。一旦 Vue.js 处理了配置属性,包括数据属性,就会调用created
方法。在清单 17-8 中,我将这个阶段的两种方法都添加到了组件中。
<template>
<div class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
</div>
</template>
<script>
export default {
name: 'App',
data: function () {
return {
checked: true
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
}
}
</script>
Listing 17-8Adding Creation Lifecycle Methods in the App.vue File to the src Folder
在清单中,beforeCreate
和created
方法都向浏览器的 JavaScript 控制台写入一条消息,其中包含了checked
属性的值。
注意
生命周期方法直接在对象的script
元素中定义,而不是在methods
属性下定义。
在beforeCreate
和created
方法之间,Vue.js 设置了使组件有用的特性,包括方法和数据属性,你可以看到浏览器的 JavaScript 控制台中显示的消息。
...
beforeCreate method called undefined
created method called true
...
当调用beforeCreate
方法时,Vue.js 还没有设置组件的data
属性,因此checked
属性的值是undefined
。在调用created
方法时,设置已经完成,并且checked
属性已经创建并被赋予了初始值。
了解组件对象的创建
beforeCreate
方法显示的消息显示 Vue.js 已经创建了组件,并在调用方法之前将其分配给了this
。分配给this
的对象是组件,如果一个应用中使用一个组件的多个实例,那么每个实例都有一个对象。当 Vue.js 经历初始化过程时,data
属性、computed
属性和方法被分配给这个对象。例如,当您定义一个方法时,您使用用于创建组件的配置对象上的methods
属性;在初始化期间,Vue.js 将您定义的函数分配给组件对象,这样您就可以作为this.myMethod()
调用它,而无需担心配置对象的结构。当 Vue.js 第一次创建组件对象时,它几乎没有什么有用的特性,大多数项目都不需要实现beforeCreate
事件。
了解反应性准备
Vue.js 的一个关键特性是反应性,其中对数据属性的更改会自动传播到整个应用,触发对计算属性、数据绑定和属性的更新,以确保一切都是最新的。
在创建阶段,Vue.js 从组件的script
元素处理组件的配置对象。一个属性被添加到每个data
属性的组件对象中,带有一个 getter 和 setter,这样 Vue.js 可以检测到属性何时被读取或修改。这意味着 Vue.js 能够检测属性何时被使用,并更新应用受影响的部分——但这也意味着在 Vue.js 执行创建阶段之前,您必须确保您已经定义了所有您需要的data
属性。
小费
如果一个应用需要外部数据,比如来自 RESTful API 的数据,那么created
方法提供了一个请求数据的好机会,如第二十章所示。
了解安装阶段
在组件生命周期的第二阶段,Vue.js 处理组件的模板,处理数据绑定、指令和其他应用特性。
访问文档对象模型
如果一个组件需要访问文档对象模型(DOM ),那么mounted
事件表明组件的内容已经被处理,可以通过一个名为$el
的属性访问,这个属性是 Vue.js 在组件对象上定义的。
在清单 17-9 中,我访问了 DOM 来获取数据值,这些数据值是通过应用组件的 HTML 元素上的属性提供的。正如我在第十六章中解释的,任何应用于 HTML 元素的属性都会被转移到组件模板的顶层元素中。
警告
只有当没有更适合 Vue.js 模型的替代方法时,才应该直接访问 DOM。在这个例子中,数据是通过属性提供的,当把 Vue.js 集成到一个以编程方式生成 HTML 文档的环境中时,这个例子会很有用。在更好的方法实现之前,这些方法应该作为临时措施,比如使用 RESTful API,如第二十章所述。
<template>
<div class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
<div class="bg-info p-2">
Names:
<ul>
<li v-for="name in names" v-bind:key="name">
{{ name }}
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: 'App',
data: function () {
return {
checked: true,
names: []
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
},
mounted() {
this.$el.dataset.names.split(",")
.forEach(name => this.names.push(name));
}
}
</script>
Listing 17-9Accessing the DOM in the App.vue File in the src Folder
在处理完模板并将其内容添加到 DOM 之后,将调用mounted
方法,此时添加到应用该组件的定制 HTML 元素的属性将已经从清单 17-9 传输到顶层div
元素。在mounted
方法中,我使用this.$el
属性来访问一组data-
属性,以读取data-names
属性的值,创建一个数组,并将每一项推入names
数据属性,其值使用v-for
指令显示在一个列表中。
小费
注意,在清单 17-9 中,我已经定义了names
属性并给它分配了一个空数组。在对所有data
属性进行处理以设置反应性之前,对其进行定义是很重要的;否则,将不会检测到更改。
在清单 17-10 中,我为应用组件的 HTML 元素添加了一个data-name
属性。
import Vue from 'vue'
import App from './App'
import "bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
new Vue({
el: '#app',
components: { App },
template: '<App data-names="Bob, Alice, Peter, Dora" />'
})
Listing 17-10Adding an Attribute in the main.js File in the src Folder
结果是组件使用浏览器提供的 DOM API 来读取应用于其元素的属性,并使用其内容来设置数据属性,如图 17-2 所示。
图 17-2
在挂载生命周期阶段访问 DOM
了解更新阶段
一旦组件被初始化并且它的内容被装载,它将进入更新阶段,在这个阶段 Vue.js 对数据属性的更改做出反应。当检测到一个变化时,Vue.js 将调用一个组件的beforeUpdate
方法,并且一旦更新已经被执行并且 HTML 内容已经被更新以反映该变化,就调用该组件的updated
方法。
大多数项目不需要使用beforeUpdate
和updated
方法,提供这两个方法是为了让组件可以在更改前后直接访问 DOM。与上一节中的直接 DOM 访问一样,这不是一件轻而易举的事情,应该只在没有标准 Vue.js 特性适用时才执行。为了完整起见,在清单 17-11 中,我为这个阶段实现了两种方法。
...
<script>
export default {
name: 'App',
data: function () {
return {
checked: true,
names: []
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
},
mounted() {
this.$el.dataset.names.split(",")
.forEach(name => this.names.push(name));
},
beforeUpdate() {
console.log(`beforeUpdate called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
updated() {
console.log(`updated called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
}
}
</script>
...
Listing 17-11Implementing the Update Phase Methods in the App.vue File in the src Folder
beforeUpdate
和updated
方法向浏览器的 JavaScript 控制台写出一条消息,其中包括checked
属性的值,包括names
数组中的第一个值和组件的 HTML 内容中的li
元素的数量,我通过 DOM 访问这些内容。当您保存对组件的更改时,您将在浏览器的 JavaScript 控制台中看到以下消息:
...
beforeCreate method called undefined
created method called true
beforeUpdate called. Checked: true Name: Bob List Elements: 0
updated called. Checked: true Name: Bob List Elements: 4
...
这个消息序列揭示了 Vue.js 为组件所经历的初始化过程。在created
方法中,我将值添加到组件的一个data
属性中,这导致呈现给用户的 HTML 内容发生相应的变化。当调用beforeUpdate
方法时,组件的内容中没有li
元素,当调用updated
方法并且 Vue.js 已经完成处理更新时,有四个li
元素。
了解更新整合
注意只有一次对beforeUpdate
和updated
方法的调用,尽管我向names
数组中添加了四项。Vue.js 不响应单独的数据更改,这将导致一系列单独的 DOM 更新,执行起来代价很高。相反,Vue.js 维护一个等待更新的队列,删除重复的更新,并允许一起批量更改。为了提供更清晰的演示,我向组件添加了一个button
元素,它导致两个data
属性都发生了变化,如清单 17-12 所示。
<template>
<div class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
<div class="bg-info p-2">
Names:
<ul>
<li v-for="name in names" v-bind:key="name">
{{ name }}
</li>
</ul>
</div>
<div class="text-white center my-2">
<button class="btn btn-light" v-on:click="doChange">
Change
</button>
</div>
</div>
</template>
<script>
export default {
name: 'App',
data: function () {
return {
checked: true,
names: []
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
},
mounted() {
this.$el.dataset.names.split(",")
.forEach(name => this.names.push(name));
},
beforeUpdate() {
console.log(`beforeUpdate called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
updated() {
console.log(`updated called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
methods: {
doChange() {
this.checked = !this.checked;
this.names.reverse();
}
}
}
</script>
Listing 17-12Making Multiple Changes in the App.vue File in the src Folder
点击按钮调用doChange
方法,该方法切换checked
属性的值并反转names
数组中的项目顺序,如图 17-3 所示。
图 17-3
进行多项更改
尽管对两个不同的数据属性进行了更改,但浏览器的 JavaScript 控制台中显示的消息显示,只对组件的 HTML 内容执行了一次更新。
...
beforeUpdate called. Checked: false Name: Dora List Elements: 4
updated called. Checked: false Name: Dora List Elements: 4
...
小费
只有在需要更改一个或多个 HTML 元素时,才会调用beforeUpdate
和updated
方法。如果您对数据属性所做的更改不需要对组件的 HTML 内容进行相应的更改,那么这些方法将不会被调用。
使用更新后回调
Vue.js 合并和推迟更新的方式的一个后果是,您不能进行数据更改,然后直接使用 DOM,并期望更改的效果已经应用到组件的 HTML 元素。Vue.js 提供了Vue.nextTick
方法,该方法可用于在应用了所有挂起的更改之后执行任务。在清单 17-13 中,我在doChange
方法中使用了nextTick
来说明动作和方法调用的顺序。
...
<script>
import Vue from "vue";
export default {
name: 'App',
data: function () {
return {
checked: true,
names: []
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
},
mounted() {
this.$el.dataset.names.split(",")
.forEach(name => this.names.push(name));
},
beforeUpdate() {
console.log(`beforeUpdate called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
updated() {
console.log(`updated called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
methods: {
doChange() {
this.checked = !this.checked;
this.names.reverse();
Vue.nextTick(() => console.log("Callback Invoked"));
}
}
}
</script>
...
Listing 17-13Using a Post-Update Callback in the App.vue File in the src Folder
nextTick
方法接受一个函数和一个可选的上下文对象作为其参数,该函数将在下一个更新周期结束时被调用。保存更改,单击按钮,您将在浏览器的 JavaScript 控制台中看到以下消息序列:
...
beforeUpdate called. Checked: false Name: Dora List Elements: 4
updated called. Checked: false Name: Dora List Elements: 4
Callback Invoked
...
导致更新的更改是由doChange
方法触发的,但是使用nextTick
方法可以确保在处理完更改的影响和更新 DOM 之前不会调用回调。
使用观察器观察数据变化
beforeUpdate
和update
方法告诉你组件的 HTML 元素何时更新,但不告诉你做了什么改变,如果改变是对一个没有在指令表达式或数据绑定中引用的data
属性,这些方法根本不会被调用。
如果您希望在数据属性更改时收到通知,那么您可以使用观察器。在清单 17-14 中,我添加了一个观察器,它将在名为checked
的data
属性被更改时提供通知。
...
<script>
import Vue from "vue";
export default {
name: 'App',
data: function () {
return {
checked: true,
names: []
}
},
beforeCreate() {
console.log("beforeCreate method called" + this.checked);
},
created() {
console.log("created method called" + this.checked);
},
mounted() {
this.$el.dataset.names.split(",")
.forEach(name => this.names.push(name));
},
beforeUpdate() {
console.log(`beforeUpdate called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
updated() {
console.log(`updated called. Checked: ${this.checked}`
+ ` Name: ${this.names[0]} List Elements: `
+ this.$el.getElementsByTagName("li").length);
},
methods: {
doChange() {
this.checked = !this.checked;
this.names.reverse();
Vue.nextTick(() => console.log("Callback Invoked"));
}
},
watch: {
checked: function (newValue, oldValue) {
console.log(`Checked Watch, Old: ${oldValue}, New: ${newValue}`);
}
}
}
</script>
...
Listing 17-14Using a Watcher in the App.vue File in the src Folder
使用组件的配置对象上的watch
属性来定义观察器,该配置对象被分配了一个对象,该对象包含要观察的每个属性的属性。观察器需要属性的名称和一个处理函数,该函数将用新值和以前的值调用。在清单中,我为checked
属性定义了一个观察器,它将新值和旧值写出到浏览器的 JavaScript 控制台。保存更改并取消选中复选框,您将看到以下消息序列,包括来自观察器的消息:
...
Checked Watch, Old: true, New: false
beforeUpdate called. Checked: false Name: Bob List Elements: 4
updated called. Checked: false Name: Bob List Elements: 4
...
注意观察器的处理函数在beforeUpdate
和updated
方法之前被调用。
使用观察选项
有两个选项可以用来改变观察器的行为,尽管它们要求观察器以不同的方式表达。第一个选项是immediate
,它告诉 Vue.js 在创建阶段,在调用beforeCreate
和created
方法之间,一旦设置了观察器,就调用处理程序。要使用该特性,观察器被表示为一个具有handler
属性和立即属性的对象,如下所示:
...
watch: {
checked: {
handler: function (newValue, oldValue) {
// respond to changes here
},
immediate: true
}
}
...
另一个选项是deep
,它将监视由分配给一个数据属性的对象定义的所有属性,这比必须为每个属性设置单独的监视器更方便。deep
选项的应用如下:
...
watch: {
myObject: {
handler: function (newValue, oldValue) {
// respond to changes here
},
deep: true
}
}
...
deep
选项不能用于创建所有数据属性的观察器,只能用于已分配对象的单个属性。当你为一个观察器使用一个函数时,你不能使用一个箭头表达式,因为this
不会被赋值给值已经改变的组件。相反,使用传统的function
关键字,如图所示。
了解销毁阶段
组件生命周期的最后阶段是它的销毁,这通常发生在组件被动态显示并且不再需要时,如第二十二章所示。Vue.js 将调用beforeDestroy
方法让组件有机会准备自己,并将在销毁过程之后调用destroy
方法,这将删除观察器、事件处理程序和任何子组件。
为了帮助演示生命周期的这一部分,我在src/components
文件夹中添加了一个名为MessageDisplay.vue
的文件,其内容如清单 17-15 所示。
<template>
<div class="bg-dark text-light text-center p-2">
<div>
Counter Value: {{ counter }}
</div>
<button class="btn btn-secondary" v-on:click="handleClick">
Increment
</button>
</div>
</template>
<script>
export default {
data: function () {
return {
counter: 0
}
},
created: function() {
console.log("MessageDisplay: created");
},
beforeDestroy: function() {
console.log("MessageDisplay: beforeDestroy");
},
destroyed: function() {
console.log("MessageDisplay: destroyed");
},
methods: {
handleClick() {
this.counter++;
}
}
}
</script>
Listing 17-15The Contents of the MessageDisplay.vue File in the src/components Folder
该组件显示一个计数器,可以通过单击按钮来递增。它还实现了created
、beforeDestroy
和destroyed
方法,这样就可以在写入浏览器 JavaScript 控制台的消息中看到组件生命周期的开始和结束。在清单 17-16 中,我已经简化了示例应用的根组件,并应用了新的组件,以便它仅在名为 checked 的data
值为true
时才显示。
<template>
<div class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
<div class="bg-info p-2" v-if="checked">
<message-display></message-display>
</div>
</div>
</template>
<script>
import MessageDisplay from "./components/MessageDisplay"
export default {
name: 'App',
components: { MessageDisplay },
data: function () {
return {
checked: true,
names: []
}
}
}
</script>
Listing 17-16Applying a Child Component in the App.vue File in the src Folder
当复选框被切换时,Vue.js 创建并销毁MessageDisplay
组件,如图 17-4 所示。
图 17-4
创建和销毁组件
警告
你不应该依赖销毁阶段来执行重要的任务,因为 Vue.js 并不总是能够完成生命周期。例如,如果用户导航到另一个 URL,整个应用将被终止,没有机会完成组件的生命周期。
当您取消选中该复选框时,子组件将被销毁,其内容将从 DOM 中删除。选中该复选框后,将创建一个新组件,生命周期将重新开始。如果您检查浏览器的 JavaScript 控制台,当一个组件被销毁而另一个组件被创建时,您会看到如下消息:
...
MessageDisplay: beforeDestroy
MessageDisplay: destroyed
MessageDisplay: created
...
如果您在切换复选框之前单击增量按钮,您将能够看到组件的状态在被销毁时丢失。参见第二十一章,了解如何为一个应用创建一个共享状态的详细信息,该共享状态将在单个组件生命周期变化后继续存在。
处理组件错误
有一个阶段在标准生命周期之外,它发生在子组件中出现错误的时候。为了演示这个阶段,我向MessageDisplay
组件添加了清单 17-17 中所示的元素和代码,以便在单击按钮时生成一个错误。
<template>
<div class="bg-dark text-light text-center p-2">
<div>
Counter Value: {{ counter }}
</div>
<button class="btn btn-secondary" v-on:click="handleClick">
Increment
</button>
<button class="btn btn-secondary" v-on:click="generateError">
Generate Error
</button>
</div>
</template>
<script>
export default {
data: function () {
return {
counter_base: 0,
generate_error: false
}
},
created: function() {
console.log("MessageDisplay: created");
},
beforeDestroy: function() {
console.log("MessageDisplay: beforeDestroy");
},
destroyed: function() {
console.log("MessageDisplay: destroyed");
},
methods: {
handleClick() {
this.counter_base++;
},
generateError() {
this.generate_error = true;
}
},
computed: {
counter() {
if (this.generate_error) {
throw "My Component Error";
} else {
return this.counter_base;
}
}
}
}
</script>
Listing 17-17Generating an Error in the MessageDisplay.vue File in the src/components Folder
组件错误处理支持可以处理在修改组件数据、执行监视功能或调用某个生命周期方法时出现的错误。当处理一个事件出错时,它不起作用,这就是为什么清单 17-17 中的代码通过设置一个data
属性来响应按钮点击,这个属性使用throw
关键字导致counter
计算属性生成一个错误。
为了处理来自其子组件的错误,组件实现了errorCaptured
方法,该方法定义了三个参数:错误、抛出错误的组件和描述错误发生时 Vue.js 正在做什么的信息字符串。如果errorCaptured
方法返回false
,错误将不会在组件层次结构中进一步传播,这是阻止多个组件响应同一个错误的有效方法。在清单 17-18 中,我在应用的根组件中实现了errorCaptured
方法,并在它的模板中添加了元素来显示它收到的错误的细节。
<template>
<div class="bg-danger text-white text-center h3 p-2" v-if="error.occurred">
An Error Has Occurred
<h4>
Error : "{{ error.error }}" ({{ error.source }})
</h4>
</div>
<div v-else class="bg-primary text-white m-2 p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="checked" />
<label>Checkbox</label>
</div>
Checked Value: {{ checked }}
<div class="bg-info p-2" v-if="checked">
<message-display></message-display>
</div>
</div>
</template>
<script>
import MessageDisplay from "./components/MessageDisplay"
export default {
name: 'App',
components: { MessageDisplay },
data: function () {
return {
checked: true,
names: [],
error: {
occurred: false,
error: "",
source: ""
}
}
},
errorCaptured(error, component, source) {
this.error.occurred = true;
this.error.error = error;
this.error.source = source;
return false;
}
}
</script>
Listing 17-18Handling an Error in the App.vue File in the src Folder
errorCaptured
方法通过设置名为error
的data
对象的属性来响应错误,该属性通过v-if
和v-else
指令显示给用户。当您保存更改并点击生成错误按钮时,您将看到如图 17-5 所示的错误信息。
图 17-5
处理错误
定义全局错误处理程序
Vue.js 还提供了一个全局错误处理程序,用来处理应用中组件没有处理的错误。全局处理程序是一个函数,它接收与errorCaptured
方法相同的参数,但是定义在main.js
文件中应用的顶层Vue
对象上,而不是在它的一个组件中。
当应用的其他部分没有处理问题时,全局错误处理程序允许您接收通知,这通常意味着您将无法做任何特别有用的事情来从这种情况中恢复。但是如果您需要在错误还没有被处理的时候执行一个任务,那么您可以在main.js
文件中定义一个处理程序,如下所示:
...
import Vue from 'vue'
import App from './App'
import "bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
Vue.config.errorHandler = function (error, component, source) {
console.log(`Global Error Handler: ${error}, ${component}, ${source}`);
}
new Vue({
el: '#app',
components: { App },
template: `<div><App data-names="Bob, Alice, Peter, Dora" /></div>`
})
...
这个处理程序向浏览器的 JavaScript 控制台写入一条消息,这类似于默认行为。有一些错误跟踪服务,允许开发人员通过将错误推送到中央服务器供以后分析来核对和检查错误,其中一些使用全局错误处理程序为 Vue.js 应用提供现成的支持,尽管如果您有大量用户,使用它们可能会变得昂贵。
摘要
在这一章中,我描述了 Vue.js 为其组件提供的生命周期,以及允许您在每次停止时接收通知的方法。我解释了如何处理数据值的更改,如何在 DOM 中访问组件的 HTML 内容,如何使用观察器观察数据值,以及如何处理错误。在下一章,我将解释如何使用松散耦合的组件向 Vue.js 应用添加结构。
十八、松散耦合的组件
随着 Vue.js 应用的增长,在父组件和子组件之间传递数据和事件的需求变得更加难以安排,尤其是当应用不同部分的组件需要通信时。结果可能是组件自身的功能被为它们的后代传递属性和为它们的前身传递事件的需求所超越。在这一章中,我描述了另一种方法,称为依赖注入,它允许组件在不紧密耦合的情况下进行通信。我将向您展示依赖注入的不同使用方式,并演示它如何释放应用的结构。我还将向您展示如何使用事件总线,它将依赖注入与以编程方式注册自定义事件的能力相结合,允许组件将自定义事件发送给任何感兴趣的接收者,而不仅仅是其父对象。表 18-1 将依赖注入和事件总线放在上下文中。
表 18-1
将依赖注入和事件总线放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 依赖注入允许任何组件向其任何后代提供服务,其中服务可以是值、对象或函数。事件总线建立在依赖注入特性的基础上,为发送和接收定制事件提供了一种通用机制。 |
| 它们为什么有用? | 这些功能允许应用的结构变得更加复杂,而不会陷入管理父子关系功能的困境,这些功能仅用于将数据传递给遥远的后代和祖先。 |
| 它们是如何使用的? | 组件使用 provide 属性定义服务,并使用 inject 属性声明对服务的依赖。事件总线是分发 Vue 对象的服务,该对象用于使用\(emit 和\)on 方法发送和接收事件。 |
| 有什么陷阱或限制吗? | 必须注意确保在整个应用中使用一致的服务和事件名称。如果不小心,不同的组件最终会重用一个对应用的另一部分已经有意义的名称。 |
| 有其他选择吗? | 简单的应用不需要本章描述的特性,可以依赖于道具和标准的自定义事件。复杂的应用可以从共享应用状态中受益,这是一种补充方法,在第二十章中有描述。 |
表 18-2 总结了本章内容。
表 18-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 定义可由组件后代使用的功能 | 使用依赖注入特性来定义服务 | 10–12 |
| 提供响应变化的功能 | 通过定义数据属性并将其用作数据值的来源来创建反应式服务 | 13–14 |
| 定义不提供服务时将使用的功能 | 使用具有注入属性的对象指定回退 | 15–16 |
| 在父子关系之外分发自定义事件 | 使用事件总线 | Seventeen |
| 使用事件总线发送事件 | 调用事件总线\(emit 方法 | Eighteen |
| 从事件总线接收事件 | 调用事件总线\)on 方法 | 19, 20 |
| 仅将事件分发到应用的一部分 | 创建本地事件总线 | 21, 22 |
为本章做准备
对于本章中的例子,在一个方便的位置运行清单 18-1 中所示的命令来创建一个新的 Vue.js 项目。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
vue create productapp --default
Listing 18-1Creating a New Project
这个命令创建了一个名为 productapp 的项目。一旦设置过程完成,将清单 18-2 中所示的语句添加到package.json
文件的 linter 部分,以禁用在使用 JavaScript 控制台时以及在定义了变量但未使用时发出警告的规则。本章中的许多例子我都依赖于控制台,并定义了占位符变量,这些变量只在引入后面的特性时使用。
...
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"no-console": "off",
"no-unused-vars": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
...
Listing 18-2Disabling a Linter Rule in the package.json File in the lifecycles Folder
接下来,运行productapp
文件夹中清单 18-3 所示的命令,将引导 CSS 包添加到项目中。
npm install bootstrap@4.0.0
Listing 18-3Adding the Bootstrap CSS Package
将清单 18-4 中所示的语句添加到src
文件夹中的main.js
文件中,将引导 CSS 文件合并到应用中。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
new Vue({
render: h => h(App)
}).$mount('#app')
Listing 18-4Incorporating the Bootstrap Package in the main.js File in the src Folder
运行productapp
文件夹中清单 18-5 所示的命令,启动开发工具。
npm run serve
Listing 18-5Starting the Development Tools
将执行初始绑定过程,之后您将看到一条消息,告诉您项目已成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口,导航到http://localhost:8080
查看项目的占位符内容,如图 18-1 所示。
图 18-1
运行示例应用
创建产品展示组件
示例应用的核心将是一个组件,它显示一个包含产品对象详细信息的表,并带有用于编辑或删除对象的按钮。我在src/components
文件夹中添加了一个名为ProductDisplay.vue
的文件,内容如清单 18-6 所示。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.price | currency }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew">
Create New
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
products: [
{ id: 1, name: "Kayak", price: 275 },
{ id: 2, name: "Lifejacket", price: 48.95 },
{ id: 3, name: "Soccer Ball", price: 19.50 },
{ id: 4, name: "Corner Flags", price: 39.95 },
{ id: 5, name: "Stadium", price: 79500 }]
}
},
filters: {
currency(value) {
return `$${value.toFixed(2)}`;
}
},
methods: {
createNew() {
},
editProduct(product) {
}
}
}
</script>
Listing 18-6The Contents of the ProductDisplay.vue File in the src/components Folder
该组件定义了一个名为products
的data
属性,该属性被分配了一个对象数组,这些对象是使用v-for
指令在模板中枚举的。每个products
对象在一个表中生成一行,该表包含用于id
、name
和price
值的列,以及一个用于编辑对象的button
元素。在表格下面还有另一个按钮,用户将单击它来创建一个新产品,并且已经将v-on
指令应用到了两个button
元素,以便在单击按钮时调用createNew
和editProduct
方法。这些方法目前都是空的。
创建产品编辑器组件
我需要一个编辑器,允许用户编辑现有的对象和创建新的。我首先将一个名为EditorField.vue
的文件添加到src/components
文件夹中,并添加清单 18-7 中所示的内容。
<template>
<div class="form-group">
<label>{{label}}</label>
<input v-model.number="value" class="form-control" />
</div>
</template>
<script>
export default {
props: ["label"],
data: function () {
return {
value: ""
}
}
}
</script>
Listing 18-7The Contents of the EditorField.vue File in the src/components Folder
该组件显示一个label
元素和一个input
元素。为了将编辑器组合成一个更大的功能单元,我在src/components
文件夹中添加了一个名为ProductEditor.vue
的文件,并添加了清单 18-8 中所示的内容。
<template>
<div>
<editor-field label="ID" />
<editor-field label="Name" />
<editor-field label="Price" />
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<button class="btn btn-secondary" v-on:click="cancel">Cancel</button>
</div>
</div>
</template>
<script>
import EditorField from "./EditorField";
export default {
data: function () {
return {
editing: false,
product: {
id: 0,
name: "",
price: 0
}
}
},
components: { EditorField },
methods: {
startEdit(product) {
this.editing = true;
this.product = {
id: product.id,
name: product.name,
price: product.price
}
},
startCreate() {
this.editing = false;
this.product = {
id: 0,
name: "",
price: 0
};
},
save() {
// TODO - process edited or created product
console.log(`Edit Complete: ${JSON.stringify(this.product)}`);
this.startCreate();
},
cancel() {
this.product = {};
this.editing = false;
}
}
}
</script>
Listing 18-8The Contents of the ProductEditor.vue File in the src/components Folder
编辑器提供了可用于编辑或创建对象的编辑器组件集合。有用于id
、name
和price
值的字段,以及使用v-on
指令完成或取消操作的button
元素。
显示子组件
为了完成本章的准备工作,我编辑了根组件以显示在前面章节中创建的组件,如清单 18-9 所示。
<template>
<div class="container-fluid">
<div class="row">
<div class="col-8 m-3">
<product-display></product-display>
</div>
<div class="col m-3">
<product-editor></product-editor>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor }
}
</script>
Listing 18-9Displaying Components in the App.vue File in the src Folder
该组件并排显示其子组件,如图 18-2 所示。单击按钮元素没有实际效果,因为组件还没有连接在一起工作。
图 18-2
向示例应用添加功能和组件
理解依赖注入
依赖注入允许组件定义一个服务,它可以是任何值、函数或对象,并使它对它的任何后代可用。通过依赖注入提供的服务不仅限于孩子,并且避免了通过一连串的道具将数据分发到需要它的应用部分的需要。
定义服务
在清单 18-10 中,我向根组件添加了一个服务,它提供了应该用于背景和文本的引导 CSS 类的细节。该服务将对应用中的所有组件可用,因为它们都是根组件的后代。
<template>
<div class="container-fluid">
<div class="row">
<div class="col-8 m-3">
<product-display></product-display>
</div>
<div class="col m-3">
<product-editor></product-editor>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor },
provide: function() {
return {
colors: {
bg: "bg-secondary",
text: "text-white"
}
}
}
}
</script>
Listing 18-10Defining a Service in the App.vue File in the src Folder
provide
属性遵循与data
属性相同的模式,并返回一个函数,该函数产生一个对象,该对象的属性是后代组件可用的服务的名称。在这个例子中,我定义了一个名为colors
的服务,它的bg
和text
属性提供了与引导 CSS 样式相关的类名。
通过依赖注入消费服务
当一个组件想要使用其前身提供的服务时,它使用 inject 属性,如清单 18-11 所示,其中我已经配置了EditorField
组件,因此它使用清单 18-10 中定义的colors
服务。
<template>
<div class="form-group">
<label>{{label}}</label>
<input v-model.number="value" class="form-control"
v-bind:class="[colors.bg, colors.text]" />
</div>
</template>
<script>
export default {
props: ["label"],
data: function () {
return {
value: ""
}
},
inject: ["colors"]
}
</script>
Listing 18-11Consuming a Service in the EditorField.vue File in the src/components Folder
属性被赋予一个数组,该数组包含组件所需的每个服务名称的字符串值。当组件被初始化时,Vue.js 通过组件的前件向上工作,直到找到具有指定名称的服务,获取服务值,并将其分配给组件对象上具有服务名称的属性。在这个例子中,Vue.js 沿着组件向上寻找名为colors
的服务,它在根组件上找到了这个服务,并使用根组件提供的对象作为EditorField
组件上名为colors
的组件的值。创建对应于服务的属性允许我在如下指令表达式中使用它的属性:
...
<input v-model.number="value" class="form-control"
v-bind:class="[colors.bg, colors.text]" />
...
结果是根组件能够提供应该用于样式元素的类名,而不必通过组件链传递它们。这些类名被应用于EditorField
组件的input
元素,产生如图 18-3 所示的结果。(您必须在input
元素中输入一些文本才能看到文本颜色。)
小费
您可能需要重新加载浏览器才能看到本示例的效果。
图 18-3
使用依赖注入
覆盖先行服务
当 Vue.js 创建一个带有inject
属性的组件时,它解析所需服务的依赖关系,沿着组件链向上工作,并检查每个组件以查看是否有带有与所需名称匹配的服务的provide
属性。这种方法意味着一个组件可以覆盖它的前身提供的一个或多个服务,这对于创建只应用于应用的一部分的更专门化的服务是一种有用的方法。为了演示,我向定义了colors
服务的ProductEditor
组件添加了一个provide
属性,如清单 18-12 所示。
...
<script>
import EditorField from "./EditorField";
export default {
data: function () {
return {
editing: false,
product: {
id: 0,
name: "",
price: 0
}
}
},
components: { EditorField },
methods: {
// ...methods omitted for brevity...
},
provide: function () {
return {
colors: {
bg: "bg-light",
text: "text-danger"
}
}
}
}
</script>
...
Listing 18-12Defining a Service in the ProductEditor.vue File in the src/components Folder
当 Vue.js 创建一个EditorField
组件并解析其inject
属性指定的服务时,它将到达ProductEditor
组件定义的服务并停止,这意味着colors
服务将使用清单 18-12 中使用的类名进行解析,如图 18-4 所示。
小费
每个服务都是独立解析的,这意味着一个组件可以选择只覆盖其前身提供的一些服务。
图 18-4
覆盖服务的效果
理解服务的匿名性
依赖注入使得创建松散耦合的应用成为可能,因为服务是匿名提供和消费的。当一个组件定义一个服务时,它不知道它的哪个后代将会使用它,它是否会被另一个组件覆盖,甚至不知道它是否会被使用。
同样,当一个组件使用一个服务时,它不知道它的哪个前身提供了这个服务。这种方法允许需要数据或功能的组件从能够提供数据或功能的组件接收数据或功能,而不需要定义严格的关系或担心通过一系列父子关系传递数据或功能。
创建反应式服务
默认情况下,Vue.js 服务不是被动的,这意味着对由color
服务提供的对象属性的任何更改都不会传播到应用的其余部分。但是,由于 Vue.js 在每次创建组件时都会解析服务的依赖关系,因此在更改后创建的组件将接收新值。
如果您想要创建一个传播变更的服务,那么您必须将一个对象分配给一个data
属性,并使用它作为服务的值,如清单 18-13 所示。
<template>
<div class="container-fluid">
<div class="text-right m-2">
<button class="btn btn-primary" v-on:click="toggleColors">
Toggle Colors
</button>
</div>
<div class="row">
<div class="col-8 m-3">
<product-display></product-display>
</div>
<div class="col m-3">
<product-editor></product-editor>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor },
data: function () {
return {
reactiveColors: {
bg: "bg-secondary",
text: "text-white"
}
}
},
provide: function() {
return {
colors: this.reactiveColors
}
},
methods: {
toggleColors() {
if (this.reactiveColors.bg == "bg-secondary") {
this.reactiveColors.bg = "bg-light";
this.reactiveColors.text = "text-danger";
} else {
this.reactiveColors.bg = "bg-secondary";
this.reactiveColors.text = "text-white";
}
}
}
}
</script>
Listing 18-13Creating a Reactive Service in the App.vue File in the src Folder
该组件定义了一个名为reactiveColors
的data
属性,该属性被分配了一个具有bg
和text
属性的对象。在组件的创建阶段,Vue.js 在处理provide
属性之前处理data
属性,这意味着reactiveColors
对象被激活,随后被用作colors
服务的值。为了帮助演示一个反应式服务,我还添加了一个button
元素,该元素使用v-on
指令来调用toggleColors
方法,从而改变bg
和text
的值。
为了让这个例子工作,我需要注释掉ProductEditor
组件中的provide
属性,如清单 18-14 所示;否则,其提供的服务将优先于清单 18-13 中的服务。
小费
您可能需要重新加载浏览器才能看到本例中的更改。
...
<script>
import EditorField from "./EditorField";
export default {
data: function () {
return {
editing: false,
product: {
id: 0,
name: "",
price: 0
}
}
},
components: { EditorField },
methods: {
// ...methods omitted for brevity...
},
// provide: function () {
// return {
// colors: {
// bg: "bg-light",
// text: "text-danger"
// }
// }
// }
}
</script>
...
Listing 18-14Removing a Service in the ProductEditor.vue File in the src Folder
结果是,当点击按钮时,服务对象的属性值被改变,并在整个应用中传播,如图 18-5 所示。
警告
任何组件都可以对反应式服务进行更改,而不仅仅是定义它的组件。这可能是一个有用的特性,但也可能导致意外的行为。
图 18-5
创建反应式服务
使用高级依赖注入特性
在使用服务时,有两个有用的高级特性可以使用。第一个特性是提供一个默认值,如果没有前件定义组件需要的服务,将使用这个默认值。第二个特性是能够更改组件识别服务的名称。我已经在清单 18-15 中应用了这两个特性。
<template>
<div class="form-group">
<label>{{ formattedLabel }}</label>
<input v-model.number="value" class="form-control"
v-bind:class="[colors.bg, colors.text]" />
</div>
</template>
<script>
export default {
props: ["label"],
data: function () {
return {
value: "",
formattedLabel: this.format(this.label)
}
},
inject: {
colors: "colors",
format: {
from: "labelFormatter",
default: () => (value) => `Default ${value}`
}
}
}
</script>
Listing 18-15Using Advanced Features in the EditorField.vue File in the src/components Folder
这些特性要求inject
属性的值是一个对象。每个属性的名称是组件内部使用服务的名称。如果不需要高级功能,则属性的值就是所需服务的名称,如下所示:
...
"colors": "colors",
...
这个属性告诉 Vue.js,组件需要一个名为colors
的服务,并希望将其称为colors
,这与前面的例子产生了相同的结果。第二个属性使用了这两个高级特性。
...
inject: {
colors: "colors",
format: {
from: "labelFormatter",
default: () => (value) => `Default ${value}`
}
}
...
from
属性告诉 Vue.js 它应该寻找由组件的前身提供的名为labelFormatter
的服务,但是当组件使用该服务时,该服务将被称为format
。default
属性提供了一个默认值,如果组件的前身都不提供labelFormatter
服务,将使用该默认值。
注意
当您为服务提供默认值时,需要一个工厂函数,这就是为什么清单 18-15 中的default
属性的值被赋予一个函数,该函数返回另一个函数作为其结果。当 Vue.js 创建组件时,它将调用default
函数来获取将被用作服务的对象。
本例中的default
值是一个函数,它在收到的值前加上Default
,这样当使用默认服务时就很明显,产生如图 18-6 左侧所示的结果。
图 18-6
使用服务的默认值
已经使用了服务的默认值,因为组件的前身都没有提供名为labelFormatter
的服务。为了演示一个提供的服务在可用时将被使用,我创建了一个同名的服务,如清单 18-16 所示。
...
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor },
data: function () {
return {
reactiveColors: {
bg: "bg-secondary",
text: "text-white"
}
}
},
provide: function() {
return {
colors: this.reactiveColors,
labelFormatter: (value) => `Enter ${value}:`
}
},
methods: {
toggleColors() {
if (this.reactiveColors.bg == "bg-secondary") {
this.reactiveColors.bg = "bg-light";
this.reactiveColors.text = "text-danger";
} else {
this.reactiveColors.bg = "bg-secondary";
this.reactiveColors.text = "text-white";
}
}
}
}
</script>
...
Listing 18-16Defining a Service in the App.vue File in the src Folder
这个新服务是一个函数,它通过在接收到的值前面加上单词 Enter 来转换这些值,产生如图 18-6 右侧所示的结果。因为这不是服务的默认值,所以我不需要定义工厂函数。
使用事件总线
依赖注入可以与另一个高级 Vue.js 特性相结合,允许组件在父子关系之外发送和接收事件,产生一个被称为事件总线的结果。创建事件总线的第一步是在Vue
对象中定义服务,如清单 18-17 所示。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
provide: function () {
return {
eventBus: new Vue()
}
}
}).$mount('#app')
Listing 18-17Creating an Event Bus Service in the main.js File in the src Folder
服务的值是一个新的Vue
对象。这可能看起来很奇怪,但它产生了一个对象,可用于以编程方式发送和接收自定义 Vue.js 事件,而不依赖于应用的组件层次结构。
使用事件总线发送事件
$emit
方法用于通过事件总线发送事件,遵循我在第十六章中演示的相同的基本方法,当时我向你展示了如何发送事件给一个组件的父组件。在清单 18-18 中,我已经更新了ProductDisplay
组件,这样当用户点击新建或编辑按钮时,它就可以使用事件总线来发送定制事件。
...
<script>
export default {
data: function () {
return {
products: [
{ id: 1, name: "Kayak", price: 275 },
{ id: 2, name: "Lifejacket", price: 48.95 },
{ id: 3, name: "Soccer Ball", price: 19.50 },
{ id: 4, name: "Corner Flags", price: 39.95 },
{ id: 5, name: "Stadium", price: 79500 }]
}
},
filters: {
currency(value) {
return `$${value.toFixed(2)}`;
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
}
},
inject: ["eventBus"]
}
</script>
...
Listing 18-18Using the Event Bus in the ProductDisplay.vue File in the src/components Folder
inject
属性用于声明对eventBus
服务的依赖,该服务的$emit
方法用于发送自定义事件create
和edit
,当用户单击组件提供的button
元素时,这些事件将被调用。
注意
事件总线模型的一个缺点是,您必须确保事件名称在应用中是唯一的,以便不会混淆事件的含义。如果你的应用变得太大,不允许简单地管理事件名,那么你应该考虑我在第二十章描述的共享状态方法。
从事件总线接收事件
允许事件总线模型工作的特性是 Vue.js 支持使用$on
方法以编程方式注册事件,一旦组件被初始化并且其对服务的依赖性被解决,就可以执行该方法。在清单 18-19 中,我使用了ProductEditor
组件中的事件总线来接收前一部分发送的事件。
...
<script>
import EditorField from "./EditorField";
export default {
data: function () {
return {
editing: false,
product: {
id: 0,
name: "",
price: 0
}
}
},
components: { EditorField },
methods: {
startEdit(product) {
this.editing = true;
this.product = {
id: product.id,
name: product.name,
price: product.price
}
},
startCreate() {
this.editing = false;
this.product = {
id: 0,
name: "",
price: 0
};
},
save() {
this.eventBus.$emit("complete", this.product);
this.startCreate();
console.log(`Edit Complete: ${JSON.stringify(this.product)}`);
},
cancel() {
this.product = {};
this.editing = false;
}
},
inject: ["eventBus"],
created() {
this.eventBus.$on("create", this.startCreate);
this.eventBus.$on("edit", this.startEdit);
}
}
</script>
...
Listing 18-19Receiving Events in the ProductEditor.vue File in the src/components Folder
inject
属性用于声明对eventBus
服务的依赖,created
方法用于注册create
和edit
事件的处理程序方法,这些方法用于调用本章开头定义的startCreate
和startEdit
方法。
组件可以通过事件总线发送和接收事件,在清单 18-19 中,我使用了$emit
方法在调用save
方法时发送一个名为complete
的事件。这种双向通信允许我轻松地组合复杂的行为,在清单 18-20 中,我进一步更新了ProductDisplay
组件,以更新显示给用户的数据来响应complete
事件,这表明用户要么已经完成了对现有产品的编辑,要么已经创建了一个新产品。
...
<script>
import Vue from "vue";
export default {
data: function () {
return {
products: [
{ id: 1, name: "Kayak", price: 275 },
{ id: 2, name: "Lifejacket", price: 48.95 },
{ id: 3, name: "Soccer Ball", price: 19.50 },
{ id: 4, name: "Corner Flags", price: 39.95 },
{ id: 5, name: "Stadium", price: 79500 }]
}
},
filters: {
currency(value) {
return `$${value.toFixed(2)}`;
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processComplete(product) {
let index = this.products.findIndex(p => p.id == product.id);
if (index == -1) {
this.products.push(product);
} else {
Vue.set(this.products, index, product);
}
}
},
inject: ["eventBus"],
created() {
this.eventBus.$on("complete", this.processComplete);
}
}
</script>
...
Listing 18-20Receiving Events in the ProductDisplay.vue File in the src/components Folder
created
方法用于监听complete
事件,并在收到事件时调用processComplete
方法。processComplete
方法使用id
属性作为键来更新已编辑的对象或添加新对象。
结果是ProductDisplay
和ProductEditor
组件能够发送和接收事件,允许它们在父子关系之外响应彼此的动作。仍然缺少一些关键功能,但是如果你点击“编辑”或“新建”按钮,你会看到编辑器按钮中的文本会相应地进行调整,如图 18-7 所示。
图 18-7
使用事件总线
创建本地事件总线
您不必为整个应用使用单个事件总线,应用的各个部分可以有自己的总线并使用自定义事件,而不必将它们分发到执行不相关任务的组件。在清单 18-21 中,我在ProductEditor
组件中添加了一个独立的事件总线editingEventBus
,并使用它来发送和接收自定义事件,这些事件将把单个编辑器字段与应用的其余部分连接起来。
<template>
<div>
<editor-field label="ID" editorFor="id" />
<editor-field label="Name" editorFor="name" />
<editor-field label="Price" editorFor="price" />
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<button class="btn btn-secondary" v-on:click="cancel">Cancel</button>
</div>
</div>
</template>
<script>
import EditorField from "./EditorField";
import Vue from "vue";
export default {
data: function () {
return {
editing: false,
product: {
id: 0,
name: "",
price: 0
},
localBus: new Vue()
}
},
components: { EditorField },
methods: {
startEdit(product) {
this.editing = true;
this.product = {
id: product.id,
name: product.name,
price: product.price
}
},
startCreate() {
this.editing = false;
this.product = {
id: 0,
name: "",
price: 0
};
},
save() {
this.eventBus.$emit("complete", this.product);
this.startCreate();
console.log(`Edit Complete: ${JSON.stringify(this.product)}`);
},
cancel() {
this.product = {};
this.editing = false;
}
},
inject: ["eventBus"],
provide: function () {
return {
editingEventBus: this.localBus
}
},
created() {
this.eventBus.$on("create", this.startCreate);
this.eventBus.$on("edit", this.startEdit);
this.localBus.$on("change",
(change) => this.product[change.name] = change.value);
},
watch: {
product(newValue, oldValue) {
this.localBus.$emit("target", newValue);
}
}
}
</script>
Listing 18-21Creating a Local Event Bus in the ProductEditor.vue File in the src/components Folder
在这个清单中有许多变化,但是它们都致力于提供一个专用于编辑产品对象的事件的本地事件总线,结合了前面部分和前面章节的特性。当用户开始编辑或创建一个新对象时,一个target
事件在本地事件总线上被发送,当一个change
事件在该总线上被接收时,产品对象被更新,反映用户所做的改变。负责显示编辑器字段的组件需要进行补充更改,如清单 18-22 所示。
<template>
<div class="form-group">
<label>{{ formattedLabel }}</label>
<input v-model.number="value" class="form-control"
v-bind:class="[colors.bg, colors.text]" />
</div>
</template>
<script>
export default {
props: ["label", "editorFor"],
data: function () {
return {
value: "",
formattedLabel: this.format(this.label)
}
},
inject: {
colors: "colors",
format: {
from: "labelFormatter",
default: () => (value) => `Default ${value}`
},
editingEventBus: "editingEventBus"
},
watch: {
value(newValue) {
this.editingEventBus.$emit("change",
{ name: this.editorFor, value: this.value});
}
},
created() {
this.editingEventBus.$on("target",
(p) => this.value = p[this.editorFor]);
}
}
</script>
Listing 18-22Using a Local Event Bus in the EditorField.vue File in the src/components Folder
结果是点击编辑按钮允许用户编辑现有对象,点击创建新按钮允许用户创建新对象,点击创建/保存按钮应用所做的任何更改,如图 18-8 所示。
图 18-8
使用本地事件总线连接编辑器组件
摘要
在这一章中,我解释了如何使用 Vue.js 依赖注入和事件总线特性来突破父子关系并创建松散耦合的组件。我演示了如何定义和使用服务,如何使服务具有反应性,以及如何创建事件总线来在整个应用中分发自定义事件。在下一章,我将解释如何在 Vue.js 应用中使用 RESTful web 服务。
十九、使用 RESTful Web 服务
在本章中,我将演示如何在 Vue.js 应用中使用 RESTful web 服务。我解释了发出 HTTP 请求的不同方式,并扩展了示例应用,使其能够从服务器读取和存储数据。表 19-1 将 web 服务的使用放在 n 上下文中。
表 19-1
将 RESTful Web 服务放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | RESTful web 服务通过 HTTP 请求向 web 应用提供数据。 |
| 它们为什么有用? | 许多应用需要访问数据,并允许用户从持久性数据存储中创建、修改和删除对象。 |
| 它们是如何使用的? | web 应用向服务器发送一个 HTTP 请求,使用请求类型和 URL 来标识所需的数据和操作。 |
| 有什么陷阱或限制吗? | RESTful web 服务没有标准,这意味着 web 服务的工作方式存在差异。在 Vue.js 应用中,HTTP 请求是异步执行的,这让许多开发人员感到困惑。需要特别注意处理错误,因为 Vue.js 不会自动检测它们。 |
| 还有其他选择吗? | 您不必在 web 应用中使用 HTTP 请求,尤其是当您只有少量数据要处理时。 |
表 19-2 总结了本章内容。
表 19-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 从 web 服务获取数据 | 创建Axios.get
方法并读取响应对象的data
属性 | 10–14 |
| 整合访问 web 服务的代码 | 创建 HTTP 服务 | 15–17 |
| 执行其他 HTTP 操作 | 使用与您需要的 HTTP 请求类型相对应的 Axios 方法 | 18–19 |
| 应对错误 | 合并请求并使用一个try
/ catch
块来捕获和处理错误 | 20–23 |
为本章做准备
在本章中,我继续使用第十八章的 productapp 项目。按照下面几节中的说明来准备处理 HTTP 请求的示例应用。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
准备 HTTP 服务器
我需要一个额外的包来接收本章中的示例应用发出的 HTTP 请求。运行productapp
文件夹中清单 19-1 所示的命令,安装一个名为json-server
的包。
npm install json-server@0.12.1
Listing 19-1Installing a Package
为了向服务器提供它将用来处理 HTTP 请求的数据,在productapp
文件夹中添加一个名为restData.js
的文件,其内容如清单 19-2 所示。
module.exports = function () {
var data = {
products: [
{ id: 1, name: "Kayak", category: "Watersports", price: 275 },
{ id: 2, name: "Lifejacket", category: "Watersports", price: 48.95 },
{ id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 },
{ id: 4, name: "Corner Flags", category: "Soccer", price: 34.95 },
{ id: 5, name: "Stadium", category: "Soccer", price: 79500 },
{ id: 6, name: "Thinking Cap", category: "Chess", price: 16 },
{ id: 7, name: "Unsteady Chair", category: "Chess", price: 29.95 },
{ id: 8, name: "Human Chess Board", category: "Chess", price: 75 },
{ id: 9, name: "Bling Bling King", category: "Chess", price: 1200 }
]
}
return data
}
Listing 19-2The Contents of the restData.js File in the productapp Folder
为了允许 NPM 运行json-server
包,将清单 19-3 中所示的语句添加到package.json
文件的scripts
部分。
...
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"json": "json-server restData.js -p 3500"
},
...
Listing 19-3Adding a Script in the package.json File in the productapp Folder
准备示例应用
为了准备本章的示例应用,我将删除一些不再需要的特性,删除硬编码到应用中的数据,并准备处理除了我在第十八章中使用的id
、name
和price
属性之外还具有 category 属性的对象。
安装 HTTP 包
但是,首先,我要添加我将用来发出 HTTP 请求的包。在productapp
文件夹中运行清单 19-4 所示的命令来安装一个名为 Axios 的包。
npm install axios@0.18.0
Listing 19-4Installing a Package
Axios 是一个流行的库,用于在 web 应用中发出 HTTP 请求。它不是专门为 Vue.js 应用编写的,但它已经成为 Vue.js 应用中最常用的 HTTP 库,因为它可靠且易于使用。您不必在自己的项目中使用 Axios,我在“选择 HTTP 请求机制”侧栏中描述了选项的范围。
简化组件
我简化了ProductEditor
组件,以便直接向用户显示用于编辑的input
元素,而不是通过一个单独的组件,我在第十八章中使用这个组件来演示如何连接应用的不同部分。清单 19-5 显示了简化的组件。
<template>
<div>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<button class="btn btn-secondary" v-on:click="cancel">Cancel</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
startEdit(product) {
this.editing = true;
this.product = {
id: product.id,
name: product.name,
category: product.category,
price: product.price
}
},
startCreate() {
this.editing = false;
this.product = {};
},
save() {
this.eventBus.$emit("complete", this.product);
this.startCreate();
},
cancel() {
this.product = {};
this.editing = false;
}
},
inject: ["eventBus"],
created() {
this.eventBus.$on("create", this.startCreate);
this.eventBus.$on("edit", this.startEdit);
}
}
</script>
Listing 19-5Simplifying the Component in the ProductEditor.vue File in the src/components Folder
接下来,我简化了ProductDisplay
组件,删除了硬编码的数据并增加了对显示类别值的支持,如清单 19-6 所示。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew">
Create New
</button>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
}
},
inject: ["eventBus"]
}
</script>
Listing 19-6Simplifying the Component in the ProductDisplay.vue File in the src/components Folder
最后,我简化了App
组件,删除了用于切换输入元素颜色的按钮以及它使用的方法和服务,如清单 19-7 所示。
<template>
<div class="container-fluid">
<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";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor }
}
</script>
Listing 19-7Simplifying the Component in the App.vue File in the src Folder
运行示例应用和 HTTP 服务器
本章需要两个命令提示符:一个运行 HTTP 服务器,另一个运行 Vue.js 开发工具。在productapp
文件夹中打开一个新的命令提示符,运行清单 19-8 中所示的命令来启动 HTTP 服务器。
npm run json
Listing 19-8Starting the RESTful Server
服务器将开始监听端口 3500 上的请求。要测试服务器是否正在运行,请打开一个新的 web 浏览器并请求 URL http://localhost:3500/products/1
。如果服务器正在运行并且能够找到数据文件,那么浏览器将显示以下 JSON 数据:
...
{
"id": 1,
"name": "Kayak",
"category": "Watersports",
"price": 275
}
...
让 HTTP 服务器保持运行,并打开另一个命令提示符。导航到productapp
文件夹并运行清单 19-9 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 19-9Starting the Vue.js Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里你将看到示例应用,如图 19-1 所示。
图 19-1
运行示例应用
理解 RESTful Web 服务
向应用交付数据的最常见方法是使用表述性状态转移模式(称为 REST)来创建数据 web 服务。REST 没有详细的规范,这导致很多不同的方法都打着 RESTful 的旗号。然而,在 web 应用开发中有一些有用的统一思想。
RESTful web 服务的核心前提是包含 HTTP 的特性,以便请求方法——也称为动词——指定服务器要执行的操作,请求 URL 指定操作将应用到的一个或多个数据对象。
例如,在示例应用中,下面是一个可能指向特定产品的 URL:
http://localhost:3500/products/2
URL 的第一段—products
—表示将被操作的对象的集合,并允许单个服务器提供多个服务,每个服务都有自己的数据。第二个片段——2
——在products
集合中选择一个单独的对象。在本例中,id
属性的值唯一地标识了一个对象,并将在 URL 中使用,在本例中,指定了Lifejacket
对象。
用于发出请求的 HTTP 动词或方法告诉 RESTful 服务器应该对指定的对象执行什么操作。在上一节中测试 RESTful 服务器时,浏览器发送了一个 HTTP GET 请求,服务器将其解释为检索指定对象并将其发送给客户机的指令。正是由于这个原因,浏览器显示了一个表示Lifejacket
对象的 JSON。
表 19-3 显示了 HTTP 方法和 URL 的最常见组合,并解释了当发送到 RESTful 服务器时它们各自的作用。
表 19-3
RESTful Web 服务中常见的 HTTP 方法及其效果
|方法
|
统一资源定位器
|
描述
|
| --- | --- | --- |
| GET
| /products
| 这种组合检索products
集合中的所有对象。 |
| GET
| /products/2
| 这个组合从products
集合中检索出id
为2
的对象。 |
| POST
| /products
| 该组合用于向products
集合添加一个新对象。请求体包含新对象的 JSON 表示。 |
| PUT
| /products/2
| 该组合用于替换products
集合中id
为 2 的对象。请求体包含替换对象的 JSON 表示。 |
| PATCH
| /products/2
| 该组合用于更新products
集合中对象属性的子集,该集合的id
为 2。请求体包含要更新的属性和新值的 JSON 表示。 |
| DELETE
| /products/2
| 该组合用于从products
集合中删除id
为 2 的产品。 |
需要谨慎,因为一些 RESTful web 服务的工作方式可能存在相当大的差异,这是由用于创建它们的框架和开发团队的偏好的差异造成的。确认 web 服务如何使用动词以及在 URL 和请求正文中需要什么来执行操作是很重要的。
一些常见的变体包括不接受任何包含id
值的请求主体的 web 服务(以确保它们是由服务器的数据存储唯一生成的)和不支持所有动词的 web 服务(通常忽略PATCH
请求,只接受使用PUT
动词的更新)。
选择 HTTP 请求机制
异步 HTTP 请求有三种不同的方式。第一种方法是使用XMLHttpRequest
对象,这是异步请求的原始机制,可以追溯到 XML 作为 web 应用的标准数据格式的时候。下面是一段代码,它向本章中使用的 RESTful web 服务发送一个 HTTP 请求:
...
let request = new XMLHttpRequest();
request.onreadystatechange = () => {
if (request.readyState == XMLHttpRequest.DONE && request.status == 200) {
this.products.push(...JSON.parse(request.responseText));
}
};
request.open("GET", "http://localhost:3500/products");
request.send();
...
由XMLHttpRequest
对象提供的 API 使用一个事件处理程序来接收更新,包括请求完成时来自服务器的响应细节。XMLHttpRequest
使用起来很笨拙,并且不支持像async
/ await
关键字这样的现代特性,但是你可以相信它在所有运行 Vue.js 应用的浏览器中都是可用的。你可以在 https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
了解更多关于XMLHttpRequest
的 API。
第二种方法是使用 Fetch API,这是最近对XMLHttpRequest
对象的替代。Fetch API 使用承诺,而不是事件,通常更容易使用。下面是获取产品数据的一段代码,相当于XMLHttpRequest
示例:
...
fetch("http://localhost:3500/products")
.then(response => response.json())
.then(data => this.products.push(...data));
...
如果有的话,Fetch API 使用了太多的承诺。fetch
方法用于发出 HTTP 请求,该请求返回一个产生结果对象的承诺,该结果对象的json
方法产生从 JSON 数据解析的请求的最终结果。Fetch API 可以与async
和await
关键字一起使用,但是处理多个承诺需要小心,可能会导致类似下面这样的语句:
...
this.products.push(
...await (await fetch("http://localhost:3500/products")).json());
...
Fetch API 是对XMLHttpRequest
的改进,但是并不是所有可以运行 Vue.js 应用的浏览器都支持它。你可以在 https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
找到获取 API 的详细信息。
第三种方法是使用一个包,该包使用了XMLHttpRequest
对象,但隐藏了细节,并提供了一个与 Vue.js 开发体验的其余部分更加一致的 API。我在这一章中使用了 Axios 包,因为它是最流行的,但也有许多可用的选择。Vue.js 没有一个“官方的”HTTP 包,但是有很多选择,甚至不是专门为 Vue.js 编写的包也很容易使用,正如本章所演示的。使用 HTTP 包的缺点是应用的大小会增加,因为浏览器需要额外的代码。
使用 RESTful Web 服务
关于 web 应用发出的 HTTP 请求,需要理解的最重要的一点是它们是异步的。这似乎是显而易见的,但它会引起混乱,因为来自服务器的响应不会立即可用。因此,HTTP 请求所需的代码必须仔细编写,并且需要 JavaScript 特性来处理异步操作。因为 HTTP 请求会引起很多混乱,所以在接下来的小节中,我将一步一步地为第一个请求构建代码。
提出跨来源请求
默认情况下,浏览器会强制执行一个安全策略,只允许 JavaScript 代码在包含异步 HTTP 请求的文档的同一来源内发出这些请求。该政策旨在降低跨站点脚本(CSS)攻击的风险,在这种攻击中,浏览器被诱骗执行恶意代码,这在 http://en.wikipedia.org/wiki/Cross-site_scripting
中有详细描述。对于 web 应用开发人员来说,同源策略在使用 web 服务时可能是一个问题,因为它们通常位于包含应用 JavaScript 代码的源之外。如果两个 URL 具有相同的协议、主机和端口,则它们被认为是来源相同,否则它们具有不同的来源。我在本章中为 RESTful web 服务使用的 URL 与主应用使用的 URL 有不同的来源,因为它们使用不同的 TCP 端口。
跨源资源共享(CORS)协议用于向不同的源发送请求。使用 CORS,浏览器在异步 HTTP 请求中包含标头,向服务器提供 JavaScript 代码的来源。来自服务器的响应包括告诉浏览器它是否愿意接受请求的头。CORS 的详细情况不在本书讨论范围之内,但在 https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
有题目介绍,在 www.w3.org/TR/cors
有 CORS 规格。
CORS 是在这一章中自动发生的事情。提供 RESTful web 服务的json-server
包支持 CORS,并将接受来自任何来源的请求,而我用来发出 HTTP 请求的 Axios 包自动应用 CORS。当您为自己的项目选择软件时,您必须选择一个允许通过单一来源处理所有请求的平台,或者配置 CORS 以便服务器接受应用的数据请求。
处理响应数据
这可能看起来违反直觉,但是最好从处理您期望从服务器接收的数据的代码开始。在清单 19-10 中,我在ProductDisplay
组件的脚本元素中添加了一个方法,当从 RESTful web 服务接收到产品数据时,它将处理这些数据。
...
<script>
import Vue from "vue";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
}
},
inject: ["eventBus"]
}
</script>
...
Listing 19-10Adding a Method in the ProductDisplay.vue File in the src/components Folder
processProducts
方法将接收一个产品对象数组,并用它们替换名为products
的data p
属性的内容。正如我在第十三章中解释的,Vue.js 在检测数组中的变化时有一些困难,所以我使用了splice
方法来移除任何现有的对象。然后,我使用destructuring
操作符解包方法参数中的值,并使用push
方法重新填充数组。由于products
数组是一个反应式数据属性,Vue.js 将自动检测变化并更新数据绑定以反映新数据。
发出 HTTP 请求
下一步是发出 HTTP 请求,并向 RESTful web 服务请求数据。在清单 19-11 中,我已经导入了 Axios 包,并使用它来发送 HTTP 请求。
...
<script>
import Vue from "vue";
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
}
},
inject: ["eventBus"],
created() {
Axios.get(baseUrl);
}
}
</script>
...
Listing 19-11Making an HTTP Request in the ProductDisplay.vue File in the src/components Folder
Axios 为每种 HTTP 请求类型提供了方法,比如使用get
方法发出 GET 请求,使用post
方法发出 POST 请求,等等。还有一个request
方法,接受一个配置对象,可以用来发出所有类型的请求;我在“创建错误处理服务”一节中使用了它。
我在组件的created
方法中发出 HTTP 请求。我希望我的请求的结果在收到数据时触发 Vue.js 更改机制,并且使用created
方法确保在我向 HTTP 服务器发出请求之前组件的数据属性已经得到处理。
小费
一些开发人员更喜欢使用mounted
方法来发出初始 HTTP 请求。您使用哪种方法并不重要,但保持一致是个好主意,这样所有组件的行为都是一样的。
接收响应
Axios get
方法的结果是一个Promise
,它将在 HTTP 请求完成时产生来自服务器的响应。正如我在第四章中解释的那样,then
方法用于指定当一个Promise
表示的工作完成时会发生什么,在清单 19-12 中,我使用了then
方法来处理 HTTP 响应。
...
<script>
import Vue from "vue";
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
}
},
inject: ["eventBus"],
created() {
Axios.get(baseUrl).then(resp => {
console.log(`HTTP Response: ${resp.status}, ${resp.statusText}`);
console.log(`Response Data: ${resp.data.length} items`);
});
}
}
</script>
...
Listing 19-12Receiving the HTTP Response in the ProductDisplay.vue File in the src/components Folder
then
方法从 Axios 接收一个对象,该对象代表服务器的响应,并定义了表 19-4 中所示的属性。
表 19-4
Axios 响应属性
|名字
|
描述
|
| --- | --- |
| status
| 该属性返回响应的状态代码,如 200 或 404。 |
| statusText
| 此属性返回伴随状态代码的说明性文本,如 OK 或 Not Found。 |
| headers
| 此属性返回一个对象,该对象的属性表示响应标头。 |
| data
| 该属性从响应中返回有效负载。 |
| config
| 此属性返回一个对象,该对象包含用于发出请求的配置选项。 |
| request
| 该属性返回用于发出请求的XMLHttpRequest
对象。 |
在清单 19-12 中,我使用status
和statusText
属性写出浏览器 JavaScript 控制台响应的细节。更令人感兴趣的是data
属性,该属性返回服务器发送的有效负载,Axios 自动对 JSON 响应进行解码,这意味着我可以读取length
属性来找出响应中包含了多少对象。保存对组件的更改,并检查浏览器的 JavaScript 控制台,您将看到以下消息:
...
HTTP Response: 200, OK
Response Data: 9 items
...
处理数据
最后一步是从响应对象读取数据属性,并将其传递给processProducts
方法,这样从 RESTful web 服务获得的对象将更新应用,如清单 19-13 所示。
...
<script>
import Vue from "vue";
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
}
},
inject: ["eventBus"],
created() {
Axios.get(baseUrl).then(resp => this.processProducts(resp.data));
}
}
</script>
...
Listing 19-13Processing the Response in the ProductDisplay.vue File in the src/components Folder
效果是组件将在其created
方法中发起一个 HTTP GET 请求。当从服务器收到响应时,Axios 解析它包含的 JSON 数据,并使它作为响应的一部分可用。响应数据用于填充组件的products
数组,随后的更新评估模板中的v-for
指令,并显示如图 19-2 所示的数据。
图 19-2
从 web 服务获取数据
我可以使用async
/ await
关键字简化这段代码,这将让我不必依赖于then
方法就能发出 HTTP 请求,有些开发人员会觉得这种方法令人困惑。在清单 19-14 中,我将async
关键字应用于create
方法,并使用await
关键字发出 HTTP 请求。
...
async created() {
let data = (await Axios.get(baseUrl)).data;
this.processProducts(data);
}
...
Listing 19-14Streamlining the Request Code in the ProductDisplay.vue File in the src/components Folder
这段代码的工作方式与清单 19-13 中的代码相同,但是没有使用then
方法来指定当 HTTP 请求完成时将要执行的语句。
创建 HTTP 服务
在继续之前,我将更改应用的结构。我在上一节中采用的方法演示了在组件中发出 HTTP 请求是多么容易,但是结果是组件提供给用户的功能被与服务器通信所需的代码冲淡了。随着请求类型范围的扩大,该组件将越来越专注于处理 HTTP。
我在src
文件夹中添加了一个名为restDataSource.js
的 JavaScript 文件,并用它来定义清单 19-15 中所示的 JavaScript 类。
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export class RestDataSource {
async getProducts() {
return (await Axios.get(baseUrl)).data;
}
}
Listing 19-15The Contents of the restDataSource.js File in the src Folder
我已经使用 JavaScript 类特性定义了RestDataSource
类,它有一个异步的getProducts
方法,使用 Axios 向 RESTful web 服务发送 HTTP 请求,并返回接收到的数据。在清单 19-16 中,我创建了一个RestDataSource
类的实例,并将其配置为main.js
文件中的一个服务,这样它将在整个应用中可用。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import { RestDataSource } from "./restDataSource";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
provide: function () {
return {
eventBus: new Vue(),
restDataSource: new RestDataSource()
}
}
}).$mount('#app')
Listing 19-16Configuring a Service in the main.js File in the src Folder
新服务被称为restDataSource
,它将可供应用中的所有组件使用。
使用 HTTP 服务
现在我已经定义了一个服务,我可以从ProductDisplay
代码中删除 Axios 代码,并使用该服务,如清单 19-17 所示。
...
<script>
import Vue from "vue";
//import Axios from "axios";
//const baseUrl = "http://localhost:3500/products/";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
}
},
inject: ["eventBus", "restDataSource"],
async created() {
this.processProducts(await this.restDataSource.getProducts());
}
}
</script>
...
Listing 19-17Using the HTTP Service in the ProductDisplay.vue File in the src/components Folder
inject
属性声明了对restDataSource
服务的依赖,在created
方法中使用该服务从 RESTful web 服务获取数据,并填充名为products
的data
属性。
添加其他 HTTP 操作
现在我已经有了一个基本的结构,我可以添加应用需要的完整的 HTTP 操作集,扩展服务以使用 Axios 提供的方法,如清单 19-18 所示。
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export class RestDataSource {
async getProducts() {
return (await Axios.get(baseUrl)).data;
}
async saveProduct(product) {
await Axios.post(baseUrl, product);
}
async updateProduct(product) {
await Axios.put(`${baseUrl}${product.id}`, product);
}
async deleteProduct(product) {
await Axios.delete(`${baseUrl}${product.id}`, product);
}
}
Listing 19-18Adding Operations in restDataSource.js in the src Folder
我添加了保存新对象、更新现有对象和删除对象的方法。所有这些方法都使用async
/ await
关键字,这将允许需要操作的组件等待结果。这很重要,因为这意味着组件可以确保数据的本地表示不会被更新,除非 HTTP 操作成功完成。
小费
删除或编辑项目后,停止并启动json-server
包,将示例数据重置为其原始状态。清单 19-2 中创建的 JavaScript 文件的内容将在进程开始时用于重新填充数据库。
在清单 19-19 中,我修改了ProductDisplay
组件以使用清单 19-18 中定义的方法,并增加了对删除对象的支持。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm btn-danger"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew">
Create New
</button>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() {
this.eventBus.$emit("create");
},
editProduct(product) {
this.eventBus.$emit("edit", product);
},
async deleteProduct(product) {
await this.restDataSource.deleteProduct(product);
let index = this.products.findIndex(p => p.id == product.id);
this.products.splice(index, 1);
},
processProducts(newProducts) {
this.products.splice(0);
this.products.push(...newProducts);
},
async processComplete(product) {
let index = this.products.findIndex(p => p.id == product.id);
if (index == -1) {
await this.restDataSource.saveProduct(product);
this.products.push(product);
} else {
await this.restDataSource.updateProduct(product);
Vue.set(this.products, index, product);
}
}
},
inject: ["eventBus", "restDataSource"],
async created() {
this.processProducts(await this.restDataSource.getProducts());
this.eventBus.$on("complete", this.processComplete);
}
}
</script>
Listing 19-19Adding Data Operations in the ProductDisplay.vue File in the src/components Folder
当调用由 HTTP 服务定义的异步方法时,使用await
关键字是很重要的。如果省略了await
关键字,那么不管 HTTP 请求的结果如何,组件方法中的后续语句都会立即执行。对于要求 RESTful web 服务存储或删除对象的操作,这意味着应用向用户显示的数据将表明操作已经立即成功完成,即使发生了错误。例如,在这个方法中使用await
关键字可以防止组件从products
数组中移除对象,直到 HTTP 请求完成:
...
async deleteProduct(product) {
await this.restDataSource.deleteProduct(product);
let index = this.products.findIndex(p => p.id == product.id);
this.products.splice(index, 1);
},
...
当您使用await
关键字时,在异步操作执行期间发生的任何错误都将导致在组件的方法中抛出异常,这将停止组件方法中语句的执行,在这种情况下,防止用户数据与服务器上的数据不同步。
小费
当你在一个组件的方法中使用await
关键字时,你必须记住也应用async
关键字,如清单 19-19 所示。
这些变化的结果是应用从 RESTful web 服务中读取数据,并能够创建新产品,编辑和删除现有产品,如图 19-3 所示。
图 19-3
执行 HTTP 操作
创建错误处理服务
Vue.js 不能检测异步 HTTP 操作中出现的异常,需要做一些额外的工作来告诉用户发生了错误。我倾向于创建一个专门显示错误的组件,并让RestDataSource
类通过事件总线发送定制事件来提供错误通知。在清单 19-20 中,我添加了对RestDataSource
类的支持,通过调度自定义事件来处理异常。
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export class RestDataSource {
constructor(bus) {
this.eventBus = bus;
}
async getProducts() {
return (await this.sendRequest("GET", baseUrl)).data;
}
async saveProduct(product) {
await this.sendRequest("POST", baseUrl, product);
}
async updateProduct(product) {
await this.sendRequest("PUT", `${baseUrl}${product.id}`, product);
}
async deleteProduct(product) {
await this.sendRequest("DELETE", `${baseUrl}${product.id}`, product);
}
async sendRequest(httpMethod, url, product) {
try {
return await Axios.request({
method: httpMethod,
url: url,
data: product
});
} catch (err) {
if (err.response) {
this.eventBus.$emit("httpError",
`${err.response.statusText} - ${err.response.status}`);
} else {
this.eventBus.$emit("httpError", "HTTP Error");
}
throw err;
}
}
}
Listing 19-20Handling Errors in the restDataSource.js File in the src Folder
常规类不经历组件生命周期,不能使用inject
属性接收服务。考虑到这一点,我添加了一个接受事件总线的构造函数,我重写了这个类,这样所有的方法都通过调用使用 Axios request
方法的sendRequest
方法来执行它们的工作。这个方法允许使用一个配置对象来指定请求的细节,并允许我合并执行 HTTP 请求的代码,以便我可以一致地处理错误。
Axios 并不总是能够提供包含响应的对象,比如当请求超时时。对于这些情况,我提供了一般性的描述,指出问题与 HTTP 请求有关。
当 HTTP 请求返回 400 和 500 范围内的状态代码时,Axios 方法会抛出错误,这表明存在问题。在清单 19-20 中,我使用了一个try
/ catch
块来捕捉异常并发送一个名为httpError
的定制事件。在catch
块中接收的对象是一个response
属性,它返回一个表示来自服务器的响应的对象。这个对象定义了表 19-4 中描述的属性,我用它来制定一个简单的消息来伴随自定义事件。
小费
注意,在发送自定义事件后,我仍然throw
事件。这是为了让发起 HTTP 请求的组件接收到异常,而不会继续更新数据的本地表示。如果没有throw
语句,只有自定义事件的接收者会知道有问题。
我在清单 19-20 中添加的构造函数需要一个事件总线,我在main.js
文件中提供了,如清单 19-21 所示。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import { RestDataSource } from "./restDataSource";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
data: {
eventBus: new Vue()
},
provide: function () {
return {
eventBus: this.eventBus,
restDataSource: new RestDataSource(this.eventBus)
}
}
}).$mount('#app')
Listing 19-21Configuring a Service in the main.js File in the src Folder
定义服务的属性不能引用其他服务,所以我定义了一个创建事件总线的数据属性,然后我通过它自己的provide
属性直接公开它,并作为RestDataSource
类的构造函数参数。
创建错误显示组件
我需要一个组件,将接收自定义事件,并向用户显示错误消息。我在src/components
文件夹中添加了一个名为ErrorDisplay.vue
的文件,并添加了清单 19-22 中所示的内容。
<template>
<div v-if="error" class="bg-danger text-white text-center p-3 h3">
An Error Has Occurred
<h6>{{ message }}</h6>
<a href="/" class="btn btn-secondary">OK</a>
</div>
</template>
<script>
export default {
data: function () {
return {
error: false,
message: ""
}
},
methods: {
handleError(err) {
this.error = true;
this.message = err;
}
},
inject: ["eventBus"],
created() {
this.eventBus.$on("httpError", this.handleError);
}
}
</script>
Listing 19-22The Contents of the ErrorDisplay.vue File in the src/components Folder
该组件通过使用事件总线在其created
方法中注册其对自定义事件的兴趣,并在接收到事件时通过v-if
指令显示一个元素进行响应。为了将组件应用到用户,我对应用的根组件进行了更改,如清单 19-23 所示。
<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 }
}
</script>
Listing 19-23Applying a Component in the App.vue File in the src Folder
一个import
语句将名称ErrorDisplay
分配给component
,用于向 Vue.js 注册它,并允许将一个error-display
元素添加到根组件的模板中。结果是在 HTTP 请求过程中遇到的任何错误都会显示给用户,如图 19-4 所示。
小费
如果您想测试错误处理,那么最简单的方法就是停止json-server
进程,并更改RestDataSource
类中的baseUrl
值,使其指向一个不存在的 URL,比如http://localhost:3500/hats/
。
图 19-4
显示错误
从 HTTP 错误中恢复
我在错误处理组件中采用的方法是全有或全无的方法,在这种方法中,用户会看到一个导航到根 URL 的 OK 按钮,这有效地重新加载了应用并获得了新数据。这种方法的缺点是用户会丢失任何本地状态,这可能会导致挫败感,特别是如果用户试图重复一个复杂的任务,结果却再次处于相同的状态。
更好的方法是允许用户纠正问题并重试 HTTP 请求,但是只有当问题的原因清楚并且解决方案显而易见时,才应该尝试这样做。即使服务器提供了额外的错误信息,从 HTTP 请求中诊断出问题并不总是容易的。
摘要
在本章中,我演示了 Vue.js 应用如何使用 Axios 包访问 RESTful web 服务。我演示了如何使用 HTTP 请求执行数据操作,我向您展示了如何创建一个单独的类来将 HTTP 请求的细节与应用的其余部分分开,我解释了如何在出现错误时进行处理。在下一章中,我将解释如何使用 Vuex 包来创建共享数据存储。
二十、使用数据存储
在这一章中,我将向您展示如何使用 Vuex 包来创建数据存储,它提供了一种在应用中共享数据和在组件之间安排协调的替代方法。表 20-1 将 Vuex 数据存储放在上下文中。
表 20-1
将 Vuex 数据存储放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | 数据存储是应用状态的公共存储库,由 Vuex 包管理,它是 Vue.js 项目的正式组成部分。 |
| 为什么有用? | 数据存储可以通过应用使数据易于使用,从而简化数据管理。 |
| 如何使用? | 使用 Vuex 包创建一个数据存储,并在main.js
文件中注册,这样每个组件都可以通过一个特殊的$store
属性访问数据存储。 |
| 有什么陷阱或限制吗? | 当您第一次开始使用 Vuex 数据存储时,它以一种特殊的方式工作,这是违反直觉的。在您习惯 Vuex 方法之前,最好启用严格模式,尽管您必须记住在部署应用之前禁用该特性。 |
| 有其他选择吗? | 如果你不能使用 Vuex,那么你可以使用依赖注入特性和事件总线模式,如第十八章所述,来达到类似的结果。 |
表 20-2 总结了本章内容。
表 20-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 创建数据存储 | 定义一个新模块,用它注册 Vuex 插件并创建一个Vuex.Store
对象 | six |
| 注册数据存储 | 导入数据存储模块,并将存储属性添加到 Vue 对象的配置中 | seven |
| 访问组件中的数据存储 | 使用$store
属性 | eight |
| 在数据存储中进行更改 | 引发突变 | nine |
| 在数据存储中定义计算的特性 | 使用吸气剂 | 10–13 |
| 在数据存储中执行异步任务 | 使用一个动作 | 14–16 |
| 观察数据存储的变化 | 使用观察器 | 17–19 |
| 将数据存储功能映射到组件计算的属性和方法 | 使用映射函数 | Twenty |
| 将数据存储分成单独的文件 | 创建其他数据存储模块 | 21–24 |
| 将模块中的特征与数据存储的其余部分分开 | 使用名称空间功能 | 25, 26 |
为本章做准备
在本章中,我继续使用第十九章中的 productapp 项目。要启动 RESTful web 服务,打开命令提示符并运行清单 20-1 中的命令。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
npm run json
Listing 20-1Starting the Web Service
打开第二个命令提示符,导航到productapp
目录,运行清单 20-2 中所示的命令,下载并安装 Vuex 包。
npm install vuex@3.0.1
Listing 20-2Installing the Vuex Package
为了准备本章,我从管理产品数据和使用事件总线的ProductDisplay
组件中删除了所有语句,只留下了当用户点击按钮时调用的空方法,如清单 20-3 所示。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm btn-danger"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew">
Create New
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
products: []
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) { }
}
}
</script>
Listing 20-3Simplifying the Contents of the ProductDisplay.vue File in the src/components Folder
我还从ProductEditor
组件中移除了事件总线,如清单 20-4 所示。
<template>
<div>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<button class="btn btn-secondary" v-on:click="cancel">Cancel</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() { },
cancel() { }
}
}
</script>
Listing 20-4Simplifying the Contents of the ProductEditor.vue File in the src/components Folder
保存更改并运行productapp
目录中清单 20-5 所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 20-5Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里您将看到示例应用,如图 20-1 所示。
图 20-1
运行示例应用
创建和使用数据存储
开始共享应用状态的过程包括几个步骤,但是一旦基本的数据存储就绪,应用就可以很容易地扩展,并且最初的时间投资是值得的。惯例是在名为store
的文件夹中创建 Vuex 存储。我创建了src/store
文件夹,并在其中添加了一个名为index.js
的文件,其内容如清单 20-6 所示。
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
})
Listing 20-6The Contents of the index.js File in the src/store Folder
这是一个基本的 Vuex 数据存储。获得商店的基本结构很重要,所以我将一步一步地检查清单 20-6 中的代码。前两条语句从模块中导入 Vue.js 和 Vuex 功能。
...
import Vue from "vue";
import Vuex from "vuex";
...
下一条语句启用 Vuex 功能,如下所示:
...
Vue.use(Vuex);
...
Vuex 是作为 Vue.js 插件提供的,它允许核心 Vue.js 特性被扩展,正如我在第二十六章中所描述的,插件是用Vue.use
方法安装的。下一条语句创建数据存储,并使其成为 JavaScript 模块的默认导出:
...
export default new Vuex.Store({
...
关键字new
用于创建一个新的Vuex.Store
对象,它接受一个配置对象作为它的参数。
理解分离状态和突变
使用数据存储最具挑战性的一个方面是,数据是只读的,所有的更改都是使用称为突变的独立函数进行的。当创建数据存储时,应用的数据是使用一个state
属性定义的,可以对该数据进行的一组更改是使用一个mutations
属性指定的。对于清单 20-6 中定义的数据存储,有一个数据项。
...
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
...
属性state
已经被用来定义一个属性products
,我已经给它分配了一个对象数组。清单 20-6 中的数据存储也定义了两个突变。
...
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
...
突变是接收数据存储的当前状态和一个可选的有效负载参数的函数,该有效负载参数提供进行更改的上下文。在这个例子中,saveProduct
变异是一个接收对象并将其添加到products
数组或替换数组中现有对象的函数,而deleteProduct
变异是一个接收对象并使用id
值从products
数组中定位和移除相应对象的函数。
小费
注意,在清单 20-6 的saveProduct
变异中,我使用了Vue.set
来替换数组中的一个项目。如第十三章所述,数据存储与 Vue.js 应用的其他部分有相同的变化检测限制。
变异函数使用第一个函数参数访问数据存储的当前状态,如下所示:
...
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
...
在突变中不使用this
关键字,只有通过第一个参数才能访问数据。大多数开发人员习惯于自由地读写数据,因此定义单独的函数来进行更改可能会感觉很笨拙。但是,正如您将了解到的,这种方法有一些好处,在大型复杂的应用中尤其有价值。
提供对 Vuex 数据存储的访问
一旦创建了数据存储,下一步就是让它对应用的组件可用。这是通过在main.js
文件中配置Vue
对象来完成的,如清单 20-7 所示。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import { RestDataSource } from "./restDataSource";
import store from "./store";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
data: {
eventBus: new Vue()
},
store,
provide: function () {
return {
eventBus: this.eventBus,
restDataSource: new RestDataSource(this.eventBus)
}
}
}).$mount('#app')
Listing 20-7Configuring the Vuex Data Store in the main.js File in the src Folder
import
语句用于从store
文件夹中导入模块(这将自动加载index.js
文件)并为其指定名称store
,然后用于在配置对象上定义一个属性,该属性用于创建Vue
对象。这样做的效果是将在index.js
文件中创建的 Vuex 存储对象作为特殊变量$store
在所有组件中可用,如下一节所示。
使用数据存储
当您创建数据存储时,数据及其变体的分离感觉很笨拙,但是它非常适合组件的结构。在清单 20-8 中,我已经更新了ProductDisplay
组件,以便它从数据存储中读取数据,并在点击Delete
按钮时使用deleteProduct
变异。
...
<script>
export default {
//data: function () {
// return {
// products: []
// }
//},
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-8Using the Data Store in the ProductDisplay.vue File in the src/components Folder
访问商店中的数据是通过添加到清单 20-7 中所示的Vue
配置对象中创建的$store
属性来完成的。因为数据存储值是只读的,所以它们作为computed
属性集成到组件中,在本例中,我用从数据存储返回值的同名计算属性替换了products
数据属性。
...
products() {
return this.$store.state.products;
}
...
$store
属性返回数据存储,state
属性用于访问单个数据属性,例如本例中使用的products
属性。
使用严格模式来避免直接的状态改变
默认情况下,Vuex 不强制分离状态和操作,这意味着组件能够访问和修改在商店的state
部分中定义的属性。人们很容易忘记不应该直接进行更改,这样做意味着像 Vue Devtools 这样的调试工具不会检测到所做的更改。
为了帮助避免意外的更改,Vuex 提供了一个strict
设置来监控存储的状态属性,如果直接进行了更改,就会抛出一个错误。通过向用于创建存储的配置对象添加一个strict
属性来启用该特性,如下所示:
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
strict: true,
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products
.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products
.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
}
})
此设置只应在开发期间使用,因为它依赖于可能会影响应用性能的昂贵操作。
对数据存储应用突变是通过$store.commit
方法完成的,类似于触发事件。comment
方法的第一个参数是应该应用的变异的名称,表示为一个字符串,后面跟着一个可选的有效载荷参数,为变异提供上下文。在这个例子中,我已经设置了组件的deleteProduct
方法的主体,当用户点击一个Delete
方法时触发,以应用同名的变异:
...
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
...
一旦理解了如何读取和修改值,就可以将数据存储集成到应用中。在清单 20-9 中,我已经更新了ProductEditor
组件,以便它修改数据存储来创建新产品。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.commit("saveProduct", this.product);
this.product = {};
},
cancel() { }
}
}
</script>
...
Listing 20-9Using the Data Store in the ProductEditor.vue File in the src/components Folder
对save
方法进行了更新,以应用数据存储的addProduct
变异,这将向数据存储的state
部分的products
数组中添加一个新产品。组件更改的结果是用户可以填写表单字段,点击创建按钮添加新产品,点击删除按钮删除新产品,如图 20-2 所示。
图 20-2
使用数据存储
访问组件外部的数据存储
可以使用应用中任何组件的$store
属性来访问数据存储。如果您想访问应用中非组件部分的数据存储,那么您可以使用import
语句,如下所示:
...
import dataStore from "../store";
...
这句话来自第二十四章的后面一个例子,我在一个不包含组件的代码文件中使用数据存储。该语句将数据存储分配给名称dataStore
,跟随from
关键字的表达式是包含index.js
文件的store
文件夹的路径。一旦使用了import
语句,就可以像这样访问数据存储功能:
...
dataStore.commit("setComponentLoading", true);
...
这是第二十四章中同一示例的另一个陈述,您可以看到该功能在上下文中的使用。
检查数据存储更改
使用数据存储的主要优点是,它将应用的数据与组件分离开来,这使组件保持简单,允许它们轻松地进行交互,并使整个项目更容易理解和测试。
仅通过突变来执行更改使得跟踪对存储数据所做的更改成为可能。Vue Devtools 浏览器插件已经集成了对使用 Vuex 的支持,如果你打开浏览器的 F12 工具并导航到 Vue 选项卡,你会看到一个带有时钟图标的按钮,点击它会显示 Vuex 商店中的数据,如图 20-3 所示。
图 20-3
检查数据存储
您可以看到,state
部分包含一个products
属性,其值是三个对象的数组。您可以浏览数组中的每个对象,并查看它定义的属性。
保持 F12 工具窗口打开,使用主浏览器窗口通过输入表 20-3 中所示的详细信息创建三个新项目。在输入每组详细信息后,单击 Create 按钮,这样一个新项目就会显示在由ProductDisplay
组件显示的产品表中。
表 20-3
用于测试 Vuex 商店的产品
|身份
|
名字
|
种类
|
价格
|
| --- | --- | --- | --- |
| One hundred | 运动鞋 | 运转 | One hundred |
| One hundred and one | 滑雪板 | 冬季运动 | Five hundred |
| One hundred and two | 手套 | 冬季运动 | Thirty-five |
创建完所有三个产品后,单击手套产品的删除按钮,这会将其从列表中删除。
当您进行每个更改时,Vue Devtools 选项卡的 Vuex 部分将显示应用于数据的每个突变的效果,以及商店数据的快照和为每个更改提供的有效负载参数的详细信息,如图 20-4 所示。
图 20-4
数据存储的发展状态
可以撤消每个更改,以展开数据存储的状态并将其返回到早期阶段,并且可以导出整个状态,然后稍后再重新导入。能够看到对存储进行的每个更改以及这些更改产生的影响是一个强大的工具,可以简化复杂问题的调试,尽管它需要一定程度的规则来确保更改仅通过突变应用,因为直接对状态属性进行的更改不会被检测到(如果您发现很难记住使用突变,请参见关于 Vuex 严格模式的“使用严格模式避免直接状态更改”侧栏)。
在数据存储中定义计算的特性
当您将应用的数据移动到一个存储中时,您经常会发现几个组件必须对一个状态值执行相同的操作才能获得它们需要的数据。与其在每个组件中复制相同的代码,不如使用 Vuex getters 特性,它相当于组件中的计算属性。在清单 20-10 中,我在数据存储中添加了两个 getters 来转换产品数据。
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
products: [
{ id: 1, name: "Product #1", category: "Test", price: 100 },
{ id: 2, name: "Product #2", category: "Test", price: 150 },
{ id: 3, name: "Product #3", category: "Test", price: 200 }]
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
},
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return getters.orderedProducts.filter(p => p.price > 100);
}
}
})
Listing 20-10Adding Getters in the index.js File in the src/store Folder
通过向存储的配置对象添加一个getters
属性来定义 Getters。每个 getter 都是一个接收对象的函数,该对象提供对数据存储的状态属性的访问,其结果是计算出的值。我定义的第一个 getter 通过 state 参数访问 products 数组来对产品数据进行排序。
...
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
...
一个 getter 可以通过定义第二个参数来构建另一个 getter 的结果,这允许 getter 的函数接收一个对象,该对象提供对存储中其他 getter 的访问。我定义的第二个 getter 获取由orderedProducts
getter 产生的排序后的产品,然后过滤它们,只选择那些price
属性大于 100 的产品。
...
filteredProducts(state, getters) {
return getters.orderedProducts.filter(p => p.price > 100);
}
...
能够编写 getters 来创建更复杂的结果是一个有用的特性,有助于减少代码重复。
警告
Getters 不应更改存储区中的数据。正是因为这个原因,我在orderedProducts
getter 中使用了concat
方法,因为sort
方法对数组中的对象进行了排序。concat
方法生成一个新数组,并确保读取 getter 的值不会导致存储的状态数据发生变化。
在组件中使用 Getter
读取一个 getter 是通过this.$store.getters
属性来完成的,并使用 getter 的名称来读取,这样就可以使用this.$store.getters.myGetter
来读取一个名为myGetter
的 getter。在清单 20-11 中,我已经更改了由ProductDisplay
组件定义的 computed 属性,这样它就可以从filteredProducts
getter 获取数据。
...
<script>
export default {
computed: {
products() {
return this.$store.getters.filteredProducts;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-11Using a Getter in the ProductDisplay.vue File in the src/components Folder
组件模板中的v-for
绑定读取products
计算属性的值,该属性从数据存储中的 getter 获取其值。结果是向用户显示一组经过过滤和排序的产品,如图 20-5 所示。
图 20-5
使用数据存储 getter
向 Getters 提供参数
Getters 还可以接收组件提供的附加参数,这些参数可以用来影响组件生成的值。在清单 20-12 中,我修改了filteredProducts
getter,这样用于过滤产品对象的数量可以作为一个参数指定。
...
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return (amount) => getters.orderedProducts.filter(p => p.price > amount);
}
}
...
Listing 20-12Using a Getter Argument in the index.js File in the src/store Folder
语法有些笨拙,但是要接收参数,getter 必须返回一个函数,当 getter 被读取并由组件提供参数时,该函数将被调用。在这个例子中,getter 的结果是一个函数,它接收一个在过滤产品对象时应用的amount
参数。在清单 20-13 中,我更新了由ProductDisplay
组件定义的 computed 属性,将一个值传递给 getter。
...
<script>
export default {
computed: {
products() {
return this.$store.getters.filteredProducts(175);
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.commit("deleteProduct", product);
}
}
}
</script>
...
Listing 20-13Using a Getter Argument in the ProductDisplay.vue File in the src/components Folder
结果是只显示一个示例产品对象,因为它的价格是唯一超过组件指定值的价格,如图 20-6 所示。
图 20-6
使用 getter 参数
执行异步操作
变异执行同步操作,这意味着它们非常适合处理本地数据,但不适合处理 RESTful web 服务。对于异步任务,Vuex 提供了一个名为 actions 的特性,它允许执行任务,并通过突变将更改反馈到数据存储中。在清单 20-14 中,我向数据存储添加了消费 RESTful web 服务的动作。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
state: {
products: []
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
}
},
getters: {
orderedProducts(state) {
return state.products.concat().sort((p1, p2) => p2.price - p1.price);
},
filteredProducts(state, getters) {
return (amount) => getters.orderedProducts.filter(p => p.price > amount);
}
},
actions: {
async getProductsAction(context) {
(await Axios.get(baseUrl)).data
.forEach(p => context.commit("saveProduct", p));
},
async saveProductAction(context, product) {
let index = context.state.products.findIndex(p => p.id == product.id);
if (index == -1) {
await Axios.post(baseUrl, product);
} else {
await Axios.put(`${baseUrl}${product.id}`, product);
}
context.commit("saveProduct", product);
},
async deleteProductAction(context, product) {
await Axios.delete(`${baseUrl}${product.id}`);
context.commit("deleteProduct", product);
}
}
})
Listing 20-14Adding Actions in the index.js File in the src/store Folder
通过向存储的配置对象添加一个actions
属性来定义动作,每个动作都是一个接收上下文对象的函数,该对象提供对状态、getters 和突变的访问。动作不允许直接修改状态数据,必须使用commit
方法通过突变来工作。在清单中,我已经从products
数组中移除了虚拟数据,并定义了三个动作,它们使用 Axios 与 RESTful web 服务通信,并使用其变体更新数据存储。
小费
你不必在给你的行为起的名字中包含Action
这个词。我喜欢确保我的行为和突变是明确区分的,但这只是个人偏好,并不是一个要求。
在清单 20-15 中,我已经更新了ProductDisplay
组件,这样当用户点击删除按钮时,它就可以使用商店的动作从 web 服务获取初始数据。
...
<script>
export default {
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() { },
editProduct(product) { },
deleteProduct(product) {
this.$store.dispatch("deleteProductAction", product);
}
},
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
...
Listing 20-15Using Actions in the ProductDisplay.vue File in the src/components Folder
使用dispatch
方法调用动作,该方法接受要使用的参数的名称和将传递给动作的可选参数,类似于使用突变的方式。没有直接支持在创建 Vuex 商店时填充它,所以我使用了组件的created
方法,在第十七章中描述,来调用getProductsAction
动作并从 web 服务获取初始数据。(我还修改了用于products
computed 属性的数据,使其从状态数据而不是 getters 中读取,确保显示从服务器接收的所有数据。)
在清单 20-16 中,我已经更新了ProductEditor
组件,以便它使用动作来存储或更新 web 服务中的数据。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() { }
}
}
</script>
...
Listing 20-16Using Action in the ProductEditor.vue File in the src/components Folder
结果是从 web 服务中获取数据,如图 20-7 所示,创建或删除对象会导致服务器端发生相应的变化。
图 20-7
使用数据存储操作
接收变更通知
Vuex 数据存储可用于应用的所有状态,而不仅仅是显示给用户的数据。在示例应用中,用户通过单击由ProductDisplay
组件提供的按钮来选择要编辑的产品,但是ProductEditor
组件提供编辑特性。为了在组件之间实现这种类型的协调,Vuex 提供了当数据值改变时触发通知的观察器,相当于 Vue.js 组件提供的观察器(在第十七章中描述)。在清单 20-17 中,我在数据存储中添加了一个状态属性,表明用户选择了哪个产品,同时添加了一个设置其值的变异。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
state: {
products: [],
selectedProduct: null
},
mutations: {
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
deleteProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
currentState.products.splice(index, 1);
},
selectProduct(currentState, product) {
currentState.selectedProduct = product;
}
},
getters: {
// ...getters omitted for brevity...
},
actions: {
// ...actions omitted for brevity...
}
})
Listing 20-17Adding a State Property and Mutation in the index.js File in the src/store Folder
在清单 20-18 中,我已经更新了ProductDisplay
组件,这样当用户单击 Create New 按钮或其中一个编辑按钮时,它就会调用selectProduct
变异。
...
<script>
export default {
computed: {
products() {
return this.$store.state.products;
}
},
methods: {
createNew() {
this.$store.commit("selectProduct");
},
editProduct(product) {
this.$store.commit("selectProduct", product);
},
deleteProduct(product) {
this.$store.dispatch("deleteProductAction", product);
}
},
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
...
Listing 20-18Using a Mutation in the ProductDisplay.vue File in the src/components Folder
当用户单击 Create New 按钮时,组件的createNew
方法被调用,这将触发没有参数的selectProduct
变异,将selectedProduct
状态属性设置为null
,这表明用户没有选择要编辑的现有产品。当用户点击一个编辑按钮时,editProduct
方法被调用,这触发了同样的变异,但提供了一个产品对象,表明这是用户想要编辑的对象。
为了响应ProductDisplay
组件通过数据存储发送的信号,我给ProductEditor
组件添加了一个 Vuex 观察器,如清单 20-19 所示。
...
<script>
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
}
},
created() {
this.$store.watch(state => state.selectedProduct,
(newValue, oldValue) => {
if (newValue == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, newValue);
}
});
}
}
</script>
...
Listing 20-19Watching a Data Store Value in the ProductEditor.vue File in the src/components Folder
我使用组件的created
生命周期方法,在第十七章中描述,来创建观察器;这是通过使用watch
方法完成的,该方法以this.$store.watch
的形式访问。watch
方法接受两个函数:第一个函数用于为watch
选择数据值,第二个函数在所选数据发生变化时调用,使用组件观察器使用的相同函数样式。
小费
watch
方法的结果是一个可以被调用来停止接收通知的函数。你可以在第二十一章中看到该功能的使用示例。
其效果是,组件通过更改其本地状态并复制选定产品(如果有)中的值来对通过数据存储发送的信号做出反应。您可以通过点击“新建”或“编辑”按钮并检查表单域是否响应来测试更改,如图 20-8 所示。
图 20-8
使用数据存储观察器
了解间接和本地组件状态
清单 20-19 中的代码有一点值得注意:我使用Object.assign
将数据存储中对象的值复制到本地product
属性,如下所示:
...
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, newValue);
}
...
您可能想知道为什么我不直接在数据存储中处理 state 属性。第一个原因是使用v-bind
指令在 Vuex 数据值上创建双向绑定是不方便的。这是可以做到的,但是需要使用单独的数据和事件绑定,或者创建既有 setters 又有 getters 的计算属性。结果并不理想,我倾向于在组件中使用本地状态,如本例所示。
第二个原因是,直接使用数据存储中的对象会给用户带来困惑。由于数据存储中的这个对象与由ProductDisplay
组件使用的v-for
指令显示的对象相同,因此在input
元素中所做的任何更改都会立即反映在表格中。如果用户在没有点击 Save 按钮的情况下取消操作,更改不会被发送到 web 服务,而是由应用显示出来,导致用户认为更改已经保存,而实际上并没有保存。
当您第一次开始使用 Vuex 时,很想使用存储中的数据来完成应用中的所有工作,但这并不总是最好的方法,有些应用功能最好通过将数据存储与本地状态数据相结合来处理。
将数据存储功能映射到组件
在组件中使用数据存储特性可以产生大量类似的代码,这些代码具有几个访问状态变量的计算属性和触发突变和动作的方法。Vuex 提供了一组函数,这些函数生成的函数提供了对数据存储功能的自动访问,并可用于简化组件。表 20-4 描述了映射功能。
表 20-4
Vuex 特征映射功能
|名字
|
描述
|
| --- | --- |
| mapState
| 该函数生成计算属性,提供对数据存储的state
值的访问。 |
| mapGetters
| 此函数生成计算的属性,这些属性提供对数据存储的 getters 的访问。 |
| mapMutations
| 该函数生成提供对数据存储的变化的访问的方法。 |
| mapActions
| 此函数生成提供对数据存储操作的访问的方法。 |
这些函数将数据存储中的特性与组件的本地属性和方法一起添加。在清单 20-20 中,我使用了表 20-4 中的函数来提供对ProductDisplay
组件中数据存储的访问。
<template>
<div>
<table class="table table-sm table-striped table-bordered">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm btn-primary"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm btn-danger"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from "vuex";
export default {
computed: {
...mapState(["products"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
}
}
</script>
Listing 20-20Generating Members in the ProductDisplay.vue File in the src/components Folder
表 20-4 中描述的函数从vuex
模块中导入,并应用 JavaScript spread 运算符(...
表达式)来创建映射到数据存储的属性和方法。
有两种方法可以创建数据存储映射。第一种方法是向映射函数传递一个字符串数组,它告诉 Vuex 创建属性或方法,其名称与数据存储中使用的名称相匹配,如下所示:
...
computed: {
...mapState(["products"])
},
...
这告诉 Vuex 创建一个名为products
的计算属性,该属性返回products
数据存储状态属性的值。如果您不想使用数据存储中的名称,那么您可以将一个 map 对象传递给表 20-4 中的函数,它为 Vuex 提供应该在组件中使用的名称,如下所示:
...
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
...
这个例子告诉 Vuex 创建名为getProducts
和deleteProduct
的方法,这些方法调用名为getProductsAction
和deleteProductAction
的数据存储动作。
调用不带参数的映射方法
由映射函数为突变和动作生成的方法将接受参数,然后这些参数被传递给数据存储。如果您想在没有参数的情况下从事件绑定器中直接调用这些方法之一,就需要小心了。在清单 20-20 中,我必须对 Create New 按钮的事件绑定进行更改。
...
<button class="btn btn-primary" v-on:click="createNew()">
...
以前,v-on
指令的表达式只指定了方法名。这具有调用 Vuex 创建的映射方法的效果,该方法带有指令提供的事件对象,该对象被传递给映射的变异。示例应用依赖于在没有参数的情况下调用的突变,所以我在v-on
指令的表达式中添加了空括号,以防止提供参数。
使用数据存储模块
随着应用复杂性的增加,存储中的数据和代码量也在增加。Vuex 提供了对模块的支持,这使得数据存储可以分解为独立的部分,更易于编写、理解和管理。
每个模块都在一个单独的文件中定义。为了演示,我在src/store
文件夹中添加了一个名为preferences.js
的文件,其内容如清单 20-21 所示。
export default {
state: {
stripedTable: true,
primaryEditButton: false,
dangerDeleteButton: false
},
getters: {
editClass(state) {
return state.primaryEditButton ? "btn-primary" : "btn-secondary";
},
deleteClass(state) {
return state.dangerDeleteButton ? "btn-danger" : "btn-secondary";
},
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
},
mutations: {
setEditButtonColor(currentState, primary) {
currentState.primaryEditButton = primary;
},
setDeleteButtonColor(currentState, danger) {
currentState.dangerDeleteButton = danger;
}
}
}
Listing 20-21The Contents of the preferences.js File in the src/store Folder
当你定义一个模块时,你就定义了一个 JavaScript 对象,它具有与你在本章前面章节中看到的相同的状态、getters、mutations 和 actions 属性。不同之处在于,传递给模块中定义的函数的上下文对象只提供对本地模块中的状态数据和其他特性的访问。如果要访问主模块中的数据存储功能,可以定义一个附加参数,如下所示:
...
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
...
这个 getter 定义了一个用于访问products
属性的rootState
参数,允许根据数组中第一个产品的price
值来确定 getter 的结果。
小费
当访问根数据存储模块时,即使不打算使用它,也必须定义 payload 参数,如清单 20-21 所示。
注册和使用数据存储模块
要将一个模块合并到数据存储中,必须使用import
语句并使用modules
属性配置数据存储,如清单 20-22 所示。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import PrefsModule from "./preferences";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
modules: {
prefs: PrefsModule
},
state: {
products: [],
selectedProduct: null
},
// ...other data store features omitted for brevity...
})
Listing 20-22Registering a Module in the index.js File in the src/store Folder
Vuex 将模块中定义的状态、getters、突变和动作与直接在index.js
文件中定义的相结合,这意味着组件不必担心数据存储的不同部分是在哪里定义的。在清单 20-23 中,我已经更新了ProductDisplay
组件,以便它使用模块中定义的数据和突变。
<template>
<div>
<table class="table table-sm table-bordered" v-bind:class="tableClass">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm"
v-bind:class="editClass"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapGetters(["tableClass", "editClass", "deleteClass"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapMutations(["setEditButtonColor", "setDeleteButtonColor"]),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
Listing 20-23Using Module Features in the ProductDisplay.vue File in the src/components Folder
我使用了映射函数来映射数据存储模块提供的 getters 和 mutations,并使用v-bind
指令将它们的值绑定到组件的模板中。我调用组件的created
方法中的突变,通过将表中第一个产品的price p
属性更改为大于 500,您可以看到访问根数据存储状态的 getter 的效果,如图 20-9 所示。当该值小于 500 时,表行不进行分条;当该值大于 500 时,行以交替颜色显示。
图 20-9
使用由数据存储模块提供的特征
访问模块状态
如果您想要访问模块中定义的状态属性,则需要一种不同的方法。尽管 getters、mutations 和 actions 与数据存储的其余部分无缝合并,但状态数据保持独立,并且必须使用导入时分配给模块的名称来访问,如清单 20-24 所示。
<template>
<div>
<table class="table table-sm table-bordered"
v-bind:class="'table-striped' == useStripedTable">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<button class="btn btn-sm"
v-bind:class="editClass"
v-on:click="editProduct(p)">
Edit
</button>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary" v-on:click="createNew()">
Create New
</button>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters(["tableClass", "editClass", "deleteClass"])
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
}),
...mapMutations(["setEditButtonColor", "setDeleteButtonColor"]),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
Listing 20-24Accessing Module State Data in the ProductDisplay.vue File in the src Folder
向mapState
方法传递一个对象,该对象的属性是将用作本地属性的名称,其值是接收状态对象并选择所需数据存储值的函数。在这个例子中,我使用这个特性创建了一个名为useStripedTable
的computed
属性,它被映射到在prefs
模块中定义的stripedTable
状态属性。
使用模块命名空间
模块的默认行为是将它们提供的 getters、mutations 和 actions 合并到数据存储中,以便组件不知道存储结构,但保持状态数据独立,以便必须使用前缀来访问它。如果您启用了名称空间特性,如清单 20-25 所示,那么所有的特性都必须使用前缀来访问。
export default {
namespaced: true,
state: {
stripedTable: true,
primaryEditButton: true,
dangerDeleteButton: false
},
getters: {
editClass(state) {
return state.primaryEditButton ? "btn-primary" : "btn-secondary";
},
deleteClass(state) {
return state.dangerDeleteButton ? "btn-danger" : "btn-secondary";
},
tableClass(state, payload, rootState) {
return rootState.products.length > 0
&& rootState.products[0].price > 500 ? "table-striped" : ""
}
},
mutations: {
setEditButtonColor(currentState, primary) {
currentState.primaryEditButton = primary;
},
setDeleteButtonColor(currentState, danger) {
currentState.dangerDeleteButton = danger;
}
}
}
Listing 20-25Enabling the Namespace Feature in the preferences.js File in the src/store Folder
当您将模块定义的特性映射到一个组件时,您想要的特性的名称必须以用于将模块注册到数据存储的名称为前缀,如清单 20-26 所示。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
striped: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 20-26Using a Module Namespace in the ProductDisplay.vue File in the src/components Folder
为了选择 getters、mutations 和 actions,使用名称空间,后跟一个正斜杠(/
字符)和函数名,如下所示:
...
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
...
这个片段映射了prefs
名称空间中的tableClass
、editClass
和deleteClass
getter。
摘要
在本章中,我向您展示了如何使用 Vuex 为 Vue.js 应用创建数据存储。我向您展示了如何使用突变修改状态数据,如何使用 getters 合成值,以及如何使用 actions 执行异步任务。我还演示了用于将数据存储特性映射到组件的 Vuex 函数,以及如何使用模块和名称空间构建数据存储。在下一章,我将描述 Vue.js 动态组件特性。
二十一、动态组件
简单的应用可以一次向用户呈现所有的内容,但是更复杂的项目需要更有选择性,在不同的时间向用户显示不同的组件。在这一章中,我解释了内置的 Vue.js 特性,这些特性允许组件基于用户交互动态显示,并仅在需要时加载组件,这有助于减少用户消耗的数据量。表 21-1 将动态组件放在上下文中。
小费
本章中描述的特性经常与 URL 路由一起使用,我在第二十二章中对此进行了描述。
表 21-1
将动态组件放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 动态组件仅在需要时向用户显示。 |
| 它们为什么有用? | 复杂的应用有太多的功能,无法一次呈现给用户。能够改变显示给用户的组件允许应用呈现复杂的内容而不会让用户不知所措。 |
| 它们是如何使用的? | Vue.js is
指令可用于动态选择组件。 |
| 有什么陷阱或限制吗? | 必须注意确保动态组件不会对其生命周期做出假设,这在将动态组件引入现有项目时可能是一个问题。有关详细信息,请参见“为动态生命周期准备组件”一节。 |
| 有其他选择吗? | 应用不必动态显示它们的组件。简单的应用可以一次显示所有的内容,如前面章节中的示例应用所示。 |
表 21-2 总结了本章内容。
表 21-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 动态选择一个组件 | 使用is
属性和v-bind
指令 | 6–12 |
| 仅在需要时加载组件 | 定义异步组件 | 13–14 |
| 禁用预取提示 | 更改应用配置 | Fifteen |
| 微调异步组件 | 使用延迟加载配置选项 | 16, 17 |
为本章做准备
我继续使用第二十一章中的 productapp 例子。要启动 RESTful web 服务,打开命令提示符并运行清单 21-1 中的命令。
npm run json
Listing 21-1Starting the Web Service
打开第二个命令提示符,导航到productapp
目录,运行清单 21-2 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 21-2Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里你将看到示例应用,如图 21-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
图 21-1
运行示例应用
为动态生命周期准备组件
编写ProductDisplay
和ProductEditor
组件时,假设它们将在应用初始化时创建,并一直存在到应用终止。当动态显示组件时,这可能是一个问题,因为 Vue.js 不会在需要时创建组件,一旦用户看不到它们,就会销毁它们。结果是,观察器和事件处理程序可能会错过重要的通知,并且每次创建组件时,即使应用已经收到了所需的数据,也要重复执行代价高昂的任务,例如从 web 服务获取数据。
获取应用数据
在清单 21-3 中,我删除了从 web 服务获取初始数据的ProductDisplay
组件的created
方法中的语句。当我开始向用户动态显示组件时,每当用户想要查看产品表时,Vue.js 将创建该组件的一个新实例,并且创建的实例将有自己的生命周期,包括调用它的created
方法。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
...mapMutations({
editProduct: "selectProduct",
createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
//getProducts: "getProductsAction",
deleteProduct: "deleteProductAction"
})
},
created() {
//this.getProducts();
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 21-3Avoiding Duplicate Requests in the ProductDisplay.vue File in the src/components Folder
应该只执行一次的任务必须由不会动态显示的组件来处理。在大多数 Vue.js 项目中,顶层组件用于协调应用其他组件的可见性,并且对用户始终可见。在清单 21-4 中,我让App
组件负责从 web 服务获取初始数据。
<template>
<div class="container-fluid">
<div class="row">
<div class="col"><error-display /></div>
</div>
<div class="row">
<div class="col-8 m-3"><product-display /></div>
<div class="col m-3"><product-editor /></div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 21-4Getting Data in the App.vue File in the src Folder
我使用了 Vuex dispatch
方法来调用名为getProductsAction
的动作,我在第二十章中定义了这个动作。由于我不会动态显示App
组件,我可以相信这个任务只被执行一次。
管理观察事件
ProductEditor
组件使用数据存储观察器来确定用户何时单击新建或编辑按钮。当动态创建组件时,可能很难确保在 Vue.js 创建它需要的组件之前处理应用状态的更改,结果是组件可能会错过配置它以供使用的事件。在清单 21-5 中,我修改了ProductEditor
组件,在它的created
方法中使用现有的数据存储值。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
save() {
this.$store.dispatch("saveProductAction", this.product);
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
},
selectProduct(selectedProduct) {
if (selectedProduct == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, selectedProduct);
}
}
},
created() {
unwatcher = this.$store.watch(state =>
state.selectedProduct, this.selectProduct);
this.selectProduct(this.$store.state.selectedProduct);
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 21-5Preparing for Dynamic Display in the ProductEditor.vue File in the src/components Folder
created
方法处理数据存储中的当前数据值,并使用watch
方法创建一个观察器来观察未来的变化。watch
方法的结果是一个可以用来停止接收通知的函数,我在beforeDestroy
生命周期方法中使用了这个函数,以确保当 Vue.js 销毁组件时,观察器不会逗留。
动态显示组件
现在组件已经更新了,所以它们可以被创建和销毁而不会引起任何问题,我可以改变应用显示其内容的方式。在这一节中,我将演示如何直接使用 Vue.js 支持来动态选择组件,这将为解释如何使用流行的Vue-Router
包提供基础。
在 HTML 元素中呈现不同的组件
Vue.js 支持 HTML 元素上的一个特殊属性来指定组件,当应用运行时,该组件的内容将用于替换该元素。这个特殊属性叫做is
,它可以用在任何元素上,尽管惯例是使用component
元素,如清单 21-6 所示。
小费
当您保存清单 21-6 中的更改时,您将会看到一个 linter 警告。这将在下一节中解决。
<template>
<div class="container-fluid">
<div class="row">
<div class="col"><error-display /></div>
</div>
<div class="row">
<div class="col">
<component is="ProductDisplay"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 21-6Using the is Attribute in the App.vue File in the src Folder
在运行时,Vue.js 将遇到component
元素上的is
属性,并评估该属性的值以确定应该显示哪个组件。在清单中,is
属性被设置为ProductDisplay
,这是由script
元素中的import
语句分配的名称,并且已经与 components configuration
属性一起使用,结果是向用户呈现产品表,如图 21-2 所示。
图 21-2
显示组件
使用数据绑定选择组件
当与数据绑定一起使用时,is
属性变得很有趣,它允许在应用运行时改变呈现给用户的组件。在清单 21-7 中,我添加了一个数据绑定到App
组件的模板,它根据用户的选择设置is
属性的值。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group btn-group-toggle">
<label class="btn btn-info"
v-bind:class="{active: (selected == 'table') }">
<input type="radio" v-model="selected" value="table" />
Table
</label>
<label class="btn btn-info"
v-bind:class="{active: (selected == 'editor') }">
<input type="radio" v-model="selected" value="editor" />
Editor
</label>
</div>
</div>
</div>
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
},
data: function() {
return {
selected: "table"
}
},
computed: {
selectedComponent() {
return this.selected == "table" ? ProductDisplay : ProductEditor;
}
}
}
</script>
Listing 21-7Using a Data Binding in the App.vue File in the src Folder
本例中的input
元素允许用户通过使用v-model
指令更改名为selected
的数据属性来选择显示哪个组件。component
元素上的is
属性的值反映了使用v-bind
指令选择的值,该指令读取一个computed
属性的值,该属性使用selected
值来标识用户需要的组件,产生如图 21-3 所示的结果。
图 21-3
使用数据绑定选择组件
向用户呈现一个按钮组,允许选择应用显示的组件:单击 Table 按钮显示ProductDisplay
组件,单击 Editor 按钮显示ProductEditor
按钮。
了解组件生命周期
值得花点时间检查一下应用的状态,看看 Vue.js 是如何处理动态变化的。在使用 Vue Devtools 查看应用的组件时,点击表格和编辑器按钮,你会看到组件在需要时被创建和销毁,结果是App
组件在任何给定时刻都只有一个子组件,如图 21-4 所示。
图 21-4
创建和销毁组件
重用动态组件
Vue.js 提供了另一种管理动态组件的方法,不需要在每次需要时创建它们,不需要时销毁它们。如果用一个keep-alive
元素包围显示组件的元素(具有is
属性的元素), Vue.js 会在不需要组件时使它们不活动,而不是销毁它们。下面是一个应用keep-alive
元素的例子:
...
<div class="row">
<div class="col">
<keep-alive>
<component v-bind:is="selectedComponent"></component>
</keep-alive>
</div>
</div>
...
这个特性的优点是您不必担心更新组件,以避免重复一次性任务或错过重要事件。缺点是资源被不活动的并且可能不再需要的组件消耗。
我的建议是让 Vue.js 在需要的时候创建和销毁组件,但是如果你确实使用了keep-alive
元素,那么你可以通过实现activated
和deactivated
生命周期方法在组件激活和非激活时接收通知(关于组件生命周期的细节,请参见第十七章)。
在应用中自动导航
让用户选择组件使我能够演示组件是如何动态显示的,但这不是大多数应用需要的工作方式。我希望应用能够根据用户的操作自动向用户呈现适当的组件,这意味着应用中的所有组件都能够更改向用户显示的组件,例如,单击编辑按钮将自动选择编辑器组件。
我首先用清单 21-8 中所示的代码向src/store
文件夹添加一个名为navigation.js
的文件。
export default {
namespaced: true,
state: {
selected: "table"
}
,
mutations: {
selectComponent(currentState, selection) {
currentState.selected = selection;
}
}
}
Listing 21-8The Contents of the navigation.js File in the src/store Folder
这个 Vuex 模块将扩展数据存储,以便我可以在应用中导航。我将使用selected
状态属性作为is
属性的值,并使用selectComponent
变异来改变它的值。在清单 21-9 中,我已经将模块导入到主数据存储中。
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import PrefsModule from "./preferences";
import NavModule from "./navigation";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500/products/";
export default new Vuex.Store({
modules: {
prefs: PrefsModule,
nav: NavModule
},
state: {
products: [],
selectedProduct: null
},
// ...other data store features omitted for brevity...
})
Listing 21-9Adding a Module in the index.js File in the src/store Folder
下一步是使用新的数据存储状态属性作为App
组件模板中is
属性的值,如清单 21-10 所示。除了使用数据存储,我还删除了允许用户显式选择要显示的组件的按钮元素。
<template>
<div class="container-fluid">
<!-- <div class="row">
<div class="col text-center m-2">
<div class="btn-group btn-group-toggle">
<label class="btn btn-info"
v-bind:class="{active: (selected == 'table') }">
<input type="radio" v-model="selected" value="table" />
Table
</label>
<label class="btn btn-info"
v-bind:class="{active: (selected == 'editor') }">
<input type="radio" v-model="selected" value="editor" />
Editor
</label>
</div>
</div>
</div> -->
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
import { mapState } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
},
// data: function() {
// return {
// selected: "table"
// }
// },
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
return this.selected == "table" ? ProductDisplay : ProductEditor;
}
}
}
</script>
Listing 21-10Using the Data Store in the App.vue File in the src Folder
剩下的工作就是调用数据存储突变来改变向用户显示的组件。在清单 21-11 中,我已经更新了ProductDisplay
组件,这样当用户点击一个新建或编辑按钮时,就会看到编辑器。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
editProduct(product) {
this.selectProduct(product);
this.selectComponent("editor");
},
createNew() {
this.selectProduct();
this.selectComponent("editor");
},
...mapMutations({
selectProduct: "selectProduct",
selectComponent: "nav/selectComponent",
//editProduct: "selectProduct",
//createNew: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
deleteProduct: "deleteProductAction"
})
},
created() {
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 21-11Adding Navigation in the ProductDisplay.vue File in the src/components Folder
我已经重新定义了editProduct
和createNew
方法,因此它们不再直接映射到selectProduct
数据存储变异。相反,我创建了本地方法,调用selectProduct
和selectComponent
突变来识别将要编辑的对象,然后显示编辑器组件。在清单 21-12 中,我已经更新了ProductEditor
组件,这样一旦用户完成或取消了编辑任务,它就会导航到表格显示。
将导航与其他操作分开
请注意,我在示例应用的组件中处理了导航,而不是将其直接合并到数据存储变异中,如selectProduct
。将导航作为其他状态变化的一部分来执行可能很诱人,但是这是假设当执行给定的突变或动作时,组件将总是显示。当您开始动态显示组件时可能是这种情况,但随着项目变得更加复杂,用户会看到应用功能的不同路径,这种情况经常会发生变化。在每个组件中执行导航可能看起来更复杂,但它会使应用更容易适应新功能。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$store.commit("nav/selectComponent", "table");
this.product = {};
},
cancel() {
this.$store.commit("selectProduct");
this.$store.commit("nav/selectComponent", "table");
},
selectProduct(selectedProduct) {
if (selectedProduct == null) {
this.editing = false;
this.product = {};
} else {
this.editing = true;
this.product = {};
Object.assign(this.product, selectedProduct);
}
}
},
created() {
unwatcher = this.$store.watch(state =>
state.selectedProduct, this.selectProduct);
this.selectProduct(this.$store.state.selectedProduct);
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 21-12Adding Navigation in the ProductEditor.vue File in the src/components Folder
该组件直接使用数据存储功能,而不使用 Vuex 映射功能。我在save
方法中添加了async
关键字,这允许我在保存产品时使用await
关键字,这样在 HTTP 操作完成之前不会执行导航。
结果是用户的动作自动选择将要显示的组件,自动在表格和编辑器之间切换,如图 21-5 所示。
图 21-5
在组件之间导航
使用异步组件
在较大的应用中,通常有些功能不是所有用户都需要的,或者只是偶尔需要,例如高级设置或管理工具。默认情况下,这些未使用的组件包含在发送给浏览器的 JavaScript 包中,这会浪费带宽并增加应用启动的时间。为了避免这个问题,Vue.js 提供了异步组件特性,用于将组件的加载推迟到需要的时候——这个特性也被称为延迟加载。为了演示异步组件特性,我在src/components
文件夹中添加了一个名为DataSummary.vue
的文件,其内容如清单 21-13 所示。
<template>
<div>
<h3 class="bg-success text-center text-white p-2">
Summary
</h3>
<table class="table">
<tr><th>Number of Products:</th><td> {{ products.length}} </td></tr>
<tr><th>Number of Categories:</th><td> {{ categoryCount }} </td></tr>
<tr>
<th>Highest Price:</th><td> {{ highestPrice | currency }} </td>
</tr></table>
</div>
</template>
<script>
import { mapState, } from "vuex";
export default {
computed: {
...mapState(["products"]),
categoryCount() {
if (this.products.length > 0) {
return this.products.map(p => p.category)
.filter((cat, index, arr) => arr.indexOf(cat)
== index).length;
} else {
return 0;
}
},
highestPrice() {
if (this.products.length == 0) {
return 0;
} else {
return Math.max(...this.products.map(p => p.price));
}
}
},
filters: {
currency(value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD" }).format(value);
}
}
}
</script>
Listing 21-13The Contents of the DataSummary.vue File in the src/components Folder
该组件显示商店中数据的摘要,对于本章来说,这是一个我不希望浏览器在需要时才加载的特性。在清单 21-14 中,我已经注册了新的组件,因此它将被延迟加载。
了解延迟加载的成本
术语 lazy 指的是组件直到被需要时才会从 HTTP 服务器加载。这与默认的急切加载策略形成对比,在默认策略中,组件作为主应用包的一部分被加载,即使它们可能并不需要。
这两种方法都代表了一种妥协。急切加载需要更大的初始下载量,并以带宽和启动速度换取更流畅的用户体验,因为用户可能需要的所有代码和内容总是可用的。延迟加载减少了初始下载的大小,但是如果需要的话,需要对组件进行额外的 HTTP 请求。
您可以根据您对应用使用方式的预期来初步评估哪些组件应该延迟加载,但是一旦您部署了应用,验证您的预期是非常重要的。如果您发现大多数用户都在执行延迟加载组件的操作,那么您应该更改应用的配置,因为这两种方法都有不利的一面,这样就不会节省带宽,并且用户必须等待延迟加载的执行。
<template>
<div class="container-fluid">
<div class="col">
<div class="col text-center m-2">
<button class="btn btn-primary"
v-on:click="selectComponent('table')">
Standard Features
</button>
<button class="btn btn-success"
v-on:click="selectComponent('summary')">
Advanced Features
</button>
</div>
</div>
<div class="row">
<div class="col">
<component v-bind:is="selectedComponent"></component>
</div>
</div>
</div>
</template>
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
const DataSummary = () => import("./components/DataSummary");
import { mapState, mapMutations } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay, DataSummary },
created() {
this.$store.dispatch("getProductsAction");
},
methods: {
...mapMutations({
selectComponent: "nav/selectComponent"
})
},
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
switch (this.selected) {
case "table":
return ProductDisplay;
case "editor":
return ProductEditor;
case "summary":
return DataSummary;
}
}
}
}
</script>
Listing 21-14Lazily Loading a Component in the App.vue File in the src/components Folder
清单中有许多变化,但这一变化告诉 Vue.js,这是一个应该只在需要时才加载的组件:
...
const DataSummary = () => import("./components/DataSummary");
...
关键字import
的标准用法创建了一个静态依赖,webpack 通过将组件包含在它创建的 JavaScript 包中来处理这个静态依赖。但是这种形式的import
,其中它被用作一个函数,组件被指定为参数,创建了一个动态依赖,webpack 通过将组件放入它自己的包中来处理它。import
函数的结果是一个 JavaScript Promise
,当组件被加载时,这个 JavaScript 就会被执行。一旦调用了import
函数,加载过程就开始了,所以分配组件引用是很重要的,在这个例子中是DataSummary
,这个函数又调用了import
。
...
const DataSummary = () => import("./components/DataSummary");
...
当选择DataSummary
与is
属性一起使用时,Vue.js 检测该函数,这表示一个异步组件。调用该函数来启动加载过程,一旦完成就显示组件。
禁用预取提示
默认情况下,项目被配置为向浏览器提供预取提示,指示将来可能需要有应用内容。这个特性的目的是让浏览器决定在需要之前获取内容是否有意义。这往往会破坏 Vue.js 模块的惰性加载的想法,因为 JavaScript 文件将被下载,然后在不使用的情况下被丢弃,而这可能是少数用户所需要的。为了禁用预取提示特性,我在productapp
文件夹中添加了一个名为vue.config.js
的文件,并添加了清单 21-15 中所示的语句。
module.exports = {
chainWebpack: config => {
config.plugins.delete('prefetch');
}
}
Listing 21-15Disabling Prefetch Hints in the vue.config.js File in the productapp Folder
要应用配置更改,请停止开发工具,并通过运行productapp
文件夹中清单 21-16 中所示的命令来重新启动它们。
npm run serve
Listing 21-16Starting the Vue.js Development Tools
为了测试异步组件,导航到http://localhost:8080
并点击高级功能按钮,这将显示清单 21-13 中定义的组件,如图 21-6 所示。
图 21-6
延迟加载组件
您不太可能在加载组件时看到任何延迟,因为浏览器和 HTTP 服务器运行在同一个工作站上。但是如果你打开浏览器的 F12 开发工具,切换到网络选项卡,当你点击高级功能按钮时,你会看到有一个对 JavaScript 文件的 HTTP 请求。这是包含DataSummary
组件的文件,对我来说它叫做1.js
,尽管你可能会看到一个不同的名字。
配置延迟加载
Vue.js 提供了一组配置选项,可用于微调异步组件的加载过程,如表 21-3 所述。
表 21-3
惰性加载配置选项
|名字
|
描述
|
| --- | --- |
| component
| 该属性用于定义将加载异步组件的import
函数。 |
| loading
| 此属性用于指定在加载过程中向用户显示的组件。 |
| delay
| 该属性用于指定在向用户显示loading
组件之前的延迟,以毫秒表示。默认值为 200。 |
| error
| 此属性用于指定在加载操作失败或超时时向用户显示的组件。 |
| timeout
| 此属性用于指定加载操作的超时时间,以毫秒为单位。默认值永远等待。 |
这些配置选项中最有用的是loading
属性,它指定在加载异步组件时应该向用户显示的组件,以及delay
选项,它指定在向用户显示加载组件之前的延迟,并确保用户不会看到快速完成的操作的加载消息。
警告
您不应该使用loading
属性来指定延迟加载的组件,因为它可能在需要的时候还没有被加载。
为了准备演示表 21-3 中描述的属性,我需要一个可以在加载过程中显示的组件,所以我在src/components
文件夹中添加了一个名为LoadingMessage.vue
的文件,其内容如清单 21-17 所示。
<template>
<h3 class="bg-info text-white text-center m-2 p-2">
Lazily Loading Component...
</h3>
</template>
Listing 21-17The Contents of the LoadingMessage.vue File in the src/components Folder
该组件只包含一个向用户显示消息的模板。在清单 21-18 中,我已经使用这个组件来配置DataSummary
组件的延迟加载。
...
<script>
import ProductDisplay from "./components/ProductDisplay";
import ProductEditor from "./components/ProductEditor";
import ErrorDisplay from "./components/ErrorDisplay";
import LoadingMessage from "./components/LoadingMessage";
const DataSummary = () => ({
component: import("./components/DataSummary"),
loading: LoadingMessage,
delay: 100
});
import { mapState, mapMutations } from "vuex";
export default {
name: 'App',
components: { ProductDisplay, ProductEditor, ErrorDisplay, DataSummary },
created() {
this.$store.dispatch("getProductsAction");
},
methods: {
...mapMutations({
selectComponent: "nav/selectComponent"
})
},
computed: {
...mapState({
selected: state => state.nav.selected
}),
selectedComponent() {
switch (this.selected) {
case "table":
return ProductDisplay;
case "editor":
return ProductEditor;
case "summary":
return DataSummary;
}
}
}
}
</script>
...
Listing 21-18Configuring Lazy Loading in the App.vue File in the src Folder
component
属性被赋予了加载组件的import
函数,我已经指定如果加载操作超过 100 毫秒,用户将看到LoadingMessage
组件。要查看效果,导航到http://localhost:8080
并点击高级功能按钮,这将产生如图 21-7 所示的加载序列。
注意
在开发过程中很难测试配置特性,因为异步组件加载得太快,以至于看不到由component
属性指定的组件。我的方法是使用 Google Chrome 开发工具创建一个网络配置文件,这会给 HTTP 请求增加几秒钟的延迟,并在触发延迟加载之前启用这个配置文件。
图 21-7
配置延迟加载过程
将组件组合成一个共享包
默认情况下,每个异步组件都将被放入自己的文件中,只有在需要时才加载。另一种方法是对相关组件进行分组,以便在第一次需要它们中的任何一个时,它们都被加载。这是通过添加 webpack 在构建过程中检测到的注释来实现的,如下所示:
...
const DataSummary = () => ({
component:
import(/* webpackChunkName: "advanced" */ "./components/DataSummary"),
loading: LoadingMessage,
delay: 100
});
...
该注释设置了一个名为webpackChunkName
的属性的值,webpack 会将所有具有相同webpackChunkName
值的异步组件打包成一个包。您指定的名称不会用作包文件的名称,包文件是在构建过程中动态选择的。
摘要
在这一章中,我演示了如何动态显示组件。我向您展示了如何使用is
属性来选择一个组件,这对于更简单的项目来说是一个很好的方法。我还向您展示了如何按需加载组件,这是处理并非所有用户都需要的组件的好方法。在下一章,我将介绍 URL 路由,它建立在本章描述的特性之上。
二十二、URL 路由
在这一章中,我开始描述 URL 路由特性,它建立在我在第二十一章中描述的动态组件的基础上,但是使用当前的 URL 来选择向用户显示的组件。URL 路由是一个复杂的话题,我将在第二十三章和第二十四章中继续描述这个特性的不同方面。表 22-1 将 URL 路由放在上下文中。
表 22-1
将 URL 路由置于上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | URL 路由根据当前 URL 选择要向用户显示的组件。 |
| 为什么有用? | 使用 URL 选择组件允许用户直接导航到应用的特定部分,并允许以比在代码中选择组件更容易维护的方式来组成复杂的应用。 |
| 如何使用? | Vue Router 包被添加到一个项目中,一个router-view
元素被用来显示应用的路由配置所选择的组件。 |
| 有什么陷阱或限制吗? | 可能很难在简明表达路线和创建易于阅读和理解的路线之间找到适当的平衡。 |
| 有其他选择吗? | URL 路由是可选的,只有复杂的应用才需要本章描述的功能。 |
表 22-2 总结了本章内容。
表 22-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 配置 URL 路由 | 创建一个VueRouter
对象,并为其提供一个具有routes
属性的配置对象 | 4, 5 |
| 显示布线元件 | 使用router-view
元素 | six |
| 在代码中导航 | 使用$router.push
方法 | seven |
| 在模板中导航 | 使用路由器链接元素 | eight |
| 配置路由模式 | 创建VuewRouter
对象时使用mode
属性 | nine |
| 定义一个总括路线 | 以*
为路径定义路线 | Ten |
| 定义路线的别名 | 使用 route 属性 | Eleven |
| 用代码获取路线的详细信息 | 使用$route
对象 | 12–14 |
| 控制路由匹配的 URL | 添加动态段、使用正则表达式或使用可选段 | 15–20 |
| 为路线指定名称 | 使用name
属性 | 21–23 |
| 当活动路线改变时接收通知 | 实现一个或多个保护方法 | Twenty-four |
为本章做准备
在本章中,我继续使用第二十一章的 productapp 项目。URL 路由特性需要将名为vue-router
的包添加到项目中。运行productapp
文件夹中清单 22-1 所示的命令来安装包。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
npm install vue-router@3.0.1
Listing 22-1Installing the Routing Package
要启动 RESTful web 服务,打开命令提示符并运行清单 22-2 中的命令。
npm run json
Listing 22-2Starting the Web Service
打开第二个命令提示符,导航到productapp
目录,运行清单 22-3 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 22-3Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里你将看到示例应用,如图 22-1 所示。
图 22-1
运行示例应用
URL 路由入门
在我进入如何使用和配置 URL 路由的细节之前,我将提供一个主要特性的快速介绍,以便您对更详细的主题有一些背景。开始使用 URL 路由的第一步是配置一组路由,它们是应用将支持的 URL 和每个 URL 将显示的组件之间的映射。惯例是将路由配置放在一个名为router
的文件夹中,所以我在示例项目中创建了src/router
文件夹,并在其中添加了一个名为index.js
的文件,代码如清单 22-4 所示。
小费
当您创建一个项目并选择单个特性时,其中一个选项是 Router,它安装vue-router
包并设置一个基本的路由配置。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor}
]
})
Listing 22-4The Contents of the index.js File in the src/router Folder
在设置路由包时,获得正确的基本配置是很重要的,所以我将仔细检查清单 22-4 中的每一行代码并解释它的用途,就像我在第二十章中设置 Vuex 数据存储时所做的一样,它遵循类似的模式。第一组语句从其他模块导入路由配置所需的功能。
...
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
...
前两个import
语句针对 Vue.js 和 Vue 路由器功能。其他的import
语句提供了对将要显示给用户的组件的访问,这是一个复杂应用中的一长串语句。
下一条语句启用 Vue 路由器功能:
...
Vue.use(VueRouter);
...
Vue 路由器是作为 Vue.js 插件提供的,它允许 Vue.js 核心功能被扩展,正如我在第二十六章中描述的,插件是用Vue.use
方法安装的。
警告
如果你忘记调用Vue.use
方法,Vue.js 将不会识别 Vue 路由器包使用的router-view
和router-link
HTML 元素。
下一条语句创建路由配置,并使其成为从routes
文件夹中的模块的默认导出:
...
export default new VueRouter({
...
关键字new
用于创建一个VueRouter
对象,它接受一个配置对象。本例中的配置提供了如何显示两个组件的一组基本指令。
...
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor}
]
...
属性用来定义 URL 和组件之间的映射。本例中的映射告诉 Vue Router 为应用的默认路由显示ProductDisplay
组件,为/edit
URL 显示ProductEditor
组件。现在不要担心 URL 路由,因为一旦你看到它们是如何被应用和使用的,它们会更容易理解。
提供对路由配置的访问
下一步是向应用添加路由功能,以便组件可以使用它,如清单 22-5 所示。
import Vue from 'vue'
import App from './App.vue'
import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
import { RestDataSource } from "./restDataSource";
import store from "./store";
import router from "./router";
Vue.config.productionTip = false
new Vue({
render: h => h(App),
data: {
eventBus: new Vue()
},
store,
router,
provide: function () {
return {
eventBus: this.eventBus,
restDataSource: new RestDataSource(this.eventBus)
}
}
}).$mount('#app')
Listing 22-5Enabling the Routing Configuration in the main.js File in the src Folder
为了将 URL 路由功能添加到应用中,我使用了一个import
语句来指定router
模块,并将其命名为router
(我不必指定index.js
文件,因为这是导入模块时查找的默认名称)。import
语句从router
文件夹中的index.js
文件加载路由配置。我向Vue
配置对象添加了一个router
属性,这使得应用的组件可以使用 URL 路由功能。
警告
如果您忘记添加清单 22-5 中所示的router
属性,URL 路由包将无法正确设置,您将在下面的示例中遇到错误。
使用布线系统显示元件
既然路由系统已经启用,我可以使用它向用户显示组件,如清单 22-6 所示,在这里我使用 URL 路由来替换现有的内容和动态显示组件的代码。
<template>
<div class="container-fluid">
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
// import ProductDisplay from "./components/ProductDisplay";
// import ProductEditor from "./components/ProductEditor";
// import ErrorDisplay from "./components/ErrorDisplay";
//import { mapState } from "vuex";
export default {
name: 'App',
// components: { ProductDisplay, ProductEditor, ErrorDisplay },
created() {
this.$store.dispatch("getProductsAction");
},
// computed: {
// ...mapState({
// selected: state => state.nav.selected
// }),
// selectedComponent() {
// return this.selected == "table" ? ProductDisplay : ProductEditor;
// }
// }
}
</script>
Listing 22-6Using URL Routing in the App.vue File in the src Folder
Vue 路由器使用router-view
元素来显示内容,取代了之前使用is
属性的元素。因为 Vue 路由器包将负责选择由router-view
元素显示的组件,所以我能够简化组件的配置对象,删除components
属性、计算属性和所有的import
语句。这些变化的结果是由元素router-view
呈现的内容的责任被委托给 Vue 路由器,产生如图 22-2 所示的结果。(Create New 和 Edit 按钮还没有效果,但是我将很快连接它们。)
小费
添加和更改路由功能时,您可能并不总能得到预期的响应。如果发生这种情况,首先要做的是重新加载浏览器,以获得应用的新副本,这通常会解决问题。
图 22-2
使用 Vue 路由器包
这可能看起来像前面的例子,但有一个重要的区别,这可以通过检查浏览器的 URL 栏看出。当您导航到http://localhost:8080
时,浏览器实际上会显示以下 URL:
http://localhost:8080/#/
需要注意的重要部分是 URL 的最后一部分,也就是#/
。仔细编辑浏览器 URL 栏中的 URL,以导航到此 URL:
http://localhost:8080/#/edit
这与之前显示的 URL 相同,但是在末尾附加了edit
。按回车键,浏览器显示的内容会改变,如图 22-3 所示。
图 22-3
更改 URL 的效果
Vue Router 没有使用数据存储属性来选择向用户显示的组件,而是使用浏览器的 URL,URL 中跟在#
字符后面的部分对应于我在清单 22-4 中定义的配置。
...
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor}
]
...
由path
属性指定的值对应于 URL 中跟在#
字符后面的部分。Vue Router 监控当前 URL,当它发生变化时,通过根据其path
属性找到相应的routes
配置项,并显示其 component 属性指定的组件,来选择组件显示在router-view
元素中。
导航到不同的 URL
要更改显示给用户的组件,我必须更改浏览器的 URL,此时 Vue Router 会将新的 URL 与其配置进行比较,并显示相应的组件。Vue 路由器提供了导航到新 URL 的特性,在清单 22-7 中,我已经更新了ProductDisplay
组件来使用它们。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
editProduct(product) {
this.selectProduct(product);
//this.selectComponent("editor");
this.$router.push("/edit");
},
createNew() {
this.selectProduct();
//this.selectComponent("editor");
this.$router.push("/edit");
},
...mapMutations({
selectProduct: "selectProduct",
//selectComponent: "nav/selectComponent",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
deleteProduct: "deleteProductAction"
})
},
created() {
this.setEditButtonColor(false);
this.setDeleteButtonColor(false);
}
}
</script>
...
Listing 22-7Navigating Programatically in the ProductDisplay.vue File in the src/components Folder
在清单 22-7 中添加router
属性的效果是,应用中的所有组件都可以通过$router
变量访问 Vue 路由器特性,类似于组件使用$store
属性访问 Vuex 数据存储的方式。$router
属性返回一个定义表 22-3 中描述的导航方法的对象。
表 22-3
Vue 路由器导航方法
|名字
|
描述
|
| --- | --- |
| push(location)
| 此方法导航到指定的 URL。此方法接受可选的回调参数,这些参数在导航完成或出现错误时被调用。 |
| replace(location)
| 该方法执行与push
方法相同的任务,但不会在浏览器的历史记录中留下条目,如表后所述。 |
| back()
| 此方法导航到浏览器历史记录中的上一个 URL。 |
| forward()
| 此方法导航到浏览器历史记录中的下一个 URL。 |
这两种方法的区别在于,使用push
方法执行的导航会在浏览器的历史记录中产生一个条目,其效果是单击浏览器的后退按钮将返回到以前的路线。replace
方法改变 URL 而不添加到浏览器的历史中,这意味着向后移动可能会导致浏览器离开 Vue.js 应用。在清单中,我禁用了应用于数据存储突变的selectComponent
方法,并将其替换为对 Vue 路由器推送方法的调用,以导航到/edit
URL。
...
this.$router.push("/edit");
...
结果是,单击“新建”按钮或“编辑”按钮会告诉 Vue Router 让浏览器导航到#/edit
URL。URL 路由是一个两阶段的过程。push
方法改变 URL,Vue Router 观察并使用它来改变显示给用户的组件。换句话说,点击一个按钮会改变 URL,进而改变显示给用户的组件,如图 22-4 所示。
图 22-4
在应用中导航
使用 HTML 元素导航
$router
方法并不是在应用中导航的唯一方式。Vue Router 还支持一个定制的 HTML 元素,当它被点击时触发导航。$router
方法和自定义 HTML 元素可以在一个组件中自由混合,如清单 22-8 所示。
<template>
<div>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<router-link to="/" class="btn btn-secondary">Cancel</router-link>
</div>
</div>
</template>
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
//this.$store.commit("nav/selectComponent", "table");
this.$router.push("/");
this.product = {};
},
// cancel() {
// this.$store.commit("selectProduct");
// //this.$store.commit("nav/selectComponent", "table");
// this.$router.push("/");
// },
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 22-8Adding URL Navigation in the ProductEditor.vue File in the src/components Folder
在组件的模板中,我使用了router-link
元素来创建一个 HTML 元素,当它被点击时将触发导航。将被导航到的 URL 是使用to
属性指定的,如下所示:
...
<router-link to="/" class="btn btn-secondary">Cancel</router-link>
...
在这种情况下,我指定了/
,这是配置中定义的第一条路由。当模板被处理并显示给用户时,结果是一个锚(a)
元素,如下所示:
...
<a href="#/" class="btn btn-secondary router-link-active">Cancel</a>
...
指定了href
属性,使得浏览器导航到的 URL 相对于 URL 的#
部分,尽管您不应该在to
属性中指定这一点,因为有其他方法可以实现 URL 导航,正如我很快解释的那样。我在本书中使用的引导 CSS 框架支持样式锚元素,因此它们显示为按钮,这允许我将路由链接呈现为它所替换的button
元素的无缝替换。由于 URL 导航是由 anchor 元素直接处理的,我已经能够移除cancel
方法。
并不是所有的导航都可以仅仅使用 HTML 元素来执行,因为一些额外的任务必须响应用户的动作来执行,正如save
方法所展示的。组件模板中的保存/创建按钮不能用router-link
元素替换,因为用户输入的数据必须发送到 web 服务。对于这种类型的活动,可以使用$router.push
方法,如清单所示。清单 22-8 中的更改允许用户从编辑器导航回表格,或者通过点击保存/创建按钮来保存他们的更改,或者点击取消来放弃它们,如图 22-5 所示。
图 22-5
导航回产品显示
了解和配置 URL 路由匹配
上一节中的例子已经简要介绍了 URL 路由的工作原理,包括定义路由和在应用中导航的不同方式。在接下来的章节中,我会更深入地探讨并解释如何改变 URL 的格式以使它们对用户更友好,以及表达路线以匹配 URL 的不同方式。
了解 URL 匹配和格式
Vue 路由器检查当前 URL,并通过其配置中的路由列表向下查找,直到找到匹配项。要匹配路由,URL 必须包含相同数量的段,并且每个段必须包含路由配置中指定的值。下面是我在清单 22-4 的src/router
文件夹的index.js
文件中定义的路线:
...
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor}
]
...
在考虑应用的路由时,请记住路由是按照定义的顺序匹配的,路由系统只对 URL 的一部分感兴趣。默认情况下,路由系统使用的 URL 部分跟在#
字符后面,称为 URL片段或名为锚的。URL http://localhost:8080/#/edit
将匹配路径为/edit
的路由,如图 22-6 所示。
图 22-6
URL 的片段部分
URL 片段最初是指 HTML 文档中的特定位置,以便用户可以在复杂的静态内容中导航。对于 Vue.js 应用,URL 片段用于路由,因为它们可以被更改,而不会导致浏览器向服务器发送 HTTP 请求并丢失应用的状态。
使用 HTML5 历史 API 进行路由
所有浏览器都支持 URL 片段,但结果是应用的 URL 具有奇怪的结构,可能会让用户感到困惑。使用 URL 路由的吸引力之一是用户可以通过改变 URL 直接导航到应用的特定部分,但是如果不理解#
字符的重要性,这可能是一个容易出错的过程。例如,如果用户导航到/edit
而不是/#/edit
,浏览器将假设用户正试图导航到一个新的 URL,并将向服务器发送 HTTP 请求,结果浏览器将导航离开 Vue.js 应用。
一个更好的替代方案是配置 Vue Router,使其使用 HTML 5 历史 API,这允许更健壮和优雅的 URL,但旧浏览器不支持,尽管 Vue Router 会自动退回到使用不支持历史 API 的浏览器中的片段。在清单 22-9 中,我已经更新了路由配置,这样 Vue 路由器将使用历史 API。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor}
]
})
Listing 22-9Enabling the History API in the index.js File in the src/router Folder
使用表 22-4 中显示的值,将mode
属性添加到 Vue 路由器配置对象中,以指定用于 URL 路由的机制。
表 22-4
模式属性配置值
|名称
|
描述
|
| --- | --- |
| hash
| 这种模式使用 URL 片段进行路由,这提供了最广泛的浏览器支持,但产生了笨拙的 URL。如果未指定mode
属性,这是使用的默认模式。 |
| history
| 这种模式使用历史 API 进行路由,这提供了最自然的 URL,但不被旧的浏览器支持。 |
在清单中,我为mode
属性指定了history
值,它告诉 Vue Router 使用历史 API。要查看效果,重新加载浏览器并在应用重新加载后点击其中一个编辑按钮,如图 22-7 所示。
小费
对于本章中的许多示例,您可能需要重新加载浏览器窗口或手动导航到http://localhost:8080
来查看更改。
图 22-7
使用历史 API 进行路由
应用导航到了http://localhost:8080/edit
,而不是包含片段的 URL,比如http://localhost:8080/#/edit
。历史 API 允许使用完整的 URL 进行导航,无需触发浏览器重新加载,其效果是用于路由的 URL 部分发生变化,如图 22-8 所示。
图 22-8
使用历史 API 的效果
注意
对于不支持历史 API(包括旧版本的 Internet Explorer)的浏览器,Vue Router 将自动尝试使用哈希设置,这将使应用返回到使用 URL 片段来定义路由。要禁用这种行为,您可以在路由配置对象中将fallback
属性设置为false
。
提供一条包罗万象的路线
使用历史 API 需要额外的工作来确保用户的流畅体验,用户可以直接导航到对应用有意义的 URL,但是服务器上没有该 URL 的 HTML 文档。在示例应用中,这意味着用户可以在地址栏中键入http://localhost:8080/edit
,这将导致浏览器发送一个对名为edit
的文档的 HTTP 请求。服务器上没有这样的 HTML 文档,但是 webpack 开发服务器已经配置为使用index.html
文件响应任何请求。
请求了什么 URL 并不重要;如果没有可用的内容,服务器将总是返回index.html
文件的内容,并且永远不会返回 404-Not Found 错误。当用户请求一个与应用定义的某个路由相对应的 URL 时,这很有用,比如示例应用中的/edit
,但是当 URL 与某个路由不相对应时,它会让用户看到一个空窗口。
警告
如果您使用历史 API 进行路由,您必须确保配置您的生产 HTTP 服务器来返回index.html
文件的内容。Vue.js 团队在 https://router.vuejs.org/en/essentials/history-mode.html
提供常用生产服务器的说明。
为了解决这个问题,可以定义一个无所不包的路由来匹配任何请求并将其重定向到另一个 URL,如清单 22-10 所示
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay },
{ path: "/edit", component: ProductEditor},
{ path: "*", redirect: "/" }
]
})
Listing 22-10Creating a Catchall Route in the index.js File in the src/router Folder
这个路由的path
属性是一个星号(*
字符),这允许它匹配任何 URL。这个路由没有component
属性,而是有一个redirect
属性,它告诉 Vue.js 执行到指定 URL 的重定向。总体效果与 HTTP 服务器的回退功能相结合,这意味着没有内容的请求将使用index.html
文件的内容来处理,如果 URL 不对应于某个路由,浏览器将被重定向到/
URL。为了测试这个特性,打开一个新的浏览器窗口并请求http://localhost:8080/does/not/exist
URL。如图 22-9 所示,浏览器将显示应用,即使请求的 URL 与应用的任何路由都不对应。
小费
重定向也可以定义为一个函数,它允许将用户请求的 URL 的某些方面合并到重定向的 URL 中。参见第二十三章中的示例。
图 22-9
总括路线的效果
当 Vue.js 应用启动时,Vue Router 检查当前的 URL,并开始通过它的路由寻找匹配。第一个和第二个路由确实与当前 URL 匹配,因为该 URL 既不是/
也不是/edit
。Vue 路由器到达最终的 URL,它匹配任何内容并导致重定向到/
。匹配过程再次开始,但是现在第一条路线匹配了,这导致应用显示ProductDisplay
组件。
使用路线别名
使用重定向的一个潜在缺陷是,用户将看到他们在浏览器中键入的 URL 将立即被重定向的 URL 所替换,这可能会令人困惑。另一种方法是为一个路由创建一个别名,这允许它匹配多个 URL 而不需要重定向。在清单 22-11 中,我在路由配置中添加了一个别名,以改变不匹配 URL 的处理方式。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay, alias: "/list" },
{ path: "/edit", component: ProductEditor } ,
{ path: "*", redirect: "/" }
]
})
Listing 22-11Using a Route Alias in the index.js File in the src/router Folder
属性用于为一个路由创建一个别名,这允许它在不使用重定向的情况下匹配多个 URL。我创建的别名将/list
URL 定义为根 URL 的别名,可以通过导航到http://localhost:8080/list
进行测试。如图 22-10 所示,显示ProductDisplay
组件,不修改当前 URL。
图 22-10
使用路由别名
获取组件中的路由数据
除了$router
属性之外,Vue Router 包还为组件提供了一个$route
属性,该属性描述了当前的路由,可用于调整组件的内容或行为。为了演示,我在示例应用中添加了一条新路线,如清单 22-12 所示。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay, alias: "/list" },
{ path: "/edit", component: ProductEditor },
{ path: "/create", component: ProductEditor },
{ path: "*", redirect: "/" }
]
})
Listing 22-12Adding a Route in the index.js File in the src/router Folder
新的路由匹配/create
URL 并指向ProductEditor
组件,这意味着有两个不同的 URL——/edit
和/create
——将引导应用显示编辑器特性。在清单 22-13 中,我用一个针对/edit
和/create
URL 的router-link
元素替换了ProductDisplay
组件模板中的button
元素。
...
<template>
<div>
<table class="table table-sm table-bordered" v-bind:class="tableClass">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<router-link to="/edit" v-bind:class="editClass"
class="btn btn-sm">
Edit
</router-link>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<router-link to="/create" class="btn btn-primary">
Create New
</router-link>
</div>
</div>
</template>
...
Listing 22-13Targeting Routes in the ProductDisplay.vue File in the src/components Folder
注意,组件不知道导航到/edit
或/create
URL 的结果会是什么。这类似于使用数据存储来协调组件的效果,如第二十一章中所述,其优点是您可以通过编辑路由配置来轻松地重新配置应用。现在有两个不同的 URL 显示了ProductEditor
组件,我可以使用$route
属性来查看它们中的哪一个被使用了,并更改呈现给用户的内容,如清单 22-14 所示。
<template>
<div>
<h3 class="btn-primary text-center text-white p-2">
{{ editing ? "Edit" : "Create"}}
</h3>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<router-link to="/" class="btn btn-secondary">Cancel</router-link>
</div>
</div>
</template>
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$router.push("/");
this.product = {};
},
selectProduct(selectedProduct) {
if (this.$route.path == "/create") {
this.editing = false;
this.product = {};
} else {
this.product = {};
Object.assign(this.product, selectedProduct);
this.editing = true;
}
}
},
created() {
unwatcher = this.$store.watch(state =>
state.selectedProduct, this.selectProduct);
this.selectProduct(this.$store.state.selectedProduct);
},
beforeDestroy() {
unwatcher();
}
}
</script>
Listing 22-14Accessing Route Information in the ProductEditor.vue File in the src/components Folder
当调用selectProduct
方法时,组件检查$route
对象以获得路线的细节。$route
属性被赋予一个描述当前路线的对象。表 22-5 描述了$route
对象提供的最有用的属性。
表 22-5
有用的 route 属性
|名字
|
描述
|
| --- | --- |
| name
| 该属性返回路由的名称,如“创建命名路由”一节所述。 |
| path
| 这个属性返回 URL 路径,比如/edit/4
。 |
| params
| 该属性返回由动态路由匹配的参数的 map 对象,如“动态匹配路由”一节中所述。 |
| query
| 此属性返回包含查询字符串值的 map 对象。例如,对于 URL /edit/4?validate=true
,查询属性将返回一个带有validate
属性的对象,该属性的值为true
。 |
在清单中,我使用path
属性来确定是否使用了/create
或/edit
URL,并相应地配置组件。为了使区别更加明显,我添加了一个h3
元素,它使用文本插值绑定来显示标题。为了测试这些更改,您可以单击由ProductDisplay
组件显示的新建或编辑按钮,或者直接导航到http://localhost:8080/create
和http://localhost:8080/edit
URL,这将产生如图 22-11 所示的结果。
图 22-11
响应组件中的不同路线
动态匹配路线
我在上一节中所做的修改的问题是,/edit
URL 告诉ProductEditor
组件用户想要执行编辑操作,但是没有指定应该编辑哪个对象。
我可以使用一个动态段来解决这个问题,当一个组件需要从 URL 接收数据时,它会被添加到一个路由中。在清单 22-15 中,我扩展了路由配置,使其包含一个动态段。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay, alias: "/list" },
{ path: "/edit/:id", component: ProductEditor },
{ path: "/create", component: ProductEditor },
{ path: "*", redirect: "/" }
]
})
Listing 22-15Using a Dynamic Segment in the index.js File in the src/router Folder
段变量通过在段前加冒号(:
字符)来定义,如下所示:
...
{ path: "/edit/:id", component: ProductEditor },
...
该路线的path
属性包含两段。第一段匹配以/edit
开头的 URL,就像前面的例子一样。第二段是动态的,将匹配任何 URL 段,并将该段的值赋给一个名为id
的变量。结果是该路由将匹配任何包含两段的 URL,其中第一段是/edit
,例如/edit/10
。
为了定位新的 URL 及其动态段,我更新了ProductDisplay
组件模板中的router-link
元素,如清单 22-16 所示。
...
<template>
<div>
<table class="table table-sm table-bordered" v-bind:class="tableClass">
<tr>
<th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<router-link v-bind:to="'/edit/' + p.id "
v-bind:class="editClass" class="btn btn-sm">
Edit
</router-link>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<router-link to="/create" class="btn btn-primary">
Create New
</router-link>
</div>
</div>
</template>
...
Listing 22-16Targeting a Dynamic Segment in the ProductDisplay.vue File in the src/components Folder
这一变化意味着单击编辑按钮将导航到包含相应对象的id
属性的 URL,例如,单击体育场产品的按钮将导航到/edit/5
。
组件可以通过$route.params
属性访问动态段变量。在清单 22-17 中,我已经更新了ProductEditor
组件,以便根据id
动态段的值从数据存储中检索产品对象。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$router.push("/");
this.product = {};
},
selectProduct() {
if (this.$route.path == "/create") {
this.editing = false;
this.product = {};
} else {
let productId = this.$route.params.id;
let selectedProduct
= this.$store.state.products.find(p => p.id == productId);
this.product = {};
Object.assign(this.product, selectedProduct);
this.editing = true;
}
}
},
created() {
unwatcher = this.$store.watch(state => state.products,
this.selectProduct);
this.selectProduct();
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 22-17Using a Dynamic Segment Value in the ProductEditor.vue File in the src/components Folder
在selectProduct
方法中,我通过$route
对象获取id
段的值,并通过数据存储获取用户选择的对象。
这可能很难发现,但是我也在这个清单中更改了数据存储观察器的目标。ProductDisplay
组件不再为用户的编辑选择使用数据存储,这可能会使您认为观察器现在是多余的。然而,观察器仍然是必需的,但是它观察对象的产品数组。
...
unwatcher = this.$store.watch(state => state.products, this.selectProduct);
...
用户现在可以直接导航到一个将编辑一个对象的 URL,结果是在用来自 HTTP 请求的数据填充数据存储之前,将向用户显示ProductEditor
组件,HTTP 请求由App
组件发送到 web 服务。为了确保用户看到他们需要的数据,我使用了一个调用selectProduct
方法的数据存储观察器。
小费
Vue 路由器包提供了一种更优雅的等待数据的方式,我在第二十四章对此进行了描述。
这些更改的效果是,单击由ProductDisplay
组件呈现的编辑按钮之一,导航到一个 URL,如/edit/4
,用户也可以使用浏览器的 URL 栏直接导航到该 URL。
当 URL 被路由系统匹配时,ProductEditor
组件读取动态段的值,从数据存储中定位相应的对象,显示给用户编辑,如图 22-12 所示。
图 22-12
使用动态段
使用正则表达式匹配 URL
动态段扩展了路由将匹配的 URL 的范围,但结果可能是路由匹配您稍后要在路由配置中处理的 URL。为了帮助提高路由的精确度,Vue 路由器支持对动态段使用正则表达式,从而对将要匹配的 URL 提供细粒度控制。
在清单 22-18 中,我修改了路由配置,添加了一个带有正则表达式的动态段,并将正则表达式应用于现有的id
段。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay, alias: "/list" },
{ path: "/:op(create|edit)/:id(\\d+)", component: ProductEditor },
//{ path: "/create", component: ProductEditor },
{ path: "*", redirect: "/" }
]
})
Listing 22-18Using Regular Expressions in the index.js File in the src/router Folder
正则表达式应用于动态段名称后的括号中。新的动态段称为op
,我应用的正则表达式允许它匹配包含create
或edit
的段,这允许我将两条路线合并为一条,并注释掉专用的/create
路线。
...
{ path: "/:op(create|edit)/:id(\\d+)", component: ProductEditor },
...
我应用于id
段的正则表达式将匹配仅由一个或多个数字组成的段。这个表达式中的d
字符必须用两个反斜杠(\
字符)进行转义,以防止它被解释为字面上的d
字符,加号(+
字符)指定表达式应该只匹配一个或多个数字。
...
{ path: "/:op(create|edit)/:id(\\d+)", component: ProductEditor },
...
结果是,该路由现在将匹配两个段 URL,其中第一个段是/create
或/edit
,第二个段包含一个或多个数字。
定义可选段
我在清单 22-18 中定义的路线并不完全如我所愿,因为它与/create
不匹配,而后者是当用户单击 Create New 按钮时ProductDisplay
组件导航到的 URL。可以使用问号(?
字符)将动态段标记为可选的,问号是用于匹配零个或多个表达式实例的符号。在清单 22-19 中,我使用了一个问号来使id
段可选。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/", component: ProductDisplay, alias: "/list" },
{ path: "/:op(create|edit)/:id(\\d+)?", component: ProductEditor },
{ path: "*", redirect: "/" }
]
})
Listing 22-19Using an Optional Segment in the index.js File in the src/router Folder
由于id
段是可选的,该路由现在将匹配任何单段 URL,如/edit
和/create
,以及任何两段 URL,其中第一段是/edit
或/create
,第二段由一个或多个数字组成。
这个路由配置将匹配一个 URL,例如/create/10
,ProductEditor
组件中的现有代码将把它视为一个编辑id
为10
的对象的请求。这显示了当您更改应用的路由配置时更新组件的重要性,在清单 22-20 中,我修改了ProductEditor
组件,通过检查清单 22-19 中引入的新动态段来避免这个问题。
...
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$router.push("/");
this.product = {};
},
selectProduct() {
if (this.$route.params.op == "create") {
this.editing = false;
this.product = {};
} else {
let productId = this.$route.params.id;
let selectedProduct
= this.$store.state.products.find(p => p.id == productId);
this.product = {};
Object.assign(this.product, selectedProduct);
this.editing = true;
}
}
},
created() {
unwatcher = this.$store.watch(state => state.products,
this.selectProduct);
this.selectProduct();
},
beforeDestroy() {
unwatcher();
}
}
</script>
...
Listing 22-20Using a New Segment in the ProductEditor.vue File in the src/components Folder
作为添加到路由配置中的正则表达式的结果,选择了ProductEditor
组件的路由不会匹配像/edit/apples
这样的 URL。相反,路由系统将通过路由配置继续工作,直到到达catch-all
路由,该路由执行到应用根 URL 的重定向,如图 22-13 所示。
图 22-13
在路线中使用正则表达式
创建命名路由
如果您不想将 URL 嵌入到组件的代码和模板中,那么您可以将您的名称分配给您的路由并使用它们。这种方法的优点是很容易改变应用使用的 URL,而不必改变其组件中的所有导航元素和代码,这可以在第二十三章中看到演示。缺点是使用命名路由需要笨拙的语法。在清单 22-21 中,我添加了针对ProductDisplay
和ProductEditor
组件的路线名称。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ name: "table", path: "/", component: ProductDisplay, alias: "/list" },
{ name: "editor", path: "/:op(create|edit)/:id(\\d+)?",
component: ProductEditor },
{ path: "*", redirect: "/" }
]
})
Listing 22-21Naming a Route in the index.js File in the src/router Folder
name
属性用于为一条路线指定一个名称,我已经使用了名称table
和editor
。在清单 22-22 中,我使用了一条路线的名称来导航,而不是它的 URL。
<template>
<div>
<h3 class="btn-primary text-center text-white p-2">
{{ editing ? "Edit" : "Create"}}
</h3>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<router-link v-bind:to="{name: 'table'}" class="btn btn-secondary">
Cancel
</router-link>
</div>
</div>
</template>
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$router.push({name: "table"});
this.product = {};
},
selectProduct() {
if (this.$route.params.op == "create") {
this.editing = false;
this.product = {};
} else {
let productId = this.$route.params.id;
let selectedProduct
= this.$store.state.products.find(p => p.id == productId);
this.product = {};
Object.assign(this.product, selectedProduct);
this.editing = true;
}
}
},
created() {
unwatcher = this.$store.watch(state => state.products,
this.selectProduct);
this.selectProduct();
},
beforeDestroy() {
unwatcher();
}
}
</script>
Listing 22-22Navigating by Route Name in the ProductEditor.vue File in the src/components Folder
为了通过名称导航到一条路线,一个具有name
属性的对象被传递给$router.push
方法或者被分配给一个router-link
元素的to
属性。name
属性的值是所需路线的名称,必须使用v-bind
指令来确保 Vue.js 将属性值解释为 JavaScript 表达式。
如果路线有动态段,那么用于导航的对象定义一个用于提供段值的params
属性。在清单 22-23 中,我已经更改了导航元素和代码,使用名称并提供参数值。
...
<template>
<div>
<table class="table table-sm table-bordered" v-bind:class="tableClass">
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th></th>
</tr>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
<td>
<router-link v-bind:to="{name: 'editor',
params: { op: 'edit', id: p.id}}"
v-bind:class="editClass" class="btn btn-sm">
Edit
</router-link>
<button class="btn btn-sm"
v-bind:class="deleteClass"
v-on:click="deleteProduct(p)">
Delete
</button>
</td>
</tr>
<tr v-if="products.length == 0">
<td colspan="5" class="text-center">No Data</td>
</tr>
</tbody>
</table>
<div class="text-center">
<router-link v-bind:to="{name: 'editor', params: { op: 'create'}}"
class="btn btn-primary">
Create New
</router-link>
</div>
</div>
</template>
...
Listing 22-23Navigating with Parameters in the ProductDisplay.vue File in the src/components Folder
当使用名称并通过router-link
元素提供参数时,需要v-bind
指令;否则,Vue.js 不会将to
属性的值解释为 JavaScript 对象,而是将该值视为 URL。
处理导航更改
当一个路由变更需要新的内容时,现有的组件被销毁,新组件的一个实例被创建,遵循我在第十七章和第二十一章中描述的生命周期。当路由更改显示相同的组件时,Vue 路由器包只是重用现有的组件,并通知它已经发生了更改。为了演示这个问题,我向ProductEditor
组件添加了新的导航特性,允许用户在可供编辑的对象间移动,如清单 22-24 所示。
<template>
<div>
<h3 class="btn-primary text-center text-white p-2">
{{ editing ? "Edit" : "Create"}}
</h3>
<div class="form-group">
<label>ID</label>
<input class="form-control" v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" v-model="product.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model.number="product.price" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="save">
{{ editing ? "Save" : "Create" }}
</button>
<router-link to="{name: 'table'}" class="btn btn-secondary">
Cancel
</router-link>
<router-link v-if="editing" v-bind:to="nextUrl" class="btn btn-info">
Next
</router-link>
</div>
</div>
</template>
<script>
let unwatcher;
export default {
data: function () {
return {
editing: false,
product: {}
}
},
computed: {
nextUrl() {
if (this.product.id != null && this.$store.state.products != null) {
let index = this.$store.state.products
.findIndex(p => p.id == this.product.id);
let target = index < this.$store.state.products.length - 1
? index + 1 : 0
return `/edit/${this.$store.state.products[target].id}`;
}
return "/edit";
}
},
methods: {
async save() {
await this.$store.dispatch("saveProductAction", this.product);
this.$router.push({name: "table"});
this.product = {};
},
selectProduct(route) {
if (route.params.op == "create") {
this.editing = false;
this.product = {};
} else {
let productId = route.params.id;
let selectedProduct
= this.$store.state.products.find(p => p.id == productId);
this.product = {};
Object.assign(this.product, selectedProduct);
this.editing = true;
}
}
},
created() {
unwatcher = this.$store.watch(state => state.products,
() => this.selectProduct(this.$route));
this.selectProduct(this.$route);
},
beforeDestroy() {
unwatcher();
},
beforeRouteUpdate(to, from, next) {
this.selectProduct(to);
next();
}
}
</script>
Listing 22-24Adding Navigation Features in the ProductEditor.vue File in the src/components Folder
我添加了一个router-link
元素,该元素在编辑产品时显示,并导航到数据存储中的下一个产品。显示给用户的组件不会改变,这意味着 Vue Router 不会销毁ProductEditor
组件的现有实例并创建一个新实例。
组件可以实现从路由系统接收通知的方法。我在第二十四章中描述了所有的方法,但是对于这一章来说,最重要的是beforeRouteUpdate
方法,因为在不破坏组件的情况下,当路线将要改变时会调用这个方法。beforeRouteUpdate
方法定义了三个参数,在表 22-6 中有描述。
表 22-6
beforeRouteUpdate 参数
|名字
|
描述
|
| --- | --- |
| to
| 此参数被赋予一个对象,该对象描述应用将要导航到的路线。 |
| from
| 此参数被赋予一个描述当前路线的对象,应用将导航离开该路线。 |
| next
| 此参数被赋予一个函数,必须调用该函数才能允许其他组件处理通知。它还可以用来控制导航过程,我在第二十四章对此进行了描述。 |
通过to
和from
参数接收的对象定义了与$route
对象相同的属性集,如表 22-5 所述。在清单中,beforeRouteUpdate
方法的实现将to
对象传递给selectProduct
方法,以便组件可以更新其状态。请记住,活动路由尚未更改,因此我不能使用$route
对象来响应更新。一旦我处理了更改,我就调用next
函数,它允许应用中的其他组件接收通知(这似乎是一个奇怪的要求,但是next
函数也可以用来阻止或改变导航,我在第二十四章中对此进行了描述)。
要测试路由通知,请导航至http://localhost:8080
,点击其中一个编辑按钮,然后点击下一步按钮浏览产品对象,如图 22-14 所示。
图 22-14
响应路由通知
摘要
在本章中,我向您展示了如何使用 URL 路由动态选择组件。我向您展示了定义路由的不同方法,如何使 HTML5 历史 API 能够在没有 URL 片段的情况下路由,如何提供一个无所不包的路由,以及如何命名路由并为它们创建别名。我还演示了使用动态段从 URL 获取数据,并向您展示了当路由更改将显示相同的组件时如何接收通知。在下一章,我将更详细地描述用于管理路由的 HTML 元素。
二十三、URL 路由元素功能
在这一章中,我描述了由router-link
和router-view
元素提供的一些特性。我将向您展示如何更改处理router-link
元素的方式,如何使用不同的导航事件,以及如何通过更改应用于导航元素的样式来响应路由激活。我还将向您展示如何在一个应用中使用多个router-view
元素,以及当两个或多个router-view
元素出现在同一个模板中时,如何管理它们。表 23-1 将本章放入上下文中。
表 23-1
将路由元素功能放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | router-link
元素提供了一些特性,这些特性控制生成的 HTML、触发导航的事件以及元素将被添加到的类,以指示活动路线。router-view
元素提供了允许多个元素存在于单个组件模板中的特性。 |
| 它们为什么有用? | 当您需要通过非标准元素呈现导航,或者想要使用 CSS 框架中的样式提供反馈时,router-link
特性非常有用。router-view
特性在复杂的应用中很有用,因为它们允许创建更高级的内容组合。 |
| 它们是如何使用的? | 使用router-link
和router-view
元素上的属性来应用这些特性,并在应用的路由中提供相应的支持。 |
| 有什么陷阱或限制吗? | 必须注意确保定义了合适的总括路线和重定向,以确保应用不会向用户显示空窗口或通过导航元素提供令人困惑的反馈。 |
| 还有其他选择吗? | 这些特性是可选的,你不必使用它们。 |
表 23-2 总结了本章内容。
表 23-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 配置用于导航的元素 | 使用由router-link
元素提供的属性 | 4–7 |
| 当元素与活动 URL 匹配时设置元素样式 | 定义选择router-link-active
和router-link-exact-active
类的样式 | eight |
| 确保仅当元素与活动路线完全匹配时,才设计元素的样式 | 应用exact
属性 | nine |
| 更改用于指示活动航路的类别 | 使用exact-active-class
和active-class
属性 | Ten |
| 创建嵌套路线 | 在应用中应用多个router-view
元素,并用使用children
属性定义的路由来定位它们 | 11–17 |
为本章做准备
在本章中,我继续使用第二十三章中的 productapp 项目。为了准备本章,我已经注释掉了ProductDisplay
组件中的create
方法,如清单 23-1 所示。该方法中的语句用于演示第二十章中数据存储的使用,本章并不需要这些语句,因为每次创建ProductDisplay
组件的新实例时,这些语句都会导致数据存储值重置。
...
<script>
import { mapState, mapMutations, mapActions, mapGetters } from "vuex";
export default {
computed: {
...mapState(["products"]),
...mapState({
useStripedTable: state => state.prefs.stripedTable
}),
...mapGetters({
tableClass: "prefs/tableClass",
editClass: "prefs/editClass",
deleteClass: "prefs/deleteClass"
})
},
methods: {
editProduct(product) {
this.selectProduct(product);
this.$router.push("/edit");
},
createNew() {
this.selectProduct();
this.$router.push("/edit");
},
...mapMutations({
selectProduct: "selectProduct",
setEditButtonColor: "prefs/setEditButtonColor",
setDeleteButtonColor: "prefs/setDeleteButtonColor"
}),
...mapActions({
deleteProduct: "deleteProductAction"
})
},
//created() {
// this.setEditButtonColor(false);
// this.setDeleteButtonColor(false);
//}
}
</script>
...
Listing 23-1Disabling a Method in the ProductDisplay.vue File in the src/components Folder
要启动 RESTful web 服务,打开命令提示符并运行清单 23-2 中的命令。
npm run json
Listing 23-2Starting the Web Service
打开第二个命令提示符,导航到productapp
目录,运行清单 23-3 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 23-3Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里你将看到示例应用,如图 23-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
图 23-1
运行示例应用
使用路由器链接元素
router-link
元素比它第一次出现时更加灵活,并且支持一些有用的选项来定制它生成的 HTML 元素,并向用户提供有用的反馈。为了准备接下来的部分,我在App
组件的模板中添加了router-link
元素,如清单 23-4 所示。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<router-link to="/list" class="m-1">List</router-link>
<router-link to="/create" class="m-1">Create</router-link>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-4Adding Navigation Elements in the App.vue File in the src Folder
这些router-link
元素作为组件模板的一部分被处理,并被转换成锚元素,如图 23-2 所示。
图 23-2
使用路由器链接元素导航
通过应用表 23-3 中描述的属性来配置router-link
元素,其中最有用的我将在接下来的章节中演示。
表 23-3
路由器链路属性
|名字
|
描述
|
| --- | --- |
| tag
| 该属性指定了转换router-link
元素时将生成的 HTML 元素的标记类型,如“选择元素类型”一节所述。 |
| event
| 该属性指定将触发导航的事件,如“选择导航事件”一节中所述。 |
| exact
| 此属性指定在标识对应于活动路由的元素时是否使用部分 URL 匹配,如“设计路由器链接元素”一节中所述。 |
| active-class
| 该属性指定当活动 URL 以元素的导航目标开始时,元素将被添加到的类,如“设计路由器链接元素”一节中所述。 |
| exact-active-class
| 该属性指定当活动 URL 匹配元素的导航目标时,元素将被添加到的类,如“设计路由器链接元素”一节中所述。 |
| to
| 该属性指定了导航位置,并将向浏览器的历史记录中添加一个条目,相当于第二十二章中描述的push
导航方法。 |
| replace
| 该属性指定导航位置,但不会向浏览器的历史记录中添加条目,相当于第二十二章中描述的replace
导航方法。 |
| append
| 此属性指定一个相对 URL,当您使用用户提供的数据进行导航,并希望确保导航包含在应用的特定部分时,此属性会很有用。 |
选择元素类型
默认情况下,router-link
元素被转换成锚点,也就是带有a
标签的元素。如果您使用浏览器的 F12 工具来检查文档对象模型,您可以看到我在清单 23-4 中添加的router-link
元素是如何被转换的。
...
<div class="col text-center m-2">
<a href="/list" class="m-1">List</a>
<a href="/create" class="m-1">Create</a>
</div>
...
小费
如果你点击了其中一个a
元素,你会看到它已经被添加到了router-link-active
和router-link-exact-active
类中。我将在“设计路由器链接元素”一节中解释这些元素的含义。
tag
属性可用于选择不同的元素类型,当router-link
元素被转换时,该元素类型将代替锚点使用。当您想要以不能使用a
元素的方式呈现一系列导航元素时,或者当您想要在选择器不能匹配锚元素的情况下应用 CSS 样式时,这是非常有用的。在清单 23-5 中,我使用了tag
属性来填充一个包含导航元素的列表。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<ol>
<router-link tag="li" to="/list">List</router-link>
<router-link tag="li" to="/create">Create</router-link>
</ol>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-5Specifying Tag Type in the App.vue File in the src Folder
tag
属性指定当组件的模板被处理时,router-link
元素应该被替换为li
元素。如果保存更改并使用浏览器的 F12 开发工具来检查文档对象模型,您将看到以下元素:
...
<ol>
<li class="">List</li>
<li class="">Create</li>
</ol>
...
当你点击其中一个li
元素时,导航发生,如图 23-3 所示。
图 23-3
更改导航元素类型
选择导航事件
默认的导航事件是click
,这意味着当用户点击由router-link
元素创建的元素时,导航被执行。event
属性用于指定一个可选事件,它允许以不同的方式进行导航。在清单 23-6 中,我使用了event
属性,这样当用户将鼠标指针移动到导航元素上时就会执行导航。
警告
用户希望当他们点击一个元素时会出现导航,因为这是大多数 web 应用的工作方式。小心使用event
属性,因为您很容易混淆您的用户并产生意想不到的结果。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<ol>
<router-link tag="li" event="mouseenter" to="/list">
List
</router-link>
<router-link tag="li" event="mouseenter" to="/create">
Create
</router-link>
</ol>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-6Specifying the Navigation Event in the App.vue File in the src Folder
当鼠标指针进入由 HTML 元素占据的浏览器窗口区域时,触发mouseenter
事件,这意味着无需用户点击鼠标按钮就可以进行导航。
设计路由器链接元素的样式
当你对router-link
元素应用样式时,重要的是要记住你是在对router-link
被转换成的元素进行样式化,而不是对router-link
元素本身。在清单 23-7 中,我为App
组件添加了一个style
属性,用于定义导航元素的样式。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<ol>
<router-link tag="li" event="mouseenter" to="/list">
List
</router-link>
<router-link tag="li" event="mouseenter" to="/create">
Create
</router-link>
</ol>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
<style scoped>
router-link { text-align: right; color: yellow; background-color: red; }
li { text-align: left; color:blue; background-color: lightblue; }
</style>
Listing 23-7Styling Navigation Elements in the App.vue File in the src Folder
重要的是要记住,是浏览器在将router-link
元素转换成由tag
属性指定的元素类型之后评估样式选择器。出于这个原因,清单 23-7 中定义的第一个样式将不匹配任何元素,因为在组件的模板被处理后没有router-link
元素可供选择。这是混淆的一个常见原因,尤其是在使用自动管理 CSS 样式的工具时。第二种样式的选择器匹配转换后的元素,并将应用于组件的内容,如图 23-4 所示。
小费
您可能需要重新加载浏览器才能看到如图 23-4 所示的新样式。
图 23-4
样式导航元素
响应活动路由
当当前 URL 匹配导航元素的目标时,Vue Router 包将该元素添加到router-link-active
和router-link-exact-active
类中,这两个类可用于应用向用户提供反馈的样式。在清单 23-8 中,我定义了在选择器中使用这些类的样式。我还删除了事件属性,这样当用户点击时就会出现导航,我还添加了新的router-link
元素,导航到/edit
和/edit/1
URL。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<ol>
<router-link tag="li" to="/list">List</router-link>
<router-link tag="li" to="/create">Create</router-link>
<router-link tag="li" to="/edit">Edit</router-link>
<router-link tag="li" to="/edit/1">Edit Kayak</router-link>
</ol>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
<style scoped>
li { text-align: left; color:blue; background-color: lightblue; }
.router-link-active { font-size: xx-large; }
.router-link-exact-active { font-weight: bolder; }
</style>
Listing 23-8Styling the Active Navigation Element in the App.vue File in the src Folder
当路线发生变化时,导航元素会自动添加到类中或从类中移除。如果由to
属性指定的目标与当前 URL 完全匹配,那么元素将被添加到router-link-exact-active
类中,该类应用使元素文本加粗的样式。如果当前 URL 以由to
属性指定的目标开始,则元素被添加到router-link-active
类中。你可以通过点击编辑 Kayak 链接看到不同之处,这将导航到/edit/1
网址。目标为/edit
的编辑元素被添加到router-link-active
类中,因为当前 URL 以其目标开始:/edit/1
以/edit
开始。Edit Kayak 链接被添加到这两个类中,因为当前 URL 以其目标开始,并且与其目标完全匹配。结果是编辑元素以更大的文本显示,而编辑 Kayak 链接以同样加粗的更大文本显示,如图 23-5 所示。
图 23-5
响应活动的路由类别
使用router-link-active
类进行部分 URL 匹配并不总是有用的,可以通过向router-link
元素添加exact
属性来禁用,如清单 23-9 所示。
...
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<ol>
<router-link tag="li" to="/list">List</router-link>
<router-link tag="li" to="/create">Create</router-link>
<router-link tag="li" to="/edit" exact>Edit</router-link>
<router-link tag="li" to="/edit/1">Edit Kayak</router-link>
</ol>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
...
Listing 23-9Disabling Partial URL Matching in the App.vue File in the src Folder
无论分配给它的值是什么,exact
属性都将生效,并且禁用部分匹配特性的是属性的存在,而不是它的值。当用户导航到/edit/1
URL 时,清单中应用的属性阻止编辑元素被添加到router-link-active
类,如图 23-6 所示。
图 23-6
禁用部分 URL 映射
更改活动路线类别
当你使用一个 CSS 框架时,比如我在本书中使用的 Bootstrap 框架,你会发现经常有一些类被用来指示一个元素何时是活动的,这些类并不对应于 Vue 路由器使用的类的名称。active-class
和exact-active-class
属性可以用来指定类的名称,当元素的目标与当前 URL 匹配时,应该将元素添加到这些类中。在清单 23-10 中,我用一组更传统的按钮元素替换了导航元素列表,并使用了active-class-exact
属性来指定用于指示活动按钮的引导类的名称。我还删除了style
元素,因为我不再需要定制的 CSS 样式。
小费
您可以通过使用路由配置对象中的linkActiveClass
和linkExactActiveClass
属性来更改用于全局指示路由的类,这意味着您不必在每个元素上指定这些类。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/list"
exact-active-class="btn-info"
class="btn btn-primary">
List
</router-link>
<router-link tag="button" to="/create"
exact-active-class="btn-info"
class="btn btn-primary">
Create
</router-link>
<router-link tag="button" to="/edit"
exact-active-class="btn-info"
class="btn btn-primary">
Edit
</router-link>
<router-link tag="button" to="/edit/1"
exact-active-class="btn-info"
class="btn btn-primary">
Edit Kayak
</router-link>
</div>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-10Specifying Active Class Names in the App.vue File in the src Folder
新的router-link
元素都被赋给了btn
和btn-primary
类,这两个类是样式按钮的引导类,当它们表示活动路线时将被赋给btn-info
类,效果如图 23-7 所示。
图 23-7
更改活动路线类别
创建嵌套路线
到目前为止,路由示例都假设应用只有一组要显示的组件,并且当选择其中一个组件时,它将总是显示相同的内容。在复杂的应用中,一个顶级组件可能需要显示不同的子组件,为了支持这一需求,Vue Router 包支持嵌套路由,也称为子路由。
规划应用布局
使用嵌套路由时,理解您的目标很重要。对于示例应用,我将为用户提供顶级导航元素,允许用户在产品相关功能和设置应用首选项的组件之间进行选择。图 23-8 显示了我打算创建的应用结构。
图 23-8
应用的结构
决定应用将支持的 URL 集使得创建路由更加简单。我将在示例应用中使用的 URL 在表 23-4 中描述,并遵循我在早期示例中使用的相同基本方法。
表 23-4
示例应用的 URL
|统一资源定位器
|
描述
|
| --- | --- |
| /products/table
| 该 URL 将显示产品列表。 |
| /products/create
| 该 URL 将显示用于创建新产品的编辑器。 |
| /products/edit/10
| 此 URL 将显示用于修改指定产品的编辑器。 |
| /preferences
| 此 URL 将显示首选项设置。 |
向项目中添加组件
为了创建我需要的结构,我需要向项目添加一个组件。首先,我在src/components
文件夹中添加了一个名为Preferences.vue
的文件,其内容如清单 23-11 所示。
<template>
<div>
<h4 class="bg-info text-white text-center p-2">Preferences</h4>
<div class="form-check">
<input class="form-check-input" type="checkbox"
v-bind:checked="primaryEdit" v-on:input="setPrimaryEdit">
<label class="form-check-label">Primary Color for Edit Buttons</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox"
v-bind:checked="dangerDelete" v-on:input="setDangerDelete">
<label class="form-check-label">Danger Color for Delete Buttons</label>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
computed: {
...mapState({
primaryEdit: state => state.prefs.primaryEditButton,
dangerDelete: state => state.prefs.dangerDeleteButton
})
},
methods: {
setPrimaryEdit() {
this.$store.commit("prefs/setEditButtonColor", !this.primaryEdit);
},
setDangerDelete() {
this.$store.commit("prefs/setDeleteButtonColor", !this.dangerDelete);
}
}
}
</script>
Listing 23-11The Contents of the Preferences.vue File in the src/components Folder
这是将向用户显示首选项的组件。它使用复选框显示数据存储中两个状态属性的值,并在用户切换控件时更新这些值。这些是相同的数据存储状态属性,用于设置由ProductDisplay
组件显示的编辑和删除按钮的颜色。
接下来,我在src/components
文件夹中添加了一个名为Products.vue
的文件,其内容如清单 23-12 所示。
<template>
<router-view></router-view>
</template>
Listing 23-12The Contents of the Products.vue file in the src/component Folder
这个组件只包含一个template
元素,而这个元素又只包含一个router-view
元素。这个组件将允许我显示产品列表或编辑器。
定义路线
组件就绪后,我可以定义应用的路由配置来实现表 23-4 中描述的 URL 集,如清单 23-13 所示。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/preferences", component: Preferences},
{ path: "/products", component: Products,
children: [
{ name: "table", path: "list", component: ProductDisplay},
{ name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor},
{ path: "", redirect: "list" }
]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}`},
{ path: "*", redirect: "/products/list" }
]
})
Listing 23-13Defining Routes in the index.js File in the src/router Folder
这些路由包含大量信息,因此我将逐一展开,并解释它们如何构成我在本节开始时描述的结构。第一条路线遵循您在前面的示例中看到的格式,如下所示:
...
{ path: "/preferences", component: Preferences},
...
这个路由告诉 Vue Router 在 URL 为/preferences
时显示Preferences
组件。该路线中没有使用动态段、名称、重定向或其他特殊功能,所选组件将显示在App
组件模板中定义的router-view
元素中。
下一条路线更复杂,最好是逐步接近。第一部分很简单。
...
{ path: "/products", component: Products,
...
path
和component
属性告诉 Vue Router 当 URL 为/products
时应该显示Products
组件。与前面的路线一样,Products
组件将显示在App
组件模板的router-view
元素中。但是Product
组件的模板还包含一个router-view
元素,必须为其选择一个组件,这就是该路由定义的children
属性的用途:
...
{ path: "/products", component: Products,
children: [
{ name: "table", path: "list", component: ProductDisplay},
{ name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor},
{ path: "", redirect: "list" }
]
},
...
children
属性用于定义一组路由,这些路由将应用于Products
组件模板中的router-view
元素。每个子路由的path
属性的值与其父路由的path
相结合,以匹配一个 URL 并选择一个组件,以便为/products/list
URL 选择ProductDisplay
组件。子路由可以包括动态段和正则表达式,这可以在为/create
和/edit/id
URL 选择ProductEditor
组件的路由中看到。
children
部分中的最后一个路由是一个将执行重定向的总括路由,这样任何以/products
开头但与前两个路由不匹配的 URL 都将被重定向到/products/list
。注意,这个路由的路径是一个空字符串,而不是一个星号,因为我想匹配没有这个段的值的 URL。
处理旧的 URL
当现有应用支持的 URL 集发生变化时,一定要确保更新组件中使用的 URL,或者在新旧 URL 之间创建重定向或别名。编辑功能以前通过/edit/:id
路径访问,但现在通过/products/edit/:id
访问。为了确保旧的 URL 仍然有效,我在清单 23-13 中定义了重定向路由。
...
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}`},
...
在第二十二章中,我使用一个固定的 URL 创建了一个重定向。这在这种情况下是行不通的,因为我需要将动态id
段的值传递给新路线。如清单所示,重定向也可以表示为一个函数,它接收匹配的路由并返回重定向 URL。在这个例子中,重定向函数接收路由并组成重定向 URL,以便它包含id
值。
创建导航元素
在清单 23-14 中,我用针对表 23-4 中描述的 URL 的按钮替换了App
组件模板中的导航按钮。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/products" active-class="btn-info"
class="btn btn-primary">
Products
</router-link>
<router-link tag="button" to="/preferences"
active-class="btn-info" class="btn btn-primary">
Preferences
</router-link>
</div>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-14Navigating to the New URLs in the App.vue File in the src Folder
这些router-link
元素将被转换成button
元素,这些元素导航到/products
和/preferences
URL,并使用 Bootstrap btn
和btn-primary
类进行样式化,这些类在 Bootstrap 配色方案的原色中应用基本的按钮样式。
为了表明什么时候button
代表活动路线,我将元素添加到了btn-info
类,该类使用active-class
属性为按钮应用了不同的引导颜色。
测试嵌套路由
支持嵌套的router-view
元素所需的所有更改都已就绪。您可以通过导航到http://localhost:8080
并使用Products
和Preferences
按钮来更改App
组件的模板中的router-view
元素的内容,这将产生如图 23-9 所示的结果。
图 23-9
为顶级路由器视图元素选择组件
要查看嵌套的router-view
元素,单击 Products 按钮,然后单击表格中显示的编辑按钮之一。Products
组件中的router-view
元素所显示的组件将变为显示编辑器,点击保存或取消按钮可以返回到表格视图,如图 23-10 所示。
图 23-10
为嵌套路由器视图元素选择组件
请注意,使用命名路由的router-link
元素和代码会自动将使用新路由的 URL 作为目标。当我在清单 23-13 中定义路由时,我将在第二十二章中定义的名称应用于新配置中的相应路由。
...
{ path: "/products", component: Products,
children: [
{ name: "table", path: "list", component: ProductDisplay},
{ name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor},
{ path: "", redirect: "list" }
]
},
...
当使用命名路由的router-link
元素被处理时,结果是一个指向指定路由的 URL,这意味着名称保持有效,即使它们所涉及的路由发生了变化。您可以在表格视图中显示的编辑按钮中看到这一点,这些按钮是用这个router-link
元素创建的:
...
<router-link v-bind:to="{name: 'editor', params: { op: 'edit', id: p.id}}"
v-bind:class="editClass" class="btn btn-sm">
Edit
</router-link>
...
使用名称设置to
属性,该名称指定编辑器路径。处理此元素时,结果是一个锚元素,其目标对应于命名的路由,如下所示:
...
<a href="/products/edit/1" class="btn btn-sm btn-secondary">
Edit
</a>
...
使用命名路由可能需要笨拙的代码和 HTML,但结果可以是更灵活和更健壮的应用,该应用适应其路由配置的变化,而不需要其组件的相应变化。
使用命名路由器视图元素
一些组件需要在同一个模板中有多个router-view
元素,这样就可以动态地选择两个或更多的子组件。当router-view
元素在同一个模板中时,name
属性用于区分它们,然后在路由中使用这些名称来选择将要显示的组件。
为了帮助演示这个特性,我通过在src/components
文件夹中添加一个名为SideBySide.vue
的文件来创建一个新组件,其内容如清单 23-15 所示。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<h3 class="bg-secondary text-white text-center p-2">Left View</h3>
<router-view name="left" class="border border-secondary p-2" />
</div>
<div class="col text-center m-2">
<h3 class="bg-secondary text-white text-center p-2">Right View</h3>
<router-view name="right" class="border border-secondary p-2" />
</div>
</div>
</div>
</template>
Listing 23-15The Contents of the SideBySide.vue File in the src/components Folder
这个组件有一个包含两个router-view
元素的模板元素,使用name
属性区分这两个元素,一个命名为left
,另一个命名为right
。引导类和结构元素将向用户并排展示router-view
元素的内容。为了瞄准新的router-view
元素,我将添加对表 23-5 中描述的 URL 的支持。
表 23-5
命名路由器视图元素的 URL
|统一资源定位器
|
描述
|
| --- | --- |
| /named/tableleft
| 这个 URL 将在left
元素中显示产品表,在right
元素中显示编辑器。 |
| /named/tableright
| 这个 URL 将在right
元素中显示产品表,在 lef t
元素中显示编辑器。 |
为了定位这些 URL,我将清单 23-16 中所示的导航元素添加到顶级App
组件的模板中。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/products"
active-class="btn-info" class="btn btn-primary">
Products
</router-link>
<router-link tag="button" to="/preferences"
active-class="btn-info" class="btn btn-primary">
Preferences
</router-link>
<router-link to="/named/tableleft" class="btn btn-primary"
active-class="btn-info">
Table Left
</router-link>
<router-link to="/named/tableright" class="btn btn-primary"
active-class="btn-info">
Table Right
</router-link>
</div>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 23-16Adding Navigation Elements in the App.vue File in the src Folder
为了完成对新 URL 的支持并以命名的router-view
元素为目标,我创建了清单 23-17 中所示的路由。
import Vue from "vue";
import VueRouter from "vue-router";
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
import SideBySide from "../components/SideBySide";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/preferences", component: Preferences},
{ path: "/products", component: Products,
children: [
{ name: "table", path: "list", component: ProductDisplay},
{ name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor},
{ path: "", redirect: "list" }
]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}`},
{ path: "/named", component: SideBySide,
children:[
{ path: "tableleft",
components: {
left: ProductDisplay,
right: ProductEditor
}
},
{ path: "tableright",
components: {
left: ProductEditor,
right: ProductDisplay
}
}
]
},
{ path: "*", redirect: "/products" }
]
})
Listing 23-17Adding Routes in the index.js File in the src/router Folder
当定义以命名的router-view
元素为目标的路由时,使用components
属性。该属性被赋予一个对象,其属性是router-view
元素的名称,其值是应该显示的组件,如下所示:
...
{ path: "tableleft",
components: {
left: ProductDisplay,
right: ProductEditor
}
},
...
components
属性告诉 Vue 路由器包在名为left
的router-view
元素中显示ProductComponent
,在名为right
的router-view
元素中显示ProductEditor
组件。
注意
以命名元素为目标的属性是components
(复数),而不是在清单 23-17 中的其他路径中使用的component
(单数)属性。
要查看结果,导航到http://localhost:8080
并点击左侧表格和右侧表格按钮,这两个按钮指向清单 23-17 中定义的路线的 URL,产生如图 23-11 所示的结果。
图 23-11
使用命名路由器视图元素
摘要
在本章中,我描述了在 Vue.js 应用中使用 URL 路由时可用的一些高级功能。我解释了如何配置router-link
元素以产生不同的 HTML 元素并响应不同的事件,以及如何设计导航元素的样式以向用户提供反馈。对于router-view
元素,我向您展示了如何使用嵌套路由,以便应用可以包含多个元素,以及当这些元素在同一个模板中定义时如何命名它们。在下一章,我将描述高级 URL 路由特性。
二十四、高级 URL 路由
在这一章中,我描述了更高级的 URL 路由功能。首先,我将向您展示如何使用多个文件构建复杂的路由配置,如何使用路由器防护来控制导航,以及如何在路由选择组件时延迟加载组件。在本章的最后,我将向您展示如何使用那些不是为使用 Vue 路由器包而编写的组件。表 24-1 总结了本章内容。
表 24-1
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 分组相关路线 | 为每组路线定义单独的模块 | 3–5 |
| 检查和拦截导航 | 使用路线守卫 | 6, 9–11 |
| 改变导航的目标 | 在 route guard 的next
功能中提供一个替换 URL | 7, 8 |
| 在导航完成之前访问组件 | 使用beforeRouteEnter
保护方法中的回调功能 | 12–15 |
| 延迟加载组件 | 对组件使用动态依赖关系 | 16–21 |
| 使用不了解路由系统的组件 | 定义路线时使用道具功能 | 22–23 |
为本章做准备
在本章中,我继续使用第二十三章中的 productapp 项目。本章不需要修改。要启动 RESTful web 服务,打开命令提示符并运行清单 24-1 中的命令。
npm run json
Listing 24-1Starting the Web Service
打开第二个命令提示符,导航到productapp
目录,运行清单 24-2 中所示的命令来启动 Vue.js 开发工具。
npm run serve
Listing 24-2Starting the Development Tools
一旦初始捆绑过程完成,打开一个新的浏览器窗口并导航到http://localhost:8080
,在那里你将看到示例应用,如图 24-1 所示。
小费
你可以从 https://github.com/Apress/pro-vue-js-2
下载本章以及本书其他章节的示例项目。
图 24-1
运行示例应用
对相关路线使用单独的文件
随着应用支持的 URL 数量的增加,跟踪路由及其支持的应用部分变得更加困难。可以使用额外的 JavaScript 文件对相关的路由进行分组,然后将这些路由导入到主路由配置中。
对路由进行分组没有固定的方法,我在本章中采用的方法是将处理使用命名的router-view
元素的组件的并行表示的路由与处理应用其余部分提供的基本功能的路由分开。我在src/router
文件夹中添加了一个名为basicRoutes.js
的文件,并添加了清单 24-3 中所示的代码。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
export default [
{ path: "/preferences", component: Preferences },
{ path: "/products", component: Products,
children: [ { name: "table", path: "list", component: ProductDisplay },
{ name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor
},
{ path: "", redirect: "list" }]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}` },
]
Listing 24-3The Contents of the basicRoutes.js File in the src/router Folder
该文件导出一组路由,处理应用支持的基本 URL。接下来,我在src/router
文件夹中添加了一个名为sideBySideRoutes.js
的文件,并添加了清单 24-4 中所示的代码。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import SideBySide from "../components/SideBySide";
export default {
path: "/named", component: SideBySide,
children: [
{ path: "tableleft",
components: { left: ProductDisplay, right: ProductEditor }
},
{ path: "tableright",
components: { left: ProductEditor, right: ProductDisplay }
}
]
}
Listing 24-4The Contents of the sideBySideRoutes.js File in the src/router Folder
将这些路由组移动到专用文件中允许我简化index.js
文件,如清单 24-5 所示,它显示了我如何导入在basicRoutes.js
和sideBySideRoutes.js
文件中定义的路由。
import Vue from "vue";
import VueRouter from "vue-router";
//import ProductDisplay from "../components/ProductDisplay";
//import ProductEditor from "../components/ProductEditor";
//import Preferences from "../components/Preferences";
//import Products from "../components/Products";
//import SideBySide from "../components/SideBySide";
import BasicRoutes from "./basicRoutes";
import SideBySideRoutes from "./sideBySideRoutes";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
...BasicRoutes,
SideBySideRoutes,
{ path: "*", redirect: "/products" }
]
})
Listing 24-5Importing Routes in the index.js File in the src/router Folder
我使用一个import
语句导入每个文件的内容,并给内容一个我在routes
属性中使用的名称。文件basicRoutes.js
导出了一个数组,必须使用 spread 操作符来解包,我在第四章中描述过。sideBySideRoutes.js
文件导出单个对象,可以在没有展开操作符的情况下使用。应用的运行方式没有明显的变化,但是路由配置已经被分解,这将使相关路由的管理更加简单。
守卫路线
允许用户使用 URL 在应用中导航的一个缺点是,他们可能会在不希望的情况下尝试访问应用的某些部分,例如在未经身份验证的情况下访问管理功能,或者在从服务器加载数据之前访问编辑功能。导航守卫控制对路线的访问,并且他们可以通过将路线重定向到另一个 URL 或完全取消导航来响应导航尝试。导航保护可以以不同的方式应用,正如我在下面的章节中解释的那样。
定义全球导航卫星系统
全局导航守卫是定义为路线配置的一部分的方法,用于控制对应用中所有路线的访问。有三种全局导航保护方法用于注册功能以控制导航,如表 24-2 所述。
表 24-2
全球导航卫星系统方法
|名字
|
描述
|
| --- | --- |
| beforeEach
| 此方法在活动路由更改前调用。 |
| afterEach
| 此方法在活动路由改变后调用。 |
| beforeResolve
| 该方法与beforeEach
相似,但它是在所有特定于路由的和组件的保护(我将在下一节中描述)都被检查后调用的,如“理解保护排序”一节所述。 |
这些方法中的每一个都在VueRouter
对象上被调用,并接受一个在导航过程中被调用的函数。这用一个例子来解释更容易,在清单 24-6 中,我使用了beforeEach
方法来阻止从路径以/named
开始的路线到/preferences
URL 的导航。
import Vue from "vue";
import VueRouter from "vue-router";
import BasicRoutes from "./basicRoutes";
import SideBySideRoutes from "./sideBySideRoutes";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
routes: [
...BasicRoutes,
SideBySideRoutes,
{ path: "*", redirect: "/products" }
]
});
export default router;
router.beforeEach((to, from, next) => {
if (to.path == "/preferences" && from.path.startsWith("/named")) {
next(false);
} else {
next();
}
});
Listing 24-6Guarding a Route in the index.js File in the src/router Folder
全球路线守卫是最灵活的守卫类型,因为它们可以用来拦截所有导航,但它们很难设置,如清单 24-6 所示。使用new
关键字创建VueRouter
对象,然后将您想要调用的函数从表 24-2 中传递给相应的方法。在这个例子中,我将一个函数传递给了beforeEach
方法,这意味着它将在每次导航尝试之前被调用。
该函数接收三个参数。前两个参数是表示应用正在导航到的路线和应用将要导航离开的路线的对象。这些对象定义了我在第二十二章中描述的属性,我在清单中使用它们的path
属性来检查应用是否从/named
导航到/preferences
。
第三个参数是一个函数,它被调用来接受、重定向或取消导航,并将导航请求传递给下一个路线守卫进行处理,这就是为什么这个参数通常被称为next
。通过向函数传递不同的参数来指定不同的结果,如表 24-3 中所述。
表 24-3
导航保护的下一个功能的使用
|方法使用
|
描述
|
| --- | --- |
| next()
| 当不带参数调用该函数时,导航将继续进行。 |
| next(false)
| 当函数将false
作为参数传递时,导航将被取消。 |
| next(url)
| 当向该函数传递一个字符串时,它被解释为一个 URL,并成为新的导航目标。 |
| next(object)
| 当向该函数传递一个对象时,它被解释为新的导航目标,这对于按名称选择路线很有用,如“重定向到命名路线”一节中所示。 |
| next(callback)
| 这是一个特殊版本的next
函数,只能在一种情况下使用,如“在 beforeRouteEnter 方法中访问组件”一节中所述,其他情况下不支持。 |
在清单中,如果当前 URL 以/named
开头,而目标 URL 是/preferences
,我就传递next
函数false
,取消导航请求。对于所有其他导航,我不带参数地调用next
方法,这允许导航继续进行。
警告
你必须记得调用你的卫士中的next
函数。在一个应用中可以定义多个保护,如果您忘记调用该函数,它们将不会被调用,这可能会导致意外的结果。
将导航请求重定向到另一个 URL
取消导航的另一种方法是将其重定向到另一个 URL。在清单 24-7 中,我添加了一个额外的守卫函数,拦截对/named/tableright
URL 的请求,并将它们重定向到/products
。
小费
当有多个全局保护函数时,它们按照传递给beforeEach
或beforeAfter
方法的顺序执行。
import Vue from "vue";
import VueRouter from "vue-router";
import BasicRoutes from "./basicRoutes";
import SideBySideRoutes from "./sideBySideRoutes";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
routes: [
...BasicRoutes,
SideBySideRoutes,
{ path: "*", redirect: "/products" }
]
});
export default router;
router.beforeEach((to, from, next) => {
if (to.path == "/preferences" && from.path.startsWith("/named")) {
next(false);
} else {
next();
}
});
router.beforeEach((to, from, next) => {
if (to.path == "/named/tableright") {
next("/products");
} else {
next();
}
});
Listing 24-7Defining Another Guard in the index.js File in the src/router Folder
我可以在现有的 guard 函数中实现这个检查,但是我想演示对多个 guard 的支持,这是在复杂的应用中对相关检查进行分组的一种有用的方法。要查看重定向的效果,导航至http://localhost:8080/preferences
,然后点击表格右侧按钮。应用导航到/products
URL,而不是并排显示组件,如图 24-2 所示。
图 24-2
使用路线保护重定向导航
小费
当您在路由防护中执行重定向时,会启动一个新的导航请求,并再次调用所有防护功能。因此,重要的是不要创建重定向循环,因为两个保护函数会导致应用在相同的两个 URL 之间来回切换。
重定向到命名路由
如果您想将一个导航请求重定向到一个指定的路线,那么您可以将一个具有name
属性的对象传递给next
函数,如清单 24-8 所示。
import Vue from "vue";
import VueRouter from "vue-router";
import BasicRoutes from "./basicRoutes";
import SideBySideRoutes from "./sideBySideRoutes";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
routes: [
...BasicRoutes,
SideBySideRoutes,
{ path: "*", redirect: "/products" }
]
});
export default router;
router.beforeEach((to, from, next) => {
if (to.path == "/preferences" && from.path.startsWith("/named")) {
next(false);
} else {
next();
}
});
router.beforeEach((to, from, next) => {
if (to.path == "/named/tableright") {
next({ name: "editor", params: { op: "edit", id: 1 } });
} else {
next();
}
});
Listing 24-8Redirecting to a Named Route in the index.js File in the src/router Folder
在清单中,我使用了next
函数将导航重定向到名为editor
的路线,效果如图 24-3 所示。
图 24-3
将导航重定向到指定路线
定义特定于路线的防护
各个路线可以实现自己的保护,这可以提供一种更自然的方式来管理导航。唯一可以直接在路线中使用的单守卫方法是beforeEnter
,我已经在清单 24-9 中使用它来守卫两条路线。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import SideBySide from "../components/SideBySide";
export default {
path: "/named", component: SideBySide,
children: [
{
path: "tableleft",
components: { left: ProductDisplay, right: ProductEditor }
},
{
path: "tableright",
components: { left: ProductEditor, right: ProductDisplay },
beforeEnter: (to, from, next) => {
next("/products/list");
}
}
],
beforeEnter: (to, from, next) => {
if (to.path == "/named/tableleft") {
next("/preferences");
} else {
next();
}
}
}
Listing 24-9Guarding Individual Routes in the sideBySideRoutes.js File in the src/router Folder
使用嵌套路由时,您可以保护父路由和单个子路由。在清单中,我在父路由中添加了一个守卫,将对/named/tableleft
的请求重定向到/preferences
,您可以通过单击应用的左侧按钮来测试,如图 24-4 所示。
图 24-4
守卫一条路线
了解保护订购
在清单 24-9 中,我向其中一个子路由添加了一个守卫,将对/named/tableright
的请求重定向到/products/list
。但是如果你点击表格右边的按钮,你会看到这个保护没有达到预期的效果。
发生这种情况是因为全局路由守卫在特定路由守卫之前执行,并且其中一个全局守卫已经在重定向对/named/tableright
的请求。当警卫执行重定向时,当前路由更改的处理被放弃,新的导航开始,这意味着在执行重定向的警卫之后执行的任何警卫将不能检查该请求。
如表 24-2 所示,全局beforeResolve
方法在所有其他类型的保护之后执行,这是一种在检查完特定路线的保护之后将全局保护定义为最终检查的有用方法。在清单 24-10 中,我已经使用了beforeResolve
方法来改变清单 24-9 中定义的阻挡路线守卫的守卫功能。
import Vue from "vue";
import VueRouter from "vue-router";
import BasicRoutes from "./basicRoutes";
import SideBySideRoutes from "./sideBySideRoutes";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
routes: [
...BasicRoutes,
SideBySideRoutes,
{ path: "*", redirect: "/products" }
]
});
export default router;
router.beforeEach((to, from, next) => {
if (to.path == "/preferences" && from.path.startsWith("/named")) {
next(false);
} else {
next();
}
});
router.beforeResolve((to, from, next) => {
if (to.path == "/named/tableright") {
next({ name: "editor", params: { op: "edit", id: 1} });
} else {
next();
}
})
Listing 24-10Changing a Guard in the index.js File in the src/router Folder
在实际项目中,只有当存在一组特定于路由的防护不会拦截的 URL 时,这才是一个有用的更改,但是您可以通过单击表格右侧的按钮来查看计时更改的效果。应用将开始导航到/named/tableright
,该应用将被路由特定的警卫拦截,该警卫将应用重定向到/products/list
,如图 24-5 所示。
图 24-5
路由保护排序的效果
定义组件路由保护
在第二十二章的中,我使用了ProductEditor
组件中的beforeRouteUpdate
方法来响应路线变化,就像这样:
...
beforeRouteUpdate(to, from, next) {
this.selectProduct(to);
next();
}
...
这是路由保护方法之一,组件可以实现该方法来参与保护选择它们进行显示的路由。使用beforeRouteUpdate
方法来接收变更通知是一项有用的技术,但该方法是组件保护方法集的一部分,如表 24-4 所述。
表 24-4
组件保护方法
|名字
|
描述
|
| --- | --- |
| beforeRouteEnter
| 此方法在目标路由选择的组件被确认之前被调用,它用于在路由导致组件被创建之前控制对路由的访问。访问该方法中的组件需要一种特定的技术,如“在 beforeRouteEnter 方法中访问组件”一节中所述。 |
| beforeRouteUpdate
| 当选择了当前组件的路由发生变化,并且新路由也选择了该组件时,将调用此方法。 |
| beforeRouteLeave
| 当应用将要离开选择了当前组件的路线时,调用此方法。 |
这些方法接收与其他保护相同的三个参数,可用于接受、重定向或取消导航。在清单 24-11 中,我实现了beforeRouterLeave
方法,当路线将要改变时,要求用户确认导航。
<template>
<div>
<div v-if="displayWarning" class="text-center m-2">
<h5 class="bg-danger text-white p-2">
Are you sure?
</h5>
<button class="btn btn-danger" v-on:click="doNavigation">
Yes
</button>
<button class="btn btn-danger" v-on:click="cancelNavigation">
Cancel
</button>
</div>
<h4 class="bg-info text-white text-center p-2">Preferences</h4>
<div class="form-check">
<input class="form-check-input" type="checkbox"
v-bind:checked="primaryEdit" v-on:input="setPrimaryEdit">
<label class="form-check-label">Primary Color for Edit Buttons</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox"
v-bind:checked="dangerDelete" v-on:input="setDangerDelete">
<label class="form-check-label">Danger Color for Delete Buttons</label>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
data: function() {
return {
displayWarning: false,
navigationApproved: false,
targetRoute: null
}
},
computed: {
...mapState({
primaryEdit: state => state.prefs.primaryEditButton,
dangerDelete: state => state.prefs.dangerDeleteButton
})
},
methods: {
setPrimaryEdit() {
this.$store.commit("prefs/setEditButtonColor", !this.primaryEdit);
},
setDangerDelete() {
this.$store.commit("prefs/setDeleteButtonColor", !this.dangerDelete);
},
doNavigation() {
this.navigationApproved = true;
this.$router.push(this.targetRoute.path);
},
cancelNavigation() {
this.navigationApproved = false;
this.displayWarning = false;
}
},
beforeRouteLeave(to, from, next) {
if (this.navigationApproved) {
next();
} else {
this.targetRoute = to;
this.displayWarning = true;
next(false);
}
}
}
</script>
Listing 24-11Route Guarding in the Preferences.vue File in the src/components Folder
当应用要导航到显示不同组件的路线时,调用beforeRouteLeave
方法。在这个例子中,我提示用户确认,并阻止导航,直到它被接收。要查看效果,请单击首选项按钮,然后单击产品按钮。如图 24-6 所示,路线守卫将阻止导航,直到您点击“是”按钮。
图 24-6
使用组件路由保护
在 beforeRouteEnter 方法中访问组件
在创建组件之前调用beforeRouteEnter
方法,这确保了在组件生命周期开始之前可以取消导航。如果要使用此方法执行需要访问组件的属性和方法的任务,例如从 web 服务请求数据,这可能会导致问题。为了解决这个限制,传递给beforeRouteEnter
方法的next
函数可以接受一个回调函数,一旦组件被创建,这个回调函数就被调用,这允许beforeRouteEnter
方法访问组件,但是只有在取消或重定向导航的机会已经过去的时候。为了演示,我创建了一个组件,它使用beforeRouteEnter
方法来访问组件定义的方法,方法是在src/component
文件夹中添加一个名为FilteredData.vue
的文件,其内容如清单 24-12 所示。
<template>
<div>
<h3 class="bg-primary text-center text-white p-2">
Data for {{ category }}
</h3>
<div class="text-center m-2">
<label>Category:</label>
<select v-model="category">
<option>All</option>
<option>Watersports</option>
<option>Soccer</option>
<option>Chess</option>
</select>
</div>
<h3 v-if="loading" class="bg-info text-white text-center p-2">
Loading Data...
</h3>
<table v-else class="table table-sm table-bordered">
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
</tr>
<tbody>
<tr v-for="p in data" v-bind:key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.category }}</td>
<td>{{ p.price }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import Axios from "axios";
const baseUrl = "http://localhost:3500/products/";
export default {
data: function () {
return {
loading: true,
data: [],
category: "All"
}
},
methods: {
async getData(route) {
if (route.params != null && route.params.category != null) {
this.category = route.params.category;
} else {
this.category = "All";
}
let url = baseUrl
+ (this.category == "All" ? "" : `?category=${this.category}`);
this.data.push(...(await Axios.get(url)).data);
this.loading = false;
}
},
watch: {
category() {
this.$router.push(`/filter/${this.category}`);
}
},
async beforeRouteEnter(to, from, next) {
next(async component => await component.getData(to));
},
async beforeRouteUpdate(to, from, next) {
this.data.splice(0, this.data.length);
await this.getData(to);
next();
}
}
</script>
Listing 24-12The Contents of the FilteredData.vue File in the src/components Folder
该组件允许用户按类别过滤产品数据,每次都从使用 Axios 请求的 web 服务中重新检索产品数据,这在第十九章中有所描述。(为了简单起见,我直接使用 Axios 并将数据存储在组件中,而不是修改存储库和数据存储。)
该组件为用户提供了一个select
元素来选择用于过滤数据的类别。当使用select
元素时,category
属性被修改,这导致观察者执行到包含类别名称的 URL 的导航,因此例如选择Soccer
类别将导航到/filter/Soccer
URL。
该组件实现了两个组件路由保护方法,这两个方法都用于调用组件的异步getData
方法,该方法接受一个路由对象并使用它从 web 服务获取适当的数据。我在第十九章中添加的json-server
包支持过滤数据,所以例如对http://localhost:3500/products?category=Soccer
的请求将只返回那些类别属性为Soccer
的对象。其中一种路由保护方法很容易理解,如下所示:
...
async beforeRouteUpdate(to, from, next) {
this.data.splice(0, this.data.length);
await this.getData(to);
next();
}
...
这是我在整本书中使用的组件工作模式,它使用this
来引用组件对象,所以this.getData
被用来调用由组件定义的getData
方法。在beforeRouteEnter
方法中实现相同的效果需要不同的方法。
...
async beforeRouteEnter(to, from, next) {
next(component => component.getData(to));
},
...
在组件创建和它的常规生命周期开始之前,beforeRouteEnter
方法被调用,这意味着this
不能用于访问组件。相反,可以向next
方法传递一个函数,一旦创建了组件,该函数将被调用,并接收组件对象作为其参数。在清单中,一旦创建了组件,我就使用next
函数来调用组件上的getData
方法。
这似乎是一种异常复杂的请求数据的方法,使用作为组件生命周期一部分的created
方法可以很容易地完成,我在第十七章中对此进行了描述。beforeRouteEnter
方法有用的原因是它允许在组件创建之前取消导航,这在created
方法中是做不到的,后者只有在导航完成并且组件已经创建之后才被调用。为了演示,我在beforeRouteEnter
方法中添加了一个检查,如果用户在 URL 中指定了除All
之外的类别值,它将重定向导航,如清单 24-13 所示。
...
async beforeRouteEnter(to, from, next) {
if (to.params.category != "All") {
next("/filter/All");
} else {
next(async component => await component.getData(to));
}
},
...
Listing 24-13Guarding a Component in the FilteredData.vue File in the src/components Folder
该方法重定向任何导航到显示组件的 URL 的尝试,除非该 URL 指向从服务器请求所有数据的All
类别。一旦组件显示出来,就可以导航到其他组件,因为这些更改受到了beforeRouteUpdate
方法的保护。
当你看到它工作时,这个例子就更容易理解了。为了添加对新组件的支持,我将清单 24-14 中所示的语句添加到基本路由集中。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
import FilteredData from "../components/FilteredData";
export default [
{ path: "/preferences", component: Preferences },
{
path: "/products", component: Products,
children: [{ name: "table", path: "list", component: ProductDisplay },
{
name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor
},
{ path: "", redirect: "list" }]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}` },
{ path: "/filter/:category", component: FilteredData }
]
Listing 24-14Adding a Route in the basicRoutes.js File in the src/router Folder
为了方便导航到新路线,我将清单 24-15 中所示的导航元素添加到顶级App
组件的模板中。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/products"
active-class="btn-info" class="btn btn-primary">
Products
</router-link>
<router-link tag="button" to="/preferences"
active-class="btn-info" class="btn btn-primary">
Preferences
</router-link>
<router-link to="/named/tableleft" class="btn btn-primary"
active-class="btn-info">
Table Left
</router-link>
<router-link to="/named/tableright" class="btn btn-primary"
active-class="btn-info">
Table Right
</router-link>
<router-link to="/filter/All" class="btn btn-primary"
active-class="btn-info">
Filtered Data
</router-link>
</div>
</div>
</div>
<div class="row">
<div class="col m-2">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 24-15Adding a Navigation Element in the App.vue File in the src Folder
结果是一个新的按钮元素,它导航到一个选择了FilteredData
组件的路径。仅当类别动态段为All
时,路由保护方法才允许导航,并将重定向其他请求。一旦组件显示出来,使用select
元素选择一个不同的组件将导航到一个由beforeRouteUpdate
方法保护的 URL,该方法通过获取指定类别中的数据来响应路由的改变,如图 24-7 所示。
图 24-7
访问路由保护中的组件对象
使用组件路由保护进行开发
使用零部件路线防护装置可能会很困难。Vue.js 开发工具会动态更新应用,但这不会正确触发路由保护方法,您可能需要重新加载浏览器窗口才能看到路由保护的工作。
类似地,当浏览器和 web 服务都在同一个开发工作站上运行时,web 服务的响应可能会非常快,以至于您在等待数据时没有机会看到组件向用户提供的反馈。如果您想减慢数据加载的速度,以便可以看到组件在等待数据时的行为,那么在发出 HTTP 请求之前添加以下语句:
...
await new Promise(resolve => setTimeout(resolve, 3000));
...
例如,对于清单 24-15 中的组件,该语句将被插入到getData
方法中,紧接在使用 Axios 发送 HTTP 请求的语句之前,并将在发送请求之前引入三秒钟的暂停。
按需加载组件
路线所需的组件可以从应用的 JavaScript 包中排除,只在需要时才加载,使用我在第二十一章中描述的基本特性。使用 URL 路由时,只支持基本的延迟加载特性,忽略配置选项,如loading
或delay
(不过,我将在下一节演示如何使用 route guards 显示加载消息)。在清单 24-16 中,我修改了导入FilteredData
组件的语句,这样它就可以延迟加载了。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
const FilteredData = () => import("../components/FilteredData");
export default [
{ path: "/preferences", component: Preferences },
{
path: "/products", component: Products,
children: [{ name: "table", path: "list", component: ProductDisplay },
{
name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor
},
{ path: "", redirect: "list" }]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}` },
{ path: "/filter/:category", component: FilteredData }
]
Listing 24-16Lazily Loading a Component in the basicRoutes.js File in the src/router Folder
这是我在第二十一章中用过的import
函数,也有同样的效果。FilteredData
组件被排除在主应用 JavaScript 包之外,放在它自己的包中,在第一次需要时加载。
为确保FilteredData
仅在需要时加载,导航至http://localhost:8080
并打开浏览器的 F12 开发者工具至网络选项卡。在主浏览器窗口中,点击过滤数据按钮,你会看到一个名为0.js
的文件的 HTTP 请求被发送到服务器,如图 24-8 所示。(您可能会在请求中看到不同的文件名,但这并不重要。)
图 24-8
延迟加载组件
在构建过程中,创建了两个独立的 JavaScript 代码包。app.js
文件包含应用的主要部分,而0.js
文件只包含FilteredData
组件。(F12 工具显示的其他请求是初始 HTTP 请求、来自 web 服务的数据请求,以及连接回开发工具用来更新浏览器的服务器。)
小费
默认情况下,URL 路由的延迟加载功能将提供预取提示。有关禁用该功能的详细信息和配置说明,请参见第二十一章。
显示组件加载消息
在撰写本文时,Vue 路由器包不支持第二十一章中描述的显示加载或错误组件的功能。为了创建一个类似的特性,我将把一个数据存储属性与路由器防护结合起来,在延迟加载一个组件时向用户显示一条消息。首先,我在数据存储中添加了一个属性,该属性将指示组件何时被加载,以及一个改变其值的突变,如清单 24-17 所示。
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,
componentLoading: false
},
mutations: {
setComponentLoading(currentState, value) {
currentState.componentLoading = value;
},
saveProduct(currentState, product) {
let index = currentState.products.findIndex(p => p.id == product.id);
if (index == -1) {
currentState.products.push(product);
} else {
Vue.set(currentState.products, index, product);
}
},
// ...other data store features omitted for brevity...
}
})
Listing 24-17Adding a Data Property and Mutation in the index.js File in the src/store Folder
为了指示组件何时被加载,我将清单 24-18 中所示的元素添加到了App
组件的模板中,同时添加了到清单 24-17 中创建的数据存储属性的映射。
<template>
<div class="container-fluid">
<div class="row">
<div class="col text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/products"
active-class="btn-info" class="btn btn-primary">
Products
</router-link>
<router-link tag="button" to="/preferences"
active-class="btn-info" class="btn btn-primary">
Preferences
</router-link>
<router-link to="/named/tableleft" class="btn btn-primary"
active-class="btn-info">
Table Left
</router-link>
<router-link to="/named/tableright" class="btn btn-primary"
active-class="btn-info">
Table Right
</router-link>
<router-link to="/filter/All" class="btn btn-primary"
active-class="btn-info">
Filtered Data
</router-link>
</div>
</div>
</div>
<div class="row">
<div class="col m-2">
<h3 class="bg-warning text-white text-center p-2"
v-if="componentLoading">
Loading Component...
</h3>
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
name: 'App',
computed: {
...mapState(["componentLoading"]),
},
created() {
this.$store.dispatch("getProductsAction");
}
}
</script>
Listing 24-18Displaying a Loading Message in the App.vue File in the src Folder
为了设置数据存储属性的值并向用户显示消息,我在路由中添加了一个针对延迟加载组件的防护,如清单 24-19 所示。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
const FilteredData = () => import("../components/FilteredData");
import dataStore from "../store";
export default [
{ path: "/preferences", component: Preferences },
{
path: "/products", component: Products,
children: [{ name: "table", path: "list", component: ProductDisplay },
{
name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor
},
{ path: "", redirect: "list" }]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}` },
{ path: "/filter/:category", component: FilteredData,
beforeEnter: (to, from, next) => {
dataStore.commit("setComponentLoading", true);
next();
}
}
]
Listing 24-19Adding a Route Guard in the basicRoutes.js File in the src/router Folder
本例中的import
语句提供了对数据存储的访问。我在第二十章的例子中使用的$store
只在组件中可用,在应用的其余部分,通过import
语句可以访问数据存储。清单 24-19 中的 guard 方法使用setComponentLoading
突变来更新数据存储,然后调用next
函数。
处理加载错误
URL 路由的延迟加载功能不支持错误组件。为了处理加载组件或调用 route guards 时的错误,可以使用由VueRouter
对象定义的onError
方法来注册一个回调函数,当出现问题时将调用该函数。
清单 24-19 中定义的 guard 方法指示加载过程何时开始,但是我还需要指示加载过程何时完成,这样用户就不会再看到加载消息。在清单 24-20 中,我已经更新了将被延迟加载的组件中的路由保护。
警告
您可能想把两个突变语句都放在组件的beforeRouteEnter
guard 方法中。这将不起作用,因为组件的代码是应用正在加载的代码,并且在加载过程完成之前不能调用 route guard 方法。
...
async beforeRouteEnter(to, from, next) {
if (to.params.category != "All") {
next("/filter/All");
} else {
next(async component => {
component.$store.commit("setComponentLoading", false);
await component.getData(to)
});
}
},
...
Listing 24-20Updating a Route Guard in the FilteredData.vue File in the src/components Folder
我在回调函数中添加了一个语句,一旦导航被确认并且组件被创建,这个函数就会被调用。使用component
参数,我更新了数据存储并应用了表示加载过程完成的突变,产生了如图 24-9 所示的效果。
图 24-9
延迟加载组件时显示消息
加载期间隐藏传出组件
如果你检查图 24-9 ,你会看到在整个装载过程中,将要被移除的组件会显示给用户。虽然这对于许多项目来说是可以接受的,但是如果你想在等待新组件被加载的时候隐藏旧组件,就必须小心使用v-show
指令,如清单 24-21 所示。
...
<div class="row">
<div class="col m-2">
<h3 class="bg-warning text-white text-center p-2"
v-if="componentLoading">
Loading Component...
</h3>
<router-view v-show="!componentLoading"></router-view>
</div>
</div>
...
Listing 24-21Hiding an Element in the App.vue File in the src Folder
正如我在第十二章中解释的那样,v-show
隐藏了一个元素而没有移除它。这很重要,因为如果您使用v-if
或v-else
指令,那么router-view
元素将从文档对象模型中删除,并且加载的组件将永远不会被初始化并显示给用户。使用v-show
指令将router-view
元素留在文档中,并作为显示延迟加载组件的目标,如图 24-10 所示。
图 24-10
在组件加载期间隐藏路由器视图
创建无布线组件
并非所有的组件都被编写为利用 Vue 路由器包及其提供的$router
和$route
属性。例如,如果你想使用第三方编写的组件,你会发现大多数组件都是使用 Vue.js props 特性配置的,我在第十六章中描述过。Vue 路由器支持为组件提供适当值作为其路由配置的一部分,这使得将组件集成到使用 URL 路由的应用中成为可能,而不必修改它们或编写笨拙的包装器来适应它们。为了演示这个特性,我在src/components
文件夹中添加了一个名为MessageDisplay.vue
的文件,其内容如清单 24-22 所示。
<template>
<h3 class="bg-success text-white text-center p-2">
Message: {{ message }}
</h3>
</template>
<script>
export default {
props: ["message"]
}
</script>
Listing 24-22The Contents of the MessageDisplay.vue File in the src/components Folder
这个组件使用一个道具显示一条消息,这就是我演示这个特性所需要的全部内容。在清单 24-23 中,我在应用的配置中添加了两条路由,它们指向新组件,并使用不同的属性值对其进行配置。
import ProductDisplay from "../components/ProductDisplay";
import ProductEditor from "../components/ProductEditor";
import Preferences from "../components/Preferences";
import Products from "../components/Products";
import MessageDisplay from "../components/MessageDisplay";
const FilteredData = () => import("../components/FilteredData");
import dataStore from "../store";
export default [
{ path: "/preferences", component: Preferences },
{
path: "/products", component: Products,
children: [{ name: "table", path: "list", component: ProductDisplay },
{
name: "editor", path: ":op(create|edit)/:id(\\d+)?",
component: ProductEditor
},
{ path: "", redirect: "list" }]
},
{ path: "/edit/:id", redirect: to => `/products/edit/${to.params.id}` },
{ path: "/filter/:category", component: FilteredData,
beforeEnter: (to, from, next) => {
dataStore.commit("setComponentLoading", true);
next();
}
},
{ path: "/hello", component: MessageDisplay, props: { message: "Hello, Adam"}},
{ path: "/hello/:text", component: MessageDisplay,
props: (route) => ({ message: `Hello, ${route.params.text}`})},
{ path: "/message/:message", component: MessageDisplay, props: true},
]
Listing 24-23Adding Routes in the basicRoutes.js File in the src/router Folder
定义路线时,使用props
属性将属性传递给组件。清单 24-23 中添加的路线展示了使用props
属性向组件传递道具的三种不同方式。在第一条路线中,prop 值完全独立于路线,并且将总是被设置为相同的值,你可以通过导航到http://localhost:8080/hello
看到,在那里你将看到如图 24-11 所示的结果。
图 24-11
传递固定属性值
另外两条路径使用路径的动态线段值来设置属性值。可以为 props 值分配一个函数,该函数接收当前路径作为其参数,并返回一个包含 props 值的对象,如下所示:
...
{ path: "/hello/:text", component: MessageDisplay,
props: (route) => ({ message: `Hello, ${route.params.text}`})},
...
这个例子从text
段获取值,并使用它来设置message
属性的值,如果需要处理来自 URL 的值,这是一个有用的技术。如果您不需要处理动态段,那么您可以使用最后一种技术,即将props
值设置为 true ,
,如下所示:
...
{ path: "/message/:message", component: MessageDisplay, props: true},
...
这具有使用来自当前路线的params
值作为属性值的效果,这避免了为每个动态段和属性显式定义映射的需要(尽管段的名称必须与组件期望的属性名称相匹配)。要查看效果,导航到http://localhost:8080/hello/adam
和http://localhost:8080/message/Hello%20Adam
,这将产生如图 24-12 所示的结果。
图 24-12
将动态段值映射到组件属性
摘要
在本章中,我解释了如何使用多个 JavaScript 文件对相关路由进行分组,以使路由配置更易于管理。我还向您展示了如何保护路由以控制它们的激活,并演示了如何在路由需要组件时延迟加载组件。在本章的最后,我向您展示了如何配置一个组件的 props,当您想要使用尚未编写的组件从路由系统中获取它们的配置信息时,这是一项非常有用的技术。在下一章中,我将向您展示如何使用过渡功能。
二十五、过渡
Vue.js 转换特性允许您在添加或删除 HTML 元素或改变位置时做出响应。当结合现代浏览器提供的特性时,过渡可以用来将用户的注意力吸引到应用中受其行为影响的部分。在本章中,我将向您展示使用过渡的不同方式,演示如何使用第三方 CSS 和 JavaScript 动画包,并向您展示如何将用户的注意力吸引到其他类型的更改上,例如当数据值被修改时。表 25-1 将本章放在上下文中。
表 25-1
将过渡置于上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 转换是在关键时刻从类中添加和删除元素的指令,比如在 DOM 中添加和删除元素。这些类用于逐渐应用元素样式的变化来创建动画效果。 |
| 它们为什么有用? | 过渡是一种有用的方式,可以将用户的注意力吸引到重要的变化上,或者使变化不那么刺耳。 |
| 它们是如何使用的? | 使用transition
和transition-group
元素应用过渡。 |
| 有什么陷阱或限制吗? | 人们很容易忘乎所以,创建一个应用,其中包含的效果会让用户感到沮丧,并扰乱有效的工作流程。 |
| 还有其他选择吗? | 转场是可选功能,您不必在项目中使用它们。 |
表 25-2 总结了本章内容。
表 25-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 应用过渡 | 使用transition
元素并定义与过渡类匹配的样式 | 13, 15, 16, 20–22 |
| 使用动画库 | 使用transition
元素属性指定将应用动画的类 | 14, 17 |
| 确定元素之间的过渡是如何呈现的 | 使用mode
属性 | Fifteen |
| 将过渡应用于一组重复的元素 | 使用transition-group
元素 | Eighteen |
| 接收转换通知 | 处理过渡事件 | Nineteen |
为本章做准备
为了创建本章示例所需的项目,在一个方便的位置运行清单 25-1 中所示的命令来创建一个新的 Vue.js 项目。
vue create transitions --default
Listing 25-1Creating the Example Project
这个命令创建了一个名为transitions
的项目。一旦设置过程完成,运行transitions
文件夹中清单 25-2 所示的命令,将引导 CSS 和 Vue 路由器包添加到项目中。
npm install bootstrap@4.0.0
npm install vue-router@3.0.1
Listing 25-2Adding Packages
为了给本章中的例子提供效果,我使用了animate.css
和popmotion
包。运行transitions
文件夹中清单 25-3 所示的命令,下载并安装软件包。
npm install animate.css@3.6.1
npm install popmotion@8.1.24
Listing 25-3Adding the Animation Packages
将清单 25-4 中显示的语句添加到src
文件夹中的main.js
文件中,将Bootstrap
和动画包合并到应用中。
import Vue from 'vue'
import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css";
import "animate.css/animate.min.css";
import "popmotion/dist/popmotion.global.min.js";
Vue.config.productionTip = false
new Vue({
render: h => h(App)
}).$mount('#app')
Listing 25-4Incorporating the Packages in the main.js File in the src Folder
创建组件
我需要这一章的一些基本组件。我首先将一个名为SimpleDisplay.vue
的文件添加到src/components
文件夹中,其内容如清单 25-5 所示。
<template>
<div class="mx-5 border border-dark p-2">
<h3 class="bg-warning text-white text-center p-2">Display</h3>
<div v-if="show" class="h4 bg-info text-center p-2">Hello, Adam</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="toggle">
Toggle Visibility
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
show: true
}
},
methods: {
toggle() {
this.show = !this.show;
}
}
}
</script>
Listing 25-5The Contents of the SimpleDisplay.vue File in the src/components Folder
该组件显示一条消息,使用v-if
指令管理该消息的可见性。接下来,我在src/components
文件夹中添加了一个名为Numbers.vue
的文件,其内容如清单 25-6 所示。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div class="col h3">= {{ total }} </div>
</div>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
first: 10,
second: 20
}
},
computed: {
total() {
return this.first + this.second;
}
}
}
</script>
Listing 25-6The Contents of the Numbers.vue File in the src/components Folder
该组件显示两个使用v-model
指令更新数据属性的input
元素,这些数据属性通过一个计算属性相加,该属性也显示在模板中。接下来,我在src/components
文件夹中添加了一个名为ListMaker.vue
的文件,其内容如清单 25-7 所示。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-info text-white text-center p-2">My List</h3>
<table class="table table-sm">
<tr><th>#</th><th>Item</th><th width="20%" colspan="2"></th></tr>
<tr v-for="(item, i) in items" v-bind:key=item>
<td>{{i}}</td>
<td>{{item}}</td>
<td>
<button class="btn btn-sm btn-info" v-on:click="moveItem(i)">
Move
</button>
<button class="btn btn-sm btn-danger" v-on:click="removeItem(i)">
Delete
</button>
</td>
</tr>
<controls v-on:add="addItem" />
</table>
</div>
</template>
<script>
import Controls from "./ListMakerControls";
export default {
components: { Controls },
data: function () {
return {
items: ["Apples", "Oranges", "Grapes"]
}
},
methods: {
addItem(item) {
this.items.push(item);
},
removeItem(index) {
this.items.splice(index, 1);
},
moveItem(index) {
this.items.push(...this.items.splice(index, 1));
}
}
}
</script>
Listing 25-7The Contents of the ListMaker.vue File in the src/components Folder
该组件显示一组项目。新的项目可以添加到数组中,现有的项目可以移动到数组的末尾或从数组中删除。我将在本章的后面对组件的模板进行修改,为了避免列出与示例不直接相关的 HTML 元素,我通过在src/components
文件夹中添加一个名为ListMakerControls.vue
的文件来创建一个支持组件,其内容如清单 25-8 所示。
<template>
<tfoot>
<tr v-if="showAdd">
<td></td>
<td><input class="form-control" v-model="currentItem" /></td>
<td>
<button id="add" class="btn btn-sm btn-info" v-on:click="handleAdd">
Add
</button>
<button id="cancel" class="btn btn-sm btn-secondary"
v-on:click="showAdd = false">
Cancel
</button>
</td>
</tr>
<tr v-else>
<td colspan="4" class="text-center p-2">
<button class="btn btn-info" v-on:click="showAdd = true">
Show Add
</button>
</td>
</tr>
</tfoot>
</template>
<script>
export default {
data: function () {
return {
showAdd: false,
currentItem: ""
}
},
methods: {
handleAdd() {
this.$emit("add", this.currentItem);
this.showAdd = false;
}
}
}
</script>
Listing 25-8The Contents of the ListMakerControls.vue in the src/components Folder
该组件允许向列表中添加新项目,并且是我在清单 25-7 中创建的组件的一个依赖项。
配置 URL 路由
为了设置 URL 路由系统,我创建了src/router
文件夹,并在其中添加了一个名为index.js
的文件,其内容如清单 25-9 所示。
import Vue from "vue"
import Router from "vue-router"
import SimpleDisplay from "../components/SimpleDisplay";
import ListMaker from "../components/ListMaker";
import Numbers from "../components/Numbers";
Vue.use(Router)
export default new Router({
mode: "history",
routes: [
{ path: "/display", component: SimpleDisplay },
{ path: "/list", component: ListMaker },
{ path: "/numbers", component: Numbers },
{ path: "*", redirect: "/display" }
]
})
Listing 25-9The Contents of the index.js File in the src/router Folder
该配置启用历史 API 模式,并定义针对前一部分创建的组件的/display
、/numbers
和/list
路线。还有一个包罗万象的路由,将浏览器重定向到/display
URL。在清单 25-10 中,我将路由器导入到main.js
文件中,并添加了使路由特性可用的属性。
import Vue from 'vue'
import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css";
import "animate.css/animate.min.css";
import "popmotion/dist/popmotion.global.min.js";
import router from "./router";
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')
Listing 25-10Enabling Routing in the main.js File in the src Folder
创建导航元素
最后的准备步骤是将导航元素添加到根组件的模板中,这些元素将指向路由配置中定义的 URL,如清单 25-11 所示。
<template>
<div class="m-2">
<div class="text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/display"
exact-active-class="btn-warning" class="btn btn-secondary">
Simple Display
</router-link>
<router-link tag="button" to="/list"
exact-active-class="btn-info" class="btn btn-secondary">
List Maker
</router-link>
<router-link tag="button" to="/numbers"
exact-active-class="btn-success" class="btn btn-secondary">
Numbers
</router-link>
</div>
</div>
<router-view />
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
Listing 25-11Adding Navigation Elements in the App.vue File in the src Folder
运行transitions
文件夹中清单 25-12 所示的命令,启动开发工具。
npm run serve
Listing 25-12Starting the Development Tools
将执行初始绑定过程,之后您将看到一条消息,告诉您项目已成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口并导航到http://localhost:8080
以查看如图 25-1 所示的内容。
图 25-1
运行示例应用
应用过渡的指南
开发人员在应用过渡时经常会忘乎所以,结果是用户感到沮丧的应用。这些特性应该尽量少用,应该简单,应该快速。使用过渡来帮助用户理解你的应用,而不是作为展示你艺术技巧的工具。用户,尤其是公司业务线应用,必须重复执行相同的任务,过多和过长的动画只会碍事。
我深受这种倾向的困扰,如果不加检查,我的应用的行为就像拉斯维加斯的老丨虎丨机。我遵循两条规则来控制问题。首先,我连续 20 次执行应用中的主要任务或工作流。在示例应用中,这可能意味着向列表中添加 20 个条目,移动它们,然后删除它们。在进入下一步之前,我会消除或缩短我发现自己必须等待完成的任何效果。
第二条规则是,我不会在开发过程中禁用特效。当我在开发一个特性的时候,注释掉一个过渡或者动画是很有诱惑力的,因为我在写代码的时候会执行一系列的快速测试。但是任何妨碍我的动画也会妨碍用户,所以我保留过渡并调整它们——通常减少它们的持续时间——直到它们变得不那么突兀和烦人。
当然,你不必遵循我的规则,但重要的是要确保过渡对用户有帮助,而不是快速工作的障碍或令人分心的烦恼。
过渡入门
默认情况下,对组件模板中 HTML 元素所做的更改会立即生效,您可以通过单击由SimpleDisplay
组件显示的切换可见性按钮来查看。每次点击按钮,show
数据属性都会被修改,这使得v-if
指令立即显示和隐藏它所应用的元素,如图 25-2 所示。
注意
静态截图不太适合显示应用中的变化。本章中的示例是最好的第一手体验,有助于理解 Vue.js 过渡功能是如何工作的。
图 25-2
状态更改的默认行为
Vue.js 转换特性可用于管理从一种状态到另一种状态的变化,这是使用transition
组件完成的。过渡组件的基本用途是应用 CSS 过渡,我已经在清单 25-13 中完成了。
Vue。Js 过渡与 CSS 过渡和动画
我在本章中描述的特性有术语冲突。Vue.js 转换特性用于响应应用状态的变化。这通常与在 DOM 中添加和删除 HTML 元素有关,但也可能是对数据值变化的响应。
响应 HTML 元素变化的最常见方式是使用 Vue.js 转换特性来应用 CSS 转换。CSS 过渡是在一组 CSS 属性值和另一组之间的逐渐变化,它具有动画元素变化的效果。你可以在清单 25-13 中看到一个 CSS 转换的例子。CSS 动画类似于 CSS 过渡,但提供了更多关于如何更改 CSS 属性值的选项。你可能遇到的另一个术语是 CSS 转换,它允许你移动、旋转、缩放和倾斜 HTML 元素。变换通常与 CSS 过渡或动画相结合,以创建更复杂的效果。
如果您发现自己陷入了这些术语的困境,请记住,Vue.js 转换特性对于 Vue.js 应用开发来说是最重要的。在最初的例子让我演示了 Vue.js 特性是如何工作的之后,我没有直接使用 CSS 特性,而是依赖第三方包来提供向用户显示的效果。我建议您在自己的项目中也这样做,因为与尝试直接使用 CSS 过渡、动画和变换功能创建自己的复杂效果相比,结果更可预测,也更少令人沮丧。
<template>
<div class="mx-5 border border-dark p-2">
<h3 class="bg-warning text-white text-center p-2">Display</h3>
<transition>
<div v-if="show" class="h4 bg-info text-center p-2">Hello, Adam</div>
</transition>
<div class="text-center">
<button class="btn btn-primary" v-on:click="toggle">
Toggle Visibility
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
show: true
}
},
methods: {
toggle() {
this.show = !this.show;
}
}
}
</script>
<style>
.v-leave-active {
opacity: 0;
font-size: 0em;
transition: all 250ms;
}
.v-enter {
opacity: 0;
font-size: 0em;
}
.v-enter-to {
opacity: 1;
font-size: x-large;
transition: all 250ms;
}
</style>
Listing 25-13Applying a Transition in the SimpleDisplay.vue File in the src/components Folder
一旦你看到它的工作,这个例子就更容易理解了。一旦保存了列表中显示的更改,重新加载浏览器并单击切换可见性按钮。元素的可见性不是瞬间变化的,而是逐渐变化的,如图 25-3 所示。(从图中可能很难看出,但是 HTML 元素变得越来越小,逐渐从视图中消失。)
小费
您可能会发现您必须重新加载浏览器才能看到本章示例的预期结果。这是 webpack 捆绑过程处理变化的结果。
图 25-3
过渡的效果
通过将您想要处理的元素包装在一个transition
元素中来应用过渡,如下所示:
...
<transition>
<div v-if="show" class="h4 bg-info text-center p-2">Hello, Adam</div>
</transition>
...
Vue.js 并不为元素本身制作动画,而是将元素添加到许多类中,并让浏览器应用与这些类相关联的任何效果。一旦您理解了所涉及的步骤,这并不像看起来那么复杂,我将在下面的部分中描述这些步骤。
理解转换类和 CSS 转换
transition
元素的作用是在其转换期间将它包含的元素添加到一系列类中,对于本例来说,这是在元素被添加到 DOM 或从 DOM 中移除时。表 25-3 描述了这些类别。
表 25-3
过渡班
|名字
|
描述
|
| --- | --- |
| v-enter
| 元素在添加到 DOM 之前被添加到这个类中,之后立即被删除。 |
| v-enter-active
| 该元素在添加到 DOM 之前被添加到该类中,并在转换完成时被移除。 |
| v-enter-to
| 元素在被添加到 DOM 后立即被添加到这个类中,并在转换完成时被移除。 |
| v-leave
| 该元素在过渡开始时被添加到该类中,并在一帧后被移除。 |
| v-leave-active
| 元素在过渡开始时被添加到该类中,在过渡结束时被移除。 |
| v-leave-to
| 该元素被添加到该类的过渡中的第一帧,并在完成时被移除。 |
当转换开始和停止时,被转换的元素被添加到表中显示的类中,并从表中显示的类中移除,并且总是以相同的顺序。图 25-4 显示了当元素被添加到 DOM 时,类成员的顺序,显示了转换和类之间的关系。
图 25-4
进入过渡阶段
v-enter
类用于定义 HTML 元素在添加到 DOM 之前的初始状态。在清单 25-13 中,我定义了一个 CSS 样式,带有一个匹配v-enter
类中元素的选择器,如下所示:
...
.v-enter {
opacity: 0;
font-size: 0em;
}
...
当一个元素是v-enter
类的成员时,它的不透明度将为零(使元素透明),它的文本将具有零高度(这将为这个例子设置元素的高度)。这表示元素在添加到 DOM 之前的起始状态,因此它是透明的,高度为零。
在清单 25-13 中,我使用了带有选择器的 CSS 样式,该选择器匹配v-enter-to
类中的元素,如下所示:
...
.v-enter-to {
opacity: 1;
font-size: x-large;
transition: all 250ms;
}
...
属性的值使元素完全不透明,属性的值指定大文本。由v-enter-to
风格定义的属性代表了转换的结束状态,这是许多开发人员感到困惑的部分。关键是transition
属性,它告诉浏览器将应用于该元素的所有 CSS 属性的值从当前值逐渐更改为该样式定义的值,并在 250 毫秒内完成此操作。浏览器可以逐渐改变 CSS 属性的值,这些属性可以用数值来表示,包括字体大小、填充和边距,甚至颜色。
理解过渡序列
将类和 CSS 样式放在一起会产生显示元素的 Vue.js 转换。当您单击切换可见性按钮时,v-if
指令确定它应该将div
元素添加到 DOM 中。div
元素已经是几个 Bootstrap 类的成员,这些类设置文本大小、背景、填充和其他显示特性。为了准备转换,Vue.js 将元素添加到v-enter
类中,该类设置了opacity
和font-size
属性;这些属性将元素的初始外观设置为透明,并且不赋予它高度。
在添加到 DOM 之后,元素立即从v-enter
类中移除,并添加到v-enter-to
类中。样式的改变在 250 毫秒的时间段内增加了不透明度和字体大小,结果是元素的大小和不透明度快速增长。在 250 毫秒结束时,元素从v-enter-to
类中被移除,并且只通过它在引导类中的成员来设置样式,效果如图 25-5 所示。
图 25-5
进入过渡的效果
使用动画库
您可以直接使用 CSS 过渡、动画和翻译功能,但它们很难使用,并且除了最基本的效果之外,还需要经验和仔细的测试才能获得良好的效果。更好的方法是使用一个动画库,比如我在本章开始时添加到项目中的animate.css
包。有很多高质量的动画库可以使用,它们包含了随时可用且易于应用的效果。
在清单 25-14 中,我已经用animate.css
库提供的效果替换了我为SimpleDisplay
组件中的div
元素定制的效果。
小费
您不一定要使用animate.css
,但是如果您不熟悉过渡,我推荐您从它开始。您可以在 https://github.com/daneden/animate.css
看到套装包含的全部效果。
<template>
<div class="mx-5 border border-dark p-2">
<h3 class="bg-warning text-white text-center p-2">Display</h3>
<transition enter-to-class="fadeIn" leave-to-class="fadeOut">
<div v-if="show" class="animated h4 bg-info text-center p-2">
Hello, Adam
</div>
</transition>
<div class="text-center">
<button class="btn btn-primary" v-on:click="toggle">
Toggle Visibility
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
show: true
}
},
methods: {
toggle() {
this.show = !this.show;
}
}
}
</script>
Listing 25-14Using Library Animations in the SimpleDisplay.vue File in the src/components Folder
animate.css
包要求它所应用的元素是animated
类的成员,我已经将它直接应用于清单 25-14 中的div
元素。为了应用单独的效果,我使用了enter-to-class
和leave-to-class
属性,它们允许元素在转换过程中被添加到的类的名称被改变。表 25-4 列出了允许选择类别的属性。
表 25-4
过渡类选择属性
|名字
|
描述
|
| --- | --- |
| enter-class
| 该属性用于指定将代替v-enter
使用的类的名称。 |
| enter-active-class
| 该属性用于指定将代替v-enter-active
使用的类的名称。 |
| enter-to-class
| 该属性用于指定将代替v-enter-to
使用的类的名称。 |
| leave-class
| 该属性用于指定将代替v-leave
使用的类的名称。 |
| leave-active-class
| 该属性用于指定将代替v-leave-active
使用的类的名称。 |
| leave-to-class
| 该属性用于指定将代替v-leave-to
使用的类的名称。 |
在清单中,我使用了enter-to-class
和leave-to-class
属性来指定由animate.css
包提供的动画类。正如类名所示,fadeIn
类应用了一种元素淡入视图的效果,而fadeOut
类应用了一种元素淡出的效果。
在多个元素之间切换
过渡的效果可以应用于由v-if
、v-else-if
和v-else
指令组合显示的多个元素。需要一个单独的transition
元素,当这些元素被添加到 DOM 或者从 DOM 中移除时,Vue.js 会自动将它们添加到正确的类中。在清单 25-15 中,我添加了一个元素,它的可见性由v-else
指令管理,并且包含在与应用了v-if
指令的元素相同的transition
元素中。
<template>
<div class="mx-5 border border-dark p-2">
<h3 class="bg-warning text-white text-center p-2">Display</h3>
<transition enter-active-class="fadeIn"
leave-active-class="fadeOut" mode="out-in">
<div v-if="show" class="animated h4 bg-info text-center p-2"
key="hello">
Hello, Adam
</div>
<div v-else class="animated h4 bg-success text-center p-2"
key="goodbye">
Goodbye, Adam
</div>
</transition>
<div class="text-center">
<button class="btn btn-primary" v-on:click="toggle">
Toggle Visibility
</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
show: true
}
},
methods: {
toggle() {
this.show = !this.show;
}
}
}
</script>
Listing 25-15Adding an Element in the SimpleDisplay.vue File in the src/components Folder
当想要将过渡应用到相同类型的多个元素时,如本例中的两个div
元素,那么必须应用key
属性,以便 Vue.js 可以区分元素:
...
<div v-if="show" class="animated h4 bg-info text-center p-2" key="hello">
Hello, Adam
</div>
...
小费
请注意,我已经使用enter-active-class
和leave-active-class
属性应用了效果。当使用动画库在元素之间进行过渡时,在整个过渡过程中应用动画是很重要的;否则,会有一个不幸的小故障,即将离开的元素会在被移除前的几分之一秒内突然回到视图中。
默认情况下,Vue.js 同时过渡两个元素,这意味着一个元素淡入,另一个元素淡出。这并没有为这个例子创造出想要的效果,因为一个元素是用来替换另一个元素的。我已经告诉 Vue.js 使用transition
元素上的mode
属性错开元素的过渡,可以给定表 25-5 中描述的值。
表 25-5
模式属性值
|名字
|
描述
|
| --- | --- |
| in-out
| 首先转换传入元素,然后转换传出元素。 |
| out-in
| 首先转换这个传出元素,然后转换传入元素。 |
我选择了清单 25-15 中的out-in
模式,这意味着 Vue.js 将等待直到传出元素完成其转换,然后开始传入元素的转换,结果如图 25-6 所示。
图 25-6
过渡多个元素
调整动画库效果的速度
使用动画库是应用过渡的好方法,但它们并不总是完全符合您的需要。我遇到的一个常见问题是,它们可能需要很长时间才能完成,当您在多个元素之间切换,并且必须等待几个效果执行时,这就会成为一个问题。
一些动画库允许你为一个效果指定一个速度,但是对于其他包——包括animate.css
——你可以通过创建一个设置animation-duration
属性的类来改变时间量,就像这样:
...
<style>
.quick { animation-duration: 250ms }
</style>
...
然后,您可以在转换过程中向该类添加元素,如下所示:
...
<transition enter-active-class="fadeIn quick"
leave-active-class="fadeOut quick" mode="out-in">
...
每个转换将在您指定的时间范围内执行,在本例中为 250 毫秒。
将过渡应用到 URL 路由元素
当一个router-view
元素显示的元素改变时,可以使用相同的方法来应用效果,如清单 25-16 所示。
<template>
<div class="m-2">
<div class="text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/display"
exact-active-class="btn-warning" class="btn btn-secondary">
Simple Display
</router-link>
<router-link tag="button" to="/list"
exact-active-class="btn-info" class="btn btn-secondary">
List Maker
</router-link>
<router-link tag="button" to="/numbers"
exact-active-class="btn-success" class="btn btn-secondary">
Numbers
</router-link>
</div>
</div>
<transition enter-active-class="animated fadeIn"
leave-active-class=" animated fadeOut" mode="out-in">
<router-view />
</transition>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
Listing 25-16Applying a Transition in the App.vue File in the src Folder
使用router-view
元素时不需要提供key
属性,因为 URL 路由系统能够区分组件。使用导航按钮可以看到清单 25-16 中的过渡效果,如图 25-7 所示。
图 25-7
将过渡应用到 URL 路由元素
为元素的外观应用过渡
默认情况下,Vue.js 不会将过渡应用到元素的初始显示。您可以通过向transition
元素添加appear
属性来覆盖它。默认情况下,Vue.js 将使用enter c
类,但是你也可以使用专门用于初始外观的类或者使用属性指定类,如表 25-6 所述。
表 25-6
元素外观的 VueTransition 类
|名字
|
描述
|
| --- | --- |
| v-appear
| 元素在最初出现之前被添加到这个类中,在被添加到 DOM 中之后立即被移除。可以使用appear-class
属性指定一个自定义类。 |
| v-appear-active
| 该元素在初始出现之前被添加到该类中,并在过渡完成时被移除。可以使用appear-active-class
属性指定一个自定义类。 |
| v-appear-to
| 元素在被添加到 DOM 后立即被添加到这个类中,并在转换完成时被移除。可以使用appear-to-class
属性指定一个自定义类。 |
在清单 25-17 中,我应用了一个只有在元素第一次出现时才会应用的过渡。
<template>
<div class="m-2">
<div class="text-center m-2">
<div class="btn-group">
<router-link tag="button" to="/display"
exact-active-class="btn-warning" class="btn btn-secondary">
Simple Display
</router-link>
<router-link tag="button" to="/list"
exact-active-class="btn-info" class="btn btn-secondary">
List Maker
</router-link>
<router-link tag="button" to="/numbers"
exact-active-class="btn-success" class="btn btn-secondary">
Numbers
</router-link>
</div>
</div>
<transition enter-active-class="animated fadeIn"
leave-active-class=" animated fadeOut" mode="out-in"
appear appear-active-class="animated zoomIn">
<router-view />
</transition>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
Listing 25-17Adding a Transition in the App.vue File in the src Folder
我添加了不需要值的appear
属性,并使用appear-active-class
属性告诉 Vue.js 在整个转换过程中应该将元素分配给animated
和zoomIn
类。结果是当应用第一次启动时,router-view
元素显示的组件被放大到视图中,如图 25-8 所示。
小费
您必须重新加载浏览器才能看到这种转换。
图 25-8
为元素的初始外观应用过渡
为集合更改应用过渡
Vue.js 支持将过渡应用到使用v-for
指令生成的元素,允许在添加、删除或移动元素时指定效果。在清单 25-18 中,我已经将过渡应用到了ListMaker
组件。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-info text-white text-center p-2">My List</h3>
<table class="table table-sm">
<tr><th>#</th><th>Item</th><th width="20%" colspan="2"></th></tr>
<transition-group enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut"
move-class="time"
tag="tbody">
<tr v-for="(item, i) in items" v-bind:key=item>
<td>{{i}}</td>
<td>{{item}}</td>
<td>
<button class="btn btn-sm btn-info" v-on:click="moveItem(i)">
Move
</button>
<button class="btn btn-sm btn-danger"
v-on:click="removeItem(i)">
Delete
</button>
</td>
</tr>
</transition-group>
<controls v-on:add="addItem" />
</table>
</div>
</template>
<script>
import Controls from "./ListMakerControls";
export default {
components: { Controls },
data: function () {
return {
items: ["Apples", "Oranges", "Grapes"]
}
},
methods: {
addItem(item) {
this.items.push(item);
},
removeItem(index) {
this.items.splice(index, 1);
},
moveItem(index) {
this.items.push(...this.items.splice(index, 1));
}
}
}
</script>
<style>
.time {
transition: all 250ms;
}
</style>
Listing 25-18Applying a Transition in the ListMaker.vue File in the src/components Folder
与transition
元素不同,transition-group
元素在应用时需要小心,因为它向 DOM 添加了一个元素。为了确保组件生成有效的 HTML,使用了tag
属性来指定将要生成的 HTML 元素的类型。由于示例组件使用v-for
指令来生成一组表行,所以我指定了代表表体的tbody
元素。
...
<transition-group enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut" move-class="time" tag="tbody">
...
进入和离开转换的应用方式与前面的例子相同,我使用了enter-active-class
和leave-active-class
属性将由v-for
指令生成的元素放入animated
、fadeIn
和fadeOut
类,因为它们被添加和删除。剩下的属性是move-class
,用于在元素从一个位置移动到另一个位置时应用一个类。Vue.js 会自动将元素从现有位置转换到新位置,如果这是您需要的唯一效果,那么可以使用move-class
属性将元素与指定移动所需时间的样式相关联。在清单中,我指定了一个名为time
的类,并定义了一个相应的 CSS 样式,该样式使用transition
属性来指定元素的所有属性都应该在 250 毫秒内进行更改。
注意
由transition-group
属性管理的元素必须有一个键,如第十三章所述。
我对transition-group
元素使用的属性导致新的元素淡入到位,被删除的元素淡出,当点击移动按钮时元素移动到位,最后一个如图 25-9 所示。
图 25-9
动画收藏项目移动
使用转换事件
transition
和transition-group
元素发出事件,这些事件可以被处理以提供对转换的细粒度控制,包括根据应用的状态调整它们。表 25-7 描述了这些事件。
表 25-7
过渡事件
|名字
|
描述
|
| --- | --- |
| before-enter
| 在 enter 转换开始并接收受影响的 HTML 元素之前调用此方法。 |
| enter
| 该方法在进入转换开始之前被调用,并接收受影响的 HTML 元素和一个回调,该回调必须被调用以告知 Vue.js 转换已经完成。 |
| after-enter
| 在 enter 转换完成并接收受影响的 HTML 元素之前,调用此方法。 |
| before-leave
| 此方法在 leave 转换开始并接收受影响的 HTML 元素之前被调用。 |
| leave
| 该方法在 leave 转换开始之前被调用,并接收受影响的 HTML 元素和一个回调,该回调必须被调用以告知 Vue.js 转换已经完成。 |
| after-leave
| 此方法在 leave 转换完成并接收受影响的 HTML 元素之前被调用。 |
在清单 25-19 中,我使用v-on
指令定义了转换事件的处理程序,并使用它们以编程方式应用效果。
<template>
<tfoot>
<transition v-on:beforeEnter="beforeEnter"
v-on:after-enter="afterEnter" mode="out-in">
<tr v-if="showAdd" key="addcancel">
<td></td>
<td><input class="form-control" v-model="currentItem" /></td>
<td>
<button id="add" class="btn btn-sm btn-info"
v-on:click="handleAdd">
Add
</button>
<button id="cancel" class="btn btn-sm btn-secondary"
v-on:click="showAdd = false">
Cancel
</button>
</td>
</tr>
<tr v-else key="show">
<td colspan="4" class="text-center p-2">
<button class="btn btn-info" v-on:click="showAdd = true">
Show Add
</button>
</td>
</tr>
</transition>
</tfoot>
</template>
<script>
export default {
data: function () {
return {
showAdd: false,
currentItem: ""
}
},
methods: {
handleAdd() {
this.$emit("add", this.currentItem);
this.showAdd = false;
},
beforeEnter(el) {
if (this.showAdd) {
el.classList.add("animated", "fadeIn");
}
},
afterEnter(el) {
el.classList.remove("animated", "fadeIn");
}
}
}
</script>
Listing 25-19Handling Transition Events in the ListMakerControls.vue File in the src/components Folder
在before-enter
事件的处理程序中,我检查了showAdd
数据属性的值,以查看是否应该对更改进行动画处理。结果是当用户单击“显示添加”按钮时应用过渡效果,而当用户单击“添加”或“取消”按钮时没有效果。
使用进入和离开事件
当您想要执行从开始到结束的自定义转换时,enter
和leave
事件非常有用,通常您想要以编程方式为元素属性生成一系列值,或者使用 JavaScript 库来完成这项工作。在清单 25-20 中,我处理了event
方法,将 HTML 元素添加到animate.css
类中,然后监听指示动画何时完成的 DOM 事件。
<template>
<tfoot>
<transition v-on:enter="enter" mode="out-in">
<tr v-if="showAdd" key="addcancel">
<td></td>
<td><input class="form-control" v-model="currentItem" /></td>
<td>
<button id="add" class="btn btn-sm btn-info"
v-on:click="handleAdd">
Add
</button>
<button id="cancel" class="btn btn-sm btn-secondary"
v-on:click="showAdd = false">
Cancel
</button>
</td>
</tr>
<tr v-else key="show">
<td colspan="4" class="text-center p-2">
<button class="btn btn-info" v-on:click="showAdd = true">
Show Add
</button>
</td>
</tr>
</transition>
</tfoot>
</template>
<script>
import { styler, tween } from "popmotion";
export default {
data: function () {
return {
showAdd: false,
currentItem: ""
}
},
methods: {
handleAdd() {
this.$emit("add", this.currentItem);
this.showAdd = false;
},
enter(el, done) {
if (this.showAdd) {
let t = tween({
from: { opacity: 0 },
to: { opacity: 1 },
duration: 250
});
t.start({
update: styler(el).set,
complete: done
})
}
}
}
}
</script>
Listing 25-20Using the Enter Event in the ListMakeControls.vue File in the src/components Folder
这个例子使用了popmotion
包,这是一个使用 JavaScript 而不是 CSS 的动画库。popmotion 如何工作的细节对于本章并不重要——详见http://popmotion.io
——这个清单只是为了演示您可以使用enter
和leave
事件通过 JavaScript 执行转换。
注意
注意清单 25-20 中的enter
方法定义了一个done
参数。这是一个回调函数,用来告诉 Vue.js 过渡已经完成。在调用done
函数之前,Vue.js 不会触发after-enter
事件,因此确保直接调用该方法或者将它用作 JavaScript 库的完成回调非常重要,就像我在清单中所做的那样。
引起对其他变化的注意
如果您想在应用的数据发生变化时引起用户的注意,那么您可以使用一个观察器来响应新的值。在清单 25-21 中,我使用了popmotion
包来创建一个当用户向由Numbers
组件呈现的输入元素输入新值时的过渡效果。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div class="col h3">= {{ displayTotal }} </div>
</div>
</div>
</div>
</template>
<script>
import { tween } from "popmotion";
export default {
data: function () {
return {
first: 10,
second: 20,
displayTotal: 30
}
},
computed: {
total() {
return this.first + this.second;
}
},
watch: {
total(newVal, oldVal) {
let t = tween({
from: Number(oldVal),
to: Number(newVal),
duration: 250
});
t.start((val) => this.displayTotal = val.toFixed(0));
}
}
}
</script>
Listing 25-21Responding to Changes in the Numbers.vue File in the src/components Folder
当total
计算属性的值改变时,观察器通过使用popmotion
包生成一系列新旧值之间的值来响应,每个值用于更新显示在组件模板中的displayTotal
属性。
这种类型的更新可以通过使用$el
属性来获取组件的 DOM 元素,使用它来定位要制作动画的元素,并将其添加到适当的类中,从而与 CSS 动画相结合,如清单 25-22 所示。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div id="total" class="col h3">= {{ displayTotal }} </div>
</div>
</div>
</div>
</template>
<script>
import { tween } from "popmotion";
export default {
data: function () {
return {
first: 10,
second: 20,
displayTotal: 30
}
},
computed: {
total() {
return this.first + this.second;
}
},
watch: {
total(newVal, oldVal) {
let classes = ["animated", "fadeIn"]
let totalElem = this.$el.querySelector("#total");
totalElem.classList.add(...classes);
let t = tween({
from: Number(oldVal),
to: Number(newVal),
duration: 250
});
t.start({
update: (val) => this.displayTotal = val.toFixed(0),
complete: () => totalElem.classList.remove(...classes)
});
}
}
}
</script>
Listing 25-22Adding an Animation in the Numbers.vue File in the src/components Folder
为了确保动画可以再次应用,当更改完成时,我从动画类中删除了该元素。结果是新结果显示为平滑过渡,如图 25-10 所示,尽管这是一个很难在截图中捕捉到的效果。
图 25-10
使用观察器来响应值的变化
摘要
在这一章中,我解释了 Vue.js 转场的不同使用方式。我演示了如何使用transition
和transition-group
元素,如何将元素分配给类,如何使用第三方包,以及如何响应转换事件。我还向您展示了如何将用户的注意力吸引到其他类型的变化上,比如当数据值发生变化时。在下一章,我将描述扩展 Vue.js 的不同方法。
二十六、扩展 Vue.js
Vue.js 提供了大多数 web 应用项目所需的所有特性。但是如果你发现你需要扩展 Vue.js,那么有几种不同的技术可用,它们是本章的主题。我将向您展示如何用您自己的定制代码来补充内置指令,如何使用 mixins 来定义组件的公共特性,以及如何使用插件来对更广泛的相关特性进行分组。表 26-1 将本章放在上下文中。
表 26-1
将 Vue.js 特性放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 本章描述的特性允许您扩展 Vue.js 提供的功能。 |
| 它们为什么有用? | 如果您有整个应用都需要的公共代码,而您不能对这些代码使用依赖注入,那么这些特性会很有用。如果您有多个应用项目所需的通用功能,这些功能也很有用。 |
| 它们是如何使用的? | 指令是使用一系列函数创建的,这些函数在它们应用到的元素的状态发生变化时被调用。混合被定义为一组特性,当创建一个新实例时,这些特性与组件定义的特性合并。插件是 JavaScript 模块,可以包含广泛的 Vue.js 特性,可以在整个应用中使用。 |
| 有什么陷阱或限制吗? | 这些都是高级功能,应该小心使用,并且不是大多数功能所必需的。在使用这些功能之前,考虑使用前面章节中的功能是否可以达到预期的效果。 |
| 还有其他选择吗? | 这些是可选特性,在大多数项目中并不需要,Vue.js 提供的内置功能就足够了。 |
表 26-2 总结了本章内容。
表 26-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 定义自定义指令 | 实现一个或多个钩子函数,并使用组件的directives
属性注册指令 | 7–8, 16–18 |
| 获取有关如何应用自定义指令的信息 | 读取绑定对象的属性 | 9–14 |
| 在钩子函数之间传递数据 | 在应用了组件的 HTML 元素上使用数据属性 | Fifteen |
| 定义零部件的基本特征 | 定义混音 | 19–22 |
| 创建一组混合的相关特征 | 创建插件 | 23–31 |
为本章做准备
要创建本章示例所需的项目,请在方便的位置运行清单 26-1 中所示的命令。
vue create extendingvue --default
Listing 26-1Creating the Example Project
一旦设置过程完成,运行extendingvue
文件夹中清单 26-2 所示的命令,将引导 CSS 包添加到项目中。
npm install bootstrap@4.0.0
Listing 26-2Adding the Bootstrap CSS Package
将清单 26-3 中显示的语句添加到src
文件夹中的main.js
文件中,将Bootstrap
包合并到应用中。
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 26-3Incorporating the Bootstrap Package in the main.js File in the src Folder
我在src/components
文件夹中添加了一个名为Numbers.vue
的文件,内容如清单 26-4 所示。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div class="col h3">= {{ total }} </div>
</div>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
first: 10,
second: 20
}
},
computed: {
total() {
return this.first + this.second;
}
}
}
</script>
Listing 26-4The Contents of the Numbers.vue File in the src/components Folder
这是我在第二十五章开始时使用的相同组件,没有我在后面的例子中添加的过渡。要将这个组件集成到应用中,需要替换App.vue
文件中的内容,如清单 26-5 所示。
<template>
<div class="m-2">
<numbers />
</div>
</template>
<script>
import Numbers from "./components/Numbers"
export default {
name: 'App',
components: { Numbers }
}
</script>
Listing 26-5The Contents of the App.vue File in the src Folder
运行transitions
文件夹中清单 26-6 所示的命令,启动开发工具。
npm run serve
Listing 26-6Starting the Development Tools
将执行初始绑定过程,之后您将看到一条消息,告诉您项目已成功编译,HTTP 服务器正在侦听端口 8080 上的请求。打开一个新的浏览器窗口并导航到http://localhost:8080
以查看如图 26-1 所示的内容。
图 26-1
运行示例应用
创建自定义指令
Vue.js 提供的内置指令涵盖了大多数应用中需要的关键任务,但是如果您需要直接处理应用提供的 HTML 元素,并且需要在整个应用中这样做,您也可以创建自己的指令。我添加了一个src/directives
文件夹,并在其中添加了一个名为colorize.js
的文件,其内容如清单 26-7 所示。
小费
注意,我已经创建了一个 JavaScript 文件。只有组件是使用.vue
文件编写的,它允许 HTML、CSS 和 JavaScript 的混合。指令只使用 JavaScript 编写。
export default {
update(el, binding) {
if (binding.value > 100) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
}
Listing 26-7The Contents of the colorize.js File in the src/directives Folder
我将很快解释自定义指令是如何工作的,但是在深入研究它是如何工作的之前,先展示一下这段代码是做什么的会有所帮助。在清单 26-8 中,我注册了该指令并将其应用于一个 HTML 元素。
为什么您可能不需要自定义指令
Vue.js 应用中的基本构建块是组件,当您想要向项目添加功能时,应该创建组件。指令更难处理,可用的功能更有限,并且可能需要使用 JavaScript APIs 来操作 HTML 元素,这可能是一个乏味的过程。
如果你想在底层操作一个 HTML 元素,指令可能是有用的,但是在大多数情况下,这可以通过使用内置指令来完成,因为最常见的改变需要像v-bind
这样的指令来支持,如第十二章中所述。如果您发现自己正在创建一个自定义指令,那么有必要花点时间问问自己,使用 Vue.js 的其他特性是否能够达到同样的效果。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div v-colorize="total" class="col h3">= {{ total }} </div>
</div>
</div>
</div>
</template>
<script>
import Colorize from "../directives/colorize";
export default {
data: function () {
return {
first: 10,
second: 20
}
},
computed: {
total() {
return this.first + this.second;
}
},
directives: { Colorize }
}
</script>
Listing 26-8Registering and Applying a Directive in the Numbers.vue File in the src/components Folder
自定义指令是使用directives
属性注册的,该属性被赋予一个对象。在这个例子中,我使用了import
关键字给这个指令命名为Colorize
,然后我将它作为一个属性应用到组件模板中的一个 HTML,这个属性的名称以v-
为前缀,如下所示:
...
<div v-colorize="total" class="col h3">= {{ total }} </div>
...
我已经将total
属性指定为属性值,当我解释该指令如何工作时,我将返回到这个属性。要测试该指令,请重新加载浏览器并输入提供大于 100 的总和的值。当你这样做时,结果的背景和文本颜色被改变,如图 26-2 所示。
图 26-2
自定义指令的效果
理解指令如何工作
指令定义了方法,称为钩子函数,这些方法在应用了它们的模板的组件的生命周期中的关键时刻被调用。表 26-3 描述了指令钩子函数。
表 26-3
指令钩子函数
|名字
|
描述
|
| --- | --- |
| bind
| 首次初始化指令时调用此方法,并提供执行任何初始任务的机会。 |
| inserted
| 当应用了指令的元素插入到其父元素中时,调用此方法。 |
| update
| 当更新其模板包含已应用指令的元素的组件时,调用此方法。此方法可能在组件的子级更新之前调用。 |
| componentUpdated
| 当其模板包含已应用指令的元素的组件被更新时,并且在其子级被更新后,调用此方法。 |
| unbind
| 调用此方法是为了在指令不再与元素关联之前提供清理的机会。 |
在清单 26-7 中,自定义指令实现了update
钩子,当模板包含 HTML 元素的组件被更新时,它可以更新 HTML 元素。当您在一个input
元素中输入一个新值时,这个变化触发了组件的更新,导致指令的update
钩子函数被调用,为指令提供了一个修改它所应用的 HTML 元素的机会。
钩子函数的第一个参数是一个HTMLElement
对象,它实现了标准的 DOM API,可以用来修改呈现给用户的 HTML 内容。我在 hook 函数中使用这个对象来添加和删除对应于引导 CSS 样式的类,如下所示:
...
export default {
update(el, binding) {
if (binding.value > 100) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
}
...
这是标准的 DOM API,我不在本书中描述,但是你可以在 https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
了解到。
全局注册指令
在清单 26-8 中,我注册了在单个组件中使用的自定义指令。您还可以注册指令,以便它们可以全局使用,这样您就不必为单个组件注册它们。使用Vue.directive
方法在main.js
文件中完成全局注册,如下所示:
...
import Vue from 'vue'
import App from './App'
import "bootstrap/dist/css/bootstrap.min.css";
import Colorize from "./directives/colorize";
Vue.directive("colorize", Colorize);
Vue.config.productionTip = false
new Vue({
el: '#app',
components: { App },
template: '<App/>'
})
...
第一个参数是应用指令的名称,第二个参数是从自定义指令的 JavaScript 文件导入的对象或函数。在为应用创建new Vue
对象之前,必须调用Vue.directive
方法,如代码片段所示,结果是您可以在整个应用中使用该指令,而无需使用directives
属性。
传递给钩子函数的第二个参数是一个对象,它表示指令与 HTML 元素的绑定,并定义了表 26-4 中所示的属性。
小费
钩子函数也和 Vue.js 用来在内部跟踪 HTML 元素的VNode
对象一起提供,但是我没有在本章中描述它们,因为它们不是很有用。详见 https://vuejs.org/v2/api/#VNode-Interface
。
表 26-4
由绑定对象定义的属性
|名字
|
描述
|
| --- | --- |
| name
| 该属性返回用于将指令应用到 HTML 元素的名称,不带v-
前缀。这将是清单 26-8 中应用的指令的colorize
。 |
| expression
| 此属性返回用于应用指令的表达式,以字符串形式表示。在本例中,这将是total
。您不必处理表达式来获得结果,结果是通过value
属性提供的。 |
| value
| 此属性返回通过计算用于应用指令的表达式而产生的当前值。例如,这将是组件的total
属性的当前值 |
| oldValue
| 该属性返回前一个表达式值,但仅适用于update
和componentUpdated
钩子函数。 |
| arg
| 此属性返回用于应用指令的参数(如果有)。 |
| modifiers
| 此属性返回用于应用指令的修饰符(如果有)。 |
我在清单 26-7 中定义的自定义指令使用value
属性来获取表达式的当前值,它使用该值来决定是否从引导 CSS 类中添加或移除元素。
...
export default {
update(el, binding) {
if (binding.value > 100) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
}
...
请注意,该指令与组件没有任何直接关系,它通过表达式接收值,而没有任何关于该值含义的上下文。
警告
表 26-4 中描述的属性是只读的。自定义指令应该只通过 HTML 元素进行更改。
使用自定义指令表达式
在创建自定义指令时,人们倾向于将过多的逻辑放入指令中,而不依赖核心 Vue.js 特性,结果是创建了一个无法广泛应用的指令。我在清单 26-7 中定义的指令落入了这个陷阱,因为它在 JavaScript 代码中硬编码了触发元素着色的值。一个更好的方法是依靠 Vue.js 表达式特性让组件控制指令的行为,如清单 26-9 所示。
...
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input v-colorize="first > 45" class="form-control"
v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div v-colorize="total > 50" class="col h3">= {{ total }} </div>
</div>
</div>
</div>
</template>
...
Listing 26-9Using an Expression in the Numbers.vue File in the src/components Folder
我没有为指令提供total
值,而是使用了允许触发类的值的表达式,这意味着我能够将相同的指令应用于具有不同触发值的不同元素。在清单 26-10 中,我对指令做了相应的修改。
export default {
update(el, binding) {
if (binding.value) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
}
Listing 26-10Removing the Trigger Value in the colorize.js File in the src/directives Folder
结果是,如果第一个input
元素的值超过 45,该指令将改变其背景和字体颜色;如果第一个div
元素的值超过 50,该指令将改变其背景和字体颜色,如图 26-3 所示。
图 26-3
对多个元素应用相同的指令
使用自定义指令参数
一个指令可以被提供参数,这些参数提供附加信息来塑造它的行为,例如在使用v-on
指令时指定你想要处理的事件的方式,如第十四章中所述。自定义指令也可以接收一个参数,在清单 26-11 中,我使用了一个参数来指定类的名称,这个类将用于改变指令所应用的元素的背景颜色。
export default {
update(el, binding) {
const bgClass = binding.arg || "bg-danger";
if (binding.value) {
el.classList.add(bgClass, "text-white");
} else {
el.classList.remove(bgClass, "text-white");
}
}
}
Listing 26-11Receiving an Argument in the colorize.js File in the src/directives Folder
我使用arg
属性获取类名,如果没有提供参数,则返回到bg-danger
类。在清单 26-12 中,我在其中一个指令中添加了一个参数来指定bg-info
类。
...
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input v-colorize:bg-info="first > 45" class="form-control"
v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div v-colorize="total > 50" class="col h3">= {{ total }} </div>
</div>
</div>
</div>
</template>
...
Listing 26-12Adding an Argument in the Numbers.vue File in the src/components Folder
因此,在第一个输入元素中输入超过 45 的值会将该元素放入 Bootstrap 使用不同背景颜色的类中,如图 26-4 所示。
图 26-4
在自定义指令中使用参数
使用自定义指令修饰符
修饰符为指令提供了额外的指令,可以用来补充参数。在清单 26-13 中,我已经更新了自定义指令,这样它可以检查指定背景和文本颜色是否应该改变的修饰符。
export default {
update(el, binding) {
const bgClass = binding.arg || "bg-danger";
const noMods = Object.keys(binding.modifiers).length == 0;
if (binding.value) {
if (noMods || binding.modifiers.bg) {
el.classList.add(bgClass);
}
if (noMods|| binding.modifiers.text) {
el.classList.add("text-white");
}
} else {
el.classList.remove(bgClass, "text-white");
}
}
}
Listing 26-13Receiving Modifiers in the colorizer.js File in the src/directives Folder
修饰符是通过参数binding
的modifiers
属性返回的对象来访问的。如果没有应用修改器,那么对象将没有属性,但是对于每个已经应用的修改器,将有一个属性,其名称为修改器,其值为true
。对于我的示例指令,如果没有修饰符,我想改变背景和文本颜色。如果使用了修饰符,那么bg
修饰符将指示背景颜色应该改变,而text
修饰符将指示文本颜色应该改变。在清单 26-14 中,我对指令使用了不同的修饰符组合,并将指令应用于第二个input
元素。
...
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input v-colorize:bg-info.bg="first > 45" class="form-control"
v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input v-colorize:bg-info="second > 30"
class="form-control" v-model.number="second" />
</div>
<div v-colorize.bg.text="total > 50" class="col h3">
= {{ total }}
</div>
</div>
</div>
</div>
</template>
...
Listing 26-14Using Directive Modifiers in the Numbers.vue File in the src/components Folder
示例指令的修饰符是可选的,所以我可以使用不带修饰符的v-colorizer
属性,只使用bg
修饰符,同时使用bg
和text
修饰符。这样做的效果是,我可以单独配置指令应用到的每个 HTML 元素,如图 26-5 所示。
图 26-5
使用修饰符配置指令
钩子函数之间的通信
定制指令的本质是简单的和无状态的,如果你想在钩子函数之间传递数据,例如,在bind
钩子中产生的一些结果可以在update
钩子中重用,或者一次调用update
钩子的结果可以在下一次更新中使用,这需要一些努力。这个问题的解决方案是使用 HTML 元素来存储使用data
属性所需的数据。在清单 26-15 中,我修改了自定义指令,因此它使用data
属性来跟踪元素是否已经被添加到引导类中。
export default {
update(el, binding) {
const bgClass = binding.arg || "bg-danger";
const noMods = Object.keys(binding.modifiers).length == 0;
if (binding.value) {
if (noMods || binding.modifiers.bg) {
el.classList.add(bgClass);
el.dataset["bgClass"] = true;
}
if (noMods|| binding.modifiers.text) {
el.classList.add("text-white");
el.dataset["textClass"] = true;
}
} else {
if (el.dataset["bgClass"]) {
el.classList.remove(bgClass);
el.dataset["bgClass"] = false;
}
if (el.dataset["textClass"]) {
el.classList.remove("text-white");
el.dataset["textClass"] = false;
}
}
}
}
Listing 26-15Using Data Attributes in the colorize.js File in the src/directives Folder
dataset
属性提供了对 HTML 元素的data-
属性的访问,我创建了data-bgClass
和data-textClass
属性来指示元素何时被添加到引导类中。在此示例中,没有明显的变化,但是如果您将第一个 HTML 元素的值设置为大于 45,然后使用浏览器的 F12 工具检查该元素,您将会看到指令已经能够使用该元素来存储其状态数据,如下所示:
...
<div class="col h3 bg-danger text-white"
data-bg-class="true" data-text-class="true">
= 70
</div>
...
这似乎是一种奇怪的方法,但它意味着 HTML 元素提供了指令使用的一致数据源,而不需要为本地状态数据和单独的指令生命周期添加功能。
定义单一功能指令
如果指令需要在设置期间执行相同的任务,并且在有变化时再次执行相同的任务,那么它们很容易出现代码重复,如清单 26-16 所示。
export default {
bind(el, binding) {
if (binding.value) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
},
update(el, binding) {
if (binding.value) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
}
Listing 26-16Adding a Hook in the colorizer.js File in the src/directives Folder
我已经简化了指令,使其不再使用参数、修饰符或数据属性,并且添加了bind
钩子。结果是,用于应用指令的表达式的初始值被用于配置 HTML 元素,尽管在每个钩子中重复相同的语句。
这是一个如此常见的模式,以至于 Vue.js 支持一个优化,它允许只需要bind
和update
钩子的指令被表达为一个单一的函数,如清单 26-17 所示。
export default function (el, binding) {
if (binding.value) {
el.classList.add("bg-danger", "text-white");
} else {
el.classList.remove("bg-danger", "text-white");
}
}
Listing 26-17Using a Single Function in the colorizer.js File in the src/directives Folder
这种方法的缺点是不能指定任何其他挂钩,但是它允许将大多数指令表示为一个函数,而不必复制任何代码。为了展示 Vue.js 应用指令就像它有一个绑定钩子一样,我增加了一个Numbers
组件的数据属性的初始值,如清单 26-18 所示。
...
<script>
import Colorize from "../directives/colorize";
export default {
data: function () {
return {
first: 50,
second: 20
}
},
computed: {
total() {
return this.first + this.second;
}
},
directives: { Colorize }
}
</script>
...
Listing 26-18Increasing a Data Property in the Numbers.vue File in the src/components Folder
新值超过了用于应用指令的阈值,这产生了如图 26-6 所示的结果。
图 26-6
使用单个函数来提供绑定钩子
创建组件混合
混合是向组件提供共享特性的一种有用方式,有助于减少代码重复。mixins 的优点是简单,但缺点是它们不能用于共享状态,这需要依赖注入或数据存储等特性。
我喜欢在 mixins 自己的文件夹中定义它们,以使它们与项目的其他部分分开。为了演示一个 mixin,我创建了src/mixins
目录,并在其中添加了一个名为numbersMixin.js
的文件,其内容如清单 26-19 所示。
import Colorize from "../directives/colorize";
export default {
data: function () {
return {
first: 50,
second: 20
}
},
computed: {
total() {
return 0;
}
},
directives: { Colorize },
}
Listing 26-19The Contents of the numbersMixin.js File in the src/mixins Folder
mixin 可以包含任何组件代码特性,包括数据和计算属性、方法、过滤器和指令。如果您正在构建一组共享通用功能的相关组件,那么 mixin 可能是避免将相同的代码复制并粘贴到.vue
文件的script
元素中的好方法。清单 26-19 中的 mixin 包含数据属性、计算属性和指令注册,所有这些都是我从现有的Numbers
组件中获取的,尽管total
计算属性返回零,我这样做是为了展示 mixin 是如何工作的。
在清单 26-20 中,我已经更新了Numbers
组件,因此它使用 mixin 并只定义不同的功能。
...
<script>
import mixin from "../mixins/numbersMixin";
export default {
computed: {
total() {
return this.first + this.second;
}
},
mixins: [ mixin ]
}
</script>
...
Listing 26-20Using a Mixin in the Numbers.vue File in the src/components Folder
使用mixins
属性应用 mixin,该属性被赋予一个 mixin 对象数组。当使用 mixin 时,Vue.js 将组件视为已经定义了 mixin 所提供的特性。当组件定义了一个同名的特性,比如清单 26-20 中的total
computed 属性,那么组件的特性会覆盖 mixin 提供的特性。
将混音应用到所有组件
Mixins 可以全局注册,这将它们的特性应用于应用中的所有组件。这不是一件轻而易举的事情,因为它的影响是广泛的,通常会导致预期的行为。使用Vue.mixin
方法在main.js
文件中全局注册 Mixins,如下所示:
...
import Vue from 'vue'
import App from './App'
import "bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
import mixin from "./mixins/numbersMixin";
Vue.mixin(mixin);
new Vue({
el: '#app',
components: { App },
template: '<App/>'
})
...
在为应用创建new Vue
对象之前,必须调用Vue.mixin
方法,如代码片段所示。当你像这样注册一个 mixin 时,你不需要在单独的组件中使用mixins
属性。
这允许 mixin 提供被更专门化的行为覆盖的通用功能。为了展示如何使用单个 mixin 为相关组件提供基础,我在src/components
文件夹中创建了一个名为Subtraction.vue
的文件,其内容如清单 26-21 所示。
小费
当一个 mixin 和一个组件都实现一个生命周期方法时,如第十七章所述,Vue.js 调用 mixin 的方法,然后调用组件定义的方法。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-info text-white text-center p-2">Subtraction</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">-</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div v-colorize.bg.text="total > 50" class="col h3">= {{ total }}</div>
</div>
</div>
</div>
</template>
<script>
import mixin from "../mixins/numbersMixin";
export default {
computed: {
total() {
return this.first - this.second;
}
},
mixins: [ mixin ]
}
</script>
Listing 26-21The Contents of the Subtraction.vue File in the src/components Folder
该组件与Numbers
组件具有相同的基本结构,但是覆盖了 total computed
属性,以便从一个数据属性中减去另一个数据属性的值,而不是由原始组件执行的加法。所有其他组件特性都由 mixin 提供。在清单 26-22 中,我已经更新了顶层的App
组件以显示新组件。
<template>
<div class="m-2">
<numbers />
<subtraction />
</div>
</template>
<script>
import Numbers from "./components/Numbers";
import Subtraction from "./components/Subtraction";
export default {
name: 'App',
components: { Numbers, Subtraction }
}
</script>
Listing 26-22Adding a Component in the App.vue File in the src Folder
我已经将新组件显示在现有组件旁边,产生了如图 26-7 所示的结果。
注意
使用 mixin 的每个组件都有自己的数据属性,这些属性不与其他组件共享。如果您希望组件对相同的数据值进行操作,那么请参见第十八章了解依赖注入的详细信息,或者参见第二十章了解关于数据存储的信息。
图 26-7
使用相同的 mixin 创建相似的组件
创建 Vue.js 插件
插件允许在整个应用中全局应用广泛的特性,而不需要单独配置每个特性。这些特性包括指令和混合,但也允许全局定义方法和属性,这就是像 Vuex(第二十章)和 Vue Router(第二十二章)这样的包如何提供对其功能的访问。
为了演示插件是如何创建和使用的,我将定义一组全局特性,这些特性将支持示例应用中的组件所执行的简单数学运算。
首先,我创建了src/plugins/maths
文件夹,并在其中添加了一个名为filters.js
的文件,其内容如清单 26-23 所示。
export default {
currency: function (value) {
return new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD" }).format(value);
},
noDecimal: function (value) {
return Number(value).toFixed(0);
}
}
Listing 26-23The Contents of the filters.js File in the src/plugins/maths Folder
这个文件定义了两个过滤函数,它们格式化数值并将它们分配给属性,稍后我将使用这些属性的名称来注册过滤器。这些过滤器的目的不如它们被包含在插件中的方式重要。有关过滤器如何工作的详细信息,请参见第十一章。
插件也可以包含指令,所以我在src/plugins/maths
文件夹中创建了一个名为directives.js
的文件,内容如清单 26-24 所示。
export default {
borderize: function (el, binding) {
if (binding.value) {
el.classList.add("border", "border-dark");
} else {
el.classList.remove("border", "border-dark");
}
}
}
Listing 26-24The Contents of the directives.js File in the src/plugins/maths Folder
这个文件定义了一个单函数指令,当它的表达式是true
时,这个指令将一个边框应用到它的 HTML 元素。这不是一个特别有用的指令,但是,如前所述,自定义指令很少有用,在大多数应用中也不需要。
插件还可以定义全局方法和属性,这些方法和属性可以在整个应用中被访问。我在src/plugins/maths
文件夹中添加了一个名为globals.js
的文件,内容如清单 26-25 所示。
export default {
sumValues(...vals) {
return vals.reduce((val, total) => total += val, 0);
},
getSymbol(operation) {
switch (operation.toLowerCase()) {
case "add": return "+";
case "subtract": return "-";
case "multiply": return "*";
default: return "/";
}
}
}
Listing 26-25The Contents of the globals.js File in the src/plugins/maths Folder
在清单中,我定义了一个sumValues
函数,它使用一个 rest 参数来接收一组值,这些值相加后产生一个结果,还定义了一个getSymbol
方法,它接受数学运算的名称并返回表示它的符号。
你也可以使用插件为每个组件添加属性和方法,类似于 Vuex 提供$store
属性和 Vue Router 提供$route
和$router
的方式。我在src/plugins/maths
文件夹中添加了一个名为componentFeatures.js
的文件,内容如清单 26-26 所示。
export default {
$calc: {
add(first, second) {
return first + second;
},
subtract(first, second) {
return first - second;
},
multiply(first, second) {
return first * second;
},
divide(first, second) {
return first / second;
}
}
}
Listing 26-26The Contents of the componentFeatures.js File in the src/plugins/maths Folder
按照惯例,提供给组件的特性名称以美元符号开头,在清单中,我用执行基本数学运算的add
、subtract
、multiply
和divide
方法定义了一个$calc
对象。
插件可以包含组件,这是确保通用功能在整个应用中可用的有效方法。我在src/plugins/maths
文件夹中添加了一个名为Operation.vue
的文件,内容如清单 26-27 所示。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-info text-white text-center p-2">{{ operation }}</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input class="form-control" v-model.number="first" />
</div>
<div class="col-1 h3">{{ symbol }}</div>
<div class="col">
<input class="form-control" v-model.number="second" />
</div>
<div class="col h3" v-borderize="total > 25">= {{ total }}</div>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
props: ["firstVal", "secondVal", "operation"],
data: function () {
return {
first: Number(this.firstVal),
second: Number(this.secondVal)
}
},
computed: {
symbol() {
return Vue.getSymbol(this.operation);
},
total() {
switch (this.operation.toLowerCase()) {
case "add":
return this.$calc.add(this.first, this.second);
case "subtract":
return this.$calc.subtract(this.first, this.second);
case "multiply":
return this.$calc.multiply(this.first, this.second);
case "divide":
return this.$calc.divide(this.first, this.second);
}
}
}
}
</script>
Listing 26-27The Contents of the Operation.vue File in the src/plugins/maths Folder
这个组件提供了一个标准化的接口,用于对两个数字执行简单的操作,并建立在插件中的一些其他特性之上,我将很快对此进行解释。
创建插件
我在上一节中定义的特性必须结合起来才能创建一个插件。我在src/plugins/maths
文件夹中添加了一个名为index.js
的文件,并添加了清单 26-28 中所示的代码,这些代码将不同的特性组合在一起,创建了一个插件。
import filters from "./filters";
import directives from "./directives";
import globals from "./globals";
import componentFeatures from "./componentFeatures";
import Operation from "./Operation";
export default {
install: function (Vue) {
Vue.filter("currency", filters.currency);
Vue.filter("noDecimal", filters.noDecimal);
Vue.directive("borderize", directives.borderize);
Vue.component("maths", Operation);
Vue.sumValues = globals.sumValues;
Vue.getSymbol = globals.getSymbol;
Vue.prototype.$calc = componentFeatures.$calc;
}
}
Listing 26-28The Contents of the index.js File in the src/plugins/maths Folder
插件是定义一个install
函数的对象,该函数接收一个Vue
对象和一个可选的配置对象。由Vue
对象提供的方法用于注册每个特性,这些特性是我从上一节创建的 JavaScript 文件中导入的,如表 26-5 所述。
表 26-5
注册插件特性的 Vue 方法
|名字
|
描述
|
| --- | --- |
| Vue.directive
| 此方法用于注册指令。参数是应用指令的名称和指令对象。 |
| Vue.filter
| 此方法用于注册过滤器。参数是应用筛选器的名称和筛选器对象。 |
| Vue.component
| 此方法用于注册组件。参数是应用组件的名称和组件对象。 |
| Vue.mixin
| 这个方法用于注册一个 mixin。这个方法的参数是 mixin 对象。 |
在清单 26-28 中,我使用了filter
、directive
和component
方法来注册上一节中定义的特性。为了注册全局方法和属性,新成员被添加到Vue
对象,如下所示:
...
Vue.getSymbol = globals.getSymbol;
...
这条语句使得getSymbol
方法在整个应用中可用,这意味着它可以通过Vue
对象来访问,如清单 26-27 中组件的这条语句所示:
...
symbol() {
return Vue.getSymbol(this.operation);
},
...
您想在每个组件中访问的方法和属性被添加到Vue.prototype
对象中,如下所示:
...
Vue.prototype.$calc = componentFeatures.$calc;
...
该语句设置了$calc
对象,因此它可以在组件中作为this.$calc
被访问,如组件列表 26-27 中的语句所示:
...
return this.$calc.add(this.first, this.second);
...
通过结合这些技术和表 26-5 中的方法,插件能够为 Vue.js 应用提供广泛的特性。
使用插件
插件是通过Vue.use
方法启用的,这个方法与我在前面章节中注册数据存储和 URL 路由插件的方法相同,唯一的区别是这些插件在它们自己的 NPM 包中。在清单 26-29 中,我已经在示例应用中导入了定制包,并使用Vue.use
方法启用了它。
import Vue from 'vue'
import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false
import MathsPlugin from "./plugins/maths";
Vue.use(MathsPlugin);
new Vue({
render: h => h(App)
}).$mount('#app')
Listing 26-29Using the Plugin in the main.js File in the src Folder
一旦一个插件被启用,它的特性就可以在整个应用中使用。在清单 26-30 中,我已经将我定义的组件添加到顶层App
组件的模板中。
<template>
<div class="m-2">
<numbers />
<subtraction />
<maths operation="Divide" firstVal="10" secondVal="20" />
</div>
</template>
<script>
import Numbers from "./components/Numbers";
import Subtraction from "./components/Subtraction";
export default {
name: 'App',
components: { Numbers, Subtraction }
}
</script>
Listing 26-30Using a Plugin’s Component in the App.vue File in the src Folder
请注意,我不必注册组件,我只需向模板添加一个maths
元素,因为插件提供的特性在整个应用中都是可用的。例如,在清单 26-31 中,我在Numbers
组件中使用了一个过滤器和一个全局方法。
<template>
<div class="mx-5 p-2 border border-dark">
<h3 class="bg-success text-white text-center p-2">Numbers</h3>
<div class="container-fluid">
<div class="row">
<div class="col">
<input v-colorize:bg-info.bg="first > 45" class="form-control"
v-model.number="first" />
</div>
<div class="col-1 h3">+</div>
<div class="col">
<input v-colorize:bg-info="second > 30"
class="form-control" v-model.number="second" />
</div>
<div v-colorize.bg.text="total > 50" class="col h3">
= {{ total | currency }}
</div>
</div>
</div>
</div>
</template>
<script>
import mixin from "../mixins/numbersMixin";
import Vue from "vue";
export default {
computed: {
total() {
return Vue.sumValues(this.first, this.second);
}
},
mixins: [ mixin ]
}
</script>
Listing 26-31Using Plugin Features in the Numbers.vue File in the src/components Folder
结果是组件显示的值被格式化为货币金额,一个新的组件显示给用户,如图 26-8 所示。
图 26-8
使用插件功能
摘要
在这一章中,我描述了扩展 Vue.js 提供的功能的不同方式。我向您展示了如何创建一个自定义指令,并警告说这很少是最好的方法;如何使用 mixin 提供具有公共特性的组件;以及如何通过创建插件来提供一系列相关功能。
这就是我要教你的关于 Vue.js 的全部内容。我从创建一个简单的应用开始,然后带你全面浏览框架中的不同构建块,向你展示如何创建、配置和应用它们来创建 web 应用。
我祝你在 Vue.js 项目中一切顺利,我只希望你能像我喜欢写这本书一样喜欢读这本书。
【推荐】2025 HarmonyOS 鸿蒙创新赛正式启动,百万大奖等你挑战
【推荐】博客园的心动:当一群程序员决定开源共建一个真诚相亲平台
【推荐】开源 Linux 服务器运维管理面板 1Panel V2 版本正式发布
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步