VueJS-前端项目-全-
VueJS 前端项目(全)
原文:
zh.annas-archive.org/md5/f9404aca1fb3b78cb6f2f92bcb2c199d
译者:飞龙
前言
关于本书
你是否想使用 Vue.js 进行 Web 应用开发,但不知道从何开始?使用 Vue.js 进行前端开发项目将帮助你构建开发工具包,并准备好应对现实世界的 Web 项目。你将通过实际示例和活动掌握这个 JavaScript 框架的核心概念。
通过本书中的用例,你将了解如何在 Vue 组件中处理数据,定义组件间的通信接口,以及处理静态和动态路由以控制应用流程。你将掌握 Vue CLI 和 Vue DevTools,并学习如何处理过渡和动画效果以创建引人入胜的用户体验。在关于测试和部署到网络的章节中,你将获得像经验丰富的 Vue 开发者一样工作的技能,并构建其他人可以使用的专业应用。
你将参与现实的项目,这些项目以小规模练习和活动形式呈现,让你以愉快且可行的方式挑战自己。这些迷你项目包括聊天界面、消息应用、购物车和价格计算器、待办事项应用以及用于存储联系信息的个人资料卡生成器。
到本书结束时,你将自信地处理任何 Web 开发项目,并应对现实世界的客户端开发问题。
关于作者
雷蒙德·坎贝尔是 IBM 的开发者倡导者。他的工作集中在 MobileFirst 平台、Bluemix、混合移动开发、Node.js、HTML5 以及一般性的 Web 标准上。他是一位出版作者,在会议和用户组上就各种主题发表演讲。雷蒙德可以通过他的博客、Twitter 或电子邮件联系。他是许多开发书籍的作者,包括《Apache Cordova 实战》和《客户端数据存储》。
雨果·迪·弗朗索瓦是一位与 JavaScript 有着广泛合作的软件工程师。他拥有伦敦大学学院(UCL)数学计算硕士学位。他在像佳能和爱思唯尔这样的公司使用 JavaScript 构建了可扩展且性能卓越的平台。他目前正使用 Node.js、React 和 Kubernetes 解决零售运营空间的问题,同时运营同名网站 Code with Hugo。在工作之外,他是一位国际击剑运动员,在全球范围内进行训练和比赛。
克利福德·格尼是一位以解决方案为导向、以结果为目标的初创公司技术负责人。他在沟通设计和广泛参与领导数字化转型计划方面的背景丰富了他使用 Vue JS 设计概念性前端解决方案的能力。克利福德在 Vue JS 墨尔本聚会中发表过演讲,并与志同道合的人合作,提供一流的数字体验平台。
菲利普·柯克布里奇拥有超过 5 年的 JavaScript 经验,并驻扎在蒙特利尔。他在 2011 年从一所技术学院毕业,自那时起,他一直在各种角色中与网络技术合作。
玛雅·沙文是一位高级前端开发者、演讲者、博主、Storefront UI 核心成员,以及 VueJS 以色列 Meetups 的创始人兼组织者。
本书面向对象
本书面向刚开始使用 Vue.js 的开发者,他们希望获得对单页应用(SPA)模式的基本理解,并学习如何使用 Vue.js 创建可扩展的企业级应用。如果你已经使用 React 或 Angular,并希望开始学习 Vue.js,这本书也会对你很有帮助。为了理解本书中解释的概念,你必须熟悉基本的 HTML、CSS、JavaScript(如对象、作用域、this 上下文以及值与引用),以及Node 包管理器(NPM)。
关于章节
第一章,开始你的第一个 Vue 项目,让你立即创建 Vue 组件。你将学习 Vue.js 的基础知识,以及理解 JavaScript 应用程序中的响应性。
第二章,处理数据,提供了关于使用计算数据属性、使用监视器观察状态变化以及利用异步 API 等更多组件构建块的信息。
第三章,Vue CLI,深入探讨了 Vue 的生活质量工具包。你将了解如何使用 Vue CLI 和浏览器开发者工具。
第四章,嵌套组件(模块化),探讨了在组件之间传递数据以实现模块化的方法。
第五章,全局组件组合,深入探讨了在 Vue.js 代码库中共享组件功能的方法。
第六章,路由,涵盖了 Vue 中使用标准路由和动态路由。你将学习如何在 Vue 中创建具有复杂多页应用的 SPA。
第七章,动画与过渡,涵盖了 Vue 内置的动画和过渡,以及如何使用外部 JavaScript 库为 Vue 添加动画。我们将在演示应用中创建自定义动画。
第八章,Vue.js 状态管理的现状,提供了对 Vue.js 状态管理不同方法的视角。
第九章,使用 Vuex – 状态、获取器、动作和突变,介绍了 Vuex 库,用于 Vue 中的状态管理。
第十章,使用 Vuex – 获取远程数据,讨论了如何使用 Vuex 和远程 API。
第十一章,使用 Vuex – 组织大型存储,帮助你组织和管理工作量大的 Vuex 存储。
第十二章,单元测试,探讨了 Vue.js 应用程序中各个部分的测试,包括组件、过滤器以及混入。
第十三章,端到端测试,介绍了 Cypress,它用于编写 Vue.js 应用程序的端到端测试。
第十四章,“将您的代码部署到 Web 上”,探讨了持续集成/持续部署的现代最佳实践,并考虑了如何将 Vue.js 应用程序部署到多个托管提供商。
习惯用法
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:
“panic()
函数接受一个空接口。”
您在屏幕上看到的单词,例如在菜单或对话框中,也以相同的格式出现。
代码块设置如下:
<template>
<div>
Vue Template Code
</div>
</template>
新术语和重要单词如下所示:“这些行为统称为方法集。”
对其他章节和图像的引用如下所示:“图 2.15显示了前面代码生成的输出。”
代码片段的关键部分如下突出显示:
<input
id="phone"
type="tel"
name="phone"
v-model="phone"
class="border-2 border-solid border-blue-200 rounded px-2 py-1"
/>
长代码片段将被截断,GitHub 上相应代码文件的名称将放置在截断代码的顶部。整个代码的永久链接将放置在代码片段下方。它应该看起来如下:
Exercise1-12.vue
17 <script>
18 export default {
19 data() {
20 return {
21 list: [
22 'Apex Legends',
23 'A Plague Tale: Innocence',
24 'ART SQOOL',
25 'Baba Is You',
26 'Devil May Cry 5',
27 'The Division 2',
28 'Hypnospace Outlaw',
29 'Katana ZERO',
30 ],
31 }
32 },
33 methods: {
34 deleteItem(value) {
35 this.list = this.list.filter(item => item !== value)
36 },
37 },
38 ...
The complete code for this step is available at https://packt.live/3pJGLvO.
在开始之前
每一段伟大的旅程都始于一个谦逊的步伐。我们即将开始的 Vue.js 之旅也不例外。在我们能够使用 Vue 做些令人惊叹的事情之前,我们需要准备好一个高效的环境。在接下来的简短章节中,我们将看到如何做到这一点。
运行 Node.js 应用程序的最低硬件推荐
为了能够运行书中推荐的全部工具,建议您具备以下条件:
-
1 GHz 或更快的桌面处理器
-
至少 512 MB 的 RAM(更多更好)
-
Windows 32/64 位、macOS 64 位、Linux ARMv7/v8
安装 Node.js
为了让 Vue.js 运行,您的计算机上必须安装 Node.js。按照nodejs.org/en/download/
上 Windows、macOS 和 Linux 的说明下载最新 LTS 版本。Node.js 安装和使用都是免费的。
安装 Git
Node.js 应用程序使用版本控制工具 Git 来安装额外的工具和代码。您可以在git-scm.com/book/en/v2/Getting-Started-Installing-Git
找到 Windows、macOS 和 Linux 的安装说明。Git 安装和使用都是免费的。
安装 Yarn
一些练习将使用 Yarn 依赖管理器来安装和运行 Vue.js 应用程序。您可以在classic.yarnpkg.com/en/docs/install
找到 Windows、macOS 和 Linux 的下载和安装 Yarn 的说明。Yarn 安装和使用都是免费的。
安装 Vue.js CLI(Vue 命令行界面)
一些练习将要求您使用 Vue.js CLI。您可以在cli.vuejs.org/guide/installation.html
找到下载和安装 Vue.js CLI 的说明。
安装 Visual Studio Code(编辑器/IDE)
您需要一个用来编写 Vue 源代码的工具。这个工具被称为编辑器或集成开发环境(IDE)。如果您已经有了喜欢的编辑器,您可以使用它来配合这本书。
如果您还没有编辑器,我们推荐您使用免费的 Visual Studio Code 编辑器。您可以从code.visualstudio.com
下载安装程序:
-
下载完成后,打开 Visual Studio Code。
-
从顶部菜单栏选择
视图
。 -
从选项列表中选择
扩展
。左侧应该会出现一个面板。在顶部是一个搜索输入框。 -
输入
Vetur
。第一个选项应该是一个名为Vetur
的扩展,由Pine Wu
提供。 -
点击该选项中的
安装
按钮。等待出现一条消息,表明已成功安装。
安装代码包
从 GitHub 下载代码文件,请访问packt.live/3nOX2xE
。请参考这些代码文件以获取完整的代码包。
联系我们
我们欢迎读者的反馈。
customercare@packtpub.com
。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com
,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
请留下评论
请在亚马逊上留下详细、公正的评论,告诉我们您的看法。我们非常重视所有反馈——它帮助我们继续制作优质产品并帮助有抱负的开发者提升技能。请抽出几分钟时间留下您的想法——这对我们来说意义重大。
本书最初是从sanet.st下载的。
第一章:1. 开始你的第一个 Vue 项目
概述
在本章中,你将学习 Vue.js 的关键概念以及为什么你应该考虑 Vue.js 作为你的下一个项目。你将学习如何从命令行运行 Vue 项目;描述 Vue.js 项目的架构;使用各种样式和 HTML 语法风格创建 Vue 单文件组件;并且能够熟练地编写 Vue 方法和数据对象以及控制 HTML 模板。
到本章结束时,你将能够描述 Vue 生命周期钩子和表达式的基础知识。
简介
行业中的开发者需要快速解决前端开发问题,同时尽量减少对现有工作流程或后端架构的影响。在许多情况下,UI 直到项目结束时才被完全忽视,这可能是由于资源不足、不断变化的产品需求或现有的前端是简单部分的态度。然而,像苹果和谷歌这样的公司已经证明,前端的设计思维对于打造一个能够激发和吸引用户、带来更高投资回报和更成功业务的产品或平台至关重要。
如果你已经发现了 Vue.js,你可能也遇到了其他前端框架,如 Ember、Angular 或 React,它们表面上似乎解决了相同的问题。在表面层面上,它们都试图使响应式前端开发更加可靠,并引入使这更容易做的模式。然而,与 Angular 或 React 项目相比,Vue 项目可能会有一些关键的不同之处。让我们来看看它们。
Angular 与 Vue 的比较
Angular 是由谷歌构建的模型-视图-视图模型(MVVM)框架,过去,由于谷歌的支持以及 Angular 从一开始就是为与 TypeScript 一起使用而创建的事实,因此,企业公司通常更倾向于选择 Angular。支持 Angular 的生态系统包括预编译(AoT)渲染、路由和 CLI 管理,但未能提供简化的全局状态管理系统;开发者需要学习和使用Flux或采用NgRx。Vue 采用了 Angular 的稳健性和可靠性核心思想,并通过其无差别的开发方法改善了开发体验,通过移除对开发者强制执行的代码风格限制。通过 Vue 的单文件组件系统简化了熟悉的 Angular 模式,如 HTML 指令和依赖注入,以实现模块化,这使开发者受益于无需学习和记住各种结构(注入器、组件、管道、模块等)。Vue 对 TypeScript 和类型支持出色,且没有 Angular 可能具有的强制编码语言和开发风格的缺点。React 和 Vue 都专注于组件驱动开发,这减少了学习新框架所需的时间和精力。
React 与 Vue 的比较
React 的流行和庞大的开发者社区背后的动力归功于 Facebook 的专职工程师以及它在 2013 年开源发布,当时 Angular 2+还无处可寻。React 的 JSX 模式(一种在 JavaScript 中编写 HTML 和 CSS 的方式)为新手开发者带来了更高的学习曲线,他们不仅需要学习另一种语言,还需要理解基于组件的架构。组件允许开发者以模块化的方式构建应用程序;单个组件描述其自己的功能部分和生命周期,当需要时可以实例化,当不再使用时可以销毁。Vue 将这些模块化编码的核心概念结合起来,使开发者能够使用 JSX 或像传统单文件 Web 应用程序一样编写 HTML、CSS 和 JavaScript 来构建这些组件。Vue 在单文件组件中的关注点分离简化了开发者的模块化结构。
使用 Vue 进行项目的优势
Vue 的学习曲线平缓,生态系统活跃。这使任何规模的团队能够受益,因为它不需要大量的开销来教育开发者如何使用 Vue.js 框架。
-
Vue.js 是开发中一个易于学习但难以掌握的模式的另一个例子。Vue 的一个关键优势是其对新老开发者都易于接近。
-
开箱即用,开发者可以使用一个优化良好且性能出色的框架来构建任何规模的动态前端应用程序。
-
单文件组件(SFC)模式提供了一个模块化和灵活的蓝图,简化了开发过程,并为所有级别的开发者提供了愉快的体验,为组件混乱带来了秩序。单文件组件使 Vue 真正多功能,你可以实现基本功能,并逐步将静态站点的部分内容集成到 Vue 中,而不是彻底改造整个网站。
-
对于熟悉Redux和NgRx模式的任何开发者来说,官方的全局状态管理支持应该是一种安慰。尽管这些库在正确使用时非常强大,但Vuex是创建健壮的全局状态模式的绝佳中间方案,这些模式具有灵活性,可以满足大多数开发需求。
对于那些希望快速上手的开发者来说,除非个别用例确实需要,否则不要重新发明轮子,构建自定义的响应式模式。你可以通过使用 Vue 作为框架来节省时间和金钱,因为它已经性能出色,并且官方支持构建端到端应用程序所需的库,包括vue-router
、Vuex 状态管理、开发工具等。
在本章中,我们将首先介绍 Vue 架构,然后让你熟悉 Vue 独特的 SFC 模式和 HTML 模板语法糖。你将学习如何使用 Vue 特定的模板语法和编码模式,包括 Vue 绑定、指令、生命周期钩子、作用域和局部状态。在 Vue 官方插件生态系统中,我们将主要关注核心 Vue 库。首先,让我们看看 Vue 的项目架构。
简单 Vue 应用中的 Vue 实例
开始使用 Vue 的一种最简单的方法是通过Vue
函数导入 Vue 包。每个 Vue 应用都由一个根 Vue 实例组成,该实例是通过new Vue
函数创建的。所有创建的相应 Vue 组件也使用相同的语法定义,然而,它们被视为嵌套 Vue 实例,可以包含它们自己的选项和属性:
var vm = new Vue({
// options
})
注意
vm
是一个常用术语,用来指代vm
,它可以帮助你在代码块中跟踪你的 Vue 实例。
在这个例子中,我们使用jsdelivr
CDN 导入 Vue,这将允许你使用 Vue 函数:
<!DOCTYPE html>
<html>
<head>
<title>Vue.js CDN</title>
<script src="img/vue.js">
</script>
</head>
</html>
使用类、ID 或数据属性在<body>
标签中声明一个元素。Vue 因其能够使用简单的模板语法(如双大括号)声明性地将数据渲染到 DOM 中而闻名,例如,{{ text }}
:
<!DOCTYPE html>
<html>
<head>
<title>Vue.js CDN</title>
<script src="img/vue.js">
</script>
</head>
<body>
<div>
<p class="reactive-text">{{ text }}</p>
</div>
</body>
</html>
在<head>
标签中,我们看到一些在 DOM 加载时触发的原生 JavaScript 代码。它构建了一个与带有class .reactive-text
的元素绑定的 Vue 组件。标记为text
的数据属性将替换大括号占位符,并用字符串Start using Vue.js today!
定义:
<head>
<title>Vue.js CDN</title>
<script src="img/vue.js">
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
new Vue({
el: '.reactive-text',
data: {
text: "Start using Vue.js today!"
}
})
})
</script>
</head>
<body>
<div>
<p class="reactive-text">{{ text }}</p>
</div>
</body>
</html>
在前面的脚本中,你将带有reactive-text
类的<p>
元素绑定到新的 Vue 实例。因此,现在 Vue 理解了这个 HTML 元素,你可以使用{{ text }}
语法在<p>
元素内部输出数据属性text
。
上述代码的输出将如下所示:
Start using Vue.js today!
虽然 CDN 是一种非常便携的方式来开始在你的项目中包含 Vue.js,但使用包管理器是 Vue 推荐的安装方法,因为 Vue 是通过 webpack 编译的,这允许你轻松控制第三方库版本。你可以在这里访问它:vuejs.org/v2/guide/installation.html
。接下来,我们将探索 webpack 示例的样子。
Webpack Vue 应用
Vue 项目结构与许多现代基于 node 的应用类似,这些应用在项目的根目录下包含一个package.json
文件和一个node_modules
文件夹。通常,其他配置文件也位于根目录级别,例如babel.config.js
和.eslintrc.js
,因为它们通常会对整个项目产生影响。以下截图显示了默认的 Vue 应用文件夹结构:
![图 1.1:默认 Vue 应用文件夹结构]
![img/B15218_01_01.jpg]
图 1.1:默认 Vue 应用程序文件夹结构
Vue 项目结构遵循一种模式,其中大部分源代码都管理在/src
目录中。你可以将 Vue 文件细分为各种文件夹,例如,使用components
文件夹来存储可复用的 Vue 组件。默认情况下,Vue 将创建资产和一个components
文件夹来拆分默认文件。对于初学者来说,遵循这种模式直到你更熟悉以对应用程序有意义的任何方式拆分代码是很好的:
![图 1.2:默认 Vue 应用程序 src 文件夹结构
图 1.2:默认 Vue 应用程序 src 文件夹结构
public
文件夹是一个特殊目录,用于包含需要直接传输到输出位置的文件。以下截图显示了此文件夹的外观:
![图 1.3:默认 Vue 应用程序 public 文件夹
图 1.3:默认 Vue 应用程序 public 文件夹
默认情况下,public
文件夹将包含一个index.html
文件,该文件作为加载 Vue 应用程序的占位符。index.html
文件可以被修改以包含所需的头部和尾部脚本,例如 Google 字体或作为 webpack 包一部分之外的第三方 JavaScript 库。
Vue 单页组件
组件是大多数现代框架的构建块。通常将工作拆分成更小的块不仅使代码更容易理解,而且从功能上遵循了不要重复自己(DRY)的原则。对于 Vue 用户来说,最具独特性的模式之一,也是最有益的模式之一,是单文件组件(SFC)模式。SFC 将外观和行为的责任集中到一个文件中,通常简化了项目架构,使开发过程更简单,能够在不切换文件的情况下引用 HTML、CSS 和 JavaScript 逻辑。你的默认.vue 文件结构如下:
![图 1.4:默认.vue 文件结构
图 1.4:默认.vue 文件结构
许多新 Vue 开发者容易陷入的一个陷阱是编写超过 500 行代码的巨大型 Vue 文件,仅为了 HTML 本身。通常这意味着你可以将这个长组件拆分成一些更小的组件;然而,我们将在未来的章节中介绍文件导入和代码拆分。
例如,在应用程序的头部,你可能有一个需要在不同页面上保持一致的复用 logo 元素。你将创建一个如logo.vue
的组件:
// logo.vue
<template>
<img src="img/myLogo.png" />
</template>
你可以将其导入到名为header.vue
的头部组件中:
// header.vue
<template>
<header>
<a href="mywebsite.com"><logo /></a>
</header>
</template>
<script>
import logo from 'components/logo.vue'
export default {
components: {
logo
}
}
</script>
很快,你将拥有大量这些语义化结构的文件,它们使用这些小块的可复用语法,你的团队可以在应用程序的各个区域实现。
在下一节中,我们将了解数据属性。
数据属性(Props)
在构建 Vue 组件时,最常用且具有响应性的术语之一是数据属性。这些在 Vue 实例的数据函数中体现出来:
<template>
<div>{{color}}</div>
</template>
<script>
export default {
data() {
return {
color: 'red'
}
}
}
</script>
您可以使用数据属性来存储您想在 Vue 模板中使用的基本信息。当这个数据属性更新或更改时,它将在相应的模板中响应式地更新。
练习 1.01:构建您的第一个组件
在这个练习中,我们将在 Vue 项目内部构建我们的第一个组件。在这种情况下,组件是通过 yarn
导入并安装的。这些将在 前言 中介绍。到练习结束时,您将能够自信地使用 Vetur 创建新的 Vue 组件并将它们导入到项目中。
要访问此练习的代码文件,请参阅 packt.live/35Lhycl
。
-
打开命令行终端,导航到
Exercise 1.01
文件夹,并按顺序运行以下命令:> cd Exercise1.01/ > code . > yarn > yarn serve
前往
https://localhost:8080
。注意
当您保存新的更改时,您的应用程序将进行热重载,因此您可以立即看到它们。
-
在
code .
命令中,进入src/App.vue
目录,并删除该文件中的所有内容并保存。 -
在您的浏览器中,一切应该是空白的,作为开始工作的清洁画布。
-
构成一个单文件组件的三个主要组成部分是
<template>
、<script>
和<style>
块。如果您已从 前言 中安装了 Vetur 扩展,请输入vue
并按 Tab 键选择提示的第一项。这是设置默认代码块的最快方式,如下面的截图所示:// src/App.vue <template> </template> <script> export default { } </script> <style> </style>
-
在
components
文件夹中创建另一个名为Exercise1-01.vue
的文件,并重复相同的步骤,使用 Vetur 搭建 Vue 块:// src/components/Exercise1-01.vue <template> </template> <script> export default { } </script> <style> </style>
-
在我们的
Exercise1-01.vue
组件中,编写一组<div>
标签,并在<template>
标签内包含一个<h1>
元素和标题:<template> <div> <h1>My first component!</h1> </div> </template>
-
在
<style>
块内添加以下样式:<template> <div> <h1>My first component!</h1> </div> </template> <style> h1 { font-family: 'Avenir', Helvetica, Arial, sans-serif; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
-
使用 ES6
import
方法将我们的组件导入到App.vue
中,并在<script>
块中的components
对象内定义组件。现在我们可以通过使用其名称在camelCase
或kebab-case
(两者都有效)来在 HTML 中引用此组件:<template> <Exercise /> </template> <script> import Exercise from './components/Exercise1-01' export default { components: { Exercise, } } </script>
当您点击
保存
时,https://localhost:8080
应该重新加载并显示以下输出:
图 1.6:Exercise 1.01 的本地主机输出
在这个练习中,我们看到了如何使用模板标签构建 Vue 组件,使用 Vetur
搭建基本 Vue 组件,输出 HTML,以及使用 ES6 语法将 Exercise1-01
组件导入到 App.vue
中。
注意
<template>
标签内只能有一个根 HTML 元素。复杂组件应该被您选择的包含 HTML 标签包裹。<div>
、<article>
和 <section>
都是语义化的 HTML 组件包装器。
使用内联插值的数据绑定语法
内联插值是将不同性质的东西插入到其他东西中的过程。在 Vue.js 的上下文中,这就是你将使用mustache语法(双花括号)来定义可以注入数据到组件 HTML 模板中的区域的地方。
考虑以下示例:
new Vue({
data() {
title: 'Vue.js'
},
template: '<span>Framework: {{ title }}</span>'
})
数据属性title
绑定到 Vue.js 响应式数据,并且会根据 UI 及其数据的状态变化实时更新。我们将在下一个练习中更深入地探讨如何使用内联插值以及如何将其绑定到数据属性。
练习 1.02:使用条件语句的内联插值
当你想将数据输出到模板中或使页面上的元素具有响应性时,可以通过使用花括号将数据内联到模板中。Vue 可以理解并替换这些占位符为数据。
要访问此练习的代码文件,请参阅packt.live/3feLsJ3
。
-
打开命令行终端,导航到
Exercise 1.02
文件夹,并按顺序运行以下命令:> cd Exercise1.02/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
在
Exercise1-02.vue
组件内部,让我们通过添加一个名为data()
的函数并在其中返回一个名为title
的键,其值为你的标题字符串,来在<script>
标签内添加数据:<script> export default { data() { return { title: 'My first component!', } }, } </script>
-
通过将
<h1>
文本替换为内联值{{ title }}
来引用数据title
:<template> <div> <h1>{{ title }}</h1> </div> </template>
当你保存此文档时,数据标题现在将出现在你的
h1
标签内。 -
在 Vue 中,内联将解析花括号内的任何 JavaScript。例如,你可以使用
toUpperCase()
方法在花括号内转换你的文本:<template> <div> <h1>{{ title.toUpperCase() }}</h1> </div> </template>
你应该看到以下截图所示的输出:
![图 1.7:保存文件——你现在应该有一个大写标题]
![图片 B15218_01_07.jpg]
图 1.7:保存文件——你现在应该有一个大写标题
-
除了解析 JavaScript 方法外,内联也可以处理条件逻辑。在数据对象内部,添加一个布尔键值对
isUppercase: false
:<template> <div> <h1>{{ isUppercase ? title.toUpperCase() : title }}</h1> </div> </template> <script> export default { data() { return { title: 'My first component!', isUppercase: false, } }, } </script>
上述代码将生成以下输出:
![图 1.8:包含内联条件语句的练习 1.02 输出]
![图片 B15218_01_08.jpg]
图 1.8:包含内联条件语句的练习 1.02 输出
-
将此条件添加到花括号中,当你保存时,你应该看到非大写标题。通过将
isUppercase
更改为true
来玩转这个值:<script> export default { data() { return { title: 'My first component!', isUppercase: true, } }, } </script>
以下截图显示了运行前面代码后生成的最终输出:
![图 1.9:最终练习 1.02 输出]
![图片 B15218_01_09.jpg]
图 1.9:最终练习 1.02 输出
在这个练习中,我们能够通过使用布尔变量在内联标签(花括号)内使用内联条件来使用内联条件。这允许我们在不过度复杂的条件下修改组件内部显示的数据,这在某些用例中可能很有用。
我们现在将学习如何使用各种方法来设置组件样式。
组件样式
当使用 Vue 组件时,webpack 编译器允许你使用你喜欢的几乎所有前端模板语言风格。例如,有几种方法可以组合 CSS,无论是直接还是通过预处理。在 Vue 模板中启用这些表达性语言的最简单方法是在使用 Vue CLI 设置项目之前安装它们。
当你在 Vue 组件中使用 style
标签时,如果你已安装适当的 webpack 加载器,你可以指定一个语言。在 Exercise 1.01
中,如果你选择安装 SCSS 预处理器,你可以在 style
标签中添加 lang="scss"
属性以开始使用 SCSS。
例如,如果你选择安装 Stylus 预处理器,你可以在 style
标签中添加 lang="stylus"
属性以开始使用 Stylus:
<style lang="stylus">
ul
color: #2c3e50;
> h2
color: #22cc33;
</style>
Vue 作用域是一个方便的方法,可以阻止单个组件从虚拟 DOM 头继承样式。将 scoped
属性添加到你的 style
标签中,并编写一些特定于组件的样式,这些样式将覆盖全局样式表中的任何其他 CSS 规则。一般规则是不作用域全局样式。定义全局样式的常见方法是将这些样式分离到另一个样式表中,并将其导入到你的 App.vue
中。
练习 1.03:将 SCSS 导入到作用域组件中
在此练习中,我们将利用 style
标签将 SCSS 预处理样式添加到组件中,并导入外部样式表。
要访问此练习的代码文件,请参阅 packt.live/3nBBZyl
。
-
打开命令行终端,导航到
Exercise1.03
文件夹,并按顺序运行以下命令:> cd Exercise1.03/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
在练习文件内部,让我们编写一些可以使用 SCSS 样式的 HTML。让我们继续练习插值方法:
// src/components/Exercise1-03.vue <template> <div> <h1>{{ title }}</h1> <h2>{{ subtitle }}</h2> <ul> <li>{{ items[0] }}</li> <li>{{ items[1] }}</li> <li>{{ items[2] }}</li> </ul> </div> </template> <script> export default { data() { return { title: 'My list component!', subtitle: 'Vue JS basics', items: ['Item 1', 'Item 2', 'Item 3'] } }, } </script>
-
将
lang
属性添加到style
标签中,并将值设置为scss
以在style
块内启用 SCSS 语法:<style lang="scss"></style>
-
在
src/
目录下创建一个名为styles
的文件夹。在这个新文件夹中创建一个名为typography.scss
的文件:src/styles/typography.scss
-
在
typography.scss
内部,为你在组件中编写的模板添加一些样式:/* typography.scss */ $color-green: #4fc08d; $color-grey: #2c3e50; $color-blue: #003366; h1 { margin-top: 60px; text-align: center; color: $color-grey; + h2 { text-align: center; color: $color-green; } } ul { display: block; margin: 0 auto; max-width: 400px; padding: 30px; border: 1px solid rgba(0,0,0,0.25); > li { color: $color-grey; margin-bottom: 4px; } } h1 + h2 { /* Add styling */ } ul > li { /* Add styling */ }
在 SCSS 中,相同的代码可以表示如下:
h1 { + h2 { // Add styling } } ul { > li { // Add styling } }
-
在你的组件中,通过使用 SCSS 的
@import
方法导入这些样式:<style lang="scss"> @import '../styles/typography'; </style>
这将生成以下输出:
图 1.10:当你保存并重新加载时,你的项目应该已经导入了样式
-
将
scoped
属性添加到你的<style>
标签中,以便只将这些样式应用于此组件实例。使用从导入的样式表中导入的变量$color-blue
:<style lang="scss" scoped> @import '../styles/typography'; h1 { font-size: 50px; color: $color-blue; // Use variables from imported stylesheets } </style>
上述代码的输出如下:
图 1.11:作用域样式的结果
检查 DOM,您将注意到在运行时,作用域已将
v-data-*
属性应用于您的 DOM 元素,指定了这些特定规则。我们针对组件作用域的typography.scss
引用了一个不在组件作用域内的 HTML 标签。当 Vue 向作用域组件添加数据属性时,如果<body>
标签存在于组件中,它将生成样式。在我们的例子中,它不存在。在浏览器开发者工具的
Elements
选项卡中展开<head>
和<style>
标签后,将显示以下内容:图 1.12:观察虚拟 DOM 如何使用数据属性来分配作用域样式
-
在
styles
文件夹中创建一个新的样式表global.scss
:/* /src/styles/global.scss */ body { font-family: 'Avenir', Helvetica, Arial, sans-serif; margin: 0; }
-
将此样式表导入到您的
App.vue
中:<style lang="scss"> @import './styles/global'; </style>
现在,我们的应用应该恢复正常,具有全局定义的样式和此组件的正确作用域样式,如下所示:
图 1.13:练习 1.03 的正确作用域样式
在这个练习中,我们插值了来自数组的原始数据,然后使用作用域 SCSS 的形式来样式化我们的组件,这些样式可以存在于 <style>
标签内部,或者从我们的项目中的另一个目录导入。
CSS 模块
在响应式框架领域最近流行的一种模式是 CSS 模块。前端开发一直必须面对 CSS 类名冲突、结构不良的 BEM 代码和混乱的 CSS 文件结构等问题。Vue 组件通过模块化并允许您组合 CSS 来帮助解决这个问题,在编译时,将为特定组件生成唯一的类名。您甚至可以在组件之间使用完全相同的类名;然而,它们将通过附加在末尾的随机生成的字符串来唯一标识。
要在 Vue 中启用此功能,您需要将模块属性添加到 style
块中,并使用 JavaScript 语法引用类:
<template>
<div :class="$style.container">CSS modules</div>
</template>
<style lang="scss" module>
.container {
Width: 100px;
Margin: 0 auto;
background: green;
}
</style>
在前面的示例中,如果您检查 DOM 树,该类将被命名为类似 .container_ABC123
的名称。如果您创建多个具有语义类名如 .container
的组件,但使用 CSS 模块,您将永远不会再次遇到样式冲突。
练习 1.04:使用 CSS 模块样式化 Vue 组件
在这个练习中,您将利用 CSS 模块来样式化一个 .vue
组件。通过在 :class
绑定中使用 $style
语法,您引用了 Vue 实例的 this.$style
作用域。Vue 将根据运行或构建时的组件生成随机类名,确保样式不会与您的项目中的任何其他类冲突。
要访问此练习的代码文件,请参阅 packt.live/36PPYdd
。
-
打开命令行终端,导航到
Exercise1.04
文件夹,并按顺序运行以下命令:> cd Exercise1.04/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
在
Exercise1-04.vue
内部,编写以下代码:<template> <div> <h1>{{ title }}</h1> <h2>{{ subtitle }}</h2> </div> </template> <script> export default { data() { return { title: 'CSS module component!', subtitle: 'The fourth exercise', } }, } </script>
-
添加带有 SCSS 语言的
<style>
块,并将module
作为属性而不是scoped
:<style lang="scss" module> h1, h2 { font-family: 'Avenir', Helvetica, Arial, sans-serif; text-align: center; } .title { font-family: 'Avenir', Helvetica, Arial, sans-serif; color: #2c3e50; margin-top: 60px; } .subtitle { color: #4fc08d; font-style: italic; } </style>
-
要在模板中使用 CSS 模块,您需要通过使用
:class
语法将它们绑定到您的 HTML 元素上,这与v-bind:class
指令相同:<h1 :class="$style.title">{{ title }}</h1> <h2 :class="$style.subtitle">{{ subtitle }}</h2>
保存时,您的项目应该看起来像这样:
图 1.14:使用 CSS 模块实现的练习 1.04 输出
如果检查虚拟 DOM,您将看到它如何为绑定元素应用了唯一的类名:
图 1.15:虚拟 DOM 树生成的 CSS 模块类
在这个练习中,我们看到了如何在 Vue 组件中使用 CSS 模块以及它是如何与 CSS 作用域不同的。在下一个练习中,我们将学习如何用 PUG (HAML) 编写模板。
注意
结合文件拆分和导入 SCSS,CSS 模块是这里作用域组件样式的首选方法。这安全地确保了单个组件样式和业务规则不会相互覆盖,并且不会因组件特定的样式要求而污染全局样式和变量。可读性很重要。类名也暗示了组件名称,而不是 v-data
属性,这在调试大型项目时可能是有益的。
练习 1.05:在 PUG (HAML) 中编写组件模板
启用正确的加载器后,您可以使用 HTML 抽象,如 PUG 和 HAML,来模板化您的 Vue 组件,而不是编写 HTML。
要访问此练习的代码文件,请参阅 packt.live/2IOrHvN
。
-
打开命令行终端,导航到
Exercise1.05
文件夹,并按顺序运行以下命令:> cd Exercise1.05/ > code . > yarn
前往
https://localhost:8080
。 -
如果 Vue 在命令行中运行,请按 Ctrl + C 停止实例。然后运行以下命令:
vue add pug yarn serve
-
在
Exercise1-05.vue
内部,编写以下代码,并在<template>
标签上指定lang
属性pug
:<template lang="pug"> div h1(class='title') {{ title }} </template> <script> export default { data() { return { title: 'PUG component!', } }, } </script> <style lang="scss"> .title { font-family: 'Avenir', Helvetica, Arial, sans-serif; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
上述代码将生成以下输出:
图 1.16:PUG 练习的输出
在这个练习中,我们看到了如何使用其他 HTML 语言进行模板化,并在 PUG
格式中插值数据。在安装 Vue.js PUG
插件后,您可以在这些模板标签内使用 PUG
编写组件语法,通过添加具有值 pug
的 lang
属性。
Vue 指令
Vue 的模板语言允许您将 HTML 代码与 JavaScript 表达式和 Vue 指令进行插值。这种模板模式通常被称为语法糖,因为它不会改变代码本身的工作方式,只是改变了您使用它的方式。语法糖允许您在 HTML 中清晰地定义特定于模板的逻辑,而无需在项目的其他地方抽象此逻辑或直接从 JavaScript 代码中返回大量的 HTML。所有基于 Vue 的指令都以 v-*
前缀开头,这表明它是一个 Vue 特定的属性:
-
v-text
:v-text
指令具有与反应性插值相同的特性,除了您在指令内部引用相同的数据片段。插值(花括号){{ }}
比指令v-text
更高效;然而,您可能会发现自己处于这样的情况,即您从服务器预先渲染了文本,并希望在 Vue 应用程序加载后覆盖它。例如,您可以在 DOM 等待data
和v-text
属性最终替换它时预先定义静态占位文本。 -
v-once
: 作为指令,这是独一无二的,因为它可以与其他指令配对以增强其功能。通过v-text
或插入花括号将数据传递到添加了此属性的 HTML 元素中,将阻止 Vue 实例重新加载元素以新的数据,从而移除元素的反应性。这在您想要使用数据渲染装饰性元素,但又不希望它们在初始渲染后数据更改时更新时非常有用。 -
v-html
: 这个指令将在绑定到其上的 HTML 元素内的数据字符串中渲染有效的 HTML。与其他指令相比,这个指令的操作更重,因此当其他选项不可用时应限制使用。注意
<script>
标签可以在这个指令中运行。仅渲染来自安全或可信来源的内容。 -
v-bind
: 这个指令是 Vue 中使用最广泛的指令之一。在 Vue 的整个框架中,您将通过这个指令的:attr
快捷方式将响应式数据绑定到 HTML 属性,并使用它将数据传递给 props,而不是使用v-bind:attr
。 -
v-if
: 为了控制模板中 HTML 元素的显示状态,您通常会使用v-if
完全从 DOM 树中移除元素。到目前为止,您已经看到了如何插入条件,例如{{ isTrue ? 'Show this': 'Not this' }}
。使用v-if
指令,您可以控制整个 HTML 语法块。v-else-if
可以像else if
语句一样使用,并以v-else
结尾,它是传统 JavaScript 中else { ... }
语句的catch { ... }
声明的等价物。 -
v-show
:您可以通过使用v-show
来控制 HTML 元素的可见状态,它不会从 DOM 树中删除元素,而是应用display: none
样式。v-if
和v-show
之间的区别在于v-show
将作为块元素保留在 DOM 树中,但将通过css
隐藏而不是从 DOM 树中删除。您也不能将v-show
与v-else
或v-else-if
连接。 -
v-for
:将此指令应用于您想要重复或迭代的元素。此指令需要一个额外的属性:key
,以便它可以正确地响应式渲染;它可以是简单的唯一数字。考虑一个例子,其中我们迭代列表元素五次。每个列表项将渲染其计数(1,2… 5):
<ul><!-- do not apply v-for to this <ul> element --> <li v-for="n in 5" :key="n">{{ n }}</li> </ul>
现在,让我们看看一些基本指令是如何工作的。
练习 1.06:基本指令(v-text、v-once、v-html、v-bind、v-if、v-show)
更复杂的组件将使用多个指令来实现所需的输出。在这个练习中,我们将构建一个组件,该组件使用多个指令来绑定、操作并将数据输出到模板视图。
要访问此练习的代码文件,请参阅 packt.live/3fdCNqa
。
-
打开命令行终端,导航到
Exercise1.06
文件夹,并按顺序运行以下命令:> cd Exercise1.06/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
在
Exercise1-06.vue
内部编写以下语法。这使用了我们在之前的练习中使用的插值方法,并且到这一点上应该非常熟悉:<template> <div> <h1>{{ text }}</h1> </div> </template> <script> export default { data() { return { // v-text text: 'Directive text', } }, } </script> <style lang="scss" scoped> h2 { margin: 40px 0 0; font-weight: normal; } </style>
-
将插值替换为
v-text
属性。您将注意到输出不会改变:<template> <div> <h1 v-text="text">Loading...</h1> </div> </template> <script> export default { data() { return { // v-text text: 'Directive text', } }, } </script>
图 1.17 展示了前面代码的输出:
![图 1.17:v-text 指令的输出与插值方法非常相似
图 1.17:v-text 指令的输出与插值方法非常相似
-
在同一元素上添加
v-once
指令。这将强制此 DOM 元素在页面存在期间只加载一次v-text
数据:<template> <div> <h1 v-once v-text="text">Loading...</h1> </div> </template> <script> export default { data() { return { // v-text text: 'Directive text', } }, } </script> ...
-
在
h1
元素下方,包含一个新的h2
元素,该元素使用v-html
属性。添加一个新的数据键html
,其中包含如下格式的字符串:<template> <div> <h1 v-once v-text="text">Loading...</h1> <h2 v-html="html" /> </div> </template> <script> export default { data() { return { // v-text text: 'Directive text', // v-html html: 'Stylise</br>HTML in<br/><b>your data</b>', } }, } </script> ...
运行前面的代码将生成如下输出:
![图 1.18:渲染 HTML 元素后的输出
图 1.18:渲染 HTML 元素后的输出
-
在
data
对象中添加一个新的link
对象,其中包含诸如 URL、目标、标题和标签索引等信息。在模板内部,添加一个新的锚点 HTML 元素,并使用冒号语法将link data
对象绑定到 HTML 元素上,例如,:href="link.url"
:<template> <div> <h1 v-once v-text="text">Loading...</h1> <h2 v-html="html" /> </div> </template> <script> export default { data() { return { // v-text text: 'Directive text', // v-html html: 'Stylise</br>HTML in<br/><b>your data</b>', } }, } </script> ...
以下截图显示了输出:
图 1.19:将 Vue 实例的响应式数据绑定到任何 HTML 属性的输出
-
将
v-if="false"
应用到h1
元素上,将v-else-if="false"
应用到h2
上,将v-else
应用到a
标签上,如下所示:<template> <div> <h1 v-if="false" v-once v-text="text">Loading...</h1> <h2 v-else-if="false" v-html="html" /> <a v-else :href="link.url" :target="link.target" :tabindex="link.tabindex" v-text="link.title" /> </div> </template>
你应该只能看到页面中的
<a>
标签,因为我们已经将条件语句设置为false
。v-else
条件将显示如下:![图 1.20:v-if 语句将整个 HTML 元素从 DOM 中隐藏]
![图片 B15218_01_20.jpg]
图 1.20:v-if 语句将整个 HTML 元素从 DOM 中隐藏
-
将模板更改为使用
v-show
而不是v-if
语句:<template> <div> <h1 v-show="true" v-once v-text="text">Loading...</h1> <h2 v-show="false" v-html="html" /> <a :href="link.url" :target="link.target" :tabindex="link.tabindex" v-text="link.title" /> </div> </template>
上述代码的输出将如下所示:
![图 1.21:将 v-show 设置为 true 将显示元素]
![图片 B15218_01_21.jpg]
图 1.21:将 v-show 设置为 true 将显示元素
当你打开浏览器开发者工具的 Elements
选项卡时,你应该能够观察到 h2
的显示状态设置为 none
,如下所示:
![图 1.22:h2 显示 "display: none" 的 false 条件]
![图片 B15218_01_22.jpg]
图 1.22:在 false 条件下,h2 显示 "display: none"
如果 v-show
结果为 true
布尔值,它将保持 DOM 元素不变。如果解析为 false
,它将应用 display: none
样式到该元素上。
在这个练习中,我们学习了 Vue 的核心指令,用于控制、绑定、显示和隐藏 HTML 模板元素,而无需在本地状态中添加任何新的数据对象之外的 JavaScript。
在下一节中,我们将学习如何在 Vue 的 v-model
的帮助下实现双向绑定。
使用 v-model 实现双向绑定
Vue 通过创建一个专门监视 Vue 组件内部数据属性的指令,简化了实现双向数据绑定的方式。当 Vue 监视的绑定数据属性发生变化时,Vue 指令 v-model
会做出响应性变化。这个指令通常对需要同时显示数据和修改数据的 HTML 表单元素很有用,例如输入、文本区域、单选按钮等。
通过向要绑定的元素添加 v-model
指令并引用数据属性来实现双向绑定:
<template>
<input v-model="name" />
</template>
<script>
export default {
data() {
return {
name: ''
}
}
}
</script>
图 1.23 代表运行上述代码生成的输出:
![图 1.23:v-model 示例的输出]
![图片 B15218_01_23.jpg]
图 1.23:v-model 示例的输出
使用此指令时要小心,因为以这种方式绑定大量数据可能会影响应用程序的性能。考虑你的 UI 并将其拆分为不同的 Vue 组件或视图。Vue 中的本地状态数据不是不可变的,可以在模板的任何地方重新定义。
练习 1.07:使用 v-model 实现双向绑定
我们将使用 Vue 的双向数据绑定属性 v-model 来构建一个组件。考虑一下双向绑定数据片段的含义。这种数据模型的应用场景通常是表单,或者你期望既有输入又有输出数据的地方。到练习结束时,我们应该能够在一个表单上下文中使用 v-model 属性。
要访问此练习的代码文件,请参阅packt.live/2IILld8
。
-
打开命令行终端,导航到
Exercise1.07
文件夹,并按顺序运行以下命令:> cd Exercise1.07/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
首先,在模板区域使用
v-model
组合一个 HTML 标签和输入元素,并将其绑定到name
数据属性:<div class="form"> <label> Name <input type="text" v-model="name" /> </label> </div>
-
通过在
<script>
标签中返回一个名为name
的响应式数据属性来完成文本输入的绑定:<script> export default { data() { return { name: '', } }, } </script>
-
使用模板区域内的
v-model
组合一个标签和可选择的 HTML 列表,并将其绑定到数据属性language
:<div class="form"> <label> Name <input type="text" v-model="name" /> </label> <label> Preferred javascript style <select name="language" v-model="language"> <option value="Javascript">JavaScript</option> <option value="TypeScript">TypeScript</option> <option value="CoffeeScript">CoffeeScript</option> <option value="Dart">Dart</option> </select> </label> </div>
-
通过在
<script>
标签中返回一个名为language
的响应式数据属性来完成选择输入的绑定:<script> export default { data() { return { name: '', language: '', } }, } </script>
-
在表单字段下方,使用花括号(例如,
{{ name }}
)在无序列表结构(<ul>
和<li>
)中输出名称和语言:<template> <section> <div class="form"> <label> Name <input type="text" v-model="name" /> </label> <label> Preferred javascript style <select name="language" v-model="language"> <option value="Javascript">JavaScript</option> <option value="TypeScript">TypeScript</option> <option value="CoffeeScript">CoffeeScript</option> <option value="Dart">Dart</option> </select> </label> </div> <ul class="overview"> <li><strong>Overview</strong></li> <li>Name: {{ name }}</li> <li>Preference: {{ language }}</li> </ul> </section> </template>
-
在组件底部的
<style>
标签内添加样式,并将lang
属性设置为scss
:
Exercise1-07.vue
37 <style lang="scss">
38 .form {
39 display: flex;
40 justify-content: space-evenly;
41 max-width: 800px;
42 padding: 40px 20px;
43 border-radius: 10px;
44 margin: 0 auto;
45 background: #ececec;
46 }
47
48 .overview {
49 display: flex;
50 flex-direction: column;
51 justify-content: space-evenly;
52 max-width: 300px;
53 margin: 40px auto;
54 padding: 40px 20px;
55 border-radius: 10px;
56 border: 1px solid #ececec;
57
58 > li {
59 list-style: none;
60 + li {
61 margin-top: 20px;
62 }
63 }
64 }
65 </style>
The complete code for this step is available at https://packt.live/36NiNXH.
您的输出应该如下所示:
图 1.24:数据更新后的最终表单显示
您的表单应该看起来像这样。当您更新表单中的数据时,它也应该同步更新概览区域。
在这个练习中,我们使用了v-model
指令来绑定名称和 JavaScript 风格的下拉选择到我们本地状态的数据。当您更改数据时,它将响应式地更新我们输出此绑定数据的 DOM 元素。
匿名循环
要在 Vue 中遍历 HTML 元素,您将使用v-for
循环指令。当 Vue 渲染组件时,它将迭代您添加指令的 HTML 元素,以使用解析到指令中的数据。:key
。当键或绑定到键的内容发生变化时,Vue 知道它需要重新加载循环内的内容。如果您在一个组件中有多个循环,请使用额外的字符或与上下文相关的字符串随机化键,以避免:key
重复冲突。
匿名循环的示例如下;请注意,您可以使用引号或反引号(`)来描述字符串:
<div v-for="n in 2" :key="'loop-1-' + n">
{{ n }}
</div>
<!-- Backticks -->
<div v-for="n in 5" :key="`loop-2-${n}`">
{{ n }}
</div>
上述代码的输出应该如下所示。
图 1.25:匿名循环示例的输出
理解循环对于不仅使用 Vue,而且一般使用 JavaScript 都是关键。现在我们已经介绍了如何使用v-for
语法处理循环以及绑定:key
属性以向循环内容添加响应性的重要性,我们将在下一个练习中利用这个功能。
练习 1.08:使用 v-for 遍历字符串数组
在这个练习中,我们将使用 Vue 的v-for
指令执行匿名循环。这将对那些之前在 JavaScript 中使用过for
或foreach
循环的人很熟悉。
要访问此练习的代码文件,请参阅 packt.live/390SO1J
。
执行以下步骤以完成练习:
-
打开命令行终端,导航到
Exercise1.08
文件夹,并按顺序运行以下命令:> cd Exercise1.08/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
通过在组件中添加
<h1>
标题和一个<ul>
元素(其中包含一个<li>
标签,该标签具有v-for
指令,其值为n
为5
)来在Exercise1-08.vue
内部编写以下语法:Exercise1-08.vue 1 <template> 2 <div> 3 <h1>Looping through arrays</h1> 4 <ul> 5 <li v-for="n in 5" :key="n"> 6 {{ n }} 7 </li> 8 </ul> The complete code for this step is available at https://packt.live/3pFAtgB.
这将生成以下输出:
图 1.26:遍历任意数字也将允许你输出索引
-
现在让我们遍历一个字符串数组,并使用
n
计算数组的迭代次数。在data()
函数中准备一个包含你个人兴趣的数组。通过在interests
数组中查找 (item, n
),item
输出数组的字符串,而n
是循环索引:<template> <div> <h1>Looping through arrays</h1> <ul> <li v-for="(item, n) in interests" :key="n"> {{ item }} </li> </ul> </div> </template> <script> export default { data() { return { interests: ['TV', 'Games', 'Sports'], } }, } </script>
运行上述代码将生成以下输出:
图 1.27:遍历字符串数组
在这个练习中,我们学习了如何遍历任意数字和特定的字符串数组,输出数组的字符串值或索引。我们还了解到,键属性需要是唯一的,以避免 DOM 冲突并强制 DOM 正确重新渲染组件。
遍历对象
当从 API 请求数据时,你通常会遍历包含逻辑和原始内容的对象数组。Vue 通过其指令语法使控制数据的各种状态变得简单。条件指令控制 Vue 中 DOM 元素的显示状态。HTML 语法在你的组件中设置显示规则时提供了清晰的可见性。
练习 1.09:使用 v-for 循环遍历对象数组并使用它们的属性进行 v-if 条件
在这个练习中,我们将控制 Vue 数据数组,并遍历其内部的对象。
要访问此练习的代码文件,请参阅 packt.live/32YokKa
。
-
打开命令行终端,导航到
Exercise1.09
文件夹,并按顺序运行以下命令:> cd Exercise1.09/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
在
Exercise1-09.vue
内部编写以下语法,并创建一个包含title
字符串和favorite
字符串数组的对象。我们将像遍历字符串数组一样遍历interests
对象;然而,你需要引用interests
对象内的title
键:<template> <div> <h1>Looping through array of objects</h1> <ul> <li v-for="(item, n) in interests" :key="n"> {{ item.title }} </li> </ul> </div> </template>
运行上述代码的输出将如下所示:
图 1.28:现在你应该在前端看到一系列标题
-
让我们创建第二个
v-for
循环来遍历你的收藏列表。注意,我们在嵌套循环中使用不同的键——fav
和m
——这是因为你仍然可以在嵌套循环的上下文中使用item
和n
的值:<template> <div> <h1>Looping through array of objects</h1> <ul> <li v-for="(item, n) in interests" :key="n"> {{ item.title }} <ol> <li v-for="(fav, m) in item.favorite" :key="m"> {{ fav }}</li> </ol> </li> </ul> </div> </template>
图 1.29 显示了通过对象数组执行循环的输出:
![图 1.29:嵌套有序列表详细说明你的收藏
图 1.29:嵌套有序列表详细说明你的收藏
-
为了优化 DOM 树,我们可以使用
Exercise 1.09
中的v-if
条件指令来隐藏不必要的 DOM 元素:![图 1.30:在虚拟 DOM 中显示空 DOM 元素图 1.30:在虚拟 DOM 中显示空 DOM 元素
-
我们将检查数组中是否有超过
0
个元素以显示有序列表 HTML 元素。向<ol>
添加一个v-if
指令,条件为item.favorite.length > 0
:// src/components/Exercise1-09.vue <template> <div> <h1>Looping through array of objects</h1> <ul> <li v-for="(item, n) in interests" :key="n"> {{ item.title }} <ol v-if="item.favorite.length > 0"> <li v-for="(fav, m) in item.favorite" :key="m"> {{ fav }}</li> </ol> </li> </ul> </div> </template>
这不会影响你页面的视觉效果,但当你检查浏览器中的虚拟 DOM 树时,你会注意到在开发模式下有一个 HTML 注释,这允许你理解
v-if
语句可能为false
的位置。当你为生产构建时,这些 HTML 注释不会出现在你的 DOM 中。![图 1.31:显示生产构建中没有 HTML 注释的输出
图 1.31:显示生产构建中没有 HTML 注释的输出
通过在开发模式下使用v-if
指令,你会看到一个 HTML 注释。这些在产品构建中不会存在。
在这个练习中,我们能够遍历复杂对象的数组,输出这些对象的嵌套键,并根据长度条件控制 DOM 元素的状态。
Vue 中的方法
Vue 方法是在 Vue 实例内的methods
对象中定义的,可以像正常的 JavaScript 函数一样编写,其中定义了一段要执行的逻辑。当你使用 JavaScript 函数时,通常你会返回一个值或者简单地执行一个全局操作。编写函数和 Vue 方法的主要区别在于 Vue 方法的作用域限定在你的 Vue 组件内,并且可以在组件内部任何位置运行。由于方法的作用域限定在组件的 Vue 实例内,你可以在 HTML 模板中轻松地通过事件指令引用它们。在 Vue 中绑定 HTML 元素的事件时,你会使用@
符号;例如,v-on:click
等同于@click
。
练习 1.10:触发方法
在这个练习中,我们将构建一个使用 Vue 方法 API 的组件。考虑这些 Vue 方法可以像你自己的命名函数一样编写,因为它们的行为非常相似。通过练习的结束,我们应该能够使用方法并在 HTML 模板中触发它们。
要访问此练习的代码文件,请参阅packt.live/3kMTWs5
。
-
打开命令行终端,导航到
Exercise1.10
文件夹,并按顺序运行以下命令:> cd Exercise1.10/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
让我们遍历一个方法触发器,并将它的数字传递给一个方法。在 HTML 列表上设置一个匿名
v-for
循环,并在列表元素内部添加一个锚元素。将循环设置为迭代5
次:<template> <div> <h1>Triggering Vue Methods</h1> <ul> <li v-for="n in 5" :key="n"> <a href="#">Trigger</a> </li> </ul> </div> </template>
-
添加一个名为
triggerAlert
的方法,并使用@click
指令引用它,将n
的值作为参数传递。使用花括号将值n
输出到锚元素中:<template> <div> <h1>Triggering Vue Methods</h1> <ul> <li v-for="n in 5" :key="n"> <a href="#" @click="triggerAlert(n)">Trigger {{ n }}</a> </li> </ul> </div> </template>
-
在
methods
对象内部,添加一个带有n
参数的triggerAlert(n)
键。在这个方法内部,添加一个alert
函数,它将输出值n
加上一些静态文本:<script> export default { methods: { triggerAlert(n) { alert(`${n} has been clicked`) }, }, } </script>
-
在组件底部的
<style>
标签内添加样式,并将lang
属性设置为scss
:Exercise1-10.vue 22 <style lang="scss" scoped> 23 ul { 24 padding-left: 0; 25 } 26 li { 27 display: block; 28 list-style: none; 29 30 + li { 31 margin-top: 10px; 32 } 33 } 34 35 a { 36 display: inline-block; 37 background: #4fc08d; 38 border-radius: 10px; 39 color: white; 40 padding: 10px 20px; 41 text-decoration: none; 42 } 43 </style> The complete code for this step is available at https://packt.live/374yKZZ.
-
您的页面应该包含一个按钮列表,当点击按钮时,会触发一个包含您点击的按钮编号的消息的警告,如下所示:![图 1.32:输出触发器列表]
![图片 B15218_01_32.jpg]
![图 1.32:输出触发器列表]
当点击触发器时,会显示以下提示:
![图 1.33:显示包含索引数字的浏览器警告]
![图片 B15218_01_33.jpg]
![图 1.33:显示包含索引数字的浏览器警告]
注意
虽然您可以将事件指令添加到任何 HTML 元素中,但建议将它们应用于原生 HTML 交互元素,如锚标签、表单输入或按钮,以帮助提高浏览器可访问性。
在这个练习中,我们能够利用 Vue 方法 API 定义和从 HTML 模板触发方法,并将参数动态地解析到每个方法中。
练习 1.11:使用 Vue 方法返回数据
在这个练习中,我们将学习如何将 Vue 方法作为一个函数在 Vue 实例和模板内部返回数据。
在 web 应用程序中,我们通常希望元素根据条件是否满足而出现在页面上。例如,如果我们的产品缺货,我们的页面应该显示缺货的事实。
因此,让我们弄清楚我们如何根据我们的产品是否有库存来有条件地渲染这些元素。
要访问此练习的代码文件,请参阅 packt.live/3pHWCeh
。
-
打开命令行终端,导航到
Exercise1.11
文件夹,并按顺序运行以下命令:> cd Exercise1.11/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
让我们遍历一个随机金额并触发
addToCart
方法。设置两个数据对象totalItems
和totalCost
,当用户点击我们的购物按钮时,这些对象将被更新。接下来,通过指定this
在 Vue 的script
块中引用数据对象。例如,在template
块中,我们将totalItems
引用为{{ totalItems }}
,但在script
块中,我们将它引用为this.totalItems
。对于方法,使用相同的模式,其中addToCart
将在另一个方法中引用为this.addToCart
:<template> <div> <h1>Returning Methods</h1> <div>Cart({{ totalItems }}) {{ totalCost }} </div> <ul> <li v-for="n in 5" :key="n"> <a href="#" @click="addToCart(n)">Add {{ n }}</a> </li> </ul> </div> </template> <script> export default { data() { return { totalItems: 0, totalCost: 0, } }, methods: { addToCart(n) { this.totalItems = this.totalItems + 1 this.totalCost = this.totalCost + n }, }, } </script> <style lang="scss" scoped> ul { padding-left: 0; } li { display: block; list-style: none; + li { margin-top: 10px; } } a { display: inline-block; background: rgb(235, 50, 50); border-radius: 10px; color: white; padding: 10px 20px; text-decoration: none; } </style>
这将生成以下输出:
图 1.34:按下任意按钮将演示购物车逻辑
当你点击按钮时,项目计数器应该增加
1
,但成本将增加n
值,这应该展示正常的购物车功能(点击加 2
,然后加 5
):图 1.35:显示递增后的返回方法的输出
-
让我们谈谈金钱。我们可以使用方法来执行逻辑操作,根据事件增强或格式化字符串。创建一个名为
formatCurrency
的方法,它接受一个参数。我们将返回相同的值,在它后面加上两位小数和一个$
符号。要在模板中使用此方法,只需将其添加到插值大括号中,并将方法内的值作为参数传递:<template> <div> <h1>Returning Methods</h1> <div>Cart({{ totalItems }}) {{ formatCurrency(totalCost) }} </div> <ul> <li v-for="n in 5" :key="n"> <a href="#" @click="addToCart(n)">Add {{ formatCurrency(n) }}</a> </li> </ul> </div> </template> <script> export default { data() { return { totalItems: 0, totalCost: 0, } }, methods: { addToCart(n) { this.totalItems = this.totalItems + 1 this.totalCost = this.totalCost + n }, formatCurrency(val) { return `$${val.toFixed(2)}` }, }, } </script>
以下截图显示了前面代码的输出:
图 1.36:现在所有值都应看起来像货币,同时保留购物车计数器
在这个练习中,我们能够利用 Vue 的方法 API 将参数解析为方法,返回修改后的值,并在一个逼真的场景中使用方法来更新本地数据状态。
Vue 生命周期钩子
Vue 组件生命周期事件包括以下内容:
-
beforeCreate
: 当你的组件被初始化时运行。data
尚未变为响应式,DOM 中的事件也没有设置。 -
created
: 你将能够访问响应式数据和事件,但模板和 DOM 尚未挂载或渲染。这个钩子通常在从服务器请求异步数据时很好用,因为你很可能希望在虚拟 DOM 挂载之前尽可能早地获取这些信息。 -
beforeMount
: 这是一个非常不常见的钩子,因为它直接在组件首次渲染之前运行,并且在服务器端渲染中不会被调用。 -
mounted
: 挂载钩子是你将最常使用的钩子之一,因为它们允许你访问你的 DOM 元素,以便集成非 Vue 库。 -
beforeUpdate
:在组件发生变化后立即运行,在它被重新渲染之前。在渲染之前获取响应式数据的状态很有用。 -
updated
:在beforeUpdate
钩子之后立即运行,并使用新的数据更改重新渲染你的组件。 -
beforeDestroy
:在销毁组件实例之前直接触发。组件在 destroyed 钩子被调用之前仍然可以正常工作,这允许你停止事件监听器和数据订阅以避免内存泄漏。 -
destroyed
:所有虚拟 DOM 元素和事件监听器都已从你的 Vue 实例中清理。此钩子允许你向任何需要知道此操作已完成的人或元素传达这一信息。
练习 1.12:使用 Vue 生命周期控制数据
在这个练习中,我们将学习如何以及何时使用 Vue 的生命周期钩子,以及它们通过 JavaScript alerts 触发的情况。到练习结束时,我们将能够理解和使用多个 Vue 生命周期钩子。
要访问此练习的代码文件,请参阅 packt.live/36N42nT
。
-
打开命令行终端,导航到
Exercise1.12
文件夹,并按顺序运行以下命令:> cd Exercise1.12/ > code . > yarn > yarn serve
前往
https://localhost:8080
。注意
随意将 alert 替换为
console.log()
。 -
首先,创建一个数组来在列表元素中迭代,将键设置为
n
,并在<li>
元素内部使用花括号输出值{{item}}
:<template> <div> <h1>Vue Lifecycle hooks</h1> <ul> <li v-for="(item, n) in list" :key="n"> {{ item }} </li> </ul> </div> </template> <script> export default { data() { return { list: [ 'Apex Legends', 'A Plague Tale: Innocence', 'ART SQOOL', 'Baba Is You', 'Devil May Cry 5', 'The Division 2', 'Hypnospace Outlaw', 'Katana ZERO', ], } } } </script>
-
在 data() 函数下方添加
beforeCreated()
和created()
作为函数。在这些钩子内部设置一个 alert 或 console log,以便你可以看到它们何时被触发:<script> export default { ... beforeCreate() { alert('beforeCreate: data is static, thats it') }, created() { alert('created: data and events ready, but no DOM') }, } </script>
当你刷新浏览器时,你应该在看到你的列表在页面上加载之前看到这两个 alert:
![图 1.37:首先观察 beforeCreate() 钩子 alert]
图 1.37:首先观察 beforeCreate() 钩子 alert
以下截图显示了在 beforeCreate() 钩子之后的 created() 钩子 alert:
![图 1.38:在 beforeCreate() 钩子之后观察 before() 钩子 alert]
图 1.38:在 beforeCreate() 钩子之后观察 before() 钩子 alert
-
在 created() 钩子下方添加
beforeMount()
和mounted()
作为函数。在这些钩子内部设置一个 alert 或 console log,以便你可以看到它们何时被触发:<script> export default { ... beforeMount() { alert('beforeMount: $el not ready') }, mounted() { alert('mounted: DOM ready to use') }, } </script>
当你刷新浏览器时,你也应该在看到你的列表在页面上加载之前看到这些 alerts:
![图 1.39:在 create() 钩子之后观察 beforeMount() 钩子 alert]
图 1.39:在 create() 钩子之后观察 beforeMount() 钩子 alert
以下截图显示了在 beforeMount() 钩子之后的 mounted() 钩子 alert:
![图 1.40:观察在 beforeMount() 钩子之后的 mounted() 钩子 alert]
图 1.40:观察在 beforeMount() 钩子之后的 mounted() 钩子 alert
-
在您的
<li>
元素内部添加一个新的锚点元素,它位于项目输出旁边。使用@click
指令将此按钮绑定到名为deleteItem
的方法,并将item
值作为参数传递:<template> <div> <h1>Vue Lifecycle hooks</h1> <ul> <li v-for="(item, n) in list" :key="n"> {{ item }} <a @click="deleteItem(item)">Delete</a> </li> </ul> </div> </template>
-
在
hooks
下方添加一个名为deleteItem
的方法到methods
对象中,但位于data()
函数下方。在这个函数中,将value
作为参数传递,并从列表数组中过滤出与值不匹配的项,然后用新列表替换现有列表:Exercise1-12.vue 17 <script> 18 export default { 19 data() { 20 return { 21 list: [ 22 'Apex Legends', 23 'A Plague Tale: Innocence', 24 'ART SQOOL', 25 'Baba Is You', 26 'Devil May Cry 5', 27 'The Division 2', 28 'Hypnospace Outlaw', 29 'Katana ZERO', 30 ], 31 } 32 }, 33 methods: { 34 deleteItem(value) { 35 this.list = this.list.filter(item => item !== value) 36 }, 37 }, The complete code for this step is available at https://packt.live/3pJGLvO.
-
在组件底部的
<style>
标签内添加样式,并将lang
属性设置为scss
:<style lang="scss" scoped> ul { padding-left: 0; } li { display: block; list-style: none; + li { margin-top: 10px; } } a { display: inline-block; background: rgb(235, 50, 50); padding: 5px 10px; border-radius: 10px; font-size: 10px; color: white; text-transform: uppercase; text-decoration: none; } </style>
-
在
mounted()
钩子下方添加beforeUpdate()
和updated()
作为函数,并在这些钩子内部设置一个警告或控制台日志,以便您可以查看它们何时被触发:<script> export default { ... beforeUpdate() { alert('beforeUpdate: we know an update is about to happen, and have the data') }, updated() { alert('updated: virtual DOM will update after you click OK') }, } </script>
当您通过在浏览器中点击删除按钮删除列表项时,您应该看到这些警告。
-
在
updated()
钩子下方添加beforeDestroy()
和destroyed()
作为函数。在这些钩子内部设置一个警告或控制台日志,以便您可以查看它们何时被触发:<script> export default { ... beforeDestroy() { alert('beforeDestroy: about to blow up this component') }, destroyed() { alert('destroyed: this component has been destroyed') }, } </script>
-
向您的
list
数组添加一个新项:<script> export default { data() { return { list: [ 'Apex Legends', 'A Plague Tale: Innocence', 'ART SQOOL', 'Baba Is You', 'Devil May Cry 5', 'The Division 2', 'Hypnospace Outlaw', 'Katana ZERO', ], } },
在您使用 localhost 运行并保存此更改后,在浏览器中显示更新警告之后,您还应该看到销毁警告。这将生成以下输出:
![图 1.41:显示 Vue 生命周期钩子的输出]
![图片 B15218_01_41.jpg]
图 1.41:显示 Vue 生命周期钩子的输出
-
每个生命周期钩子都会运行警告。尝试删除元素,在列表数组中添加新元素,并刷新页面以查看每个这些钩子何时发生。这将生成以下输出:![图 1.42:在每次触发时显示消息]
![图片 B15218_01_42.jpg]
图 1.42:在每次触发时显示消息
每次您在页面上操作某个元素时,都会触发一个警告,演示每个可用的 Vue 生命周期。
注意
Mounted
和created
生命周期钩子将在组件每次加载时运行。如果这不是您期望的效果,请考虑在父组件或视图中运行您想要运行的代码一次,例如App.vue
文件。
在这个练习中,我们学习了 Vue 生命周期钩子是什么以及它们何时触发。这将与触发方法和控制 Vue 组件中的数据相结合非常有用。
活动一.01:使用 Vue.js 构建动态购物清单应用程序
在这个活动中,我们将构建一个动态购物清单应用程序,通过使用 SFC 的所有基本功能来测试您对 Vue 的了解,例如表达式、循环、双向绑定和事件处理。
此应用程序应允许用户创建和删除单个列表项,并一键清除整个列表。
以下步骤将帮助您完成活动:
-
使用绑定到
v-model
的输入在一个组件中构建一个交互式表单。 -
添加一个输入字段,您可以将购物清单项添加到其中。通过将方法绑定到
@keyup.enter
事件,允许用户使用Enter键添加项。 -
用户可以通过删除所有项目或逐个删除它们来清除列表。为此,你可以使用一个可以传递数组位置作为参数的
delete
方法,或者简单地覆盖整个购物清单数据属性,使其成为一个空数组[]
。预期的结果如下:
![图 1.43:最终输出
图 1.43:最终输出
注意
本活动的解决方案可以通过这个链接找到。
摘要
在本章中,你学习了如何使用命令提示符运行 Vue 项目以及如何创建基本的 Vue 组件。在这些 Vue 组件中,你可以构建模板,使用 Vue 的独特指令和 HTML 语法糖来遍历数据或使用条件语句控制 DOM 状态。通过使用数据属性和 v-model
绑定,我们探讨了响应式数据的关键概念,并在利用 Vue.js 方法和生命周期的实际示例中使其变得有用。
在下一章中,我们将学习更多高级的响应式数据概念,这些概念将建立在第一章的基础上:使用计算属性和监听器以及从外部源获取异步数据。
第二章:2. 处理数据
概述
在本章中,你将通过介绍更多控制 Vue 组件内部数据的方法来扩展上一章所学的内容。你将学习如何设置高级监视器来观察组件内部的数据变化,并利用 Vue 强大的反应性数据特性,计算数据属性,在模板中简洁地输出所需的数据。你还将能够利用异步方法为 Vue 组件获取数据。
到本章结束时,你将能够监视、管理和操作 Vue.js 组件中的各种数据源。
简介
在上一章中,你被介绍了单文件组件的概念和 Vue API,它提供了访问方法、指令和数据属性的方式。基于这些基础,我们将介绍计算属性,它们与数据属性一样,在 UI 中是反应性的,但可以执行强大的计算,并且它们的结果是可缓存的,这提高了你项目的性能。当构建电子商务商店时,你通常希望用户与你的 UI 交互时,反应性地计算定价和购物车项目,这在过去需要在不重新加载页面的情况下使用类似 jQuery
的方法来实现。Vue.js 通过引入计算属性,这些计算属性可以立即对前端用户输入做出反应,轻松处理这些常见的前端任务。
让我们从介绍可以即时计算的反应性数据开始,并了解如何调用和操作异步数据。
计算属性
计算属性是一种独特的数据类型,当属性中使用的源数据更新时,它们会反应性地更新。它们看起来可能像 Vue 方法,但实际上不是。在 Vue 中,我们可以通过将数据属性定义为计算属性来跟踪数据属性的变化,在这个属性中添加自定义逻辑,并在组件的任何地方使用它来返回一个值。Vue 会缓存计算属性,这使得它们在返回数据方面比数据属性或使用 Vue 方法更高效。
你可能使用计算属性的场景包括但不限于:
-
total
数据属性小于1
。每当向items
数组添加新的数据时,total
的计算属性将更新:<template> <div>{{errorMessage}}</div> </template> <script> export default { data() { return { items: [] } }, computed: { total() { return this.items.length }, errorMessage() { if (this.total < 1) { return 'The total must be more than zero' } else { return '' } } } } </script>
这将生成以下输出:
The total must be more than zero
-
formalName
,可以在你的组件中使用:<template> <div>{{ formalName }}</div> </template> <script> export default { data() { return { title: 'Mr.', surname: 'Smith' } }, computed: { formalName() { return this.title + ' ' + this.surname } } } </script>
这将生成以下输出:
Mr. Smith
-
post
。你将使用简化和语义化的计算属性将信息输出到组件模板中。本例中的计算属性使得识别和使用作者的完整姓名、查看他们发布了多少帖子以及显示他们的特色帖子变得更容易:<template> <div> <p>{{ fullName }}</p> <p>{{ totalPosts }}</p> <p>{{ featuredPosts }}</p> </div> </template> <script> export default { data() { return { post: { fields: { author: { firstName: 'John', lastName: 'Doe' }, entries: [{ title: "Entry 1", content: "Entry 1's content", featured: true }, { title: "Entry 2", content: "Entry 2's content", featured: false } ] } } } }, computed: { fullName() { // Return string return this.post.fields.author.firstName + ' ' + this.post.fields.author.lastName }, totalPosts() { // Return number return this.post.fields.entries.length }, featuredPosts() { // Return string return this.post.fields.entries.filter(entry => { // If featured is true, return the entry title if (entry.featured) { return entry } }) } } } </script>
这将生成以下输出:
图 2.1:计算名称输出
计算属性对于创建高性能组件的 Vue 开发者来说非常有价值。在下一个练习中,我们将探讨如何在 Vue 组件内部使用它。
练习 2.01:将计算数据集成到 Vue 组件中
在这个练习中,你将使用计算属性来帮助你减少在 Vue 模板内部需要编写的代码量,通过简洁地输出基本数据。要访问此练习的代码文件,请参阅 packt.live/3n1fQZY
。
-
打开命令行终端,导航到
Exercise 2.01
文件夹,并按顺序运行以下命令:> cd Exercise2.01/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
创建一个用于第一个名称的输入字段,使用
v-model
将数据属性firstName
绑定到该字段:<input v-model="firstName" placeholder="First name" />
-
创建第二个输入字段用于姓氏,并使用
v-model
将数据属性lastName
绑定到该字段:<input v-model="lastName" placeholder="Last name" />
-
通过在
data()
函数中返回这些新的v-model
数据属性,将它们包含在 Vue 实例中:data() { return { firstName: '', lastName: '', } },
-
创建一个名为
fullName
的计算数据变量:computed: { fullName() { return `${this.firstName} ${this.lastName}` }, },
-
在你的输入字段下方,使用
heading
标签输出计算数据:<h3 class="output">{{ fullName }}</h3>
这将生成以下输出:
![图 2.2:计算数据的输出将显示姓名和姓氏]
![img/B15218_02_02.jpg]
图 2.2:计算数据的输出将显示姓名和姓氏
在这个练习中,我们看到了如何在计算数据属性内部编写表达式,使用 v-model
的数据,并将第一个名称和姓氏合并成一个可以重用的单个输出变量。
计算设置器
在上一个练习中,你看到了如何编写可维护和声明式的计算属性,这些属性是可重用和响应式的,并且可以在组件内的任何地方调用。在某些实际情况下,当调用计算属性时,你可能需要调用外部 API 来与该 UI 交互或更改项目中的其他数据。执行此功能的东西被称为设置器。
计算设置器在以下示例中演示:
data() {
return {
count: 0
}
},
computed: {
myComputedDataProp: {
// getter
get() {
return this.count + 1
},
// setter
set(val) {
this.count = val - 1
this.callAnotherApi(this.count)
},
},
},
}
默认情况下,计算数据仅是获取器,这意味着它只会输出你的表达式的结果。在此示例中,计算 myComputedDataProp
数据属性将在你的 Vue 组件中输出 1
:
get() {
return this.count + 1
},
然后,使用计算属性中的设置器可以让你响应式地监听数据并运行一个回调(设置器),该回调包含从获取器返回的值,这些值可以可选地用于设置器中。
在此示例中,设置器将更新数据属性 count
到其新值(在获取器中反映)并调用组件内的一个方法 callAnotherApi
。在这里,我们传递 count
数据属性来模拟将此信息发送到某个有用的地方:
set(val) {
this.count = val - 1
this.callAnotherApi(this.count)
},
在以下练习中,你将了解到如何将计算数据作为获取器和设置器使用。
练习 2.02:使用计算设置器
在这个练习中,你将使用计算属性作为设置器和获取器,这两个属性在用户输入触发时都会输出表达式并设置数据。
要访问此练习的代码文件,请参阅packt.live/2GwYapA
。
-
打开命令行终端,导航到
Exercise 2.02
文件夹,并按顺序运行以下命令:> cd Exercise2.02/ > code . > yarn > yarn serve
访问
https://localhost:8080
。 -
创建一个
v-model
值绑定到名为incrementOne
的计算数据值的输入字段,在 getter 中返回名为count
的 Vue 数据变量的值,并在 setter 中设置count
变量:<template> <div class="container"> <input type="number" v-model="incrementOne" /> <h3>Get input: {{ incrementOne }}</h3> </div> </template> <script> export default { data() { return { count: -1, } }, computed: { incrementOne: { // getter get() { return this.count + 1 }, // setter set(val) { this.count = val - 1 }, }, }, } </script>
上述代码的输出将如下所示:
图 2.3:计算 setter 和 getter 的第一步
-
接下来,让我们再次使用 setter。我们将把新的
val
参数除以2
,并将其保存到名为divideByTwo
的新数据变量中:<template> <div class="container"> <input type="number" v-model="incrementOne" /> <h3>Get input: {{ incrementOne }}</h3> <h5>Set division: {{ divideByTwo }}</h5> </div> </template> <script> export default { data() { return { count: -1, divideByTwo: 0, } }, ... </script> ...
-
将 setter 更新为除以
val
,并将这个新值绑定到divideByTwo
变量:set(val) { this.count = val - 1 this.divideByTwo = val / 2 },
divideByTwo
值的输出应生成从输入字段中输入的值的输出,如下所示:
图 2.4:divideByTwo 值的输出
在这个练习中,我们看到了如何通过将计算变量绑定到v-model
来使用计算数据在我们的模板中反应性地获取和设置数据。
监听器
Vue oldVal
和newVal
。这可以帮助你在写入或绑定新值之前比较数据。监听器可以观察对象以及string
、number
和array
类型。当观察对象时,只有当整个对象发生变化时,才会触发处理程序。
在第一章,开始您的第一个 Vue 项目中,我们介绍了在组件生命周期特定时间运行的生存周期钩子。如果将immediate
键设置为true
,则当组件初始化时,将运行此监听器。你可以通过包含键和值deep: true
(默认为false
)来监视任何给定对象中的所有键。为了清理你的监听器代码,你可以将处理程序参数分配给定义的 Vue 方法,这对于大型项目来说是最佳实践。
监听器补充了计算数据的用法,因为它们可以被动地观察值,不能像正常 Vue 数据变量那样使用,而计算数据必须始终返回一个值并且可以被查询。请记住,除非你不需要 Vue 的this
上下文,否则不要使用箭头函数。
以下示例展示了immediate
和deep
可选键;如果myDataProperty
对象中的任何键发生变化,它将触发控制台日志:
watch: {
myDataProperty: {
handler: function(newVal, oldVal) {
console.log('myDataProperty changed:', newVal, oldVal)
},
immediate: true,
deep: true
},
}
现在,让我们在监听器的帮助下设置一些新值。
练习 2.03:使用监听器设置新值
在这个练习中,你将使用监听器参数来监视数据属性的变化,然后使用这个监听器通过一个方法设置变量。
要访问此练习的代码文件,请参阅packt.live/350ORI4
。
-
打开命令行终端,导航到
Exercise 2.03
文件夹,并按顺序运行以下命令:> cd Exercise2.03/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
通过添加一个折扣和带有一些样式的
oldDiscount
数据变量来设置文档:<template> <div class="container"> <h1>Shop Watcher</h1> <div> Black Friday sale <strike>Was {{ oldDiscount }}%</strike> <strong> Now {{ discount }}% OFF</strong> </div> </div> </template> <script> export default { data() { return { oldDiscount: 0, discount: 5, } }, } </script> <style lang="scss" scoped> .container { margin: 0 auto; padding: 30px; max-width: 600px; font-family: 'Avenir', Helvetica, sans-serif; margin: 0; } a { display: inline-block; background: rgb(235, 50, 50); border-radius: 10px; font-size: 14px; color: white; padding: 10px 20px; text-decoration: none; } </style>
-
通过将
discount
属性添加到watch
对象中,来观察discount
属性。触发名为updateDiscount
的方法。在方法内部,将oldDiscount
数据属性设置为this.discount + 5
:watch: { discount(newValue, oldValue) { this.oldDiscount = oldValue }, },
-
包含一个将增加
discount
变量并触发观察者的方法:methods: { updateDiscount() { this.discount = this.discount + 5 }, },
现在添加一个换行符,并添加一个带有绑定到
updateDiscount
方法的@click
指令的锚点元素:<br /> <a href="#" @click="updateDiscount">Increase Discount!</a>
前一个命令的输出将如下所示:
图 2.5:一个商店观察者页面应该看起来像这样
在这个练习中,我们看到了如何使用观察者来观察和响应式地操作数据,当数据被 Vue 组件中的其他方法更改时。
在下一节中,我们将学习关于深度观察的概念。
深度观察概念
当使用 Vue.js 观察数据属性时,你可以有目的地观察对象内的键以进行更改,而不是观察对象本身的更改。这是通过将可选的deep
属性设置为true
来完成的:
data() {
return {
organization: {
name: 'ABC',
employees: [
'Jack', 'Jill'
]
}
}
},
watch: {
organization: {
handler: function(v) {
this.sendIntercomData()
},
deep: true,
immediate: true,
},
},
此示例将观察组织数据对象内部的所有可用键以进行更改,因此如果组织内的name
属性发生变化,组织观察者将触发。
如果你不需要观察对象内的每个键,可以通过指定为myObj.value
字符串来仅观察对象内的特定键以进行更改,这可能会更高效。例如,你可能允许用户编辑他们的公司名称,并且只有当该键被修改时,才将数据发送到 API。
在下面的示例中,观察者专门观察了organization
对象的name
键。
data() {
return {
organization: {
name: 'ABC',
employees: [
'Jack', 'Jill'
]
}
}
},
watch: {
'organization.name': {
handler: function(v) {
this.sendIntercomData()
},
immediate: true,
},
},
我们看到了深度观察是如何工作的。现在,让我们尝试下一个练习,并观察数据对象的嵌套属性。
练习 2.04:观察数据对象的嵌套属性
在这个练习中,你将使用观察者来观察对象内的键,这些键将在用户在 UI 中触发方法时更新。
要访问此练习的代码文件,请参阅packt.live/353m59N
。
-
打开命令行终端,导航到
Exercise 2.04
文件夹,并按顺序运行以下命令:> cd Exercise2.04/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
首先定义一个包含
price
、label
和discount
键的product
对象。将这些值输出到模板中:<template> <div class="container"> <h1>Deep Watcher</h1> <div> <h4>{{ product.label }}</h4> <h5>${{ product.price }} (${{ discount }} Off)</h5> </div> </div> </template> <script> export default { data() { return { discount: 0, product: { price: 25, label: 'Blue juice', }, } }, } </script> <style lang="scss" scoped> .container { margin: 0 auto; padding: 30px; max-width: 600px; font-family: 'Avenir', Helvetica, sans-serif; margin: 0; } a { display: inline-block; background: rgb(235, 50, 50); border-radius: 10px; font-size: 14px; color: white; padding: 10px 20px; text-decoration: none; } </style>
-
设置一个按钮,将修改产品的价格。通过添加一个带有绑定到
updatePrice
方法的click
事件的按钮元素来实现这一点,该方法递减价格值:<template> ... <a href="#" @click="updatePrice">Reduce Price!</a> ... </template> <script> ... methods: { updatePrice() { if (this.product.price < 1) return this.product.price-- }, }, ... </script>
当你点击按钮时,它应该像以下屏幕截图所示那样降低价格:
图 2.6:显示蓝汁降价屏幕
-
是时候观察嵌套观察者了。我们将观察
product
对象的price
,并增加discount
数据属性:watch: { 'product.price'() { this.discount++ }, },
现在,随着你减少
price
,由于观察者的作用,discount
值将会上升:![图 2.7 显示增加的折扣值]
![img/B15218_02_07.jpg]
图 2.7 显示增加的折扣值
在这个练习中,我们使用了观察者来观察对象中的键,然后使用或不用观察者解析的可选参数设置新数据。
方法与观察者与计算属性的比较
在 Vue.js 工具箱中,我们有方法、观察者和计算属性。你何时应该使用其中一个或另一个?
方法最适合在 date.now()
事件发生时做出反应。
在 Vue 中,你会组合一个由 @click
标记的动作,并引用一个方法:
<template>
<button @click="getDate">Click me</button>
</template>
<script>
export default {
methods: {
getDate() {
alert(date.now())
}
}
}
</script>
计算属性最适合在响应数据更新或为我们在模板中组合复杂表达式时使用。在这种情况下,如果 animalList
数据发生变化,animals
计算属性也将通过从数组中切片第二个项目并返回新值来更新:
<template>
<div>{{ animals }}</div>
</template>
<script>
export default {
data() {
return {
animalList: ['dog', 'cat']
}
},
computed: {
animals() {
return this.animalList.slice(1)
}
}
}
</script>
它们的响应性使得计算属性非常适合从现有数据中组合新的数据变量,例如当你引用一个更大、更复杂对象的特定键时,有助于简化模板的可读性。在这个例子中,我们以两种不同的方式输出了作者两次。然而,请注意在 authorName
计算属性中,你可以干净地组合条件逻辑,而不会使 HTML 模板膨胀:
<template>
<div>
<p id="not-optimal">{{ authors[0].bio.name }}</p>
<p id="optimal">{{ authorName }}</p>
</div>
</template>
<script>
export default {
data() {
return {
authors: [
{
bio: {
name: 'John',
title: 'Dr.',
}
}
]
}
},
computed: {
authorName () {
return this.authors ? this.authors[0].bio.name : 'No Name'
}
}
}
</script>
当你需要监听数据属性的变化或对象中的特定数据属性变化,并执行一个动作时,应该使用数据观察者。由于观察者的独特 newVal
和 oldVal
参数,你可以观察一个变量直到达到某个值,然后才执行动作:
<template>
<div>
<button @click="getNewName()">Click to generate name </button>
<p v-if="author">{{ author }}</p>
</div>
</template>
<script>
export default {
data() {
return {
data: {},
author: '',
}
},
watch: {
data: function(newVal, oldVal) {
this.author = newVal.first
alert(`Name changed from ${oldVal.first} to ${newVal.first}`)
}
},
methods: {
async getNewName() {
await fetch('https://randomuser.me/api/'). then(response => response.json()).then(data => {
this.data = data.results[0].name
})
},
},
}
</script>
有了这个想法,我们将使用一个方法、计算属性和观察者来构建一个简单的搜索功能,以实现类似的效果并展示每种方法的能力。
练习 2.05:使用 Vue 方法、观察者和计算属性处理搜索功能
在这个练习中,你将创建一个组件,允许用户使用 Vue 中的三种不同方法搜索数据数组。到练习结束时,你将能够亲眼看到每种不同方法是如何工作的。
要访问此练习的代码文件,请参阅 packt.live/32iDJVe
。
-
打开命令行终端,导航到
Exercise 2.05
文件夹,并按顺序运行以下命令:> cd Exercise2.05/ > code . > yarn > yarn serve
前往
https://localhost:8080
。 -
在
data
对象中,添加一个框架列表数组,分配给frameworkList
值。包括一个空字符串作为输入键和一个空数组作为methodFilterList
键:<script> export default { data() { return { // Shared frameworkList: [ 'Vue', 'React', 'Backbone', 'Ember', 'Knockout', 'jQuery', 'Angular', ], // Method input: '', methodFilterList: [], } }, } </script>
-
在模板中,包括一个
div
容器、一个title
和一个column
容器。在这个column
容器内部,创建一个绑定到v-model
输入的输入框,并将输入框上的keyup
事件绑定到searchMethod
方法:<template> <div class="container"> <h1>Methods vs watchers vs computed props</h1> <div class="col"> <input type="text" placeholder="Search with method" v-model="input" @keyup="searchMethod" /> <ul> <li v-for="(item, i) in methodFilterList" :key="i"> {{ item }}</li> </ul> </div> </div> </template> <script> export default { data() { return { // Shared frameworkList: [ 'Vue', 'React', 'Backbone', 'Ember', 'Knockout', 'jQuery', 'Angular', ], // Method input: '', methodFilterList: [], } }, methods: { searchMethod(e) { console.log(e) }, }, } </script> <style lang="scss" scoped> .container { margin: 0 auto; padding: 30px; max-width: 600px; font-family: 'Avenir', Helvetica, Arial, sans-serif; } .col { width: 33%; height: 100%; float: left; } input { padding: 10px 6px; margin: 20px 10px 10px 0; } </style>
前面代码的输出将如下所示:
![图 2.8:控制台应输出关键输入
图 2.8:控制台应输出关键输入
-
在我们的
searchMethod
方法中,编写一个过滤表达式,将methodFilterList
数据属性绑定到基于输入值的过滤frameworkList
数组。在created()
生命周期钩子上触发searchMethod
,以便当组件加载时,有一个列表存在:<script> export default { ... created() { this.searchMethod() }, methods: { searchMethod() { this.methodFilterList = this.frameworkList.filter(item => item.toLowerCase().includes(this.input.toLowerCase()) ) }, }, } </script>
运行前面的代码后,你将能够像图 2.9中所示的那样过滤列表:
![图 2.9:你现在应该能够使用 Vue 方法过滤列表
图 2.9:你现在应该能够使用 Vue 方法过滤列表
-
让我们使用计算属性来创建一个过滤器。包括一个新的数据属性
input2
,并创建一个名为computedList
的计算属性,它返回与searchMethod
相同的过滤器,但不需要绑定到另一个数据属性:<template> <div class="container"> ... <div class="col"> <input type="text" placeholder="Search with computed" v-model="input2" /> <ul> <li v-for="(item, i) in computedList" :key="i"> {{ item }}</li> </ul> </div> ... </div> </template> <script> export default { data() { return { ... // Computed input2: '', ... } }, ... computed: { computedList() { return this.frameworkList.filter(item => { return item.toLowerCase().includes(this.input2\. toLowerCase()) }) }, }, ... } </script>
现在你可以借助计算属性过滤框架的第二列,如下面的截图所示:
![图 2.10:使用计算属性过滤框架的第二列
图 2.10:使用计算属性过滤框架的第二列
-
最后,让我们使用一个观察者来过滤相同的列表。包含一个带有空字符串的
input3
属性和一个带有空数组的watchFilterList
属性。同时创建一个第三列div
,其中包含一个绑定到input3
的v-model
的输入框,以及输出watchFilterList
数组的列表:<template> <div class="container"> … <div class="col"> <input type="text" placeholder="Search with watcher" v-model="input3" /> <ul> <li v-for="(item, i) in watchFilterList" :key="i"> {{ item }}</li> </ul> </div> </div> </template> <script> export default { data() { return { ... // Watcher input3: '', watchFilterList: [], } }, ... </script>
-
创建一个观察者,它监视
input3
属性的变化,并将frameworkList
过滤的结果绑定到watchFilterList
数组。将input3
的立即键设置为true
,以便它在组件创建时运行:<script> export default { ... watch: { input3: { handler() { this.watchFilterList = this.frameworkList.filter(item => item.toLowerCase().includes(this.input3.toLowerCase()) ) }, immediate: true, }, }, ... } </script>
在观察者的帮助下,你现在应该能够过滤第三列,如下面的截图所示:
![图 2.11:在第三列使用观察者过滤列表
图 2.11:在第三列使用观察者过滤列表
在这个练习中,我们看到了如何使用方法、计算属性和观察者来实现过滤列表。每个都有自己的优点、缺点和使用场景,具体取决于你想要在应用程序中实现什么。
异步方法和数据获取
JavaScript 中的异步函数是通过 async
函数语法定义的,并返回一个 AsyncFunction
对象。这些函数通过事件循环异步操作,使用隐式的 promise(一个可能在未来返回结果的对象)。Vue.js 使用这种行为允许你在方法中包含 async
关键字来声明异步代码块。然后,你可以链式调用 then()
和 catch()
函数,或者在 Vue 方法中使用 {}
语法并返回结果。
Axios
是一个流行的 JavaScript 库,允许你使用 Node.js 进行外部数据请求。它具有广泛的浏览器支持,使其在执行 HTTP
或 API 请求时成为一个多才多艺的库。我们将在下一个练习中使用这个库。
练习 2.06:使用异步方法从 API 获取数据
在这个练习中,你将异步从外部 API 源获取数据,并使用计算属性在前端显示它。
要访问此练习的代码文件,请参阅 packt.live/353md9h
。
-
打开命令行终端,导航到
Exercise 2.06
文件夹,并运行以下命令来安装axios
:> cd Exercise2.06/ > code . > yarn > yarn add axios > yarn serve
访问
https://localhost:8080
。 -
让我们从将
axios
导入我们的组件并创建一个名为getApi()
的方法开始。使用axios
调用api.adviceslip.com/advice
的响应,并使用console.log
输出结果。包括一个按钮,将其click
事件绑定到getApi()
调用:<template> <div class="container"> <h1>Async fetch</h1> <button @click="getApi()">Learn something profound</button> </div> </template> <script> import axios from 'axios' export default { methods: { async getApi() { return axios.get('https://api.adviceslip.com/advice'). then((response) => { console.log(response) }) }, }, } </script> <style lang="scss" scoped> .container { margin: 0 auto; padding: 30px; max-width: 600px; font-family: 'Avenir', Helvetica, Arial, sans-serif; } blockquote { position: relative; width: 100%; margin: 50px auto; padding: 1.2em 30px 1.2em 30px; background: #ededed; border-left: 8px solid #78c0a8; font-size: 24px; color: #555555; line-height: 1.6; } </style>
上述代码的输出将如下所示:
图 2.12:显示控制台中一个非常大的对象的屏幕
-
我们只对
response
对象内部的数据对象感兴趣。将此数据对象分配给一个名为response
的 Vue 数据属性,我们可以重用它:export default { data() { return { axiosResponse: {}, } }, methods: { async getApi() { return axios.get('https://api.adviceslip.com/advice'). then(response => { this.axiosResponse = response.data }) }, }, }
-
使用计算属性输出
response
属性对象内部的quote
,该计算属性将在response
属性更改时更新。使用三元运算符执行条件语句以检查response
属性是否包含slip
对象,以避免错误:<template> <div class="container"> <h1>Async fetch</h1> <button @click="getApi()">Learn something profound</button> <blockquote v-if="quote">{{ quote }}</blockquote> </div> </template> <script> import axios from 'axios' export default { data() { return { axiosResponse: {}, } }, computed: { quote() { return this.axiosResponse && this.axiosResponse.slip ? this.axiosResponse.slip.advice : null }, }, methods: { async getApi() { return axios.get('https://api.adviceslip.com/advice'). then(response => { this.axiosResponse = response.data }) }, }, } </script>
图 2.13 显示了上述代码生成的输出:
图 2.13:显示模板中引用输出的屏幕
-
作为最后的润色,包括一个
loading
数据属性,以便用户可以看到 UI 是否正在加载。默认将loading
设置为false
。在getApi
方法中,将loading
设置为true
,然后在then()
链中使用setTimeout
函数在 4 秒后将它设置回false
。你可以使用三元运算符在加载状态和默认状态之间切换按钮文本:<template> <div class="container"> <h1>Async fetch</h1> <button @click="getApi()">{{ loading ? 'Loading...' : 'Learn something profound' }}</button> <blockquote v-if="quote">{{ quote }}</blockquote> </div> </template> <script> import axios from 'axios' export default { data() { return { loading: false, axiosResponse: {}, } }, computed: { quote() { return this.axiosResponse && this.axiosResponse.slip ? this.axiosResponse.slip.advice : null }, }, methods: { async getApi() { this.loading = true return axios.get('https://api.adviceslip.com/advice'). then(response => { this.axiosResponse = response.data setTimeout(() => { this.loading = false }, 4000); }) }, }, } </script>
上述代码的输出将如下所示:
图 2.14:在模板中显示加载按钮状态输出的屏幕
在这个练习中,我们看到了如何从外部源获取数据,将其分配给计算属性,在我们的模板中显示它,并给我们的内容应用加载状态。
活动二.01:使用 Contentful API 创建博客列表
在这个活动中,我们将构建一个博客,列出来自 API 源的文章。这将通过使用所有基本的async
方法从 API 获取远程数据以及使用计算属性来组织深层嵌套的对象结构来测试您对 Vue 的了解。
Contentful
是一个无头内容管理系统(CMS),允许您将内容与代码存储库分开管理。您可以使用 API 在所需的任何代码存储库中消费此内容。例如,您可能有一个作为信息主要来源的博客网站,但您的客户想要一个独立页面的不同域名,该页面只拉取最新的特色文章。使用无头 CMS 本质上允许您开发这两个独立的代码库,并使用相同的数据源。
这个活动将使用无头 CMS Contentful
。访问密钥和端点将在解决方案中列出。
以下步骤将帮助您完成活动:
-
使用 Vue CLI 创建一个使用
babel
预设的新项目。 -
将
contentful
依赖项安装到您的项目中。 -
使用计算属性从 API 响应中输出深层嵌套的数据。
-
使用
data
属性输出用户的name
、职位名称
和描述
。 -
使用
SCSS
来设置页面样式。
预期结果是以下内容:
![图 2.15:使用 Contentful 博客文章的预期结果
图 2.15:使用 Contentful 博客文章的预期结果
注意
该活动的解决方案可以通过此链接找到。
活动完成后,您应该能够使用async
方法从 API 源将远程数据拉入您的 Vue 组件。您会发现计算属性是一种将信息分解成更小的可重用数据块的高级方式。
摘要
在本章中,您被介绍了 Vue.js 的计算和观察属性,这些属性允许您观察和控制响应式数据。您还看到了如何使用axios
库异步从 API 获取数据,以及如何使用计算属性将数据扁平化,以便在 Vue 模板中使用。通过构建使用每种方法的搜索功能,演示了使用方法和计算以及观察属性之间的区别。
下一章将介绍 Vue CLI,并展示如何管理并调试使用这些计算属性和事件的 Vue.js 应用程序。
第三章:3. Vue CLI
概述
本章介绍了 Vue CLI,包括 Vue-UI 和 Vue.js DevTools,这些工具在开发用于生产的 Vue 应用程序时会被使用。Vue-UI 允许你通过一个伴随的图形用户界面来创建、开发和管理工作室 Vue 项目。Vue.js DevTools 是一个独立的应用程序和浏览器扩展,用于调试 Vue.js 应用程序。我们将详细介绍使用 Vue CLI 功能的使用案例和好处,这将教会你如何使用这些 Vue 命令。除了命令行控制之外,我们还将设置并运行 Vue 项目,利用新的 Vue GUI。我们将结合前几章积累的知识来创建新的 Vue 应用程序,这些应用程序使用 v-model 指令和双向绑定概念。然后我们将深入探讨如何原型化 Vue 组件。我们还将学习如何构建用于生产的 Vue 原型并在本地提供服务。随着我们的进展,你将看到如何设置和调试你的 Vue 应用程序并展示其功能。
到本章结束时,你将牢固掌握如何使用 Vue CLI 的功能、原型化 Vue 组件以及利用 Vue.js DevTools。
简介
在上一章中,我们介绍了如何使用 Vue.js 在组件模板中反应性地管理和操作数据。在本章中,我们将探讨如何使用 Vue CLI 支持此类模板的开发。Vue.js 利用了npm
和 webpack 生态系统,正如在第一章,开始你的第一个 Vue 项目,在简单的 Vue 应用程序中的 Vue 实例示例中看到的那样。这些工具帮助开发者快速搭建和构建出色的 Web 应用程序。Vue.js 内部的一些显著模式包括vue.config(允许你添加 webpack 规则,而无需直接编辑 webpack 文件本身)、双向数据绑定和单文件组件(SFCs),正如在第一章,开始你的第一个 Vue 项目,使用 V-Model 进行双向绑定示例中看到的那样。
使用Vue 命令行界面(Vue CLI)实例化的 Webpack 项目将自带热重载功能。热重载是一种前端开发模式,当检测到代码更改时,浏览器中的应用程序将自动更新。你想要这个功能的原因是,这样你不会丢失任何浏览器状态,你代码中的更改将立即反映在浏览器中,这在处理用户界面(UI)时非常有用。偶尔,可能需要进行完整的页面重载,因为 JavaScript 是一种非常状态化的语言。
Vue CLI 是 Vue 开发的核心工具,因为它允许程序员通过一组描述性和预配置的命令更舒适地维护他们的项目。在开发项目中,一个经常被忽视的过程是代码检查,这是一个程序将标记你代码中潜在的错误或问题的过程,这可能在现有项目中相当困难。当使用 Vue CLI 时,如果你的 webpack 项目在创建 Vue CLI 项目时选择了代码检查选项,它将自带代码检查功能。
我们将使用 Vue CLI 配置一个 Vue 项目,并运行每个基本命令,以便你了解构建 Vue 应用程序所需的工具。默认情况下,Vue CLI 支持 Babel、TypeScript、ESLint、PostCSS、PWAs、测试等。
使用 Vue CLI
使用 Vue CLI 工具创建的项目可以访问一些常用任务,这些任务将帮助你在本地浏览器中运行(运行项目)、构建(为生产编译文件)和检查(检查代码中的错误)你的项目。Vue CLI 服务开发依赖包会自动与新的项目一起安装,并允许你运行以下命令:
-
npm run serve
或yarn serve
– 在localhost:8080
上运行项目代码,并具有热重载功能。端口号8080
是任意指定的,因为它高于其他计算领域使用的知名端口号1-1023
。如果你同时运行多个 Vue 项目,它们将具有递增的端口号,例如:8080
、:8081
等。 -
npm run build
或yarn build
– 运行生产构建,减小项目文件大小,并可以从主机提供服务。 -
npm run lint
或yarn lint
– 运行代码检查过程,这将突出显示代码错误或警告,使你的代码更加一致。
现在你已经了解了 Vue CLI 是什么,以及可用的命令,我们将学习如何使用 Vue CLI 从零开始设置 Vue.js 项目。
练习 3.01:使用 Vue CLI 设置项目
在这个练习中,你将使用 Vue CLI 命令创建你的第一个 Vue.js webpack 项目。但是,首先,请确保你已经遵循了前言指南安装了 Node
和 Vue CLI 4
。建议在 OS X 上使用 iTerm2
,因为它非常适合你的开发流程。如果你使用 Windows,建议使用 PowerShell,因为它可能比默认的命令提示符和 GIT bash 更高效。
要访问此练习的代码文件,请参阅 packt.live/3ph2xXt
。
-
打开命令提示符。你的窗口应该看起来如下:
图 3.1:一个空的命令提示符窗口
-
运行命令
vue --version
。确保你使用的是 Vue CLI 的最新版本,因为以下说明在 Vue CLI 2 或更早版本中可能无法正常工作。在前面的命令之后,你的屏幕应该看起来如下:
图 3.2:检查 Vue 版本时的命令提示符
您的
@vue/cli
版本至少应该是 4.1.2。 -
运行以下 Vue CLI 命令:
vue create my-app
运行前面的命令后,您应该会看到一个已保存预设的列表,如下面的截图所示:
图 3.3:显示已保存预设的列表
-
通过按一次向下箭头键然后按Enter键来选择最后一个选项
Manually select features
:? Please pick a preset: (Use arrow keys) default (babel, eslint) > Manually select features
-
您会注意到带有括号内星号的功能。每个功能代表一个您可以在您的应用程序中启用的预设。您不必知道这些代表什么。现在,我们将通过使用箭头键导航,在每个选项上按空格键,然后按Enter键来选择
Babel
、CSS Pre-processors
和Linter/Formatter
:? Check the features needed for your project: (*) Babel ( ) TypeScript ( ) Progressive Web App (PWA) Support ( ) Router ( ) Vuex (*) CSS Pre-processors >(*) Linter / Formatter ( ) Unit Testing ( ) E2E Testing
-
由于您选择启用预处理器,您现在可以选择您偏好的 CSS 预处理器。在本练习中,我们将使用
Sass/SCSS (with dart-scss)
:? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys) > Sass/SCSS (with dart-sass) Sass/SCSS (with node-sass) Less Stylus
注意
dart-scss
是Sass
库的纯 JavaScript 编译版本,与node-sass
(它是 SCSS 的 C++实现包装器)相比,它是一个更小的依赖项,并且不需要在 Node 升级版本之间重新构建。 -
我们现在将选择
Eslint + Prettier
选项,它将以一致的方式自动格式化代码:? Pick a linter / formatter config: (Use arrow keys) ESLint with error prevention only ESLint + Airbnb config ESLint + Standard config > ESLint + Prettier
-
要在保存工作自动格式化代码,请选择
Lint on Save
选项:? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection) >(*) Lint on save (*) Lint and fix on commit
注意
Lint on save
是一个有用的工具,可以在编写代码时对其进行格式化。在步骤 8中选择两个 linting 选项,以确保您的工作在编写过程中都经过 linting,从而使代码更易于阅读和一致。 -
接下来,我们将选择
In dedicated config files
选项,以根据我们的偏好放置配置:? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (Use arrow keys) > In dedicated config files In package.json
注意
package.json
的一个论点是保持所有配置以 JSON 格式和在一个文件中保持一致格式。对于较小的项目,这可能是可接受的,但是大型项目往往会生成一个非常长的package.json
文件。这就是文件拆分更可取的地方。将配置拆分到单独的文件中,在编辑这些选项时减少了认知负荷,并在提交日志中更强调正在更改的内容。例如,当您编辑babelrc
文件时,您知道更改与 Babel 配置有关,而不是package.json
更改,后者可能涉及任何内容。 -
选择
Save preset (y)
选项以保存您的预设。您可以用任何名称调用预设。在示例中,它将被命名为
My preset
。下次您想要安装此预设时,尝试运行
vue create favourite -p "My preset"
(使用vue create [project-name] -p [preset-name]
的一般命令行语法):? Save this as a preset for future projects? Yes ? Save preset as: My preset
-
运行包安装器。如果安装器没有自动启动,请运行
yarn install
命令:yarn install v1.16.0 info No lockfile found. [1/4] Resolving packages...
-
一旦包安装器完成,
serve
您的项目以编译您的代码并在http://localhost:8080
上提供服务:yarn serve
如果端口
8080
已经被另一个应用程序占用,请使用--port
标志指定另一个端口,例如9000
:yarn serve --port 9000
运行前面的命令,我们将看到一个默认的 Vue 项目屏幕,如图 3.4 所示:
![图 3.4:默认 Vue 项目屏幕将出现在您的 localhost:8080 上
图 3.4:默认的 Vue 项目屏幕将出现在您的 localhost:8080 上
在这个练习中,我们看到了如何使用命令提示符中的 Vue CLI 命令创建一个 Vue.js webpack 项目。接下来,我们将探讨如何在不创建 webpack 项目的情况下原型化一个 Vue.js 组件。
Vue 原型化
假设,有一天你醒来,有一个关于组件的绝佳想法,或者你参与了一个大型项目,并且你想要在不复杂的现有项目依赖关系下调试组件。Vue 原型化可以帮助你创建新的组件或调试现有的组件,即使是大型项目。这是通过直接在单独且隔离的编译器中运行 .vue
文件来完成的,无需任何本地依赖。以这种方式运行 .vue
文件可能会节省时间,因为你不需要安装如 练习 3.01 中描述的完整 Vue 项目。相反,您只需要安装 npm install -g @vue/cli-service-global
或 yarn global add @vue/cli-service-global
。
一旦安装完成,您将能够访问以下两个命令:
-
vue serve
– 此命令编译 Vue.js 代码并在浏览器中的本地主机环境中运行。 -
vue build
– 此命令将 Vue.js 代码编译成可分发包。
原型化入门
要开始,您首先需要通过打开命令终端并运行以下 install
命令来安装全局包:
npm install -g @vue/cli-service-global
# or
yarn global add @vue/cli-service-global
这将生成以下截图:
![图 3.5:安装 Vue 原型化所需的全球依赖
图 3.5:安装 Vue 原型化所需的全局依赖
安装可能需要几分钟,具体取决于您的互联网连接速度。您将知道安装何时完成,因为您将能够在终端中编写其他命令。如果安装失败,请简单地重新打开终端并运行相同的命令。
要开始使用原型化,创建一个名为 helloWorld.vue
的示例组件:
// helloWorld.vue
<template>
<h1>Hello World!</h1>
</template>
在您的终端窗口中(与您的新的 .vue
文件相同的目录),使用以下命令:
vue serve helloWorld.vue
前一个命令将显示如下:
![图 3.6:vue serve 命令正在 D:\ 目录下的文件上运行
图 3.6:vue serve 命令正在 D:\ 目录下的文件上运行
运行 serve
命令后,组件将在终端窗口中编译一段时间,然后才能在浏览器中访问,如下所示:
![图 3.7:vue serve 命令将在本地主机环境中提供您的 Vue 文件]
![图 3.7:vue serve 命令将在本地主机环境中提供您的 Vue 文件]
编译完成后,使用浏览器导航到命令窗口中指定的本地主机 URL。这里,它是http://localhost:8080/
。在您的浏览器中,您应该看到文本Hello World!
:
Hello World!
我们现在已经学会了如何在不创建完整的 webpack 项目的情况下即时原型化 Vue 组件。让我们看看我们如何导入字体或库以在原型中使用。
定义您的入口点
在您的原型中,您可能需要使用外部库,如字体或脚本,以完成您的原型组件。index.html
文件是 Vue.js HTML 模板的入口点。如果您没有定义index.html
文件,将使用全局默认文件。
要定义自定义入口点,在相同目录下创建一个index.html
文件。使用以下从默认索引页面派生的代码,您将看到已向<head>
标签添加了 Google 字体:
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
<link ref="https://fonts.googleapis.com/css2?family=Roboto& display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
</body>
</html>
要在您的原型组件中使用此字体,在index.html
文件所在的同一目录下创建一个helloWorld.vue
组件,然后对该组件应用一些css
样式:
// helloWorld.vue
<template>
<h1>Hello World!</h1>
</template>
<style>
h1 {
font-family: 'Roboto';
}
</style>
要查看对这些文件所做的更改,请在您的终端中运行以下命令:
vue serve helloWorld.vue
这将生成以下截图:
![图 3.8:在您想要原型化的文件上运行 vue serve 命令]
![图 3.8:在您想要原型化的文件上运行 vue serve 命令]
运行serve
命令后,组件将在浏览器可访问之前在终端窗口中进行编译,具体如下:
![图 3.9:vue serve 命令将在本地主机环境中提供您的 Vue 文件]
![图 3.9:vue serve 命令将在本地主机环境中提供您的 Vue 文件]
在您的浏览器中打开本地主机 URL。您应该会看到使用新字体格式化的文本。输出将如下所示:
Hello World!
我们现在已经学会了如何在 Vue 组件原型中包含外部库,而无需创建完整的 webpack 项目。接下来,我们将看到如何构建一个可以托管在网站上或由您的团队外部预览的原型。
构建用于生产的原型
当您完成原型制作并希望与团队中的其他人分享或提交给技术负责人进行审查时,您可以导出您的代码作为可分发文件。
这意味着您的代码可以在其他机器上运行或在外部服务器上托管,而无需 Vue CLI 运行它(即vue serve
命令)。
使用之前的示例文件进行此操作,打开您的命令终端,导航到包含您的 Vue 原型的文件夹,并运行vue build helloWorld.vue
命令。这将生成以下输出:
![图 3.10:运行 build 命令后的终端输出]
图 3.10:运行构建命令后的终端输出
将创建一个包含你的原型编译版本的dist
文件夹,你可以将其上传到网络主机。在dist
文件夹内,你可以期待看到以下文件:
-
一个
index.html
文件 -
/css
文件夹 -
/js
文件夹
所有这些文件都是正确运行你的编译原型所必需的。如果你双击index.html
文件,它不会加载你的应用。要在本地机器上查看或服务可分发文件,你需要一个可以服务静态网站或单页应用的库。一个名为serve
的npm
包就是为了这个目的而构建的。
要服务你的dist
文件夹,通过打开命令行终端并使用以下命令全局安装serve
包:
npm install -g serve
# or
yarn global add serve
serve
命令通过允许你指定要服务的目录或文件来工作:
serve [path/to/serve]
在你的命令行终端中,确保你位于你的 Vue 原型dist
文件夹所在的根目录。要服务此文件夹目录中的dist
文件夹,请运行以下命令:
serve dist
前面的命令将显示如下输出:
![图 3.11:运行 serve 命令后的终端输出]
图 3.11:运行 serve 命令后的终端输出
导航到http://localhost:5000
,你将看到以下输出中显示的原型项目:
Hello World!
我们现在已经学会了如何构建一个可以托管在网站上的 Vue 组件原型,以及如何在本地预览构建的文件。现在,我们将看到这些 Vue 原型概念在下一个练习中的应用。
练习 3.02:使用 Vue CLI 进行即时原型设计
在这个练习中,你将创建一个使用 Vue 的即时双向数据绑定的 Vue 组件。使用即时原型设计可以使你快速利用 Vue 语法,并附加热重载等好处。
要访问此练习的代码文件,请参阅packt.live/35kZrd3
。
-
在
Exercise 3.02
文件夹中,创建一个名为prototype.vue
的文件。 -
在此文件夹内打开一个命令行终端,并使用
vue serve prototype.vue
命令。 -
使用
vue
,然后按Tab键以即时创建 Vue 组件结构:<template> </template> <script> export default { } </script> <style> </style>
-
创建一个名为
heading
的数据属性,其字符串值为Prototype Vue Component
,然后在模板中将它包裹在h1
标签中。在浏览器中的localhost:8080
查看结果:<template> <h1>{{ heading }}</h1> </template> <script> export default { data() { return { heading: "Prototype Vue Component" } } } </script> <style> h1 { font-family: Arial, Helvetica, sans-serif; } </style>
前面的代码将显示如下输出:
Prototype Vue Component
要为生产构建此组件,请运行
vue build prototype.vue
命令。运行此命令后,你将在原型组件所在的同一目录中生成一个dist
文件夹,如图图 3.12所示:![图 3.12:此练习的最终输出包含一个/dist 文件夹]
图 3.12:本练习的最终输出包含一个/dist 文件夹
-
在构建你的可分发文件后,在你的命令行终端中运行
serve dist
。然后,在你的浏览器中导航到终端中指定的 localhost URL。你将能够以以下方式查看你构建的原型:Prototype Vue Component
在这个练习中,你看到了如何通过命令行运行原型化的 Vue 组件,而无需安装全新的项目。你还看到了如何将新原型构建成可分发文件,然后提供服务。接下来,我们将探讨如何使用 Vue-UI 启动和运行 Vue 应用程序。
Vue-UI
Vue-UI 是一个图形界面,允许你控制 Vue 属性,而无需过多了解命令行的工作方式或如何配置单个文件,如package.json
或 webpack 文件。Vue-UI 提供了对诸如vue ui
命令等信息轻松访问。在撰写本文时,Vue-UI 仍在测试版。如果你在使用此工具时遇到任何问题,请停止命令,然后再次运行vue ui
。
Vue-UI 可用于新项目和现有项目。通常,你会在项目开始时选择使用 Vue CLI 的预设,例如使用哪个SCSS
编译器、测试框架或lint
方法。使用 Vue-UI,即使是新的 Vue 开发者也可以轻松地在任何时间配置 Vue 预设,包括诸如输出目录或开启sourcemaps
这样的晦涩的 webpack 设置。以下截图显示了常规设置
页面:
图 3.13:在 Vue-UI 中轻松配置的项目设置
npm
包系统非常庞大。然而,对于有经验的用户来说,导航起来相当容易。通常,有经验的开发者会通过命令行安装一个包,然后它会自动更新package.json
文件并锁定新包。锁定的文件是记录你与项目一起提交的npm
包所需相互依赖关系的生成记录器。Vue 有一些特定的包称为插件,这些是 Vue 的特别npm
包,不仅会安装依赖项,通常还会以有用的方式增强你的项目。例如,如果你安装了vue router cli
插件,它将自动在你的项目中生成一个route.js
文件,让你更快地上手。
Vuetify
是一个在尝试快速搭建更复杂组件或项目时非常有用的框架,它包含了许多常见的 UI 元素和组件,如按钮和输入字段,这样你就可以专注于界面而不是构建单个组件本身。如果你使用 Vue CLI 插件包安装Vuetify
,它将自动为你设置 Vuetify。
我们现在已经了解了 Vue-UI,以及新和有经验的开发者如何使用这个工具来管理和依赖项。接下来,我们将使用 Vue-UI 来创建和运行 Vue.js 项目。
练习 3.03:从 Vue-UI 创建和构建新项目
在这个练习中,你将逐步学习如何使用 Vue-UI 设置和安装 Vue.js 项目。你还将被要求安装并使用Vuetify
库作为依赖项。安装后,你将使用 Vue-UI 运行此项目,并看到 Vuetify 元素在页面上运行。
要访问此练习的代码文件,请参阅packt.live/35jOsAH
。
-
打开命令行终端并运行
vue ui
命令。你会看到以下屏幕:![图 3.14:Vue-UI 中没有项目图 3.14:Vue-UI 中没有项目
-
点击“创建”以启动新项目。导航到你希望安装项目的文件夹:![图 3.15:安装项目
图 3.15:安装项目
-
在“项目文件夹”字段中,输入
demo-ui
,选择yarn
作为你的包管理器,然后点击“下一步”,如图所示:![图 3.16:Vue-UI 的项目创建界面图 3.16:Vue-UI 的项目创建界面
-
选择“手动”,你将被带到“功能”屏幕。在此屏幕上,选择
Babel
、“CSS 预处理器”、Linter/Formatter
和“使用配置文件”。图 3.22显示了选择这些选项的截图:![图 3.17:在 Vue-UI 中为你的新项目启用功能图 3.17:在 Vue-UI 中为你的新项目启用功能
-
选择“Sass/SCSS(使用 dart-sass)”预处理器和
ESLint + Prettier
配置,并启用以下截图所示的附加 lint 功能:![图 3.18:在 Vue-UI 中为新项目启用配置选项图 3.18:在 Vue-UI 中为新项目启用配置选项
-
当提示时,选择“继续不保存预设”,并等待项目安装。你应该会看到一个类似图 3.19的屏幕:![图 3.19:Vue 创建和安装项目依赖时请耐心等待
图 3.19:Vue 创建和安装项目依赖时请耐心等待
-
导航到插件页面,点击“安装依赖”,搜索
vuetify
,并安装vue-cli-plugin-vuetify
。你可以在“依赖”页面上观察到vuetify
已自动添加到项目依赖列表中,如下所示:![图 3.20:依赖搜索和安装的干净界面图 3.20:依赖搜索和安装的干净界面
-
导航到“项目任务”页面,点击
serve
任务。然后,点击以下截图所示的“运行任务”图标:![图 3.21:serve 任务仪表板包含运行任务按钮图 3.21:serve 任务仪表板包含运行任务按钮
-
等待 Vue 编译应用。当应用准备就绪时,点击如图 3.22 所示的“打开应用”按钮:
图 3.22:打开应用按钮将直接带你到浏览器中的应用
你应该在浏览器中看到你的应用,如下截图所示:
图 3.23:在 http://localhost:8080 上,你应该看到一个 Vuetify 风格的页面
-
为了将此项目准备用于生产,请回到 Vue-UI 浏览器标签页,并在“项目任务”中点击“构建”标签。点击“开始任务”按钮旁边的“参数”按钮。开启“现代模式”并确保“输出目录”设置为
dist
。“现代模式”将你的代码转换为两个版本,一个轻量级且针对现代浏览器,另一个详细且用于支持旧浏览器。这将是你编译后找到文件的地方。你的屏幕应该显示如下截图:图 3.24:Vue-UI 构建参数
-
要为生产构建此项目,请点击“开始任务”按钮并让它运行。
注意
你不需要停止
serve
任务来完成此操作。任务完成后,你的屏幕将显示如下:
图 3.25:构建完成后,你将看到一个包含有用分析仪表板的控制台
在这个练习中,你看到了如何创建一个全新的项目,配置预设,使用 serve
任务运行一个应用,以及如何通过 Vue-UI 构建用于生产的项目。你应该足够熟悉,可以添加新的 Vue CLI 插件并管理 npm 包依赖。
Vue.js DevTools
Vue.js DevTools 是一个适用于 Chrome 和 Firefox 的浏览器扩展,以及一个可以从你的电脑上运行的 Electron 桌面应用,可以帮助你调试本地运行的 Vue.js 项目。这些工具在生产和远程运行的项目中不起作用(例如,如果你提供了一个生产构建的项目或在线查看网站)。你可以从 Chrome 扩展页面下载 Vue.js DevTools 扩展,如下截图所示:
图 3.26:Vue.js DevTools Chrome 扩展页面
你也可以从 Firefox 下载 Vue.js DevTools 扩展(addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/
):
图 3.27:Vue.js DevTools Firefox 扩展页面
DevTools 是 Vue 开发者的最佳伴侣,因为它们将在浏览器的开发者控制台中揭示您通常不会看到的有用信息。这包括 Vue 组件加载性能和跟踪 Vue 应用程序运行期间触发的事件。有几个选项卡,我们现在将查看。
组件
选项卡帮助您导航虚拟 < > 检查 DOM
,这将直接带您到 Chrome 或 Firefox DOM 树中该组件的位置。使用以下 图 3.28 中的“选择”目标图标(左面板右上角)直接从浏览器 UI 中选择 Vue 元素。
您的屏幕应该看起来如下:
图 3.28:Vue.js DevTools 中的组件选项卡
Vuex
- 使用此选项卡,您可以导航 Vuex 的全局状态。您将看到 Vuex 存储中发生的突变记录,如下所示:
图 3.29:Vue.js DevTools 中的 Vuex 选项卡
关于这一点,将在未来的章节中详细介绍。
事件
– 使用此选项卡,您可以导航从您的组件中发出的自定义事件。关于这一点,将在未来的章节中详细介绍。默认情况下,事件将记录如下截图所示:
图 3.30:Vue.js DevTools 中的事件选项卡
路由
– 使用此选项卡,您可以在该面板中观察路由历史和事件。关于这一点,将在未来的章节中详细介绍。当路由事件发生时,它们将记录如下截图所示:
图 3.31:Vue.js DevTools 中的路由选项卡
性能
– 使用此选项卡,您可以在应用程序运行时导航到记录组件帧率和渲染时间的性能区域,以优化最终用户体验。当您点击“开始”按钮以收集性能指标时,它们将以蓝色条的形式显示,如下截图所示:
图 3.32:Vue.js DevTools 中的性能选项卡
图 3.32 中的蓝色柱状图表示加载时间(毫秒)。
设置
– 使用此选项卡,您可以自定义 Vue.js DevTools 的体验,如下截图所示。对于新开发者,默认设置无需更改:
图 3.33:Vue.js DevTools 中的设置选项卡
刷新
– 点击此按钮将刷新浏览器中的 Vue.js DevTools 实例。
我们现在已经了解了 Vue.js DevTools,这将帮助你在开发下一个 Vue 应用程序的组件时。接下来,我们将构建一个 Vue 组件,并使用 Vue.js DevTools 来检查代码并操作组件内部的数据本地状态。
练习 3.04:使用 DevTools 调试 Vue 应用程序
在这个练习中,你将构建一个使用你在前几章中探索的几个 Vue.js 模式的组件,并且你将使用 DevTools 来探索这些模式。确保你正在使用 Chrome 或 Firefox,并且已安装 DevTools。你将使用 Vue.js DevTools 来检查代码并操作组件内部的数据本地状态。
要访问此练习的代码文件,请参阅packt.live/3eLIcVe
。
-
导航到
Exercise3.04
项目文件夹,并在 VS Code 中打开它。在你的命令提示符中,通过运行yarn
命令安装所需的脚本。 -
在
yarn
使用的相同命令提示符中,运行项目使用yarn serve
。 -
在你的浏览器中导航到
localhost:8080
,以便你可以查看以下步骤中做出的更改。 -
在
App.vue
中创建响应式数据,通过添加一个数据属性frameworkList
,填充一个字符串数组,以及一个值为空字符串的input
属性:<script> export default { data() { return { frameworkList: [ 'Vue', 'React', 'Backbone', 'Ember', 'Knockout', 'jQuery', 'Angular', ], input: '', } }, } </script>
-
接下来,创建一个名为
computedList
的计算属性,用于使用input
属性值筛选frameworkList
属性:... computed: { computedList() { return this.frameworkList.filter(item => { return item.toLowerCase().includes(this.input. toLowerCase()) }) }, }, ...
-
在 Vue 的
template
块中,添加一个使用 v-model 绑定到input
数据属性并循环computedList
的input
字段。添加一些样式(可选):<template> <div id="app" class="container"> <h1>Vue devtools debugging</h1> <input type="text" placeholder="Filter list" v-model= "input" /> <ul> <li v-for="(item, i) in computedList" :key="i">{{ item }} </li> </ul> </div> </template> <style lang="scss" scoped> #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; } ul { max-width: 200px; margin: 0 auto; list-style: none; padding: 0; > li { background: #42b983; color: white; padding: 6px; border-radius: 6px; margin-bottom: 2px; max-width: 200px; } } input { padding: 10px 6px; margin: 20px 10px 10px 10px; } </style>
上述代码将生成以下截图:
图 3.34:检查点 – 你的列表是可筛选的
-
在你的浏览器中,你可以查看你的应用,右键点击并选择
Inspect
以打开开发者控制台或使用快捷键 Ctrl + Shift + J(Mac 用户:Cmd + Shift + J)并导航到Vue
标签。这将生成以下截图:图 3.35:Vue.js DevTools 的 Chrome 扩展
-
默认情况下,你将在
Components
标签中。选择Anonymous Component
以检查与该组件关联的数据。点击到Filter list
输入字段并输入V
。你会观察到两个事情发生:在右侧面板中,数据属性input
现在具有值V
和计算列表。computedList
现在只包含字符串Vue
。在浏览器中,这些数据将在 UI 中反映出来,如图 3.36所示:图 3.36:Vue.js DevTools 的 Chrome 扩展
-
通过点击
输入
属性旁边的铅笔
图标直接在右侧面板中编辑数据,并输入R
。DOM 将响应式地更新,如以下截图所示,这是从 DevTools 直接更改输入属性所做的直接更改:图 3.37:在您的 Vue 项目中编辑实时值非常简单
在更改 Vue.js DevTools 中的值后,UI 中的值将响应式地改变,在这个例子中,输入值现在是
R
,然后触发响应式的computedList
数组只显示包含字母r
的值,如 图 3.38 所示:图 3.38:计算列表更新到 DevTools 中写入的值
-
前往
性能
选项卡,点击组件渲染
开关,然后点击开始
按钮。当它运行时,在输入框中输入几个项目,例如A
,然后B
,然后V
。当您在输入框中输入文本时,您将看到性能指标作为蓝色条形,如下面的截图所示:图 3.39:计算列表更新到 DevTools 中写入的值
-
点击
停止
并观察组件渲染
选项卡中的毫秒
计时,这反映了您的组件加载所需的时间,如下面的截图所示:
图 3.40:在右侧面板中选择组件将打开左侧的生命周期钩子
注意
重复测试将允许您比较基准,然而,如果您刷新页面,您将丢失它们。
在这个练习结束时,您已经看到了如何使用 Vue.js DevTools 通过 组件
选项卡导航 Vue 应用程序中的基本组件。您知道如何在 DevTools 中观察和编辑数据,因为您已经看到计算属性会响应您的数据属性更改。您知道 性能
选项卡在哪里,以及如何在创建 Vue 应用程序时使用它。
活动 3.01:使用 Vue-UI 和 Vuetify 组件库构建 Vue 应用程序
在这个活动中,您将使用命令行构建一个 Vue 项目,然后将其导入到 Vue-UI 中,并比较安装 Vuetify 之前后的构建大小。这将测试您控制可用的各种 Vue 工具的能力,并突出您在实际场景中使用这些工具的情况。
以下步骤将帮助您完成活动:
-
使用 Vue CLI 创建一个新的项目,并使用 Babel 预设。
-
使用 Vue-UI 导入您新创建的项目。
-
使用 VueUI 安装
Vuetify
插件,并在项目中使用 Vuetify 的 Vue 组件。 -
从 Vuetify 网站复制一个预制的布局,或者使用他们的组件构建自己的布局:
vuetifyjs.com/en/getting-started/pre-made-layouts
。
预期结果如下:
![图 3.41:最终结果
图 3.41:最终结果
这个活动还有一个可切换的菜单,如图 3.42 所示:
![图 3.42:显示可切换菜单的输出
图 3.42:显示可切换菜单的输出
注意
这个活动的解决方案可以通过这个链接找到。
活动完成后,你应该能够使用 Vue CLI 和 Vue-UI 来管理你未来的 Vue 项目。你会发现,在某些情况下,这两个工具可以互换使用,或者根据你更舒适的方式结合使用。
摘要
在本章中,你被介绍了多种 Vue.js 工具,这些工具可以帮助你维护和管理你的 Vue 应用程序。你从命令行和新的 Vue-UI 创建了 Vue.js 项目,安装了新的依赖项,并从这两个界面中提供了服务和构建了你的项目。你可以一起使用这些工具或单独使用—— whichever you feel more comfortable with。Vue.js DevTools 拥有许多提高生活质量的特性,这些特性将帮助你通过本书的高级部分,并在你开始在组件和路由页面之间传递 props 时提供帮助。
在下一章中,你将学习更多高级的 Vue 组件概念,例如通过使用数据 props 和模板插槽在不同组件之间传递和验证信息。
第四章:4. 组件嵌套(模块化)
概述
在本章中,你将发现如何使用组件层次结构和嵌套来模块化 Vue.js 应用程序。本章介绍了 props、events、prop 验证和 slots 等概念。你将学习如何对比它们并根据情况确定应该应用哪个概念。然后,你将练习实现一个使用 refs 封装直接 DOM 操作的组件。你还将学习如何识别可以使用 slots、命名 slots 和作用域 slots 的组件组合场景。然后,你将确定何时将功能抽象为过滤器。
到本章结束时,你将能够使用 props、events 和验证器定义组件之间的通信接口。你将接触到如何将 JavaScript 库作为 Vue.js 组件封装以及在使用组件时 Vue.js 上下文的潜在问题。
简介
在上一章中,我们学习了如何初始化、构建和调试一个简单的 Vue.js 应用程序。在本章中,我们将更深入地了解如何利用组件组合来实现代码重用。
可重用和可扩展的组件是围绕组件库构建产品的核心。组件库允许团队以高速度和高一致性构建项目。
如果 Vue.js 中的组件库没有暴露正确的扩展点,通常会发生的情况是将库中的组件复制到应用程序的代码库中。这导致了代码的重复和从设计角度的凝聚力降低。
在 第三章,Vue CLI 中,我们学习了如何在 Vue 中创建简单组件。组件是 Vue 实例,可以被实例化和渲染多次。由于只能有一个根组件,应用程序中的大多数组件都是由另一个组件渲染的。为了使父组件与其子组件通信,我们使用 props 和 prop 传递。
传递 Props
this
) 并在组件的 template
中。
prop 的值取决于父组件在渲染时传递给子组件的 template
中的内容。
定义一个接受 Props 的简单组件
让我们看看一个简单的 Hello
单文件组件。这可以在 ./src/components/Hello.vue
文件中找到(在一个 Vue CLI 生成的 项目中)。注意 who
值是如何在 props
数组中设置的,并且它是通过使用 {{ who }}
作为值进行插值的。Vue.js 组件的 props
属性可以是字符串数组或对象字面量。
当在 props
中定义一个值时,它随后在 Vue.js 组件的 template
部分作为一个实例变量可访问:
<template>
<div>
<h1>Hello {{ who }}</h1>
</div>
</template>
<script>
export default {
props: ['who']
}
</script>
现在,我们将学习如何使用 props 渲染组件。
使用 Props 渲染组件
接下来是一个如何在我们 Vue.js 应用程序中使用 Hello
组件的演示。
首先,我们需要导入,然后将其设置在想要渲染此导入组件的 Vue.js 组件的 components
属性中。
然后,在template
部分,我们需要将<Hello>
渲染出来,并将who
属性设置为"Vue.js"
,如下所示:
<template>
<div id="app">
<Hello who="Vue.js"/>
</div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
components: {
Hello
}
}
</script>
这将在页面上渲染以下内容:
Hello Vue.js
我们现在已经看到了如何在 Vue.js 应用程序中使用组件并通过 props 传递它。这对于代码重用和将应用程序行为抽象为组件大小的块非常有用。
接下来,我们将学习如何与已注册的组件一起工作。
组件注册技巧
关于components
属性,有几个需要注意的事项。
已注册的组件既可以用CamelCaseName
格式,也可以用kebab-case-name
格式,因此如果我们把前一个示例中的模板部分改为使用<hello />
而不是<Hello />
,它将无任何问题地工作:
<template>
<div id="app">
<hello who="Vue.js"/>
</div>
</template>
更新的模板在浏览器中渲染相同的内容,如下所示输出:
Hello Vue.js
components
属性倾向于使用 ES6 简写属性语法。简写属性语法意味着我们不需要写{ Hello: Hello }
,而是可以写{ Hello }
。我们可以在以下示例中看到它的实际应用,该示例注册了Hello
组件:
import Hello from './components/Hello.vue'
export default {
components: {
Hello
}
}
Vue 的components
声明不知道组件的名称。它使用components
对象中的键来注册,无论是驼峰式还是短横线命名法:
<template>
<div id="app">
<Hey who="Vue.js"/>
</div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
components: {
Hey: Hello
}
}
</script>
上述代码将生成以下输出:
Hello Vue.js
我们现在已经学会了如何在 Vue.js 中使用components
属性和 ES6 简写对象属性语法来注册组件。
接下来,我们将查看一个Greeting
组件的实际示例。
练习 4.01:实现问候组件
利用我们对如何从父组件向子组件传递 props 的知识,我们将创建一个组件,允许你自定义问候语(例如,Hello
、Hey
或Hola
)以及被称呼的对象(例如,World
、Vue.js
或JavaScript 开发者
)。
要访问此练习的代码文件,请参阅packt.live/35jGd7B
。
按照以下步骤完成这个练习:
-
在
./src/components
目录下创建一个名为Greeting.vue
的新文件。这将是我们单文件组件。 -
首先,用空的
template
和script
标签搭建组件的框架:<template> <div>Empty</div> </template> <script> export default {} </script>
-
接下来,我们需要告诉 Vue.js 我们的组件期望 props。为此,我们将在组件定义(在
script
部分的export default
设置的 object)中添加一个props
属性,并向其中添加一个greeting
和who
属性:export default { props: ['greeting', 'who'] }
-
现在,我们想要渲染
greeting
和who
。正如我们所见,当在props
中定义值时,它们在template
的最高级别中可用:<template> <div>{{ greeting }} {{ who }}</div> </template>
我们现在可以渲染
App.vue
中的Greeting
组件。 -
打开
src/App.vue
文件,并将Greeting
组件从./src/components/Greeting.vue
导入到script
部分:<script> import Greeting from './components/Greeting.vue' </script>
-
接下来,在
components
中注册Greeting
组件:<script> export default { components: { Greeting } } </script>
-
现在组件已经注册,我们可以在
template
中渲染它:<template> <div id="app"> <Greeting greeting="Hey" who="JavaScript"/> </div> </template>
你将在浏览器中看到以下内容(确保你在
Exercise4.01
目录中运行了npm install
和npm run serve
):Hey JavaScript
-
使用
template
中的属性值修改greeting
和who
属性:<template> <div id="app"> <Greeting greeting="Hi" who="Everyone"/> </div> </template>
运行前面的代码后,你应该在浏览器中看到以下类似的输出(确保你在
Exercise4.01
目录中运行了npm install
,然后运行npm run serve
):Hi Everyone
在这个练习中,我们学习了如何使用属性和属性传递来通过泛化组件来增加组件的重用场景。组件不是渲染静态数据,而是其父组件传递数据以进行渲染。
在下一节中,我们将学习如何动态设置属性值。
动态属性与数据绑定
我们到目前为止看到的示例都使用了硬编码的属性值作为属性。但如果我们想从父组件传递实例数据到子组件怎么办?
这就是 v-bind:
的用法,但你也可以使用 :
作为缩写;它们是等价的。
Greeting
组件的 who
属性绑定到 appWho
应用组件的实例属性:
<template>
<div id="app">
<Hello v-bind:who="appWho"/>
</div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
components: {
Hello
},
data() {
return {
appWho: 'Vue.js'
}
}
}
</script>
简写形式下,template
将如下所示:
<template>
<div id="app">
<Hello :who="appWho"/>
</div>
</template>
两种版本都将输出以下视图到浏览器:
Hello Vue.js
注意
v-bind:prop-name
和 :prop-name
有惊人的相似之处,因为 v-bind
和 prop-name
之间的分隔符是 :
(一个分号)。在 Vue.js 单文件组件中,由于模板在构建时编译,它们在功能上是等价的。
以下是一个示例,展示如何将值从父组件(App
)传递到子组件(Hello
),其中包含两个按钮,用于更改 Hello
消息的受众。
按钮调用一个名为 setWho
的组件方法,setWho
函数更新 appWho
实例属性:
<template>
<div id="app">
<Hello :who="appWho"/>
<button @click="setWho('JavaScript')">JavaScript</button>
<button @click="setWho('Everyone')">Everyone</button>
</div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
components: {
Hello
},
data() {
return {
appWho: 'Vue.js'
}
},
methods: {
setWho(newWho) {
this.appWho = newWho
}
}
}
</script>
初始输出到浏览器的显示为 Hello Vue.js
,如下面的截图所示:
图 4.1:浏览器中初始的 Hello Vue.js 输出
当点击 JavaScript
按钮,appWho
变量更新,绑定的 Hello
组件的 who
属性也会更新。因此,显示为 Hello JavaScript
,如下所示:
图 4.2:点击 JavaScript 按钮后的 Hello JavaScript
当点击 Everyone
按钮,appWho
变量更新,绑定的 Hello
组件的 who
属性也会更新。因此,显示为 Hello Everyone
,如下所示:
图 4.3:点击 Everyone 按钮后的 Hello Everyone
我们现在已经看到了如何将属性绑定到值,以便它们保持同步。
大多数 Vue.js 应用程序利用组件不仅用于模块化渲染组件(正如我们在 Greeting
和 Hello
组件中所做的那样),还用于其他方面。
正如我们所看到的,我们能够绑定属性,以便对父组件中任何值的更新都会导致子组件的更新。
使用Greeting
组件渲染当前的greeting
和who
:
到目前为止,应用程序在浏览器中应显示相同的问候语,如下面的输出所示:
要访问此练习的代码文件,请参阅packt.live/3kovKfo
。
让我们重构data
方法,使其只存储默认索引并创建查找索引以生成基于当前索引的greeting
和who
的计算属性(使用中间的currentGreeting
计算属性):
-
浏览器将显示一条消息,如下所示(确保你在
Exercise4.02
目录中运行了npm install
和npm run serve
):<template> <div>{{ greeting }} {{ who }}</div> </template> <script> export default { props: ['greeting', 'who'] } </script>
-
在
./src/App.vue
组件中,将./src/components/Greeting.vue
导入为Greeting
组件,并注册为组件,以便你可以渲染它:<script> import Greeting from './components/Greeting.vue' export default { components: { Greeting } } </script>
-
在
script
部分,创建一个返回初始greeting
和who
的顶级data
方法:export default { data() { return { greeting: 'Hello', who: 'Vue.js' } } }
-
图 4.5:在 3n + 1 次按钮点击后“嘿,大家好”
<template> <div id="app"> <Greeting :greeting="greeting" :who="who"/> </div> </template>
要知道使用哪个问候语,我们将实现一个具有多个问候语并遍历它们的greeter应用程序。
在初始加载和点击
New Greeting
按钮的3n
次之后,应用程序显示Hello Vue.js
,如下面的截图所示:![图 4.4:在 3n 次按钮点击后“你好,Vue.js”]
-
注意
<script> // imports const possibleGreetings = [ { greeting: 'Hello', who: 'Vue.js' }, { greeting: 'Hey', who: 'Everyone' }, { greeting: 'Hi', who: 'JavaScript' } ] // components export </script>
-
由于计算属性可以清理代码,我们不需要更新我们的模板。相反,我们用同名的计算属性替换了
greeting
和who
实例属性。<script> // imports and greetings export default { // components definition data() { return { currentIndex: 0 } }, computed: { currentGreeting() { return possibleGreetings[this.currentIndex] }, greeting() { return this.currentGreeting.greeting }, who() { return this.currentGreeting.who } } } </script>
图 4.4:在 3n 次按钮点击后“你好,Vue.js”
Hello Vue.js
![图 4.6:在 3n + 2 次按钮点击后“嗨,JavaScript”]
我们现在需要在
script
部分实现newGreeting
。newGreeting
应该移动到下一个问候语(通过增加currentIndex
)。或者,如果我们已经到达了possibleGreetings
数组的末尾,它应该重置currentIndex
: -
让我们添加一种遍历这些
问候语
的方法。这需要有一个按钮,点击后会在我们的template
中调用一个newGreeting
函数:<template> <div id="app"> <Greeting :greeting="greeting" :who="who"/> <button @click="newGreeting()">New Greeting</button> </div> </template>
-
注意
<script> // imports and greetings export default { // other component properties methods: { newGreeting() { this.currentIndex = this.currentIndex === possibleGreetings.length – 1 ? 0 : this.currentIndex + 1 } } } </script>
创建一个
./src/components/Greeting.vue
组件,并用我们之前实现的Greeting
组件初始化它。这就是我们将显示问候语的方式:注意
以下代码需要你了解我们已在第二章,与数据一起工作中介绍的计算属性。如果你需要复习,现在请回到那一章。
练习 4.02:传递随时间变化的属性
在第一次点击和点击New Greeting
按钮的3n + 1
次之后,应用程序显示Hey Everyone
,如下所示:
![图 4.5:在 3n + 1 次按钮点击后“嘿,大家好”]
![图 4.4:在 3n 次按钮点击后“你好,Vue.js”]
按照以下步骤完成此练习:
在第二次点击和点击New Greeting
按钮的3n + 2
次之后,应用程序显示Hi JavaScript
,如下所示:
我们现在将一些greeting/who
配对作为数组添加到script
部分:
![图 4.6:在 3n + 2 次按钮点击后“嗨,JavaScript”]
图 4.6:在 3n + 2 次按钮点击后说“Hi JavaScript”
注意
这段代码可以进一步改进;例如,possibleGreetings.length - 1
是常量,因为我们从未添加或删除问候语。我们可以在newGreeting
方法之外,仅计算一次,而不是在每次newGreeting
调用时计算。读取数组的长度和简单的算术(-1
)并不太昂贵,但这是对可变值与常量值思考方式的好复习。
通过这样,我们已经看到了如何使用属性和属性绑定来从父组件向它们渲染的子组件传递变化的数据。为了扩展代码库或广泛共享代码,当用户使用代码不正确时,给出提示是有帮助的。
接下来,我们将学习如何为组件的属性添加类型提示以确保它们被正确使用。
属性类型和验证
属性定义了 Vue.js 组件的接口。由于 JavaScript 是一种动态类型语言,Vue.js 提供了一个我们可以用来验证属性形状和类型的工具。
要验证属性类型,应使用其对象字面量形式的props
组件属性(而不是更简单的数组形式)。
原始属性验证
假设我们想要一个Repeat.vue
组件,它接受一个times
属性,以及一个content
属性。我们可以定义以下内容:
<template>
<div>
<span v-for="r in repetitions" :key="r">
{{ content }}
</span>
</div>
</template>
<script>
export default {
props: ['times', 'content'],
computed: {
repetitions() {
return Array.from({ length: this.times });
}
}
}
</script>
我们的Repeat
组件可以这样使用:
<template>
<div id="app">
<Repeat :times="count" content="Repeat." />
<button @click="increment()">Repeat</button>
</div>
</template>
<script>
import Repeat from './components/Repeat.vue'
export default {
components: {
Repeat
},
data() {
return { count: 1 }
},
methods: {
increment() {
this.count += 1
}
}
}
</script>
上述代码将在浏览器中产生以下输出:
![图 4.7:重复示例动作输出(无点击)]
图 4.7:重复示例动作输出(无点击)
点击Repeat
按钮几次后,Repeat
组件将每次点击额外重复一次,生成如下输出:
![图 4.8:重复示例在点击五次后的输出]
图 4.8:重复示例在点击五次后的输出
为了使该组件正常工作,我们需要times
是一个Number
类型,理想情况下content
是一个String
类型。
注意
现在是提醒学生 JavaScript 原始类型的好时机:String
、Number
、Boolean
、Array
、Object
、Date
、Function
和Symbol
。
Vue.js 支持在props
字段中使用所有 JavaScript 原始类型构造函数作为类型提示。
在这种情况下,我们将times
属性定义为Number
类型,将content
属性定义为String
类型:
<script>
export default {
props: {
times: {
type: Number
},
content: {
type: String
}
},
// rest of component definition
}
</script>
要使用此组件,我们可以更新script
部分如下:
<script>
import Repeat from './components/RepeatTyped.vue'
// no other changes
</script>
当times
和content
分别是Number
和String
类型时,组件的行为仍然相同。
如果我们更新App
使其故意传递错误类型的属性。在这种情况下,times
是一个String
类型,而content
是一个Number
类型。
<template>
<div id="app">
<Repeat :times="count" :content="55" />
</div>
</template>
<script>
// no changes to imports
export default {
data() {
return { count: 'no-number-here' }
},
// other properties
}
</script>
在这里,Repeat
组件将无法渲染,以下错误将被记录到控制台:
![图 4.9:Vue.js 属性输入错误示例]
图 4.9:Vue.js 属性输入错误示例
times
属性检查失败,消息解释说我们传递了一个本应为 Number
的 String
属性:
Invalid prop: type check failed for prop "times". Expected Number with value NaN, got String with value "no-number-here"
content
属性检查失败,消息解释说我们传递了一个本应为 String
的 Number
属性:
Invalid prop: type check failed for prop "content". Expected String with value "55", got Number with value 55
注意
根据 Vue.js 文档,null 和 undefined 值将通过任何类型验证,这意味着类型验证并不是万无一失的,因此为组件添加自定义验证是有意义的。
联合和自定义属性类型
在前面的例子中,我们只是渲染了内容,所以类型是什么并不重要。
Vue.js 支持联合类型。联合类型是一种可以是许多其他类型之一的类型。例如,String
或 Number
是联合类型。
在 Vue.js 中,联合类型使用数组来表示属性 type
属性,例如,为了支持 content
作为数字和字符串:
<script>
export default {
props: {
// other prop definitions
content: {
type: [String, Number]
}
}
// rest of component definition
}
</script>
在这种情况下,我们可以无错误地消费 RepeatTyped
组件,如下所示:
<template>
<div id="app">
<Repeat :times="3" :content="55" />
</div>
</template>
这会显示 55
三次。在这里,55
被作为 Number
传递,而我们的组件现在支持这一点。这可以在以下输出中看到:
55 55 55
任何有效的构造函数都可以用作属性类型。例如,Promise
或自定义的 User
构造函数可以用来。在以下示例中,我们定义了一个 TodoList
组件属性接口:
<script>
import User from './user.js'
export default {
props: {
todoListPromise: {
type: Promise
},
currentUser: {
type: User
}
}
}
</script>
该组件公开的属性接口可以如下使用:
<template>
<div>
<template v-if="todosPromise && !error">
<TodoList
:todoListPromise="todosPromise"
:currentUser="currentUser"
/>
</template>
{{ error }}
</div>
</template>
<script>
import TodoList from './components/TodoList.vue'
import User from './components/user.js'
const currentUser = new User()
export default {
components: {
TodoList
},
mounted() {
this.todosPromise = fetch('/api/todos').then(res => {
if (res.ok) {
return res.json()
}
throw new Error('Could not fetch todos')
}).catch(error => {
this.error = error
})
},
data() {
return { currentUser, error: null }
}
}
</script>
我们已经看到了如何使用 union
和 custom
类型来验证 Vue.js 属性。
注意
Vue.js 在内部使用 instanceof
,所以请确保任何自定义类型都是使用相关构造函数实例化的。
传递 null
或 undefined
将会失败 instanceof
对 Array
和 Object
的检查。
传递一个数组将通过 instanceof
对 Object
的检查,因为在 JavaScript 中,Array
实例也是 Object
实例。
使用验证器进行数组的自定义验证、对象形状等
Vue.js 允许使用 validator
属性将自定义验证器用作属性。这允许我们实现有关对象和数组形状的深度检查,作为原始类型的自定义逻辑。
为了说明这一点,让我们看看一个 CustomSelect
组件。在基本层面上,select
的属性接口包括一个 options
数组和一个 selected
选项。每个选项应该有一个 label
,表示在选择中显示的内容,以及一个 value
,它对应于传递给 API 的值。例如,selected
选项可以是空的,或者应该对应于 options
中的一个 value
字段。
我们可以将 CustomSelect
以以下简单方式实现(不验证输入):
<template>
<select>
<option
:selected="selected === o.value"
v-for="o in options"
:key="o.value"
>
{{ o.label }}
</option>
</select>
</template>
<script>
export default {
props: {
selected: {
type: String
},
options: {
type: Array
}
}
}
</script>
然后,CustomSelect
可以用来显示 src/App.vue
中的列表):
<template>
<div id="app">
<CustomSelect :selected="selected" :options="options" />
</div>
</template>
<script>
import CustomSelect from './components/CustomSelect.vue'
export default {
components: {
CustomSelect
},
data() {
return {
selected: 'salt-vinegar',
options: [
{
value: 'ready-salted',
label: 'Ready Salted'
},
{
value: 'cheese-onion',
label: 'Cheese & Onion'
},
{
value: 'salt-vinegar',
label: 'Salt & Vinegar'
},
]
}
}
}
</script>
前面的应用程序输出了一个选择,其中 Salt & Vinegar
是默认选中选项,如下面的截图所示:
![图 4.10:折叠的 CustomSelect,选择了 Salt & Vinegar]
图 4.10:折叠的 CustomSelect,选择了 Salt & Vinegar
以下截图显示了三种口味选项,其中一个是被选中的:
图 4.11:打开带有口味选项和盐味芥末的 CustomSelect
为了进一步验证我们关于形状选项的业务逻辑,我们可以实现以下属性验证器:
<script>
export default {
// other component properties
props: {
// other prop definitions
options: {
type: Array,
validator(options) {
return options.every(o => Boolean(o.value && o.label))
}
}
}
}
</script>
如果我们传递一个缺少value
或label
的选项,我们将在控制台得到以下消息:
图 4.12:当自定义验证器失败时 Vue.js 的警告
通过这些,我们已经学习了如何使用自定义 Vue.js 验证器对复杂属性进行深入检查。接下来,我们将学习required
属性类型属性是如何工作的。
必需属性
要将 Vue.js 属性标记为必需,我们可以使用required
属性类型属性。
在CustomSelect
示例中,我们可以将selected
属性标记为必需。
要做到这一点,我们需要修改属性定义,使其包括required: true
,如下所示:
<script>
export default {
// other component properties
props: {
selected: {
type: String,
required: true
}
// other prop definitions
}
}
</script>
现在,如果我们修改CustomSelect
的消费者,使其不传递selected
属性,我们将看到以下错误:
图 4.13:当选定的必需属性缺失时 Vue.js 的警告
通过这些,我们已经学习了如何标记 Vue.js 属性为必需,以及当不传递必需属性时会发生什么。接下来,我们将学习将属性默认为最佳选择。
默认属性
有时候,将属性默认为最佳组件接口。
这的一个例子是一个PaginatedList
组件,它接受一个列表并根据limit
和offset
参数显示该列表的子集。在这种情况下,我们可能最好将limit
默认为25
,将offset
默认为0
(默认情况下,我们显示第一页,包含25
个结果)。
这是我们如何实现没有默认值的PaginatedList
组件的示例:
<template>
<ul>
<li
v-for="el in currentWindow"
:key="el.id"
>
{{ el.content }}
</li>
</ul>
</template>
<script>
export default {
props: {
items: {
type: Array
},
limit: {
type: Number
},
offset: {
type: Number
}
},
computed: {
currentWindow() {
return this.items.slice(this.offset, this.limit)
}
}
}
</script>
我们可以使用以下代码来使用它:
<template>
<div id="app">
<PaginatedList :items="snacks" :offset="offset" : limit="limit"/>
<button @click="offset++">
Increment Offset (current: {{ offset }})
</button>
<button @click="limit++">
Increment Limit (current: {{ limit }})
</button>
</div>
</template>
<script>
import PaginatedList from './components/PaginatedList.vue'
export default {
components: {
PaginatedList
},
data() {
return {
offset: 0,
limit: 0,
snacks: [
{
id: 'ready-salted',
content: 'Ready Salted'
},
{
id: 'cheese-onion',
content: 'Cheese & Onion'
},
{
id: 'salt-vinegar',
content: 'Salt & Vinegar'
},
]
}
}
}
</script>
通过将限制增加到 3,我们可以显示整个列表,如下所示:
Hello Vue.js
然后,通过增加偏移量,我们可以跳过列表中的前X个元素。以下截图显示了PaginatedList
:
图 4.14:带有限制 3 和偏移 1 的 PaginatedList
现在,为了使我们的PaginatedList
具有弹性,我们将默认limit
为25
,将offset
默认为0
。为此,我们可以设置相关属性的default
属性:
<script>
export default {
props: {
// other props
limit: {
type: Number,
default: 25,
},
offset: {
type: Number,
default: 0,
}
},
// other component properties
}
</script>
使用这些默认值,我们将默认从列表开头显示25
个条目。
在数组或对象的情况下,default
存在一个问题(例如,如果我们想将items
默认),根据 Vue.js 文档;即,“对象或数组默认必须从工厂函数返回”。
factory function
是一个函数——在这种情况下,称为 default
——它返回我们想要的默认值。
在 items
的情况下,我们可以编写以下内容:
<script>
export default {
props: {
items: {
type: Array,
default() {
return []
}
}
// other props
},
// other component properties
}
</script>
通过这样,我们已经学习了如何设置 Vue.js 组件属性的默认值。当我们希望为可选参数提供值,以便 Vue.js 组件实现不需要处理默认属性值时,这很有帮助。
练习 4.03:验证对象属性
在这个练习中,我们将重写 Repeat
组件,使其支持单个 config
属性,用于传递 times
(一个数字)和 content
(一个字符串)。
我们将不得不编写一个自定义验证器来确保 times
和 content
存在且类型正确。
要访问此练习的代码文件,请参阅 packt.live/2Ui1hVU
。
按照以下步骤完成此练习:
-
我们希望我们的
src/components/Repeat.vue
组件支持一个config
属性。这将是一个Object
,它产生以下<script>
:<script> export default { props: { config: { type: Object } } } </script>
-
接下来,我们希望当传递
config
时渲染某些内容。为此,我们将创建一个数组,通过v-for
遍历一个计算机属性。数组的长度将基于config.times
的值:<script> export default { // other component properties computed: { repetitions() { return Array.from({ length: this.config.times }) } } } </script>
-
下一步是设置
<template>
以渲染config.content
,对于每个repetitions
项目:<template> <div> <span v-for="r in repetitions" :key="r"> {{ config.content }} </span> </div> </template>
-
目前,我们正在确保
content
和times
已设置且类型正确。为此,我们将在配置属性validator
中实现typeof
检查:<script> export default { props: { config: { type: Object, validator(value) { return typeof value.times === 'number' && typeof value.content === 'string' } } }, // other component properties } </script>
-
最后,我们可以在
src/App.vue
中消耗Repeat
。我们需要导入它,在script
中注册它,并在template
中渲染它:<template> <div id="app"> <Repeat :config="{}" /> </div> </template> <script> import Repeat from './components/Repeat.vue' export default { components: { Repeat } } </script>
很遗憾,这不会渲染任何内容,因为
config
是一个空对象。你将观察到以下警告:图 4.15:由于配置属性的定制验证器检查失败而导致的 Vue.js 警告
我们将在以下情况下看到相同的错误:
a) 我们只添加一个
times
属性;即<Repeat :config="{ times: 3 }" />
。b) 我们只添加一个
content
属性;即<Repeat :config="{ content: 'Repeat me.' }" />
。c)
times
的类型不正确;即<Repeat :config="{ times: '3', content: 'Repeat me.' }" />
。d)
content
属性的类型不正确;即<Repeat :config="{ times: 3, content: 42 }" />
。 -
为了使
Repeat
正确工作,我们可以修改在template
中消耗它的行,如下所示:<Repeat :config="{ times: 3, content: 'Repeat me.' }" />
这在控制台中没有显示错误,并渲染
Repeat me.
三次,如下所示:Repeat me. Repeat me. Repeat me.
通过这样,我们已经展示了如何验证属性以更好地定义 Vue.js 组件的接口。
接下来是对 slots
的深入探讨,这是一种机制,我们可以通过延迟模板逻辑来组合我们的组件。
插槽、命名插槽和作用域插槽
另一个在 Vue.js 中实现可重用性的组件组合模式是 slots
。
插槽是组件中的部分,其中模板/渲染被委托回组件的消费者。
在这里,props 可以被视为从父组件传递给子组件的数据,以便子组件执行某些逻辑或进行渲染。
插槽可以被视为从父组件传递给子组件以进行渲染的模板或标记。
将标记传递给子组件进行渲染
最简单的插槽类型是默认的 child
插槽。
我们可以定义一个具有插槽的 Box
组件如下。请注意,这个 Box
组件做得很少:
<template>
<div>
<slot />
</div>
</template>
以下标记用于父组件(src/App.vue
):
<template>
<div>
<Box>
<h3>This whole h3 is rendered in the slot</h3>
</Box>
</div>
</template>
<script>
import Box from './components/Box.vue'
export default {
components: {
Box
}
}
</script>
在浏览器中,前面的代码将如下所示:
This whole h3 is rendered in the slot
Vue 单文件组件的作用域中的 template
部分,是用父组件的作用域编译的。
考虑以下示例:
<template>
<div>
<Box>
<h3>This whole h3 is rendered in the slot with parent count {{ count }}</h3>
</Box>
<button @click="count++">Increment</button>
</div>
</template>
<script>
import Box from './components/Box.vue'
export default {
components: {
Box
},
data() {
return { count: 0 }
}
}
</script>
前面的代码将根据父组件中的 count
值渲染 count
。它无法访问 Box
实例数据或 props,并将生成以下输出:
图 4.16:初始的 h3,计数为 0,根据父组件的初始数据
增加计数确实会更新模板,正如我们预期的那样,如果模板中的变量绑定到父组件上的数据。这将生成以下输出:
图 4.17:在父组件的作用域中计数增加五次后的 h3,计数为 5
插槽是将子组件的一部分渲染委托给父组件的方式。任何对实例属性、数据或方法的引用都将使用父组件实例。这种类型的插槽无法访问子组件的属性、props 或数据。
在下一节中,我们将探讨如何使用命名插槽来渲染多个部分。
使用命名插槽来委托多个部分的渲染
命名插槽用于当子组件需要能够将多个部分的模板委托给父组件时。
例如,一个 Article
组件可能会将 header
和 excerpt
的渲染委托给其父组件。
在这种情况下,这将在 Article.vue
文件中如下所示。命名插槽是具有 name
属性的 slot
条目,表示插槽的名称:
<template>
<article>
<div>Title: <slot name="title" /></div>
<div>Excerpt: <slot name="excerpt" /></div>
</article>
</template>
通过这样做,您可以在另一个组件中消费此组件。
为了传递插槽的内容,我们使用 v-slot:name
指令(其中 name
应替换为插槽的名称)。
例如,对于名为 title
的插槽,我们将使用 v-slot:title
,而对于 excerpt
插槽,我们将使用 v-slot:excerpt
:
<template>
<div>
<Article>
<template v-slot:title>
<h3>My Article Title</h3>
</template>
<template v-slot:excerpt>
<p>First paragraph of content</p>
<p>Second paragraph of content</p>
</template>
</Article>
</div>
</template>
<script>
import Article from './components/Article.vue'
export default {
components: {
Article
}
}
</script>
当在浏览器中看到前面的应用程序时,它将如下所示:
图 4.18:使用命名插槽渲染父组件定义的模板
如您所见,命名插槽确实渲染了预期的内容。
v-slot:slot-name
的简写语法是 #slot-name
。我们可以重构我们的模板,以消费 Article
如下:
<template>
<div>
<Article>
<template #title>
<h3>My Article Title</h3>
</template>
<template #excerpt>
<p>First paragraph of content</p>
<p>Second paragraph of content</p>
</template>
</Article>
</div>
</template>
v-slot
不能与原生元素一起使用。它只能使用 template
和组件。例如,以下 <template>
部分尝试在 h3
元素上设置 v-slot
:
<template>
<div>
<Article>
<h3 v-slot:title>My Article Title</h3>
</Article>
</div>
</template>
这个模板将因为 v-slot
只能在组件或 <template>
上使用而失败,如下面的截图所示:
图 4.19:原生元素上的 v-slot – 编译错误
Vue.js 的早期版本允许使用另一种语法来表示命名插槽的内容(这在 Vue 2.6.0+ 中已被弃用)。而不是使用 v-slot:slot-name
指令样式,使用了 slot="slot-name"
。slot
语法被允许在原生元素、模板和组件上使用。
注意
适用于默认插槽的一切也适用于命名插槽。事实上,默认插槽是一个名为 default
的命名插槽。这意味着命名插槽也可以访问父实例,但不能访问子实例。
默认插槽只是一个名为 default
的插槽,并且由于在没有任何 name
的 slot
中默认使用,Vue.js 对其进行了特殊处理。
默认插槽隐式推断如下:
<template>
<MyComponent>
<template>Default template</template>
</MyComponent>
</template>
默认插槽可以用简写槽表示法表示。
<template>
<MyComponent>
<template #default>Default template</template>
</MyComponent>
</template>
The default slot can be denoted with longhand slot notation.
<template>
<MyComponent>
<template v-slot:default>Default template</template>
</MyComponent>
</template>
我们已经看到,命名插槽允许组件将某些部分的模板委托给消费者,以及这些命名插槽如何有一个默认模板来处理命名插槽可选的情况。
在下一节中,我们将学习如何使用作用域插槽来封装属性传递逻辑。
使用作用域插槽封装属性传递逻辑
我们迄今为止探索的插槽类型只能访问它们声明的组件实例。
有时,让父组件决定渲染,同时让子组件以某种方式转换数据是有用的。这就是作用域插槽的用途。
一个 slot
元素通过使用 v-bind
或简写 :
绑定了一些属性。
在这种情况下,item
被绑定到 el
。el
是传递给此 PaginatedList
组件的 items
属性的一个元素:
<template>
<ul>
<li
v-for="el in currentWindow"
:key="el.id"
>
<slot :item="el" />
</li>
</ul>
</template>
<script>
export default {
props: ['items', 'limit', 'offset'],
computed: {
currentWindow() {
return this.items.slice(this.offset, this.limit)
}
}
}
</script>
在消费者端(父组件),我们可以将槽模板视为带有所有绑定到子组件槽中的数据的对象被调用。因此,这些槽被称为 scoped
;它们通过子组件定义的 scope
对象传递。
在这个例子中,我们可以这样消费 PaginatedList
:
<template>
<div>
<PaginatedList :items="snacks">
<template #default="{ item }">
{{ item.content }}
</template>
</PaginatedList>
</div>
</template>
#default="{ item }"
是默认作用域插槽的简写表示法,允许我们将槽的作用域解构为 item
。
槽的模板定义的长版本如下:
<template v-slot="slotProps">
{{ slotProps.item.content }}
</template>
item
随后用于渲染{{ item.content }}
。带有要渲染的零食的script
部分如下:
<script>
import PaginatedList from './components/PaginatedList.vue'
export default {
components: {
PaginatedList
},
data() {
return {
snacks: [
{
id: 'ready-salted',
content: 'Ready Salted'
},
{
id: 'cheese-onion',
content: 'Cheese & Onion'
},
{
id: 'salt-vinegar',
content: 'Salt & Vinegar'
},
]
}
}
}
</script>
在浏览器中,我们得到以下输出:
图 4.20:使用作用域插槽显示的零食,意味着渲染逻辑在父组件中
通过这样,我们已经学会了作用域插槽如何使组件具有更大的灵活性,可以将模板逻辑委托给消费者。
注意
作用域插槽还有一个已弃用的(自 Vue.js 2.6.0+起)slot-scope
语法。v-slot:name="slotProps"
的弃用等效语法是slot="name" slot-scope="slotProps"
。有关更多信息,请参阅 Vue.js 文档:vuejs.org/v2/guide/components-slots.html#Scoped-Slots-with-the-slot-scope-Attribute
。
现在,让我们学习如何借助这些命名插槽实现卡片组件。
练习 4.04:使用命名插槽实现卡片组件
在这个练习中,我们将使用命名插槽实现一个卡片组件。卡片将包含标题、图片和描述部分。我们将使用插槽允许父组件定义title
、image
和description
。
要访问此练习的代码文件,请参阅packt.live/2UhLxlK
。
按照以下步骤完成此练习:
-
我们将首先创建一个新的
src/components/Card.vue
组件,该组件有一个支持三个插槽的模板——title
、image
和description
:<template> <div> <slot name="image" /> <slot name="title" /> <slot name="description" /> </div> </template>
-
然后,我们将
Card.vue
组件导入到新src/App.vue
文件的script
部分:<script> import Card from './components/Card.vue' export default { components: { Card } } </script>
-
我们现在可以在
template
中使用Card
:<template> <div id="app"> <Card> <template #image> <img src="img/300" /> </template> <template #title> <h2>My Holiday picture</h2> </template> <template #description> <p>Here I can describe the contents of the picture.</p> <p>For example what we can see in the photo is a nice landscape.</p> </template> </Card> </div> </template>
现在,我们可以使用
npm run serve
启动vue-cli dev
服务器,并查看Card
组件的实际效果。输出将如下所示:
图 4.21:带有图片、标题和描述的卡片组件
通过这样,我们已经学会了不同类型的插槽如何帮助创建更通用的组件。插槽允许子组件将自身某些部分的渲染推迟到父组件(消费者)。
要在单个模板中重用功能,我们可以使用过滤器。我们将在下一节学习如何使用它们。
使用过滤器共享模板逻辑
Vue.js 使用过滤器来共享模板逻辑。
过滤器可以在 mustache 插值({{ interpolatingSomething }}
)或表达式中使用(例如,在绑定值时)。filter
是一个函数,它接受一个值并输出可以渲染的内容(通常是String
或Number
)。
因此,一个名为truncate
的示例过滤器可以在模板中使用,如下所示(这里我们放置了一些长占位文本):
<template>
<div id="app">
{{ message | truncate }}
</div>
</template>
<script>
export default {
data() {
return {
message: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation llamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'
}
}
}
</script>
truncate
也可以在 Vue.js 绑定表达式中使用。例如,<MessageComponent :msg="message | truncate">
将message
的截断输出绑定到msg
。
要定义truncate
过滤器,我们可以在组件的script
部分的filters
属性中定义它。
truncate
过滤器将文本截断到120
个字符:
<script>
export default {
filters: {
truncate(value) {
return value.slice(0, 120)
}
},
// other component properties
}
</script>
没有截断过滤器,我们得到446
个字符的Lorem ipsum,如下所示:
![图 4.22:未截断的 Lorem ipsum]
![img/B15218_04_22.jpg]
图 4.22:未截断的 Lorem ipsum
使用truncate
过滤器,我们减少到120
个字符,如下所示 截图:
![图 4.23:使用truncate
过滤器的 Lorem ipsum]
![img/B15218_04_23.jpg]
图 4.23:使用truncate
过滤器的 Lorem ipsum
编写这个truncate
过滤器的更防御性的方式是,如果val
为假,则提前返回,然后toString
它(这将把数字转换为字符串,例如)然后再进行.slice
的输出:
<script>
export default {
filters: {
truncate(value) {
if (!value) return
const val = value.toString()
return val.slice(0, 120)
}
},
// other component properties
}
</script>
通过这样,我们已经学会了如何为组件注册和实现 Vue.js 过滤器。我们还学会了如何在组件的模板中使用管道语法在插值表达式中使用过滤器。
在接下来的练习中,我们将学习如何实现省略号过滤器。
练习 4.05:实现省略号过滤器
过滤器非常适合重复的文本处理任务。在这个练习中,我们将实现一个按如下方式工作的ellipsis
过滤器。
如果传递的文本超过14
个字符,则应将其截断到11
个字符,并在文本末尾添加省略号(…
)。
当传递的文本为空或不是String
时,我们应该相当宽容,要么返回空值,要么在我们处理之前将其转换为String
。
要访问此练习的代码文件,请参阅packt.live/2IsZyuv
。
按照以下步骤完成此练习:
-
首先,我们需要设置模板,以便它将少于 14 个字符的字符串、14 个字符的字符串和多于 14 个字符的字符串通过
ellipsis
传递,以检查在所有可能的条件下是否按预期工作(我们将按照标准的 Vue CLI 设置,在src/App.vue
中这样做)。我们还应该将数字和一个空值(null
)通过ellipsis
传递:<template> <div id="app"> <p>{{ '7 char' | ellipsis }}</p> <p>{{ '14 characters' | ellipsis }}</p> <p>{{ 'More than 14 characters' | ellipsis }}</p> <p>{{ null | ellipsis }}</p> <p>{{ 55 | ellipsis }}</p> </div> </template>
在这个阶段,应用程序应该只显示控制台中的文本。应该有一些警告表明
ellipsis
过滤器尚未定义,如下所示截图:![图 4.24:显示未更改文本的应用程序]
![img/B15218_04_24.jpg]
图 4.24:显示未更改文本的应用程序
以下截图显示了警告:
![图 4.25:Vue.js 警告缺少省略号过滤器]
![img/B15218_04_25.jpg]
![图 4.25:Vue.js 警告缺少省略号过滤器]
-
接下来,我们将在组件的
script
部分实现过滤器的初始版本。这将检查传入值的长度,如果它超过 14 个字符,则将其截断到11
并添加…
:<script> export default { filters: { ellipsis(value) { return value.length > 14 ? `${value.slice(0, 11)}...` : value } } } </script>
在这个阶段,组件无法渲染,Vue.js 记录了一个错误,显示为
Cannot read property 'length' of null
,如下面的截图所示:![图 4.26:null 被导入到应用中
图 4.26:null 被导入到应用中
-
接下来,我们需要修改
ellipsis
实现,以便在传入的值为false
时短路(以避免null
的问题):ellipsis(value) { if (!value) return // rest of the function }
我们现在有了
ellipsis
过滤器正在工作;它适用于我们包含的所有测试用例。输出将如下所示:![图 4.27:省略号过滤器对给定输入的工作情况
图 4.27:省略号过滤器对给定输入的工作情况
过滤器对于在组件中共享简单的文本处理逻辑很有用。过滤器是 Vue.js 的一个原始操作,它将模板和格式化关注点保留在模板中,例如截断内容和添加省略号。
Vue.js 在 DOM Web API 之上提供了一个抽象层。然而,当需要直接访问 DOM 时,例如集成 DOM 库,Vue.js 通过 refs 提供了一种一等的方式来这样做。我们将在下一节学习 Vue.js 的 refs。
Vue.js refs
在 Vue.js 中,refs是对 DOM 元素或其他组件的引用。这是程序化发生的。
Refs 的一个大用途是直接 DOM 操作和与基于 DOM 的库(通常需要一个它们应该挂载到的 DOM 节点)的集成。
Refs 在模板中的原生元素或子组件上使用ref="name"
定义。在以下示例中,输入将被存储在theInput
refs 中:
<template>
<div id="app">
<input ref="theInput" />
</div>
</template>
Refs 可以通过 Vue.js 组件实例通过this.$refs[name]
访问。因此,在前面的示例中,我们有一个定义为ref="theInput"
的 refs,我们可以通过this.$refs.theInput
来访问它。
要在按钮点击时聚焦输入,我们可以编写以下代码:
<template>
<div id="app">
<input ref="theInput" />
<button @click="focus()">Focus Input</button>
</div>
</template>
<script>
export default {
methods: {
focus() {
this.$refs.theInput.focus()
}
}
}
</script>
当点击“聚焦输入”按钮时,输入将被聚焦,如下面的截图所示:
![图 4.28:按钮点击时聚焦输入
图 4.28:按钮点击时聚焦输入
通过这样,我们已经学习了如何在 Vue.js 组件中使用$refs
来抽象 DOM 操作逻辑。在 Vue.js 中直接选择 DOM 节点合理的地方,建议使用ref
而不是使用 DOM 选择 API(querySelector
/querySelectorAll
)。
在以下练习中,我们将学习Countable
库如何帮助提高项目的交互性。
练习 4.06:使用 Vue.js 包装 Countable.js
Countable
是一个库,给定一个元素(通常是 HTML textarea
或输入),将为段落、单词和字符添加实时计数。在捕获的文本上的实时度量可以非常有用,可以增加编辑文本为核心关注点的项目中交互性。
在 Vue.js 中使用 refs 的一个大型用例是能够与直接作用于 DOM 的库集成。
在这个练习中,我们将通过使用 Countable.js
和 Vue.js refs 创建一个具有段落/单词/字符计数的 textarea
组件。
要访问此练习的代码文件,请参阅 packt.live/36oOuGz
。
按照以下步骤完成此练习:
-
从
npm
安装countable
。在这里,我们将运行npm install --save countable
,这将将其添加到我们的依赖项中 -
接下来,我们将创建一个新的
src/components/TextEditorWithCount.vue
组件,其中包含一个我们将有ref
的textarea
:<template> <div> <textarea ref="textArea" cols="50" rows="7" > </textarea> </div> </template>
-
接下来,我们将导入并渲染
src/App.vue
中的组件:<template> <div id="app"> <TextEditorWithCount /> </div> </template> <script> import TextEditorWithCount from './components/ TextEditorWithCount.vue' export default { components: { TextEditorWithCount } } </script>
应用程序渲染了一个
textarea
,如下所示:![图 4.29:应用程序渲染的裸文本区域]
![图片 B15218_04_29.jpg]
图 4.29:应用程序渲染的裸文本区域
-
我们现在需要集成
Countable
。我们将导入它,并用this.$refs.textArea
初始化它。我们还将把计数存储在实例上作为this.count
:<script> import * as Countable from 'countable' export default { mounted() { Countable.on(this.$refs.textArea, (count) => { this.count = count }) }, data() { return { count: null } } } </script>
-
通过对
template
的小更新,我们可以显示我们关心的计数:<template> <div id="app"> <!-- textarea --> <ul v-if="count"> <li>Paragraphs: {{ count.paragraphs }}</li> <li>Sentences: {{ count.sentences }}</li> <li>Words: {{ count.words }}</li> </ul> </div> </template>
现在,我们可以看到当
textarea
为空时,计数设置为0
,如下所示:![图 4.30:当文本区域为空时,计数设置为 0]
![图片 B15218_04_30.jpg]
图 4.30:当文本区域为空时,计数设置为 0
如果我们在
textarea
中放入一些 Lorem ipsum,计数将相应更新,如下所示:![图 4.31:当填充时更新的计数文本区域]
![图片 B15218_04_31.jpg]
图 4.31:当填充时更新的计数文本区域
-
我们最后需要做的一件事是在组件销毁时移除
Countable
事件监听器:<script> // imports export default { mounted() { Countable.on(this.$refs.textArea, (count) => { this.count = count }) this.$once('hook:beforeDestroy', function () { Countable.off(this.$refs.textArea) }) }, // other component properties } </script>
注意
我们已经通过程序性监听器实现了这一点,尽管我们也可以通过
beforeDestroy
生命周期方法实现相同的效果。
在 Vue.js 中将 JavaScript/DOM 库集成到应用中是 Vue.js refs 的关键应用。Refs 允许我们从现有的库生态系统中选择,并将它们包装或集成到组件中。
Vue.js refs 对于集成 DOM 库或直接访问 DOM API 非常有用。
为了结束我们对组件组合的学习,我们需要知道如何从子组件向父组件传递数据。
Vue.js 子父组件通信事件
我们已经看到,props 用于从父组件传递数据到子组件。
要从子组件将数据传递回父组件,Vue.js 有自定义事件。
在组件中,可以使用$emit
实例方法来触发一个事件。它可以在script
部分使用this.$emit('eventName', /* payload */)
来使用,但也可以在template
部分作为$emit
暴露。
假设我们有一个响应式实例属性this.message
,我们可以在script
部分使用this.$emit
来发出一个带有message
值的send
事件。这可以作为一个MessageEditor
组件的基础:
<script>
export default {
data () {
return {
message: null
}
},
methods: {
send() {
this.$emit('send', this.message);
}
}
}
</script>
在相同的场景中,我们可以从template
部分触发一个send
事件:
<template>
<div>
<input v-model="message" />
<button @click="$emit('send', message)">Emit inline</button>
</div>
</template>
从父组件中,我们可以使用v-on:event-name
或简写的@event-name
。event-name
必须与传递给$emit
的名称匹配;eventName
和event-name
不等同。
例如,父组件会是如何使用@send
监听send
事件并保存包含在$event
魔法值中的事件负载。要在方法调用中使用事件负载,我们可以使用@eventName="methodToCall($event)"
:
<template>
<div id="app">
<p>Message: {{ message }}</p>
<MessageEditor @send="message = $event" />
<button @click="message = null">Reset</button>
</div>
</template>
<script>
import MessageEditor from './components/MessageEditor.vue'
export default {
components: {
MessageEditor
},
data() {
return {
message: null
}
}
}
</script>
使用内联和方法版本的$emit
会产生相同的结果。完整的MessageEditor
应用应如下所示:
![图 4.32:Hello World!从子组件到父组件发出的消息]
图 4.32:Hello World!从子组件到父组件发出的消息
Vue.js 自定义事件支持将任何 JavaScript 类型作为负载传递。然而,事件名称必须是一个String
。
注意
将监听器绑定到 Vue.js 自定义事件与绑定到原生事件(如click
)非常相似。
现在,让我们根据我们迄今为止所学的内容完成一个活动。
活动 4.01:具有可重用组件的本地消息视图
此活动旨在利用组件、属性、事件和 refs 来渲染一个聊天界面,用户可以添加消息,并且它们会被显示。
按照以下步骤完成此活动:
-
创建一个
MessageEditor
组件(在src/components/MessageEditor.vue
),向用户显示一个textarea
。 -
向
MessageEditor
添加一个message
响应式实例变量,默认值为''
。 -
监听
textarea
的change
事件,并将message
的值设置为textarea
内容(它作为事件的值暴露)。 -
添加一个
Send
按钮,当点击时,会发出一个带有message
作为负载的send
事件。 -
在
src/App.vue
中添加一个main
App
组件,渲染MessageEditor
。 -
在
App
中监听来自MessageEditor
的send
事件,并将每条消息存储在messages
响应式实例变量中(messages
是一个数组)。 -
创建一个
MessageFeed
(在src/components/MessageFeed.vue
),它有一个必需的messages
属性,它是一个数组。 -
在
MessageFeed
中,将messages
属性中传递的每条消息渲染为一个段落(p
元素)。 -
将
MessageFeed
导入到App
中,并将messages
应用实例变量绑定为MessageFeed
的messages
属性。 -
改进
MessageEditor
,以便在发送消息时重置消息。为此,我们需要使用 Vue.js 的 ref 设置textarea.value
并重置message
实例变量。注意
重置
textarea
的更简单的方法本来是直接使用v-model="message"
,而不是绑定@change
并手动同步textarea.value
到message
。预期的输出如下:
![图 4.33:发送了 Hello World!和 Hello JavaScript 的消息应用]
![图片 B15218_04_33.jpg]
图 4.33:发送了 Hello World!和 Hello JavaScript 的消息应用
注意
该活动的解决方案可以通过此链接找到。
摘要
在本章中,我们探讨了 Vue.js 的基本功能,这些功能使我们能够以高效的方式构建组件。
Props 和 slots 用于将组件内的行为延迟到渲染它们的父组件。具有验证能力的 Props 非常适合将数据传递到嵌套组件中。slots 旨在将渲染控制权交还给父组件。事件使子组件能够将数据发送回父组件,从而完成父子通信周期(props 向下,events 向上)。
全局模板辅助函数可以被封装在过滤器中,以减少样板代码并增加代码复用。通过允许我们直接访问 DOM 元素,Refs 可以解锁与第三方 JavaScript 或 DOM 库的集成机会。
我们现在能够组合和创建组件,这些组件通过输入(props 和 slots)和输出(渲染模板和事件)清楚地定义了它们的接口,同时访问常见的用例(包装 DOM 库、在过滤器中抽象模板关注点等)。
在下一章中,我们将探讨高级组件组合模式和技巧,这些模式和技巧能够实现更好的代码复用。
第五章:5. 全局组件组合
概述
在本章中,你将学习如何使用全局抽象、新的组合模型和新的组件类型来减少你的 Vue.js 应用程序代码中的重复。你将实验 Vue.js 的混合、插件和新的组件类型以及它们的组合方式。
到本章结束时,你将能够识别在 Vue.js 应用程序中使用混合和插件以实现全局组合并保持代码 DRY(不要重复自己)的情况,以及如何定义全局组件、功能组件和非 Vue 文件中的组件。你还将能够对比全局组合的优点和缺点,并选择正确的抽象以最大化组件的灵活性。
简介
组件嵌套是一种组合方法,其中应用程序由更小的单元(组件)构建而成。应用程序可以被视为组件相互嵌套。在这种情况下,任何共享功能将通过组件提供。Vue.js 提供了其他组合方法。
基于组件的组合可能会非常冗长,这意味着我们会在需要某个特定功能的地方重复导入。这不符合 DRY 原则。为了避免这种重复和冗长,我们可以在MyComponent
的每个消费者中全局注册import MyComponent from ...
。
同样,应用程序也可以由不同类型的原语(混合、插件和组件)构建而成。为了最大灵活性,组件可以以不同的方式定义,而不仅仅是 Vue.js 的单文件组件文件(.vue
文件)。在这个类别中,我们有功能组件以及使用render
函数定义的组件。每种类型的组件都有其优点和缺点。
为了保持代码 DRY(Don't Repeat Yourself),组件应该易于使用和扩展。本章将探讨一些我们可以遵循的技巧,以使组件更具可重用性,从而使应用程序更加 DRY。
混合
混合可以为使用它们的组件添加方法、属性和默认的生命周期方法。在下面的示例中,我们正在定义一个混合,它向组件的data
函数添加一个greet
方法和一个greeting
字段:
export default {
methods: {
greet(name) {
return `${this.greeting} ${name}`
}
},
data() {
return {
greeting: 'Hello'
}
}
}
混合(Mixins)允许独立定义多个组件的共享功能。它们通过一个mixins
组件属性来使用,该属性接受一个数组。
在App.vue
文件中,我们可以通过设置组件的mixins
属性来使用混合。
混合的属性和方法随后将在组件中可用(就像它们在组件本身中定义一样):
<template>
<div>{{ greet('World') }}</div>
</template>
<script>
import greeter from './mixins/greeter.js'
export default {
mixins: [greeter]
}
</script>
这将在浏览器中显示以下消息:
![图 5.1:使用 greeter 混合的 Hello World
![img/B15218_05_01.jpg]
图 5.1:使用 greeter 混合的 Hello World
当组件和混合器在实例属性或方法名称相同的情况下发生冲突时,组件获胜。这可以理解为组件默认采用混合器行为,除非该组件声明了相同的实例属性或方法。在这种情况下,混合器中定义的实例访问将访问组件的实例。
例如,让我们向具有greeting
设置为Hi
的App
组件添加一个data()
初始化器:
<script>
// other imports
export default {
// other component properties
data() {
return {
greeting: 'Hi'
}
}
}
</script>
混合器定义了一个data
方法,但组件也定义了。在这种情况下,组件获胜,因此显示的问候语是Hi
(在组件中定义),而不是Hello
(在混合器中定义),如下所示:
![图 5.2:使用重写数据的 greeter mixin 实现的 Hi World]
![图片 B15218_05_02.jpg]
图 5.2:使用重写数据的 greeter mixin 实现的 Hi World
注意,当组件没有定义data
方法时,将使用混合器的实现,但如果混合器和组件都定义了它,组件将获胜。
Vue.js 生命周期钩子是提取到混合器的首选候选者。我们可以使用的生命周期钩子(按执行顺序)是beforeCreated
、created
、beforeMount
、mounted
、beforeUpdate
、updated
、beforeDestroy
和destroyed
。
生命周期钩子是之前提到的混合器/组件冲突解决规则的例外。在 Vue.js 生命周期钩子函数的情况下,对于每个混合器、组件和钩子,钩子函数都是按照混合器的顺序(按添加到组件的顺序)首先执行,组件最后执行。
我们可以在以下示例中看到这一点。让我们创建两个实现mounted
生命周期钩子的混合器,并在组件中实现该钩子。这说明了混合器/组件冲突解决的案例:
<template>
<div ref="zone" />
</template>
<script>
const firstMixin = {
mounted() {
console.log('First mixin mounted hook')
}
}
const secondMixin = {
mounted() {
console.log('Second mixin mounted hook')
}
}
export default {
mixins: [firstMixin, secondMixin],
mounted() {
console.log('Component mounted hook')
}
}
</script>
此组件的浏览器控制台输出(按顺序)将是First mixin mounted hook
、Second mixin mounted hook
和Component mounted hook
,如下所示:
![图 5.3:显示在组件钩子之前执行的混合器中定义的钩子的浏览器控制台输出]
![图片 B15218_05_03.jpg]
图 5.3:显示在组件钩子之前执行的混合器中定义的钩子的浏览器控制台输出
我们所看到的所有示例都直接使用混合器将功能注入到组件中。混合器也可以通过使用Vue.mixin
函数调用全局创建。
例如,我们可以使我们的问候函数成为全局实例方法:
Vue.mixin({
methods: {
$greet(greeting, name) {
return `${greeting} ${name}`
}
}
})
this.$greet
现在将在Vue.mixin
调用之后声明的所有 Vue 实例上可用。然而,此用例最好通过插件来实现。
注意
Vue.js 中用于由 Vue.js 应用程序实例提供的方法(而不是当前组件实例)的约定是$methodName
。
练习 5.01:创建自己的混合器
在这个练习中,我们将创建一个名为 debug
的混入,它将返回传入输入的 JSON 字符串表示形式。debug
执行所谓的漂亮打印,以便我们可以更容易地阅读它。
这在调试 Vue.js 应用程序时也可能很有用,尤其是在 Vue.js DevTools 不可用或不可靠的情况下打印数据。要访问此练习的代码文件,请参阅 packt.live/38ivgFq
。
我们将从一个干净的 Vue CLI 项目开始(这可以通过 vue new exercise5.01
命令创建)。Vue CLI 项目中的应用程序可以通过 npm run serve
启动。
按照以下步骤完成此练习:
-
创建一个新的
src/mixins
文件夹和一个src/mixins/debug.js
文件,我们将在这里定义混入的框架:export default {}
-
混入将添加一个
debug
方法,我们应该在methods
下定义它。debug
方法将接受一个obj
参数,并返回该数据的JSON.stringify
输出。我们将使用JSON.stringify(obj, null, 2)
来输出两空格缩进的漂亮打印 JSON:export default { methods: { debug(obj) { return JSON.stringify(obj, null, 2) } } }
-
我们现在能够从
src/App.vue
导入debug
混入,并在mixins
属性下注册它:<script> import debug from './mixins/debug.js' export default { mixins: [debug], } </script>
-
要查看
debug
方法的实际效果,我们将在src/App.vue
文件中添加一个data
方法和一个created
钩子(从中我们可以打印debug
的输出):<script> // imports export default { // other component properties data() { return { myObj: { some: 'data', other: 'values' } } }, created() { console.log(this.debug(this.myObj)) } } </script>
你应该得到以下输出:
![图 5.4:由于创建钩子而产生的浏览器控制台输出]
![图片 B15218_05_04.jpg]
![图 5.4:由于创建钩子而产生的浏览器控制台输出]
-
debug
也在模板中可用;我们可以在pre
标签中插入其输出,以便尊重空白字符:<template> <div id="app"> <pre>{{ debug(myObj) }}</pre> </div> </template>
应用程序以及此模板将如下所示:
![图 5.5:使用混入的 debug 方法打印 myObj]
![图片 B15218_05_05.jpg]
![图 5.5:使用混入的 debug 方法打印 myObj]
通过这种方式,我们学习了如何使用混入以相当明确的方式(mixins
属性)将共享功能注入到多个组件中。我们还看到了当组件的实现覆盖了混入提供的属性和方法时会发生什么(组件通常获胜)。
我们现在将探讨如何注入实例和全局功能,并通过插件进行分发。
插件
Vue.js 插件是一种向 Vue.js 全局添加自定义功能的方法。插件的良好候选者通常是应用程序的核心,并且被广泛使用。插件候选者的经典例子包括翻译/国际化库(例如 i18n-next
)和 HTTP 客户端(例如 axios
、fetch
和 GraphQL
客户端)。插件初始化器可以访问 Vue
实例,因此它是一个很好的方式来包装全局指令、混入、组件和过滤器定义。
插件可以通过注册指令和过滤器来注入功能。它们还可以添加 global
和 instance
Vue.js 方法,以及定义全局组件混入。
Vue.js 插件是一个暴露 install
方法的对象。install
函数使用 Vue
和 options
调用:
const plugin = {
install(Vue, options) {}
}
在 install
方法中,我们可以注册指令、过滤器、混入,并添加全局和实例属性和方法:
const plugin = {
install(Vue, options) {
Vue.directive('fade', { bind() {} })
Vue.filter('truncate', str => str.slice(0, 140))
Vue.mixin({
data() { return { empty: true } }
})
Vue.globalProperty = 'very-global-value'
Vue.prototype.$myInstanceMethod = function() {}
}
}
插件使用 Vue.use
方法进行注册:
import plugin from './plugin'
Vue.use(plugin)
Vue.use
也可以将选项作为第二个参数传递。这些选项传递给插件:
Vue.use(plugin, { optionProperty: true })
Vue.use
的一个特性是不允许你注册相同的插件两次。这是一个很好的特性,可以避免在尝试多次实例化或安装相同插件时出现的边缘情况行为。
在与 Vue.js 结合使用时,axios
是一个流行的 HTTP 客户端。通常,我们会通过拦截器或 axios
选项来配置 axios
,以实现重试、传递 cookies 或跟随重定向等功能。
可以使用以下命令安装 axios
:npm install –save axios
。
练习 5.02:创建自定义 Axios 插件
为了避免必须添加 import axios from 'axios'
或将我们的自定义 axios
实例包装在 http
或 transport
内部模块下,我们将我们的自定义 axios
实例注入到 Vue 对象和 Vue 组件实例的 Vue.axios
和 this.axios
下。这将使它在我们的应用程序中使用,该应用程序需要使用 axios
作为 HTTP 客户端调用 API,变得更加容易和舒适。要访问此练习的代码文件,请参阅 packt.live/36po08b
。
我们将从一个干净的 Vue CLI 项目开始(可以使用 vue new exercise5.02
命令创建)。Vue CLI 项目中的应用程序可以使用 npm run serve
启动。
按照以下步骤完成此练习:
-
为了正确组织我们的代码,我们将在
src/plugins
中创建一个新的文件夹,并在src/plugins/axios.js
中为我们的axios
插件创建一个新的文件。在新文件中,我们将构建axios
插件:import axios from 'axios' export default { install(Vue, options) {} }
-
现在我们将在
src/main.js
中的 Vue.js 实例上注册我们的axios
插件:// other imports import axiosPlugin from './plugins/axios.js' // other code Vue.use(axiosPlugin) // Vue instantiation code
-
我们现在将通过以下命令使用
npm
安装axios
。这将允许我们导入axios
并通过插件在 Vue 中暴露它:npm install --save axios
-
现在我们将在
src/plugins/axios.js
中将axios
添加到 Vue 作为全局属性:import axios from 'axios' export default { install(Vue) { Vue.axios = axios } }
-
axios
现在在Vue
中可用。在src/App.vue
中,我们可以向一个 API 发送请求,该 API 将填充todos
列表:<template> <div id="app"> <div v-for="todo in todos" :key="todo.id"> <ul> <li>Title: {{ todo.title }}</li> <li>Status: {{ todo.completed ? "Completed" : "Not Completed" }}</li> </ul> </div> </div> </template> <script> import Vue from 'vue' export default { async mounted() { const { data: todos } = await Vue.axios('https://jsonplaceholder.typicode.com/todos') this.todos = todos }, data() { return { todos: [] } } } </script>
下面的输出是预期的:
图 5.6:全局 Vue.axios todo 显示示例
-
在我们的情况下,必须添加
import Vue from 'vue'
有点奇怪。通过插件注入axios
的全部目的是为了消除import
模板。更好的方法是通过对组件实例进行暴露;即this.axios
。为此,我们需要更新src/plugins/axios.js
文件中的安装步骤,并将axios
添加到Vue.prototype
,这样任何new Vue()
组件都将将其作为属性:// imports export default { install(Vue, options) { // other plugin code Vue.prototype.axios = axios } }
-
我们现在可以删除
import Vue from 'vue'
行,并在src/App.vue
中通过this.axios
访问axios
:<script> export default { async mounted() { const { data: todos } = await this.axios('https://jsonplaceholder.typicode.com/todos') this.todos = todos }, data() { return { todos: [] } } } </script>
以下为输出结果:
![图 5.7:Vue 实例 axios todo 显示示例
图 5.7:Vue 实例 axios todo 显示示例
有了这个,我们已经使用插件注入了全局和实例级别的属性和方法,以及学习了如何以易于分发的方式使用它们来创建指令和其他 Vue 构造。
现在,我们将探讨如何在代码库中全局注册组件,以帮助减少高使用频率组件的样板代码。
全局注册组件
使用插件的一个原因是减少所有 Vue 应用程序文件中的样板代码,通过删除 导入
并用对 this
和/或 Vue
的访问来替换它们。
Vue.js 组件通常在单个文件组件中定义,并显式导入。出于与定义全局方法和属性相同的原因,我们可能希望全局注册组件。这将允许我们在所有其他组件模板中使用这些组件,而无需导入它们并在 components
属性下注册它们。
这种情况在使用设计系统或组件在代码库中跨模块使用时非常有用。
全局注册组件有助于某些类型的更新,例如,如果文件名未暴露给消费者,那么在更改文件名时,只有一个路径需要更新,而不是每个用户一个。
假设我们在 CustomButton.vue
文件中有一个 CustomButton
组件,其外观如下:
<template>
<button @click="$emit('click', $event)">
<slot />
</button>
</template>
我们可以将 CustomButton
全局注册如下(这通常在 main.js
文件中完成):
// other imports
import CustomButton from './components/CustomButton.vue'
Vue.component('CustomButton', CustomButton)
// other global instance setup
我们现在可以在 App.vue
文件中使用它,而无需本地注册或导入:
<template>
<div>
<CustomButton>Click Me</CustomButton>
</div>
</template>
这将按预期渲染,按钮名为“点击我”:
![图 5.8:CustomButton 渲染与“点击我”按钮
图 5.8:使用“点击我”按钮的 CustomButton 渲染
通过这样,我们已经探讨了如何在代码库中频繁使用组件时,全局注册组件可以减少样板代码。
接下来,我们将探讨如何在 Vue.js 中提高组件的灵活性的一些技巧。
最大化组件灵活性
Vue.js 组件接受 props 和 slots 作为输入;它们的输出以 HTML 渲染,并发出事件。
为了最大化组件的灵活性,始终利用插槽和 props 是很有意义的。
精确利用 props 和默认值意味着组件可以被重用和扩展。例如,我们可以在组件中不硬编码值,而是将其设置为默认 prop。在这种情况下,date
默认为当前日期,new Date()
。然后我们使用计算属性提取纪元:
<template>
<div>Date as epoch: {{ epoch }}</div>
</template>
<script>
export default {
props: {
date: {
type: Date,
default() {
return new Date()
}
}
},
computed: {
epoch() {
return Number(this.date)
}
}
}
</script>
当注册并使用时,渲染如下:
Date as epoch: 1574289255348
插槽可以被视为组件将渲染委托回其消费者的一种方式。将模板的部分委托给父组件有助于可重用性。
使用插槽来最大化重用性的一个特定例子是无渲染组件模式。例如,在时代显示示例中,我们可以利用作用域插槽并从组件中移除任何渲染逻辑:
<template>
<div>
<slot :epoch="epoch" />
</div>
</template>
在父组件中,可以使用作用域插槽来定义渲染:
<template>
<div>
<Epoch>
<template v-slot:default="{ epoch }">
Epoch as rendered with parent template {{ epoch }}
</template>
</Epoch>
</div>
</template>
这意味着组件的委托被委托给了父组件,并显示以下内容:
Epoch as rendered with parent template 1574289270190
下一组实践通过使它们的 API 可预测来最大化组件的重用。在许多方面,前向属性、利用style
和class
属性合并以及实现v-model
接口是使 Vue.js 自定义组件表现得更像 HTML 元素的一种方式。
前向属性可能很有趣。例如,一个CustomInput
组件(在CustomInput.vue
文件中)可能需要传递type
属性,以及required
属性:
<template>
<input v-bind="$attrs">
</template>
CustomInput
组件可以用来渲染任何类型的组件(src/App.vue
):
<template>
<div id="app">
<fieldset>
<label for="textinput">
Text Input
</label>
<CustomInput
type="text"
name="textinput"
id="textinput"
/>
</fieldset>
<fieldset>
<label for="dateinput">
Date Input
</label>
<CustomInput
type="date"
name="dateinput"
id="dateinput"
/>
</fieldset>
</div>
</template>
<script>
import CustomInput from './components/CustomInput.vue'
export default {
components: {
CustomInput
}
}
</script>
这正确渲染了文本和日期输入:
![图 5.9:具有文本和日期类型的 CustomInput
图 5.9:具有文本和日期类型的 CustomInput
Vue.js 在类/内联样式方面做了很多繁重的工作,因为它将组件上定义的style
和class
对象与该组件根元素的style
和class
对象合并。根据文档,“类和样式属性有点智能,所以两个值都会合并” (Vue.js 组件属性指南:vuejs.org/v2/guide/components-props.html#Replacing-Merging-with-Existing-Attributes
)。
在 Vue.js 中,输入元素和组件倾向于通过v-model
进行双向响应式绑定,v-model
是使用v-bind:value
和v-on:input
来提供值并保持与子组件或元素的输出同步的简写。
传递的value
仅用作起始值;当输入被捕获完成时(例如,完成输入)会发出input
事件。
如果组件实现了v-model
形状,它可以直接替换表单元素。
例如,实现了v-model
接口的TextInput
可以与input
和textarea
互换使用:
<template>
<div>
<textarea
v-if="type === 'long'"
:value="value"
@input="$emit('input', $event.target.value)"
>
</textarea>
<input
v-else
:value="value"
@input="$emit('input', $event.target.value)"
type="text"
/>
</div>
</template>
<script>
export default {
props: ['value', 'type']
}
</script>
这可以在src/App.vue
中如下使用:
<template>
<div id="app">
<label>Short Text: {{ shortText }}</label>
<TextInput v-model="shortText" type="short" />
<label>Long Text: {{ longText }}</label>
<TextInput v-model="longText" type="long" />
</div>
</template>
<script>
import TextInput from './components/TextInput.vue'
export default {
components: {
TextInput
},
data() {
return {
shortText: '',
longText: ''
}
}
}
</script>
应用程序渲染如下:
![图 5.10:实现 v-model 的自定义组件
图 5.10:实现 v-model 的自定义组件
这样,我们就已经探讨了如何利用 props 和 slots、继承属性以及实现已知的 Vue.js 接口来帮助最大化组件的灵活性。
下一节将专门介绍如何通过学习在不使用.vue
文件的情况下使用 Vue.js 组件来加深我们对 Vue.js 组件的理解。
使用 Vue.js 组件而不使用.vue 单文件组件
我们所看到的 Vue.js 组件的大部分示例都利用了 .vue
单文件组件。
这不是定义 Vue.js 组件的唯一方法。在本节中,我们将探讨四种不同的方法来定义 Vue.js 组件,而不使用 .vue
文件。
评估这些选项将帮助我们理解 Vue.js 组件的核心是什么。
使用字符串模板的运行时定义
组件可以使用接受字符串值的 template
属性。这通常被称为 字符串模板。此模板在运行时(在浏览器中)被评估。
我们可以在 StringTemplate.js
文件中定义一个组件,通过定义一个具有 template
属性的对象:
export default {
template: `<div>String Template Component</div>`
}
然后,可以从 App.vue
文件中消费它,如下所示:
<template>
<div id="app">
<StringTemplate />
</div>
</template>
<script>
import StringTemplate from './components/StringTemplate.js'
export default {
components: {
StringTemplate
}
}
</script>
不幸的是,这会在加载时崩溃,并在控制台显示以下 Vue 警告:
图 5.11:Vue 运行时编译器缺失警告
根据 Vue 警告,为了使此组件在导入时工作,我们需要在运行时构建中包含 Vue.js 编译器。为此,在 Vue CLI 项目中,我们可以在 vue.config.js
(Vue CLI 配置文件)中将 runtimeCompiler
选项设置为 true
。
您的 vue.config.js
应该看起来像以下这样:
module.exports = {
runtimeCompiler: true
};
设置此选项并重新启动开发服务器后,来自 StringTemplate
组件的消息会出现在浏览器中:
String Template Component
可以使用 .vue
组件对象定义属性和其他组件实例属性。
渲染函数
Vue.js 单文件组件的 template
部分在构建时被编译成一个 render
函数。
render
函数通常用于 Vue CLI 项目的 main.js
文件中 - 特别是 new Vue()
调用:
new Vue({
render: h => h(App),
}).$mount('#app')
一个 render
函数接受一个 createElement
参数,并返回一个虚拟 DOM 节点。这是通过调用 createElement
函数(在上面的示例中,这是 h
)来完成的。
h
由于其紧凑性,常被用作 createElement
的缩写。
我们可以在 JavaScript 文件(RenderFunction.js
)中定义一个具有 render
属性的组件,如下所示:
export default {
render(createElement) {
return createElement(
'h2',
'Render Function Component'
)
}
}
这可以在 App.vue
文件中这样呈现:
<template>
<div id="app">
<RenderFunction />
</div>
</template>
<script>
import RenderFunction from './components/RenderFunction.js'
export default {
components: {
RenderFunction
}
}
</script>
此组件在浏览器中显示一个 h2
,内容为 渲染函数组件:
Render Function Component
除了在非 .vue
文件中编写组件外,render
函数对于高度动态的组件也很有用。
JSX
JSX 由 React 推广。根据 React 文档,JSX 是 JavaScript 的语法扩展。我们建议与 React 一起使用它来描述 UI 应该是什么样子 (reactjs.org/docs/introducing-jsx.html
)。JSX 是 JavaScript 的超集,允许使用花括号进行 HTML-style
标签和插值。
与 Vue.js 一样,React 不会将 JSX 渲染到 DOM 中。与 Vue.js 模板类似,React 应用程序构建工具将 JSX 编译为在运行时使用的 render
函数,以便它们可以渲染到虚拟 DOM。然后虚拟 DOM 与真实 DOM 进行 reconcile(同步)。
JSX 编译为 render
函数,Vue.js 支持 render
函数的组件定义。此外,Vue CLI 3+ 默认编译 JSX。
这意味着我们可以编写以下内容,这与 RenderFunction
组件、JSXRender.js
文件等效:
export default {
render() {
return <h2>JSX Render Function Component</h2>
}
}
没有 JSX 的等效 render
函数如下所示(基于上一节中的示例):
export default {
render(createElement) {
return createElement(
'h2',
'JSX Render Function Component'
)
}
}
以下 App.vue
文件将 JSXRender
渲染到浏览器中:
<template>
<div id="app">
<JSXRender />
</div>
</template>
<script>
import JSXRender from './components/JSXRender.js'
export default {
components: {
JSXRender
}
}
</script>
现在,我们可以在屏幕上看到 JSXRender
中的 h2
,内容符合预期:
JSX Render Function Component
通过这些,我们已经了解到 Vue.js 组件只是具有 render
或 template
函数的对象。.vue
组件的 template
部分在构建时编译为 render
函数,这意味着要使用字符串模板,我们需要在应用程序运行时包含 Vue.js 编译器。我们还学习了如何使用 render
函数以及 JSX 来定义组件,并指出了 React 和 Vue.js 在实现方面的一些共同点。在决定使用 JSX 或 render
函数时,JSX 可以更容易阅读,同时具有 render
函数的完整灵活性(而常规模板并不总是具备)。
我们现在将探讨如何使用 Vue.js 的 component
标签从运行时数据动态渲染组件。
Vue 组件标签
JSX 和 render
函数非常适合需要非常动态渲染的组件的情况。
在常规 Vue.js 模板中实现这一点的办法是使用 component
标签。
component
标签使用 is
属性来动态选择将渲染哪个组件。
要渲染一个动态组件,我们使用一个带有绑定 is
属性的 component
标签(在这里,我们使用缩写 :is
,它等同于 v-bind:is
):
<component :is="componentName" />
现在,我们将学习如何使用名称或组件引用渲染动态组件。
通过名称或组件引用渲染动态组件
假设我们有一个网格,其中包含可以切换显示为卡片显示(一个包含图像和文本的设计元素)或仅图像视图的项目。
首先,我们需要导入相关组件并将它们注册为组件。我们还将设置一些固定数据以循环网格:
<template>
<div id="app">
<div class="grid">
<component
class="grid-item"
v-for="item in items"
:key="item.id"
/>
</div>
</div>
</template>
<script>
import Card from './components/Card.vue';
import ImageEntry from './components/ImageEntry.vue';
export default {
components: {
Card,
ImageEntry
},
data() {
return {
items: [
{
id: '10',
title: 'Forest Shot',
url: 'https://picsum.photos/id/10/1000/750.jpg',
},
{
id: '1000',
title: 'Cold cross',
url: 'https://picsum.photos/id/1000/1000/750.jpg',
},
{
id: '1002',
title: 'NASA shot',
url: 'https://picsum.photos/id/1002/1000/750.jpg',
},
{
id: '866',
title: 'Peak',
url: 'https://picsum.photos/id/866/1000/750.jpg'
},
]
}
}
}
</script>
我们可以按名称引用组件——即 card
和 image-entry
——并将 itemComponent
设置为 is
的值:
<template>
<!-- rest of template -->
<component
:is="itemComponent"
class="grid-item"
v-for="item in items"
:key="item.id"
/>
<!-- rest of template -->
</template>
<script>
// rest of script
export default {
// other component properties
data() {
return {
itemComponent: 'card',
// other data properties eg. `items`
}
}
}
</script>
在这种情况下,Card
组件将被渲染,因为我们传递了其小写名称(card
)给 component
标签。
如果我们将 itemComponent
改为 image-entry
,则 ImageEntry
组件将被渲染。此切换可以使用 v-model
如下进行:
<template>
<!-- rest of template -->
Display mode:
<input
type="radio"
name="style"
value="card"
v-model="itemComponent"
id="card-radio"
/>
<label for="card-radio">Card</label>
<input
type="radio"
name="style"
value="image-entry"
v-model="itemComponent"
id="image-radio"
/>
<label for="image-radio">Image</label>
<!-- rest of template -->
</template>
我们还可以使用组件引用本身(而不是名称)将组件传递给 is
。例如,我们可以将 itemComponent
设置为 Card
:
<script>
// rest of script
export default {
// other component properties
data() {
return {
itemComponent: Card,
// other data properties eg. `items`
}
}
}
</script>
在这种情况下,在卡片视图和图像视图之间切换会更困难,因为我们需要使用组件引用而不是使用名称。
我们可以将属性传递给使用 component
动态渲染的组件,就像我们使用 v-bind:prop-name
或 :prop-name
简写传递常规属性一样:
<template>
<!-- rest of template -->
<component
class="grid-item"
v-for="item in items"
:key="item.id"
:is="itemComponent"
:url="item.url"
:title="item.title"
/>
<!-- rest of template -->
</template>
给定以下 Card
和 ImageEntry
组件,我们得到一个具有可切换视图的网格项的应用程序。
Card.vue
渲染图像和标题,并具有最大宽度 150px
:
<template>
<div class="card">
<img :src="img/url" width="100%" />
<h3>{{ title }}</h3>
</div>
</template>
<script>
export default {
props: {
url: String,
title: String
}
}
</script>
<style scoped>
.card {
margin: 10px;
max-width: 150px;
}
h3 {
font-weight: normal;
}
</style>
您的输出将显示卡片视图中的条目,如下所示:
![图 5.12:在卡片视图中渲染网格条目]
![图片 B15218_05_12.jpg]
图 5.12:在卡片视图中渲染网格条目
使用 ImageEntry.vue
以卡片视图的两倍宽度渲染图像:
<template>
<img class="image" :src="img/url" />
</template>
<script>
export default {
props: {
url: String
}
}
</script>
<style scoped>
.image {
margin: 20px;
max-width: 300px;
}
</style>
您现在将看到图像视图中的条目,如下面的截图所示:
![图 5.13:在图像视图中渲染网格条目]
![图片 B15218_05_13.jpg]
图 5.13:在图像视图中渲染网格条目
component
标签的一个注意事项是,当它不再显示时,渲染的动态组件会被完全销毁。在这个例子中,正在渲染的动态组件没有任何状态,所以这种销毁不会引起任何问题。
我们现在将学习如何缓存动态组件状态。
使用 Keep-Alive 缓存动态组件状态
通过 component
标签动态渲染的组件可以有状态,例如在多部分表单中,下一页有 name
字段和 address
字段。
让我们使用 component
标签来实现这一点,如下所示:
<template>
<div id="app">
<component
:is="activeStep"
@next="activeStep = 'second-step'"
@back="activeStep = 'first-step'"
/>
</div>
</template>
<script>
import FirstStep from './components/FirstStep.vue'
import SecondStep from './components/SecondStep.vue'
export default {
components: {
FirstStep,
SecondStep
},
data() {
return {
activeStep: 'first-step',
}
}
}
</script>
通过这样做,我们可以在 Name
字段中输入数据:
![图 5.14:在名称字段中输入我的名字]
![图片 B15218_05_14.jpg]
图 5.14:在名称字段中输入我的名字
如果我们使用 Next
导航到表单的地址部分,然后使用 Back
,名称将会消失,如下面的截图所示:
![图 5.15:在地址步骤中点击 Next 然后 Back 后的空名称字段]
![图片 B15218_05_15.jpg]
图 5.15:在地址步骤中点击 Next 然后 Back 后的空名称字段
这是因为组件在不是当前渲染的动态组件时会被销毁(摧毁)。
为了解决这个问题,我们可以在 component
标签周围使用 keep-alive
元素:
<template>
<!-- rest of template -->
<keep-alive>
<component
:is="activeStep"
@next="activeStep = 'second-step'"
@back="activeStep = 'first-step'"
/>
</keep-alive>
<!-- rest of template -->
</template>
以这种方式,填写名称并从表单的地址部分返回将显示以下内容:
![图 5.16:导航后,我的名字仍然是名称字段中的值]
![图片 B15218_05_16.jpg]
图 5.16:导航后,我的名字仍然是名称字段中的值
通过这种方式,我们已经学习了如何使用 component
标签来表示一个区域,在这个区域内我们可以根据字符串或组件本身(如导入的)动态显示组件。我们还探讨了如何解决 component
的主要问题;即如何使用 keep-alive
在 component
标签中不是正在积极使用的组件时保持组件状态。
练习 5.03:使用 component 标签创建动态卡片布局
现代应用程序布局是一个带有卡片网格。Card
布局的好处是适合移动、桌面和平板显示器。在这个练习中,我们将创建一个具有三种不同模式和选择其中之一的方式的动态 card
布局。此布局将允许用户选择屏幕上显示多少信息以适应他们的偏好。
Rich
视图将显示一个项目的所有详细信息,包括图片、标题和描述。
Compressed
视图将显示所有详细信息,但不显示图片预览。
List
视图将仅显示标题,应采用垂直布局。
每个 card
视图都将作为一个单独的组件实现,然后使用 component
标签动态渲染。要访问此练习的代码文件,请参阅 packt.live/3mYYvkq
。
按照以下步骤完成此练习:
-
在
src/components/Rich.vue
中创建丰富的布局。它包含三个属性:url
(图片 URL)、title
和description
,分别渲染图片、标题和描述:<template> <div class="card"> <img :src="img/url" width="100%" /> <h3>{{ title }}</h3> <p>{{ description }}</p> </div> </template> <script> export default { props: ['url', 'title', 'description'] } </script> <style scoped> .card { display: flex; flex-direction: column; max-width: 200px; } h3 { font-weight: normal; margin-bottom: 0; padding-bottom: 0; } </style>
-
使用一些固定数据设置
src/App.vue
:<template> <div id="app"> </div> </template> <script> export default { data() { return { items: [ { id: '10', title: 'Forest Shot', description: 'Recent shot of a forest overlooking a lake', url: 'https://picsum.photos/id/10/1000/750.jpg', }, { id: '1000', title: 'Cold cross', description: 'Mountaintop cross with snowfall from Jan 2018', url: 'https://picsum.photos/id/1000/1000/750.jpg', }, ] } } } </script>
-
将
Rich
视图组件导入到src/App.vue
并本地注册:<script> import Rich from './components/Rich.vue' export default { components: { Rich }, // other component properties, eg. "data" } </script>
-
一旦我们获得了
Rich
视图组件,将其连接到src/App.vue
应用程序中,使用component
渲染它,并通过相关属性传递:<template> <!-- rest of template --> <component v-for="item in items" :key="item.id" :is="layout" :title="item.title" :description="item.description" :url="item.url" /> <!-- rest of template> </template> <script> export default { // other component properties data() { return { layout: 'rich', // other data definitions eg. `items` } } } </script>
-
这是一个添加一些样式使网格看起来像网格的好地方:
<template> <!-- rest of template --> <div class="grid"> <component v-for="item in items" :key="item.id" :is="layout" :title="item.title" :description="item.description" :url="item.url" /> </div> <!-- rest of template --> </template> <style scoped> .grid { display: flex; } </style>
这将显示以下输出:
![图 5.17:动态渲染丰富的组件]
图 5.17:动态渲染丰富的组件
-
现在,实现
Compressed
视图,它只是Rich
视图,但在Compressed.vue
文件中没有图片:<template> <div class="card"> <h3>{{ title }}</h3> <p>{{ description }}</p> </div> </template> <script> export default { props: ['title', 'description'] } </script> <style scoped> .card { display: flex; flex-direction: column; max-width: 200px; } h3 { font-weight: normal; padding-bottom: 0; } p { margin: 0; } </style>
-
在
src/App.vue
中导入并注册Compressed
组件:<script> // other imports import Compressed from './components/Compressed.vue' export default { components: { Rich, Compressed, }, // other component properties }
-
添加一个
select
来在视图之间切换。它将有两个选项,值为rich
和compressed
,并使用v-model
绑定到layout
:<template> <!-- rest of template --> Layout: <select v-model="layout"> <option value="rich">Rich</option> <option value="compressed">Compressed</option> </select> <!-- rest of template --> </template>
使用此
select
,我们可以切换到compressed
布局,其外观如下:![图 5.18:打开选择器的压缩布局]
图 5.18:打开选择器的压缩布局
-
将
List
布局添加到src/components/List.vue
。list
视图是压缩视图,但没有描述:<template> <h3>{{ title }}</h3> </template> <script> export default { props: ['title'] } </script> <style scoped> h3 { width: 100%; font-weight: normal; } </style>
-
将
List
组件导入到src/App.vue
并本地注册:<script> // other imports import List from './components/List.vue' export default { components: { Rich, Compressed, List }, // other component properties }
-
添加一个额外的选项
value="list"
以切换到List
布局:<template> <!-- rest of template --> Layout: <select v-model="layout"> <option value="rich">Rich</option> <option value="compressed">Compressed</option> <option value="list">List</option> </select> <!-- rest of template --> </template>
当切换到
list
布局时,项目将按如下方式显示为水平行:![图 5.19:水平堆叠错误的列表视图
图 5.19:水平堆叠错误的列表视图
-
要修复这种水平堆叠,创建一个新的
grid-column
类,将其设置为flex-direction: column
(与默认的row
相对)并在布局为list
时条件性地应用它:<template> <!-- rest of template --> <div class="grid" :class="{ 'grid-column': layout === 'list' }"> <!-- grid using component tag --> </div> <!-- rest of template --> </template> <style scoped> /* existing rules */ .grid-column { flex-direction: column; } </style>
我们的
List
布局现在看起来如下:![图 5.20:垂直堆叠的列表视图
图 5.20:垂直堆叠的列表视图
通过这样,我们已经学习了如何使用component
标签动态渲染不同组件,无论是通过名称还是使用组件对象本身。我们还探讨了有状态动态组件的陷阱,即组件不再显示时的组件销毁以及如何使用keep-alive
元素来规避这些问题。
我们现在将探讨如何仅使用render
函数或template
标签使用功能组件来实现简单组件。
功能组件
功能组件是常规 Vue.js 组件的子集。它们没有状态或组件实例。它们可以被视为render
函数(如本章前面所示),其中传递了属性。
注意
我们可以将组件标记为功能性的,这意味着它们是无状态的(没有响应式数据)和实例化的(没有this
上下文)。
更多信息请参阅 Vue.js 文档(vuejs.org/v2/guide/render-function.html#Functional-Components
)。
功能组件只能访问从父组件传递过来的属性、子组件、插槽和作用域插槽,它们还接收对父组件和监听器的引用。
以下是一个Greet
组件(在Greet.vue
文件中)。注意template
中的functional
注解:
<template functional>
<div>Functional Component: {{ props.greeting }} {{ props.audience }}</div>
</template>
功能组件必须通过props.propName
访问属性。功能组件也可以通过functional: true
布尔值表示,并使用render
函数:
export default {
functional: true,
render(h, context) {
return h(
'h2',
`Functional Render: ${context.props.greeting} ${context.props.audience}`
)
}
}
我们可以在App.vue
文件中使用这两个功能组件:
<template>
<div id="app">
<Greet greeting="Hello" audience="World" />
<GreetRender greeting="Hello" audience="World" />
</div>
</template>
<script>
import Greet from './components/Greet.vue'
import GreetRender from './components/GreetRender.js'
export default {
components: {
Greet,
GreetRender
}
}
</script>
这将在浏览器中渲染以下内容:
![图 5.21:功能组件渲染
图 5.21:功能组件渲染
功能组件是封装仅渲染功能的绝佳方式;也就是说,它们从属性中获取模板。由于它们没有关联的响应式状态或组件实例,因此它们比常规组件有更好的性能表现。
我们已经为非功能组件覆盖了一个常见用例,即发出事件,可以如下操作:
<template>
<input
type="submit"
@click="$emit('click', $event)"
/>
</template>
要使用功能组件发出事件,我们可以将元素绑定到listeners
对象中的属性。
要将所有事件委派给子组件,我们可以使用v-on="listeners"
:
<template functional>
<input
v-on="listeners"
v-bind="data.attrs"
/>
</template>
要绑定一个特定的监听器,我们可以使用 v-on:eventName="listeners.listenerName"
,其中 listenerName
是功能组件的父组件绑定的监听器:
<template functional>
<input
type="submit"
v-on:click="listeners.click"
v-bind="data.attrs"
/>
</template>
注意
绑定到一个不存在的监听器属性将导致错误。为了避免这种情况,我们可以使用 listeners.listenerName || (() => {})
表达式。
通过这样,我们已经学习了如何使用功能组件以及 .vue
组件的 template
变体和 render
函数来实现常见的 Vue.js 组件模式。
现在,我们将构建一个使用本章中我们查看的所有模式的待办事项应用程序。
活动五.01:使用插件和可重用组件构建 Vue.js 应用程序
在这个活动中,我们将构建一个 jsonplaceholder
作为数据源。
我们的待办事项应用程序将加载待办事项并将它们显示为列表。它将根据待办事项是否已完成显示复选框,以及待办事项的名称。
当勾选待办事项时,应用程序将同步到 API。
我们将作为插件注入 axios
以查询 jsonplaceholder.typicode.com
。
按照以下步骤完成此活动:
-
将
axios
安装到项目中。 -
要将
axios
注入为this
组件实例的属性,创建一个src/plugins/axios.js
插件文件,在install
时,这意味着组件实例将有一个axios
属性。 -
为了使插件工作,请在
src/main.js
中导入并注册它。 -
我们还希望将我们的 API 的
baseUrl
注入到所有组件中。我们将创建一个内联的src/main.js
文件插件来完成此操作。 -
现在,我们想要从我们的
src/App.vue
中获取所有待办事项。一个好的地方是在mounted
生命周期方法中做这件事。 -
要显示待办事项列表,我们将在
src/components/TodoList.vue
中创建一个TodoList
功能组件,它接受一个todos
属性,遍历项目,并在todo
作用域插槽中延迟渲染待办事项,该插槽绑定待办事项。 -
我们现在可以使用
TodoList
组件在src/App.vue
中渲染我们已获取的待办事项。 -
现在,我们需要创建一个
TodoEntry
组件,我们将在这里实现大部分待办事项特定的逻辑。对于组件来说,一个好的做法是让属性非常具体于组件的角色。在这种情况下,我们将处理的todo
对象的属性是id
、title
和completed
,因此这些应该是我们的TodoEntry
组件接收的属性。我们不会将TodoEntry
制作成功能组件,因为我们需要组件实例来创建 HTTP 请求。 -
然后,我们将更新
src/App.vue
,使其消费TodoEntry
(确保绑定id
、title
和completed
)。 -
添加切换
todo
的功能。我们将大部分实现放在src/components/TodoEntry.vue
中。我们将监听input
变更事件。在变更时,我们将读取新值并向/todos/{todoId}
发送一个包含completed
设置为新值的PATCH
请求。我们还将想要在 Vue.js 中发出一个completedChange
事件,以便App
组件可以更新内存中的数据。 -
在
App.vue
中,当触发completeChange
时,我们希望更新相关的todo
。由于completeChange
不包括todo
的 ID,我们需要在设置handleCompleteChange
函数以监听completeChange
时从上下文中读取该 ID。
预期输出如下:
![图 5.22:使用 jsonplaceholder 数据的待办事项应用]
图 5.22:使用 jsonplaceholder 数据的待办事项应用
注意
这个活动的解决方案可以通过这个链接找到。
摘要
在本章中,我们探讨了全局组合模式和高级组件设置,这些可以在 Vue.js 应用程序中减少重复。
首先,我们学习了混合(mixins),它明确地共享功能,同时让组件有最后的决定权,并看到了这个规则的例外情况。然后,我们看到了插件是如何成为深入多个 Vue.js 原语的一个很好的钩子。
接下来,我们探讨了如何通过规定模式在 Vue.js 中最大化组件的可重用性。例如,利用 props 来委派数据、slots 来委派模板,以及实现允许组件使用 Vue-idiomatic 简写(如 v-model
)的接口。
我们还深入探讨了 Vue.js 组件是什么,而不仅仅是 .vue
文件。我们通过引入字符串模板、render
函数和 JSX,以及这些方法各自的工作要求,来深入了解 Vue.js 组件。最后,我们看到了功能组件如何巩固我们使用 .vue
文件定义组件的方法。
到目前为止,我们已经学习了如何从组件、混合和插件的角度构建应用程序。要构建跨越多个页面的应用程序,我们需要实现路由。这就是我们在下一章将要解决的问题。
第六章:6. 路由
概述
在本章中,我们将了解路由和 Vue Router 是如何工作的。我们还将使用 Vue Router 在我们的应用中设置、实现和管理路由系统。然后我们将探讨动态路由以传递参数值,以及嵌套路由以在复杂应用中提高复用性。此外,我们还将探讨 JavaScript 钩子,这些钩子可用于认证和错误处理等功能。到本章结束时,您将准备好在任何 Vue 应用中处理静态和动态路由。
简介
在他们的 URL 栏中输入website.com/about
,他们将被路由到关于页面。
在单页应用(SPAs)中,路由允许在应用内部进行平滑导航,无需刷新页面。在 Web 开发中,路由是我们决定如何将 HTTP 请求连接到处理它们的代码的匹配机制。当我们的应用需要 URL 导航时,我们使用路由。大多数现代 Web 应用包含大量不同的 URL,即使是单页应用。因此,路由在创建导航系统方面发挥着重要作用,并帮助用户快速在我们的应用和网络上移动。
简而言之,路由是应用根据提供的 URL 解释用户想要什么资源的一种方式。它是一个基于 URL 的 Web 资源导航系统,例如资产(图像和视频)、脚本和样式的路径。
Vue Router
根据 Vue.js 文档所述,Vue Router 被官方推荐为任何 Vue.js 应用的路由服务。它提供了一个组件间通过路由进行通信的单个入口点,因此可以有效地控制应用流程,无论用户的行为如何。
通过丰富的功能,它简化了页面切换的过程,无需刷新页面。
设置 Vue Router
Vue Router 默认未安装;然而,当使用 Vue CLI 创建应用时,它很容易被启用。通过运行以下命令创建应用:
vue create <your-project-name>
选择如图 6.1 所示的手动选择功能
选项:
图 6.1:选择手动预设以创建新的 Vue.js 项目
在选择手动选择功能选项后,您将看到如图 6.2 所示的特性列表。在撰写本文时,默认选中了Babel
和Linter / Formatter
。使用下箭头键,导航到Router
选项。当选项高亮时,按空格键启用它,然后按Enter 键继续。
图 6.2:将 Vue Router 添加到项目中
接下来,你将看到一个提示,询问你是否想为路由配置使用history mode
,如图 6.3 所示。通过输入Y
启用历史模式。历史模式允许在不需要默认 hash 模式重新加载的情况下在页面之间导航。我们将在本章稍后更详细地比较这两种模式:
图 6.3:使用历史模式配置 Vue Router
最后,继续进行其余的过程,我们将拥有一个准备就绪的 Vue.js 应用,其中包含 Vue Router。
注意
如果你想要将 Vue Router 添加到现有的 Vue.js 应用中,你可以使用以下命令将其作为应用依赖项安装:
npm install vue-router
下一步是理解vue-router
如何实现浏览器 URL 与应用视图之间的同步的基本原理。
首先,让我们看看router-view
元素。
路由视图元素
router-view
元素是一个功能性组件,其中应用的路由系统加载用户接收到的任何给定 URL 路径的匹配和最新视图内容。
简而言之,router-view
是一个 Vue 组件,其任务是执行以下操作:
-
渲染不同的子组件
-
自动在任意嵌套级别挂载和卸载,取决于给定的路由路径
没有使用router-view
,在运行时几乎不可能正确渲染动态内容。例如,当用户导航到Home
页面时,router-view
会知道并只渲染与该页面相关的内。
在下一节中,我们将看到如何通过传递一个 prop 来设置应用的入口点(默认路由)。
使用 Props 定义应用的入口点
由于router-view
是一个组件,它也可以接收 props。它接收的唯一 prop 是name
,这是在初始化阶段在router
对象中定义的相应路由记录中注册的相同名称。
任何其他额外的属性都会在渲染过程中直接传递给router-view
的子组件。以下是一个带有 class 属性的示例:
<router-view class="main-app-view"/>
如果router-view
作为子组件渲染,我们可以在关联的模板中定义布局。一个非常简单的模板示例如下:
<template>
<div>Hello World</div>
</template>
子组件接收传递的 class 属性,渲染后的实际输出如下:
<div class="main-app-view">Hello World</div>
当然,为了使我们的模板有用,它也应该包含<router-view/>
元素,这样我们想要路由的内容就有地方渲染。一个常见的设置是在模板中有一个导航菜单,下面是router-view
。这样,内容在页面之间变化,但菜单保持不变。
导航到App.vue
并确保你的模板中有以下代码:
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</div>
</template>
让我们移除<div id="app">
内的所有代码,只留下一个单独的<router-view/>
组件:
<div id="app">
<router-view/>
</div>
我们现在将注释掉所有routes
的代码,如下所示:
const routes = [
// {
// path: '/',
// name: 'Home',
// component: Home
// },
// {
// path: '/about',
// name: 'About',
// // route level code-splitting
// // this generates a separate chunk (about.[hash].js) for this route
// // which is lazy-loaded when the route is visited.
// component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
// }
]
现在我们应用的输出将渲染为在 localhost:8080
运行的空白页面,如图 6.4 所示:
![Figure 6.4:浏览器中的 Hello Vue Router 应用程序]
![img/B15218_06_04.jpg]
图 6.4:浏览器中的 Hello Vue Router 应用程序
输出是一个空白页面,因为我们没有在我们的文件中设置任何路由配置,包括将路径与相关视图进行映射。没有这一步,路由系统将无法选择正确的视图并将其动态渲染到我们的 router-view
元素中。
在下一节中,我们将看到如何设置 Vue Router。
为 Vue 设置 Vue Router
当我们将 Vue Router 添加到我们的项目中时,Vue CLI 会创建并添加一个 router
文件夹到代码目录中,其中包含一个单独自动生成的 index.js
文件。此文件包含我们路由所需的所有配置。
我们将导航到该文件,并查看 Vue Router 的基本预定义配置。
首先,你会注意到我们需要分别从 vue
和 vue-router
包中导入 Vue
和 VueRouter
。然后我们调用 Vue.use (VueRouter)
来将其作为插件安装到我们的应用程序中:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
Vue.use
是一个全局方法,如第五章 全局组件组合 中所述。它触发 VueRouter
的内部 install
方法,以及 Vue 构造函数,一旦 Vue 成为应用程序的全局变量。此方法具有内置机制以防止插件安装超过一次。
执行 Vue.use(VueRouter)
后,以下对象在任意组件中都可以访问:
-
this.$router
– 全局路由对象 -
this.$route
– 当前路由对象
this
指的是上下文中的组件。
现在我们已经在应用程序中注册了 Vue Router 的使用,接下来进行下一步——定义路由实例的配置对象的路由。
定义路由
在一个 Web 应用程序中,一个 route
是一个 URL 路径模式,并将其映射到特定的处理器。在现代 Web 开发中,handler
是一个组件,定义并位于一个物理文件中。例如,当用户输入路由 localhost:8080//home
时,如果 Home
映射到这个特定的路由,路由系统就会知道触发处理器 Home
来相应地渲染内容。
如前例所示,设置应用程序内导航的路由(或路径)至关重要。
每个路由都是一个对象字面量,其属性由 RouteConfig
接口声明:
interface RouteConfig = {
path: string,
component?: Component,
name?: string, // for named routes
components?: { [name: string]: Component }, // for named views
redirect?: string | Location | Function,
props?: boolean | Object | Function,
alias?: string | Array<string>,
children?: Array<RouteConfig>, // for nested routes
beforeEnter?: (to: Route, from: Route, next: Function) => void,
meta?: any,
// 2.6.0+
caseSensitive?: boolean, // use case sensitive match? ( default: false)
pathToRegexpOptions?: Object // path-to-regexp options for compiling regex
}
应用程序所需的所有路由都位于 routes
的 Array
实例中:
const routes = [
//Route1,
//Route2,
//...
]
现在,让我们回到之前的文件,并取消注释 routes
中的代码。将会有两个预定义的路由,home
和 about
,每个都是一个对象,位于 routes
数组中,以便于我们使用。
让我们以第一个路由为例,进行更详细的了解:
{
path: '/',
name: 'home',
component: Home
}
path
属性是一个/about
路径,将被转换为<app domain>/about
(localhost:8080/about
或example.com/about
)。
在这种情况下,Vue Router 将/
——空路径——理解为在没有其他指示符(例如,当用户导航到<app-domain>
或<app-domain>/
)后的默认路径,用于加载应用程序。
下一个属性是name
,它是一个字符串,表示分配给目标路由的名称。尽管它是可选的,但强烈建议为每个路由定义一个名称,以利于代码维护和路由跟踪,我们将在本章后面的传递路由参数部分进一步讨论。
最后一个属性是component
,它是一个 Vue 组件实例。router-view
使用这个属性作为对视图组件的引用,在路径激活时渲染页面内容。
在这里,我们将路由定义为home
路由,将其映射为应用的默认路径,并将其与Home
组件关联以显示内容。
Vue CLI 也为这两个示例路由自动生成了两个简单的组件——Home
和About
。
在下一节中,我们将介绍一些在加载与路由一起使用的组件时可能有所帮助的技巧。
路由配置中加载组件的技巧
当然,我们需要在同一个index.js
文件中导入组件,将其与目标路由关联起来。最经典和最受欢迎的方法是在文件顶部导入,如下所示:
import Home from '../views/Home.vue'
通常这会被添加到主导入之下,如图 6.5所示:
![图 6.5:第 3 行导入 Home 组件 – src/router/index.js
![img/B15218_06_05.jpg]
图 6.5:第 3 行导入 Home 组件 – src/router/index.js
然而,一个更有效的方法是懒加载组件。
懒加载,也称为按需加载,是一种旨在优化网站或 Web 应用程序在运行时内容的技术。它有助于减少首次加载应用程序时的时间和资源消耗。这种优化对于确保最佳的用户体验至关重要,因为每一毫秒的等待都很重要。除此之外,懒加载还允许在路由级别进行更好的代码拆分,并在大型或复杂应用程序中进行性能优化。
我们可以利用webpack
的优势来懒加载组件。我们可以在定义about
路由名称之后动态添加以下内容,而不是像处理Home
组件那样在文件顶部导入About
组件(参见图 6.5):
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
在这里,我们动态地懒加载about
路由的About
视图组件。在编译过程中,webpack
为about
路由生成一个具有指定名称("about"
)的单独块,并且只有在用户访问此路由时才加载它。
在大多数情况下,由于用户很可能会在第一次访问时停留在默认路径上,因此最好不懒加载默认组件(在我们的应用程序中是 Home
),而是以正常方式导入它。因此,这里的建议是在设计路由时确定哪些组件应该被懒加载,并将两种方法结合起来以获得最大效益。
我们现在将看到如何设置路由实例。
设置路由实例
在定义了路由之后,最后一步是根据给定的配置选项创建 router
实例:
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
配置是一个对象,由不同的属性组成,有助于形成应用程序的路由。我们现在将在以下小节中检查这些属性。
routes
routes
是必须传递给构造函数的选项。没有这个选项,路由器将无法识别路径并相应地将用户引导到合适的视图内容。
mode
mode
决定了路由器的模式。在 VueRouter
中,URL 有两种模式:
-
history
模式:这通过HTML5 History
API 利用默认的history.pushState()
API。它允许我们在不重新加载页面的情况下进行 URL 导航,并使 URL 路径易于阅读,例如yourapplication.com/about
。 -
hash
模式:这使用哈希符号(#
)来模拟 URL,例如,yourapplication.com/#about
用于about
页面或youapplication/#/
用于应用程序的home
URL。
base
base
决定了应用程序的基本 URL。它将被设置为 process.env.BASE_URL
以允许开发人员从应用程序代码之外控制它(例如,从 .env
文件中)。因此,开发人员可以在运行时设置代码应该从中提供服务的目录。
现在已经解决了 base
的问题,我们已经创建了 router
实例。剩下要做的就是导出它:
export default router
然后在 main.js
中导入它,在创建主应用实例的 new Vue
对象之前。我们仍然需要在实例配置中指定 router
,如下所示:
import router from './router'
Vue.config.productionTip = false
new Vue({
router, //specify the router configuration for use
render: h => h(App)
}).$mount('#app')
使用这段更新后的代码,我们的应用程序现在将按以下方式渲染:
图 6.6:浏览器中 Hello Vue Router 应用程序的首页
如果我们导航到 localhost:8080/about
,我们将看到从自动生成的代码中渲染的 about
组件内容:
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
网站应该看起来与 图 6.7 中所示相似:
图 6.7:浏览器中 "Hello Vue Router" 应用程序的关于页面
在本节中,我们探讨了如何使用懒加载组件来加速大型和复杂的 SPAs。我们还探讨了在设置路由时可以设置的某些选项,例如路由、模式和基础。在下一节中,我们将学习如何在 Vue Router 的帮助下实现和添加消息源页面。
练习 6.01:使用 Vue Router 实现和添加消息源页面
我们将创建一个新的页面,向用户显示消息列表。用户可以在浏览器中输入localhost:8080/messages
路径时访问此页面。
要访问此练习的代码文件,请参阅packt.live/35alpze
。
-
使用
vue create
生成的应用程序作为起点,或者使用vue-cli
创建一个新的应用程序。确保在生成项目时启用了路由,如本章前面所述:vue create Exercise6.01
-
让我们在
./src/views/
文件夹中添加一个名为MessageFeed.vue
的新视图组件:<template> <div> <h2> Message Feed </h2> <p v-for="(m, i) in messages" :key="i"> {{ m }} </p> </div> </template> <script> export default { data() { return { messages: [ 'Hello, how are you?', 'The weather is nice', 'This is message feed', 'And I am the fourth message' ] } } } </script>
-
在
src/router/index.js
中创建一个路由文件。它应该导入VueRouter
并告诉 Vue 使用该路由,如下所示:import Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' Vue.use(VueRouter)
-
接下来,在
./src/router/index.js
文件中,我们声明了一个用于MessageFeed
的路由,命名为messageFeed
,并将其路径设置为/messages
。我们还将懒加载该组件。这一步将通过将包含所需信息的对象附加到routes
数组中来完成:export const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../ views/About.vue') }, { path: '/messages', name: 'messageFeed', component: () => import(/* webpackChunkName: "messages" */ '../ views/MessageFeed.vue') } ]
-
最后,在同一个文件中,使用我们定义的
routes
创建一个router
实例:const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
-
使用以下命令运行应用程序:
yarn serve
-
当在浏览器中访问
localhost:8080/messages
时,页面应该显示正确的内容——即如图所示的Message Feed
页面:
图 6.9:应用程序渲染的消息源页面
这展示了如何简单地将一个新的页面路由添加到 Vue.js 应用程序中,同时保持代码组织良好且易于阅读。现在我们已经准备好了可用的路由,我们可以为用户提供在页面之间导航的能力,而无需输入完整的路径。
设置导航链接
如果router-view
负责根据 URL 路径渲染正确的活动视图内容,那么router-link
是一个 Vue 组件,它帮助用户在启用了路由的应用程序中进行导航。默认情况下,它渲染一个带有由其to
属性生成的正确href
链接的锚标签<a>
。
在我们由 Vue CLI 生成的示例应用程序中,由于有两个预填充的路由,因此也在App.vue
的<template>
部分添加了两个router-link
实例,位于<router-view/>
之前:
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
由于我们使用base
选项和history
模式,每个router-link
的to
属性应该接收与目标route
对象中声明的path
属性相同的值。
此外,由于我们的路由已命名,to
属性的一个替代方案是使用与名称相同的值,而不是路径。使用名称被高度推荐,以避免在需要调整应用程序中某些路由的路径时进行复杂的链接重构。因此,我们可以将链接重写如下:
<div id="nav">
<router-link to="home">Home</router-link> |
<router-link to="about">About</router-link> |
</div>
我们还可以选择将位置描述符对象绑定到to
属性,其格式类似于route
对象。考虑以下示例:
<router-link :to="{path: '/'}">Home</router-link>
此外,当相关路由处于活动状态时,将向<a>
标签添加一个额外的CSS
类router-link-active
。这个类可以通过router-link
组件的active-class
属性进行自定义。
在DevTools
中,我们可以看到router-link
组件的渲染方式如下:
图 6.10:浏览器 DevTools 中的 router-link
浏览器中的视图将如下所示:
图 6.11:带有导航链接的 Hello Vue Router 应用首页
注意,由于我们可以在组件内部访问this.$router
,我们可以通过使用this.$router.push()
以编程方式触发导航路由,并传递一个路径或类似to
的路由对象:
this.$router.push('/home')
在本节中,我们创建了一个示例页面,在/messages
路由位置渲染消息列表。我们还探讨了如何使用<router-link/>
元素在视图之间进行导航,其方式与传统 HTML <a>
标签类似。
接下来,我们将看到如何以类似于网络浏览器后退按钮的方式,以编程方式将用户发送到他们最后查看的路由。
实现返回按钮的小技巧
有时我们希望导航回上一页。使用this.$router.push()
可以实现这一点,但这会在历史堆栈中添加更多路由,而不是返回。正确的技术是使用this.$router.go(steps)
,其中steps
是一个整数,表示在历史堆栈中后退/前进的步数。此功能与window.history.go(steps)
类似。
考虑以下示例:
this.$router.go(-1) // similar to window.history.back() - go back one page
此外,您还可以使用相同的方法导航到之前加载且仍然存在于历史堆栈中的页面:
this.$router.go(1) // similar to window.history.forward() – go forward one page
在本节中,我们探讨了如何手动访问路由的历史记录,以便将用户发送到他们之前所在的页面。
在下一节中,我们将利用导航链接将我们的新消息馈送页面添加到应用程序的nav
菜单中。
练习 6.02:将导航链接添加到 MessageFeed 路由
我们将使用to
属性和router-link
(如前文所述)添加一个快速链接到我们在练习 6.01中创建的MessageFeed
路由,即使用 Vue Router 实现和添加消息馈送页面。
要访问此练习的代码文件,请参阅packt.live/3lr8cYR
。
-
使用 Vue 生成的起始应用程序作为起点,或者使用
Vue cli
创建一个新的应用程序。确保在生成项目时启用路由,如本章前面所述:vue create Exercise6.02
-
在
./src/App.vue
文件中,除了为home
和about
自动生成的router-link
组件外,还需要添加另一个指向Message Feed
标题下/messages
路径的router-link
组件:<template> <div id="app"> <div id="nav"> <router-link to="/">Home</router-link> | <router-link to="/about">About</router-link> | <router-link to="/messages">Message Feed</router-link> </div> <router-view/> </div> </template>
我们将看到在任何视图中都可用导航链接,并且当用户导航离开时它们不会消失,因为它们不是
router-view
组件的一部分。我们的屏幕应该如下所示:图 6.12. 更新后的导航链接的 Hello Vue Router 应用首页
-
让我们将
to
值更改为指向名为messageFeed
的对象,这与在./src/App.vue
中为该路由指定的name
相同:<router-link :to="{ name: `messageFeed` }">Message Feed </router-link>
-
导航应该与之前一样工作;点击
消息流
链接应将您导向/messages
,如下面的截图所示:图 6.13:点击消息流链接后的 Hello Vue Router 的消息流页面
-
现在,打开位于
./src/router/
文件夹中的index.js
文件,将messageFeed
路由定义的路径从/messages/
更改为/messagesFeed
:export const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') }, { path: '/messagesFeed', name: 'messageFeed', component: () => import(/* webpackChunkName: "messageFeed" */ '../views/MessageFeed.vue') } ]
-
使用以下命令运行应用程序:
yarn serve
导航到应用的
首页
并再次点击消息流
。它应该显示与之前相同的消息流
页面,但请注意 URL 路径已更改为/messagesFeed
:
图 6.14:使用更新后的 URL 路径渲染的消息流页面
注意,仅用一行代码就可以轻松设置指向/messages
路径的链接,以及更新相关路径而不需要重构。到目前为止,我们只定义了一些简单的路由,没有为目标路由添加任何额外的参数。这将是我们的下一个挑战。
传递路由参数
在本章的前几节中,每个路由都是一个独立的视图,不需要传递或连接任何数据到其他路由。但路由的力量不仅限于此。通过命名路由,我们还可以轻松地启用路由之间的数据通信。
在我们的示例应用中,我们希望我们的about
页面能够接收一个名为user
的数据字符串,作为用户名从链接触发。这可以通过将to
属性从字符串字面量更改为对象字面量:to="{ name: 'about' }"
来实现,然后向该对象添加一个新的params: { user: 'Adam' }
属性:
<router-link :to="{ name: 'about', params: { user: 'Adam' }}">
About
</router-link>
此更改通知路由器在用户点击目标链接时将所需的参数传递给About
页面。这些额外的参数在渲染的href
链接中不可见,如下面的截图所示:
图 6.15:生成的 href 链接没有参数
然而,Vue 系统正在跟踪这些额外的参数。使用 Vue DevTools,我们可以通过展开如图 6.16 所示的to
属性来查看参数:
在About.vue
文件中,由于我们可以访问当前活动的$route
(参见本章前面提到的Vue Router部分),我们可以检索通过链接传递的数据,并将其作为$route.params.user
获取并打印出来:
<template>
<div class="about">
<h1>About {{$route.params.user}}</h1>
</div>
</template>
输出将如下所示:
任何params
的 prop 用户都不会出现在 URL 路径上,从而保持路径的整洁,并确保视图之间传递的数据的安全性。
但是使用$route.params.user
既不方便也不易读,并且从长远来看不利于组件的可重用性。我们可以通过在组件内部解耦传递的params
与props
来改进这一点。
我们现在将看到如何借助props
解耦params
。
使用属性解耦参数
在index.js
文件中,让我们调整about
路由的配置,添加一个名为props
的额外属性。通过将此属性的值设置为true
,路由器将自动理解并将$route.params
映射到相应的props
组件:
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
props: true
}
在About.vue
文件中,我们将声明props
类型如下:
props: {
user: String
}
在<template>
部分,我们将$route.params.user
替换为user
:
<template>
<div class="about">
<h1>About {{user}}</h1>
</div>
</template>
输出仍然相同,如下面的截图所示:
并映射到属性
此外,你还可以在route
配置的props
属性中定义你想要传递的数据。与布尔值不同,现在props
可以声明为一个对象,包含所需的数据,如下例所示:
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
props: { age: 32 }
}
通过类似的步骤,我们将在About.vue
中将age
声明为props
组件,并将其作为文本打印到屏幕上:
<template>
<div class="about">
<h1>About {{user}}</h1>
<h2>Age: {{age}}</h2>
</div>
</template>
<script>
export default {
props: {
user: String,
age: Number
}
}
</script>
现在当点击About
页面时,页面将渲染如下:
我们之前用户数据不再可见!这是因为,现在,props
在About
路由的配置中声明为静态数据,并且不能从外部覆盖。它的值在整个在应用程序中导航的过程中将保持不变,无论我们在目标router-link
组件的to
属性的params
中传递什么值。
我们现在将学习如何将所选消息的内容传递到新消息页面并打印出来。
练习 6.03:将所选消息的内容传递到新消息页面并打印出来
我们将从练习 6.02继续,将导航链接添加到消息推送路由,在那里我们定义了MessageFeed
路由,其 URL 路径为messages
。此视图将在视图组件选项的data
属性中渲染预定义的消息列表。
在这个练习中,我们将创建一个新的message
页面,专门用于渲染用户选择的消息内容。它应该是可重用的。
要访问此练习的代码文件,请参阅packt.live/36mTwTY
。
-
在
./src/views/
文件夹中,我们创建了一个新的单文件组件Message.vue
。该组件接收一个类型为string
的content
属性,并在<p>
标签下渲染它:<template> <div> <p>{{content}}</p> </div> </template> <script> export default { props: { content: { default: '', type: String } } } </script>
-
让我们将创建的视图组件注册到
./src/router/index.js
中的现有routes
。我们将定义一个新的路由为message
,路径为/message
。它还将接受props: true
,以便将传递给路由的所有参数相应地映射到相关属性。将要使用的完整路由列表如下:export const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') }, { path: '/messages', name: 'messageFeed', component: () => import(/* webpackChunkName: "messages" */ '../views/MessageFeed.vue') }, { path: '/message', name: 'message', component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'), props: true } ]
-
由于路由已注册并准备好使用,我们需要修改
./src/views/MessageFeed.vue
中的<template>
部分,以确保每条消息现在都是可点击的,并且在点击时将用户重定向到新路由。让我们将<p>
标签替换为router-click
。因为我们已经将新路由命名为message
,所以我们将to
设置为绑定到{ name: 'message' }
:<template> <div> <h2> Message Feed </h2> <div v-for="(m, i) in messages" :key="i" > <router-link :to="{ name: 'message'}"> {{ m }} </router-link> </div> </div> </template>
-
在
template
中,我们将添加一个包含我们messages
的一些示例数据的script
标签:<script> export default { data() { return { messages: [ 'Hello, how are you?', 'The weather is nice', 'This is message feed', 'And I am the fourth message' ] } } } </script>
-
当你打开
./messages
页面时,现在所有消息都是可点击的,如下面的截图所示:![图 6.20:更改消息为可点击后的消息推送页面图 6.20:更改消息为可点击后的消息推送页面
-
现在当用户点击一条消息时,它将打开一个新页面。然而,页面内容将是空的,因为我们没有将任何内容参数传递给
<route-click>
组件,如下面的截图所示:![图 6.21:无内容生成的消息页面图 6.21:无内容生成的消息页面
-
让我们回到
./src/views/MessageFeed.vue
并添加params: { content: m }
:<template> <div> <h2> Message Feed </h2> <div v-for="(m, i) in messages" :key="i" > <router-link :to="{ name: 'message', params: { content: m }}"> {{ m }} </router-link> </div> </div> </template>
-
现在当你点击第一条消息“你好,你好吗?”,输出将是以下内容:![图 6.22:已渲染点击消息内容的消息页面
图 6.22:已渲染点击消息内容的消息页面
简单,不是吗?我们已经使用router-link
以及组件的params
和props
的组合,动态完成了从消息流到单个选中消息详细页面的流程。然而,这种方法有一个显著的缺点。
当你仍然位于第一条消息的./message
路径上时,让我们刷新页面。输出将与步骤 5中的相同——一个空的内容页面。刷新后,路由被触发,没有传递任何content
params
,与用户点击特定链接时不同,之前传递的params
也没有被保存或缓存。因此,没有内容。
在以下部分,我们将学习如何拦截导航流程,并使用路由钩子解决这个问题。
路由钩子
路由导航的一般流程在以下图中描述:
图 6.23:导航解析流程图
一旦在某个路由上触发导航,Vue Router 为开发者提供了几个主要的导航守卫或钩子,用于保护或拦截该导航过程。这些守卫可以是全局的或组件内的,具体取决于类型。以下是一些示例:
-
全局:
beforeEach
、beforeResolve
和afterEach
-
每个组件:
beforeEnter
-
组件内:
beforeRouteUpdate
、beforeRouteEnter
和beforeRouterLeave
如图 6.23所示,只有当所有钩子或守卫(包括任何异步守卫)都解析完成后,导航才被认为是完成的。现在,让我们看看如何设置beforeEach
钩子。
设置beforeEach
钩子
beforeEach
是一个全局钩子,在导航开始时(前一个视图组件的beforeRouteLeave
)被调用。它应该在index.js
文件初始化时定义为一个router
实例的全局方法,并采用以下语法:
const router = new VueRouter({
//...
})
router.beforeEach(beforeEachCallback)
在前面的代码片段中,beforeEachCallback
是一个hook
函数,它接收三个参数:
const beforeEachCallback = (
to, // The destination route
from, //The source route
next //The function to trigger to resolve the hook
) => { … })
或者我们可以直接这样写:
router.beforeEach((to, from, next) => { … })
例如,如果我们想在用户导航到没有用户参数值的About
时显示不同的页面来显示通用消息,我们可以将beforeEach
钩子配置如下:
router.beforeEach((
to, // The destination route
from, //The source route
next //The function to trigger to resolve the hook
) => {
if (to.name === 'about' && (!to.params || !to.params.user)) {
next({ name: 'error' })
}
else {
next();
}
})
在这里,我们检查目标路由是否为about
,并且没有传递任何额外的params
,也没有为user
参数传递任何值,我们将导航到error
路由,而不是正常进行,使用next()
。
注意
next()
必须调用(如果else
),否则将出现错误。
我们仍然需要创建一个带有Error.vue
视图组件的error
页面,该组件显示一条简单的消息:
<template>
<div>
<h2>No param passed.</h2>
</div>
</template>
还要确保相应地注册路径:
{
path: '/error',
name: 'error',
component: () => import(/* webpackChunkName: "error" */ '../views/Error.vue'),
}
现在,在Home
视图中,点击About
链接后,应用将渲染Error
页面,而不是About
页面,如以下截图所示:
图 6.24:点击 About 时未传递任何参数显示的错误页面
现在,让我们转到App.vue
文件,并将to
属性绑定到{ name: 'about', params: { user: 'Adam' }}
对象上:
<router-link :to="{ name: 'about', params: { user: 'Adam' }}">About</router-link>
让我们导航回应用的Home
页面并点击About
链接。由于我们传递了正确的params
,输出将如下所示:
图 6.25:当在params
中传递用户时显示的“关于”页面
此外,从现在开始,每次我们刷新“关于”页面时,都会被重定向到“错误”页面,因为没有在刷新时传递user
参数。
我们现在将探讨beforeEach
和beforeResolve
钩子之间的一些关键区别点。
区分 beforeEach 和 beforeResolve 钩子
我们还可以使用相同的语法使用beforeResolve
注册全局钩子。然而,与在导航创建阶段触发的beforeEach
不同,beforeResolve
将在导航执行和确认之前触发,在所有钩子(全局和组件内)解析之后:
router.beforeResolve((
to, // The destination route
from, //The source route
next //The function to trigger to resolve the hook
) => {
if (to.name === 'about' && (!to.params || !to.params.user)) {
next({ name: 'error' })
}
else {
next();
}
})
输出结果将与图 6.25相同:
图 6.26:当在params
中传递用户时显示的“关于”页面
现在我们来详细看看afterEach
钩子。
afterEach 钩子
afterEach()
钩子是在导航确认后(这意味着在beforeResolve()
之后)被触发的最后一个全局导航守卫。与其他全局守卫不同,传递给afterEach()
的钩子函数不会接收next
函数,因此它不会影响导航。
此外,to
和from
参数是只读的Route
对象。因此,afterEach
的最佳用例是保存数据,例如为“返回”按钮保存最后访问的Route
对象,传递给路由目标的params
,或页面视图跟踪。例如,我们可以设置一个默认值user
,并在需要时赋值并保存:
let user = 'Adam';
router.beforeEach((to, from, next) => {
if (to.name === 'about' && (!to.params || !to.params.user)) {
next({ name: 'about', params: { user }})
}
else {
user = to.params.user;
next()
}
});
router.afterEach((to, from) => {
if (to.name === 'about' && to.params && to.params.user) {
user = to.params.user;
}
})
现在在App.js
文件中,除了Adam
外,添加以下内容:
<router-link
:to="{ name: 'about', params: { user: 'Adam' }}"
>
About
</router-link>
让我们将其更改为Alex
:
<router-link
:to="{ name: 'about', params: { user: 'Alex' }}"
>
About
</router-link>
点击About
链接时的输出如下所示:
图 6.27:显示新用户名字的“关于”页面 – Alex
但是在重新加载时,由于传递了params
中的用户,About
页面将渲染为默认用户Adam
,如下所示:
图 6.28:在重新加载时显示默认用户值“Adam”的“关于”页面
在本节中,我们探讨了afterEach
钩子。我们使用afterEach
钩子将数据传递到about
页面,而无需在 URL 中包含该数据。同样的技术可以用于更新其他行为,例如按下“返回”按钮时希望的目标页面。
根据路由个性化钩子
而不是定义一个全局钩子,这可能会引起未知的错误并需要路由检查,我们可以在目标路由的配置对象中直接定义 beforeEnter
守卫,例如,我们的 About
路由:
beforeEnter: (to, from, next) => {
if (!to.params || !to.params.user) {
to.params.user = 'Adam'
}
next()
}
使用这种方法,无论是重新加载还是点击链接导航到 About
页面,输出现在都是一致的,如下面的截图所示:
![图 6.29:使用用户值 Adam 渲染的关于页面]
![图片 B15218_06_29.jpg]
图 6.29:使用用户值 Adam 渲染的关于页面
注意
使用 beforeEnter()
,to
是可写的,你将能够访问 this
(指向特定的路由 - About
)。它只会在用户触发导航到 About
页面时被触发。
在本节中,我们探讨了 Vue 中可用的不同路由钩子,包括 beforeEach
、beforeResolve
和 afterEach
。我们看到了每个钩子如何在路由过程中的不同点被调用。作为一个实际例子,我们查看了一个路由,如果没有提供参数,则将用户重定向到错误页面。这些钩子在设置认证路由时非常有用。在下一节中,我们将探讨设置组件内部的钩子。
设置组件内部的钩子
最后,我们还可以使用组件内部的钩子作为组件生命周期钩子,在需要将钩子作用域限定在组件级别以更好地维护代码或增强工作流程的情况下。
我们现在可以定义如下的 beforeRouteEnter()
钩子来拥有 About
组件:
<script>
export default {
data() {
return {
user: ''
}
},
beforeRouteEnter(to, from, next) {
if (!to.params || !to.params.user) {
next(comp => {
comp.user = 'Alex'
})
}
else {
next();
}
}
}
</script>
正如你所见,在 beforeRouteEnter
期间,我们没有访问组件的 this
作用域,因为视图组件在触发时的那一刻仍在创建中。幸运的是,我们可以通过传递给 next()
的回调函数来访问实例。每当导航被确认,即组件被创建时,回调函数将被触发,组件实例将作为回调函数的唯一参数(comp
)可用。
注意
对于 beforeRouteUpdate
和 beforeRouteLeave
,组件已经被创建,因此这个实例是可用的,不需要为 next()
提供回调函数。实际上,回调函数仅在 beforeRouteEnter()
的使用中支持 next()
。
当相同的组件被用于不同的路由时,会调用 beforeRouteUpdate
。这适用于我们使用动态路由的情况,这将在下一节中讨论。
当组件即将被停用或用户即将离开当前视图时,会触发 beforeRouteLeave
。这发生在新导航的 beforeEach
守卫之前,通常用于编辑组件以防止用户在不保存的情况下离开。
在这个守卫中,我们可以通过向 next()
函数传递 false
来取消新的导航。
例如,假设我们在 About.vue
文件的组件选项中添加以下钩子:
//...
beforeRouteLeave(to, from, next) {
const ans = window.confirm('You are about to leave the About page. Are you sure?');
next(!!ans);
}
当我们从“关于”页面导航离开时,会出现一个弹出对话框请求确认,如下面的截图所示,然后继续相应地导航:
图 6.30:在离开“关于”页面之前请求确认的对话框
在本节中,我们探讨了设置组件内钩子,即仅限于特定组件的钩子。我们为我们的About
组件设置了一个组件内钩子,在用户离开页面之前要求用户确认。在下一节中,我们将把消息列表移动到外部文件,以便仅在MessageFeed
可见时加载。
练习 6.04:将消息列表提取到外部文件并在 MessageFeed 可见时加载
回到练习 6.03,将选中消息的内容传递到新消息页面并打印出来,现在我们将使用beforeEnter
和beforeRouteEnter
路由钩子进行一些代码增强。这个练习旨在让你更熟悉使用路由钩子。
要访问此练习的代码文件,请参阅packt.live/3lg1F2R
。
-
让我们从
./src/views/MessageFeed.vue
中提取messages
静态数据并将其保存到./src/assets/messages.js
中:const messages = [ 'Hello, how are you?', 'The weather is nice', 'This is message feed', 'And I am the fourth message' ]; export default messages;
-
在
./src/views/MessageFeed.vue
中,我们将用props: { messages: { type: String, default: [] }}
替换本地数据属性:export default { props: { messages: { type: Array, default: () => [] } } }
-
现在,我们需要在导航到“消息”路由时加载消息列表并将其分配给
messages
参数。我们将通过使用路由配置对象中的beforeEnter
钩子来完成此操作。别忘了添加props: true
以将params
标准化为相关的props
以进行渲染。你可以通过修改src/router/index.js
中定义的route
来实现这一点:{ path: '/messages', name: 'messageFeed', component: () => import(/* webpackChunkName: "messages" */ '../views/MessageFeed.vue'), props: true, async beforeEnter(to, from, next) { next() } },
-
我们将使用
import
懒加载消息列表:const module = await import (/* webpackChunkName: "messagesFeed" */ '../assets/messages.js');
-
然后,按照以下方式检索所需信息:
const messages = module.default; if (messages && messages.length > 0) { to.params.messages = messages; }
-
src/router/index.js
中路由的完整代码应该是以下内容:{ path: '/messages', name: 'messageFeed', component: () => import(/* webpackChunkName: "messages" */ '../views/MessageFeed.vue'), props: true, async beforeEnter(to, from, next) { if (!to.params || !to.params.messages) { const module = await import (/* webpackChunkName: "messagesFeed" */ '../assets/messages.js'); const messages = module.default; if (messages && messages.length > 0) { to.params.messages = messages; } } next() } },
在查看网站时,我们应该看到一个类似于上一个练习的消息推送。如下面的截图所示:
图 6.31:重构后的消息推送页面
到目前为止,我们已经学习和实践了如何使用不同的路由钩子配置路由、传递参数以及拦截应用中页面间的导航。在下一节中,我们将探讨一个更高级的主题——动态路由。
动态路由
如果有很多遵循相同格式的大量数据,例如用户列表或消息列表,并且需要为每个创建一个页面,我们需要使用路由模式。使用路由模式,我们可以根据一些附加信息从相同的组件动态创建新路由。例如,我们想要为每个用户渲染 User
视图组件,但具有不同的 id
值。Vue Router 提供了我们使用冒号(:
)表示的动态段来实现动态路由的能力。
我们不使用 params
,因为 params
在刷新时不会持久化其值,也不会出现在 URL 中,我们直接在路径中定义所需的 params
,如下所示:
{
path: '/user/:id',
name: 'user',
component: () => import(/* webpackChunkName: "user" */ '../views/User.vue')
}
在前面的代码中,:id
表示这里的 params
不是静态的。当路由与给定的模式匹配时,Vue Router 将渲染相应的组件,并保持 URL 不变。:id
的值将作为该视图组件实例中的 this.$route.params.id
暴露:
<template>
<div>
<h1>About a user: {{$route.params.id}}</h1>
</div>
</template>
当用户选择 /user/1
和 /user/2
等 URL 时(./src/App.vue
),Vue 将自动使用我们的模板生成子页面。
导航路径将被映射到相同的路由模式组件,但带有不同的信息,如下面的截图所示:
![图 6.32:导航到 /user/2
图 6.32:导航到 /user/2
当你点击 User 1
时,你会看到以下内容:
![图 6.33:导航到 /user/1
图 6.33:导航到 /user/1
我们也可以使用 props: true
将 id
标准化到 User
组件的 props
中,并与 beforeRouteEnter()
结合,在实例创建和渲染之前加载数据:
<script>
import users from '../assets/users.js';
export default {
props: {
id: Number
},
data() {
return {
name: '',
age: 0
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
const user = users[vm.id];
vm.name = user.name;
vm.age = user.age;
})
}
}
</script>
现在,我们可以调整 <template>
来打印出用户的详细信息:
<template>
<div>
<h1>About a user: {{$route.params.id}}</h1>
<h2>Name: {{name}}</h2>
<p>Age: {{age}}</p>
</div>
</template>
选择 /user/1
时的输出将如下所示:
![图 6.34:使用更新后的 UI 导航到 /user/1
图 6.34:使用更新后的 UI 导航到 /user/1
如果我们在 user/:id
路由中更改 :id
为另一个用户,我们需要相应地更新本地数据,因为在这种情况下 beforeRouteEnter
不会再次触发。实际上,组件的所有生命周期钩子都不会被调用,因为组件实例不会被重新创建:
beforeRouteUpdate(to, from, next) {
const user = users[to.params.id - 1];
this.name = user.name;
this.age = user.age;
next();
}
在本节中,我们通过设置一个从给定 URL 提取参数的路由来查看动态路由。这项技术允许你创建用户友好的 URL 并动态地将信息传递给路由。在下一节中,我们将查看捕获错误路径。
捕获错误路径
除了主页('/'
)之外,我们还需要记住处理的其他重要路由包括错误路由,例如当 URL 路径不匹配任何已注册路径时的 404 Not found
等。
对于404 未找到
,我们可以使用regex
星号*
,它代表匹配所有内容来收集所有不匹配路由的情况。此路由器的配置应位于数组routes
的末尾,以避免匹配错误路径:
{
path: '*',
name: '404',
component: () => import(/* webpackChunkName: "404" */ '../views/404.vue'),
}
当我们为/users
输入错误的路径时,输出将如下所示:
![图 6.35:当'/users'路径未找到时重定向到 404
图 6.35:当'/users'路径未找到时重定向到 404
在本节中,我们探讨了如何使用*
正则表达式通配符来创建一个显示给所有导航到不存在路由的人的404
页面。接下来,我们将实现一个消息路由,使用动态路由模式在 URL 本身传递相关数据。
练习 6.05:使用动态路由模式为每个消息实现消息路由
回到我们的消息源在练习 6.04中,将消息列表提取到外部文件并在 MessageFeed 视图中加载,我们将重构我们的Message
路径,使用路由模式在用户选择时动态导航到特定的消息路径。这将使你熟悉在与其他导航钩子结合时创建和维护动态路由。
要访问此练习的代码文件,请参阅packt.live/32sWogX
。
-
让我们打开
./src/router/index.js
,将消息路由的路径配置更改为/message/:id
,其中id
将是消息列表中该message
的索引:{ path: '/message/:id', name: 'message', component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'), props: true, }
-
现在导航到
./src/views/MessageFeed.vue
,并将每个消息的router-link
的to
属性更改为以下内容:<router-link :to="`/message/${i}`">
-
让我们回到
./src/router/index.js
,并将beforeEnter
定义为异步钩子,用于将消息内容懒加载到我们的Message
组件的内容属性中:async beforeEnter(to, from, next) { if (to.params && to.params.id) { const id = to.params.id; const { module } = await import (/* webpackChunkName: "messagesFeed" */ '../assets/messages.js'); const messages = module.default; if (messages && messages.length > 0 && id < messages.length) { to.params.content = messages[id]; } } next() },
-
使用以下命令运行应用程序:
yarn serve
当点击
消息源
中的第一条消息时,下一页将如下所示:![图 6.36:访问/message/0 路径时显示的页面
图 6.36:访问/message/0 路径时显示的页面
现在你已经学会了如何使用动态路由,你可以进一步探索更多层级的路由模式,如message/:id/author/:aid
。然而,对于这种情况,我们通常采用更好的方法,嵌套路由。
嵌套路由
在现实中,许多应用程序由由多个多级嵌套组件组成的组件构成。例如,/user/settings/general
表示一个通用视图嵌套在settings
视图中,而这个settings
视图又嵌套在user
视图中。它代表用户设置页面的通用信息
部分。
大多数时候,我们希望 URL 与以下截图所示的结构相对应:
![图 6.37:具有两个嵌套视图的用户 – 信息和额外信息
图 6.37:具有两个嵌套视图(信息和个人资料)的用户
Vue Router 通过使用嵌套
路由配置和router-view
组件,使实现这种结构变得简单。
让我们回到之前示例中的User.vue
视图(位于./src/views/
),并在<template>
部分添加一个嵌套的router-view
组件:
<div>
<h1>About a user: {{$route.params.id}}</h1>
<router-link :to="{ name: 'userinfo', params: { id: id }}">Info </router-link> |
<router-link :to="/user/${id}/extra">Extra</router-link>
<router-view/>
</div>
为了开始向此router-view
渲染组件,我们将配置user
路由以具有子选项,该选项接受子路由的路线配置数组。在我们的示例中,我们将为每个用户添加一个“信息”和“额外”页面。这些子路由将通过/user/:id/info
和/user/:id/extra
访问,为每个用户提供唯一的“信息”和“额外”页面:
{
path: '/user/:id',
name: 'user',
component: () => import(/* webpackChunkName: "user" */ '../views/User.vue'),
props: true,
children: [{
path: 'info',
name: 'userinfo',
component: () => import(/* webpackChunkName: "info" */ '../views/UserInfo.vue')
}, {
path: 'extra',
component: () => import(/* webpackChunkName: "extra" */ '../views/UserExtra.vue')
}]
}
并非所有嵌套路径都需要以/
开头作为其父路径,这将避免它们被视为根路径,并使 Vue Router 计算匹配的路由更加容易。
当然,我们将在文件夹中创建两个新的视图,这些视图将根据接收到的id
渲染有关用户的所有信息:
<template>
<div>
<h2>Name: {{name}}</h2>
<p>Age: {{age}}</p>
</div>
</template>
<script>
import users from '../assets/users.js';
export default {
data() {
return {
name: '',
age: 0
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
const user = users[to.params.id - 1];
vm.name = user.name;
vm.age = user.age;
})
},
beforeRouteUpdate(to, from, next) {
const user = users[to.params.id - 1];
this.name = user.name;
this.age = user.age;
next();
}
}
</script>
我们还创建了UserExtra.vue
,它将渲染额外信息(如果有)。在这个例子中,它将仅渲染简单的文本:
<template>
<div>
<h2>I'm an extra section</h2>
</div>
</template>
嵌套视图已准备就绪!每当用户点击“信息”链接时,它将加载“用户信息”视图并更新 URL 如下:
![图 6.38:包含嵌套用户信息视图的用户页面
![图片 B15218_06_38.jpg]
图 6.38:包含嵌套用户信息视图的用户页面
当用户点击“额外”时,他们将看到以下截图所示的内容:
![图 6.39:包含嵌套用户额外视图的用户页面
![图片 B15218_06_39.jpg]
图 6.39:包含嵌套用户额外视图的用户页面
在本节中,我们探讨了嵌套路由,即具有多个子路由的路由。在我们的示例中,子路由是用户信息和用户额外。这种模式允许我们创建扩展其父页面的页面。在前面的示例中,我们现在可以编辑“关于用户”标题,并使其对所有子路由生效。随着项目的增长,利用这种模式将允许您避免在多个视图中重复代码。
在下一节中,我们将利用到目前为止所学的内容来为我们的消息视图组件创建导航标签。
练习 6.06:在消息视图中构建导航标签
我们将把从“嵌套路由”部分学到的知识应用到构建从“练习 6.05”中创建的“消息”视图。
要访问此练习的代码文件,请参阅packt.live/2U9Bn6I
。
-
首先,让我们通过添加以下“作者”和“发送”字段来修改我们的
messages
数据库,该数据库位于src/assets/messages.js
中:const messages = [ { content: 'Hello, how are you?', author: 'John', sent: '12 May 2019' }, { content: 'The weather is nice', author: 'Lily', sent: '12 Jun 2019' }, { content: 'This is message feed', author: 'Smith', sent: '10 Jan 2020' }, { content: 'And I am the fourth message', author: 'Chuck', sent: '1 Apr 2021' }, ];
-
接下来,我们将创建一个
MessageAuthor.vue
视图,该视图仅渲染消息创建者的姓名:<template> <div> <h3>Author:</h3> <p>{{message.author}}</p> </div> </template> <script> export default { props: { message: { type: Object, default: () => {} } } } </script>
-
然后,我们将创建一个
MessageInfo.vue
视图,该视图渲染message.sent
值:<template> <div> <h3>Message info: </h3> <p>{{message.sent}}</p> </div> </template> <script> export default { props: { message: { type: Object, default: () => {} } } } </script>
-
一旦我们完成了组件,我们需要在我们的
src/router/index.js
路由中的message
路由的子路由下注册新的嵌套路由:{ path: '/message/:id', name: 'message', component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'), async beforeEnter(to, from, next) { ... }, props: true, children: [{ path: 'author', name: 'messageAuthor', props: true, component: () => import(/* webpackChunkName: "messageAuthor" */ '../views/MessageAuthor.vue'), }, { path: 'info', props: true, name: 'messageInfo', component: () => import(/* webpackChunkName: "messageInfo" */ '../views/MessageInfo.vue'), }] }
-
最后,在
Message.vue
中,我们将重构代码如下:<template> <div> <p>Message content: {{message.content}}</p> <router-link :to="{ name: 'messageAuthor', params: { message }}">Author</router-link> | <router-link :to="{ name: 'messageInfo', params: { message }}">Info</router-link> <router-view/> </div> </template> <script> export default { props: { id: { type: String }, message: { default: () => {}, type: Object, } } } </script>
现在我们可以在一个
Message
中在Author
和Info
标签页之间导航如下:![图 6.40:选择 Info 的消息页面
![图片 B15218_06_40.jpg]
图 6.40:选择 Info 的消息页面
-
使用以下命令运行应用程序:
yarn serve
当你选择
Author
选项时,你会看到以下内容:![图 6.41:选择作者的消息页面
![图片 B15218_06_41.jpg]
图 6.41:选择作者的消息页面
通过这个练习,我们几乎涵盖了 Vue Router 的所有基本功能,特别是处理动态和嵌套路由。在最后一节,我们将介绍如何为视图模板创建可重用的布局。
使用布局
在 Vue.js 应用程序中实现布局有许多方法。其中之一是使用 slot
并在 router-view
之上创建一个静态的包装器布局组件。尽管这种方法具有灵活性,但它会导致高昂的性能成本,包括组件的不必要重新创建以及在每次路由变化时所需的额外数据获取。
在本节中,我们将讨论一种更好的方法,即利用动态组件的力量。组件如下:
<component :is="layout"/>
在 App.vue
文件中,我们将更改 Vue CLI 生成的默认视图,使其仅包含 <router-view>
和围绕它的包装器。这个包装器是一个动态组件,它将渲染在 layout
变量中定义的任何组件:
<template>
<div id="app">
<component :is="layout">
<router-view/>
</component>
</div>
</template>
<script>
默认情况下,我们将在 data
中定义 layout
为 default.vue
布局:
<script>
export default {
data() {
return {
layout: () => import(/* webpackChunkName: "defaultlayout" */ './layouts/default.vue')
}
}
}
</script>
在 layouts
文件夹中,我们将创建一个具有简单头部导航、main
插槽以渲染实际内容(这是 <router-view>
渲染的内容)和页脚的 default
布局组件:
<template>
<div class="default">
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<main class="main">
<slot/>
</main>
<footer>
<div>Vue Workshop Chapter 06</div>
</footer>
</div>
</template>
回到我们的 App.vue
文件,为了使布局 component
能够根据相应的路由变化进行渲染,router-view
应该控制渲染哪个布局。换句话说,layout
应该是可更新的,并由 router-view
内部渲染的视图组件决定。为了实现这一点,我们将在 <router-view>
上使用 sync
定义 currentLayout
属性与 layout
保持同步:
<component :is="layout">
<router-view :currentLayout="layout"/>
</component>
在创建 Home.vue
组件的实例时,我们将发出一个带有期望更新的布局的 update:currentLayout
事件,并相应地渲染:
import DefaultLayout from '../layouts/default.vue';
export default {
name: 'home',
components: {
HelloWorld,
},
created() {
this.$emit('update:currentLayout', DefaultLayout)
}
}
输出结果如下:
![图 6.42:使用布局渲染的首页
![图片 B15218_06_42.jpg]
图 6.42:使用布局渲染的首页
由于 layout
组件不是 router-view
组件的一部分,它只有在布局从视图内部发生变化时才会重新渲染。这将保持应用程序在用户导航时的性能。
在本节中,我们探讨了如何使用动态component
组件为不同的路由提供不同的布局。这使我们能够拥有不同的通用布局,例如,一个用于用户界面页面的全局菜单和另一个用于管理页面的菜单,这些菜单将根据使用的路由进行渲染。在下一节中,我们将通过创建一个具有动态嵌套路由和布局的消息应用来构建我们在这里学到的内容。
活动 6.01:创建具有动态、嵌套路由和布局的消息 SPA
本活动旨在利用您关于 Vue Router 的知识,包括注册路由、处理动态路由、嵌套路由和路由钩子,以创建消息****SPA。此应用将允许用户编写新消息、查看消息流并在消息之间导航以查看其详细信息:
-
创建一个
MessageEditor
视图(在src/views/MessageEditor.vue
),它将为用户渲染一个带有textarea
的视图和一个submit
按钮来保存消息。 -
在
src/router/index.js
中将editor
路由与MessageEditor
视图注册。 -
创建一个
MessageList
视图(在src/views/MessageList.vue
),它将渲染一个由a
标签包裹的message id
值的列表,当选择时,将跳转到具有给定id
的单个消息页面。 -
在
src/router/index.js
中将list
路由与MessageList
视图注册。 -
添加
Messages
视图(在src/views/Messages.vue
),它将渲染指向editor
或list
的链接作为其嵌套路由,并相应地渲染嵌套视图。 -
当用户从
editor
导航离开时,如果某些内容尚未提交,应显示一条消息询问他们是否在导航离开前保存。选择Yes
将继续,选择No
将中止导航。 -
添加一个
Message
视图(在src/views/Message.vue
),它将从props
渲染消息内容并具有一个返回按钮以返回到上一个视图。默认情况下,它应跳转到messages
。 -
在
src/router/index.js
中将Message
视图与动态路由message/:id
注册。 -
通过创建两个不同的简单布局来改进 UI,一个用于
messages
(仅包含标题)和一个用于message
(包含标题和返回按钮)。
预期输出如下:
- 显示消息流的
/list
视图应如下所示:
![图 6.43:消息应用中的 /list 视图]
图 6.43:消息应用中的 /list 视图
- 允许用户编写并发送新消息的
/editor
视图如下所示:
![图 6.44:消息应用中的 /editor 视图]
![图 6.44:消息应用中的 /editor 视图]
Message
应用中的/message/:id
动态路由(这里,/message/0
表示具有id
为0
的消息)如下所示:
![图 6.45:消息应用中的 /message/0 视图]
![图 6.45:消息应用中的 /message/0 视图]
当用户尝试带有未保存消息的导航离开时,将显示一个警告,如下面的截图所示:
![图 6.46:当用户尝试带有未保存消息的导航离开时的/editor 视图]
![img/B15218_06_46.jpg]
图 6.46:当用户尝试带有未保存消息的导航离开时的/editor 视图
注意
该活动的解决方案可以通过此链接找到。
摘要
在本章中,我们学习了 Vue Router 为构建任何 Vue.js 应用的路由所提供的最基本和最有用的功能,以有效和有序的方式进行。
router-view
和router-link
允许应用开发者轻松设置导航路径到相关视图,并保持 SPA 概念。它们本身是 Vue 组件的事实为我们开发者提供了 Vue 架构的好处,使我们能够在实现嵌套视图或布局时具有灵活性。
将路由定义为具有不同属性的对象简化了架构过程,包括重构现有路径和向系统中添加新路由。使用路由参数和模式提供了动态路由,具有可重用视图,并允许页面之间的通信和数据保留。
最后,通过 Hooks,我们看到了如何拦截导航流程,在需要的地方设置身份验证,重定向到期望的路径,甚至在用户到达目标页面之前加载并保留一些重要数据。这些 Hooks 有无数的使用场景,例如在实现返回按钮时非常有用。
通过这些,我们现在能够为用户提供适当的导航系统来探索 Vue.js 应用,同时保持每个导航用例(动态路由和嵌套视图)的数据流以及应用设计布局。
在下一章中,你将探索如何将过渡应用到路由上,并为你的 Vue 组件和应用添加不同的动画,以实现美丽的加载效果。
第七章:7. 动画与过渡
概述
本章将介绍如何在 Vue 应用程序中创建过渡和动画效果。在本章中,你将探索 Vue 过渡的基础知识。我们将涵盖单元素过渡、使用过渡组来动画化元素列表,以及使用过渡路由创建全页动画。你还将学习如何创建自己的过渡,以及如何与外部库结合使用以实现各种动画。
到本章结束时,你将准备好为任何 Vue 应用程序实现和处理基本过渡和动画效果。
简介
在 第六章,路由 中,你学习了路由以及如何使用 Vue Router 设置基本的路由导航系统。通过平滑地实现不同路由之间的过渡,或者在用户与应用程序交互时为应用程序提供适当的动画效果,这是达到更高层次的目标。虽然页面之间的平滑过渡提供了更好的用户体验,但如加载或内容渲染方式等动画效果可以保持用户与应用程序的互动。因此,我们接下来的重点是 Vue 中的过渡和动画,以及我们可以如何将这些概念应用到我们的 Vue 应用程序中。
Vue 过渡
与其他框架不同,Vue.js 为开发者提供了内置支持,用于动画化 Vue.js 应用程序,包括过渡和动画。过渡的实现方式简单直接,开发者可以轻松配置并将其添加到他们的应用程序中。Vue.js 过渡机制支持 CSS 过渡、使用 JavaScript 的程序性操作,甚至可以与第三方动画库(如 GSAP 或 Animate.css)集成。
首先,我们将讨论过渡和动画之间的区别。当组件(或元素)从一个状态移动到另一个状态时,就会发生过渡,例如在按钮上悬停、从一个页面导航到另一个页面、显示弹出模态框等。同时,动画类似于过渡,但并不局限于仅两个状态。了解过渡的基础知识将帮助你开始学习动画。
过渡元素
在此示例中,为了为单个组件或元素启用过渡,Vue.js 提供了内置的 transition
组件,它将围绕目标元素包裹,如 ./src/components/HelloWorld.vue
中所示:
<transition name="fade-in">
<h1>{{ msg }}</h1>
</transition>
transition
组件为任何目标元素或组件添加了两个过渡状态——enter
和 leave
,包括具有条件渲染(v-if
)和条件显示(v-show
)的组件。
此组件接收一个名为 name
的属性,它代表过渡的名称——在本例中是 fade-in
——也是过渡类名的前缀,将在下面讨论。
过渡类
Vue.js 实现了基于 CSS 和类的leave
/enter
过渡效果,因此过渡将通过一组类选择器应用于目标组件。
这些类选择器都有v-
前缀,以防在transition
组件上没有提供name
属性。还有一些标准类被分为两组。
第一组过渡类是用于enter
过渡的,当组件首次显示时。以下是一个enter
过渡类的列表:
v-enter
(或<name>-enter
):这是起始状态,在组件添加或更新之前添加到组件上。这个类将在过渡完成后从结束状态中移除。在src/components/HelloWorld.vue
的<style>
部分,我们将.fade-in-enter
起始状态设置为完全隐藏,使用opacity: 0
:
<style>
.fade-in-enter {
opacity: 0;
}
</style>
v-enter-active
(或<name>-enter-active
):这个类定义了组件在活动进入过渡时的延迟、持续时间和缓动曲线。它将在组件插入之前添加到组件上,在整个进入阶段应用于组件,并在效果完成后移除。
让我们添加.fade-in-enter-active
,它将在 3 秒内将透明度状态进行过渡:
.fade-in-enter-active {
transition: opacity 3s easein;
}
v-enter-to
(或<name>-enter-to
):这是进入的最后一个子状态,其中在组件插入后添加效果帧,并在效果完成后移除。在我们的例子中,我们不需要定义任何内容,因为此状态的opacity
值应该是1
。
第二组类包括leave
过渡,当组件被禁用或从视图中移除时触发:
-
v-leave
(或<name>-leave
):这是离开过渡的起始状态。类似于v-enter-to
,我们不需要为此状态定义样式效果。 -
v-leave-active
(或<name>-leave-active
):这个类在离开阶段应用,其行为类似于v-enter-active
。由于我们想要实现淡出效果,我们将使用与fade-in-enter-active
相同的样式:.fade-in-enter-active, .fade-in-leave-active { transition: opacity 3s ease-in; }
-
v-leave-to
(或<name>-leave-to
):这是与v-enter-to
具有相似行为的结束状态。由于组件将从视图中消失,我们将重用为enter
阶段的开始阶段定义的样式:.fade-in-enter, .fade-in-leave-to { opacity: 0; }
以下截图是到目前为止描述的所有transition
状态的总结:
图 7.1:过渡阶段图解
在本节中,我们探讨了进入和离开的三个不同过渡状态,还介绍了使用过渡状态在用户按下按钮时缓慢淡入一些文本的方法。
组件的动画
由于动画基本上是过渡的扩展形式(具有超过两个状态),因此它的应用方式与过渡相同,唯一的区别是v-enter
只会在由 Vue.js 触发的animationend
事件上被移除。
注意
animationend
是一个 DOM 事件,当 CSS 动画完成执行时触发,条件是目标元素仍然存在于 DOM 中,并且动画仍然附加到该元素上。
在下一个示例中,在<template>
部分,我们可以定义一个新的名为slide
的过渡,使用动画 CSS 效果作为显示 msg 的h1
元素的包装器。这个过渡提供了从左侧滑到中心在进入时的动画效果,在离开时则相反。
要开始,使用以下命令使用 CLI 生成一个vue
起始项目:
vue create hello-world
接下来,打开项目并进入src/components/HelloWorld.vue
,然后修改现有的<h1>{{msg}}</h1>
代码:
<transition name="slide">
<h1 v-if="show">{{ msg }}</h1>
</transition>
在<style>
中,我们需要为slide
动画效果定义关键帧:
@keyframes slide {
0% { transform: translateX(-100px)}
100% { transform: translateX(0px)}
}
相关的过渡类将被分配以下样式:
.slide-enter, .slide-leave-to {
transform: translateX(-100px);
}
.slide-enter-active {
animation: slide 5s;
}
.slide-leave-active {
animation: slide 5s reverse;
}
这意味着在进入的起始阶段和离开的结束阶段,文本位置将位于页面指定位置-100px
处。浏览器将使用滑动关键帧在 5 秒内对元素进行动画处理,并且在离开的活跃状态下,动画将正好与进入活跃阶段的动画相反。
你还想要添加一个显示数据变量。你可以通过修改现有的导出来实现,如下所示:
<script>
export default {
name: 'HelloWorld',
data: {
showHello: true,
},
props: {
msg: String
}
}
</script>
有了这些,我们就实现了我们的动画。接下来是下一个挑战:如果我们想将不同的动画或过渡效果组合到进入和离开状态,或者为这些状态使用外部 CSS 库,该怎么办?让我们看看自定义过渡类。
自定义过渡类
在本节中,我们再次从使用 vue create hello-world 创建的默认起始项目开始。我们不是设置过渡名称,让 Vue.js 机制填充所需的类名,而是可以通过以下属性提供自定义类,并替换传统默认值。
对于进入状态,使用以下代码:
-
enter-class
-
enter-active-class
-
enter-to-leave
对于离开状态,使用以下代码:
-
leave-class
-
leave-active-class
-
leave-to-class
我们将从一个基于之前示例的文件开始,但现在我们将对进入状态的活跃阶段使用swing
动画效果,对离开状态的活跃阶段使用tada
效果。我们将在transition
组件中定义enter-active-class
和leave-active-class
属性,如下所示:
<transition
name="slide"
enter-active-class="swing"
leave-active-class="tada"
>
<h1 v-if="show">{{ msg }}</h1>
</transition>
在<style>
部分,我们只需要定义.tada
和.swing
,无需任何后缀模式:
.tada {
animation-fill-mode: both;
animation-name: tada;
animation-duration: 3s;
}
.swing {
animation-fill-mode: both;
transform-origin: top center;
animation-duration: 2s;
animation-name: swing;
}
然后添加专用的关键帧来设置动画:
@keyframes tada {
0% {
transform: scale3d(1, 1, 1);
}
10%, 20% {
transform: scale3d(.8, .9, .8) rotate3d(0, 0, 1, -5deg);
}
30%, 50%, 70%, 90% {
transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 5deg);
}
40%, 60%, 80% {
transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -5deg);
}
100% {
transform: scale3d(1, 1, 1);
}
@keyframes swing {
20% { transform: rotate(5deg); }
40% { transform: rotate(-10deg); }
60% { transform: rotate(5deg); }
80% { transform: rotate(-10deg); }
100% { transform: rotate(0deg); }
}
你还想要添加一个显示数据变量。你可以通过修改现有的export
来实现,如下所示:
<script>
export default {
name: 'HelloWorld',
data: {
showHello: true,
},
props: {
msg: String
}
}
</script>
当我们使用yarn serve
命令运行应用程序时,我们将为进入和离开设置单独的动画。以下截图显示了屏幕现在的样子:
图 7.2:动作上的摆动动画效果
你应该看到欢迎文本在旋转的同时缩小,从图 7.2中显示的过渡到以下:
图 7.3:动作上的 tada 动画效果
在本节中,我们探讨了创建自定义过渡效果。作为示例,我们创建了swing
和tada
。我们通过在样式表中定义过渡类并为每个效果添加关键帧来实现这一点。这种技术可以用来创建各种自定义过渡效果。在下一节中,我们将探讨 JavaScript 钩子以及它们如何用于更复杂的动画。
JavaScript 钩子
如我们在上一节所学,我们可以使用自定义过渡类来集成外部第三方 CSS 动画库以实现样式效果。然而,有些外部库不是基于 CSS 的,而是基于 JavaScript 的,例如Velocity.js或GreenSock Animation API(GSAP),这些库需要通过 JavaScript 事件和外部动画处理程序设置钩子。
为了在 Vue 应用程序中使用 Velocity.js 或 GSAP 库,你需要分别使用npm install
或yarn add
命令安装它们,如下所示:
-
要安装 Velocity.js,请使用以下命令:
npm install velocity-animate #Or yarn add velocity-animate
-
要安装 GSAP,请使用以下命令:
npm install gsap #or yarn add gsap
作为 Vue.js 组件,transition
组件支持将自定义处理程序绑定到事件列表的 props 上。考虑以下示例:
<transition
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<h1 v-if="show">{{ msg }}</h1>
</transition>
我们将动画方法程序性地绑定到过渡元素的相关事件上:
-
beforeEnter
是在组件插入之前的动画状态——类似于 v-enter 阶段。 -
enter
用于整个进入阶段的动画——类似于 v-enter-active 阶段。 -
leave
用于整个离开阶段的动画。这类似于 v-leave-active 阶段。
我们需要在HelloWorld.vue
组件配置的methods
部分中定义这些事件处理程序:
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
},
data() {
return {
show: false
}
},
methods: {
beforeEnter() {
//...
},
enter() {
//...
},
leave() {
//...
}
}
}
</script>
在本例中,我们将使用 GSAP 库提供的TweenMax
和TimelineMax
功能来创建我们的动画事件,如下所示:
beforeEnter(el) {
el.style.opacity = 0;
},
enter(el, done) {
TweenMax.to(el, 2, {
opacity: 1,
fontSize: '20px',
onComplete: done
})
},
leave(el, done) {
const tl = new TimelineMax({
onComplete: done
});
tl.to(el, {rotation: -270,duration: 1, ease: "elastic"})
.to(el, {rotation: -360})
.to(el, {
rotation: -180,
opacity: 0
});
}
对于TweenMax
和TimelineMax
,to()
动画触发方法的语法相当简单:
TimelineMax.to(<element>, <effect properties>, <time position>)
TweenMax.to(<element>, <effect properties>, <time position>)
大多数效果属性与 CSS 的语法相似,因此它们不难学习和使用。此外,我们必须将事件发射器接收到的 done
回调传递给 onComplete
,以确保它被触发,并且钩子不会同步调用。另外,请注意,所有事件发射器也传递 el
,它是当前过渡元素的指针,用于使用。
除了这三个事件之外,我们还可以绑定其他事件,具体取决于动画和过渡的复杂度,例如 afterEnter
、enterCancelled
、beforeLeave
、afterLeave
和 leaveCancelled
。
请注意,如果你仅使用 JavaScript 进行过渡,强烈建议添加 v-bind:css="false"
(或 :css="false"
)。这是为了防止 Vue.js 侦测并应用任何相关的 CSS,从而避免意外地发生过渡干扰:
<transition
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
:css="false"
>
<h1 v-if="show">{{ msg }}</h1>
在本节中,我们探讨了如何使用外部 JavaScript 库进行动画。我们使用 GSAP 库实现了一个简单的缓动,利用其 TweenMax
和 TimelineMax
函数。
现在,让我们学习如何使用动画效果添加新消息。
练习 7.01:使用动画效果添加新消息
我们将创建一个消息编辑器,用户可以在其中编写和提交新消息。新消息将立即通过从右向左的滑动动画效果显示。
要访问此练习的代码文件,请参阅 packt.live/338ZXJv
:
注意
在开始此练习之前,运行 vue create
命令以生成 Vue 入门项目。
-
首先创建一个名为
MessageEditor.vue
的新组件,位于/src/components/
文件夹中。在这个组件中,<template>
将包含两个部分,textarea
用于编写新消息,并有一个提交按钮,以及section
用于显示新编写的消息:<template> <div> <div class="editor--wrapper"> <textarea ref="textArea" class="editor"> </textarea> <button @click="onSendClick()" class="editor--submit"> Submit</button> </div> <section v-if="message" class="message--display"> <h4>Your saved message: </h4> <span>{{message}}</span> </section> </div> </template>
-
接下来,将整个消息部分包裹在
transition
元素中,为我们的动画做准备。<transition name="slide-right"> <section v-if="message" class="message--display"> <h4>Your saved message: </h4> <span>{{message}}</span> </section> </transition>
-
我们需要一个具有更改消息文本方法的
export
组件。使用以下代码添加它:<script> export default { data() { return { message: '' } }, methods: { onSendClick() { const message = this.$refs.textArea.value; this.message = message; this.$refs.textArea.value = ''; } } } </script>
-
接下来,我们将使用以下命令在我们的
style
部分使用@keyframes
定义slide-right
动画效果:<style scoped> @keyframes slide-right { 100% { transform: translateX(0) } } </style>
这意味着它将具有此效果的元素在水平方向上(在 X 轴上)重新定位到原始起始点,(0,0)。
-
现在,我们将定义两个类,一个用于从左到右滑动(
slide-right
),另一个用于相反方向(slide-left
):.slide-right { animation: 1s slide-right 1s forwards; transform:translateX(-100%); transition: border-top 2s ease; } .slide-left { animation: 1s slide-right 1s reverse; transform:translateX(-100%); }
-
将
border-top:0
作为slide-right
过渡的起始点,以便对这一部分的border-top
产生一点效果:.slide-right-enter { border-top: 0; }
-
接下来,利用我们学到的关于自定义过渡类的知识,将
enter-active
绑定到slide-right
类,并将leave-active
类似地绑定到slide-left
。这三个属性被添加到在 步骤 2 中创建的transition
元素中:<transition name="slide-right" enter-active-class="slide-right" leave-active-class="slide-left" > Add CSS stylings using CSS Flexbox to make the editor look nice: .editor--wrapper { display: flex; flex-direction: column; } .editor { align-self: center; width: 200px; } .editor--submit { margin: 0.5rem auto; width: 50px; align-self: center; } .message--display { margin-top: 1rem; border-top: 1px solid lightgray; }
-
使用
yarn serve
命令运行应用程序。这将生成一个组件,将显示带有滑动动画效果的输入消息,如图 图 7.4 所示:
![图 7.4:消息编辑器文本区域
图 7.4:消息编辑器文本区域
以下截图显示了消息组件具有从左到右滑动动画效果的外观:
![图 7.5:用于显示的消息过渡
图 7.5:用于显示的消息过渡
从左侧动画进入后,组件应停在居中位置,如图 图 7.6 所示:
![图 7.6:动画后的消息
图 7.6:动画后的消息
这个练习帮助您熟悉 CSS 中的某些转换效果,例如 translateX
和 transition
。它还展示了在 Vue 应用程序中添加动画是多么容易。对于同一组中的多个元素,如列表,过渡怎么办?我们将在下一个主题中找到答案。
过渡组
到目前为止,我们已经介绍了 Vue 过渡元素的基本知识,适用于简单组件和元素,同时支持自定义 CSS 仅和 JavaScript 仅动画。接下来,我们将探讨如何使用 v-for
在一组组件上应用过渡,例如,同时渲染的项目列表。
Vue.js 为此特定目的提供了另一个组件,即 transition-group
组件。
我们现在假设我们有一个显示在源上的消息列表,我们希望为此列表添加一个过渡效果,以便在屏幕上每个项目出现时产生一些效果。在 ./src/components/Messages.vue
文件中,让我们用 transition-group
组件包裹主要容器,并传递我们之前为 transition
组件使用的相同属性。它们具有相同的属性类型:
<transition-group name="fade">
<p v-for="message in messages" :key="message" v-show="show">
{{message}}
</p>
</transition-group>
我们需要为传递为 fade
的过渡效果设置 CSS 样式效果,遵循与过渡类相同的语法规则:
.fade-enter-active, .fade-leave-active {
transition: all 2s;
}
.fade-enter, .fade-leave-active {
opacity: 0;
transform: translateX(30px);
}
使用 yarn serve
命令运行应用程序后,您的列表项在出现时将具有淡入效果。以下截图显示了您的屏幕应该如何显示:
![图 7.7:列表项的淡入
图 7.7:列表项的淡入
注意,与不渲染任何包装容器元素的 transition
组件不同,transition-group
将渲染一个实际元素,您可以通过使用 tag
prop 来更改元素标签名。默认使用的元素是 span
:
<transition-group
name="fade"
tag="div"
>
<p v-for="message in messages" :key="message" v-show="show">
{{message}}
</p>
</transition-group>
在浏览器中,实际的 HTML 输出将如下所示:
![图 7.8:根据标签属性渲染的过渡容器元素
图 7.8:根据标签属性渲染的过渡容器元素
此外,所有过渡类只应用于具有 v-for
属性的列表项元素,而不应用于包装器。
最后,你必须为每个列表项设置 :key
属性,以便 Vue.js 能够索引并知道要将转换应用到哪个项上。
我们现在将在列表上创建移动效果。
在过渡列表时创建移动效果
除了 transition
组件中提供的所有类之外,transition-group
还有一个类 v-move
,它允许我们在每个项移动到其位置时添加额外的效果。可以通过 move-class
属性手动分配:
.fade-move {
transition: transform 2s ease-in;
}
接下来,我们将探讨在页面或组件的初始渲染上制作动画。
在初始渲染时制作动画
通常,项目列表将在第一次初始页面加载时显示,我们的动画将不会工作,因为元素已经在视图中。为了触发动画,我们需要使用不同的转换属性 appear
,在页面加载后立即强制在初始页面渲染上进行动画:
<transition-group
appear="true"
tag="div"
>
<p v-for="message in messages" :key="message">{{message}}</p>
</transition-group>
我们还可以使用 v-on:after-appear
、v-on:appear
、v-on:after-appear
和 v-on:appear-cancelled
设置钩子,或者我们可以使用以下格式创建自定义类:
<transition-group
appear="true"
appear-class="fade-enter"
appear-active-class="fade-enter-active"
tag="div"
>
<p v-for="message in messages" :key="message">{{message}}</p>
</transition-group>
在渲染时进行动画是一个常用的功能,可以在许多情况下使用,例如像我们在这里所做的那样淡入组件。在下一节中,我们将探讨如何使用动画对消息列表进行排序。
练习 7.02:使用动画对消息列表进行排序
在这个简短的练习中,我们将向消息列表添加额外的功能:排序。在排序(A-Z 或 Z-A)时,列表将会有翻转动画效果。
要访问此练习的代码文件,请参阅 packt.live/35TFs5l
:
注意
在开始此练习之前,运行 vue create
命令以生成 Vue 入门项目。
-
我们将使用之前用于在
Messages.vue
组件中渲染消息的相同组件代码。列表将被transition-group
组件包裹,准备进行动画。并且不要忘记设置appear="true"
,或者简单地使用appear
,以便元素仅在页面加载完成后进行动画:<transition-group appear name="flip" tag="div" > <p v-for="message in messages" :key="message" class="message--item" >{{message}}</p> </transition-group>
-
使用
yarn serve
命令运行应用程序。这将生成以下输出:图 7.9:动画前的消息列表
-
没有动画,因为我们还没有为
flip
定义 CSS 动画样式。让我们来做。在src/components/Messages.vue
的<style>
部分中,我们将添加opacity: 0
并将列表中的每个元素垂直(在 Y 轴上)从原始位置移动20px
。这应该是元素进入flip-enter
或即将离开转换到flip-leave-to
的初始阶段:<style scoped> .flip-enter, .flip-leave-to { opacity: 0; transform: translateY(20px); } </style>
-
在相同的
<style>
部分,为每个消息元素(message-item
类)添加自定义 CSS 样式transition: all 2s
。这是为了确保元素的过渡效果将在2
秒内完成所有 CSS 属性的转换:.message--item { transition: all 2s; }
-
一旦
flip-move
开始工作,我们只需要为transform
(之前定义为垂直20px
偏移)添加过渡效果。我们可以完美地看到每个消息的上下移动效果。此外,我们还需要在过渡处于离开阶段中间时添加position: absolute
:.flip-leave-active { position: absolute; } .flip-move { transition: transform 1s; }
-
我们接下来将添加三个按钮——允许从 A 到 Z 排序、从 Z 到 A 排序以及随机洗牌:
<button @click="sorting()">Sort A-Z</button> <button @click="sorting(true)">Sort Z-A</button> <button @click="shuffle()">Shuffle</button>
-
我们还需要添加我们的基本组件导出代码以及我们的消息源数据。请随意使用您喜欢的任何内容作为您的消息:
export default { data() { return { messages: [ 'Hello, how are you?', 'The weather is nice', 'This is message feed', 'And I am the fourth message', 'Chapter 7 is fun', 'Animation is super awesome', 'Sorry, I didn't know you called', 'Be patient, animation comes right up' ], show: false } }, }
-
接下来,我们将添加排序和洗牌的逻辑。
methods
部分应该位于上一步创建的组件export
内部:methods: { sorting(isDescending) { this.messages.sort(); if (isDescending) { this.messages.reverse(); } }, shuffle() { this.messages.sort(() => Math.random() - 0.5); } }
点击按钮后的输出将类似于以下内容:
图 7.10:排序动画中的消息列表
在这个练习中,我们学习了如何根据元素顺序的变化,使用transition-group
动态地为组件列表添加翻转动画效果。接下来,让我们探索如何在页面之间导航时应用过渡效果。
过渡路由
通过结合 Vue Router 的router-element
组件和transition
组件,我们可以轻松地设置用户在从一个 URL(路由)导航到另一个 URL 时的过渡效果。
为了让您有更深入的理解,我们在以下部分演示了一个基本案例,其中用户从网站的home
页面重定向到about
页面。
让我们将router-element
包裹在transition
中,并添加name="zoom"
属性:
<transition
name="zoom"
mode="out-in"
>
<router-view/>
</transition>
在这里,我们将使用mode
属性来指示过渡模式。目前有两种模式可供设置:
-
in-out
:新元素首先进入,然后当前元素才会从视图中消失。 -
out-in
:当前元素首先消失,然后新元素才会进入。我们将使用这个模式作为示例,它比上一个模式更常见。
然后,我们只需像往常一样设置带有过渡类的过渡 CSS 效果,任务就完成了。就这么简单:
/**Zoom animation **/
.zoom-enter-active,
.zoom-leave-active {
animation-duration: 0.3s;
animation-fill-mode: both;
animation-name: zoom;
}
.zoom-leave-active {
animation-direction: reverse;
}
@keyframes zoom {
from {
opacity: 0;
transform: scale3d(0.4, 0.4, 0.4);
}
100% {
opacity: 1;
}
}
在本节中,我们探讨了过渡路由。过渡效果是在路由渲染之间发生的动画,例如从一个页面导航到另一个页面。在下一节中,我们将探讨如何在我们的应用程序中为每个导航的路由创建过渡效果。
练习 7.03:为每个导航的路由创建过渡效果
在此练习中,我们将根据 过渡路由 部分学到的关于路由元素过渡的知识,为不同的路由创建不同的过渡效果。默认效果将是 fade
。
要访问此练习的代码文件,请访问 packt.live/376DoXo
:
注意
在开始此练习之前,运行 vue create
命令以生成 Vue 入门项目。
-
使用 Vue Router 创建一个简单的应用程序,并在
src/views/
文件夹中添加一个名为Messages.vue
的路由,为messages
添加一个路由。使用前一个练习中的代码,并在App.vue
中添加一个指向新创建的路由的链接。 -
接下来,我们在
App.vue
中将router-view
元素包裹在transition
组件中:<transition :name="transition" :mode="mode"> <router-view/> </transition>
-
在
App.vue
的export
部分中,确保data
函数包含transition
和mode
的值,如下所示:data() { return { transition: 'fade', mode: 'out-in', }; },
-
在
App.vue
中使用以下 CSS 添加淡入淡出的样式:<style> .fade-enter, .fade-leave-to { opacity: 0; } .fade-enter-active, .fade-leave-active { transition: opacity 1s ease-in; } </style>
-
到目前为止,所有页面都使用
fade
效果加载,包括/messages
。但我们希望消息页面使用不同的效果——zoom
效果。接下来,在同一个style
标签内添加相关的zoom
动画 CSS 代码:/**Zoom animation */ .zoom-enter-active, .zoom-leave-active { animation-duration: 0.5s; animation-fill-mode: both; animation-name: zoom; } .zoom-leave-active { animation-direction: reverse; } @keyframes zoom { from { opacity: 0; transform: scale3d(0.4, 0.4, 0.4); } 100% { opacity: 1; } }
-
我们现在将使用以下代码帮助添加一些标准的 CSS 样式以应用于应用程序的默认布局:
#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; }
-
现在我们需要将
/messages
路由与这个特定的过渡效果相匹配,同时不影响其他路由。为了做到这一点,我们需要在src/router/index.js
中的此路由配置中添加一个名为transition
的字段:{ path: '/messages', name: 'messages', meta: { transition: 'zoom', }, component: () => import(/* webpackChunkName: "about" */ '../views/Messages.vue') }
-
检查您的
routes
对象的代码,以确认它与以下代码相同。在这里,我们将我们应用程序的每个 URL 与一个视图文件相匹配:const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') }, { path: '/messages', name: 'messages', meta: { transition: 'zoom', }, component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue') } ]
-
这在浏览器中不会显示,因为此过渡声明尚未与
App.vue
组件的data
字段绑定,并且需要在视图开始加载之前绑定。为此,我们将利用 第六章 中提到的$router
全局变量的created
生命周期钩子和beforeEach
路由钩子。 -
让我们在
App.vue
中添加一个在每次路由更改之前的钩子。我们将检查目标路由(to
)是否有自定义的transition
效果。如果有,我们将把App
实例中的transition
值映射到它;否则,在继续导航之前,我们将使用回退默认值,如下所示:created() { this.$router.beforeEach(( to, // The destination route from, //The source route next //The function to trigger to resolve the hook ) => { let transition = 'fade'; if (to.meta && to.meta.transition) { transition = to.meta.transition; } this.transition = transition; next(); }) }
-
使用以下命令运行应用程序:
yarn serve
-
现在如果您在浏览器中打开
localhost:8080
并导航到/messages
,您应该会看到类似于 图 7.11 的内容:
图 7.11:带有缩放效果的导航到 /messages
在导航到其他路由时,我们应该看到 图 7.12 中显示的默认过渡效果:
图 7.12:带有淡入效果的导航到 /home
这个练习演示了我们可以如何通过结合正确的钩子和方法,以最少的努力在不同的页面上设置不同的过渡。你可以通过外部库进一步实验,以使你的应用程序动画更加平滑和生动。
使用 GSAP 库进行动画
GSAP 是一个专注于使用 JavaScript 进行快速动画的开源脚本库,并提供跨平台的兼容性支持。它支持在广泛的元素类型上动画,例如矢量图形 (SVG)、React 组件、画布等。
GSAP 是灵活的,易于安装,并能适应任何配置,从 CSS 属性或 SVG 属性到将对象渲染到画布上的数值。
核心库是一套不同的工具,分为核心和其他,例如插件、缓动工具和实用工具。
安装 GSAP
使用 npm install
或 yarn add
可以直接安装 GSAP:
yarn add gsap
#or
npm install gsap
安装后,你应该会看到一个类似于以下截图的成功输出:
![图 7.13:成功安装后的结果
图 7.13:成功安装后的结果
现在我们已经安装了 GSAP,我们将看看 GSAP 中的基本缓动动画。
基本缓动动画
缓动是由 GSAP 库的创建者定义的一个概念,是一个高性能的设置器,用于执行所有基于用户配置输入的所需动画工作。输入可以是动画的目标对象、一个时间段或任何特定的 CSS 属性。在执行动画时,缓动根据给定的持续时间确定 CSS 属性的值,并相应地应用它们。
以下是一些创建基本缓动动画的基本方法。
gsap.to()
最常用的缓动是 gsap.to()
,它用于创建动画,基于两个主要参数:
-
#myId
。 -
透明度:0
,旋转:90
,或字体大小:'20px'
,动画属性如持续时间:1
,延迟:0.2
,或缓动:"弹性"
,以及事件处理程序属性如onComplete
或onUpdate
。
例如,如果我们想在 HelloWorld.vue
中动画化 Vue 的标志,我们运行以下代码:
gsap.to(el, {duration: 3, opacity: 1, onComplete: done});
或者使用以下方法通过 x
属性移动一个对象(与 transform: translateX()
相同):
gsap.to(".green", {duration: 3, x: 500, rotation: 360});
gsap.from() 和 gsap.fromTo
我们并不总是想为视图中元素定义预期的动画效果。相反,我们定义动画应该从目标元素开始时的默认值。这就是我们使用 gsap.from()
的时候。
例如,假设一个盒子的当前 透明度
值为 1
,缩放
值为 1
,x
位置为 0
,我们想要设置一个动画,从位置 x
的 300
,透明度
值为 0
,缩放
值为 0.5
开始。换句话说,动画将从 {x: 300, 透明度: 0, 缩放: 0.5}
开始,到元素当前拥有的任何值:
gsap.from(".red", {duration: 3, x: 300, scale: 0.5, opacity: 0});
但在许多情况下,我们需要为动画设置起始值和结束值,因为单侧不够好。为此目的,GSAP 提供了gsap.fromTo()
,其语法如下:
gsap.fromTo(target, fromValues, toValues)
让我们定义一个动画,将一个灰色框从原始值{ opacity: 0, scale: 0.5, x: 300 }
转换为值{ opacity: 1, scale: 1, x: 100, rotation: 360}
:
gsap.fromTo(".grey",
{ duration: 3, opacity: 0, scale: 0.5, x: 600 },
{ duration: 3, opacity: 1, scale: 1, x: 200, rotation: 360}
)
为了将所有类似 CSS 的值转换为相应的 CSS 值,GSAP 的核心插件之一是CSSPlugin
。此插件将自动检测目标是否为 DOM 元素,拦截传递的值,将它们转换为适当的 CSS 值,然后相应地将其作为内联样式应用到元素上。
在下一节中,我们将通过一个使用 GSAP 创建简单缓动的练习进行讲解。
练习 7.04:使用 GSAP 进行缓动
本练习的目标是让您熟悉使用外部库,如 GSAP。我们将制作一个简单的动画,但您可以在 Vue 代码的任何地方应用此相同的模式。我们将在挂载时应用动画,但 JavaScript 动画可以根据计时器、随机整数或按钮等输入动态触发。
要访问此练习的代码文件,请访问packt.live/3kVO4gm
:
注意
在开始此练习之前,运行vue create
命令以生成 Vue 入门项目。
-
通过运行以下命令创建一个 Vue 项目:
vue create Exercise7.04
-
使用
yarn
或npm
通过以下命令之一安装 GSAP:yarn add gsap # OR npm install gsap
-
在
src/App.vue
中导入 GSAP:import gsap from 'gsap'
-
在
src/App.vue
中找到现有的img
标签,并按照以下方式添加ref="logo"
:<img ref="logo" alt="Vue logo" src="img/logo.png">
-
在
src/App.vue
中导出的对象中添加一个名为mounted
的函数,该函数将 logo 定义为变量并添加一个动画,该动画为10
次旋转,持续30
秒:mounted() { const { logo } = this.$refs; gsap.to(logo, {duration: 30, rotation: 3600}); }
-
接下来,通过在终端运行
yarn serve
来启动应用程序。 -
打开您的浏览器到
localhost:8080
,您应该看到默认的 Vue 入门页面,但带有旋转的 logo,如下面的截图所示:![图 7.14:使用 GSAP 的简单动画![img/B15218_07_14.jpg]
图 7.14:使用 GSAP 的简单动画
在这个练习中,我们使用 GSAP 在 Vue 中实现了一个简单的旋转动画。接下来,我们将看到如何通过缓动修改动画的外观和感觉。
使用缓动修改外观和感觉
缓动很重要,因为它决定了动画的原点与目的地之间的运动风格。它控制缓动过程中的变化率;因此,用户有时间看到效果,无论是平滑、突然、弹跳还是其他过渡效果:
gsap.from(".bubble", 2, {
scale: 0.2,
rotation: 16,
ease: "bounce",
})
此外,GSAP 还提供了额外的内置插件,用于配置额外的缓动效果,例如 power、back、elastic 等。以气泡效果为例;为了使运动在一定范围内平滑,我们使用Back.easeOut.config()
和Elastic.easeOut.config()
并传递相关设置:
gsap.to(".bubble", 2, {
scale: 0.2,
rotation: 16,
ease: Back.easeOut.config(1.7),
})
gsap.to(".bubble", 4, {
scale: 1.2,
rotation: '-=16',
ease: Elastic.easeOut.config(2.5, 0.5),
})
使用ease
,我们可以根据设置的样式使相同的动画看起来完全不同。接下来,我们将探讨stagger
,这是另一个影响动画外观和感觉的选项。
使用交错修改外观和感觉
在前面的章节中,我们介绍了如何使用 Vue 过渡来动画化一系列项目。对于对象列表,我们应该考虑交错动画,因为它使得此类目标的动画变得简单,并且每个项目动画之间有适当的延迟。
例如,通过将值分配给stagger
属性,我们可以在除了延迟持续时间数字(以毫秒为单位)之外创建并应用一些配置选项:
gsap.to('.stagger-box', 2, {
scale: 0.1,
y: 60,
yoyo: true,
repeat: 1,
ease: Power1.inOut,
delay:1,
stagger: {
amount: 1.5,
grid: "auto",
from: "center"
}
})
您可以使用repeat
来定义动画应该重复多少次。负数将使其无限重复。
使用时间线
时间线是您完全控制的缓动调度,用于定义缓动之间的重叠或间隔。当您需要根据顺序控制一组动画、构建一系列动画、链式动画以进行最终回调或模块化动画代码以实现可重用时,它非常有用。
为了使用时间线,您可以选择使用内置的gsap.timeline()
方法创建时间线实例,或者从核心库中导入TimelineMax
或TimelineLite
并使用一组配置设置实例,如下所示:
import { TimelineMax } from 'gsap';
const tl = new TimelineMax({
onComplete: done
})
//OR
const tl = gsap.timeline();
我们将简要介绍时间线的两个主要用例,排序和链式。
排序
与 GSAP 类似的核心功能,时间线也提供了to()
、from()
和fromTo()
方法。默认情况下,所有动画都可以按顺序排列,有选项强制使用position
属性来控制事物在哪里或何时进行,这是一个可选参数:
var tl = gsap.timeline({ repeat: -1});
tl.to("#box-green", {duration: 2, x: 550})
//1 second after end of timeline (gap)
tl.to("#box-red", {duration: 2, x: 550, scale: 0.5}, "+=1")
//0.5 seconds before end of timeline (overlap)
tl.to("#box-purple", {duration: 2, rotation: 360, x:550, scale: 1.2, ease: "bounce"}, "-=1")
在本节中,我们探讨了使用 GSAP 时间线功能来安排一系列动画,这些动画一个接一个地运行,有些有间隔,有些有重叠。在下一节中,我们将进一步探讨使用链式概念排序动画。
链式
与排序类似,链式排列将动画按顺序排列。而不是每次单独调用每个动画的实例方法,它将被放置在链中。在子缓动之间使用的所有特殊值都可以定义,或者在实例中创建为默认值,或者也可以在第一次调用中,获取链中的其他时间线(动画列表)以继承这些值:
var tl = gsap.timeline({ defaults: { duration: 2 }, repeat: -1});
tl.to("#box-green", { x: 550 })
.to("#box-red", { scale: 0.5, x: 450 })
.to("#box-purple", { scale: 1.2, ease: "bounce", x: 500 })
我们还可以使用position
拦截每个链式时间线的定时位置,正如之前所描述的:
tl.to("#box-green", { x: 550 })
.to("#box-red", { scale: 0.5, x: 450 }, "+=1")
.to("#box-purple", { scale: 1.2, ease: "bounce", x: 500 }, "-=1")
GSAP 有非常详细的文档,所以只需访问greensock.com/get-started
并开始动画。
在下一节中,我们将基于我们对 GSAP 的了解来制作一个带有动画过渡的消息查看应用。
活动 7.01:使用过渡和 GSAP 构建消息应用
在此活动中,你将使用 CSS 编写自定义过渡,使用过渡组和路由进行更复杂的过渡,并使用第三方过渡库如 GSAP 在应用中进行动画和过渡。你将创建一个简单的消息应用,利用过渡效果:
注意
在开始此练习之前,运行vue create
命令以生成 Vue 入门项目。
-
创建一个
Messages
路由(在src/views/Messages.vue
),用于渲染两个嵌套视图:Messages
(src/views/MessageList.vue
)显示消息列表和MessageEditor
(src/views/MessageEditor.vue
)包含一个textarea
和一个用于创建新消息的提交按钮。 -
创建一个
Message
路由(在src/views/Message.vue
),用于渲染具有给定 ID 的单条消息视图。 -
注册所有路由。
-
在
src/App.vue
文件中为主router-view
添加一个简单的过渡名称fade
和out-in
模式。 -
通过使用自定义过渡类,将过渡添加到
src/views/Messages.vue
中的嵌套router-view
。 -
编写一个动画效果,在进入路由时放大,在离开路由时缩小。
-
为离开事件编写另一个淡入动画效果。
-
在
MessageList.vue
的消息列表中添加一个弹入效果的过渡。 -
使用 GSAP 动画实现弹入效果。
-
为出现的项目添加移动效果。
-
当从列表页面导航到编辑页面时,你应该看到内容流滑向左侧,同时编辑器出现,如图图 7.15所示:
图 7.15:从消息列表视图导航到编辑视图时淡出
当从消息视图导航到编辑视图时,你应该看到文本输入向左滑动,如图图 7.16所示:
图 7.16:从编辑视图导航到消息列表视图时淡出
接下来,消息列表将以弹跳效果显示,数字旋转,如图图 7.17所示:
图 7.17:在消息列表视图中显示消息源时的弹跳效果
当点击特定的消息,例如我们的例子中的0
或1
,我们的列表将向左滑动,你应该看到消息内容,如图图 7.18所示:
图 7.18:单条消息视图
注意
此活动的解决方案可以通过此链接找到。
摘要
在本章中,我们探讨了 Vue.js 内置对过渡和动画的支持,包括对单个和多个组件的支持,并看到了如何轻松地设置它们。到这一点,你已经为路由和组件创建了过渡和动画效果,并见证了 Vue.js 过渡的所有基本功能:自定义过渡类、分组过渡和过渡模式。此外,你还了解到了其他领先的第三方动画库,如 GSAP,并看到了如何将它们与你的 Vue 应用程序集成,以便在网页上获得更好的动画效果。
下一章将重点介绍构建生产就绪的 Vue 应用程序的关键主题之一,即状态管理,以及应用程序内部组件如何使用 Vuex(一个状态管理库)相互通信。
第八章:8. Vue.js 状态管理的状态
概述
到本章结束时,你将能够使用和对比在 Vue.js 应用程序中共享状态和保持全局状态的方法。为此,你将使用一个共享的祖先组件来保持那些没有父子关系的组件(兄弟组件)所需的状态。你还将熟悉 Vue.js 应用程序上下文中的事件总线。随着我们的深入,你将了解何时以及如何利用 Vuex 进行状态管理,以及与其他解决方案(如事件总线或 Redux)相比的优势和劣势。在本章的末尾,你将熟悉选择哪些状态部分应该存储在全局和本地,以及如何将它们结合起来构建一个可扩展且性能良好的 Vue.js 应用程序。
在本章中,我们将探讨 Vue.js 状态管理的状态,从局部状态到基于组件的状态共享模式,再到更高级的概念,如利用事件总线或 Vuex 等全局状态管理解决方案。
简介
在本章中,我们将探讨 Vue 中状态管理的概念。
在前面的章节中,我们看到了如何使用局部状态和props
来保持状态并在父子组件层次结构中共享它。
我们将首先展示如何利用state
、props
和events
在不是父子配置的组件之间共享状态。这类组件被称为兄弟组件。
![Figure 8.1: 子组件 1 和子组件 2 是“兄弟”组件
![img/B15218_08_01.jpg]
图 8.1:子组件 1 和子组件 2 是“兄弟”组件
在本章的整个过程中,我们将构建一个个人资料卡片生成器应用程序,以展示状态如何在应用程序中以 props 的形式向下流动到组件树,以及如何使用事件、事件总线和存储更新来向上传播更新。
既然我们想要构建一个Header
,其中我们将有全局控件并显示页面标题;一个ProfileForm
,我们将捕获数据;最后,一个ProfileDisplay
,我们将显示个人资料卡片。
![Figure 8.2: 表示个人资料卡片应用程序组件树
![img/B15218_08_02.jpg]
图 8.2:表示个人资料卡片应用程序组件树
我们现在已经看到了如何推理组件树以及我们的应用程序如何在组件树中结构化。
在公共祖先组件中保持状态
为了仅使用组件状态和props
来保持状态并使用事件来更新它,我们将将其存储在最近的公共祖先组件中。
状态仅通过props
进行传播,并且仅通过events
进行更新。在这种情况下,所有state
都将存在于需要状态的组件的共享祖先中。由于 App 组件是根组件,因此它是保持共享状态的理想默认选项。
![Figure 8.3: 公共祖先组件通过 props 和事件传播保持状态
![img/B15218_08_03.jpg]
图 8.3:常见的祖先组件通过属性和事件传播持有状态
要更改 state
,组件需要向持有状态(共享祖先)的组件发出 event
。共享祖先需要根据事件数据和类型更新 state
。这反过来又会导致重新渲染,在此期间,祖先组件将更新的 props
传递给读取 state
的组件。
![图 8.4:当祖先持有状态时更新兄弟组件]
图 8.4:当祖先持有状态时更新兄弟组件
要构建一个标题,我们需要在 AppHeader.vue
文件中创建一个 AppHeader
组件,它将包含一个模板和一个带有 TailwindCSS 类的 h2
标题:
<template>
<header class="w-full block p-4 border-b bg-blue-300 border-gray-700">
<h2 class="text-xl text-gray-800">Profile Card Generator</h2>
</header>
</template>
然后,我们将导入它,注册它,并在 App.vue
文件中渲染它:
<template>
<div id="app">
<AppHeader />
</div>
</template>
<script>
import AppHeader from './components/AppHeader.vue'
export default {
components: {
AppHeader
}
}
</script>
上述代码的输出将如下所示:
![图 8.5:在个人资料卡片生成器中显示的 AppHeader]
图 8.5:在个人资料卡片生成器中显示的 AppHeader
我们将同样创建一个 AppProfileForm
文件:
<template>
<section class="md:w-2/3 h-64 bg-red-200 flex">
<!-- Inputs -->
</section>
</template>
我们将创建一个 AppProfileDisplay
文件,其初始内容如下:
<template>
<section class="md:w-1/3 h-64 bg-blue-200 flex">
<!-- Profile Card -->
</section>
</template>
我们的两个容器(AppProfileForm
和 AppProfileDisplay
)现在都可以导入并在 App
中渲染:
<template>
<!-- rest of template, including AppHeader -->
<div class="flex flex-col md:flex-row">
<AppProfileForm />
<AppProfileDisplay />
</div>
<!-- rest of template -->
</template>
<script>
// other imports
import AppProfileForm from './components/AppProfileForm.vue'
import AppProfileDisplay from './components/AppProfileDisplay.vue'
export default {
components: {
// other component definitions
AppProfileForm,
AppProfileDisplay,
}
}
</script>
上述代码的输出将如下所示:
![图 8.6:带有 AppHeader、AppProfileForm 和 AppProfileDisplay 的 App 骨架]
图 8.6:带有 AppHeader、AppProfileForm 和 AppProfileDisplay 的 App 骨架
要添加一个表单字段,在这种情况下是 name
,我们首先将在 AppProfileForm
中添加一个输入:
<template>
<section class="md:w-2/3 h-64 bg-red-200 flex flex-col p-12 items-center">
<!-- Inputs -->
<div class="flex flex-col">
<label class="flex text-gray-800 mb-2" for="name">Name
</label>
<input
id="name"
type="text"
name="name"
class="border-2 border-solid border-blue-200 rounded px-2 py-1"
/>
</div>
</section>
</template>
上述代码将显示如下:
![图 8.7:带有名称字段和标签的 AppProfileForm]
图 8.7:带有名称字段和标签的 AppProfileForm
为了跟踪名称输入数据,我们将使用 v-model
添加双向绑定到它,并在组件的 data
初始化器中设置一个 name
属性:
<template>
<!-- rest of the template -->
<input
id="name"
type="text"
name="name"
class="border-2 border-solid border-blue-200 rounded px-2 py-1"
v-model="name"
/>
<!-- rest of the template -->
</template>
<script>
export default {
data() {
return {
name: '',
}
}
}
</script>
我们还需要一个 submit
按钮,点击时通过发出包含表单内容的 submit
事件将表单数据发送到父组件:
<template>
<!-- rest of template -->
<div class="flex flex-row mt-12">
<button type="submit" @click="submitForm()">Submit</button>
</div>
<!-- rest of template -->
</template>
<script>
export default {
// rest of component
methods: {
submitForm() {
this.$emit('submit', {
name: this.name
})
}
}
}
</script>
这将显示如下:
![图 8.8:连接好的提交按钮的 AppProfileForm]
图 8.8:连接好的提交按钮的 AppProfileForm
下一步是将表单状态存储在 App
组件中。由于它是 AppProfileForm
和 AppProfileDisplay
的共同祖先,因此它是存储表单状态的理想选择。
首先,我们需要一个由 data()
返回的 formData
属性。我们还需要一种更新 formData
的方法。因此,我们将添加一个 update(formData)
方法:
<script>
export default {
// rest of component
data() {
return {
formData: {}
}
},
methods: {
update(formData) {
this.formData = formData
}
}
// rest of component
}
</script>
接下来,我们需要将 update()
绑定到由 AppProfileForm
发出的 submit
事件。我们将使用 @submit
简写和魔法事件对象表示法 update($event)
来完成此操作:
<template>
<!-- rest of template -->
<AppProfileForm @submit="update($event)" />
<!-- rest of template -->
</template>
要在 AppProfileDisplay
内显示名称,我们需要将 formData
作为属性添加:
<script>
export default {
props: {
formData: {
type: Object,
default() {
return {}
}
}
}
}
</script>
我们还需要使用formData.name
显示名称。我们将向容器添加一个p-12
类来改善组件的外观:
<template>
<section class="md:w-1/3 h-64 bg-blue-200 flex p-12">
<!-- Profile Card -->
<h3 class="font-bold font-lg">{{ formData.name }}</h3>
</section>
</template>
最后,App
需要将formData
作为 prop 传递给AppProfileDisplay
:
<template>
<!-- rest of template -->
<AppProfileDisplay :form-data="formData" />
<!-- rest of template -->
</template>
我们现在能够更新表单上的名称。当你点击提交
按钮时,它将在配置文件显示中显示如下:
图 8.9:App 存储状态,通过 props 传递给 AppProfileDisplay
我们现在已经看到如何在App
组件上存储共享状态,以及如何从AppProfileForm
更新它并在AppProfileDisplay
中显示它。
在下一个主题中,我们将看到如何向配置文件生成器添加一个额外的字段。
练习 8.01:向配置文件生成器添加职业字段
在存储name
共享状态的例子之后,另一个有趣的字段是个人职业。为此,我们将在AppProfileForm
中添加一个occupation
字段来捕获这个额外的状态,并在AppProfileDisplay
中显示它。
要访问此练习的代码文件,请参阅packt.live/32VUbuH
。
-
首先要做的就是在
src/components/AppProfileForm
中添加新的occupation
字段。我们也将借此机会移除section
元素上的h-64
和bg-red-200
类(如果存在),这意味着表单将没有背景和固定高度:<template> <section class="md:w-2/3 flex flex-col p-12 items-center"> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="occupation">Occupation</label> <input id="occupation" type="text" name="occupation" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </section> </template>
上述代码的输出将如下所示:
图 8.10:带有新职业字段的 AppProfileForm
-
为了使用双向数据绑定跟踪
occupation
的值,我们将向data()
属性的输出添加一个新属性:<script> export default { // rest of component data() { return { // other data properties occupation: '', } }, // rest of component }
-
我们现在将使用
v-model
从occupation
响应式数据属性到occupation
输入进行双向数据绑定:<template> <!-- rest of template --> <input id="occupation" type="text" name="occupation" v-model="occupation" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> <!-- rest of template --> </template>
-
为了在点击
提交
时传输occupation
值,我们需要将其添加到submitForm
方法中作为submit
事件负载的属性:<script> export default { // rest of component methods: { submitForm() { this.$emit('submit', { // rest of event payload occupation: this.occupation }) } } } </script>
-
添加此字段的最后一步是在
AppProfileDisplay
组件中显示它。我们添加一个带有几个样式类的段落。我们也将借此机会从容器中移除h-64
和bg-blue-200
类(如果存在):<template> <section class="md:w-1/3 flex flex-col p-12"> <!-- rest of template --> <p class="mt-2">{{ formData.occupation }}</p> </section> </template>
我们的浏览器应该看起来如下:
图 8.11:AppProfileForm
正如我们刚刚看到的,使用共同祖先来管理状态添加新字段是一个在事件中向上传递数据并在 props 中向下传递到读取组件的情况。
我们现在将看到如何使用Clear
按钮重置表单和配置文件显示。
练习 8.02:向配置文件生成器添加清除按钮
当我们使用应用程序创建新配置文件时,能够重置配置文件是有用的。为此,我们将添加一个Clear
按钮。
一个Clear
按钮应该重置表单中的数据,同时也重置AppProfileDisplay
中的数据。要访问这个练习的代码文件,请参阅packt.live/2INsE7R
。
现在让我们看看执行这个练习的步骤:
-
我们希望显示一个
Clear
按钮。我们将借此机会改进Clear
和Submit
按钮的样式(在src/components/AppProfileForm.vue
中):<template> <!-- rest of template --> <div class="w-1/2 flex md:flex-row mt-12"> <button class="flex md:w-1/2 justify-center" type="button" > Clear </button> <button class="flex md:w-1/2 justify-center" type="submit" @click="submitForm()" > Submit </button> </div> <!-- rest of template --> </template>
-
要清除表单,我们需要重置
name
和occupation
字段。我们可以在src/components/AppProfileForm.vue
中创建一个clear
方法来完成这个操作:<script> export default { // rest of the component methods: { // other methods clear() { this.name = '' this.occupation = '' } } // rest of the component }
-
我们希望将
clear
方法绑定到Clear
按钮的click
事件上以重置表单(在src/components/AppProfileForm.vue
中):<template> <!-- rest of template --> <button class="flex md:w-1/2 justify-center" Submit button, it will propagate data to AppProfileDisplay as follows:Figure 8.13: AppProfileForm and AppProfileDisplay with data filled in and submitted with a Clear buttonUnfortunately, `AppProfileDisplay` still has stale data, as shown in the following screenshot:Figure 8.14: AppProfileForm and AppProfileDisplay with only AppProfileForm cleared AppProfileDisplay still has stale data
-
要清除
AppProfileDisplay
的内容,我们需要通过在src/components/AppProfileForm.vue
中发出一个带有空有效负载的submit
事件来更新App.vue
中的formData
:<script> export default { // rest of component methods: { // other methods clear() { // rest of the clear() method this.$emit('submit', {}) } } } </script>
当我们填写表单并提交时,它看起来如下所示:
图 8.15:填写并提交数据的 AppProfileForm 和 AppProfileDisplay,带有清除按钮
我们可以点击Clear
并按照以下截图重置AppProfileDisplay
和AppProfileForm
中显示的数据:
图 8.16:清除数据后的 AppProfileForm 和 AppProfileDisplay(使用清除按钮)
我们已经看到了如何通过共同祖先设置兄弟组件之间的通信。
注意
要跟踪应用程序中需要保持同步的所有状态片段,需要做大量的记录和心智工作。
在下一节中,我们将探讨事件总线是什么以及它如何帮助我们缓解遇到的一些问题。
事件总线
我们将要探讨的第二种情况是当存在全局事件总线时。
事件总线是一个实体,我们可以在这个实体上发布和订阅事件。这允许应用程序的不同部分保持自己的状态并保持同步,而无需将事件传递给或从共同的祖先传递下来。
图 8.17:利用事件总线的读取组件和更新组件的序列图
为了提供这一点,我们的事件总线需要提供一个subscribe
方法和publish
方法。能够取消订阅也是有用的。
Vue 实例是一个事件总线,因为它提供了三个关键操作:main.js
文件:
import Vue from 'vue'
const eventBus = new Vue()
我们的事件总线有几个方法,即$on
,它是$on(eventName, callback)
:
// rest of main.js file
console.log('Registering subscriber to "fieldChanged"')
eventBus.$on('fieldChanged', (event) => {
console.log(`Received event: ${JSON.stringify(event)}`)
})
我们可以使用$emit
来触发订阅者回调。$emit(eventName, payload)
是事件总线的$emit
支持两个参数——事件的名称(作为字符串)和负载,这是可选的,可以是任何对象。它可以如下使用:
// rest of main.js file
console.log('Triggering "fieldChanged" for "name"')
eventBus.$emit('fieldChanged', {
name: 'name',
value: 'John Doe'
})
console.log('Triggering "fieldChanged" for "occupation"')
eventBus.$emit('fieldChanged', {
name: 'occupation',
value: 'Developer'
})
在浏览器中运行此文件将产生以下控制台输出,其中首先注册了订阅者,然后在每次$emit
上触发回调:
图 18.18:使用订阅者和两个事件发布的 Vue.js 实例作为事件总线的控制台输出
$off
,即取消订阅操作,需要使用与订阅操作相同的参数调用。即两个参数,事件名称(作为字符串)和回调(在每次事件发布时以事件作为参数运行)。为了正确使用它,我们需要使用函数的引用(而不是内联匿名函数)来注册订阅者:
// rest of main.js, including other subscriber
const subscriber = (event) => {
console.log('Subscriber 2 received event: ${JSON.stringify (event)}')
}
console.log('Registering subscriber 2')
eventBus.$on('fieldChanged', subscriber)
console.log('Triggering "fieldChanged" for "company"')
eventBus.$emit('fieldChanged', {
name: 'company',
value: 'Developer'
})
console.log('Unregistering subscriber 2')
eventBus.$off('fieldChanged', subscriber)
console.log('Triggering "fieldChanged" for "occupation"')
eventBus.$emit('fieldChanged', {
name: 'occupation',
value: 'Senior Developer'
})
注意,一旦调用 $off
,第二个订阅者不会触发,但初始订阅者会触发。在浏览器中运行时的控制台输出将如下所示:
图 8.19:显示 $off 作用的控制台输出
通过在event-bus.js
文件中设置事件总线,我们可以避免将数据发送到App
组件(公共祖先)的混淆:
import Vue from 'vue'
export default new Vue()
我们可以从AppProfileForm.vue
文件在表单提交时向事件总线$emit
profileUpdate
事件,而不是使用this.$emit
:
<script>
import eventBus from '../event-bus'
export default {
// rest of component
methods: {
submitForm() {
eventBus.$emit('profileUpdate', {
name: this.name,
occupation: this.occupation
})
},
clear() {
this.name = ''
this.occupation = ''
eventBus.$emit('profileUpdate', {})
}
}
}
</script>
在AppProfileDisplay.vue
文件中,我们可以使用$on
订阅profileUpdate
事件并在状态中更新formData
。请注意,我们已经移除了formData
属性。我们使用mounted()
和beforeDestroy()
钩子来订阅和取消订阅事件总线:
<script>
import eventBus from '../event-bus'
export default {
mounted() {
eventBus.$on('profileUpdate', this.update)
},
beforeDestroy() {
eventBus.$off('profileUpdate', this.update)
},
data() {
return {
formData: {}
}
},
methods: {
update(formData) {
this.formData = formData
}
}
}
</script>
该应用按预期工作。以下截图显示了您的屏幕将如何显示:
图 8.20:AppProfileForm 和 AppProfileDisplay 通过事件总线进行通信
由于我们移除了formData
属性用于AppProfileDisplay
,我们可以在App.vue
文件中停止传递它。由于我们不依赖于AppProfileForm
的submit
事件,我们也可以删除该绑定:
<template>
<!-- rest of template -->
<AppProfileForm />
<AppProfileDisplay />
<!-- rest of template -->
</template>
我们还可以从App.vue
文件中删除未使用的App update
和data
方法,这意味着整个App
脚本部分如下(仅注册components
,不注册状态或处理程序):
<script>
import AppHeader from './components/AppHeader.vue'
import AppProfileForm from './components/AppProfileForm.vue'
import AppProfileDisplay from './components/AppProfileDisplay.vue'
export default {
components: {
AppHeader,
AppProfileForm,
AppProfileDisplay,
}
}
</script>
我们现在通过使用事件总线而不是在公共祖先组件中存储共享状态来简化了应用数据流。现在,我们将看到如何将“清除”按钮移动到配置文件生成器的应用头部。
练习 8.03:将清除按钮移动到应用头部配置文件生成器
在我们的配置卡片生成应用程序中,清除
按钮清除整个应用程序的状态。它在表单内的存在使得清除
按钮的功能不明确,因为它看起来可能只会影响表单。
为了反映清除
按钮是全局功能的事实,我们将将其移动到标题中。
要访问此练习的代码文件,请参阅packt.live/2UzFvwZ
。
以下步骤将帮助我们完成这项练习:
-
我们将首先在
src/components/AppHeader.vue
中创建一个按钮
:<template> <header class="w-full flex flex-row p-4 border-b bg-blue-300 border-gray-700"> <h2 class="text-xl flex text-gray-800">Profile Card Generator</h2> <button class="flex ml-auto text-gray-800 items-center"> Reset </button> </header> </template>
-
我们可以在
AppHeader
中导入事件总线并创建一个clear()
处理程序,其中我们将触发一个带有空有效负载的更新事件(在src/components/AppHeader.vue
):<script> import eventBus from '../event-bus' export default { methods: { clear() { eventBus.$emit('profileUpdate', {}) } } } </script>
-
我们应该将
clear()
函数绑定到按钮
(在src/components/AppHeader.vue
):<template> <!-- rest of template --> <button @click="clear()" class="flex ml-auto text-gray-800 items-center" > Reset </button> <!-- rest of template --> </template>
在这个阶段,我们应该能够填写表格,并且应该出现如下所示的
重置
按钮:图 8.21:填写好的表格和标题中的重置按钮
重置
按钮仅重置AppProfileDisplay
数据:图 8.22:填写好的表格,但卡片部分已被清除
-
为了使
重置
清除表格,我们需要在AppProfileForm
的挂载生命周期方法中订阅profileUpdate
事件,并通过重置表格(使用handleProfileUpdate
)来响应这些事件:<script> import eventBus from '../event-bus' export default { mounted() { eventBus.$on('profileUpdate', this.handleProfileUpdate) }, beforeDestroy() { eventBus.$off('profileUpdate', this.handleProfileUpdate) }, // rest of component methods: { // other methods handleProfileUpdate(formData) { this.name = formData.name || '' this.occupation = formData.occupation || '' } } } </script>
-
我们也借此机会删除
清除
按钮并调整提交
按钮:<template> <!-- rest of template --> <div class="flex align-center mt-12"> <button type="submit" @click="submitForm()" > Submit </button> </div> <!-- rest of template --> </template>
表单填写并提交后的样子如下:
图 8.23:填写并提交的表格
现在重置表格会清除表单字段以及AppProfileDisplay
:
图 8.24:使用重置按钮重置表格和显示
使用事件总线触发事件并监听相同事件是 Vuex 模式的基础,其中事件和状态更新被封装。
与其他模式如 Redux 对比使用 Vuex 模式
我们将要考虑的最后一个场景是使用 Vuex 模式。在这种情况下,所有状态都保存在单个存储中。对状态的任何更新都会派发到这个存储。组件从存储中读取共享和/或全局状态。
Vuex 既是 Vue.js 核心团队提供的状态管理模式,也是库的实现。该模式旨在减轻当全局状态被应用程序的不同部分共享时发现的问题。存储的状态不能直接操作。突变用于更新存储状态,由于存储状态是响应式的,任何 Vuex 存储的消费者都会自动更新。
Vuex 从 JavaScript 状态管理空间中的先前工作中汲取灵感,例如 Flux 架构,它普及了单向数据流的概念,以及 Redux,它是一个 Flux 的单一存储实现。
Vuex 不仅仅是一个 Flux 实现。它是一个针对 Vue.js 的特定状态管理库。因此,它可以利用 Vue.js 特定的东西,如响应性,以提高更新性能。以下图表显示了属性和状态更新的层次结构:
图 8.25:Vuex 属性和状态更新层次结构
为了更新全局状态的部分,组件会触发一个在存储中称为突变的更新。存储知道如何处理这种更新。它更新状态并通过 Vue.js 的响应性相应地向下传播属性:
图 8.26:Vuex 全局状态更新序列图
我们可以使用 Vuex 扩展现有应用程序。
首先,我们需要使用 yarn add vuex
或 npm install --save vuex
命令添加 vuex
模块。
接下来,我们需要在 store.js
文件中使用 Vue.use()
将 Vuex 与 Vue 注册:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
最后,我们创建一个具有默认状态的 Vuex 存储。此状态包括我们在 store.js
文件中使用的相同的 formData
对象。然后我们使用 export default
导出它:
export default new Vuex.Store({
state: {
formData: {
name: '',
occupation: ''
}
},
})
最后,我们需要在 main.js
文件中将我们的存储与 Vue.js 的主应用程序实例注册:
// other imports
import store from './store'
// other imports and code
new Vue({
render: h => h(App),
store
}).$mount('#app')
令人兴奋的是,每个组件都有一个对 this.$store
下的存储的引用。例如,要访问 formData
,我们可以使用 this.$store.state.formData
。使用这个,我们可以用单个计算属性替换 AppProfileDisplay.vue
文件脚本部分中的事件总线订阅和本地状态更新:
<script>
export default {
computed: {
formData() {
return this.$store.state.formData
}
}
}
</script>
要触发状态更新,我们需要定义一些突变。在这种情况下,我们需要在 store.js
文件中定义 profileUpdate
。突变接收 state
(当前状态)和 payload
(存储 commit
负载)作为属性。
export default new Vuex.Store({
// other store properties
mutations: {
profileUpdate(state, payload) {
state.formData = {
name: payload.name || '',
occupation: payload.occupation || ''
}
}
}
})
现在我们已经得到了 profileUpdate
突变,我们可以在 AppHeader.vue
文件中更新 Reset
按钮以使用 Vuex $store.commit()
函数:
<script>
export default {
methods: {
clear() {
this.$store.commit('profileUpdate', {})
}
}
}
</script>
我们还应该更新 AppProfileForm.vue
文件,将提交操作提交到 $store
而不是通过事件总线发出:
<script>
export default {
// rest of component
methods: {
submitForm() {
this.$store.commit('profileUpdate', {
name: this.name,
occupation: this.occupation
})
},
// other methods
}
}
</script>
应用程序现在将支持更新名称和职业:
图 8.27:填写并提交的 AppProfileForm 应用程序
不幸的是,Reset
按钮没有清除表单:
图 8.28:在点击 Reset 按钮时未清除 AppProfileForm 的应用程序
为了更有效地重置,我们将在 store.js
文件中添加一个 profileClear
突变:
export default new Vuex.Store({
// other store properties
mutations: {
// other mutations
profileClear(state) {
state.formData = {
name: '',
occupation: ''
}
}
}
})
我们将在 AppHeader.vue
文件中将此操作提交为 profileUpdate
而不是 profileUpdate
,使用空数据代替 profileUpdate
使我们的代码清除:
<script>
export default {
methods: {
clear() {
this.$store.commit('profileClear')
}
}
}
</script>
最后,我们需要订阅存储更改,并在 AppProfileForm
文件中提交 profileClear
到存储时重置本地状态:
<script>
export default {
created() {
this.$store.subscribe((mutation) => {
if (mutation.type === 'profileClear') {
this.resetProfileForm()
}
})
},
// other component properties
methods: {
// other methods
resetProfileForm() {
this.name = ''
this.occupation = ''
}
}
}
</script>
现在应用的“重置”按钮将正确地与 Vuex 一起工作。我们的屏幕应显示如下:
![图 8.29:应用程序重置按钮清除表单和显示]
图 8.29:应用程序重置按钮清除表单和显示
我们现在已经看到了如何使用 Vuex 存储在应用程序中存储全局状态。
练习 8.04:将组织字段添加到配置生成器
在“配置卡生成器”中,除了个人的姓名和职业外,了解他们在哪里工作,换句话说,他们的组织,也是有用的。
要做到这一点,我们将在 AppProfileForm
和 AppProfileDisplay
中添加一个 organization
字段。要访问此练习的代码文件,请参阅 packt.live/3lIHJGe
。
-
我们可以从向
AppProfileForm
添加新的文本输入和标签开始:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="organization">Organization</label> <input id="occupation" type="text" name="organization" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template>
新字段看起来如下:
![图 8.30:具有新组织字段的应用程序]
图 8.30:具有新组织字段的应用程序
-
我们可以将
organization
字段添加到src/store.js
中的初始状态和突变中,以便organization
被初始化,在profileUpdate
时设置,并在profileClear
时重置:// imports & Vuex setup export default new Vuex.Store({ state: { formData: { // rest of formData fields organization: '' } }, mutations: { profileUpdate(state, payload) { state.formData = { // rest of formData fields organization: payload.organization || '', } }, profileClear(state) { state.formData = { // rest of formData fields organization: '' } } } })
-
我们需要在
src/components/AppProfileForm.vue
组件的本地状态中跟踪organization
,使用v-model
并在data()
函数中初始化它:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="organization">Organization</label> <input id="occupation" type="text" name="organization" v-model="organization" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template> <script> export default { // rest of component data() { return { // other data properties organization: '' } } } </script>
-
为了使突变的负载包含
organization
,我们需要将其添加到$store.commit('profileUpdate')
负载中,并在组件触发profileClear
突变时在表单中重置它:<script> export default { // rest of component methods: { submitForm() { this.$store.commit('profileUpdate', { // rest of payload organization: this.organization }) }, resetProfileForm() { // other resets this.organization = '' } } } </script>
-
为了使
organization
显示,我们需要在src/components/AppProfileDisplay.vue
中使用条件 span(当没有设置organization
时隐藏at
)来渲染它:<template> <!-- rest of template --> <p class="mt-2"> {{ formData.occupation }} <span v-if="formData.organization"> at {{ formData.organization }} </span> </p> <!-- rest of template --> </template>
应用程序现在将允许我们捕获
organization
字段并显示它。![图 8.31:支持组织字段的配置卡生成器,已填写并提交]
图 8.31:支持组织字段的配置卡生成器,已填写并提交
它将允许我们无任何问题地清除配置:
![图 8.32:支持组织字段的配置卡生成器,点击重置按钮后]
图 8.32:支持组织字段的配置卡生成器,点击重置按钮后
我们现在已经看到了如何向使用 Vuex 的应用程序添加字段。Vuex 相比于事件总线或存储祖先组件中的状态的最大好处之一是,随着你添加更多数据和操作,它可以进行扩展。以下活动将展示这一优势。
活动 8.01:将电子邮件和电话号码添加到个人资料卡片生成器
在个人资料生成器中,你查看个人资料以获取有关个人的某些信息。电子邮件和电话号码通常是个人资料卡片上寻找的最关键的信息。这个活动是关于将这些详细信息添加到个人资料卡片生成器中。
要做到这一点,我们将在 AppProfileForm
和 AppProfileDisplay
中添加 Email
和 Phone Number
字段:
-
我们可以先向
AppProfileForm
添加一个新的email
输入字段和标签,用于Email
字段。 -
然后,我们可以在
AppProfileForm
中添加一个新的phone
输入字段(类型为tel
)和标签,用于Phone Number
字段:新字段如下所示:
![图 8.33:包含新电子邮件和电话号码字段的应用程序]
图 8.33:包含新电子邮件和电话号码字段的应用程序
-
然后,我们可以在
src/store.js
中的初始状态和突变中添加email
和phone
字段,以便在profileUpdate
期间设置组织,并在profileClear
期间重置。 -
我们需要在
src/components/AppProfileForm.vue
组件的本地状态中使用v-model
跟踪email
,并在data()
函数中初始化它。 -
我们需要在
src/components/AppProfileForm.vue
组件的本地状态中跟踪phone
,使用v-model
并在data()
函数中初始化它。 -
为了使突变的负载包含
email
和phone
,我们需要将其添加到$store.commit('profileUpdate')
负载中。我们还想在组件触发profileClear
突变时在表单中重置它。 -
为了显示
email
,我们需要在src/components/AppProfileDisplay.vue
中使用条件段落(在未设置电子邮件时隐藏Email
标签)来渲染它。 -
为了显示
phone
,我们需要在src/components/AppProfileDisplay.vue
中使用条件 span(在未设置电话时隐藏Phone Number
标签)来渲染它。当表单填写并提交时,应用程序应如下所示:
![图 8.34:包含电子邮件和电话号码字段的应用程序]
图 8.34:包含电子邮件和电话号码字段的应用程序
注意
这个活动的解决方案可以通过此链接找到。
何时使用本地状态和何时保存到全局状态
如通过公共祖先、事件总线、Vuex 示例所见,Vue.js 生态系统有管理共享和全局状态的方法。我们现在将探讨如何决定某物属于本地状态还是全局状态。
一个好的经验法则是,如果一个属性通过三个组件的深度传递,那么最好将这块状态放在全局状态中,并以此方式访问它。
决定某事物是局部还是全局的第二种方法是问自己 当页面重新加载时,用户是否希望这个信息保持不变? 这为什么很重要呢?因为全局状态比本地状态更容易保存和持久化。这是由于全局状态的本质是 仅仅是一个 JavaScript 对象,而与组件状态相比,组件状态与组件树和 Vue.js 的联系更为紧密。
另一个需要牢记的关键思想是,在组件中混合使用 Vuex 和本地状态是完全可能的。正如我们在 AppProfileForm
的示例、练习和活动中所看到的,我们可以使用 $store.subscribe
选择性地从突变中同步数据到组件中。
最后,将 Vue.js 的数据属性包装在计算属性中,并通过访问计算属性来使潜在过渡到 Vuex 更容易,这并没有什么问题。在这种情况下,由于所有访问都已经通过计算属性完成,所以只是从 this.privateData
变为 this.$store.state.data
的一个变化。
概述
在本章中,我们探讨了在 Vue.js 应用程序中共享和全局状态管理的不同方法。
在共享祖先中的状态允许通过 props 和事件在兄弟组件之间共享数据。
事件总线有三个操作——Vue.js
应用。我们也看到了如何将 Vue.js
实例用作事件总线。
你知道 Vuex 模式和库包含什么,它们与 Redux 和 Flux 的区别,以及使用 Vuex 存储相对于共享祖先或事件总线的优势。
最后,我们探讨了可以使用哪些标准来决定状态应该存在于本地组件状态还是更全局或共享的状态解决方案,例如 Vuex。这一章是关于 Vue.js
中状态管理领域的介绍。
下一章将深入探讨使用 Vuex 编写大规模 Vue.js
应用程序。
第九章:9. 使用 Vuex – 状态、Getters、Actions 和 Mutations
概述
在本章中,你将学习如何使用 Vuex 构建更复杂的 Vue 应用程序。你将了解如何将 Vuex 添加到 Vue 应用程序中,如何使用 Vuex 存储定义状态,然后使用 getter、actions 和 mutations 从存储中读取数据,并在存储中更新数据。到本章结束时,你将看到多个 Vuex 如何改变你的 Vue 应用程序的例子,使它们能够以更可管理的方式变得更加复杂。
简介
在上一章中,你学习了如何使用事件总线模式来帮助解决一个重要问题:在复杂且高度嵌套的组件集合之间双向通信事件。事件总线模式提供了一个简单的发布/订阅系统,任何组件都可以发出事件,任何组件也可以监听该事件。虽然自己编写解决方案是保持编码技能锐利的好方法,但在这种情况下,使用已经在 Vue 社区中开发、经过充分测试的解决方案会更好——Vuex (vuex.vuejs.org/
):
![图 9.1:Vuex 首页
图 9.1:Vuex 首页
Vuex 是 Vue 生态系统的一个核心部分,它提供了我们在上一章中构建的内容以及更多。让我们从高层次上了解一下 Vuex 的主要功能。
存储
从高层次来看,一个 Vuex 实例或 Vuex 的一次使用被认为是一个存储。存储是使用以下子节中描述的一切的最高级容器。
状态
Vuex 最重要的方面是它所代表的状态或数据。这是所有组件都可以依赖的单一事实来源。随着状态的变化,任何使用状态的组件都可以确保其副本始终与状态同步。想象一个允许你编辑博客条目的 Vue 应用程序。存储可以包括博客条目本身以及你正在编辑的当前博客条目的值。当在某个地方编辑博客条目时,任何使用它们的其他地方都会立即更新。
Getters
虽然 Vue 可以直接从 Vuex 实例读取状态数据,但有时你可能需要为数据本身提供额外的逻辑或抽象。就像 Vue 为虚拟或派生数据提供computed
属性一样,getter为需要在使用前操纵数据的情况提供了对状态的抽象。回到我们之前处理博客条目的例子,想象一个返回按浏览量最高的博客条目的 getter。getter 抽象掉了“热门”博客条目的逻辑,并允许你轻松地在将来更改该逻辑。
变更
在 Vuex 中使用状态数据的组件永远不会直接修改该数据。相反,组件可以执行一个突变。将其视为组件对 Vuex 执行状态更改的命令。通过使用突变来封装对状态的更改,Vuex 可以确保使用该状态的每个组件都能及时了解更改。
行为
行为类似于突变,但它们必须用于处理异步更改。异步行为是任何需要不确定时间才能完成的逻辑。最常见的例子是对远程 API 的网络调用。同步调用是那些立即执行并完成的调用。当你看到它们被使用时,这会更有意义,但一般来说,任何异步操作都应该通过行为完成,而同步逻辑可以通过突变完成。一旦完成了所需的异步工作,行为通常会链接到突变。
模块
本章将要涵盖 Vuex 的最后一个方面是模块。模块只是将更复杂的数据集打包起来,用于更大的应用程序。虽然一个简单的状态可能适合典型应用程序,但一个更大的应用程序可能有一个更复杂的状态,需要通过模块进行更好的组织。在第十一章,与 Vuex 一起工作 – 组织更大的存储库中,你将看到如何使用模块来更好地组织 Vuex 实例。
安装 Vuex
根据你正在构建的 Vue 应用程序类型,使用 Vuex 的主要有两种方法。如果你没有使用 CLI 搭建应用程序,只是通过脚本标签添加 Vue,你可以以相同的方式包含 Vuex。假设你已经将 Vue 和 Vuex 下载到一个名为 js
的文件夹中,你会这样加载它们:
<script src="img/vue.js"></script>
<script src="img/vuex.js"></script>
你也可以通过内容分发网络(CDNs)加载 Vue 和 Vuex:
<script src="img/vue"></script>
<script src="img/vuex"></script>
注意
确保在 Vue 之后加载 Vuex 非常重要。这样做可以使 Vuex 对你的 Vue 代码可用,而无需任何其他配置。
如果你使用 CLI 创建了应用程序,请记住 CLI 本身将在创建过程中提示你是否要添加 Vuex:
![图 9.2:在应用程序搭建过程中选择 Vuex]
![图片 B15218_09_02.jpg]
图 9.2:在应用程序搭建过程中选择 Vuex
如果你没有这样做,你仍然可以使用 CLI 添加 Vuex:vue add vuex
。当 Vuex 被添加(或在搭建过程中选择)时,你的 Vue 应用程序将以我们将要讨论的方式进行修改。
首先,添加一个新的文件夹 store
,其中包含一个文件 index.js
:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
这是一个空的存储库,为你的状态、突变、行为和模块预留了位置。请注意,这里没有为定义的获取器预留空间,但你绝对可以添加它们。这只是一个新存储库的默认布局,你可以根据需要对其进行修改。
接下来,main.js
被修改为加载和安装此存储库:
import Vue from 'vue'
import App from './App.vue'
import store from './store'
Vue.config.productionTip = false
new Vue({
store,
render: h => h(App)
}).$mount('#app')
再次强调,这是 CLI 使用 Vuex 的方式,如果你更喜欢使用另一种方法(可能是一个不同于store
的文件夹名),你完全自由这样做。
处理状态
在 Vuex store 的最低级别,你会找到 Vuex 实际管理的数据(状态)。所有组件都可以通过特殊的$store
变量访问状态。虽然这个变量中还有更多内容,但要读取状态,你可以使用$store.state.someStateValue
。所以,例如:Hello, my name is {{ $store.state.name }}
会在组件中输出 Vuex store 中的名称值。对于简单的从 store 读取操作,这就足够了。
我们将在以下练习中学习如何显示状态值。
练习 9.01:显示状态值
在这个练习中,你将创建一个空的 Vue 应用程序并使用 Vuex。前面的部分描述了如何通过 CLI 完成此操作,如果你跟随着做,你现在应该有一个准备好了。如果没有,请现在创建一个,确保你启用了 Vuex。在这个练习中,我们将在状态中设置一些值并在组件中显示它们。
要访问此练习的代码文件,请参阅packt.live/32s4RkN
。
-
一旦你搭建了应用程序,打开
store/index.js
并修改state
块以添加三个新值。这里的数据是任意的,可以是 JavaScript 可以处理的所有内容(字符串、数字、数组等等):import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { name:"Lindy", favoriteColor: "blue", profession: "librarian" }, mutations: { }, actions: { }, modules: { } })
-
现在我们需要修改组件以显示来自状态的价值。打开
App.vue
并按如下方式修改它:<template> <div id="app"> <p> My name is {{ $store.state.name }} and my favorite color is {{ $store.state.favoriteColor }}. My job is a {{ $store.state.profession }}. </p> <HelloWorld /> </div> </template> <script> import HelloWorld from '@/components/HelloWorld'; export default { name: 'app', components:{ HelloWorld } } </script>
-
接下来,编辑
HelloWorld.vue
组件以显示来自状态的一个值:<template> <div> <p> Hi, I'm a component, and I also have access to state! My name is {{ $store.state.name }}. </p> </div> </template> <script> export default { name: 'HelloWorld' } </script>
要查看你的应用程序,请在终端中输入
npm run serve
。当 CLI 完成时,你可以在浏览器中打开显示的 URL 来查看你的应用程序,它应该如下所示:My name is Lindy and my favorite color is blue. My job is a librarian. Hi, I am a component, and I also have access to state! My name is Lindy.
如你所见,主组件和子组件都可以访问状态并看到相同的值。这不应该令人惊讶,但总是很好确认事情按预期工作。
直接访问状态值虽然简单,但让我们看看更复杂的用法:使用 getter 获取派生值。
应用 getter
在上一个练习中,你看到了直接访问状态是多么简单,但有时你可能需要更复杂的对状态视图。为了使这更容易,Vuex 支持一个名为getter的功能。
Getter 在 store 中拥有自己的块,你可以定义尽可能多的 getter。每个 getter 都会将状态作为参数传递,这让你可以使用任何你需要来创建你的值。最后,getter 的名称就是它将被暴露的方式。考虑这个简单的例子:
state: {
name: "Raymond",
gender: "male",
job: "Developer Evangelist"
},
getters: {
bio(state) {
return `My name is ${state.name}. I'm a ${state.job}`;
}
}
此 store 定义了三个状态值(name
、gender
和job
),还提供了一个名为bio
的“虚拟”属性,它返回数据的描述。请注意,getter 只使用了两个状态值,这是完全可以的。
要在组件中引用获取器,你使用 $store.getters.name
,其中 name
是获取器的名称。因此,要访问前面代码中定义的 bio 获取器,你会使用以下:
{{ $store.getters.bio }}
除了传递状态外,获取器还通过其第二个参数传递 其他 获取器,这允许一个获取器在必要时调用另一个获取器。
在下一个练习中,我们将看到一个如何使用它的示例。
练习 9.02:向 Vuex 存储添加获取器
在这个练习中,你将构建一个利用获取器功能的示例。你将为 Vuex 存储添加获取器,并从主 Vue 应用程序中调用它。
要访问此练习的代码文件,请参阅 packt.live/36ixlyf
。
-
搭建一个新的应用程序,记得在设置中使用 Vuex(如果你忘记了,只需使用
vue add vuex
)。输入npm run serve
以启动应用程序并在浏览器中打开 URL。 -
打开你的存储文件(
store/index.js
),然后修改它以定义两个状态值和一个获取器,该获取器将返回两者:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { firstName: "Lindy", lastName: "Roberthon" }, getters: { name(state) { return `${state.firstName} ${state.lastName}`; } }, mutations: { }, actions: { }, modules: { } })
-
现在打开
App.vue
并修改它,以便使用name
获取器:<template> <div id="app"> <p> My name is {{ $store.getters.name }} </p> </div> </template>
如以下截图所示,这将显示基于获取器中使用的逻辑的完整名称:
My name is Lindy Roberthon
虽然相当简单,但希望你能看到获取器在这里实现的力量。目前,我们有一个由姓氏和名字组成的名称概念。这不是非常复杂的逻辑,但通过将其放置在 Vuex 存储中,我们将其定义为我们应用程序中所有组件都可以访问的一个位置。如果这个名称定义发生了变化(例如,姓氏在前,用逗号分隔),你只需修改一次即可完成。
接下来,我们将考虑如何通过附加逻辑增强获取器。
带参数的获取器
虽然获取器可以通过 $store.getters
属性直接访问,但你可能会遇到需要更多控制获取器工作方式的情况。参数提供了一种自定义获取器工作方式的方法。考虑以下存储:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
books:[
{type:'nonfiction', title:'Truth about Cats', pages: 200},
{type:'nonfiction', title:'Truth about Dogs', pages: 100},
{type:'fiction', title:'The Cat Said Meow', pages: 400},
{type:'fiction', title:'The Last Dog', pages: 600},
]
},
getters: {
fiction(state) {
return state.books.filter(book => book.type === 'fiction');
},
nonfiction(state) {
return state.books.filter(book => book.type === 'nonfiction');
}
},
mutations: {
},
actions: {
},
modules: {
}
})
在此存储中只有一个状态值,它是一个书籍数组。每本书都有一个类型(非小说或小说)、标题和页数。为了便于获取一本书而不是另一本书,使用了两个获取器。它们通过小说或非小说书籍进行筛选。
这是在组件中使用它的方法。首先,我们遍历 fiction
获取器,然后是 nonfiction
获取器:
<h2>Fiction Books</h2>
<ul>
<li v-for="book in $store.getters.fiction" :key="book.title">
{{ book.title }}
</li>
</ul>
<h2>Non-Fiction Books</h2>
<ul>
<li v-for="book in $store.getters.nonfiction" :key= "book.title">
{{ book.title }}
</li>
</ul>
在前面的模板中,使用了两个无序列表来遍历每种类型的书籍。结果如下所示:
![图 9.3:通过获取器渲染小说和非小说书籍
图 9.3:通过获取器渲染小说和非小说书籍
好的,到目前为止一切顺利。但如果你想要根据页数获取书籍呢?这并不是一个简单的布尔值或字符串属性,而是一个数字。但是,因为获取器可以接受参数,我们可以创建一个新的获取器,允许我们请求最大页数。(我们可以支持多个参数,所以,如果你想要一个获取器来请求在一定范围内的书籍,你可以支持最小和最大页数。)为了创建一个接受参数的获取器,您的代码本身必须返回一个函数。
这里有一个示例,我们定义了一个返回全名一部分的获取器:
shortName(state) {
return function(length) {
return ('${state.firstName} ${state.lastName}'). substring(0, length);
}
}
生成的获取器可以使用 length
参数:{{ $store.getters.shortName(10) }}
。
在下一个练习中,您将构建一个利用带有参数的获取器功能的应用程序。
练习 9.03:使用带参数的获取器
您将在下面的练习中测试这个功能。通过向获取器添加参数,您将能够构建更灵活的获取器,这些获取器在不同组件中更有用。在这个练习中,您将创建一个接受一个参数的获取器。
要访问此练习的代码文件,请参阅 packt.live/2Ioi2vy
。
-
使用 Vuex 再次搭建一个应用程序,一旦打开,编辑 store 以包括一组书籍和用于虚构、非虚构和页码的获取器。这可以在
store
目录中的index.js
找到:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { books:[ {type:'nonfiction', title:'Truth about Cats', pages: 200}, {type:'nonfiction', title:'Truth about Dogs', pages: 100}, {type:'fiction', title:'The Cat Said Meow', pages: 400}, {type:'fiction', title:'The Last Dog', pages: 600}, ] }, getters: { fiction(state) { return state.books.filter(book => book.type === 'fiction'); }, nonfiction(state) { return state.books.filter(book => book.type === 'nonfiction'); }, booksByMaxPages(state) { return function(pages) { return state.books.filter(book => book.pages <= pages); } } } })
-
现在编辑
App.vue
以使用所有三个获取器——首先是虚构和非虚构获取器,然后是页数不超过150
的书籍:<template> <div id="app"> <h2>Fiction Books</h2> <ul> <li v-for="book in $store.getters.fiction" :key= "book.title"> {{ book.title }} </li> </ul> <h2>Non-Fiction Books</h2> <ul> <li v-for="book in $store.getters.nonfiction" :key= "book.title"> {{ book.title }} </li> </ul> <h2>Short Books</h2> <ul> <li v-for="book in $store.getters.booksByMaxPages(150)" :key="book.title"> {{ book.title }} </li> </ul> </div> </template> <script> export default { name: 'app' } </script>
完成后,您可以通过启动您的 Vue 应用程序(在终端中输入
npm run serve
)并在浏览器中打开 URL 来查看结果。以下是您应该看到的内容:![图 9.4:参数化获取器的作用
![img/B15218_09_04.jpg]
图 9.4:参数化获取器的作用
在这个练习中,我们学习了如何使用更强大的带有参数的获取器。这使得它们更加灵活,更能适应组件可能的需求。现在您已经看到了从 Vuex store 中读取数据的多种方式,是时候看看如何修改状态了。
使用突变修改状态
到目前为止,您已经看到了如何从 Vuex store 中读取数据,无论是通过直接访问状态还是通过使用获取器。但为了实际改变 store 的状态,Vuex 支持突变的概念。突变是在您的 store 中定义的方法,用于处理状态的变化。例如,而不是您的组件简单地设置状态中的新值,您的组件将要求 store 执行突变,而 store 本身处理那个逻辑。
这里有一个简单的例子:
state: {
totalCats: 5,
name:'Lindy'
},
mutations: {
newCat(state) {
state.totalCats++;
},
setName(state, name) {
state.name = name;
}
}
在前面的代码片段中,存储在其状态中有两个值,totalCats
和 name
。存在两个突变以允许你更改这些值。所有突变都传递一个状态对象,它为你提供直接访问以读取和更改值。第一个突变 newCat
简单地增加 totalCats
的值。第二个突变 setName
展示了一个接受参数的突变示例。在这种情况下,你可以使用 setName
来更改存储中的名称值。
为了执行一个突变,你的组件将使用 commit
方法。例如,如下所示:
$store.commit('newCat');
$store.commit('setName', 'Raymond');
如果你将它们作为对象而不是简单值传递,你也可以传递多个值。在下一个练习中,你将有机会练习构建你自己的突变。
练习 9.04:使用突变
在这个练习中,你将构建一个使用突变来修改 Vuex 中状态数据的应用程序。搭建一个新的应用程序,一旦准备就绪,打开位于 store/index.js
的存储文件。你的存储将基于前面的示例。
要访问此练习的代码文件,请参阅 packt.live/3kcARiN
。
-
定义一个
totalCats
状态变量和名称状态值,然后定义三个突变来处理它们——一个突变用于增加猫的数量,一个用于减少它,最后一个用于设置名称:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { totalCats:5, name: "Lindy" }, mutations: { adoptCat(state) { state.totalCats++; }, placeCat(state) { if(state.totalCats > 0) state.totalCats--; }, setName(state, name) { state.name = name; } } })
接下来,你将构建一个简单的界面来与这个存储进行交互。模板需要有一个 UI 来报告名称和猫的数量。你还需要一个文本字段和按钮来处理名称的更新。
-
打开
App.vue
并更新它以包含输出存储中的当前值以及提供一个简单的表单以允许更新:<template> <div id="app"> <h1>About Me</h1> <p> My name is {{ $store.state.name }} and I have {{ $store.state.totalCats }} cats. </p> <p> <input v-model="newName"> <button @click="setName" :disabled="!newName">Update Name </button> </p> <Cat/> </div> </template> <script> import Cat from './components/Cat.vue' export default { name: 'app', components: { Cat }, data() { return { newName:'' } }, methods: { setName() { if(this.newName) { this.$store.commit('setName', this.newName); this.newName = ''; } } } } </script>
-
构建
Cat
组件。此组件将具有简单的按钮来执行我们定义的突变,以增加和减少猫的数量:<template> <div> <button @click="addCat">More Cats!</button> <button @click="removeCat">Less Cats :(</button> </div> </template> <script> export default { name: 'Cat', methods: { addCat() { this.$store.commit('adoptCat'); }, removeCat() { this.$store.commit('placeCat'); } } } </script>
完成后,像以前一样使用
npm run serve
启动你的应用程序,并在浏览器中打开显示的 URL。你的应用程序应该看起来是这样的:![图 9.5:具有突变支持的 Vue 应用程序]
![图 9.5:具有突变支持的 Vue 应用程序]
虽然这是一个简单的练习,但有一些重要的事情需要注意。首先,注意根组件和子组件与存储交互时没有问题。你的组件层次结构可以非常深,而且它只是简单地工作。其次,注意 Vue 应用程序更简单,因为与数据交互的逻辑在存储中。我们处理的两个组件只是显示数据并处理将突变调用传递给存储。如果我们的逻辑需要更新,我们可以在存储中处理,并且一切都会正确更新。
到目前为止,你已经看到了如何以立即、同步的方式实现对存储的更改。现在,你将学习如何处理异步更新。
使用动作进行异步状态更改
Vuex 中的操作是处理存储异步逻辑的主要方式。突变必须是同步的,但操作可以选择异步。另一个区别是,操作会得到一个context
对象,它代表存储本身。这使得操作可以调用突变或直接与状态交互。一般来说,大多数开发者会从他们的操作中调用突变。
这可能听起来有些令人困惑,但一般来说,将操作视为您存储的异步支持。一旦我们看到一个或两个示例,它就会变得有意义。
让我们看看一个示例操作。以下代码片段包含一个突变和一个将使用该突变的异步操作:
mutations: {
setBooks(state, books) {
state.books = books;
}
},
actions: {
loadBooks(context) {
fetch('/data/books.json')
.then(res => res.json())
.then(res => {
context.commit('setBooks', res);
});
}
},
查看loadBooks
,您可以看到它执行网络请求,完成后,它调用前面的突变并允许它存储结果数据。
调用一个操作与突变略有不同;而不是使用commit
调用,您使用dispatch
:
this.$store.dispatch('loadBooks');
与突变一样,操作可以接受参数,这些参数作为第二个参数传递给操作方法。接下来,您将构建一个示例来展示这一点。
练习 9.05:使用操作进行异步逻辑
在这个练习中,您将构建一个需要异步逻辑才能完成的操作示例。这类似于许多现实世界场景,其中应用程序所需的数据位于远程 API 上。您将在 Vuex 存储中实现网络调用并处理结果。
对于这个示例,您将在public
文件夹下的一个名为data
的子目录中设置一个可用的 JSON 资源。当 Vue 构建您的代码时,它将复制public
文件夹中的任何内容到应用程序中,使其在运行时可用。该 JSON 文件包含一个包含四本书的数组。每本书都有一个类型、标题和页数。
要访问此练习的代码文件,请参阅packt.live/3eE6KQd
。
-
虽然不是必需的,但这是 JSON 数据的样子。您可以自由地构建自己的:
[ { "type": "nonfiction", "title": "Truth about Cats", "pages": 200 }, { "type": "nonfiction", "title": "Truth about Dogs", "pages": 100 }, { "type": "fiction", "title": "The Cat Said Meow", "pages": 400 }, { "type": "fiction", "title": "The Last Dog", "pages": 600 } ]
-
在一个新的存储(位于
store/index.js
的常规位置),为书籍设置一个空数组,然后定义一个操作,该操作将使用 Fetch API 检索 JSON 内容。(您将在第十章,使用 Vuex - 获取远程数据中看到更多使用 API 的示例,以及一种更强大的 HTTP 操作方式,即Axios
库。)当数据被检索后,它应该调用一个突变来存储结果:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { books:[] }, mutations: { setBooks(state, books) { state.books = books; } }, actions: { loadBooks(context) { fetch('/data/books.json') .then(res => res.json()) .then(res => { context.commit('setBooks', res); }); } } })
-
为了调用此操作,请在您的组件中添加一个
dispatch
调用以运行操作,然后添加代码来显示书籍:<template> <div id="app"> Books <ul> <li v-for="book in $store.state.books" :key="book.title"> {{ book.title }}</li> </ul> </div> </template> <script> export default { name: 'app', created() { this.$store.dispatch('loadBooks'); } } </script>
在图 9.6中,您可以查看异步操作请求其数据的结果:
图 9.6:异步加载数据的示例
现在,你已经看到了在 Vuex 存储中处理异步操作的一个例子。请注意,即使你的代码是同步的,你也可以使用操作。如果你不确定数据将来是否将是异步的,这通常是一个好主意。现在让我们看看简化一些 Vuex 语法模板的一个好方法。
使用 mapState 和 mapGetters 简化
作为我们将要使用 Vuex 覆盖的最后一个功能之一,让我们看看 mapState
和 mapGetters
。这些实用的工具帮助将状态值和获取器映射到组件的计算属性中。作为一个实际问题,它使你的 HTML 模板更简单。所以,你不必使用 {{ $store.state.firstName }}
,你可以简单地使用 {{ firstName }}
。不必使用 {{ $store.getters.name }}
,你只需使用 {{ name }}
。
mapState
和 mapGetters
都可以接受一个要映射的值数组,或者是一个对象,其中每个键代表你希望在组件中使用的名称,值是 Vuex 存储中的 state value
或 getter
。它们都与你的 Vue 应用程序的 computed
块一起使用。
在这个第一个例子中,两个状态值和三个获取器仅通过它们的名称进行映射:
mapState(["age", "rank", "serialNumber"]);
mapGetters(["name", "fiction", "nonfiction"]);
但如果这些名称可能过于通用,或者可能与现有数据冲突,你可以为它们指定其他名称:
mapState({
howOld:"age",
level:"rank",
sn:"serialNumber"
});
mapGetters({
ourName:"name",
fictionBooks:"fictionBooks",
nonfictionBooks: "nonfictionBooks"
});
为了使用 mapState
和 mapGetters
,你首先需要导入它们:
import { mapState, mapGetters } from 'vuex';
使用这两个功能肯定有助于减少你编写与 Vuex 一起工作的代码量。
你将通过以下练习了解如何添加 mapState
和 mapGetters
。
练习 9.06:添加 mapState 和 mapGetters
让我们看看一个简单的例子。在 Exercise 9.02 中,我们使用获取器创建了一个获取名称值的快捷方式。我们可以通过应用我们刚刚学到的知识来简化这段代码。我们可以使用映射函数来简化我们的代码。
要访问此练习的代码文件,请参阅 packt.live/3ldBxpb
。
-
创建一个新的 Vue 应用程序并使用 Vuex,然后将存储(位于
store/index.js
)复制到这个新版本中。你需要姓名和姓氏的状态值,以及一个返回完整姓名的获取器:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { firstName: "Lindy", lastName: "Roberthon" }, getters: { name(state) { return `${state.firstName} ${state.lastName}`; } } })
-
编辑主组件。你将想要编辑存储中的所有三个值(状态值和获取器),但可以使用
mapState
和mapGetters
来简化它:<template> <div id="app"> <p> My name is {{ firstName }} {{ lastName}}, or just {{ name }}. </p> </div> </template> <script> import { mapGetters } from 'vuex'; import { mapState } from 'vuex'; export default { name: 'app', computed: { ...mapState([ "firstName", "lastName" ]), ...mapGetters([ "name" ]) } } </script>
如你所见,通过使用
mapState
和mapGetters
,我们为应用的模板部分提供了一种使数据稍微简单一些的方法:My name is Lindy Roberthon, or just Lindy Roberthon.
完成后,你应该看到与之前完全相同的输出。重要的是,你需要编写的代码量减少了!
在下一节中,我们将简要介绍 mapMutations
和 mapActions
。
使用 mapMutations 和 mapActions 简化
我们将要介绍的最终功能与上一个功能非常相似:mapMutations
和 mapActions
。正如你可能猜到的,这两个功能与 mapState
和 mapGetters
的工作方式非常相似,即它们提供了一种简写方式,可以将您的代码连接到 Vuex 的 mutations 和 actions,而无需编写样板代码。
它们遵循相同的格式,您可以在其中指定一个要映射的项目列表,或者指定一个列表同时提供不同的名称,如下面的示例所示:
mapMutations(["setBooks"]);
mapActions(["loadBooks"]);
这些可以在您的 Vue 组件的 methods
块中使用:
methods:{
...mapMutations(["setBooks"]),
...mapActions(["loadBooks"])
}
这然后允许您的 Vue 代码调用 setBooks
或 loadBooks
而无需指定 store
对象,或 dispatch
和 commit
。
现在,让我们尝试自己创建一个简单的购物车和价格计算器。
活动 9.01:创建简单的购物车和价格计算器
想象一个假设的硬件公司网站,允许员工选择他们需要运送到办公室的产品。这个购物车比典型的电子商务网站简单得多,因为它不需要处理信用卡,甚至不需要询问他们在哪里(IT 知道你在哪里坐!)它仍然需要向您展示一个项目列表,让您选择您想要的数量,并提供一个将向您的部门收取的总价。
在这个活动中,您需要构建一个 Vuex 存储库来表示可用的产品和它们的价格。您将需要多个组件来处理应用程序的不同方面,并正确地与存储数据交互。
步骤:
-
在状态中构建一个存储库并定义一个产品数组和购物车。每个产品都将有
name
和price
属性。 -
定义一个组件来列出每个产品和价格。
-
修改组件以添加或删除购物车中的一个产品按钮。
-
定义第二个组件以显示当前购物车(每个产品和数量)。
-
使用第三个组件来显示购物车总价,并有一个按钮来完成结账。总价是购物车中每个产品的数量乘以产品数量的总和。对于这个活动,
结账
按钮应该简单地提醒用户结账过程已完成,但不要采取其他步骤。
您最初应该得到以下输出,显示一个空购物车:
![图 9.7:购物车的初始显示]
![图片 B15218_09_07.jpg]
![图 9.7:购物车的初始显示]
当您添加和删除项目时,您将看到购物车和总计实时更新:
![图 9.8:添加了多个数量项目的购物车]
![图片 B15218_09_08.jpg]
![图 9.8:添加了多个数量项目的购物车]
如您所见,当产品被添加时,购物车显示会更新以显示数量值,而 结账
部分的总计会准确反映总价。
注意
这个活动的解决方案可以通过这个链接找到。
摘要
在本章中,你已经看到了 Vuex 的大多数功能,现在应该对如何从存储中读取和写入有一个概念。你使用了突变(mutations)来进行同步更改,并使用了动作(actions)来进行异步修改。你创建了获取器(getters)来提供基于你状态的虚拟值的访问。你也看到了组件在处理存储时的样子。它们拥有更少的逻辑,并且简单地将这部分工作交给存储。在更大的 Vue 应用程序中,这一点将变得更加重要。你的组件将处理 UI 和 UX,但让存储处理数据层。因此,拥有作为单一事实来源的存储,将大大减轻你的许多“苦力”工作,你将非常感激 Vuex,即使在较小的应用程序中也是如此。
在下一章中,你将学习如何使用 Vuex 存储与远程数据。在现代网络应用中,与远程 API 一起工作是一个常见的需求。将这些 API 集成到 Vuex 中将使你的 Vue 应用程序的其他部分更容易使用远程服务提供的数据。
第十章:10. 使用 Vuex – 获取远程数据
概述
在本章中,您将学习如何使用Axios
库与远程 API 一起工作。您将进行网络调用并使用 Vuex 存储结果。您还将看到一个如何使用 Vuex 存储身份验证令牌并用于后续 API 调用的示例。
到本章结束时,您将了解 Vuex 如何帮助抽象并创建远程 API 的包装器,并简化它们集成到 Vue 应用程序中的过程。这种抽象使得将来迁移到其他 API 变得更容易,确保您的应用程序的其他部分继续正常工作。
简介
在第九章,使用 Vuex – 状态、获取器、动作和突变中,您被介绍了 Vuex,并看到了多个如何与之交互的示例,以从存储中读取数据并向存储写入数据。我们看到了多个组件如何与存储一起工作,并且在我们这边几乎不需要做任何工作就能保持同步。在本章中,我们将通过使用Axios
(一个流行的开源库,使使用网络资源变得容易)将 Vuex 与远程数据集成来扩展我们的 Vuex 使用。让我们从对Axios
的深入了解开始。
Axios
(github.com/axios/axios
)是一个具有async
和await
功能的 JavaScript 库。其他功能包括支持默认参数(对于每个调用都需要键的 API 很有用)以及转换您的输入和输出数据的能力。在本章中,我们不会涵盖每个用例,但您将了解如何为未来的项目使用Axios
。
为了明确,如果您不喜欢Axios
,您不必使用它。您可以使用任何其他库,或者根本不使用库。Fetch API(developer.mozilla.org/en-US/docs/Web/API/Fetch_API
)是一个现代浏览器 API,用于处理网络请求,虽然不如Axios
强大,但不需要额外的库。
在下一节中,我们将探讨如何安装Axios
。
Axios 的安装
与 Vuex 类似,您有多种方法可以将Axios
包含到项目中。最简单的方法是将指向库的内容分发网络(CDN)的<script>
标签粘贴到项目中:
<script src="img/axios.min.js"></script>
另一个选项是使用npm
。在现有的 Vue 应用程序中,您可以按照以下方式安装Axios
:
npm install axios
一旦完成此操作,您的 Vue 组件就可以按照以下方式导入库:
import axios from 'axios';
您如何使用Axios
将取决于您交互的 API。以下是一个简单的示例,用于调用一个假想的 API:
axios.get('https://www.raymondcamden.com/api/cats')
.then(res => {
this.cats = res.data.results;
})
.catch(error => {
console.error(error);
});
在前面的示例中,我们正在对一个虚构的 API 执行GET
请求(GET
是默认值),即https://www.raymondcamden.com/api/cats。Axios
返回 promises,这意味着我们可以使用then
和catch
链式处理结果和错误。结果 JSON(再次强调,这是一个虚构的 API)会自动解析,所以剩下的只是将结果分配给一个值,在这个例子中,是一个名为cats
的值,用于我的 Vue 应用程序。
现在让我们看看使用Axios
从 API 加载数据的逐步过程。
练习 10.01:使用 Axios 从 API 加载数据
让我们看看一个使用Axios
的复杂示例。此示例将对星球大战 API 进行两次不同的 API 调用,并返回两个信息列表。目前,我们将跳过使用 Vuex,以使这个介绍更简单。
要访问此练习的代码文件,请参阅packt.live/3kbn1x1
。
-
创建一个新的 Vue 应用程序,CLI 完成之后,将
Axios
添加为npm
依赖项:npm install axios
-
打开
App.vue
页面并添加对axios
的导入:import axios from 'axios';
-
打开
App.vue
页面并为films
和ships
数组添加数据值:data() { return { films:[], ships:[] } },
-
打开
App.vue
并使用created
方法从 API 加载films
和starships
:created() { axios.get('https://swapi.dev/api/films') .then(res => { this.films = res.data.results; }) .catch(error => { console.error(error); }); axios.get('https://swapi.dev/api/starships') .then(res => { this.ships = res.data.results; }) .catch(error => { console.error(error); }); }
-
接下来,编辑模板以迭代值并显示它们:
<h2>Films</h2> <ul> <li v-for="film in films" :key="film.url"> {{ film.title }} was released in {{ film.release_date }} </li> </ul> <h2>Starships</h2> <ul> <li v-for="ship in ships" :key="ship.url"> {{ ship.name }} is a {{ ship.starship_class }} </li> </ul>
注意
错误处理是通过 catch 处理程序完成的,但只是发送到浏览器控制台。如果远程数据没有加载,最好告诉用户一些信息,但到目前为止,这是可以接受的。另一个建议是处理加载状态,您将在本章后面的示例中看到。
-
使用以下命令启动应用程序:
npm run serve
在您的浏览器中打开 URL 将生成以下输出:
图 10.1:浏览器中渲染的 API 调用结果
这个简单的示例展示了将Axios
添加到 Vue 应用程序是多么容易。请记住,Axios
不是 Vue 的必需品,您可以使用任何您想要的库,或者简单地使用浏览器本地的 Fetch API。
现在您已经看到了如何将Axios
引入项目,让我们看看Axios
的一个更酷的特性:指定默认值。
使用 Axios 的默认值
虽然练习 10.01中的使用 Axios 从 API 加载数据代码运行良好,但让我们考虑一个稍微复杂一点的例子。Axios
的一个特性是能够设置在后续调用中使用的默认值。如果您查看前面代码中进行的两个调用,您可以看到它们是相似的。您可以更新created
方法来利用这一点:
created() {
const api = axios.create({
baseURL:'https://swapi.dev/api/',
transformResponse(data) {
data = JSON.parse(data);
return data.results;
}
});
api.get('films')
.then(res => this.films = res.data);
api.get('starships')
.then(res => this.ships = res.data);
}
在这个更新版本中,我们切换到Axios
的一个实例。指定了一个默认的baseURL
值,这样在后续操作中可以节省输入。接下来,使用transformResponse
功能来转换响应。这让我们可以在数据发送到后续调用处理程序之前对其进行修改。由于所有 API 调用都返回一个结果值,而我们只关心这个值,所以我们通过只返回这个值而不是整个结果来简化事情。请注意,如果你想要构建一个复杂的转换集,Axios
允许你在transformResponse
中使用一个函数数组。
在下一节,我们将学习如何使用Axios
与 Vuex 结合。
使用 Vuex 与 Axios 结合
现在你已经看到了使用Axios
的基本方法,是时候考虑如何将它与 Vuex 结合使用了。一种简单的方法是直接使用 Vuex 来处理对 API 的调用封装,使用Axios
来执行 HTTP 调用。
练习 10.02:在 Vuex 中使用 Axios
我们将使用之前的功能(加载films
和ships
数组)并在 Vuex 存储的上下文中重新构建它。和之前一样,你需要使用 CLI 来搭建一个新的应用,并确保你要求包含 Vuex。CLI 完成后,你可以使用npm
命令添加Axios
。
这个练习将与我们在练习 10.01中构建的第一个应用非常相似,即使用 Axios 从 API 加载数据,但有一些细微的差别。让我们首先看看 UI。在初始加载时,Films
和Ships
都是空的:
图 10.2:初始应用 UI
注意到Films
部分有一个加载信息。一旦应用加载,我们将发起一个请求来获取这些数据。对于Ships
,我们则等待用户明确请求他们想要这些数据。以下是films
数组加载后的样子:
图 10.3:应用的渲染电影
最后,在点击Load Ships
按钮后,按钮将禁用(以防止用户多次请求数据),然后在数据加载完成后,整个按钮将被移除:
图 10.4:所有内容加载完成后的最终视图
要访问这个练习的代码文件,请参考packt.live/32pUsWy
。
-
从第一个组件
App.vue
开始,编写 HTML。记住,films
在组件中显示,但ships
将在自己的组件中。使用v-else
添加一个加载信息,这个信息将在Axios
进行 HTTP 请求时显示:<template> <div id="app"> <h2>Films</h2> <ul v-if="films.length"> <li v-for="film in films" :key="film.url"> {{ film.title }} was released in {{ film.release_date }} </li> </ul> <div v-else> <i>Loading data...</i> </div> <Ships /> </div> </template>
-
现在添加必要的代码来加载和注册
Ships
组件:import Ships from './components/Ships.vue' export default { name: 'app', components: { Ships },
-
同时导入
mapState
:import { mapState } from 'vuex';
-
接下来,添加代码将我们的存储中的
films
数组映射到一个本地的计算值。记住要导入mapState
:computed: { ...mapState(["films"]) },
-
最后,使用
created
方法在我们的存储器中触发一个动作:created() { this.$store.dispatch('loadFilms'); }
-
接下来,在
components/Ship.vue
中构建Ships
组件。Ships
组件也包含数据列表,但使用按钮让用户可以请求加载数据。按钮在完成时应该自动消失,并在加载过程中禁用:<template> <div> <h2>Ships</h2> <div v-if="ships.length"> <ul> <li v-for="ship in ships" :key="ship.url"> {{ ship.name }} is a {{ ship.starship_class }} </li> </ul> </div> <button v-else @click="loadShips" :disabled="loading">Load Ships</button> </div> </template>
-
添加处理
ships
状态映射的代码,并触发 Vuex 中的动作来加载ships
:<script> import { mapState } from 'vuex'; export default { name: 'Ships', data() { return { loading:false } }, computed: { ...mapState(["ships"]) }, methods:{ loadShips() { this.loading = true; this.$store.dispatch('loadShips'); } } } </script>
-
现在,构建存储器。首先,定义
state
来保存films
和ships
数组:import Vue from 'vue' import Vuex from 'vuex' import axios from 'axios' Vue.use(Vuex) export default new Vuex.Store({ state: { films:[], ships:[] },
-
接下来,添加加载
ships
和films
数据的动作。它们都应该使用mutations
来将值赋给state
:mutations: { setFilms(state, films) { state.films = films; }, setShips(state, ships) { state.ships = ships; } }, actions: { loadFilms(context) { axios.get('https://swapi.dev/api/films') .then(res => { context.commit('setFilms', res.data.results); }) .catch(error => { console.error(error); }); }, loadShips(context) { axios.get('https://swapi.dev/api/starships') .then(res => { context.commit('setShips', res.data.results); }) .catch(error => { console.error(error); }); } } })
-
使用以下命令运行您的应用程序:
npm run serve
您的输出将是以下内容:
图 10.5:最终输出
总体而言,这并不是与没有 Vuex 的初始版本有巨大变化(如果我们忽略 UI 变化),但现在我们所有的 API 使用都由存储器处理。如果我们决定停止使用Axios
并切换到 Fetch,这可以在这里完成。无论我们决定添加缓存系统还是存储数据以供离线使用,都可以在存储器中完成。通过运行npm run serve
并在浏览器中打开 URL 来自行测试这个版本。
现在是时候将您所学到的知识应用到下一个活动上了!
活动十.01:使用 Axios 和 Vuex 进行身份验证
Vuex 的一个更有趣的功能是管理身份验证。我们这是什么意思?在许多 API 中,在使用服务之前需要身份验证。用户验证后,他们会被分配一个令牌。在未来的 API 调用中,令牌会随请求一起传递,通常作为头部信息,这会让远程服务知道这是一个授权用户。Vuex 可以为您处理所有这些,而Axios
使得处理头部信息变得容易,所以让我们考虑一个实际操作的例子。
在本书中构建具有身份验证和授权的服务器远远超出了本书的范围,因此,我们将采取模拟的方式。我们将使用两个JSONBin.io
,这是我们曾在第九章,使用 Vuex – 状态、获取器、动作和突变中使用的服务。第一个端点将返回一个令牌:
{
"token": 123456789
}
第二个端点将返回一个cats
数组:
[
{
"name": "Luna",
"gender": "female"
},
{
"name": "Pig",
"gender": "female"
},
{
"name": "Cracker",
"gender": "male"
},
{
"name": "Sammy",
"gender": "male"
},
{
"name": "Elise",
"gender": "female"
}
]
在这个活动中,我们将使用 Vue Router 来处理表示应用程序的两个视图,即登录界面和猫展示界面。
步骤:
-
为应用程序的初始视图提供一个登录界面。它应该提示用户名和密码。
-
将登录凭证传递给端点并获取一个令牌。这部分将进行模拟,因为我们不是在构建一个完整的、真实的身份验证系统。
-
从远程端点加载猫,并将令牌作为身份验证头部传递。
初始输出应该是以下内容:
图 10.6:初始登录界面
登录后,您将看到以下数据:
图 10.7:登录后成功显示数据
注意
该活动的解决方案可以通过此链接找到。
摘要
在本章中,你学习了 Vuex 的一个重要用例——与远程 API 协同工作。远程 API 可以为你的应用程序提供大量的额外功能,有时对开发者的额外成本几乎为零。你看到了如何使用Axios
使网络调用更简单,以及如何将 Vuex 的状态管理功能与之结合。最后,你将其与 Vue Router 结合,创建了一个简单的登录/授权演示。
在下一章中,我们将讨论如何使用模块构建更复杂的 Vuex 存储。
第十一章:11. 使用 Vuex – 组织更大的存储
概述
在本章中,你将学习如何更好地组织更大的 Vuex 存储。随着你的应用程序在复杂性和功能上的增长,你的存储文件可能变得难以操作。随着文件越来越大,甚至简单地找到东西也可能变成一项困难的任务。本章将讨论两种不同的方法来简化存储的组织,以便进行更简单的更新。第一种方法将要求你将代码拆分到不同的文件中,而第二种方法将使用更高级的 Vuex 功能,即模块。
简介
到目前为止,我们处理过的存储都很简单且简短。但是,正如众所周知的那样,即使是简单的应用程序随着时间的推移也会趋向于复杂化。正如你在前面的章节中学到的,你的存储可以包含一个state
、一个getters
的块、一个mutations
和actions
的块,以及你将在本章后面学到的内容,即modules
。
随着你的应用程序增长,拥有一个文件来管理你的 Vuex 存储(store)可能会变得难以管理。修复错误和更新新功能可能会变得更加困难。本章将讨论两种不同的方法来帮助管理这种复杂性并组织你的 Vuex 存储。为了明确,这些都是你可以做的可选事情来帮助管理你的存储。如果你的存储很简单,并且你希望保持这种状态,那也是可以的。你总是可以在将来使用这些方法,而且好处是,没有人需要知道你的存储之外的事情——他们将继续像以前一样使用 Vuex 数据。你可以将这些技巧作为一组工具保留在心中,以帮助你在应用程序需要升级时使用。让我们从最简单的方法,文件拆分,开始。
方法一 – 使用文件拆分
第一种方法,当然也是最简单的一种方法,就是简单地将你的各种 Vuex 部分的代码(如state
、getters
等)移动到它们自己的文件中。然后,这些文件可以被主 Vuex 存储import
并正常使用。让我们考虑一个简单的例子:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
name:"Lindy",
favoriteColor: "blue",
profession: "librarian"
},
mutations: {
},
actions: {
},
modules: {
}
})
这是从第九章中的第一个练习使用 Vuex – 状态、获取器、动作和突变中来的,并且是一个只有三个状态值的存储。要将状态迁移到新文件,你可以在store
文件夹中创建一个名为state.js
的新文件,并按照如下设置:
export default {
name: 'Lindy',
favoriteColor: 'blue',
profession: 'librarian'
}
然后,回到你的存储中,将其修改为import
并使用代码:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
export default new Vuex.Store({
state,
mutations: {
},
actions: {
},
modules: {
}
})
虽然这个例子最终变成了更多的代码行,但你可以看到我们是如何开始将存储的不同部分分离到不同的文件中,以便更容易更新的。让我们考虑一个稍微大一点的例子,再次参考第九章中的第二个练习,使用 Vuex – 状态、获取器、动作和突变。以下是原始存储:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
firstName: "Lindy",
lastName: "Roberthon"
},
getters: {
name(state) {
return state.firstName + ' ' + state.lastName;
}
},
mutations: {
},
actions: {
},
modules: {
}
})
这个例子只使用了state
值和一个getter
,但让我们将它们都移动到新文件中。首先,让我们将state
移动到名为state.js
的文件中:
export default {
firstName: 'Lindy',
lastName: 'Roberthon'
}
接下来,让我们将 getters
移入一个名为 getters.js
的文件中:
export default {
name(state) {
return state.firstName + ' ' + state.lastName;
}
}
现在我们可以更新存储:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
import getters from './getters.js';
export default new Vuex.Store({
state,
getters,
mutations: {
},
actions: {
},
modules: {
}
})
将相同类型的更新应用于 mutations
和 actions
将遵循完全相同的模式,并且显然,你不必拆分一切。例如,你可以将状态值保留在主文件中,但只拆分你的函数(getters
、mutations
和 actions
)。
练习 11.01:使用文件拆分
在这个练习中,我们将在一个稍微大一点的 Vue store 中使用文件拆分。说实话,它并不大,但我们将会使用文件拆分来处理 state
、getters
、mutations
和 actions
。
要访问此练习的代码文件,请访问 packt.live/32uwiKB
:
-
生成一个新的 Vue 应用程序并添加 Vuex 支持。
-
修改默认存储的
index.js
文件(位于src/store/index.js
),导入我们将创建的四个文件来表示存储:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) import state from './state.js'; import getters from './getters.js'; import mutations from './mutations.js'; import actions from './actions.js'; export default new Vuex.Store({ state, getters, mutations, actions, modules: { } })
-
编辑新的
state.js
文件,添加姓名和姓氏的值,代表该人拥有的猫和狗数量的数字,以及一个最爱电影:export default { firstName: 'Lindy', lastName: 'Roberthon', numCats: 5, numDogs: 1, favoriteFilm:'' }
-
添加一个
getter.js
文件来定义全名和宠物总数的getter
:export default { name(state) { return state.firstName + ' ' +state.lastName }, totalPets(state) { return state.numCats + state.numDogs } }
-
接下来,添加一个
mutations.js
文件来添加猫和狗的数量,设置姓名和姓氏,以及添加最爱电影:export default { addCat(state) { state.numCats++; }, addDog(state) { state.numDogs++; }, setFirstName(state, name) { if(name !== '') state.firstName = name; }, setLastName(state, name) { if(name !== '') state.lastName = name; }, setFavoriteFilm(state, film) { if(film !== '') state.favoriteFilm = film; } }
-
最后,添加
actions.js
文件来定义一个动作,updateFavoriteFilm
。这将向Star Wars
API 发起网络请求,以确保只有当新的最爱电影是《星球大战》电影时才允许:export default { async updateFavoriteFilm(context, film) { try { let response = await fetch('https://swapi.dev/api/films?search='+encodeURIComponent(film)); let data = await response.json(); if(data.count === 1) context.commit ('setFavoriteFilm', film); else console.log('Ignored setting non-Star Wars film '+film+' as favorite.'); } catch(e) { console.error(e); } } }
-
要看到它的实际效果,更新
src/App.vue
以访问存储的各个部分。这一步的唯一目的是强调你使用存储的方式并没有改变:<template> <div id="app"> My first name is {{ $store.state.firstName }}.<br/> My full name is {{ $store.getters.name }}.<br/> I have this many pets - {{ $store.getters.totalPets }}.<br/> My favorite film is {{ $store.state.favoriteFilm }}. </div> </template> <script> export default { name: 'app', created() { this.$store.dispatch('updateFavoriteFilm', 'A New Hope'); } } </script>
上述代码将生成如下输出:
![图 11.1:新组织存储的输出
![img/B15218_11_01.jpg]
图 11.1:新组织存储的输出
你现在已经看到了一个(相对简单)的例子,使用文件拆分来管理 Vuex 存储的大小。虽然功能与之前看到的不同,但随着你的应用程序的增长,你可能会发现添加和修复要容易得多。
第二种方法 – 使用模块
在先前的方法中,我们主要只是将代码行移动到其他文件中。正如我们所说的,虽然这使处理存储本身变得更容易,但它并没有改变 Vue 组件使用存储的方式。模块帮助我们处理组件级别的复杂性。
想象一个包含许多不同值的 state
对象,例如这个:
state: {
name:"Lindy",
favoriteColor: "blue",
profession: "librarian",
// lots more values about Lindy
books: [
{ name: "An Umbrella on Fire", pages: 283 },
{ name: "Unicorn Whisperer", pages: 501 },
// many, many more books
],
robots: {
skill:'advanced',
totalAllowed: 10,
robots: [
{ name: "Draconis" },
// so much robots
]
}
}
这个例子包含了关于一个人的信息,与书籍相关的数据,以及代表机器人的值集。这是一大批数据,涵盖了三个独特不同的主题。将这些内容移入单独的文件并不一定能使使用变得更简单或有助于保持组织有序。这种复杂性也会影响到getters
、mutations
和actions
。给定一个名为setName
的操作,你可以假设它适用于代表个人的状态值,但如果其他状态值有类似的名字,可能会开始变得混乱。
这就是模块的作用。一个模块允许我们定义一个完全独立的state
、getters
、mutations
和actions
,与根或核心存储完全分离。
下面是一个使用resume
模块的示例存储:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
firstName:'Raymond',
lastName:'Camden'
},
getters: {
name(state) {
return state.firstName + ' ' + state.lastName;
}
},
modules: {
resume: {
state: {
forHire:true,
jobs: [
"Librarian",
"Jedi",
"Cat Herder"
]
},
getters: {
totalJobs(state) {
return state.jobs.length;
}
}
}
}
})
state
和getters
也可以公开mutations
和actions
。注意在resume
模块的getters
中,totalJobs
的state
变量引用的是它自己的状态,而不是父状态。这是非常好的,因为它确保你可以在模块内部工作,而不用担心意外修改根或其他模块中的某个值。你可以在getters
中使用一个新的第三个参数rootState
来访问根状态:
totalJobs(state, anyArgument, rootState)
动作可以通过上下文对象context.rootState
使用rootState
。然而,从理论上讲,你的模块应该关注它们自己的数据,并且只有在必要时才向外扩展到根状态。
当使用模块值时,你的代码必须知道模块的名称。考虑以下示例:
first name {{ $store.state.firstName }}<br/>
for hire? {{ $store.state.resume.forHire }}<br/>
getters
、actions
和mutations
并没有被区分。这就是你访问getters
的方式:
full name {{ $store.getters.name }}<br/>
total jobs {{ $store.getters.totalJobs }}<br/>
这个想法背后的目的是允许一个模块或多个模块可能对相同的调用做出响应。如果你不喜欢这个,你可以使用namespaced
选项:
modules: {
resume: {
namespaced: true,
state: {
forHire:true,
jobs: [
"Librarian",
"Jedi",
"Cat Herder"
]
},
getters: {
totalJobs(state) {
return state.jobs.length;
}
}
}
}
然后要引用此模块的getters
、mutations
和actions
,你必须将模块的名称作为调用的一部分传递。例如,现在的 getter 变成了:$store.getters['resume/totalJobs']
。
大部分来说,这是模块支持的核心,但请注意,还有更多关于模块如何全局暴露自己的选项,这些选项超出了本书的范围。请参阅模块文档的后半部分(vuex.vuejs.org/guide/modules.html
)以获取相关示例。最后,请注意,你可以根据需要将模块嵌套在模块中,Vuex 允许这样做!
练习 11.02:利用模块
在这个练习中,我们将与一个 Vuex 存储库一起工作,它使用不止一个模块,为了使它更有趣,其中一个模块将存储在另一个文件中,这表明我们在使用模块时也可以使用第一种方法。
要访问此练习的代码文件,请访问packt.live/35d1zDv
:
-
如同往常,生成一个新的 Vue 应用程序,并确保你添加了 Vuex。
-
在
store/index.js
存储文件中,为姓氏和名字添加两个state
值,并添加一个 getter 来返回两者:state: { firstName:'Raymond', lastName:'Camden' }, getters: { name(state) { return state.firstName + ' ' + state.lastName; } },
-
接下来,向
store
文件添加一个resume
模块。它将有两个state
值,一个表示可雇佣值,另一个是一个表示过去工作的数组。最后,添加一个 getter 来返回工作的总数:modules: { resume: { state: { forHire:true, jobs: [ "Librarian", "Jedi", "Cat Herder" ] }, getters: { totalJobs(state) { return state.jobs.length; } } },
-
现在为下一个模块创建一个新的文件,
store/portfolio.js
。这将包含一个表示已工作的网站数组的state
值和一个添加值的mutation
:export default { state: { websites: [ "https://www.raymondcamden.com", "https://codabreaker.rocks" ] }, mutations: { addSite(state, url) { state.websites.push(url); } } }
-
在主存储的
index.js
文件中,导入portfolio
:import portfolio from './portfolio.js';
-
然后将
portfolio
添加到模块列表中,在resume
之后:modules: { resume: { state: { forHire:true, jobs: [ "Librarian", "Jedi", "Cat Herder" ] }, getters: { totalJobs(state) { return state.jobs.length; } } }, portfolio }
-
现在,让我们在我们的主
src/App.vue
文件中使用这些模块。修改模板以添加对存储中各个部分的调用:<p> My name is {{ $store.getters.name }} and I <span v-if="$store.state.resume.forHire"> am looking for work! </span><span v-else> am not looking for work. </span> </p> <p> I've had {{ $store.getters.totalJobs }} total jobs. </p> <h2>Portfolio</h2> <ul> <li v-for="(site,idx) in $store.state.portfolio.websites" :key="idx"><a :href="site" target="_new">{{ site }}</a></li> </ul>
-
然后添加一个表单,以便我们可以添加一个新网站:
<p> <input type="url" placeholder="New site for portfolio" v-model="site"> <button @click="addSite">Add Site</button> </p>
-
定义
addSite
方法的函数。它将提交mutation
并清除站点值。务必为站点添加一个本地数据值。以下是完整的脚本块:export default { name: 'app', data() { return { site:'' } }, methods: { addSite() { this.$store.commit('addSite', this.site); this.site = ''; } } }
结果将如下所示:
图 11.2:使用模块利用 Vuex 数据的应用程序
现在你已经看到了另一种帮助管理你的 Vuex 存储的方法。模块提供了一种更深入、更复杂的组织存储的方式。一如既往,选择最适合你的应用程序需求以及你和你的团队最舒适的方法!
组织 Vuex 存储的其他方法
虽然前两种方法应该为你提供一些管理 Vuex 存储的好选项,但你可能还想考虑其他一些选项。
Vuex Pathify
Vuex Pathify([davestewart.github.io/vuex-pathify/
](https://davestewart.github.io/vuex-pathify/))是一个实用工具,它使得通过resume
和state
值jobs
访问 Vuex 存储变得更加容易:store.get('resume/jobs')
。基本上,它为读取和写入存储中的值以及简化同步创建了一个快捷方式。XPath 的爱好者会喜欢这个。
Vuex 模块生成器(VMG)
state
、mutations
和actions
。任何在 Web 开发领域工作过一段时间的人都会熟悉 CRUD 模式,并且绝对会为不必再次编写这些函数而感到高兴。
查看 GitHub 仓库(github.com/abdullah/vuex-module-generator
)以获取更多详细信息及示例应用程序。
Vuex ORM
将ORM
库添加到 Vuex 存储中。ORM代表对象关系映射,是一种帮助简化对象持久化的模式。像 VMG 一样,Vuex ORM 旨在简化 Web 开发者必须编写的相对常见的 CRUD 任务。
Vuex ORM 允许你定义代表你的存储数据结构的类。一旦你定义了数据结构,Vuex ORM 就提供了实用函数,使得在存储中存储和检索数据变得更加简单。它甚至处理数据之间的关系,例如属于它的 cat
对象。
下面是如何定义一种数据类型的示例:
class Cat extends Model {
static entity = 'cats'
static fields () {
return {
id: this.attr(null),
name: this.string(''),
age: this.number(0),
adoptable: this.boolean(true)
}
}
}
在前面的课程中,为 Cat
类定义了四个属性:id
、name
、age
和 adoptable
。对于每个属性,都指定了默认值。一旦定义,请求所有数据就像 Cat.all()
一样简单。Vuex ORM 还有更多内容,你可以在 vuex-orm.github.io/vuex-orm/
上查看。
活动 11.01:简化 Vuex 存储
这个活动将与你之前做过的活动略有不同。在这个活动中,你将使用一个 现有 的应用,该应用使用 Vuex,并应用本章中学到的某些技术来简化存储,使其在未来更新中更容易使用。这在进行功能调整或修复时可能非常有用。
步骤:
-
要开始这个活动,你将使用位于
Chapter11/activity11.01/initial
的完成示例(packt.live/3kaqBHH
)。 -
修改存储文件,将
state
、getters
和mutations
放入它们自己的文件。 -
修改
state
,使cat
值位于module
中。 -
将与猫相关的
getter
迁移到module
。 -
更新
App.vue
文件,使其仍然正确显示最初的数据。这是构建后的样子:
图 11.3:活动的最终输出
注意
这个活动的解决方案可以通过这个链接找到。
摘要
在本章中,你学习了多种不同的技术来为你的 Vuex 存储准备增长复杂性。你首先学习了如何将逻辑移动到单独的文件并在你的存储中包含它们。然后你学习了模块以及它们是如何通过存储暴露给组件的。最后,你学习了可能使 Vuex 使用更加强大的某些可选库。
在下一章,你将学习关于开发一个极其重要的方面,单元测试。
第十二章:12. 单元测试
概述
在本章中,我们将探讨对 Vue.js 应用程序进行单元测试的方法,以提高我们的质量和交付速度。我们还将探讨使用测试来驱动开发,即 Test-Driven Development(TDD)。
随着我们继续前进,你将了解为什么代码需要被测试,以及可以在 Vue.js 应用的不同部分采用哪些类型的测试。你将看到如何使用浅渲染和 vue-test-utils
对隔离组件及其方法进行单元测试,你还将学习如何测试异步组件代码。在整个章节的过程中,你将熟悉编写针对 混入 和 过滤器 的有效单元测试的技术。在章节的末尾,你将熟悉包括路由和 Vuex 在内的 Vue.js 应用程序的测试方法,你还将了解如何使用快照测试来验证你的用户界面。
简介
在本章中,我们将探讨有效测试 Vue.js 应用程序的目的和方法。
在前面的章节中,我们看到了如何构建合理的复杂 Vue.js 应用程序。本章是关于测试它们以保持代码质量和防止缺陷。
单元测试将使我们能够编写快速且具体的测试,我们可以针对这些测试进行开发,并确保功能不会表现出不受欢迎的行为。我们将了解如何为 Vue.js 应用的不同部分编写单元测试,例如组件、混入、过滤器以及路由。我们将使用 Vue.js 核心团队支持的工具,如 vue-test-utils
,以及开源社区其他部分支持的工具,如 Vue 测试库和 Jest 测试框架。这些不同的工具将用于说明不同的单元测试哲学和方法。
我们为什么需要测试代码
测试对于确保代码按预期执行至关重要。
质量生产软件是经验上正确的。这意味着对于开发人员和测试人员发现的列举案例,应用程序的行为符合预期。
这与已被 证明 正确的软件形成对比,这是一个非常耗时的工作,通常是学术研究项目的一部分。我们仍然处于这样一个阶段,即 正确的软件(已证明)仍在构建,以展示在正确性的约束下可以构建哪些类型的系统。
测试可以防止引入缺陷,如错误和回归(即,当某个功能停止按预期工作时)。在下一节中,我们将了解各种测试类型。
理解不同类型的测试
测试范围从端到端测试(通过操作用户界面)到集成测试,最后到单元测试。端到端测试测试一切,包括用户界面、底层 HTTP 服务,甚至数据库交互;没有任何内容被模拟。例如,如果你有一个电子商务应用程序,端到端测试可能会实际使用真实信用卡下订单,或者它可能会使用测试信用卡下测试订单。
端到端测试的运行和维护成本较高。它们需要使用通过程序性驱动程序(如Selenium、WebdriverIO或Cypress)控制的完整浏览器。这种测试平台运行成本较高,应用代码中的微小变化都可能导致端到端测试开始失败。
集成或系统级测试确保一组系统按预期工作。这通常涉及确定被测试系统的界限,并允许它运行,通常是对模拟或存根的上游服务和系统进行测试(因此这些服务和系统不在测试范围内)。由于外部数据访问被存根,可以减少许多问题,如超时和故障(与端到端测试相比)。集成测试套件通常足够快,可以作为持续集成步骤运行,但完整的测试套件通常不会由工程师在本地运行。
单元测试在开发过程中提供快速反馈方面非常出色。单元测试与 TDD(测试驱动开发)相结合是极限编程实践的一部分。单元测试擅长测试复杂的逻辑或从预期的输出构建系统。单元测试通常足够快,以至于开发者在将代码提交审查和持续集成测试之前,会先针对它们编写代码。
以下是对测试金字塔的解释。它可以理解为:你应该有大量便宜且快速的单元测试,合理数量的系统测试,以及少数端到端 UI 测试:
![图 12.1:测试金字塔图]
![图 B15218_12_01.jpg]
图 12.1:测试金字塔图
现在我们已经了解了为什么我们应该测试应用程序,让我们开始编写一些测试。
你的第一个测试
为了说明在 Vue CLI 项目中开始自动化单元测试有多快、有多简单,我们将首先设置并使用 Jest 和@vue-test-utils
编写一个单元测试。有一个官方的 Vue CLI 包可以用来生成一个包含使用 Jest 和vue-test-utils
进行单元测试的设置。以下命令应在已设置 Vue CLI 的项目中运行:
vue add @vue/unit-jest
Vue CLI 将 Jest 作为测试运行器,@vue/test-utils
作为官方的Vue.js
测试工具,以及vue-jest
,它是 Jest 中用于处理.vue
单文件组件文件的处理器。它添加了一个test:unit
脚本。
默认情况下,它创建一个tests/unit
文件夹,我们将删除它。相反,我们可以创建一个__tests__
文件夹,并创建一个App.test.js
文件,如下所示。
我们将使用 shallowMount
来渲染应用程序并测试它是否显示正确的文本。为了本例的目的,我们将使用文本:"The Vue.js Workshop Blog
"。
shallowMount
进行浅渲染,这意味着只渲染组件的最顶层;所有子组件都是占位符。这对于单独测试组件很有用,因为子组件的实现并未运行:
import { shallowMount } from '@vue/test-utils'
import App from '../src/App.vue'
test('App renders blog title correctly', () => {
const wrapper = shallowMount(App)
expect(wrapper.text()).toMatch("The Vue.js Workshop Blog")
})
当我们运行 npm run test:unit
时,这个测试将失败,因为我们没有在 App
组件中包含 The Vue.js Workshop Blog
:
![图 12.2:在命令行中测试失败的博客标题标题]
![图片 B15218_12_02.jpg]
图 12.2:在命令行中测试失败的博客标题标题
为了让测试通过,我们可以在 App.vue
文件中实现我们的博客标题标题:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<h2
class="leading-loose pb-4 flex justify-center m-auto md:w-1/3 text-xl mb-8 font-bold text-gray-800 border-b"
>
The Vue.js Workshop Blog
</h2>
</div>
</div>
</template>
现在我们已经得到了正确的标题,npm run test:unit
将会通过:
![图 12.3:博客标题测试通过]
![图片 B15218_12_03.jpg]
图 12.3:博客标题测试通过
我们还可以检查它在浏览器中的渲染是否符合预期:
The Vue.js Workshop Blog
你刚刚完成了你的第一个 TDD(测试驱动开发)。这个过程从编写一个失败的测试开始。随后是对测试代码(在本例中是 App.vue
组件)的更新,这使得失败的测试通过。TDD 过程让我们有信心我们的功能已经得到了适当的测试,因为我们可以看到在更新驱动我们功能的代码之前,测试是失败的。
测试组件
组件是 Vue.js 应用程序的核心。使用 vue-test-utils
和 Jest 对它们进行单元测试非常简单。对大多数组件进行测试可以让你有信心它们按设计运行。理想的组件单元测试运行速度快且简单。
我们将继续构建博客应用程序示例。我们现在已经构建了标题,但一个博客通常还需要一个帖子列表来显示。
我们将创建一个 PostList
组件。目前,它将只渲染一个 div
包装器并支持 posts
Array
属性:
<template>
<div class="flex flex-col w-full">
</div>
</template>
<script>
export default {
props: {
posts: {
type: Array,
default: () => []
}
}
}
</script>
我们可以在 App
组件中添加一些数据:
<script>
export default {
data() {
return {
posts: [
{
title: 'Vue.js for React developers',
description: 'React has massive popularity here are the key benefits of Vue.js over it.',
tags: ['vue', 'react'],
},
{
title: 'Migrating an AngularJS app to Vue.js',
description: 'With many breaking changes, AngularJS developers have found it easier to retrain to Vue.js than Angular 2',
tags: ['vue', 'angularjs']
}
]
}
}
}
</script>
现在我们有一些帖子,我们可以将它们作为绑定属性从 App
组件传递给 PostList
组件:
<template>
<!-- rest of template -->
<PostList :posts="posts" />
<!-- rest of template -->
</template>
<script>
import PostList from './components/PostList.vue'
export default {
components: {
PostList
},
// rest of component properties
}
我们的 PostList
组件将在 PostListItem
组件中渲染每篇帖子,我们将按以下方式创建它。
PostListItem
接受两个属性:title
(一个字符串)和 description
(也是一个字符串)。它分别用 h3
标签和 p
标签渲染它们:
<template>
<div class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4">
<h3 class="flex text-md font-semibold text-gray-700">
{{ title }}</h3>
<p class="flex leading-relaxed">{{ description }}</p>
</div>
</template>
<script>
export default {
props: {
title: {
type: String
},
description: {
type: String
}
}
}
</script>
现在,我们需要遍历帖子并使用 PostList.vue
组件渲染带有相关属性的 PostListItem
组件:
<template>
!-- rest of template -->
<PostListItem
v-for="post in posts"
:key="post.slug"
:title="post.title"
:description="post.description"
/>
<!-- rest of template -->
</template>
<script>
import PostListItem from './PostListItem.vue'
export default {
components: {
PostListItem,
},
// rest of component properties
}
</script>
我们现在可以在应用程序中看到标题和帖子列表:
The Vue.js Workshop blog
要测试 PostListItem
组件,我们可以使用一些任意的 title
和 description
属性进行浅渲染,并检查它们是否被渲染:
import { shallowMount } from '@vue/test-utils'
import PostListItem from '../src/components/PostListItem.vue'
test('PostListItem renders title and description correctly', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: 'Blog post title',
description: 'Blog post description'
}
})
expect(wrapper.text()).toMatch("Blog post title")
expect(wrapper.text()).toMatch("Blog post description")
})
运行 npm run test:unit __tests__/PostListItem.test.js
的测试输出如下;组件通过了测试:
![图 12.4:PostListItem 测试输出]
图 12.4:PostListItem 测试输出
接下来,我们将看到浅渲染的一个陷阱。当测试PostList
组件时,我们所能做的就是测试它渲染的PostListItem
组件的数量:
import { shallowMount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
import PostListItem from '../src/components/PostListItem.vue'
test('PostList renders the right number of PostListItem', () => {
const wrapper = shallowMount(PostList, {
propsData: {
posts: [
{
title: "Blog post title",
description: "Blog post description"
}
]
}
})
expect(wrapper.findAll(PostListItem)).toHaveLength(1)
})
这通过了,但我们测试的是用户不会直接与之交互的东西,即PostList
中渲染的PostListItem
实例的数量,如下面的截图所示:
图 12.5:PostList 测试输出
一个更好的解决方案是使用mount
函数,它渲染完整的组件树,而shallow
函数只会渲染正在渲染的组件的子组件。使用mount
,我们可以断言标题和描述被渲染到页面上。
这种方法的缺点是我们同时测试了PostList
组件和PostListItem
组件,因为PostList
组件不渲染标题或描述;它渲染一组PostListItem
组件,这些组件反过来渲染相关的标题和描述。
代码如下:
import { shallowMount, mount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
// other imports and tests
test('PostList renders passed title and description for each passed post', () => {
const wrapper = mount(PostList, {
propsData: {
posts: [
{
title: 'Title 1',
description: 'Description 1'
},
{
title: 'Title 2',
description: 'Description 2'
}
]
}
})
const outputText = wrapper.text()
expect(outputText).toContain('Title 1')
expect(outputText).toContain('Description 1')
expect(outputText).toContain('Title 2')
expect(outputText).toContain('Description 2')
})
新的测试按照以下npm run test:unit __tests__/PostList.vue
的输出通过:
图 12.6:PostList 的浅渲染和挂载测试运行
我们现在已经看到了如何使用 Jest 和vue-test-utils
为 Vue.js 组件编写单元测试。这些测试可以经常运行,测试运行在几秒内完成,这在我们处理新组件或现有组件时提供了几乎即时的反馈。
练习 12.01:构建和单元测试标签列表组件
当创建posts
的测试用例时,我们用vue
、angularjs
和react
填充了tags
字段,但没有显示它们。为了使标签有用,我们将在帖子列表中显示标签。
要访问此练习的代码文件,请参阅packt.live/2HiTFQ1
:
-
我们可以首先编写一个单元测试,说明当传递一组标签作为 props 给
PostListItem
组件时,我们期望它做什么。它期望每个标签都会有一个前置的井号;例如,react
标签将显示为#react
。在__tests__/PostListItem.test.js
文件中,我们可以添加一个新的test
:// rest of tests and imports test('PostListItem renders tags with a # prepended to them', () => { const wrapper = shallowMount(PostListItem, { propsData: { tags: ['react', 'vue'] } }) expect(wrapper.text()).toMatch('#react') expect(wrapper.text()).toMatch('#vue') })
当使用
npm run test:unit __tests__/PostListItem.test.js
运行此测试时,测试失败:图 12.7:PostListItem 标签测试失败
-
接下来,我们应该在
src/components/PostListItem.vue
中实现标签列表渲染。我们将添加标签作为Array
类型的 props,并使用v-for
渲染标签:<template> <!-- rest of template --> <div class="flex flex-row flex-wrap mt-4"> <a v-for="tag in tags" :key="tag" class="flex text-xs font-semibold px-2 py-1 mr-2 rounded border border-blue-500 text-blue-500" > #{{ tag }} </a> </div> <!-- rest of template --> </template> <script> export default { props: { // rest of props tags: { type: Array, default: () => [] } } } </script>
在实现了
PostListItem
组件之后,单元测试现在应该通过:图 12.8:PostListItem 单元测试通过
然而,标签在应用程序中没有显示:
![图 12.9:显示没有标签的 PostList]
正确的 PostListItem 实现
图 12.9:尽管 PostListItem 实现正确,但 PostList 显示没有标签
-
我们可以为
PostList
编写一个单元测试,以展示这种行为。本质上,我们将向我们的posts
列表中传递一些标签,并运行PostListItem.test.js
文件中已经存在的相同断言。我们将在__tests__/PostList.test.js
中这样做:// rest of tests and imports test('PostList renders tags for each post', () => { const wrapper = mount(PostList, { propsData: { posts: [ { tags: ['react', 'vue'] }, { tags: ['html', 'angularjs'] } ] } }) const outputText = wrapper.text() expect(outputText).toContain('#react') expect(outputText).toContain('#vue') expect(outputText).toContain('#html') expect(outputText).toContain('#angularjs') })
根据我们的应用程序输出,当使用
npm run test:unit __tests__/PostList.test.js
运行时,测试失败:![图 12.10:PostList 标签测试失败]
图 12.10:PostList 标签测试失败
-
为了修复这个测试,我们可以在
src/components/PostList.vue
中找到问题,这里的PostListItem
的tags
属性没有被绑定。通过更新src/components/PostList.vue
来绑定tags
属性,我们可以修复单元测试:<template> <!-- rest of template--> <PostListItem v-for="post in posts" :key="post.slug" :title="post.title" :description="post.description" :tags="post.tags" /> <!-- rest of template --> </template>
失败的单元测试现在通过了,如下面的截图所示:
![图 12.11:PostList 标签测试通过]
图 12.11:PostList 标签测试通过
标签也出现在应用程序中,如下面的截图所示:
![图 12.12:带有标签的博客列表渲染]
图 12.12:带有标签的博客列表渲染
我们已经看到了如何使用浅渲染和组件挂载来测试渲染的组件输出。让我们简要了解这些术语的含义:
-
浅渲染:这将在深度 1 处渲染,这意味着如果子元素是组件,它们将仅作为组件标签渲染;它们的模板将不会运行。
-
挂载:这将以与在浏览器中渲染相似的方式渲染整个组件树。
接下来,我们将探讨如何测试组件方法。
测试方法、过滤器和方法混合
由于 click
、input change
、focus change
和 scroll
)。
例如,一个将输入截断为八个字符的过滤器将实现如下:
<script>
export default {
filters: {
truncate(value) {
return value && value.slice(0, 8)
}
}
}
</script>
有两种测试它的方法。我们可以直接通过导入组件并在某些输入上调用 truncate
来测试它,就像 truncate.test.js
文件中那样:
import PostListItem from '../src/components/PostListItem.vue'
test('truncate should take only the first 8 characters', () => {
expect(
PostListItem.filters.truncate('longer than 8 characters')
).toEqual('longer t')
})
另一种方法是检查它在 PostListItem
组件中的使用情况:
<template>
<!-- rest of template -->
<h3 class="flex text-md font-semibold text-gray-700">
{{ title | truncate }}
</h3>
<!-- rest of template -->
</template>
现在,我们可以通过在 PostListItem.test.js
文件中将长标题传递给 PostListItem
组件来测试 truncate
,我们在以下测试中这样做:
// imports
test('PostListItem renders title and description correctly', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: 'Blog post title',
description: 'Blog post description'
}
})
expect(wrapper.text()).toMatch("Blog post title")
expect(wrapper.text()).toMatch("Blog post description")
})
// other tests
前面的代码将生成以下截图所示的输出:
![图 12.13:PostListItem 测试失败,因为]
标题的内容被截断
图 12.13:PostListItem 的标题测试失败,因为标题的内容被截断
为了修复这个问题,我们可以更新失败的测试,期望 Blog pos
而不是 Blog post title
。
这两种方法都是测试过滤器的优秀方法。正如我们之前在filters.truncate()
测试中看到的那样,它直接访问了truncate
过滤器。较宽松的单元测试是使用传入的属性并验证组件输出的测试。更紧密的单元测试通常意味着测试更简单,但这也意味着有时以与最终用户感知非常不同的方式测试功能。例如,用户永远不会直接调用filters.truncate()
。
我们已经看到了如何测试一个任意的truncate
过滤器。现在我们将实现一个ellipsis
过滤器并对其进行测试。
ellipsis
过滤器将应用于帖子描述,并将其长度限制为40
个字符加上…
。
练习 12.02:构建和测试省略号过滤器
我们已经看到了如何测试一个任意的truncate
过滤器;现在我们将实现一个ellipsis
过滤器并对其进行测试。
要访问此练习的代码文件,请参阅packt.live/2UK9Mcs
。
现在让我们看看构建和测试ellipsis
过滤器的步骤:
-
我们可以先为
ellipsis
过滤器编写一组测试(该过滤器将位于src/components/PostListItem.vue
中)。一个测试应该检查如果传入的值少于50
个字符,过滤器不做任何处理;另一个测试应该检查如果传入的值超过50
个字符,它将截断到50
个字符并附加…
。我们将在__tests__/ellipsis.test.js
文件中这样做:import PostListItem from '../src/components/PostListItem.vue' test('ellipsis should do nothing if value is less than 50 characters', () => { expect( PostListItem.filters.ellipsis('Less than 50 characters') ).toEqual('Less than 50 characters') }) test('ellipsis should truncate to 50 and append "..." when longer than 50 characters', () => { expect( PostListItem.filters.ellipsis( 'Should be more than the 50 allowed characters by a small amount' ) ).toEqual('Should be more than the 50 allowed characters by a...') })
-
我们现在可以在
src/components/PostListItem.vue
中实现ellipsis
的逻辑。我们将添加一个带有ellipsis
的filters
对象,如果传入的值超过50
个字符,它将使用String#slice
,否则不做任何处理:<script> export default { // rest of component properties filters: { ellipsis(value) { return value && value.length > 50 ? `${value.slice(0, 50)}...` : value } } } </script>
在这种情况下,现在测试通过
npm run test:unit __tests__/ellipsis.test.js
,如图图 12.14所示:![图 12.14:省略号过滤器单元测试通过]
![图片 B15218_12_14.jpg]
图 12.14:省略号过滤器单元测试通过
-
现在,我们需要将我们的
ellipsis
过滤器集成到组件中。为了检查这能否工作,我们首先可以在__tests__/PostListItem.test.js
中编写测试:// other tests and imports test('PostListItem truncates long descriptions', () => { const wrapper = shallowMount(PostListItem, { propsData: { description: 'Very long blog post description that goes over 50 characters' } }) expect(wrapper.text()).toMatch("Very long blog post description that goes over 50 ...") })
这个测试失败了,因为我们没有在组件模板中使用过滤器。输出将如下所示:
![图 12.15:PostListItem 省略号测试失败]
![图片 B15218_12_15.jpg]
![图 12.15:PostListItem 省略号测试失败]
-
为了使测试通过,我们需要将
description
属性通过src/components/PostListItem.vue
中的ellipsis
过滤器:<template> <!-- rest of template --> <p class="flex leading-relaxed">{{ description | ellipsis }} </p> <!-- rest of template --> </template>
现在,测试将通过,如下面的截图所示:
![图 12.16:PostListItem 省略号测试通过]
![图片 B15218_12_16.jpg]
![图 12.16:PostListItem 省略号测试通过]
我们可以在浏览器中的应用程序界面中看到描述被截断,如下所示:
![图 12.17:博客帖子项描述被截断到 50 个字符]
![图片 B15218_12_17.jpg]
图 12.17:博客帖子项描述被截断到 50 个字符
我们已经看到了如何测试 Vue.js 组件的过滤器和其他属性,不仅可以通过直接针对对象进行测试,还可以通过测试它在组件级测试中的功能来测试。
接下来,我们将看到如何处理使用 Vue.js 路由的应用程序。
测试 Vue 路由
我们目前有一个渲染我们博客主页或feed 视图的应用程序。
接下来,我们应该有帖子页面。为此,我们将使用 Vue Router,如前几章所述,并确保我们的路由通过单元测试按设计工作。
Vue Router 使用npm
安装,具体来说,npm install vue-router
,并在main.js
文件中进行配置:
// other imports
import router from './router'
// other imports and configuration
new Vue({
render: h => h(App),
router,
}).$mount(‹#app›)
router.js
文件使用Vue.use
将vue-router
注册到 Vue 中,并实例化一个VueRouter
实例:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
export default new VueRouter({})
没有路由的路由器并不很有用。我们将在router.js
文件中定义根路径(/
),以显示PostList
组件,如下所示:
// other imports
import PostList from './components/PostList.vue'
// registering of Vue router
const routes = [
{
path: '/',
component: PostList
}
]
export default new VueRouter({
routes
})
现在我们已经有了初始路由,我们应该更新App.vue
文件以利用由路由器渲染的组件。我们将渲染render-view
而不是直接使用PostList
。然而,posts
绑定保持不变:
<template>
<!-- rest of template -->
<router-view
:posts="posts"
/>
<!-- rest of template -->
</template>
现在,App.vue
文件中的帖子缺少一些核心数据来渲染SinglePost
组件。我们需要确保有slug
和content
属性,以便在我们的SinglePost
页面上渲染有用的内容:
<script>
export default {
data() {
return {
posts: [
{
slug: 'vue-react',
title: 'Vue.js for React developers',
description: 'React has massive popularity here are the key benefits of Vue.js over it.',
content:
'React has massive popularity here are the key benefits of Vue.js over it.
See the following table, we'll also look at how the is the content of the post.
There's more, we can map React concepts to Vue and vice-versa.',
tags: ['vue', 'react'],
},
{
slug: 'vue-angularjs',
title: 'Migrating an AngularJS app to Vue.js',
description: 'With many breaking changes, AngularJS developers have found it easier to retrain to Vue.js than Angular 2',
content:
'With many breaking changes, AngularJS developers have found it easier to retrain to Vue.js than Angular 2
Vue.js keeps the directive-driven templating style while adding a component model.
It's performant thanks to a great reactivity engine.',
tags: ['vue', 'angularjs']
}
]
}
}
}
</script>
现在,我们可以开始工作在SinglePost
组件上。目前,我们只是在模板中添加一些占位符。此外,SinglePost
将接收posts
作为属性,因此我们也可以填写这个属性:
<template>
<div class="flex flex-col w-full md:w-1/2 m-auto">
<h2
class="font-semibold text-sm mb-4"
>
Post: RENDER ME
</h2>
<p>Placeholder for post.content</p>
</div>
</template>
<script>
export default {
props: {
posts: {
type: Array,
default: () => []
}
}
}
</script>
接下来,我们将在router.js
中注册SinglePost
,使用/:postId
路径(这将通过this.$route.params.postId
在组件中可用):
// other imports
import SinglePost from './components/SinglePost.vue'
// vue router registration
const routes = [
// other route
{
path: '/:postId',
component: SinglePost
}
]
// exports and router instantiation
如果我们切换回实现SinglePost
组件,我们将能够访问postId
,它将映射到posts
数组中的 slug,并且我们也有posts
的访问权限,因为它被App
绑定到render-view
。现在我们可以创建一个计算属性post
,它根据postId
查找帖子:
<script>
export default {
// other properties
computed: {
post() {
const { postId } = this.$route.params
return posts.find(p => p.slug === postId)
}
}
}
</script>
从这个计算后的post
属性中,我们可以提取title
和content
,如果post
存在的话(我们必须注意那些不存在的帖子)。所以,在SinglePost
中,我们可以添加以下计算属性:
<script>
export default {
// other properties
computed: {
// other computed properties
title() {
return this.post && this.post.title
},
content() {
return this.post && this.post.content
}
}
}
</script>
然后,我们可以用计算属性的值替换模板中的占位符。因此,我们的模板最终如下所示:
<template>
<div class="flex flex-col w-full md:w-1/2 m-auto">
<h2
class="font-semibold text-sm mb-4"
>
Post: {{ title }}
</h2>
<p>{{ content }}</p>
</div>
</template>
最后,我们应该在PostListItem.vue
文件中使整个帖子项成为一个指向正确 slug 的router-link
:
<template>
<router-link
class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4"
:to="`/${slug}`"
>
<!-- rest of the template -->
</router-link>
</template>
router-link
是 Vue Router 特定的链接,这意味着在PostList
页面上,点击帖子列表项时,我们将被带到正确的帖子 URL,如下面的截图所示:
图 12.18:在浏览器中显示的帖子列表视图
我们将被重定向到正确的 URL,即文章的缩略语,这将通过 slug
渲染正确的文章,如图 图 12.19 所示。
图 12.19:单篇文章视图在浏览器中显示
要测试 vue-router
,我们将探索一个更适合测试具有路由和 Vuex 存储的应用程序的新库,即 Vue 测试库,该库可在 npm
上作为 @testing-library/vue
访问。
我们可以使用 npm install --save-dev @testing-library/vue
来安装它。
要测试 SinglePost
路由和渲染,我们执行以下操作。首先,我们应该能够通过点击 PostList
视图中的文章标题来访问 SinglePost
视图。为了做到这一点,我们通过检查内容(我们将看到两个带有标题的文章)来确认我们处于主页。然后我们点击一个文章标题并检查主页的内容已消失,文章内容已显示:
import {render, fireEvent} from '@testing-library/vue'
import App from '../src/App.vue'
import router from '../src/router.js'
test('Router renders single post page when clicking a post title', async () => {
const {getByText, queryByText} = render(App, { router })
expect(queryByText('The Vue.js Workshop Blog')).toBeTruthy()
expect(queryByText('Vue.js for React developers')).toBeTruthy()
expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeTruthy()
await fireEvent.click(getByText('Vue.js for React developers'))
expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeFalsy()
expect(queryByText('Post: Vue.js for React developers')). toBeTruthy()
expect(
queryByText(
'React has massive popularity here are the key benefits of Vue.js over it. See the following table, we'll also look at how the is the content of the post. There's more, we can map React concepts to Vue and vice-versa.'
)
).toBeTruthy()
})
我们应该检查直接导航到有效的文章 URL 将产生正确的结果。为了做到这一点,我们将使用 router.replace('/')
来清除任何设置的状态,然后使用 router.push()
并带有一个文章缩略语。然后我们将使用前一个代码片段中的断言来验证我们是否在 SinglePost
页面,而不是主页:
test('Router renders single post page when a slug is set', async () => {
const {queryByText} = render(App, { router })
await router.replace('/')
await router.push('/vue-react')
expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeFalsy()
expect(queryByText('Post: Vue.js for React developers')). toBeTruthy()
expect(
queryByText(
'React has massive popularity here are the key benefits of Vue.js over it. See the following table, we'll also look at how the is the content of the post. There's more, we can map React concepts to Vue and vice-versa.'
)
).toBeTruthy()
})
当使用 npm run test:unit __tests__/SinglePost.test.js
运行这两个测试时,它们按预期工作。以下截图显示了所需的输出:
图 12.20:SinglePost 的路由测试通过
我们已经看到了如何使用 Vue.js 测试库来测试一个使用 vue-router
的应用程序。
练习 12.03:构建标签页面并测试其路由
与我们构建的单篇文章页面类似,我们现在将构建一个标签页面,它与 PostList
组件类似,只是只显示具有特定标签的文章,并且每篇文章都是一个链接到相关单篇文章视图的链接。
要访问此练习的代码文件,请参阅 packt.live/39cJqZd
:
-
我们可以从在
src/components/TagPage.vue
中创建一个新的TagPage
组件开始。我们知道它将接收posts
作为属性,并且我们希望渲染一个PostList
组件:<template> <div class="flex flex-col md:w-1/2 m-auto"> <h3 class="font-semibold text-sm text-center mb-6" > #INSERT_TAG_NAME </h3> <PostList :posts="[]" /> </div> </template> <script> import PostList from './PostList' export default { components: { PostList }, props: { posts: { type: Array, default: () => [] } }, } </script>
-
接下来,我们想在
src/router.js
中将TagPage
组件连接到路由器。我们将导入它并将其添加到routes
中,路径为/tags/:tagName
:// other imports import TagPage from './components/TagPage.vue' // Vue router registration const routes = [ // other routes { path: '/tags/:tagName', component: TagPage } ] // router instantiation and export
-
我们现在可以在计算属性中使用
$route.params.tagName
并创建一个tagPosts
计算属性,该属性通过标签过滤文章:<script> // imports export default { // rest of component computed: { tagName() { return this.$route.params.tagName }, tagPosts() { return this.posts.filter(p => p.tags.includes(this.tagName)) } } } </script>
-
现在我们有了对
tagPosts
和tagName
的访问权限,我们可以替换模板中的占位符。我们将渲染#{{ tagName }}
并将tagPosts
绑定到PostList
的posts
属性:<template> <div class="flex flex-col md:w-1/2 m-auto"> <h3 class="font-semibold text-sm text-center mb-6" > #{{ tagName }} </h3> <PostList :posts="tagPosts" /> </div> </template>
现在,如果我们导航到例如
/tags/angularjs
,页面将显示如下:图 12.21:angularjs 的标签页面
-
下一步是将
PostListItem
中的标签锚点(a
)转换为指向/tags/${tagName}
的router-link
(在src/components/PostListItem.vue
中):<template> <!-- rest of template --> <router-link :to="`/tags/${tag}`" v-for="tag in tags" :key="tag" class="flex text-xs font-semibold px-2 py-1 mr-2 rounded border border-blue-500 text-blue-500" > #{{ tag }} </router-link> <!-- rest of template --> </template>
-
现在是时候编写一些测试了。我们首先检查在主页上点击
#angularjs
会将我们带到angularjs
标签页。我们将在__tests__/TagPage.test.js
中如下编写:import {render, fireEvent} from '@testing-library/vue' import App from '../src/App.vue' import router from '../src/router.js' test('Router renders tag page when clicking a tag in the post list item', async () => { const {getByText, queryByText} = render(App, { router }) expect(queryByText('The Vue.js Workshop Blog')). toBeTruthy() expect(queryByText('Vue.js for React developers')). toBeTruthy() expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeTruthy() await fireEvent.click(getByText('#angularjs')) expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeTruthy() expect(queryByText('Vue.js for React developers')).toBeFalsy() expect(queryByText('React')).toBeFalsy() })
-
我们还应该测试直接访问标签 URL 是否按预期工作;也就是说,我们看不到不相关的内容:
// import & other tests test('Router renders tag page when a URL is set', async () => { const {queryByText} = render(App, { router }) await router.push('/') await router.replace('/tags/angularjs') expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeTruthy() expect(queryByText('Vue.js for React developers')). toBeFalsy() expect(queryByText('React')).toBeFalsy() })
测试通过,因为应用程序按预期工作。因此,输出将如下所示:
图 12.22:TagPage 路由测试通过命令行
我们已经看到了如何实现和测试一个包含vue-router
的应用程序。在下一节中,我们将详细了解 Vuex 的测试。
测试 Vuex
为了展示如何测试依赖于 Vuex(Vue.js 的官方全局状态管理解决方案)的组件,我们将实现并测试新闻通讯订阅横幅。
首先,我们应该创建横幅模板。横幅将包含一个“订阅新闻通讯”的行动呼吁和一个关闭图标:
<template>
<div class="text-center py-4 md:px-4">
<div
class="py-2 px-4 bg-indigo-800 items-center text-indigo-100
leading-none md:rounded-full flex md:inline-flex"
role="alert"
>
<span
class="font-semibold ml-2 md:mr-2 text-left flex-auto"
>
Subscribe to the newsletter
</span>
<svg
class="fill-current h-6 w-6 text-indigo-500"
role="button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<title>Close</title>
<path
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651
3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1\. 2
1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1
1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1\. 698z"
/>
</svg>
</div>
</div>
</template>
我们可以在App.vue
文件中如下显示NewsletterBanner
组件:
<template>
<!-- rest of template -->
<NewsletterBanner />
<!-- rest of template -->
</template>
<script>
import NewsletterBanner from './components/NewsletterBanner.vue'
export default {
components: {
NewsletterBanner
},
// other component properties
}
</script>
然后,我们将使用npm install --save vuex
命令安装 Vuex。一旦安装了 Vuex,我们就可以在store.js
文件中初始化我们的存储,如下所示:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {},
mutations: {}
})
我们的 Vuex 存储也已在main.js
文件中注册:
// other imports
import store from './store'
// other configuration
new Vue({
// other vue options
store
}).$mount('#app')
为了决定是否显示新闻通讯横幅,我们需要在我们的存储中添加一个初始状态:
// imports and configuration
export default new Vuex.Store({
state: {
dismissedSubscribeBanner: false
}
})
要关闭横幅,我们需要一个突变,该突变将dismissedSubscribeBanner
设置为true
:
// imports and configuration
export default new Vuex.Store({
// other store configuration
mutations: {
dismissSubscribeBanner(state) {
state.dismissedSubscribeBanner = true
}
}
})
现在,我们可以使用存储状态和dismissSubscribeBanner
突变来决定是否显示横幅(使用v-if
)以及是否关闭它(绑定到close
按钮的点击):
<template>
<div v-if="showBanner" class="text-center py-4 md:px-4">
<!-- rest of template -->
<svg
@click="closeBanner()"
class="fill-current h-6 w-6 text-indigo-500"
role="button"
xmlns=http://www.w3.org/2000/svg
viewBox="0 0 20 20"
>
<!-- rest of the template -->
</div>
</template>
<script>
export default {
methods: {
closeBanner() {
this.$store.commit('dismissSubscribeBanner')
}
},
computed: {
showBanner() {
return !this.$store.state.dismissedSubscribeBanner
}
}
}
</script>
在这一点上,横幅在浏览器中的样子如下:
图 12.23:浏览器中显示的新闻通讯横幅
要编写单元测试,我们将使用 Vue 测试库,它提供了一个注入 Vuex 存储的功能。我们需要导入存储和NewsletterBanner
组件。
我们可以先进行一个合理性检查,即默认情况下,新闻通讯横幅是显示的:
import {render, fireEvent} from '@testing-library/vue'
import NewsletterBanner from '../src/components/ NewsletterBanner.vue'
import store from '../src/store'
test('Newsletter Banner should display if store is initialised with it not dismissed', () => {
const {queryByText} = render(NewsletterBanner, { store })
expect(queryByText('Subscribe to the newsletter')).toBeTruthy()
})
下一个检查应该是,如果存储有dismissedSubscribeBanner: true
,则横幅不应显示:
// imports and other tests
test('Newsletter Banner should not display if store is initialised with it dismissed', () => {
const {queryByText} = render(NewsletterBanner, { store: {
state: {
dismissedSubscribeBanner: true
}
} })
expect(queryByText('Subscribe to the newsletter')).toBeFalsy()
})
我们将要进行的最后一个测试是确保点击横幅的关闭按钮会将突变提交到存储中。我们可以通过将存根作为dismissSubscribeBanner
突变注入,并检查在点击关闭按钮时是否被调用来实现这一点:
// imports and other tests
test('Newsletter Banner should hide on "close" button click', async () => {
const dismissSubscribeBanner = jest.fn()
const {getByText} = render(NewsletterBanner, {
store: {
...store,
mutations: {
dismissSubscribeBanner
}
}
})
await fireEvent.click(getByText('Close'))
expect(dismissSubscribeBanner).toHaveBeenCalledTimes(1)
})
当使用npm run test:unit __tests__/NewsletterBanner.test.js
运行时,测试将通过,如下所示:
图 12.24:通过命令行执行的 NewsletterBanner 单元测试
我们已经看到了如何使用 Vue.js 测试库来测试由 Vuex 驱动的应用程序功能。
练习 12.04:构建和测试 cookie 免责声明横幅(Vuex)
我们现在将探讨如何使用 Vuex 实现 cookie 免责声明横幅,以及如何使用 Vue.js 测试库进行测试。
我们将在 Vuex 中存储 cookie 横幅是否显示(默认为true
);当横幅关闭时,我们将将其存储在 Vuex 中。
使用模拟 Vuex 存储来测试此打开/关闭操作。要访问此练习的代码文件,请参阅packt.live/36UzksP
:
-
创建一个带有加粗标题
Cookies Disclaimer
、免责声明和I agree
按钮的绿色 cookie 横幅。我们将在src/components/CookieBanner.vue
中创建此组件:<template> <div class="flex flex-row bg-green-100 border text-center border-green-400 text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative" role="alert" > <div class="flex flex-col"> <strong class="font-bold w-full flex">Cookies Disclaimer </strong> <span class="block sm:inline">We use cookies to improve your experience</span> </div> <button class="ml-auto align-center bg-transparent hover:bg-green-500 text-green-700 font-semibold font-sm hover:text-white py-2 px-4 border border-green-500 hover:border-transparent rounded" > I agree </button> </div> </template>
-
接下来,我们将在
src/App.vue
中导入、注册并渲染CookieBanner
组件到router-view
下方:<template> <!-- rest of template --> <CookieBanner /> <!-- rest of template --> </template> <script> // other imports import CookieBanner from './components/CookieBanner.vue' export default { components: { // other components CookieBanner }, // other component properties } </script>
-
添加一个
state
切片来控制是否显示 cookie 横幅。在我们的 Vuex 存储中,我们将初始化此acceptedCookie
字段为false
:// imports and configuration export default new Vuex.Store({ state: { // other state fields acceptedCookie: false }, // rest of vuex configuration })
-
我们还需要一个
acceptCookie
突变来关闭横幅:// imports and configuration export default new Vuex.Store({ // rest of vuex configuration mutations: { // other mutations acceptCookie(state) { state.acceptedCookie = true } } })
-
接下来,我们将暴露存储状态作为
acceptedCookie
计算属性。我们将创建一个acceptCookie
函数,该函数触发acceptCookie
突变:export default { methods: { acceptCookie() { this.$store.commit('acceptCookie') } }, computed: { acceptedCookie() { return this.$store.state.acceptedCookie } } } </script>
-
我们将使用
v-if
在尚未接受 cookie 时显示横幅。当点击I agree
按钮时,通过切换acceptCookie
来关闭横幅:<template> <div v-if="!acceptedCookie" class="flex flex-row bg-green-100 border text-center border-green-400 text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative" role="alert" > <!-- rest of template --> <button @click="acceptCookie()" class="ml-auto align-center bg-transparent hover:bg-green-500 text-green-700 font-semibold font-sm hover:text-white py-2 px-4 border border-green-500 hover:border-transparent rounded" > I agree </button> </div> </template>
现在我们已经得到了一个 cookie 横幅,直到点击
I agree
才会显示,如下面的截图所示:![图 12.25:浏览器中显示的 cookie 横幅]
![img/B15218_12_25.jpg]
图 12.25:浏览器中显示的 cookie 横幅
-
现在,我们将编写一个测试来检查
CookieBanner
组件是否默认显示:import {render, fireEvent} from '@testing-library/vue' import CookieBanner from '../src/components/CookieBanner.vue' import store from '../src/store' test('Cookie Banner should display if store is initialised with it not dismissed', () => { const {queryByText} = render(CookieBanner, { store }) expect(queryByText('Cookies Disclaimer')).toBeTruthy() })
-
我们还将编写一个测试来检查如果存储中的
acceptedCookie
为true
,则 cookie 横幅不会显示:test('Cookie Banner should not display if store is initialised with it dismissed', () => { const {queryByText} = render(CookieBanner, { store: { state: { acceptedCookie: true } } }) expect(queryByText('Cookies Disclaimer')).toBeFalsy() })
-
最后,我们希望检查当点击
I agree
按钮时,会触发acceptCookie
突变:test('Cookie Banner should hide on "I agree" button click', async () => { const acceptCookie = jest.fn() const {getByText} = render(CookieBanner, { store: { ...store, mutations: { acceptCookie } } }) await fireEvent.click(getByText('I agree')) expect(acceptCookie).toHaveBeenCalledTimes(1) })
当我们使用
npm run test:unit __tests__/CookieBanner.test.js
运行我们编写的三个测试时,它们都会通过,如下所示:![图 12.26:cookie 横幅测试通过]
![img/B15218_12_26.jpg]
图 12.26:cookie 横幅测试通过
我们已经看到了如何测试依赖于 Vuex 进行状态和更新的组件。
接下来,我们将探讨快照测试,看看它是如何简化渲染输出的测试的。
快照测试
快照测试提供了一种为快速变化的代码片段编写测试的方法,而不需要将断言数据内联到测试中。它们存储快照。
快照的更改反映了输出的更改,这对于代码审查非常有用。
例如,我们可以在PostList.test.js
文件中添加一个快照测试:
// imports and tests
test('Post List renders correctly', () => {
const wrapper = mount(PostList, {
propsData: {
posts: [
{
title: 'Title 1',
description: 'Description 1',
tags: ['react', 'vue']
},
{
title: 'Title 2',
description: 'Description 2',
tags: ['html', 'angularjs']
}
]
}
})
expect(wrapper.text()).toMatchSnapshot()
})
当我们再次运行此测试文件时,使用npm run test:unit __tests__/PostList.test.js
,我们将得到以下输出:
![图 12.27:第一次运行快照测试]
图 12.27:第一次运行快照测试
快照已写入__tests__/__snapshots__/PostList.test.js.snap
,如下所示:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Post List renders correctly 1`] = `
"Title 1 Description 1
#react
#vue
Title 2 Description 2
#html
#angularjs"
`;
这使得我们可以快速看到这些更改在具体输出方面的含义。
我们现在已经看到了如何使用快照测试。接下来,我们将把本章学到的所有工具结合起来,添加一个新页面。
活动十二.01:通过测试添加一个简单的按标题搜索页面
我们已经构建了一个帖子列表页面、单个帖子视图页面和按标签分类的帖子页面。
在博客上重新展示旧内容的一个好方法是通过实现良好的搜索功能。我们将向PostList
页面添加搜索功能:
-
在新文件
src/components/SearchForm.vue
中创建一个带有输入和按钮的搜索表单。 -
现在,我们将通过导入、注册并在
src/App.vue
中渲染来使表单显示。现在,我们可以在应用程序中看到搜索表单,如下所示:
![图 12.28:带有搜索表单的帖子列表视图]
图 12.28:带有搜索表单的帖子列表视图
-
我们现在准备好为搜索表单添加一个快照测试。在
__tests__/SearchForm.test.js
中,我们应该添加SearchForm should match expected HTML
。 -
我们希望使用
v-model
跟踪搜索表单输入的内容,以双向绑定searchTerm
实例变量和输入内容。 -
当提交搜索表单时,我们需要更新 URL 以包含正确的参数。这可以通过
this.$router.push()
来完成。我们将把搜索存储在q
查询参数中。 -
我们希望将
q
查询参数的状态反映在搜索表单输入中。我们可以通过从this.$route.query
中读取q
并将其设置为SearchForm
组件状态中searchTerm
数据字段的初始值来实现这一点。 -
接下来,我们希望过滤主页上传递给
PostList
的帖子。我们将使用this.$route.query.q
在一个计算属性中过滤帖子标题。这个新的计算属性将替代src/App.vue
中的posts
。 -
接下来,我们应该添加一个测试,更改搜索查询参数,并检查应用程序是否显示正确的结果。为此,我们可以导入
src/App.vue
、src/store.js
和src/router.js
,并使用存储和路由渲染应用程序。然后,我们可以通过使用字段的占位符为Search
来更新搜索字段的内容。最后,我们可以通过点击test id
为Search
(即搜索按钮)的元素来提交表单。注意
这个活动的解决方案可以通过这个链接找到。
摘要
在本章中,我们探讨了测试不同类型 Vue.js 应用程序的不同方法。
通常来说,测试对于从经验上证明系统正在正常工作是有用的。单元测试是最容易构建和维护的,应该是测试功能的基础。系统测试是测试金字塔的下一层级,它使你能够对大多数功能按预期工作充满信心。端到端测试表明整个系统的主流程正在正常工作。
我们已经看到了如何对组件、过滤器、组件方法和混入进行单元测试,以及如何通过层进行测试,以及以黑盒方式测试组件输出而不是检查组件内部以测试功能。使用 Vue.js 测试库,我们已经测试了利用 Vuex 的高级功能,如路由和应用程序。
最后,我们探讨了快照测试,并看到了它如何成为为模板密集型代码块编写测试的有效方式。
在下一章中,我们将探讨可以应用于 Vue.js 应用的端到端测试技术。
第十三章:13. 端到端测试
概述
在本章中,我们将探讨如何使用 Cypress 为 Vue.js 应用程序创建一个端到端(E2E)测试套件。为了编写健壮的测试,我们将探讨常见的陷阱和最佳实践,例如拦截 HTTP 请求和等待元素出现而不设置超时。
随着我们继续前进,你将了解端到端测试及其用例。你将看到如何配置 Cypress 以测试 Vue.js 应用程序,并使用它与之交互和检查用户界面。在整个章节中,你将熟悉任意超时的陷阱以及如何使用 Cypress 的等待功能来避免它们。在章节的末尾,你还将学习何时、为什么以及如何使用 Cypress 拦截 HTTP 请求。
简介
在本章中,我们将为高度异步的应用程序编写端到端测试。
在前面的章节中,我们看到了如何构建复杂的 Vue.js 应用程序以及如何为它们编写单元测试。本章将介绍如何使用 Cypress 为高度交互式且使用 HTTP API 的 Vue.js 应用程序编写端到端测试。我们将看到端到端测试如何通过自动化用户流程,为你提供应用程序按设计工作的信心水平。
理解端到端测试及其用例
大多数开发者都曾见过以下图中展示的测试金字塔版本:
图 13.1:测试金字塔图解
端到端测试属于用户界面(UI)测试类别。在本章中,我们将探讨的是使用 Cypress 进行的自动化端到端测试。
E2E 和 UI 测试提供的信心水平高于单元或集成测试。它们测试的是终端用户使用的应用程序。终端用户不关心错误发生的原因或地点,只关心存在错误。错误的原因和地点往往是单元和系统级测试关注的焦点。单元和系统级测试检查系统的内部是否按照规范或代码描述的方式工作。UI 级测试验证应用程序流程是否按预期工作。
一个运行速度快、假阴性(测试失败但应用程序工作)少、假阳性(所有测试通过但应用程序损坏)更少的强大端到端测试套件,可以启用持续部署(CD)。持续部署,正如其名所暗示的,涉及持续部署项目或应用程序。在这种设置中,应用程序版本由端到端套件验证,然后自动部署到生产环境。
为 Vue.js 应用程序配置 Cypress
Cypress 是一个 JavaScript E2E 测试框架。它旨在解决使用 JavaScript 编写 E2E 测试的非常具体的需求。这与其他完整的 浏览器自动化 解决方案(如 WebDriverIO (webdriver.io/
))、Selenium WebDriver (www.selenium.dev/
))、Puppeteer (developers.google.com/web/tools/puppeteer/
) 和 Playwright (github.com/microsoft/playwright
)形成对比,这些解决方案通常用于编写 E2E 测试。
与其他解决方案相比,Cypress 的一个显著区别是它专注于编写 E2E 测试(而不是通用的浏览器自动化)。测试只能使用 JavaScript 编写(Selenium 支持其他语言),并且直到最近,它只支持 Chrome(Cypress 4.0 现在支持 Firefox 和 Microsoft Edge,请参阅 www.cypress.io/blog/2020/02/06/introducing-firefox-and-edge-support-in-cypress-4-0/
)。
Cypress 拥有一个用于本地运行和调试测试的 图形用户界面 (GUI),并附带内置的断言和存根/模拟库。
要使用 Vue CLI 将 Cypress 添加到项目中,我们可以使用 e2e-cypress
插件 (cli.vuejs.org/core-plugins/e2e-cypress.html
),其安装说明指导我们在命令行中运行以下命令。作为添加插件的一部分,Cypress 及其支持包将被下载并解压,因此可能需要一段时间才能完成:
vue add @vue/e2e-cypress
插件添加了一个 test:e2e
脚本,我们可以使用以下命令运行它。此命令需要一段时间才能启动,因为它需要运行应用程序的生产构建并启动 Cypress 应用:
npm run test:e2e
最终,我们将看到以下 Cypress GUI:
![图 13.2:插件安装和运行 test:e2e 命令后的 Cypress 图形用户界面 (GUI)]
图 13.2:插件安装和运行 test:e2e 命令后的 Cypress GUI
插件为我们创建了一个默认的 test.js
文件。默认内容如下。测试会访问应用程序根目录 (/
) 并检查页面上的 h1
是否包含 Welcome to Your Vue.js App
:
// https://docs.cypress.io/api/introduction/api.html
describe('My First Test', () => {
it('Visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'Welcome to Your Vue.js App')
})
})
这在空 Vue CLI 项目中是有效的。
我们可以尝试使用 cy.visit(url)
访问 google.com
,并通过首先使用 cy.get('input')
选择页面上的输入元素,然后使用 .should('exist')
断言来检查与 Google 主页同义的 input
元素是否存在:
describe('My First Test', () => {
it('Opens an arbitrary URL', () => {
cy.visit('https://google.com')
cy.get('input').should('exist')
})
})
我们可以通过在 Cypress UI 中单击 test.js
来运行测试(当 npm run test:e2e
正在运行时)如下所示:
![图 13.3:运行 test.js 的 Cypress UI]
图 13.3:运行 test.js 的 Cypress UI
当 Cypress 运行测试时,会打开一个浏览器窗口来运行它们:
图 13.4:在访问 Google 主页时在 Chrome 中运行的 Cypress 测试
我们现在已经看到了如何安装和使用 Cypress 访问网页。
在下一节中,我们将看到如何使用 Cypress 与 UI 交互和检查。
使用 Cypress 与 Vue.js UI 交互和检查
为了端到端测试具有应用程序名称的 h2
),在 App.vue
文件中,我们将有如下代码:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<h2
class="uppercase leading-loose pb-4 flex justify-center m-auto md:w-1/3 text-xl mb-8 font-bold text-gray-800 border-b"
>
Commentator Pro
</h2>
</div>
</div>
</template>
<script>
export default {}
</script>
为了使用 Cypress 进行测试,我们可以使用以下代码更改 tests/e2e/specs/test.js
文件。我们将使用 cy.visit('/')
访问运行中的应用程序,然后检查页面上的 h2
是否包含 cy.contains('h2', 'Commentator Pro')
。cy.contains
函数是重载的,可以用一个参数(要匹配的文本)或两个参数(容器的选择器和要匹配的文本)使用:
describe('Commentator Pro', () => {
it('Has a h2 with "Commentator Pro"', () => {
cy.visit('/')
cy.contains('h2', 'Commentator Pro')
})
})
然后,我们可以使用以下截图所示的 Cypress UI 运行 test.js
:
图 13.5:标题内容测试在 Chrome 中成功运行
现在我们已经看到了如何访问页面并对其内容进行断言,我们将看到如何使用 Cypress 自动化 Vue.js 应用程序中新功能的测试。
练习 13.01:添加“新评论”按钮和相应的端到端测试
为了使“添加新评论”按钮允许用户添加评论。
我们将添加一个带有文本“添加新评论”的蓝色巨型按钮,并使用 Cypress 编写相应的端到端测试。
要访问此练习的代码文件,请参阅 packt.live/36PefjJ
。
要做到这一点,请执行以下步骤:
-
要在应用程序中添加按钮,我们将在
src/App.vue
中添加一个带有一些TailwindCSS
类的button
元素:<template> <div id="app" class="p-10"> <div class="flex flex-col"> <!-- rest of template --> <button class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> Add a New Comment </button> </div> </div> </template>
输出应如下所示:
图 13.6:带有“添加新评论”按钮的 Commentator Pro 应用程序
-
接下来,我们将在
tests/e2e/specs/add-new-comment.js
创建一个新的端到端测试。我们将设置测试套件的名称和测试的描述分别为“添加新评论”和“主页应该有一个带有正确文本的按钮”:describe('Adding a New Comment', () => { it('the homepage should have a button with the right text', () => { // test will go here }) })
-
为了测试主页,我们必须使用
cy.visit('/')
导航到它:describe('Adding a New Comment', () => { it('the homepage should have a button with the right text', () => { cy.visit('/') }) })
-
最后,我们可以编写断言,页面中有一个包含文本“添加新评论”的
button
实例:describe('Adding a New Comment', () => { it('the homepage should have a button with the right text', () => { cy.visit('/') cy.contains('button', 'Add a New Comment') }) })
-
我们可以使用 Cypress UI 运行此测试(使用
npm run test:e2e
运行):图 13.7:“add-new-comment.js”测试在 Cypress UI 中显示
-
当我们运行测试时,我们将在 Chrome 中得到以下输出。测试通过,因为主页上有一个带有相关文本的按钮:![图 13.8:Cypress 在 Chrome 中运行我们的 "add-new-comment" 测试
图 13.8:Cypress 在 Chrome 中运行我们的 "add-new-comment" 测试
我们已经看到了如何访问页面并对其内容进行断言。
在下一节中,我们将探讨如何使用 Cypress 来测试交互行为。Cypress 具有自动选择器重试功能,这使得它非常适合测试高度交互的 Vue.js 应用程序。我们将看到如何使用 Cypress 与 UI 交互并断言我们交互的效果。
使用 Cypress 触发和等待 UI 更新
我们到目前为止编写的测试相当简单,仅检查应用程序在浏览器加载时不会崩溃。
E2E 测试的一个优势是,当用户与之交互时,可以以高保真度测试 UI 的行为是否符合预期。在本节中,我们将使用 Cypress 的选择(.get()
函数)、事件触发(.click()
函数)和断言(.should()
函数)功能来测试 Vue.js 应用程序。Cypress 在 DOM 选择上的自动重试将允许我们编写无需显式等待/超时条件的 E2E 测试。等待和超时是其他 E2E 测试系统的基本要素,并且往往是测试不稳定的原因。
首先,我们将向我们的textarea
添加一个评论编辑器,点击添加新评论
按钮将切换它。
为了在不处理复杂且脆弱的选择器的情况下继续编写测试,我们将开始添加data-test-id
属性;首先,我们可以在App.vue
文件中的添加新评论
按钮上添加一个:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<!-- rest of template -->
<button
class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
data-test-id="new-comment-button"
>
Add a New Comment
</button>
<!-- rest of template -->
</div>
</div>
</template>
接下来,我们将在App
组件的 Vue.js data()
方法中添加一个showEditor
属性。我们将在编辑器的v-if
中使用这个表达式。我们还可以设置新的评论按钮来切换这个实例属性:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<!-- rest of template -->
<button
@click="showEditor = !showEditor"
class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
data-test-id="new-comment-button"
>
Add a New Comment
</button>
<!-- rest of template -->
</div>
</div>
</template>
<script>
export default {
data() {
return {
showEditor: false
}
}
}
</script>
我们可以使用new-comment-editor
data-test-id
来添加我们的编辑器,该data-test-id
可以通过showEditor
来切换:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<!-- rest of template -->
<div v-if="showEditor">
<textarea
data-test-id="new-comment-editor"
class="flex mx-auto my-6 shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
>
</textarea>
</div>
</div>
</div>
</template>
为了测试切换功能,我们可以添加一个测试,打开该应用程序并检查评论编辑器最初不会显示,以及根据在new-comment-button
上触发的点击次数来检查它是否显示:
describe('Adding a New Comment', () => {
// other tests it('the Add a New Comment button should toggle the editor display on and off', () => {
cy.visit('/')
cy.get('[data-test-id="new-comment-editor"]').should ('not.be.visible')
cy.get('[data-test-id="new-comment-button"]').click()
cy.get('[data-test-id="new-comment-editor"]').should ('be.visible')
cy.get('[data-test-id="new-comment-button"]').click()
cy.get('[data-test-id="new-comment-editor"]').should ('not.be.visible')
})
})
上述代码将生成以下结果:
![图 13.9:Cypress 运行 "add-new-comment" 测试,包括新的编辑器切换测试
![图 13.9:Cypress 运行 "add-new-comment" 测试,包括新的编辑器切换测试
我们已经看到了如何编写选择和断言 DOM 元素的 Cypress 测试。
注意
data-test-id
实例,作为一种约定,是使测试与应用程序和样式特定的选择器解耦的一种方式。如果编写测试的人不总是编写代码的人,这特别有用。在这种情况下,使用data-test-id
允许标记结构和类发生变化,但只要test-id
实例保持在正确的元素上,测试就会继续通过。
练习 13.02:添加新评论编辑器输入和提交功能
要能够将新的评论文本发送到 API,我们需要将文本存储在 Vue.js 状态中。添加评论的另一个先决条件是拥有一个虚拟的"提交
"按钮。
要访问此练习的代码文件,请参阅packt.live/2HaWanh
。
添加这些功能和相应的测试,请执行以下步骤:
-
要将
textarea
(编辑器)内容存储在内存中,我们将使用v-model
。我们将创建一个新的数据(状态)变量newComment
,它被初始化为""
。现在,v-model
将双向绑定textarea
内容和newComment
:<template> <div id="app" class="p-10"> <!-- rest of template --> <textarea data-test-id="new-comment-editor" class="flex mx-auto my-6 shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" v-model="newComment" > </textarea> <!-- rest of template --> </div> </template> <script> export default { data() { return { // other data properties newComment: '' } } } </script>
-
我们将在编辑器内部添加一个
提交
按钮,它应该只在编辑器开启时显示。我们还确保包含一个data-test-id="new-comment-submit"
属性,以便稍后用 Cypress 选择它:<template> <div id="app" class="p-10"> <!-- rest of template --> <div v-if="showEditor"> <!-- rest of template --> <button data-test-id="new-comment-submit" class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" > Submit </button> <!-- rest of template --> </div> <!-- rest of template --> </div> </template>
-
现在是时候添加一个端到端测试来测试当我们向其中输入文本时
new-comment-editor
是否按预期工作。为了实现这一点,我们需要加载应用程序并点击新评论
按钮,以便显示编辑器。然后我们可以选择new-comment-editor
(通过data-test-id
)并使用 Cypress 的.type
函数添加一些文本。我们可以将.should('have.value', 'Just saying...')
链接起来以验证我们对textarea
的交互是否成功:describe('Adding a New Comment', () => { // other tests it('the new comment editor should support text input', () => { cy.visit('/') // Get the editor to show cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-editor"]').should ('be.visible') cy.get('[data-test-id="new-comment-editor"]') .type('Just saying...') .should('have.value', 'Just saying...') }) })
当使用 Cypress UI 运行时,这个
添加新评论
测试套件应该产生以下结果:图 13.10:Cypress 运行"添加新评论"测试,包括新的编辑器文本输入测试
-
最后,我们可以添加一个
端到端测试
来检查提交
按钮默认情况下不会显示,但当我们点击新评论
按钮时会出现。我们还可以检查new-comment-submit
按钮的文本内容:describe('Adding a New Comment', () => { // other tests it('the new comment editor should have a submit button', () => { cy.visit('/') cy.get('[data-test-id="new-comment-submit"]').should ('not.be.visible') // Get the editor to show cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-submit"]').should ('be.visible') cy.contains('[data-test-id="new-comment-submit"]', 'Submit') }) })
当这个测试通过 Cypress UI 运行时,我们看到以下结果:
图 13.11:Cypress 运行"添加新评论"测试,包括新的提交按钮测试
-
我们还可以添加的一个功能是,在文本编辑器中有文本之前,使
submit
按钮处于禁用状态。为此,我们可以在new comment submit
按钮上绑定:disabled
到!newComment
。我们将使用降低的不透明度使按钮看起来被禁用。顺便说一下,我们添加newComment
和textarea
之间的双向绑定的一大原因是为了启用此类 UI 验证:<template> <!-- rest of template --> <button data-test-id="new-comment-submit" class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" :disabled="!newComment" :class="{ 'opacity-50 cursor-not-allowed' : !newComment }" > Submit </button> <!-- rest of template --> </template>
-
相关测试将检查当文本编辑器内容为空时,
new-comment-submit
按钮是否被禁用,使用 Cypress 的should('be.disabled')
和should('not.be.disabled')
断言:describe('Adding a New Comment', () => { // other tests it('the new comment submit button should be disabled based on "new comment" content', () => { cy.visit('/') // Get the editor to show cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-submit"]').should ('be.visible') cy.get('[data-test-id="new-comment-submit"]') .should('be.disabled') cy.get('[data-test-id="new-comment-editor"]') .type('Just saying...') cy.get('[data-test-id="new-comment-submit"]') .should('not.be.disabled') }) })
当通过 Cypress UI 和 Chrome 自动化运行时,会产生以下输出:
![图 13.12:Cypress 运行 "add-new-comment" 测试,包括 新的评论提交按钮禁用测试
图 13.12:Cypress 运行 "add-new-comment" 测试,包括新的评论提交按钮禁用测试
我们已经看到了如何使用 Cypress 来选择、点击和输入文本。我们还看到了检查元素可见性、文本内容、输入值和禁用状态的方法。
熟悉其他自动化框架的任何人都会注意到,在 Cypress 测试中,没有显式的等待或重试。这是因为 Cypress 会自动等待和重试断言和选择。我们编写的大多数测试都没有以这种方式展示这一点,但下一个练习,我们将引入异步性,将会展示这一点。
练习 13.03:向新评论编辑器添加提交状态
为了展示 Cypress 令人印象深刻的自动重试/等待能力,我们将查看为新的评论编辑器添加和测试 submitting
状态。
实际上,我们将对 submit
按钮的点击做出反应,并显示一个持续 2.5s
的加载指示器来模拟对后端 API 的合理慢速 HTTP 请求。这是一个展示 Cypress 自动等待和重试选择能力的示例。这个功能减少了任意等待的需要以及与之相关的易变性。
要访问此练习的代码文件,请参阅packt.live/2UzsYJU
:
-
为了显示加载指示器,我们将
tailwindcss-spinner
包添加到项目中:npm install --save-dev tailwindcss-spinner # or yarn add -D tailwindcss-spinner
-
我们需要在 Tailwind 配置文件(
tailwind.js
)中进行配置。它需要使用require('tailwindcss-spinner')
导入,添加为插件,并在theme
中设置相关变量。我们的加载指示器将是灰色
,使用 Tailwind 间距大小4
,边框宽度为2px
,持续时间为500ms
:module.exports = { theme: { extend: {}, spinner: (theme) => ({ default: { color: theme('colors.gray.400'), size: theme('spacing.4'), border: theme('borderWidth.2'), speed: theme('transitionDuration.500'), }, }), }, variants: { spinner: ['responsive'], }, plugins: [require('tailwindcss-spinner')()], }
-
接下来,我们需要在 Vue.js 应用程序的
data()
中添加一个isSubmitting
状态,这将允许我们切换submit
按钮的状态。我们将将其初始化为false
,因为我们还没有在用户点击submit
按钮之前提交任何内容:<script> export default { data() { return { // other properties isSubmitting: false } } } </script>
-
接下来,我们将为
submit
按钮添加一个点击处理程序(作为methods.submitNewComment
)。它将使用setTimeout
模拟2.5s
的加载时间:<script> export default { // other component properties methods: { submitNewComment() { this.isSubmitting = true setTimeout(() => { this.isSubmitting = false }, 2500) } } } </script>
-
现在我们已经有一个
fake submit
处理器,我们应该将其绑定到new-comment-submit
按钮的点击事件上:<template> <div id="app" class="p-10"> <div class="flex flex-col"> <!-- rest of template --> <div v-if="showEditor"> <!-- rest of editor --> <button data-test-id="new-comment-submit" class="flex mx-auto bg-blue-500 hover:bg-blue-700 text- white font-bold py-2 px-4 rounded" :disabled="!newComment" :class="{ 'opacity-50 cursor-not-allowed' : !newComment }" @click="submitNewComment()" > Submit </button> </div> </div> </div> </template>
-
现在是我们要对提交按钮做出反应的部分。当
isSubmitting
为真时,我们将显示旋转器。为了做到这一点,我们只需将spinner
类设置为在isSubmitting
为真时添加。除此之外,我们还将设置按钮在isSubmitting
为真时被禁用:<template> <div id="app" class="p-10"> <div class="flex flex-col"> <!-- rest of template --> <div v-if="showEditor"> <!-- rest of editor --> <button data-test-id="new-comment-submit" class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" :disabled="!newComment || isSubmitting" :class="{ 'opacity-50 cursor-not-allowed' : !newComment, spinner: isSubmitting }" @click="submitNewComment()" > Submit </button> </div> </div> </div> </template>
-
最后,我们可以添加一个测试来检查当点击
submit
按钮时加载旋转器是否出现。首先,我们需要设置文本编辑器,以便在点击add new comment
按钮并设置评论的文本值时,文本编辑器显示并启用。接下来,我们可以点击已启用的new-comment-submit
按钮并检查它是否被禁用并具有spinner
类(使用should()
函数)。之后,我们应该编写另一个断言,按钮不再被禁用且不显示旋转器:it('the new comment editor should show a spinner on submit', () => { cy.visit('/') // Get the editor to show cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-submit"]').should ('be.visible') cy.get('[data-test-id="new-comment-editor"]') .type('Just saying...') cy.get('[data-test-id="new-comment-submit"]') .should('not.be.disabled') .click() .should('have.class', 'spinner') .should('be.disabled') // eventually, the spinner should stop showing cy.get('[data-test-id="new-comment-submit"]') .should('not.have.class', 'spinner') .should('not.be.disabled') })
尽管旋转器显示的时间为
2.5s
,但由于 Cypress 的自动重试/等待功能,这个测试仍然通过:]
图 13.13:Cypress 运行 "添加新评论" 测试,包括评论提交加载状态测试
我们已经看到 Cypress 如何通过自动等待/重试来允许我们在应用程序中无缝处理异步性,当断言或选择失败时。
截获 HTTP 请求
如前几节所述,Cypress 被设计为 JavaScript 端到端测试解决方案。这意味着它自带断言、自动等待/重试、运行应用程序的合理默认值以及广泛的模拟功能。
HTTP 请求可能会很慢,并且倾向于给测试引入不稳定的(flaky)行为。所谓的 flaky 指的是间歇性的假阴性,即不是由应用程序问题引起的失败,而是由连接问题(例如,测试运行的服务器和后端主机之间的连接)引起的。
我们还将测试后端系统的实现。当使用持续集成(CI)时,这意味着需要在任何需要运行端到端测试的 CI 管道步骤中运行后端系统。
通常,当拦截后端请求并发送模拟响应时,我们也说 HTTP 请求被模拟,以避免测试不稳定(意味着间歇性失败与应用程序更改无关)。
由于请求并没有完全通过堆栈(包括后端 API),这在技术上不再是系统的完整端到端测试。然而,我们可以将其视为前端应用程序的端到端测试,因为整个应用程序由独立的练习组成,并且不是特定于实现的。
为了在 Cypress 中模拟请求,我们需要使用cy.server()
和cy.route()
。Cypress 文档还让我们知道,为了使用 HTTP 拦截功能,我们目前需要一个使用XMLHttpRequest
(而不是fetch
API)的客户端。
备注
目前正在进行支持 HTTP 级拦截(这意味着fetch
、XHR等最终都将得到支持)的工作。
我们将使用unfetch
库,该库在XMLHttpRequest
之上实现了fetch
接口。我们可以使用以下命令安装它:
npm install --save-dev unfetch
# or
yarn add -D unfetch
然后,我们可以按照以下方式将其导入到src/App.vue
中。
<script>
import fetch from 'unfetch'
// rest of component
</script>
为了展示 HTTP 拦截,我们将从JSONPlaceholder
获取评论列表并将它们存储在comments
响应式实例变量下。我们可以使用fetch
(我们导入unfetch
时使用的名称)在mounted()
生命周期事件中这样做,如下所示:
<script>
// imports
export default {
data() {
return {
// other data properties
comments: []
}
},
mounted() {
fetch('https://jsonplaceholder.typicode.com/comments')
.then(res => res.json())
.then(comments => {
this.comments = comments
})
}
// other component properties
}
</script>
一个示例评论包括 ID、正文和电子邮件等属性。
这意味着我们可以通过创建一个ul
容器来渲染评论,该容器仅在存在评论时显示(comments.length > 0
)。在ul
容器内部,我们可以使用v-for
渲染一个具有卡片布局的li
元素列表。每个卡片将渲染评论的正文和作者的电子邮件,并在mailto:
链接内显示。
注意我们如何为列表容器和列表项分别设置comments-list
和comment-card
的data-test-ids
:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<!-- rest of template -->
<ul
v-if="comments.length > 0"
class="flex flex-col items-center my-4 mx-auto md:w-2/3 lg:w-1/2"
data-test-id="comments-list"
>
<li
class="flex flex-col px-6 py-4 rounded overflow-hidden shadow-lg mb-6"
v-for="(comment, index) in comments"
:key="comment.id + index"
data-test-id="comment-card"
>
<p class="flex text-gray-700 text-lg mb-4"> {{ comment.body }}</p>
<p class="flex text-gray-600 font-semibold text-sm">
<a :href="'mailto:' + comment.email"> {{ comment.email }}</a>
</p>
</li>
</ul>
</div>
</div>
</template>
如果我们不使用 HTTP 拦截来测试,我们必须保持断言相当通用。例如,我们可以在新的端到端测试文件中检查comments-list
是否可见,以及是否有(大于 0)个comment-card
实例:
describe('Loading Existing Comments', () => {
it('should load & display comments', () => {
cy.visit('/')
cy.get('[data-test-id="comments-list"]')
.should('be.visible')
cy.get('[data-test-id="comment-card"]')
.should('have.length.gt', 0)
})
})
使用 Cypress GUI 运行的以下测试通过,但测试相当通用。我们无法对特定评论数量或其内容做出任何断言:
图 13.14:Cypress 运行“load-comments”测试,包括通用的加载和显示测试
为了拦截请求,我们必须使用cy.server()
初始化 Cypress 模拟服务器。然后我们可以使用cy.route()
拦截特定的请求,从而产生以下新的测试。当cy.route
与两个参数一起使用时,它接受 URL 后跟存根响应,在我们的例子中是一个评论数组。我们将在存根中使用一个虚构的电子邮件地址:
describe('Loading Existing Comments', () => {
// other tests
it('should load and display comments correctly', () => {
cy.server()
cy.route('**/comments', [
{
body: 'Vue is getting great adoption',
email: 'evan@vuejs.org',
id: 100,
},
{
body: 'Just saying...',
email: 'evan@vuejs.org',
id: 10
},
{
body: 'The JS ecosystem is great',
email: 'evan@vuejs.org',
id: 1
}
]).as('getComments')
})
})
一旦我们设置了存根路由,我们就可以访问页面并使用cy.wait('@getComments')
等待评论获取完成,因为我们之前已经使用.as('getComments')
将评论获取路由的别名设置为getComments
:
describe('Loading Existing Comments', () => {
// other tests
it('should load and display comments correctly', () => {
// test setup
cy.visit('/')
cy.wait('@getComments')
})
})
然后,我们首先可以断言comments-list
是可见的,然后对comment-card
卡片数量进行断言:
describe('Loading Existing Comments', () => {
// other tests
it('should load and display comments correctly', () => {
// test setup
cy.get('[data-test-id="comments-list"]')
.should('be.visible')
cy.get('[data-test-id="comment-card"]')
.should('have.length', 3)
})
})
我们还可以使用.contains()
函数对卡片的特定内容进行断言:
describe('Loading Existing Comments', () => {
// other tests
it('should load and display comments correctly', () => {
// test setup
cy.contains('[data-test-id="comment-card"]', 'Vue is getting great adoption')
.contains('evan@vuejs.org')
cy.contains('[data-test-id="comment-card"]', 'Just saying...')
.contains('evan@vuejs.org')
cy.contains('[data-test-id="comment-card"]', 'The JS ecosystem is great')
.contains('evan@vuejs.org')
})
})
然后,我们可以使用 Cypress GUI 运行测试套件并看到它通过:
![图 13.15:Cypress 运行“load-comments”测试,包括我们的模拟评论测试]
图 13.15:Cypress 运行“load-comments”测试,包括我们的模拟评论测试
我们现在已经看到了如何以及为什么我们可能会使用 Cypress 来模拟 HTTP 请求。
练习 13.04:提交时将评论 POST 到 API
当前new comment
提交按钮仅设置几秒钟的加载状态然后重置 - 实际上评论并没有被发送到任何地方。
让我们使用JSONPlaceholder
API 作为发送我们新评论的地方。
当向 API 的 POST 请求成功时,我们将评论添加到评论列表的顶部。
要访问此练习的代码文件,请参阅packt.live/2IIWY3g
。
为了完成练习,我们将执行以下步骤:
-
首先,让
submitNewComment
方法实际上使用fetch
(实际上是unfetch
)向c
发送数据:<script> // imports export default { // other component properties methods: { submitNewComment() { this.isSubmitting = true fetch('https://jsonplaceholder.typicode.com/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: this.email, body: this.newComment }) }) } } } </script>
不幸的是,
fetch()
调用本身不会更新数据或退出加载状态。为了做到这一点,我们需要链式调用一些.then()
函数来处理响应,以及一个.catch
函数来处理错误。在成功(.then
)的情况下,我们应该获取请求的 JSON 输出并将其添加到comments
数组的副本的前面。我们还应该重置isSubmitting
、newComment
和showEditor
。在错误(.catch
)的情况下,我们只需将加载状态isSubmitting
重置为 false;我们不会清除编辑器或关闭它,因为用户可能想要再次尝试提交:<script> // imports export default { // other component properties methods: { submitNewComment() { this.isSubmitting = true fetch( // fetch params ).then(res => res.json()) .then(data => { this.comments = [ data, ...this.comments, ] this.isSubmitting = false this.newComment = '' this.showEditor = false }).catch(() => { this.isSubmitting = false }) } } } </script>
我们现在应该将新的测试添加到
tests/e2e/specs/add-new-comment.js
测试套件中。 -
首先,为了成为好的
JSONPlaceholder
用户,我们将模拟add-new-comment
套件中所有对/comments
的GET
请求。为了实现这一点,我们将使用beforeEach
钩子来启动 Cypress 模拟服务器(cy.server()
)并模拟匹配**/comments
通配符的任何 URL 的GET
请求,使用[]
作为响应(cy.route('GET', '**/comments', [])
):describe('Adding a New Comment', () => { beforeEach(() => { cy.server() // GET comments is not the concern of this test suite cy.route('GET', '**/comments', []) }) // tests
-
然后,我们可以继续更新
the new comment editor should show a spinner on submit
测试,因为我们不再使用setTimeout
,而是使用 HTTP 请求。首先,我们需要模拟/comments
POST 请求,我们将使用cy.route
的配置对象语法来引入 HTTP 请求中的延迟,以便它不会立即响应。我们使用.as('newComment')
来别名此请求:describe('Adding a New Comment', () => { // setup & tests it('the new comment editor should show a spinner on submit', () => { cy.route({ method: 'POST', url: '**/comments', delay: 1500, response: {} }).as('newComment') // rest of the test }) })
-
而不是
// 最终,旋转器应该停止显示
,我们现在可以使用cy.wait()
等待newComment
HTTP 请求完成,然后再检查旋转器是否消失:describe('Adding a New Comment', () => { // setup & tests it('the new comment editor should show a spinner on submit', () => { // test setup // click the "submit" button // check the spinner appears cy.wait('@newComment') // check that the spinner is gone }) })
-
我们添加了新的功能,在
submit
操作成功完成后关闭编辑器,因此我们应该添加相关的测试。我们将使用与更新后的loading state
测试类似的骨架,设置 POST 评论路由的模拟cy.route('POST', '**/comments', {}', 别名为
.as('newComment'))。然后我们可以获取新的评论编辑器以显示,添加一些文本,并提交表单。然后我们将等待
POST` 请求完成,然后再检查编辑器和提交按钮是否不再可见:describe('Adding a New Comment', () => { // other tests it('adding a new comment should close the editor', () => { cy.route( 'POST', '**/comments', { body: 'Just saying...', email: 'hi@vuejs.org' } ).as('newComment') cy.visit('/') // Get the editor to show cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-submit"]').should ('be.visible') cy.get('[data-test-id="new-comment-editor"]') .type('Just saying...') cy.get('[data-test-id="new-comment-submit"]') .should('not.be.disabled') .click() cy.wait('@newComment') cy.get('[data-test-id="new-comment-editor"]').should ('not.be.visible') cy.get('[data-test-id="new-comment-submit"]').should ('not.be.visible') }) })
现在可以使用 Cypress GUI 运行此测试,并且会通过:
![图 13.16:Cypress 运行 "add-new-comment" 测试,包括 提交时编辑器关闭的测试
图 13.16:Cypress 运行 "add-new-comment" 测试,包括编辑器关闭的测试
-
我们添加的第二项功能是在 HTTP 请求完成后,将新的用例添加到评论列表的前面。为了测试这一点,最好将评论的
GET
请求的响应改为至少包含一个元素(这样我们就可以检查新评论是否被添加到列表的顶部):describe('Adding a New Comment', () => { // setup & other tests it('submitting a new comment should POST to /comments and adds response to top of comments list', () => { cy.route('GET', '**/comments', [ { email: 'evan@vuejs.org', body: 'Existing comment' } ]) }) })
-
然后,我们可以使用一些模拟数据模拟
POST
请求,向编辑器添加文本,并提交表单:describe('Adding a New Comment', () => { // setup & other tests it('submitting a new comment should POST to /comments and adds response to top of comments list', () => { // GET request stubbing cy.route({ method: 'POST', url: '**/comments', response: { email: 'evan@vuejs.org', body: 'Just saying...', }, }).as('newComment') cy.visit('/') cy.get('[data-test-id="comment-card"]').should ('have.length', 1) cy.get('[data-test-id="new-comment-button"]').click() cy.get('[data-test-id="new-comment-editor"]') .type('Just saying...') cy.get('[data-test-id="new-comment-submit"]') .should('not.be.disabled') .click() cy.wait('@newComment') }) })
-
最后,我们可以通过组合使用
cy.get()
、.first()
和.contains()
来断言第一个评论是新添加的评论:describe('Adding a New Comment', () => { // setup & other tests it('submitting a new comment should POST to /comments and adds response to top of comments list', () => { // setup & wait for POST completion cy.get('[data-test-id="comments-list"]').should('be.visible') cy.get('[data-test-id="comment-card"]') .should('have.length', 2) .first() .contains('[data-test-id="comment-card"]', 'Just saying...') .contains('evan@vuejs.org') }) })
当使用 Cypress GUI 运行
add-new-comment
测试套件时,我们可以看到新的测试通过:![图 13.17:Cypress 运行 "add-new-comment" 测试,包括 新评论添加到列表顶部的测试
图 13.17:Cypress 运行 "add-new-comment" 测试,包括新评论添加到列表顶部的测试
我们已经看到了如何使用 Cypress 来拦截 HTTP 请求,所以在下一节中,我们将探讨 Cypress 提供的一些关于视觉回归(快照)测试的报表工具。
使用 Cypress 快照进行视觉回归测试
我们到目前为止使用 Cypress 编写的测试类型主要是功能测试。
功能测试检查应用程序是否按预期 行为。视觉测试检查应用程序是否按预期 外观。
有方法可以检查渲染的 CSS,但这种方法通常相当繁琐,并且在标记或样式重构时容易出错(即,相同的视觉输出,但有不同的规则或标记)。
幸运的是,Cypress 通过 cypress-plugin-snapshots
插件允许我们获取并比较应用程序的快照。首先,需要使用以下命令进行安装:
npm install --save-dev cypress-plugin-snapshots
# or
yarn add -D cypress-plugin-snapshots
插件在 Cypress 选择上添加了 .toMatchImageSnapshot
方法。为了使其工作,我们需要在插件的初始化文件中注册它。
我们应该导入 cypress-plugin-snapshots/plugin
并使用插件初始化数据运行其 initPlugin
导出:
const { initPlugin } = require('cypress-plugin-snapshots/plugin');
module.exports = (on, config) => {
initPlugin(on, config);
// rest of plugin config, including return
}
我们还需要从cypress-plugin-snapshots/commands
注册相关命令,这可以在commands.js
文件中完成:
import 'cypress-plugin-snapshots/commands'
我们还需要在cypress.json
中为cypress-plugin-snapshots
添加一些配置:
{
"//": "other config",
"env": {
"cypress-plugin-snapshots": {
"autoCleanUp": false,
"autopassNewSnapshots": true,
"diffLines": 3,
"imageConfig": {
"createDiffImage": true,
"resizeDevicePixelRatio": true,
"threshold": 0.01,
"thresholdType": "percent"
},
"screenshotConfig": {
"blackout": [],
"capture": "fullPage",
"clip": null,
"disableTimersAndAnimations": true,
"log": false,
"scale": false,
"timeout": 30000
},
"backgroundBlend": "difference"
}
}
}
最后,我们可以在test.js
文件中添加一个快照测试。
首先,我们将清理文件并模拟/comments
API 调用(这对于快照测试将特别有用):
describe('Commentator Pro', () => {
beforeEach(() => {
cy.server()
cy.route('GET', '**/comments', [
{
body: 'Just saying...',
email: 'evant@vuejs.org'
}
]).as('getComments')
})
// tests
})
接下来,我们可以添加视觉回归测试。我们将打开编辑器以最大化单个快照测试,使用#app
获取整个app
,并对其进行快照:
describe('Commentator Pro', () => {
// setup & other tests
it('visual regression', () => {
cy.visit('/')
cy.get('[data-test-id="new-comment-button"]')
.click()
cy.wait('@getComments')
cy.get('[data-test-id="new-comment-editor"]')
.should('be.visible')
cy.get('#app').toMatchImageSnapshot({
threshold: 0.001,
})
})
})
当使用 Cypress UI 运行测试时,我们看到以下输出:
图 13.18:Cypress 运行测试,包括视觉回归测试
我们可以通过在App.vue
文件中将按钮颜色改为红色(从bg-blue-500
变为bg-red-500
)来显示失败的快照:
<template>
<div id="app" class="p-10">
<div class="flex flex-col">
<!-- rest of template -->
<button
@click="showEditor = !showEditor"
class="flex mx-auto bg-red-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
data-test-id="new-comment-button"
>
Add a New Comment
</button>
<!-- rest of template -->
</div>
</div>
</template>
当我们运行相关测试集时,它们现在失败了(因为我们可以看到,按钮是红色而不是蓝色):
图 13.19:Cypress 运行测试,包含失败的视觉回归测试
通过点击比较快照
,我们得到一个视觉差异视图,这允许我们更新快照:
图 13.20:Cypress 的失败的视觉回归测试差异视图
我们现在已经看到了如何使用 Cypress 进行视觉回归测试。
我们现在将查看添加新功能及其所有相关测试。
活动 13.01:添加设置用户电子邮件和测试的能力
你会记得我们将evan@vuejs.org
硬编码为任何评论的电子邮件。在这个活动中,我们将添加一个电子邮件输入,它将设置评论上的email
属性。我们将在新的tests/e2e/specs/enter-email.js
测试套件中添加相关测试:
-
为了跟踪电子邮件,我们将它在
data()
中设置为一个响应式状态,并在页面上添加一个电子邮件类型输入,它将使用v-model
双向绑定到email
。我们还添加了一个标签和相应的标记。注意,我们将在电子邮件输入上设置一个data-test-id
属性,设置为email-input
。 -
我们现在将添加一个
beforeEach
钩子来设置 Cypress 模拟服务器并模拟GET
评论(列表)请求。评论列表请求应别名为getComments
。 -
我们将添加第一个测试,检查是否在电子邮件输入中键入工作正确。我们将进入应用,输入电子邮件,并检查我们输入的内容现在是否是输入值。
当使用 Cypress UI 运行时,我们应该得到以下通过测试:
图 13.21:Cypress 运行"enter-email"测试,包含电子邮件输入测试
-
拥有
email
属性是添加评论的先决条件,因此当email
为空时(!email
),我们将禁用添加新评论
按钮。我们将绑定到disabled
属性,并根据email
字段是否已填充来切换一些类。 -
使用这个新的
当 email 为空时禁用添加新评论按钮
功能,我们应该添加一个新的端到端测试。我们将加载页面,并在初始加载时检查电子邮件输入是否为空,以及添加新评论
按钮是否被禁用。然后我们将在电子邮件输入字段中输入电子邮件,并检查添加新评论
按钮现在是否 未 禁用,这意味着它已被启用。当使用 Cypress UI 运行时,我们应该看到新的测试通过,输出如下:
![图 13.22:Cypress 运行 "enter-email" 测试,禁用添加评论按钮测试]
添加评论按钮测试
图 13.22:Cypress 运行 "enter-email" 测试,禁用添加评论按钮测试
-
现在我们有了捕获电子邮件的方法,我们应该在调用 POST 评论时将其传递给后端 API(即提交新评论时)。为了做到这一点,我们应该修改
methods.submitNewComment
中将email
固定为evan@vuejs.org
的位置。 -
现在我们正在使用用户输入的电子邮件,我们应该编写一个端到端测试来检查它是否被发送。我们将模拟 POST 请求,将其别名为
newComment
,并返回一个任意值。然后我们可以访问页面,填写电子邮件输入,打开评论编辑器,填写内容,并提交。然后我们将等待newComment
请求,并断言请求体中的内容和电子邮件与我们完成它们时相同。注意
我们也可以选择不模拟
POST
请求,而是检查新插入页面上的评论卡片是否包含正确的电子邮件和内容。当使用 Cypress UI 运行时,我们得到以下测试运行输出:
![图 13.23:Cypress 运行 "enter-email" 测试,电子邮件输入测试]
图 13.23:Cypress 运行 "enter-email" 测试,电子邮件输入测试
注意
此活动的解决方案可以通过此链接找到。
摘要
在本章中,我们探讨了如何利用 Cypress 从端到端测试 Vue.js 应用程序。
端到端测试通常非常有用,可以给我们一个高度信心,即测试的流程将按预期工作,而不是单元或集成测试,后者以更低的开销验证我们的代码是否按预期工作。
我们已经展示了如何使用 Cypress 来检查、交互和断言 UI。我们还展示了 Cypress 的默认等待/重试功能在编写健壮测试时的巨大优势。
我们利用 Cypress 的 HTTP 请求拦截库来模拟 HTTP 请求,使测试更加可预测和快速。
最后,我们探讨了如何使用 Cypress 设置视觉回归测试。
在下一章中,我们将探讨如何将 Vue.js 应用程序部署到网页上。
第十四章:14. 将您的代码部署到网络
概述
在本章结束时,您将能够解释 CI/CD 工作流程的好处以及它是如何与发布周期、发布节奏和开发工作流程相结合的。为此,您将能够阐述 Vue.js 开发与生产构建之间的差异以及所做出的权衡。为了测试和部署 Vue.js 应用程序,您将配置 GitLab CI/CD,包括管道、作业和步骤。您将熟悉 Netlify、AWS S3 和 AWS CloudFront,以及它们的关键相似之处和差异。
在本章中,我们将探讨如何将 Vue.js 应用程序部署到万维网,以及如何自动化此部署过程以轻松和有信心地频繁发布。
简介
在本章中,我们将探讨如何利用 CI/CD 工具和技术,以高信心和高频率将应用程序交付到生产环境中。
在前面的章节中,我们看到了如何构建和测试复杂的 Vue.js 应用程序。本章是关于利用所有技术,包括测试和自动化它们,以最小风险和时间开销将软件交付到生产环境中。
CI/CD 作为敏捷软件开发过程的一部分的好处
持续集成(CI)是每天多次集成代码的实践。为了支持这一点,需要一个支持单个存储库中多个工作状态(分支)的现代 版本控制系统(VCS),如 Git,以便允许开发者独立工作代码,同时仍然允许他们安全地协作和集成他们的更改。
为了增强版本控制系统(VCS)、存储库(如 GitLab 或 GitHub)周围的托管和协作工具的能力,已经创建了这些工具,并允许开发者通过网页 用户界面(UI)更有效地查看和管理代码更改。
作为这些托管平台及其提供的协作工具的一部分,或作为补充,自动检查对于在集成前后保持代码质量的高信心至关重要。采用 CI 方法通常意味着包括额外的代码质量步骤,例如单元或集成测试、覆盖率检查,以及每次任何新代码集成到主线分支(集成更改的分支)时构建工件。
团队遵循的用于代码协作和 CI 的 Git 规范被称为 Git 工作流程,通常简称为 Git flow。
Git flow 将决定分支命名规范,以及如何以及何时集成更改。例如,一个团队可能会决定分支应以工单编号开头,后跟一个简短的短划线小写描述,例如 WRK-2334-fix-ie-11-scroll
。
作为 Git flow 的一部分,决定并遵守的其他约定示例包括提交消息的长度和标题、应该通过或允许失败的自动检查,以及合并变更请求所需的审阅者数量,在 GitHub 和 GitLab 的术语中,分别称为拉取请求或合并请求。
Git 流程分为两大类:基于主干的开发和(功能)基于分支的开发。我们将首先介绍基于分支的开发,因为其局限性已经变得非常明显,大多数项目倾向于使用基于主干的开发。
在基于分支的 Git 工作流程中,多个工作分支被保存在仓库中。基于分支的流程可以用来保持与环境状态一致的分支。
例如,以下图表显示了三个分支——生产、预发布和开发。生产不包含来自预发布或开发的任何变更。预发布领先于生产,但除了生产上的变更外,与开发没有共同变更。开发领先于预发布和生产:它在与预发布相同的提交上从生产分支出来,但它与预发布没有共享任何进一步的提交:
图 14.1:具有三个环境分支的基于分支的 Git 提交/分支树示例
基于分支的工作流程也可以用来跟踪进入发布线的变更。这在项目需要维护应用程序或库的两个版本,但需要对两个版本都应用错误修复或安全补丁的情况下很有用。
在以下示例中,我们有一个与环境分支类似的分支示例。版本 1.0.0 包含一些在 1.0.1 和 1.1.0 中不存在的变化,但不共享任何新的代码。版本 1.0.1 和 1.1.0 同时从 1.0.0 分支出来,但它们没有共享进一步的变更:
图 14.2:具有三个发布分支的基于分支的 Git 提交/分支树示例
在基于主干分支的 Git 工作流程中,团队中的每个成员都会从一个单一分支创建新的分支,通常是“master”分支。这个过程通常被称为“从分支分支”:
图 14.3:一个基于主干分支的 Git 提交/分支树示例,有两个功能分支从主分支分支出来
基于主干分支的工作流程的一个极端情况是只有一个单一的分支供所有人提交。
注意
在基于仓库的环境下,"发布分支"的替代方案是使用 Git 标签来跟踪发布快照。这提供了与维护分支相同的优势,即减少了分支噪音,并且由于标签一旦创建就不能更改,因此具有不可变性的额外好处。
持续交付(CD)是团队能够将每个良好的构建部署到生产环境的能力。
持续交付(CD)的一个先决条件是持续集成(CI),因为持续集成(CI)为构建的质量提供了一些初始的信心。作为持续交付(CD)的一部分,除了持续集成(CI)之外,还需要新的系统、工具和实践。
参考以下图表,了解与持续集成(CI)和持续交付(CD)相关的工具和实践:
图 14.4:持续集成(CI)和持续交付(CD)实践之间的关系
采用持续交付(CD)所需的额外成分是对应用程序将继续按预期(对于最终用户)工作以及新缺陷没有无意中引入的高度信心。这意味着在能够部署之前,需要在持续集成(CI)检查期间或之后进行额外的端到端测试步骤来验证构建。
这些端到端测试可以手动进行,也可以自动化。在一个理想的持续交付(CD)设置中,后者(自动化端到端测试)是首选的,因为它意味着部署不包括人工交互。如果端到端测试通过,构建可以自动部署。
为了促进持续交付(CD),用于部署软件的系统必须重新思考。作为 CD 的一部分,部署不能是一个冗长的手动过程。这导致公司采用云原生技术,如 Docker,以及基础设施即代码工具,如 HashiCorp 的 Terraform。
向持续交付(CD)实践转变的强调导致了 GitOps 和 ChatOps 等想法的诞生。
在 GitOps 和 ChatOps 中,部署和运营任务是由开发者和利益相关者每天交互的工具驱动的。
在 GitOps 中,可以通过 GitHub/GitLab(或另一个 Git 托管提供商)、直接使用 GitHub Actions 或 GitLab CI/CD,或者通过具有紧密集成和报告功能的持续集成/持续交付(CI/CD)软件(如 CircleCI 或 Jenkins)来进行部署。
在 ChatOps 的情况下,使用对话界面来部署和操作软件。某些 ChatOps 的变体可以被认为是 GitOps 的子集,例如,与 deploy <service-name> <environment>
等工具交互,这些工具会将服务部署到相关环境。请注意,聊天界面非常类似于开发者可能习惯的命令行界面,但其他利益相关者可能需要一些时间来适应。
我们现在已经探讨了持续集成(CI)和持续交付(CD)的方法;接下来我们将讨论使用持续集成(CI)和持续交付(CD)的优势:
图 14.5:持续集成(CI)和持续交付(CD)的优势
这两种实践也会对团队的心态和表现产生影响。能够在一天内看到您的更改集成,并在一周内将其部署到生产环境中,这意味着贡献者可以立即看到他们的工作产生了影响。
CI/CD 还有助于推广敏捷原则,其中更改是迭代地应用和部署的。这与项目长期的时间表形成对比,其中估计的不准确会累积并可能导致重大延误。
为生产构建
将应用程序部署到生产环境始于创建一个可以部署的工件。在 Vue.js 的情况下,我们正在构建一个客户端应用程序,这意味着我们的构建工件将包含 HTML、JavaScript 和 CSS 文件。
Vue CLI 内置了一个 build
命令。这个 build
命令将把我们的 Vue.js 单文件组件(.vue
文件)编译成渲染函数(Vue 运行时可以使用这些函数来渲染我们的应用程序),并将它们输出到 JavaScript 中。
作为构建过程的一部分,Vue CLI 将会处理 JavaScript、Vue 单文件组件以及相互导入的模块,并将它们 打包。打包意味着相互依赖的相关代码块将被输出为一个单一的 JavaScript 文件。
由于我们使用了 Vue CLI,Vue.js 库本身也可以被精简。Vue.js 运行时包可以包含一个 运行时编译器,它可以将字符串模板转换为客户端上的渲染函数。由于我们在构建时使用 Vue CLI 编译为渲染函数,因此 Vue.js 的这部分不需要包含在我们的 JavaScript 中。
Vue CLI 的构建步骤还包括一个 死代码消除
步骤。这意味着它可以分析正在生成的代码,如果其中任何部分显然从未使用过——例如,一个如 if (false) { /* do something */ }
这样的语句——那么它将不会出现在构建输出中。
默认情况下,当我们调用 vue service build
时,Vue CLI 会为生产环境构建,这在 Vue CLI 项目中是通过 build
脚本来实现的,可以使用 npm run build
或 yarn build
来运行。
在一个示例 Vue CLI 项目中,我们将看到类似以下的内容:
![图 14.6:在新的 Vue CLI 项目中 "npm run build" 的输出
图 14.6:在新的 Vue CLI 项目中 "npm run build" 的输出
dist
文件夹现在可以使用静态托管解决方案(如 Netlify 或 AWS S3 和 CloudFront)进行部署。
我们现在已经看到了如何使用 Vue CLI 和 npm run build
命令来构建用于生产的 Vue.js 应用程序。
接下来,我们将看到如何使用 GitLab CI/CD 来测试我们的代码(在部署之前)。
使用 GitLab CI/CD 测试您的代码
GitLab 有一个内置的 CI/CD 工具,称为 GitLab CI/CD。
为了使用 GitLab CI/CD,您需要一个 GitLab 账户。
要与托管在 GitLab 上的 Git 仓库交互,您还需要将您机器上的 SSH 密钥与您的 GitLab 账户关联。
注意
在 GitLab 文档中添加 SSH 密钥的说明可以在 docs.gitlab.com/ee/gitlab-basics/create-your-ssh-keys.html
找到。
一旦您创建了账户,您可以使用 项目
页面右上角的 新建项目
按钮创建一个新的存储库,如图所示 截图:
图 14.7:带有新建项目按钮的 GitLab "项目"页面
如果您点击 新建项目
按钮,您将被带到 新建项目
页面,在那里您可以使用默认的 空白项目
选项卡通过给它一个名称和 slug 来创建项目,如图所示 截图:
图 14.8:选择空白项目的 GitLab 新项目页面
一旦您点击 创建项目
,GitLab 项目
页面将以空状态出现,显示如何克隆项目的说明。您应该运行克隆存储库所需的命令,这可能是以下等效命令(您应该在您的机器上运行):
注意
如果您没有将 SSH 密钥与您的账户关联,在此阶段 GitLab 应该会显示一个带有您可点击链接的警告,以设置 SSH 密钥。
git clone <repository-url>
在您的机器上,您应该打开已克隆存储库的目录。为了添加 GitLab CI/CD,我们需要在项目的根目录中添加一个 .gitlab-ci.yml
文件。一个示例 .gitlab-ci.yml
文件,它将一个 build
作业添加到管道的 build
阶段,用于安装依赖项(使用 npm ci
),运行生产构建(npm run build
),并缓存输出工件,定义如下。
作业名称是通过在 YAML 文件中设置顶级键来定义的——在这种情况下,build:
。
在 YAML 语法中,我们将增加缩进来表示 build
键指向一个对象。
在 build job
对象中,我们将定义用于运行作业的 Docker 镜像,使用 image: node:lts
。这意味着我们希望这个作业在 Node.js 长期支持 (LTS) 镜像上运行,该镜像将是 Node.js 12,直到 2020 年 10 月 20 日,届时它将指向 Node.js 14 镜像。
注意
您可以在 nodejs.org/en/about/releases/
访问最新的 Node.js LTS 发布计划。
我们可以在作业中定义的另一个属性是阶段。GitLab CI/CD 管道默认有三个阶段:构建、测试和部署。当团队的工作流程不适合这三个类别时(例如,如果需要部署到多个环境),可以使用自定义阶段来替换这些阶段。请参阅文档 (docs.gitlab.com/ee/ci/yaml/#stages
)。
注意
stages
用于定义作业可以使用的阶段,并且它是全局定义的。
stages
的指定允许灵活的多阶段管道。阶段中元素的顺序定义了作业执行的顺序:
a) 同一阶段的作业是并行运行的。
b) 下一个阶段的作业将在前一个阶段的作业成功完成后运行。
我们的管道目前只有一个阶段和一个作业,所以大部分前面的内容对我们不适用。
我们设置的最后一个属性是script
,它定义了在作业运行时应运行的步骤,以及artifacts
,它配置了工件存储。在我们的例子中,我们将运行npm ci
来安装所有依赖项,然后运行npm run build
,这将运行生产 Vue.js CLI 构建。我们的工件被设置为保留一周,并包含dist
文件夹(Vue CLI build
输出存储的地方)。
完整来说,我们有以下内容:
build:
image: node:lts
stage: build
script:
- npm ci
- npm run build
artifacts:
expire_in: 1 week
paths:
- dist
一旦我们将这个.gitlab-ci.yml
文件推送到包含 Vue CLI 项目的仓库中,我们将在仓库视图中看到以下内容,其中有一个步骤的管道正在最新提交上运行:
图 14.9:GitLab 仓库视图,显示在最新提交上运行的构建作业
如果我们点击Pipeline
图标(蓝色进行中指示器),我们将获得管道视图。在管道视图中,Build
代表状态管道
(我们将其设置为build
)并且它代表作业名称(我们将其定义为build
)。在作业完成之前,我们会看到相同的进行中指示器,如下所示:
图 14.10:GitLab CI 管道视图,显示正在运行的构建作业
作业完成后,我们会看到一个成功
图标(绿色勾号)。我们可以在作业运行时或完成后(无论它是否失败或成功)点击此图标或作业名称来访问作业视图。当作业完成时,我们还会看到一个重试
图标,这可以用来重试失败的管道步骤。以下截图显示了作业成功运行:
图 14.11:GitLab CI 管道视图,显示构建作业通过
点击作业后,我们会看到作业
视图,它显示了作业中所有步骤的详细分解。从准备 docker_machine 执行器
步骤开始,该步骤加载 Node.js Docker 镜像,我们看到运行脚本以及缓存和工件恢复的步骤,如下所示:
图 14.12:GitLab CI 作业视图,显示成功的构建作业
如果我们想在 GitLab CI/CD 运行中添加test
步骤,我们需要在一个支持单元测试的项目中。这可以通过使用 Vue CLI 来实现,它是通过vue add @vue/unit-jest
安装的。安装和添加单元测试在第十二章,单元测试中进行了详细说明。
我们需要在.gitlab-ci.yml
文件中添加一个新的作业;我们将称之为test
,使用node:lts
镜像,并将作业分配给test
状态。在作业中,我们运行npm ci
,然后是npm run test:unit
(这是由unit-jest
CLI 插件添加的npm
脚本):
# rest of .gitlab-ci.yml
test:
image: node:lts
stage: test
script:
- npm ci
- npm run test:unit
一旦我们推送这个新的.gitlab-ci.yml
文件,我们将在主存储库页面上看到以下视图:
图 14.13:存储库视图,GitLab CI/CD 正在运行包含新测试步骤的管道
我们可以点击进入管道视图。GitLab CI/CD 使用管道的原因是,在某个阶段的失败步骤将意味着后续阶段的步骤将不会运行。例如,如果我们得到一个失败的build
作业,test
阶段的作业将不会运行。以下截图很好地解释了这一点:
图 14.14:GitLab CI/CD 管道视图,失败的构建作业阻止测试作业/阶段运行
如果我们再次提交另一个提交或重试构建步骤(如果失败不是由更改引起的)并再次导航到管道视图,我们将看到以下内容:
图 14.15:构建阶段作业全部成功后,GitLab CI/CD 管道视图中的测试作业正在运行
一旦测试
作业成功,我们将看到以下管道:
图 14.16:GitLab CI/CD 管道视图,构建和测试阶段的作业全部成功
我们现在已添加了一个包含build
和test
阶段的 GitLab CI/CD 管道,该管道将验证在每次向 GitLab 存储库推送时,代码仍然按预期集成。
练习 14.01:向您的 GitLab CI/CD 管道添加 Lint 步骤
Linting 是一种获取自动化格式化和代码风格检查的方法。将其集成到 CI 中可以确保所有合并到主线分支的代码都遵循团队的代码风格指南。它还减少了代码风格审查评论的数量,这些评论可能会很嘈杂,并可能分散对更改请求的基本问题的关注。
要访问此练习的代码文件,请参阅packt.live/2IQDFW0
:
-
为了添加代码检查,我们需要确保我们的
package.json
文件中包含lint
脚本。如果它缺失,我们需要添加它并将其设置为vue-cli-service lint
:{ "// other": "properties", "scripts": { "// other": "scripts", "lint": "vue-cli-service lint", "// other": "scripts" }, "// more": "properties" }
-
为了在 GitLab CI/CD 上运行代码检查,我们需要添加一个新的
lint
作业,该作业将在 GitLab CI/CD 管道的test
阶段运行在 Node.js LTS Docker 映像中。我们将在.gitlab-ci.yml
中这样做:lint: image: node:lts stage: test
-
为了让
lint
作业按照package.json
中的设置运行lint
脚本,我们需要在.gitlab-ci.yml
文件中添加一个script
部分。首先需要运行npm ci
来安装依赖项,然后运行npm run lint
来执行代码检查:lint: image: node:lts stage: test script: - npm ci - npm run lint
-
最后,我们需要使用以下命令提交和推送代码到 GitLab:
git add . git commit -m "add linting" git push
一旦代码被推送,我们就可以通过 GitLab CI/CD UI 看到管道运行,如下所示。注意,在
test
阶段的全部作业都是并行运行的:包括并行运行的 "test" 和 "lint"
图 14.17:GitLab CI/CD 管道视图,所有作业均成功,包括并行运行的 "test" 和 "lint"
我们现在已经看到了如何使用 GitLab CI/CD 在每次提交时运行构建和测试。
接下来,我们将看到如何将 Vue.js 应用程序部署到 Netlify。
部署到 Netlify
Netlify 是一家专注于静态托管和相关支持服务的托管提供商,以便拥有一个使用静态托管的全交互网站。这包括诸如 Netlify Functions(无服务器函数)、Netlify Forms(无后端表单提交系统)和 Netlify Identity(身份/认证提供商)等服务。
以下部分需要您拥有一个 Netlify 账户,这是免费的。
将网站部署到 Netlify 的最简单方法是使用拖放界面。您可以在登录视图的首页底部找到它:app.netlify.com
。它看起来如下所示:
App 首页底部
图 14.18:Netlify 的拖放部署区域位于 App 首页底部
因此,我们可以选择一个已经运行过 npm run build
命令并可以通过简单地将 dist
文件夹拖动到拖放部署区域来部署的项目,如下面的截图所示:
图 14.19:将 dist 文件夹拖放到 Netlify 拖放部署区域
一旦上传成功,Netlify 会将您重定向到您的新网站管理页面。它看起来如下所示:
图 14.20:Netlify 新应用页面用于拖放网站
我们可以点击网站链接,然后我们会看到默认的 Vue CLI 首页模板,如下所示:
![图 14.21:Netlify 新应用显示问候信息]
![图片 B15218_14_21.jpg]
图 14.21:Netlify 新应用显示问候信息
我们已经看到了如何使用拖放界面手动将站点部署到 Netlify。
接下来,我们将看到如何从 GitLab 将我们的站点部署到 Netlify。
在 Netlify 应用主页上,我们需要点击显示在以下截图中的“从 Git 创建新站点”按钮:
![图 14.22:Netlify 主页,带有从 Git 创建新站点的按钮]
![图片 B15218_14_22.jpg]
图 14.22:Netlify 主页,带有从 Git 创建新站点的按钮
我们将看到一个页面,要求我们选择要连接的 Git 提供商。在这个例子中,我们将使用GitLab
。以下截图显示了屏幕将如何显示:
![图 14.23:Netlify – 创建新站点 | 连接到 Git 提供商]
![图片 B15218_14_23.jpg]
图 14.23:Netlify – 创建新站点 | 连接到 Git 提供商
点击GitLab
后,我们将收到 GitLab 的 OAuth 授权挑战,我们需要通过点击以下截图所示的“授权”按钮来接受:
![图 14.24:GitLab OAuth 授权模态框]
![图片 B15218_14_24.jpg]
图 14.24:GitLab OAuth 授权模态框
然后,我们将被重定向到 Netlify,并被要求选择要部署的仓库,如下所示:
![图 14.25:选择要部署的 GitLab 仓库]
![图片 B15218_14_25.jpg]
图 14.25:选择要部署的 GitLab 仓库
我们选择我们想要部署的仓库,并遇到一个配置页面。由于我们现在是在 Netlify 的构建服务器上构建,我们需要配置 Netlify 以构建应用程序并部署正确的文件夹。
我们将构建命令填写为npm run build
,因为这是我们构建脚本。发布目录是dist
。
然后,我们可以点击“部署站点”按钮,这将启动部署过程,如下所示:
![图 14.26:Netlify 构建配置选项卡,已填写 npm run build]
分别为构建命令和发布目录的 dist
![图片 B15218_14_26.jpg]
图 14.26:Netlify 构建配置选项卡,已填写 npm run build 和 dist 作为构建命令和发布目录
然后,我们将被重定向到新创建的应用程序页面,如下所示:
![图 14.27:新的 Netlify 应用]
![图片 B15218_14_27.jpg]
图 14.27:新的 Netlify 应用
我们已经看到了如何使用手动上传方法将应用程序部署到 Netlify,以及如何使用 GitLab 作为 Git 托管提供商。
练习 14.02:从 GitHub 将站点部署到 Netlify
我们已经看到了如何从 GitLab 将站点部署到 Netlify,但它与从 GitHub 部署有何不同?答案是它们非常相似;唯一的显著区别是“连接到 Git 提供商”选项卡中的第一步:
-
我们将首先点击主页上的“从 Git 创建新站点”按钮,如下所示:![图 14.28:Netlify 仪表板上的从 Git 创建新站点]
![图片 B15218_14_28.jpg]
图 14.28:Netlify 控制台上的 Git 新站点
-
然后,我们将选择
GitHub
作为 Git 托管提供商,如下截图所示:![图 14.29:持续部署图 14.29:持续部署
-
当我们遇到 GitHub OAuth 授权挑战,如下截图所示,我们授权 Netlify:![图 14.30:GitHub 授权挑战
图 14.30:GitHub 授权挑战
-
我们从仓库列表中选择我们想要部署的 Vue CLI 项目,如下所示:
-
![图 14.31:选择正确的仓库
图 14.31:选择正确的仓库
-
在部署选项选项卡上,我们选择
master
作为要部署的分支。 -
我们将构建命令设置为
npm run build
。 -
我们将发布目录设置为
dist
。 -
完成的部署选项如下所示:![图 14.32:Netlify 构建配置选项卡已填写 npm run build 和 dist 分别代表构建命令和发布目录
图 14.32:Netlify 构建配置选项卡已填写 npm run build 和 dist,分别代表构建命令和发布目录
-
我们点击
部署站点
以开始部署过程。
我们现在已经看到了如何使用手动上传方法以及使用 GitLab 或 GitHub 作为 Git 托管提供商将应用程序部署到 Netlify。
接下来,我们将了解如何使用 Amazon Web Services 简单存储服务(AWS S3)和 AWS CloudFront 部署 Vue.js 应用程序。
使用 S3 和 CloudFront 部署到 AWS
Amazon S3 是一种静态存储服务,可以用作静态文件的托管,例如由 Vue CLI 的 build
脚本生成的文件。
CloudFront 是 AWS 的 内容分发网络(CDN)服务。CDN 可以通过从 边缘位置提供静态内容来提高 Web 应用程序的性能。这些服务器位于世界各地,并且更有可能位于比 源服务器(实际提供内容的服务器)更靠近最终用户的地方。如果 CDN 的边缘服务器没有缓存资源,它们将从源请求资源,但会为后续请求提供服务。
以下步骤的一个先决条件是 AWS 账户:
-
我们首先创建并配置一个 S3 存储桶。
我们首先前往 S3 产品页面。它将类似于以下截图:
![图 14.33:从 AWS 服务列表中选择 S3
图 14.33:从 AWS 服务列表中选择 S3
-
在 S3 控制台主页上,我们可以点击
创建存储桶
按钮,这将带我们到存储桶创建页面,如下所示:![图 14.34:AWS S3 控制台上的创建存储桶按钮图 14.34:AWS S3 控制台上的创建存储桶按钮
-
首先,我们给我们的存储桶命名。为了本例的目的,让我们称它为
vue-workshop
,如下所示:图 14.35:在存储桶创建页面输入存储桶名称
-
我们还需要将 S3 存储桶设置为公开。这是通过取消选择
阻止所有公开访问
复选框来完成的。一旦这样做,我们必须检查确认复选框,如下所示:图 14.36:将 S3 存储桶设置为公开并确认警告
-
一旦完成,我们将被重定向到存储桶列表页面。我们想要点击进入我们新的存储桶。然后,我们需要访问
属性
标签,以找到静态网站托管
选项: -
图 14.37:S3 存储桶属性标签中的静态网站托管选项
-
我们可以填写
静态网站托管
S3 属性,选择使用此存储桶托管网站
,并将索引文档和错误文档设置为index.html
。记下端点
URL 是个好主意,因为我们需要配置 CloudFront,如下所示:图 14.38:填写静态网站托管 S3 属性
-
我们现在可以回到 S3 存储桶页面的
概览
标签,点击上传
,并将文件从我们的dist
文件夹之一拖放到以下截图所示的位置: -
图 14.39:通过拖放将文件添加到 vue-workshop S3 存储桶
-
一旦文件被拖放到概览页面,我们点击
下一步
,并确保在页面的管理公开权限
部分选择授予此对象(s)公开读取访问权限
,以确保文件权限设置为公开
。完成此操作后,我们可以通过点击下一步
和上传
,在审查上传的文件后,不更改默认值完成上传,如下所示: -
图 14.40:设置上传到 S3 存储桶的文件权限为公开
-
我们现在应该已经配置了 S3 存储桶以托管静态内容,通过访问网站端点(在属性 | 静态网站托管中可用),我们看到以下 Vue.js 应用程序(这是我们上传的):
图 14.41:从我们的 AWS S3 存储桶提供的 Vue.js 应用程序
注意,S3 只能通过 HTTP 提供网站服务,并且无法直接从 S3 存储桶配置域名。除了性能和健壮性之外,能够设置自定义域名和 HTTPS 支持也是将 AWS CloudFront 设置为网站 CDN 的其他原因。
-
我们将首先导航到 CloudFront 控制台并点击“创建分布”按钮,如下所示:
-
图 14.42:从 AWS 服务列表中选择 CloudFront
-
当提示我们想要创建哪种类型的分布时,我们将通过点击相关的“开始”按钮选择
Web
,如下截图所示: -
图 14.43:选择创建 Web CloudFront 分布
-
“源域名”应该是 S3 存储桶网站端点域名——换句话说,就是之前我们用来访问它的 URL 的域名。对于位于
us-east-1
区域的example
存储桶,它看起来像example.s3-website.us-west-1.amazonaws.com
。以下截图显示了这一点:图 14.44:在 CloudFront 分布的“源域名”字段中输入网站端点域名
-
在设置分布时,选择“默认缓存行为”部分的“查看器协议策略”字段的“将 HTTP 重定向到 HTTPS”选项是个好主意,如下所示:
-
图 14.45:为查看器协议策略字段选择将 HTTP 重定向到 HTTPS
现在我们已经准备好点击“创建分布”按钮并等待更改传播。
注意
由于 CloudFront 分布更改正在部署到世界各地的服务器上,因此它们需要一段时间才能传播。
一旦控制台显示其状态为“已部署”,我们就可以打开 CloudFront 分布的域名。
我们已经看到了如何设置 S3 和 CloudFront 来托管静态网站。现在我们将看到如何使用 AWS CLI 将本地目录同步到 S3 存储桶。
下一个部分的前提条件是有一个使用AWS_ACCESS_KEY_ID
、AWS_SECRET_ACCESS_KEY
和AWS_DEFAULT_REGION
环境变量注入 AWS 凭证的 shell 实例。访问密钥和秘密密钥需要从“账户”下拉菜单中的“我的安全凭证”下的“访问密钥”生成。它还需要 AWS CLI 版本 2。
如果我们是在 Vue CLI 项目中,我们可以使用 AWS S3 CLI 命令将 dist
文件夹(可以使用 npm run build
构建)部署到我们的 vue-workshop
存储桶。我们想要更新一个 s3
资源,以便我们的命令以 aws s3
开始。我们想要执行的命令是同步文件,所以我们将使用 aws s3 sync
命令。我们将同步 ./dist
到 vue-workshop
S3 存储桶,使用 AWS URI 语法,即 s3://vue-workshop
。我们还想要确保我们上传的文件,就像存储桶配置一样,允许 public-read
。完整的命令如下:
aws s3 sync ./dist s3://vue-workshop --acl=public-read
练习 14.03:从 GitLab CI/CD 部署到 S3
S3 是一种非常经济高效且性能出色的解决方案,用于大规模存储静态文件。在这个练习中,我们将探讨如何集成 GitLab CI/CD 和 AWS S3 来部署 Vue.js 应用程序。这将自动化 Vue.js 应用的部署。部署将在每次向 GitLab 推送时运行,无需任何手动干预。
要访问此练习的代码文件,请参阅 packt.live/3kJ1HPD
。
为了从 GitLab CI/CD 部署到 S3 存储桶,我们首先需要设置凭证管理:
-
按照以下步骤导航到 GitLab 的
CI/CD
设置部分:图 14.46:设置菜单中的 CI/CD
-
我们将想要添加变量,所以让我们展开该部分。你将看到如下截图所示的消息:
图 14.47:GitLab CI/CD 设置的变量部分展开
-
接下来,我们将使用 UI 添加
AWS_ACCESS_KEY_ID
和AWS_SECRET_ACCESS_KEY
(由于它们是敏感的 API 密钥,所以未显示其值),如下所示: -
图 14.48:输入 AWS_ACCESS_KEY_ID 环境变量
-
然后,我们可以使用 UI 添加默认的
AWS_REGION
变量。这不是那么敏感,所以其值在以下截图中显示: -
图 14.49:输入 AWS_DEFAULT_REGION 环境变量
-
现在我们已经在 GitLab CI/CD 上设置了环境变量,我们可以开始更新我们的
.gitlab-ci.yml
文件。首先,我们想在build
步骤之后开始缓存dist
目录。为了做到这一点,我们需要在build
作业中添加一个cache
属性:build: # other properties cache: key: $CI_COMMIT_REF_SLUG paths: - dist # other properties # other jobs
-
我们现在可以添加我们的
deploy
作业,它将是deploy
阶段的一部分。为了访问 AWS CLI,我们将使用 Python 映像(python:latest
)并在before_script
步骤中使用pip
(Python 包管理器)安装 AWS CLI。一旦我们安装了 AWS CLI,我们将在script
步骤中使用我们用于从本地机器部署的aws s3 sync
命令来运行部署:# other jobs deploy: image: python:latest stage: deploy cache: key: $CI_COMMIT_REF_SLUG paths: - dist before_script: - pip install awscli script: - aws s3 sync ./dist s3://vue-workshop --acl=public-read
注意
由于 Vue CLI 的
build
命令通过指纹文件内容内置了缓存清除功能,我们不需要使缓存失效。指纹化意味着如果文件内容发生变化,其名称/URL 将相应地更改。当请求此新文件时,它将从未缓存的 URL 加载,因此将获取文件的最新版本。一旦将此配置更新推送到 GitLab 仓库,我们可以看到管道运行了三个阶段,所有阶段都通过了,如下所示:
![图 14.50:通过构建、测试和部署作业
图 14.50:通过构建、测试和部署作业
我们已经看到了如何使用 AWS CLI 和 GitLab CI/CD 配置和部署 Vue.js 应用程序到 S3 和 CloudFront。
活动 14.01:将 GitLab CI/CD 添加到图书搜索应用程序并部署到 Amazon S3 和 CloudFront
现在,让我们使用 GitLab CI/CD 将一个完全构建的 图书搜索 Vue.js 应用程序部署到 S3/CloudFront,该应用程序从 Google Books API 加载数据。我们将从在本地运行生产构建并检查输出开始。然后,我们将切换到在 GitLab CI 上运行构建和代码质量步骤(linting)。最后,我们将设置 S3 存储桶和 CloudFront 分发,并将它们与 GitLab CI/CD 集成,以便在每次向仓库推送时部署。
此活动的起始代码可以在 Chapter14/Activity14.01_initial
中找到;我们从一个 Chapter14/Activity14.01_solution
开始:
-
首先,我们想要在本地运行生产构建。我们可以使用用于构建所有 Vue CLI 项目的常规命令。我们还将检查相关的资产(JavaScript、CSS 和 HTML)是否正确生成。
我们预计
dist
文件夹将包含以下类似的结构:![图 14.51:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令) 在 Vue CLI 生产构建运行之后
图 14.51:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令生成)
-
为了运行 GitLab CI/CD,我们需要一个
.gitlab-ci.yml
文件。我们将在.gitlab-ci.yml
中添加一个作业,该作业在build
阶段运行 Node.js LTS Docker 容器中的包安装,然后是生产构建。我们还将确保缓存生产构建的输出。一旦我们使用
git add .gitlab-ci.yml
并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含正在运行状态的build
作业:![图 14.52:构建作业正在运行的 GitLab CI/CD 管道
图 14.52:构建作业正在运行的 GitLab CI/CD 管道
另一方面,以下截图表示
build
作业完成并处于passed
状态时的 GitLab CI/CD 管道:![图 14.53:构建作业通过后的 GitLab CI/CD 管道
图 14.53:GitLab CI/CD 流水线中 build 作业通过
-
接下来,我们希望在 GitLab CI/CD 的
test
阶段添加一个代码质量作业(通过更新.gitlab-ci.yml
)。我们将这个作业命名为lint
,它将运行依赖项的安装以及通过 Vue CLI 进行代码检查。一旦我们使用
git add .gitlab-ci.yml
提交并推送更改,我们应该看到以下 GitLab CI/CD 流水线运行,其中包含正在运行状态的lint
作业:图 14.54:运行中的 GitLab CI/CD 流水线及 lint 作业
以下截图显示了 GitLab CI/CD 流水线,其中
lint
作业已成功完成:图 14.55:GitLab CI/CD 流水线中 lint 作业通过
-
为了部署我们的应用程序,我们需要在 S3 控制台中创建一个启用
public access
的vue-workshop-book-search
S3 存储桶。S3 存储桶创建页面应如下所示:
图 14.56:S3 存储桶创建页面,输入 vue-workshop-book-search 作为存储桶名称
以下截图显示了 S3 存储桶创建页面上的公共访问和免责声明信息:
图 14.57:S3 存储桶创建页面,启用公共访问并接受相关免责声明
-
为了通过 Web 访问 S3 存储桶内容,我们还需要配置其网络托管。我们可以通过 S3 控制台配置网络托管属性。
应该按照以下配置,将索引和错误页面设置为
index html
:图 14.58:S3 存储桶属性页面,启用网络托管并配置索引和错误页面为 index.html
-
为了让 GitLab CI/CD 能够在 S3 上创建和更新文件,我们需要将相关的 AWS 密钥添加到我们的 GitLab 仓库 CI/CD 设置中。这些密钥可以在 AWS 管理控制台的
Username
下拉菜单 |My Security Credentials
|Access keys
(访问密钥 ID 和秘密访问密钥) |Create New Access Key
(或选择一个密钥进行重用)中找到。以下截图显示了这些详细信息: -
图 14.59:GitLab CI/CD 设置页面,已添加所需的 AWS 环境变量(值已隐藏)
-
接下来,我们希望在 GitLab CI/CD 的
deploy
阶段添加一个deploy
作业(通过更新.gitlab-ci.yml
)。我们将作业命名为deploy
;它需要下载awscli
pip
包(Python 包管理器),这意味着最有意义的 Docker 镜像是python:latest
。deploy
作业将从缓存中加载构建好的生产构建,使用pip
安装awscli
,并运行aws s3 sync <build_directory> s3://<s3-bucket-name> --acl=public-read
。一旦我们使用
git add .gitlab-ci.yml
并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态的deploy
作业:![图 14.60:GitLab CI/CD 管道中部署作业正在运行
图 14.60:GitLab CI/CD 管道中部署作业正在运行
以下截图显示了成功完成的
deploy
作业的 GitLab CI/CD 管道:![图 14.61:GitLab CI/CD 管道中部署作业已通过
图 14.61:GitLab CI/CD 管道中部署作业已通过
一旦管道完成,我们的应用程序应可通过以下截图所示的
web
S3 端点访问:![图 14.62:通过 S3 网端点 URL 访问的图书搜索
图 14.62:通过 S3 网端点 URL 访问的图书搜索
-
最后,我们将创建一个充当
web
S3 端点 CDN 的 CloudFront 分发。我们需要将origin
设置为我们的 S3 存储桶网端点的源,并确保我们已经启用了Redirect HTTP to HTTPS
,如下面的截图所示: -
![图 14.63:显示源和设置行为的 CloudFront 分发创建页面 源和设置行为
图 14.63:显示源和设置行为的 CloudFront 分发创建页面
一旦 CloudFront 分发部署,我们的应用程序应可通过以下截图所示的 CloudFront 分发域名访问:
![图 14.64:通过 CloudFront 域访问的图书搜索显示 "harry potter" 搜索的结果对于 "harry potter" 搜索的结果
图 14.64:通过 CloudFront 域访问的图书搜索显示 "harry potter" 搜索的结果
注意
此活动的解决方案可以通过此链接找到。
摘要
在本章中,我们探讨了如何将 CI 和 CD 实践引入 Vue.js 项目,以便安全高效地部署到生产环境。我们看到了 CI 和 CD 在敏捷交付过程中的好处。我们使用 GitLab 的 CI/CD 功能在每次提交时运行测试、代码检查和构建。我们看到了如何通过将 Netlify 连接到我们的托管提供商来利用 Netlify 托管静态网站。最后,我们探讨了如何设置和部署到 AWS S3 和 CloudFront。
在这本书的整个过程中,你已经学会了如何构建、测试和部署一个适用于你的团队和最终用户的可扩展且性能良好的 Vue.js 应用程序。
附录
1. 开始你的第一个 Vue 项目
活动一.01:使用 Vue.js 构建动态购物清单应用
解决方案:
要访问此活动的代码文件,请参阅packt.live/35Tkzau
。
-
通过运行
vue create new-activity-app
命令使用 Vue CLI 创建一个新的 Vue 项目。通过命令提示符手动选择dart-sass
、babel
和eslint
功能。 -
使用占位符“按 Enter 键添加新项目”创建一个输入字段,并将其
v-model
绑定到名为input
的数据对象上,同时设置ref
属性值为input
。通过使用@keyup.enter
并引用addItem
方法,将Enter
键绑定到addItem
方法,该方法将在下一步创建:<template> <div class="container"> <h2>Shopping list</h2> <div class="user-input"> <input placeholder="Press enter to add new item" v-model="input" @keyup.enter="addItem" ref="input" /> </div> </div> </template> <script> export default { data() { return { input: '', } }, } </script> <style lang="scss"> @import 'styles/global'; $color-green: #4fc08d; $color-grey: #2c3e50; .container { max-width: 600px; margin: 80px auto; } // Type .h2 { font-size: 21px; } .user-input { display: flex; align-items: center; padding-bottom: 20px; input { width: 100%; padding: 10px 6px; margin-right: 10px; } } </style>
-
在
addItem
上引入一个绑定点击事件的按钮,并在methods
对象中包含相应的addItem()
方法。在addItem()
方法中,将数据属性input
的字符串推入shoppingList
,并添加一个检查以确保input
属性存在。可选地,为你的按钮添加一些样式:<template> <div class="container"> <h2>Shopping list</h2> <div class="user-input"> <input placeholder="Press enter to add new item" v-model="input" @keyup.enter="addItem" ref="input" /><button @click="addItem">Add item</button> </div> </div> </template> <script> export default { data() { return { input: '', shoppingList: [], } }, methods: { addItem() { // Don't allow adding to the list if empty if (!this.input) return this.shoppingList.push(this.input) // Clear the input after adding this.input = '' // Focus the input element again for quick typing! this.$refs.input.focus() }, }, } </script> <style lang="scss"> ... // Buttons button { appearance: none; padding: 10px; font-weight: bold; border-radius: 10px; border: none; background: $color-grey; color: white; white-space: nowrap; + button { margin-left: 10px; } } </style>
-
在 DOM 中输出购物清单项。当你点击“添加项目”按钮时,它应该被添加到
shoppingList
并显示:<template> <div class="container"> ... <ul v-if="shoppingList"> <li v-for="(item, i) in shoppingList" :key="i" class="item" ><span>{{ item }}</span> </li> </ul> </div> </template> <style lang="scss"> .item { display: flex; align-items: center; } ul { display: block; margin: 0 auto; padding: 30px; border: 1px solid rgba(0, 0, 0, 0.25); > li { color: $color-grey; margin-bottom: 4px; } } </style>
以下截图显示了购物清单:
![图 1.44:购物清单应根据用户输入显示
图 1.44:购物清单应根据用户输入显示
-
为了满足从列表中删除项目的最后要求,创建一个名为
deleteItem
的新方法,并允许传入一个名为i
的参数。如果有参数传递到该方法中,过滤出该数组项并更新shoppingList
属性;如果没有参数传递到该方法中,则用空数组替换数据属性:... <script> export default { data() { return { input: '', shoppingList: [], } }, methods: { addItem() { // Don't allow adding to the list if empty if (!this.input) return this.shoppingList.push(this.input) // Clear the input after adding this.input = '' // Focus the input element again for quick typing! this.$refs.input.focus() }, deleteItem(i) { this.shoppingList = i ? this.shoppingList.filter((item, x) => x !== i) : [] }, }, } </script>
-
创建一个“删除所有”按钮元素,并使用点击事件
@click
将其绑定到deleteItem
方法:<button class="button--delete" @click="deleteItem()"> Delete all</button> ... <style lang="scss"> ... .button--delete { display: block; margin: 0 auto; background: red; } </style>
-
在列表循环中添加一个“删除”按钮,通过传递
v-for
属性i
来删除单个购物清单项:<template> <div class="container"> ... <ul v-if="shoppingList"> <li v-for="(item, i) in shoppingList" :key="i" class="item" ><span>{{ item }}</span> <button class="button--remove" @click="deleteItem(i)">Remove</button> </li> </ul> <br /> <button class="button--delete" @click="deleteItem()"> Delete all</button> </div> </template> ... <style lang="scss"> ... .button--remove { background: none; color: red; text-transform: uppercase; font-size: 11px; align-self: flex-end; } </style>
图 1.45 显示了添加项目之前的购物清单所有详细信息:
![图 1.45:最终输出
图 1.45:最终输出
以下截图显示了添加项目到购物清单后的输出:
![图 1.46:添加项目到购物清单后的最终输出
图 1.46:添加项目到购物清单后的最终输出
在这个活动中,你通过使用SFC的所有基本功能来测试你的 Vue 知识,例如表达式、循环、双向绑定和事件处理。你构建了一个购物清单应用,允许用户使用 Vue 方法添加和删除单个列表项,或者通过单击一次清除整个列表。
2. 数据处理
活动二.01:使用 Contentful API 创建博客列表
解决方案:
执行以下步骤以完成活动。
注意
要访问此活动的代码文件,请参阅packt.live/33ao1f5
。
-
使用 Vue CLI 的
vue create activity
命令创建一个新的 Vue 项目,并选择以下预设:Babel、SCSS 预处理器(您可以选择任一预处理器),以及 prettier 格式化器。 -
添加
contentful
依赖项:yarn add contentful
-
在
App.vue
中,移除默认内容并将contentful
导入到组件中:<template> <div id=»app»> </div> </template> <script> import { createClient } from 'contentful' const client = createClient({ space: ‹hpr0uushokd4›, accessToken: ‹jwEHepvQx-kMtO7_2ldjhE4WMAsiDp3t1xxBT8aDp7U›, }) </script> <style lang="scss"> #app { font-family: ‹Avenir›, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin: 60px auto 0; max-width: 800px; } </style>
-
在创建生命周期中向
getPeople
和getBlogPosts
添加async
方法,并将调用响应分别分配给模板中的authors
和posts
数据属性:<template> <div id=»app»> <pre>{{ authors }}</pre> <pre>{{ posts }}</pre> </div> </template> <script> import { createClient } from 'contentful' const client = createClient({ space: ‹hpr0uushokd4›, accessToken: ‹jwEHepvQx-kMtO7_2ldjhE4WMAsiDp3t1xxBT8aDp7U›, }) export default { name: ‹app›, data() { return { authors: [], posts: {}, } }, async created() { this.authors = await this.getPeople() this.posts = await this.getBlogPosts() }, methods: { async getPeople() { const entries = await client.getEntries({ content_type: 'person' }) return entries.items }, async getBlogPosts() { const entries = await client.getEntries({ content_type: ‹blogPost›, order: ‹-fields.publishDate›, }) return entries.items }, }, } </script>
-
使用
posts
对象遍历文章,并输出publishDate
、title
、description
和image
:<div class=»articles»> <hr /> <h2>Articles</h2> <section v-if=»posts» class=»articles-list»> <article v-for=»(post, i) in posts» :key=»i»> <img class=»thumbnail» :src=» post.fields.heroImage.fields.file.url + '?fit=scale&w=350&h=196' « /> class=»article-text»> <div class="date">{{ new Date(post.fields.publishDate).toDateString() }}</div> <h4>{{ post.fields.title }}</h4> <p>{{ post.fields.description }}</p> </div> </article> </section> </div>
-
向
articles-list
添加一些scss
样式:.articles-list { article { display: flex; text-align: left; padding-bottom: 15px; .article-text { padding: 15px 0; } .thumbnail { margin-right: 30px; } .date { font-size: 12px; font-weight: bold; text-transform: uppercase; } } }
-
使用计算属性输出作者信息:
<template> ... <div v-if=»name» class=»author»> <h2 >{{ name }} <br /> <small v-if=»title» >{{ title }}</small></h2 > <p v-if=»bio» >{{ bio }}</p> </div> ... </template> ... computed: { name() { return this.authors[0] && this.authors[0].fields.name }, title() { return this.authors[0] && this.authors[0].fields.title }, bio() { return this.authors[0] && this.authors[0].fields.shortBio }, }, ...
以下截图显示了作者信息以及他们的博客文章列表:
![图 2.16:使用 Contentful 博客文章的预期结果
![img/B15218_02_16.jpg]
图 2.16:使用 Contentful 博客文章的预期结果
在此活动中,您使用 Vue SFC 的基本功能构建了一个博客,列出了来自 API 源的文章,使用 async
方法从 API 获取远程数据,并使用计算属性组织深层嵌套的对象结构。
3. Vue CLI
活动 3.01:使用 Vue-UI 和 Vuetify 组件库构建 Vue 应用程序
解决方案:
执行以下步骤以完成活动。
注意
要访问此活动的代码文件,请参阅 packt.live/35WaCJG
。
-
打开命令行,运行
vue create activity-app
。 -
通过按一次 向下箭头键 并按 Enter 键选择最后一个选项,
手动选择功能
:? Please pick a preset: (Use arrow keys) default (babel, eslint) > Manually select features
-
选择
Babel
、CSS 预处理器
和检查器/格式化器
:? Check the features needed for your project: (*) Babel ( ) TypeScript ( ) Progressive Web App (PWA) Support ( ) Router ( ) Vuex (*) CSS Pre-processors >(*) Linter / Formatter ( ) Unit Testing ( ) E2E Testing
-
选择
Sass/SCSS (with dart-sass)
:? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys) > Sass/SCSS (with dart-sass) Sass/SCSS (with node-sass) Less Stylus
-
接下来,我们将选择
Eslint+ Prettier
来格式化代码:? Pick a linter / formatter config: (Use arrow keys) ESLint with error prevention only ESLint + Airbnb config ESLint + Standard config > ESLint + Prettier
-
然后,我们将选择选项
保存时检查
和提交时检查和修复
以选择额外的检查功能并保存它们:? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection) >(*) Lint on save (*) Lint and fix on commit
-
要将配置放在专用文件中,我们将选择
在专用配置文件中
选项:? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (Use arrow keys) > In dedicated config files In package.json
按 Enter 键跳过保存。
npm
包将自动安装。您应该在终端看到以下输出:yarn install v1.16.0 info No lockfile found. [1/4] Resolving packages...
-
安装完包后,运行
yarn serve
命令。然后,转到您的浏览器并导航到http://localhost:8080
。您应该看到以下输出:![图 3.43:默认 Vue 项目屏幕![img/B15218_03_43.jpg]
图 3.43:默认 Vue 项目屏幕
-
停止
serve
任务,并在命令行中运行vue ui
。 -
在 Vue-UI 内,转到项目选择屏幕(位于
http://localhost:8000/project/select
)。 -
点击
导入
按钮,导航到您新创建的 Vue 项目存储的文件夹。以下截图显示了您的屏幕应该看起来像什么:![图 3.44:Vue-UI 项目管理器![img/B15218_03_44.jpg]
图 3.44:Vue-UI 项目管理器
-
点击大绿色的
导入此文件夹
按钮。 -
从
Projects
仪表板导航到Plugins
选项卡。 -
点击
+ 添加插件
按钮。你的屏幕应该看起来像下面的截图:图 3.45:Vue-UI 插件管理器,你可以在这里添加、删除和修改 Vue 插件
-
搜索
vuetify
并安装vue-cli-plugin-vuetify
,然后选择默认配置设置,如图 3.46 所示:图 3.46:安装 Vuetify CLI 时 App.vue 的默认配置
-
导航到
Tasks
页面并点击Start Tasks
。当应用程序初始化时,点击Open App
按钮。在 localhost URL 上,你应该看到一个如下所示的 Vuetify 风格的页面:图 3.47:当 Vuetify CLI 插件安装时你在浏览器中看到的内容
-
点击 Vuetify 页面布局中的
Select a layout
超链接。 -
从以下截图所示的选项中点击
Baseline
主题(或任何其他你感兴趣的主题)的代码链接:图 3.48:Vuetify 网站提供了多个预制的布局
-
从 Vuetify 仓库复制
baseline.vue
文件的内容,并用此内容替换你的App.vue
文件内容。你的localhost:8080
应该会重新加载你复制的内容,浏览器应显示如下:
图 3.49:从浏览器中看到的模板最终结果
到此活动结束时,你看到了如何使用 Vue-UI 准备 Vue.js 项目,选择和组织用于企业级 Vue 应用程序生产的宝贵预设。你安装并使用了 Vuetify
框架,利用 Vuetify 组件创建了一个布局,然后你可以在浏览器中预览它。
4. 嵌套组件(模块化)
活动 4.01:具有可重用组件的本地消息视图
解决方案:
执行以下步骤以完成活动。
注意
要访问此活动的代码文件,请参阅 packt.live/36ZxyH8
。
首先,我们需要一种方法来捕获用户的消息:
-
创建一个显示
textarea
的MessageEditor
组件:<template> <div> <textarea></textarea> </div> </template>
-
使用
data
组件方法可以添加一个响应实例属性:<script> export default { data() { return { message: '' } } } </script>
-
当
textarea
发生change
时,我们将状态存储在一个我们已在data
组件方法中设置为 null 的message
响应实例变量中:<template> <!-- rest of the template --> <textarea @change="onChange($event)" > </textarea> </template> <script> export default { // rest of component properties methods: { onChange(event) { this.message = event.target.value } } } </script>
-
一个
Send
操作应该导致textarea
的最新内容作为send
事件的负载发出:<template> <!-- rest of the template --> <button @click="$emit('send', message)">Send</button> <!-- rest of the template --> </template>
-
要显示
MessageEditor
,我们需要导入它,在components
中注册它,并在src/App.vue
的template
部分引用它:<template> <div id="app"> <MessageEditor /> </div> </template> <script> import MessageEditor from './components/MessageEditor.vue' export default { components: { MessageEditor, }, } </script>
-
要显示消息,我们将使用
@send
监听send
事件,并将每个有效负载添加到一个新的messages
数组响应式实例变量中:<template> <!-- rest of template --> <MessageEditor @send="onSend($event)" /> <!-- rest of template --> </template> <script> // rest of script export default { // other component fields data() { return { messages: [] } }, methods: { onSend(message) { this.messages = [...this.messages, message] } } } </script>
-
MessageFeed
支持通过messages
数组作为prop
传递:<template> </template> <script> export default { props: { messages: { type: Array, required: true } } } </script>
-
我们将使用
v-for
来遍历messages
数组:<template> <div> <p v-for="(m, i) in messages" :key="i"> {{ m }} </p> </div> </template>
-
要显示我们存储的消息,我们将在
App
中渲染MessageFeed
,并将messages
应用实例变量绑定为MessageFeed
的messages
属性:<template> <!-- rest of template --> <MessageFeed :messages="messages" /> <!-- rest of template --> </template> <script> // other imports import MessageFeed from './components/MessageFeed.vue' export default { components: { // other components, MessageFeed } } </script>
-
在
MessageEditor
中,我们将重构send
按钮点击handler
,以便在点击时也将this.message
设置为''
:<template> <!-- rest of template --> <textarea ref="textArea" @change="onChange($event)" > </textarea> <button @click="onSendClick()">Send</button> <!-- rest of template --> </template> <script> export default { // rest of component methods: { // other methods onSendClick() { this.$emit('send', this.message) this.message = '' this.$refs.textArea.value = '' } } } </script>
预期输出如下:
![图 4.34:带有已发送的 Hello World! 和 Hello JavaScript! 的消息应用]
![图片 B15218_04_34.jpg]
图 4.34:带有已发送的 Hello World! 和 Hello JavaScript! 的消息应用
通过这样,我们已经学习了如何使用组件、属性、事件和引用来渲染聊天界面。
5. 全局组件组合
活动 5.01:使用插件和可重用组件构建 Vue.js 应用程序
解决方案:
完成此活动的步骤如下:
注意
要访问此活动的代码文件,请参阅 packt.live/35UlWpj
。
-
将
axios
安装到项目中:npm install --save axios
-
要将
axios
注入为this
组件实例的属性,创建一个src/plugins/axios.js
插件文件,在install
时,这意味着组件实例将有一个axios
属性:import axios from 'axios' export default { install(Vue) { Vue.prototype.axios = axios } }
-
为了使插件工作,请在
src/main.js
中导入并注册它:// other imports import axiosPlugin from './plugins/axios.js' Vue.use(axiosPlugin) // other initialisation code
-
我们还希望将我们的 API 的
baseUrl
注入到所有组件中。我们将在src/main.js
文件中创建一个插件来执行此操作:const BASE_URL = 'https://jsonplaceholder.typicode.com' Vue.use({ install(Vue) { Vue.baseUrl = BASE_URL Vue.prototype.baseUrl = BASE_URL } })
注意
熟悉
axios
的人知道我们可以将此 URL 注入为axios
的baseURL
。 -
现在,我们需要从
src/App.vue
中获取所有todos
。在mounted
生命周期方法中做这件事是个好地方:<script> export default { async mounted() { const { data: todos } = await this.axios.get( `${this.baseUrl}/todos`) this.todos = todos } } </script>
-
要显示
todo
列表,我们将在src/components/TodoList.vue
中创建一个TodoList
函数式组件。这将接受一个todos
属性,遍历项目,并在todo
作用域插槽中绑定它以延迟渲染我们的todo
:<template functional> <ul> <li v-for="todo in props.todos" :key="todo.id"> <slot name="todo" :todo="todo" /> </li> </ul> </template>
-
我们现在可以使用
TodoList
组件来渲染出我们在src/App.vue
中已经获取的todos
属性:<template> <div id="app"> <TodoList :todos="todos"> <template #todo="{ todo }"> {{ todo.title }} </template> </TodoList> </div> </template> <script> import TodoList from './components/TodoList.vue' export default { components: { TodoList }, // other component methods data() { return { todos: [] } } } </script>
这将生成以下输出:
![图 5.23:正在加载 Todos 和显示标题]
![图片 B15218_05_23.jpg]
![图 5.23:正在加载 Todos 和显示标题]
注意
该数据集的链接,已作为 JSON API 公开,可在
jsonplaceholder.typicode.com/
找到。 -
现在,让我们创建一个
TodoEntry
组件,我们将在这里实现大部分关于待办事项的逻辑。对于组件来说,一个好的做法是让 props 非常具体于组件的角色。在这种情况下,我们将处理的todo
对象的属性是id
、title
和completed
,因此这些应该是我们的TodoEntry
组件接收的 props。我们不会将TodoEntry
制作成函数式组件,因为我们还需要一个组件实例来创建 HTTP 请求:<template> <div> <label>{{ title }}</label> <input type="checkbox" :checked="completed" /> </div> </template> <script> export default { props: { id: { type: Number, required: true }, title: { type: String, required: true }, completed: { type: Boolean, default: false } } } </script>
-
更新
src/App.vue
以使其消费TodoEntry
如下(确保绑定id
、title
和completed
):<template> <div id="app"> <TodoList :todos="todos"> <template #todo="{ todo }"> <TodoEntry :id="todo.id" :title="todo.title" :completed="todo.completed" /> </template> </TodoList> </div> </template> <script> // other imports import TodoEntry from './components/TodoEntry.vue' export default { components: { // other components TodoEntry }, // other component methods } </script>
我们将得到以下输出:
图 5.24:TodoEntry 渲染从 API 获取的数据
-
现在,我们需要添加切换
src/components/TodoEntry.vue
的功能。我们将监听input
变更事件;在变更时,我们将读取新值,并向/todos/{todoId}
发送一个包含completed
设置为新值的PATCH
请求。我们还将发出一个 Vue.js 中的completedChange
事件,以便App
组件可以更新内存中的数据:<template> <!-- rest of the template --> <input type="checkbox" :checked="completed" @change="toggleCompletion()" /> <!-- rest of the template --> </template> <script> export default { // other component properties methods: { toggleCompletion() { const newCompleted = !this.completed this.$emit('completeChange', newCompleted) this.axios.patch( `${this.baseUrl}/todos/${this.id}`, { completed: newCompleted } ) } } } </script>
-
在
App.vue
中,当触发completeChange
事件时,我们需要更新相关的todo
。由于completeChange
事件不包含我们todo
的 ID,因此在我们设置handleCompleteChange
函数以便它监听completeChange
事件时,我们需要从上下文中读取那个 ID:<template> <!-- rest of template --> <TodoEntry :id="todo.id" :title="todo.title" :completed="todo.completed" @completeChange="handleCompleteChange(todo.id, $event)" /> <!-- rest of template --> </template> <script> // imports export default { // other component properties methods: { handleCompleteChange(id, newCompleted) { this.todos = this.todos.map( t => t.id === id ? { ...t, completed: newCompleted } : t ) } } } </script>
在这个阶段,我们应该看到以下输出:
图 5.25:使用 JSON placeholder 数据的待办事项应用
通过这样,我们已经学会了如何使用插件和可重用组件来构建一个消费 JSONPlaceholder
数据的 todo
应用。
6. 路由
活动 6.01:创建一个带有动态嵌套路由和布局的消息单页应用
解决方案:
完成以下步骤以完成活动:
注意
要访问此活动的代码文件,请参阅 packt.live/2ISxml7
。
-
在
src/views/
文件夹中创建一个新的MessageEditor.vue
文件,作为主要组件,用于与用户交互以编写消息。我们使用textarea
作为消息输入字段,并将listener
方法onChange
绑定到DOM
事件change
上,以捕获用户输入的任何消息更改。此外,我们还添加了ref
以保持对渲染的 HTMLtextarea
元素的指针记录,以便在稍后阶段修改我们保存的消息。除了这个之外,我们还附加了另一个
listener
方法onSendClick
到提交按钮
的click
事件上,以捕获用户发送消息的确认。onChange
和onSendClick
的实际逻辑实现显示在 步骤 3 中。 -
<template>
部分 应该看起来像以下这样:<template> <div> <textarea ref="textArea" @change="onChange($event)" > </textarea> <button @click="onSendClick()">Submit</button> </div> </template>
-
在
script
中,除了之前的代码外,我们还将接收到一个list
,用于在提交新消息后更新,并将更新后的列表发送回父组件:<script> export default { props: { list: Array }, data() { return { message: '' } }, methods: { onChange(event) { this.message = event.target.value }, onSendClick() { if (!this.message) return; this.list.push(this.message); this.$emit('list:update', this.list); this.message = '' this.$refs.textArea.value = '' } }, } </script>
-
我们需要在
./src/router/index.js
中的路由数组中定义一个父路由作为默认路由,其path
为/
,name
为messages
:{ path: '/', name: 'messages', component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue'), }
然后在
children
属性下添加一个新的路由作为嵌套路由,称为editor
:{ path: '/', name: 'messages', component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue'), children: [{ path: 'editor', name: 'editor', component: () => import(/* webpackChunkName: "editor" */ '../views/MessageList.vue'), props: true, }] },
-
我们创建一个新的视图组件
MessageList.vue
,使用v-for
将消息列表渲染到router-link
组件中:<template> <div> <h2> Message Feed </h2> <div v-for="(m, i) in list" :key="i" > <router-link :to="`/message/${i}`"> {{ i }} </router-link> </div> </div> </template> <script> export default { props: { list: { type: Array, default: () => [] } } } </script>
-
类似于 步骤 2,将
MessageList.vue
组件注册到messages
路由的children
路由数组中:{ path: '/', name: 'messages', component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue'), children: [{ path: 'list', name: 'list', component: () => import(/* webpackChunkName: "list" */ '../views/MessageList.vue'), props: true, }, { path: 'editor', name: 'editor', component: () => import(/* webpackChunkName: "editor" */ '../views/MessageEditor.vue'), props: true, }] },
-
现在,我们的
messages
视图需要一个 UI。我们使用router-link
定义Messages.vue
视图,以允许在editor
和list
之间进行导航,并使用router-view
组件来渲染嵌套视图:<template> <div> <router-link :to="{ name: 'list', params: { list }}">List</router-link> | <router-link :to="{ name: 'editor', params: { list }}">Editor</router-link> <router-view :list.sync="list"/> </div> </template> <script>
当然,我们需要从
props
接收一个messages
的list
:<script> export default { props: { list: Array } </script>
由于我们没有全局状态或适当的数据库,我们需要在
./src/router/index.js
中模拟一个全局消息列表:const messages = []
然后将它作为默认
props
传递给messages
路由,如下所示:{ path: '/', name: 'messages', /* ... */ props: { list: messages }, }
-
为了捕捉用户是否正在离开当前编辑视图,我们将在组件内的
beforeRouteLeave
导航守卫上添加一个 Hook,这将允许我们显示警告并根据用户的决定中止或继续。这是在MessageEditor.vue
文件中完成的:beforeRouteLeave(to, from, next) { if (this.$refs.textArea.value !== '') { const ans = window.confirm(You have an unsaved message. Are you sure you want to navigate away?'); next(!!ans); } else { next(); } }
-
创建
messageLayout.vue
很简单,包括标题文本、来自props
的content
和一个返回按钮
:<template> <div class="message"> <h2>Message content:</h2> <main> <slot/> </main> <button @click="goBack">Back</button> </div> </template>
goBack
逻辑应该是简单的:如果有保存的前一个路由,我们就使用this.$routes.go(-1)
在导航栈中后退一步。否则,我们将使用this.$router.push({ name: 'message'})
将messages
导航路由推送到栈中:<script> import MessageLayout from '../layouts/messageLayout.vue'; export default { props: { content: { type: String, default: '' }, }, methods: { goBack() { if (this.$route.params.from) { this.$router.go(-1) } else { this.$router.push({ name: 'messages' }) } } } } </script>
但然后我们仍然需要从跟踪中传递前一个路由,
this.$route.params.from
,应在路由注册时完成。 -
我们在
routes
中添加message
路由配置,并使用beforeEnter
组件守卫将from
前一个导航路由保存并传递到视图的params
中。此外,由于它是一个具有
message/:id
模式的动态路由,我们需要检索消息内容并将其映射到相关的 prop 上:{ path: '/message/:id', name: 'message', component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'), props:true, beforeEnter(to, from, next) { if (to.params && to.params.id) { const id = to.params.id; if (messages && messages.length > 0 && id < messages.length) { to.params.content = messages[id]; } } to.params.from = from; next() }, }
-
最后,为了从
Message.vue
和Messages.vue
中拆分 UI 布局,在./src/layouts
文件夹中,我们创建了一个default.vue
布局和一个messageLayout.vue
布局。正如我们在本章所学,在
App.vue
中,我们将使用根据布局变量渲染的组件包裹router-view
。当然,router-view
需要一个同步的layout
属性,以便根据当前视图动态更改布局:<template> <div id="app"> <component :is="layout"> <router-view :layout.sync="layout"/> </component> </div> </template> <script> export default { data() { return { layout: () => import(/* webpackChunkName: "defaultLayout" */ './layouts/default.vue') } } } </script>
-
在
default.vue
中,我们只需为messages
部分添加标题文本和一个slot
:<template> <div class="default"> <h1>Messages section</h1> <main> <slot/> </main> </div> </template>
-
在
messageLayout.vue
中,我们将从Message.vue
中提取标题文本和按钮逻辑:<template> <div class="message"> <h2>Message content:</h2> <main> <slot/> </main> <button @click="goBack">Back</button> </div> </template> <script> export default { methods: { goBack() { if (this.$route.params.from) { this.$router.go(-1) } else { this.$router.push({ name: 'messages' }) } } } } </script>
最后一步是确保在组件创建时触发
update:layout
事件以更新Message.vue
和Messages.vue
的视图布局:import MessageLayout from '../layouts/messageLayout.vue'; export default { /*...*/ created() { this.$emit('update:layout', MessageLayout); } }
在
Messages.vue
中,这将如下所示:<script> import DefaultLayout from '../layouts/default'; export default { props: { list: Array }, created() { this.$emit('update:layout', DefaultLayout); } } </script>
-
使用以下命令运行应用程序:
yarn serve
为了确保您已正确完成步骤,您需要访问每个路由并确保内容渲染与相应的图示一致。首先,确保
/list
视图渲染的消息列表如图 6.47 所示:![图 6.47:Messages 应用中的 /list 视图
图 6.47:Messages 应用中的 /list 视图
-
接下来,确保
/editor
视图允许用户发送新消息,如图 6.48 所示:![图 6.48:Messages 应用中的 /editor 视图图 6.48:Messages 应用中的 /editor 视图
-
接下来,确保通过访问
/message/0
路由来确保/message/:id
动态路由正常工作。你应该会看到类似于图 6.49 所示的消息内容:![图 6.49:Message 应用中的 /message/0 视图图 6.49:Message 应用中的 /message/0 视图
-
确保当用户正在编写消息时,如果他们尝试在没有保存消息的情况下离开,将触发一个警告,如图 6.50 所示:![图 6.50:用户尝试在没有保存消息的情况下离开时的 /editor 视图
图 6.50:用户尝试在没有保存消息的情况下离开时的 /editor 视图
注意
由于我们没有全局状态管理,我们的 messages
数据在刷新时不会保存。我们可以在探索应用程序时使用 localStorage
来帮助保存数据。
在此活动中,我们将本章涵盖的几个主题组合在一起,包括设置视图、使用模板和动态路由,以及使用 Hooks 在用户在未保存内容的情况下离开前提示确认警告。这些工具可用于许多常见的 SPA 用例,并将有助于您未来的项目。
7. 动画和过渡
活动七.01:使用过渡和 GSAP 构建 Messages 应用
解决方案:
执行以下步骤以完成活动:
注意
要访问此活动的代码文件,请访问 packt.live/399tZ3Y
。
-
我们将重用第六章中创建的 路由 代码,以便为
Message
应用设置所有路由。src/views/MessageEditor.vue
的template
部分将如下所示:<template> <div> <textarea ref="textArea" @change="onChange($event)" > </textarea> <button @click="onSendClick()">Submit</button> </div> </template>
-
接下来,
src/views/MessageEditor.vue
的script
部分应包含点击和离开路由的逻辑:<script> export default { props: { list: Array }, data() { return { message: '' } }, methods: { onChange(event) { this.message = event.target.value }, onSendClick() { if (!this.message) return; this.list.push(this.message); this.$emit('list:update', this.list); this.message = '' this.$refs.textArea.value = '' } }, beforeRouteLeave(to, from, next) { if (this.$refs.textArea.value !== '') { const ans = window.confirm('You have unsaved message. Are you sure to navigate away?'); next(ans); } else { next(); } } } </script>
-
接下来,我们需要
MessageList.vue
的template
代码。代码如下:<template> <div> <h2> Message Feed </h2> <transition-group @appear="enter" tag="div" move-class="flip" :css="false" > <div v-for="(m, i) in list" :key="m"> <router-link :to="`/message/${i}`"> {{ i }} </router-link> </div> </transition-group> </div> </template>
-
接下来,我们需要在
MessageList.vue
文件中添加一个script
部分。要添加script
部分,代码如下:<script> import { TimelineMax } from 'gsap'; export default { props: { list: { type: Array, default: () => [] } }, methods: { enter(el, done) { const tl = new TimelineMax({ onComplete: done, stagger: 1.2, duration: 2, }); tl.fromTo(el, {opacity: 0}, {opacity: 1}) .to(el, {rotation: -270, duration: 1, ease: "bounce"}) .to(el, {rotation: -360}) .to(el, {rotation: -180, opacity: 0}) .to(el, {rotation: 0, opacity: 1}); } } } </script>
-
我们还将在
MessageList.vue
中创建一个style
部分,并使用以下代码定义.flip-move
类:<style> .flip-move { transition: transform 1s; } </style>
-
Message.vue
应该在p
元素内包含渲染的内容。我们还将定义content
属性并发出更新信号:<template> <div> <p>{{content}}</p> <router-view/> </div> </template> <script> import MessageLayout from '../layouts/messageLayout.vue'; export default { props: { content: { type: String, default: '' } }, created() { this.$emit('update:layout', MessageLayout); } } </script>
-
确保你的
src/router/index.js
文件与在 第六章,路由 中创建的相同,创建具有动态嵌套路由和布局的消息 SPA,该章节可在packt.live/2ISxml7
找到:import Vue from 'vue' import VueRouter from 'vue-router' import Messages from '@/views/Messages.vue' Vue.use(VueRouter) const messages = [] export const routes = [ { path: '/', name: 'messages', component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue'), props: { list: messages }, children: [{ path: 'list', name: 'list', component: () => import(/* webpackChunkName: "list" */ '../views/MessageList.vue'), props: true, }, { path: 'editor', name: 'editor', component: () => import(/* webpackChunkName: "list" */ '../views/MessageEditor.vue'), props: true, }] }, { path: '/message/:id', name: 'message', component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'), props:true, beforeEnter(to, from, next) { if (to.params && to.params.id) { const id = to.params.id; if (messages && messages.length > 0 && id < messages.length) { to.params.content = messages[id]; } } to.params.from = from; next() }, } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
-
现在,我们将使用具有两个属性
name="fade"
和mode="out-in"
的transition
组件包裹App.vue
中的<template>
部分的router-view
:<component :is="layout"> <transition name="fade" mode="out-in"> <router-view :layout.sync="layout"/> </transition> </component> Create CSS stylings for the related classes, inside App.vue: <style> .fade-enter-active, .fade-leave-active { transition: opacity 2s, transform 3s; } .fade-enter, .fade-leave-to { opacity: 0; transform: translateX(-20%); } </style>
-
在
src/views/Messages.vue
中,我们将使用transition
组件包裹router-view
。这次,我们将使用一个自定义的enter-active-class
过渡类属性,以及fade
名称:<transition name="fade" enter-active-class="zoom-in"> <router-view :list.sync="list"/> </transition>
-
在
src/views/Messages.vue
的style
部分中添加zoom-in
和fade-enter
动画效果:<style> .zoom-in { animation-duration: 0.3s; animation-fill-mode: both; animation-name: zoom; } .fade-enter-active { transition: opacity 2s, transform 3s; } .fade-enter { opacity: 0; transform: translateX(-20%); } </style>
-
在
src/views/MessageList.vue
中,将transition-group
作为消息链接列表的包装器,并使用 JavaScript 钩子进行程序化动画。但我们必须指定在页面初始渲染时的过渡,因为列表应该在出现时进行动画。我们将添加appear
属性并将enter
绑定到appear
,以及添加move-class
翻转(将在style
部分稍后创建的动画):<transition-group appear @appear="enter" tag="div" move-class="flip" :css="false" > <div v-for="(m, i) in list" :key="m"> <router-link :to="`/message/${i}`"> {{ i }} </router-link> </div> </transition-group>
-
将 GSAP 作为依赖项添加,并在
src/views/MessageList.vue
中的appear
过渡事件处理程序(钩子)上实现弹跳进入效果:<script> import { TimelineMax } from 'gsap'; export default { props: { list: { type: Array, default: () => [] } }, methods: { enter(el, done) { const tl = new TimelineMax({ onComplete: done, stagger: 1.2, duration: 2, }); tl.fromTo(el, {opacity: 0}, {opacity: 1}) .to(el, {rotation: -270, duration: 1, ease: "bounce"}) .to(el, {rotation: -360}) .to(el, {rotation: -180, opacity: 0}) .to(el, {rotation: 0, opacity: 1}); } } } </script>
-
接下来,我们需要创建我们在 HTML 中定义的
flip-move
类。我们将通过添加一个包含我们的新flip-move
类的style
部分来完成此操作:<style> .flip-move { transition: transform 1s; } </style>
-
使用
yarn serve
命令运行应用程序,你应该在浏览器中的localhost:8080
看到以下内容:
图 7.19:从消息列表视图导航到编辑视图时淡出
现在,你应该在从消息列表视图导航到编辑视图时看到淡出效果,如 图 7.19 所示,以及从编辑视图导航到列表视图时的淡出效果,如 图 7.20 所示:
图 7.20:从编辑视图导航到消息列表视图时淡出
当消息在动态中时,你应该看到翻转动作期间的弹跳效果,如 图 7.21 所示:
图 7.21:在消息列表视图中显示消息动态时的弹跳效果
最后,当点击列表中的特定消息时,它应该渲染如 图 7.22 所示的内容:
图 7.22:单个消息视图
在这个活动中,我们将几个不同的动画组合起来,并与路由结合以创建自定义页面过渡。我们使用了多种不同的动画类型来展示动画可以提供的多种可能性。
8. Vue.js 状态管理的状态
活动第 8.01 节:将邮箱和电话号码添加到个人资料卡片生成器
解决方案:
执行以下步骤以完成活动:
注意
要访问此活动的代码文件,请参阅packt.live/3m1swQE
。
-
我们可以从向
src/components/AppProfileForm
添加一个新的email
输入字段和标签开始,用于Email
字段:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="email">Email </label> <input id="email" type="email" name="email" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template>
-
然后,我们可以在
AppProfileForm
中添加一个新的phone
输入字段(类型为tel
)和标签,用于Phone Number
字段:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="phone">Phone Number</label> <input id="phone" type="tel" name="phone" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template>
新字段如下所示:
图 8.35:包含新邮箱和电话号码字段的示例应用
-
然后,我们可以在
src/store.js
中的初始状态和变异中添加email
和phone
字段,以便organization
在初始化时设置,在profileUpdate
时设置,并在profileClear
时重置:// imports & Vuex setup export default new Vuex.Store({ state: { formData: { // rest of formData fields email: '', phone: '', } }, mutations: { profileUpdate(state, payload) { state.formData = { // rest of formData fields email: payload.email || '', phone: payload.phone || '', } }, profileClear(state) { state.formData = { // rest of formData fields email: '', phone: '', } } } })
-
我们需要在
src/components/AppProfileForm.vue
组件的本地状态中使用v-model
跟踪email
,并在data()
函数中初始化它:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="email">Email </label> <input id="email" type="email" name="email" v-model="email" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template> <script> export default { // rest of component data() { return { // other data properties email: '' } } } </script>
-
我们需要在
src/components/AppProfileForm.vue
组件的本地状态中使用v-model
跟踪phone
,并在data()
函数中初始化它:<template> <!-- rest of template --> <div class="flex flex-col mt-2"> <label class="flex text-gray-800 mb-2" for="phone">Phone Number</label> <input id="phone" type="tel" name="phone" v-model="phone" class="border-2 border-solid border-blue-200 rounded px-2 py-1" /> </div> <!-- rest of template --> </template> <script> export default { // rest of component data() { return { // other data properties phone: '' } } } </script>
-
为了让变异的负载包含
email
和phone
,我们需要将其添加到$store.commit('profileUpdate')
的负载中。我们还想在组件触发profileClear
变异时在表单上重置它:<script> export default { // rest of component methods: { submitForm() { this.$store.commit('profileUpdate', { // rest of payload email: this.email, phone: this.phone }) }, resetProfileForm() { // other resets this.email = '' this.phone = '' } } } </script>
-
为了使
email
显示,我们需要在src/components/AppProfileDisplay.vue
中使用条件段落(在没有设置邮箱时隐藏Email
标签)来渲染它:<template> <!-- rest of template --> <p class="mt-2" v-if="formData.email"> Email: {{ formData.email }} </p> <!-- rest of template --> </template>
-
为了使
phone
显示,我们需要在src/components/AppProfileDisplay.vue
中使用条件 span(在没有设置电话时隐藏Phone Number
标签)来渲染它:<template> <!-- rest of template --> <p class="mt-2" v-if="formData.phone"> Phone Number: {{ formData.phone }} </p> <!-- rest of template --> </template>
当表单填写并提交时,应用应如下所示:
图 8.36:包含邮箱和电话号码字段的示例应用
我们已经看到了如何向 Vuex 管理的应用中添加新字段。接下来,我们将看到如何决定是否将某些内容放入全局或局部状态。
9. 使用 Vuex – 状态、获取器、操作和变异
活动第 9.01 节:创建简单的购物车和价格计算器
解决方案:
执行以下步骤以完成活动:
注意
要访问此活动的代码文件,请参阅packt.live/2KpvBvQ
。
-
使用 CLI 创建一个新的具有 Vuex 支持的 Vue 应用。
-
将产品和空
cart
添加到位于store/index.js
的存储中。请注意,产品名称和价格是任意的:state: { products: [ { name: "Widgets", price: 10 }, { name: "Doodads", price: 8 }, { name: "Roundtuits", price: 12 }, { name: "Fluff", price: 4 }, { name: "Goobers", price: 7 } ], cart: [ ]
-
创建一个新的
Products
组件 (components/Products.vue
),它遍历每个产品并包括每个产品的名称和价格。它还将包括添加或从购物车中删除项目的按钮:<h2>Products</h2> <table> <thead> <tr> <th>Name</th> <th>Price</th> <th> </th> </tr> </thead> <tbody> <tr v-for="(product, idx) in products" :key="idx"> <td>{{ product.name }}</td> <td>{{ product.price }}</td> <td> <button @click="addToCart(product)">Add to Cart </button> <button @click="removeFromCart(product)">Remove from Cart</button> </td> </tr> </tbody> </table>
-
为了在不添加
$store
前缀的情况下使用产品,包括mapState
并在Products
组件的computed
属性中定义其使用:import { mapState } from 'vuex'; export default { name: 'Products', computed: mapState(['products']),
-
接下来包括添加和从购物车中删除项目的函数。这只会调用存储中的 mutations:
methods: { addToCart(product) { this.$store.commit('addToCart', product); }, removeFromCart(product) { this.$store.commit('removeFromCart', product); } }
-
在
store/index.js
文件中定义您的 mutations 来处理与购物车的工作。当向购物车添加新项目时,您首先需要查看该项目是否之前已添加,如果是,则简单地增加数量。当从购物车中删除项目时,如果数量达到 0,则应完全删除该项目:mutations: { addToCart(state, product) { let index = state.cart.findIndex(p => p.name === product.name); if(index !== -1) { state.cart[index].quantity++; } else { state.cart.push({ name: product.name, quantity: 1}); } }, removeFromCart(state, product) { let index = state.cart.findIndex(p => p.name === product.name); if(index !== -1) { state.cart[index].quantity--; if(state.cart[index].quantity === 0) state.cart.splice (index, 1); } }
-
定义一个
Cart
组件 (components/Cart.vue
),它遍历购物车并显示每个项目的数量:<h2>Cart</h2> <table> <thead> <tr> <th>Name</th> <th>Quantity</th> </tr> </thead> <tbody> <tr v-for="(product, idx) in cart" :key="idx"> <td>{{ product.name }}</td> <td>{{ product.quantity }}</td> </tr> </tbody> </table>
-
与前面的组件一样,添加
mapState
并将购物车别名为:import { mapState } from 'vuex'; export default { name: 'Cart', computed: mapState(['cart']) }
-
定义最终的组件
Checkout
(components/Checkout.vue
),并显示一个名为cartTotal
的属性。这将通过在存储中创建的 getter 来定义:<h2>Checkout</h2> Your total is ${{ cartTotal }}.
-
在脚本块中映射 getter:
import { mapGetters } from 'vuex'; export default { name: 'Cart', computed: mapGetters(['cartTotal']),
-
添加一个结账按钮。它应该只在存在总计时显示,并运行名为
checkout
的方法:<button v-show="cartTotal > 0" @click="checkout">Checkout </button>
-
定义
checkout
以简单地提醒用户:methods: { checkout() { alert('Checkout process!'); } }
-
在 Vuex 存储中,定义
cartTotal
的 getter。它需要遍历购物车并通过将价格乘以数量来确定总和:getters: { cartTotal(state) { return state.cart.reduce((total, item) => { let product = state.products.find(p => p.name === item.name); return total + (product.price * item.quantity); }, 0); } },
-
在主
App.vue
组件中使用所有三个组件:<template> <div id="app"> <Products /> <Cart /> <Checkout /> </div> </template> <script> import Products from './components/Products.vue' import Cart from './components/Cart.vue' import Checkout from './components/Checkout.vue' export default { name: 'app', components: { Products, Cart, Checkout } } </script>
按照之前的方式启动您的应用程序 (
npm run serve
) 并在浏览器中打开 URL。最初,您应该看到以下输出,显示一个空购物车:
图 9.9:购物车的初始显示
当您添加和删除项目时,您会看到购物车和总计实时更新:
图 9.10:添加了多个数量项目的购物车
前面的图显示了产品和它们的价格,以及包含多个不同产品数量的购物车和最终的结账金额。您现在已经构建了一个完整的、尽管简单的、由 Vue 和 Vuex 驱动的电子商务购物车产品。
10. 使用 Vuex – 获取远程数据
活动 10.01:使用 Axios 和 Vuex 进行身份验证
解决方案:
执行以下步骤以完成此活动。
注意
要访问此活动的代码文件,请参阅 packt.live/3kVox6M
。
-
使用 CLI 来构建新的应用程序,并确保启用 Vuex 和 Vue Router。完成后,使用
npm
安装Axios
。现在您已经构建了应用程序的框架,让我们开始构建它。首先,打开App.vue
,这是应用程序的核心组件,并修改它,使整个模板成为视图:<template> <div id="app"> <router-view/> </div> </template>
-
默认情况下,CLI 会生成两个
views
:Home
和About
。我们将把About
改成显示猫的视图,但现在打开Home.vue
并添加登录表单。使用按钮运行一个方法来执行(假的)登录:<template> <div> <h2>Login</h2> <form> <div> <label for="username">Username: </label> <input type="text" id="username" v-model="username" required> </div> <div> <label for="password">Password: </label> <input type="password" id="password" v-model="password" required> </div> <div> <input type="submit" @click.prevent="login" value= "Log In"> </div> </form> </div> </template>
-
添加登录表单的
data
和登录按钮的handler
。这将触发对存储的 dispatch。在成功的登录(它总是会成功)后,使用$router.replace
来导航到下一页。这样做是为了代替$router.go
,这样用户就不能通过点击后退按钮返回到登录表单:<script> export default { name: 'home', data() { return { username:'', password:'' } }, methods: { async login() { let response = await this.$store.dispatch('login', { username:this.username, password:this.password }); if(response) { this.$router.replace('cats'); } else { // handle a bad login here.. } } } } </script>
-
现在让我们在
views/Cats.vue
中构建Cats
组件。这个组件将简单地遍历存储中的猫,并对存储进行调用以加载它们:<template> <div> <h2>Cats</h2> <ul> <li v-for="(cat,idx) in cats" :key="idx"> {{cat.name}} is {{cat.gender}} </li> </ul> </div> </template> <script> import { mapState } from 'vuex'; export default { created() { this.$store.dispatch('loadCats'); }, computed: { ...mapState(["cats"]) } } </script>
-
现在通过编辑
store/index.js
来构建 Vuex 存储。首先导入 Vuex 并定义两个端点的常量。记住,我们在这里是模拟一个真实的 API,所以端点只是返回静态的 JSON:import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) import axios from 'axios'; const LOGIN_URL = 'https://api.jsonbin.io/b/ 5debc045bc5ffd04009563cd'; const CATS_URL = 'https://api.jsonbin.io/b/ 5debc16dcb4ac6042075d594';
-
存储需要保留两样东西:认证
token
和cats
。设置state
并为它们定义mutations
:export default new Vuex.Store({ state: { token:'', cats:[] }, mutations: { setCats(state, cats) { state.cats = cats; }, setToken(state, t) { state.token = t; } },
-
现在添加
actions
。登录action
将结果存储为token
,而cats
action
将token
作为authorization
标头传递:actions: { loadCats(context) { axios.get(CATS_URL, { headers: { 'Authorization': 'bearer '+context.state.token } }) .then(res => { context.commit('setCats', res.data); }) .catch(error => { console.error(error); }); }, async login(context, credentials) { return axios.get(LOGIN_URL, { params:{ username: credentials.username, password: credentials.password } }) .then(res => { context.commit('setToken', res.data.token); return true; }) .catch(error => { console.error(error); }); } } })
-
应用程序的最后一部分是路由器,它有一个相当有趣的特点。想想
cats
页面。如果用户首先访问该页面会发生什么?没有token
,对端点的调用将无法返回有效数据。(再次,在一个真实的服务器上是这样。)幸运的是,Vue Router 提供了一种非常简单的方式来处理这种情况——路由守卫。在cats
路由中利用beforeEnter
来处理这个调用。编辑你的router/index.js
文件,使其看起来像以下代码:import Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' import store from '../store'; Vue.use(VueRouter) const routes = [ { path: '/', name: 'home', component: Home }, { path: '/cats', name: 'cats', component: () => import(/* webpackChunkName: "cats" */ '../views/Cats.vue'), beforeEnter: (to, from, next) => { if(!store.state.token) { next('/'); } next(); } } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
-
使用
npm run serve
启动应用程序,将 URL 复制到浏览器中,你应该会看到以下输出:![图 10.8:初始登录屏幕
图 10.8:初始登录屏幕
登录后,你会看到以下截图所示的数据显示:
![图 10.9:登录成功后成功显示数据
图 10.9:登录成功后成功显示数据
在这个活动中,你看到了使用 Vuex 和 Axios
时认证系统会是什么样子。虽然后端是假的,但这里使用的代码可以很容易地连接到一个真实的认证系统。
11. 使用 Vuex – 组织更大的存储
活动 11.01:简化 Vuex 存储
解决方案:
执行以下步骤来完成活动。
注意
要访问此活动的初始代码文件,请访问 packt.live/3kaqBHH
。
-
首先创建一个新文件,
src/store/state.js
,它将存储除了cat
对象之外的所有状态值:export default { name:'Lindy', job:'tank', favoriteColor:'blue', favoriteAnimal:'cat' }
-
创建一个新文件,
src/store/getters.js
,并将desiredPet
的 getter 移入其中:export default { desiredPet(state) { return state.favoriteColor + ' ' + state.favoriteAnimal; } }
-
接下来,创建
src/store/mutations.js
并复制与猫名称无关的mutations
:export default { setName(state, name) { state.name = name; }, setJob(state, job) { state.job = job; }, setFavoriteColor(state, color) { state.color = color; }, setFavoriteAnimal(state, animal) { state.animal = animal; } }
-
更新存储文件 (
src/store/index.js
) 以导入新文件:import state from './state.js'; import getters from './getters.js'; import mutations from './mutations.js';
-
编辑现有的
state
、mutations
和getters
块以使用包含的值:export default new Vuex.Store({ state, getters, mutations,
-
现在将猫相关值移动到存储的
modules
块中。创建一个state
、getters
和mutations
块,并将所有值移动过来,更新它们以引用状态值,而不是state.cat
:cat: { state: { name:'Cracker', gender:'male', job:'annoyer' }, getters: { petDescription(state) { return state.name + ' is a ' + state.gender + ' ' + state.job + ' cat.'; } }, mutations: { setCatName(state, name) { state.name = name; }, setCatGender(state, gender) { state.gender = gender; }, setCatJob(state, job) { state.job = job; } } }
-
运行应用程序并确认
App.vue
组件继续按预期工作。您的输出将如下所示:
图 11.4:活动的最终输出
现在,Vuex 存储已被修改得更加易于接近、编辑和未来调试。要访问此活动的解决方案,请访问 packt.live/3l4Lg0x
。
12. 单元测试
活动 12.01:添加带有测试的简单标题搜索页面
解决方案:
执行以下步骤来完成活动:
注意
要访问此活动的代码文件,请参阅 packt.live/2UVF28c
。
-
在
src/components/SearchForm.vue
的新文件中创建一个带有输入和按钮的搜索表单:<template> <form class="flex flex-row m-auto mb-10"> <input placholder="Search" class="bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded py-2 px-4 flex appearance-none leading-normal" type="text" /> <button type="submit" class="flex bg-blue-500 hover:bg-blue-700 text-white font-semibold font-sm hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded" > Search </button> </form> </template>
-
现在,我们将通过导入、注册并在
src/App.vue
中渲染来使表单显示:<template> <!-- rest of template --> <div class="flex flex-col"> <SearchForm /> <!-- rest of template --> </div> <!-- rest of template --> </template>
-
我们现在准备好为搜索表单添加快照测试。在
__tests__/SearchForm.test.js
中,我们应该添加SearchForm should match expected HTML
:import {render} from '@testing-library/vue' import SearchForm from '../src/components/SearchForm.vue' test('SearchForm should match expected HTML', () => { const {html} = render(SearchForm) expect(html()).toMatchSnapshot() })
-
我们希望使用
v-model
跟踪搜索表单输入的内容,以双向绑定searchTerm
实例变量和输入内容:<template> <!-- rest of template --> <input v-model="searchTerm" placholder="Search" class="bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded py-2 px-4 flex appearance-none leading-normal" type="text" /> <!-- rest of template --> </template> <script> export default { data() { return { searchTerm: '' } } } </script>
-
当提交搜索表单时,我们需要使用
this.$router.push()
更新 URL 中的正确参数。这可以通过q
查询参数存储搜索来完成。<template> <form @submit="onSubmit()" class="flex flex-row m-auto mb-10" > <!-- rest of template --> </form> </template> <script> export default { // other properties methods: { onSubmit() { this.$router.push({ path: '/' query: { q: this.searchTerm } }) } } } </script>
-
我们希望将搜索表单中的
q
查询参数的状态反映在搜索表单输入中。从this.$route.query
中读取q
并将其设置为SearchForm
组件状态中searchTerm
数据字段的初始值:<script> export default { data() { return { searchTerm: this.$route.query.q || '' } }, // other properties } </script>
-
接下来,我们将想要过滤传递给主页上
PostList
的帖子。我们将使用this.$route.query.q
在一个计算属性中过滤按标题排序的帖子。这个新的计算属性将随后用于替代src/App.vue
中的posts
:<template> <!-- rest of template --> <router-view :posts="relevantPosts" /> <!-- rest of template --> </template> <script> export default { // other properties computed: { relevantPosts() { const { q } = this.$route.query if (!q) { return this.posts } return this.posts.filter( p => p.title.toLowerCase().includes(q.toLowerCase()) ) } } } </script>
-
接下来,我们应该添加一个测试,更改搜索查询参数并检查应用程序显示正确的结果。为此,我们可以导入
src/App.vue
、src/store.js
和src/router.js
,并使用存储和路由渲染应用程序。然后,我们可以通过使用字段的占位符为Search
来更新搜索字段的内容。最后,我们可以通过点击具有test id
为Search
的元素来提交表单(这是搜索按钮):// imports and other tests test('SearchForm filter by keyword on submission', async () => { const {getByPlaceholderText, getByText, queryByText} = render(App, { router, store }) expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeTruthy() expect(queryByText('Vue.js for React developers')). toBeTruthy() await fireEvent.update(getByPlaceholderText('Search'), 'react') await fireEvent.click(getByText('Search')) expect(queryByText('Vue.js for React developers')). toBeTruthy() expect(queryByText('Migrating an AngularJS app to Vue.js')). toBeFalsy() })
我们现在处于一个通过测试的状态。以下截图显示了这一点:
图 12.29:路由测试通过
我们还得到了一个能够按搜索词过滤的应用程序,如下所示:
![图 12.30:搜索“react”过滤与该搜索词相关的帖子]
![img/B15218_12_30.jpg]
图 12.30:搜索“react”过滤与该搜索词相关的帖子
我们已经看到了如何创建和测试具有多个页面、Vuex 和一系列组件的 Vue.js 应用程序。
13. 端到端测试
活动第 13.01 节:添加设置用户电子邮件和测试的功能
解决方案:
执行以下步骤以完成活动:
注意
要访问此活动的代码文件,请参阅packt.live/2IZP4To
。
-
为了跟踪电子邮件,我们将在
data()
中将它设置为一个响应式状态,并在页面上添加一个电子邮件类型输入,它将使用v-model
与email
双向绑定。我们还添加了一个标签和相应的标记。请注意,我们将在电子邮件输入上设置一个data-test-id
属性,设置为"email-input"
:<template> <div id="app" class="p-10"> <div class="flex flex-col"> <!-- rest of template --> <div class="flex flex-col mx-auto mb-4"> <label class="flex text-gray-700 text-sm font-bold mb-2" for="email" > Enter your email: </label> <input v-model="email" id="email" type="email" data-test-id="email-input" class="flex shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required /> </div> <!-- rest of template --> </template> <script> // imports export default { data() { return { email: '', // other data properties } }, // other component properties } </script>
-
我们现在将添加一个
beforeEach
钩子来设置 Cypress 模拟服务器并模拟GET
评论(列表)请求。评论列表请求应该被别名为getComments
:describe('Email input', () => { beforeEach(() => { cy.server() cy.route('GET', '**/comments', []).as('getComments') }) })
-
我们将添加第一个测试,检查将文本输入到电子邮件输入框是否正确工作。我们将进入应用程序,输入电子邮件,并检查我们输入的内容现在是否是输入值:
describe('Email input', () => { // setup it('email input should work', () => { cy.visit('/') cy.get('[data-test-id="email-input"]') .type('hugo@example.tld') .should('have.value', 'hugo@example.tld') }) })
当使用 Cypress UI 运行时,我们得到以下通过测试:
![图 13.24:Cypress 运行“enter-email”测试,带有电子邮件输入测试]
![img/B15218_13_24.jpg]
图 13.24:Cypress 运行“enter-email”测试,带有电子邮件输入测试
-
email
属性的存在是添加评论的先决条件,因此当email
为空时(!email
),我们将禁用添加新评论
按钮。我们将绑定到disabled
属性并根据email
字段是否填充切换一些类:<template> <div id="app" class="p-10"> <div class="flex flex-col"> <!-- rest of template --> <button @click="showEditor = !showEditor" class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" data-test-id="new-comment-button" :disabled="!email" :class="{ 'opacity-50 cursor-not-allowed' : !email }" > Add a New Comment </button> <!-- rest of template --> </div> </div> </template>
-
使用这个新的
当电子邮件为空时禁用添加新评论按钮
功能,我们应该添加一个新的端到端测试。我们将加载页面,并在初始加载时检查email
输入框为空,并且新评论
按钮被禁用。然后我们将在电子邮件输入框中输入电子邮件,并检查新评论
按钮现在不是禁用的,这意味着它已被启用:describe('Email input', () => { // setup & other tests it('add comment button should be disabled when no email', () => { cy.visit('/') cy.get('[data-test-id="email-input"]') .should('have.value', '') cy.get('[data-test-id="new-comment-button"]') .should('be.disabled') cy.get('[data-test-id="email-input"]') .type('hugo@example.tld') cy.get('[data-test-id="new-comment-button"]') .should('not.be.disabled') }) })
更新后的测试运行输出如下:
![图 13.25:Cypress 运行“enter-email”测试,带有禁用的]
添加评论按钮测试
![img/B15218_13_25.jpg]
图 13.25:Cypress 运行“enter-email”测试,带有禁用的添加评论按钮测试
-
现在我们有了捕获电子邮件的方法,我们应该在提交新评论的 POST 调用(即提交新评论时)将其传递给后端 API。为了做到这一点,我们应该修改
methods.submitNewComment
中email
被硬编码为evan@vuejs.org
的位置:<script> // imports export default { // other component properties methods: { submitNewComment() { // rest of method fetch('https://jsonplaceholder.typicode.com/comments', { // other fetch options body: JSON.stringify({ email: this.email, body: this.newComment }) }).then(res => res.json()) // rest of promise chain } } } </script>
-
现在我们正在使用用户输入的电子邮件,我们应该编写一个端到端测试来检查它是否被发送。我们将模拟
POST
请求,将其别名为newComment
,并返回一个任意值。然后我们可以访问页面,填写电子邮件输入,打开评论编辑器,填写内容,并提交。然后我们将等待newComment
请求,并断言请求体中的内容和电子邮件与我们完成时相同:describe('Email input', () => { // setup & other tests it('when adding comment, it should be created with the input email', () => { cy.route('POST', '**/comments', { body: 'My new comment', email: 'hugo@example.tld' }).as('newComment') cy.visit('/') cy.get('[data-test-id="email-input"]') .type('hugo@example.tld') cy.get('[data-test-id="new-comment-button"]') .should('not.be.disabled') .click() cy.get('[data-test-id="new-comment-editor"]') .type('My new comment') cy.get('[data-test-id="new-comment-submit"]') .should('not.be.disabled') .click() cy.wait('@newComment') .its('request.body') .should('deep.equal', { body: 'My new comment', email: 'hugo@example.tld' }) }) })
当使用 Cypress UI 运行时,我们得到以下测试运行输出:
图 13.26:Cypress 运行 "enter-email" 测试,包含电子邮件输入测试
我们现在已经看到了如何有效地使用 Cypress 构建和测试(使用端到端测试)Vue.js 应用程序。
14. 将您的代码部署到网络
活动 14.01:将 GitLab CI/CD 添加到图书搜索应用程序并部署到 Amazon S3 和 CloudFront
解决方案
执行以下步骤以完成活动:
注意
要访问此活动的代码文件,请参阅 packt.live/36ZecBT
。
-
首先,我们希望在本地运行一个生产构建。我们可以使用用于构建所有 Vue CLI 项目的常规命令。我们还想检查相关的资产(JavaScript、CSS 和 HTML)是否正确生成。
生产构建命令是
npm run build
,如下截图所示:图 14.65:初始 book-search Vue CLI 项目的 npm run build 输出
npm run build
命令构建一个包含以下内容的dist
目录,如下截图所示。它包含CSS
、JavaScript
和HTML
资产,以及sourcemaps
(.js.map
文件)和favicon
:图 14.66:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令生成)
-
为了运行 GitLab CI/CD,我们需要一个
.gitlab-ci.yml
文件。我们将在.gitlab-ci.yml
中添加一个作业,在build
阶段运行 Node.js LTS Docker 容器中的包安装和生产构建。我们还将确保缓存生产构建的输出:build: image: node:lts stage: build script: - npm ci - npm run build cache: key: $CI_COMMIT_REF_SLUG paths: - dist artifacts: expire_in: 1 week paths: - dist
一旦我们使用
git add .gitlab-ci.yml
并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的build
作业:图 14.67:正在运行的 GitLab CI/CD 管道,构建作业正在运行
以下截图显示了 GitLab CI/CD 管道,
build
作业已成功完成:图 14.68:GitLab CI/CD 管道,构建任务已通过
-
接下来,我们希望在 GitLab CI/CD 的
test
阶段添加一个代码质量任务(通过更新.gitlab-ci.yml
)。我们将该任务命名为lint
,它将运行依赖项的安装以及通过 Vue CLI 进行代码检查:# other jobs lint: image: node:lts stage: test script: - npm ci - npm run lint
一旦我们使用
git add .gitlab-ci.yml
并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的lint
任务:图 14.69:GitLab CI/CD 管道,lint 任务正在运行
以下截图显示了 GitLab CI/CD 管道,
lint
任务成功完成:图 14.70:GitLab CI/CD 管道,lint 任务已通过
-
为了部署我们的应用程序,我们需要使用 S3 控制台创建一个启用公共访问的
vue-workshop-book-search
S3 存储桶。S3 存储桶创建页面应如图下截图所示:
图 14.71:S3 存储桶创建页面,输入
vue-workshop-book-search
作为存储桶名称图 14.72 显示了 S3 存储桶创建页面,具有公共访问权限和免责声明信息:
图 14.72:S3 存储桶创建页面,启用公共访问并接受相关免责声明
-
要通过网页访问 S3 存储桶内容,我们还需要将其配置为 Web 服务器。我们可以通过 S3 控制台配置 Web 服务器属性。
应按以下方式配置,将索引和错误页面设置为
index.html
:图 14.73:S3 存储桶属性页面,已启用 Web 服务器并配置了索引和错误页面为 index.html
-
为了让 GitLab CI/CD 能够在 S3 上创建和更新文件,我们需要将相关的 AWS 密钥添加到我们的 GitLab 仓库 CI/CD 设置中。这些密钥可以在 AWS 管理控制台的
用户名
下拉菜单 |我的安全凭证
|访问密钥
(访问密钥 ID 和秘密访问密钥)|创建新访问密钥
(或选择一个密钥进行重用)。以下截图显示了CI/CD 设置
页面:图 14.74:GitLab CI/CD 设置页面,变量部分已打开
一旦点击
变量
部分的展开
按钮,我们添加相关的 AWS 环境变量:AWS_ACCESS_KEY_ID
、AWS_DEFAULT_REGION
和AWS_SECRET_ACCESS_KEY
。然后变量
部分将如下所示:图 14.75:带有所需 AWS 环境变量(值被屏蔽)的 GitLab CI/CD 设置页面
-
接下来,我们希望在 GitLab CI/CD 的
deploy
阶段添加一个deploy
作业(通过更新.gitlab-ci.yml
)。我们将作业命名为deploy
;它需要下载awscli
pip
包(Python 包管理器),这意味着最有意义的 Docker 镜像就是python:latest
。deploy
作业将从缓存中加载构建的生产版本,使用pip
安装awscli
,并运行aws s3 sync <build_directory> s3://<s3-bucket-name> --acl=public-read
:# other jobs deploy: image: python:latest stage: deploy cache: key: $CI_COMMIT_REF_SLUG paths: - dist before_script: - pip install awscli script: - aws s3 sync ./dist s3://vue-workshop-book-search --acl=public-read
一旦我们使用
git add .gitlab-ci.yml
提交并推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的deploy
作业:图 14.76:正在运行的 GitLab CI/CD 管道中的 deploy 作业
图 14.77显示了成功完成的
deploy
作业的 GitLab CI/CD 管道:图 14.77:通过通过的
deploy
作业的 GitLab CI/CD 管道一旦管道完成,我们的应用程序应该可以通过 S3 网络端点访问,如下面的截图所示:
图 14.78:通过 S3 网络端点 URL 访问的图书搜索
-
最后,我们将创建一个充当 S3 网络端点 CDN 的 CloudFront 分发。我们希望将
origin
设置为 S3 存储桶网络端点的源,并确保我们已启用将 HTTP 重定向到 HTTPS
: -
图 14.79:将源域名设置为 S3 存储桶的 CloudFront 分发创建页面
一旦 CloudFront 分发部署完成,我们的应用程序应该可以通过 CloudFront 分发的域名访问,如下面的截图所示:
图 14.80:通过 CloudFront 域名访问的图书搜索,显示 harry potter 查询的结果
通过使用 GitLab CI/CD,我们已将 CI/CD 添加到现有的 Vue CLI 项目中。然后我们使用 CloudFront 作为我们的 CDN 将其部署到 S3。