Vue3-前端项目-全-
Vue3 前端项目(全)
原文:
zh.annas-archive.org/md5/47dfd5e6e269be27634520147d259abf译者:飞龙
前言
你是否想使用 Vue.js 3 进行 Web 应用程序开发,但不知道从何开始?使用 Vue.js 3 进行前端开发项目将帮助你构建开发工具包,为解决现实世界的 Web 项目做好准备,通过实际示例和活动帮助你掌握这个 JavaScript 框架的核心概念。
在本书中,你将参与小型项目,包括聊天界面、购物车和价格计算器、待办事项应用和一个用于存储联系信息的个人资料卡片生成器。这些真实的项目以小规模练习和活动的方式呈现,让你在愉快且可行的方式中挑战自己。
在这里,你将了解如何在 Vue 组件中处理数据,定义组件间的通信接口,以及处理静态和动态路由以控制应用程序流程。你还将使用 Vite 和 Vue Devtools,并学习如何处理过渡和动画效果以创建引人入胜的用户体验。稍后,你将了解如何测试你的应用程序并将其部署到网络上。
在本书结束时,你将获得像经验丰富的 Vue 开发者一样工作的技能,构建其他人可以使用的专业应用程序,并拥有解决现实世界前端开发问题的信心。
本书面向对象
本书是为 Vue.js 初学者设计的。无论这是你的第一个 JavaScript 框架,还是你已经熟悉 React 或 Angular,本书都将帮助你走上正确的道路。为了理解本书中解释的概念,你必须熟悉 HTML、CSS、JavaScript 和 Node 包管理。
本书涵盖内容
第一章, 开始你的第一个 Vue 项目,帮助你理解 Vue.js 的关键概念和优势,如何使用终端(或命令行)设置项目架构,以及如何根据组件基础创建一个简单的 Vue 组件,并使用本地数据。
第二章, 与数据协同工作,使你能够监控、管理和操作 Vue.js 组件中的各种来源的数据。你将学习如何通过计算属性利用 Vue 强大的数据响应性和缓存系统,以及如何设置高级监视器来观察组件的数据变化。
第三章, Vite 和 Vue Devtools,向你介绍 Vite,并展示如何使用 Vue Devtools 调试这些计算属性和事件。
第四章, 组件嵌套(模块化),帮助你了解如何使用组件层次和嵌套来模块化 Vue 应用程序。本章介绍了诸如 props、事件、属性验证和插槽等概念。它还涵盖了如何使用 refs 在运行时访问 DOM 元素。
第五章, 组合式 API,教您如何使用setup()方法编写隔离的可复用组件(或自定义钩子),以及如何构建一个超越经典 Options API 的、可扩展的 Vue 项目组件系统。
第六章, 全局组件组合,帮助您使用 mixins 和插件组织代码,实现全局组合,并在任何项目中遵循不要重复自己(DRY)原则以保持代码简洁。您还将了解全局组合的优缺点,从而决定最佳方法以最大化组件的灵活性。
第七章, 路由,指导您了解路由和 Vue Router 的工作原理。您将学习如何使用 Vue Router 在您的应用程序中设置、实现和管理路由系统。
第八章, 动画和过渡,帮助您探索 Vue 过渡的基本知识以及如何创建过渡,包括单元素动画和元素组动画,以及如何将它们与外部库结合以进行进一步定制。您还将学习如何使用过渡路由创建全页动画。
第九章, Vue 状态管理的现状,帮助您了解如何在复杂的 Vue 应用程序中管理状态(数据)。
第十章, 使用 Pinia 进行状态管理,教您如何使用 Pinia 库简化状态管理。
第十一章, 单元测试,向您介绍 Vue 组件的测试。
第十二章, 端到端测试,解释了端到端测试(E2E)以及它与单元测试的区别,以及许多将端到端测试添加到 Vue 项目的示例。
第十三章, 将您的代码部署到 Web 上,帮助您深入了解如何将 Vue 项目真正部署到互联网上。
为了充分利用本书
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
npm) |
Windows, macOS, 或 Linux |
Yarn 包管理器(yarn) |
|
| Visual Studio Code (VS Code) IDE |
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供包含本书中使用的截图和图表的彩色 PDF 文件。您可以从这里下载:packt.link/kefZM。
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个例子:“以下是如何使用this实例的示例:”
代码块应如下设置:
export default {
data() {
return {
yourData: "your data"
}
},
computed: {
yourComputedProperty() {
return `${this.yourData}-computed`;
}
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
// header.vue
<script>
import logo from 'components/logo.vue'
export default {
components: {
logo
}
}
</script>
任何命令行输入或输出都应如下编写:
node -v
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个例子:“我们可以通过检查在点击关闭按钮时是否被调用来实现这一点。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能向我们提供地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您想成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
分享您的想法
读完使用 Vue.js 3 的前端开发项目后,我们很乐意听到您的想法!请选择www.amazon.in/review/create-review/error?asin=1803234997为此书提供反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。从您最喜欢的技术书籍中直接搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容
按照以下简单步骤获取福利:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781803234991
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件
第一部分:简介和快速入门
在这部分,我们将通过构建 Vue 组件和运行实时项目来介绍 Vue 框架。我们将发现使用 Vue 的双向绑定指令语法控制数据是多么容易,理解 Vue 中的事件生命周期和响应性,并变得擅长创建复杂表单。
在本节中,我们将涵盖以下章节:
-
第一章,开始您的第一个 Vue 项目
-
第二章,与数据一起工作
-
第三章,Vite 和 Vue Devtools
第一章:开始你的第一个 Vue 项目
在本章中,你将了解 Vue.js(Vue)的关键概念和优势,如何使用终端(或命令行)设置项目架构,以及如何根据组件基本原理创建一个具有本地数据的简单 Vue 组件。
本章将涵盖以下主题:
-
理解 Vue 作为框架
-
设置 Vite 驱动的 Vue 应用程序
-
探索
data属性作为本地状态 -
使用
<script setup>编写组件 -
理解 Vue 指令
-
使用
v-model启用双向绑定 -
使用
v-for理解数据迭代 -
探索方法
-
理解组件生命周期钩子
-
样式组件
-
理解 CSS 模块
到本章结束时,你将能够描述 Vue 生命周期钩子和表达式的根本,并使用各种样式方法和 HTML 语法风格来熟练地控制 HTML 模板。
技术要求
本章的完整代码可在 GitHub 上找到:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01
理解 Vue 作为框架
行业中的开发者必须快速解决前端开发问题,同时对现有工作流程或后端架构的影响最小。在许多情况下,开发者往往在项目结束时才关注 UI,这可能是由于资源不足、产品需求不断演变或现有的前端是简单部分的态度。
然而,像苹果和谷歌这样的公司已经证明,深入思考前端设计是打造一个能够激发和吸引用户、带来更高投资回报和更成功业务的产品或平台的关键。
如果你了解 Vue,你可能也遇到过其他前端框架,它们表面上解决的是相同的问题,例如 Ember、Angular 或 React。在表面层面上,它们试图使响应式前端开发更加可靠,并引入使开发更简单的模式。然而,与 Angular 或 React 项目相比,Vue 项目可能会有显著的不同。让我们来调查一下。
Angular 与 Vue 的比较
Angular 是由谷歌构建的 模型-视图-视图模型(MVVM)框架,并内置了对 TypeScript 的支持。Angular 生态系统包括 预编译(AoT)渲染、路由器和 CLI 工具。然而,它未能提供全局状态管理的简化系统;开发者需要学习如何使用 Flux 或采用 NgRx。
Vue 继承了 Angular 的核心稳健性,并通过移除对开发者强制代码风格的限制,提供了更好的开发体验。Vue 还简化了常见的 Angular 模式,如 HTML 指令,并消除了 Angular 的各种项目结构,如可注入的、组件、管道、模块等。从 Vue 3.0 开始,它为 TypeScript 和类型提供了出色的支持,而没有 Angular 强制编码风格的缺点。
在许多情况下,Vue 比 Angular 更灵活、开发者友好、高效,并且设置和学习的直接性更强。
接下来,让我们看看 Vue 和 React 之间的区别。
React 与 Vue 的比较
首次发布于 2013 年,由 Meta(之前称为 Facebook)支持,React 迅速在开发者社区中获得了人气。React 引入了JSX 模式,可以直接用 JavaScript 编写 HTML 语法。有了 JSX,React 增加了新开发者需要学习的 JavaScript 和基于组件的架构的知识量。
React 和 Vue 都采用相同的组件驱动开发方法,允许开发者以模块化的方式构建应用程序。每个组件都包含其功能性和生命周期。Vue 将这些模块化编码的核心概念带给开发者,并提供了灵活性,让他们可以选择使用哪种方法来编写组件:JSX 或传统的风格,其中 HTML、CSS 和 JavaScript 是分离的。
Vue 使用单文件组件(SFC)方法来利用这种模块化结构到一个文件中,同时保持对开发者的可读性和可理解性。
使用 Vue 进行项目的优势
Vue 的学习曲线更平缓,生态系统更活跃。这种平缓的学习曲线有助于降低任何团队在将开发者引入新的 Vue 项目时的开销和成本。
Vue 的一个关键优势是其对新老开发者都易于接近:
-
开发者可以立即使用一个经过良好优化和性能出色的框架来构建可扩展的、动态的前端应用程序。
-
SFC 格式模式提供了一个模块化和灵活的蓝图,为开发者提供了愉快的体验。SFC 允许 Vue 真正地灵活多变。你可以实现基本功能,并逐步将静态站点的部分内容集成到 Vue 中,而不是彻底重写整个网站。
与 Redux 和 NgRx 一样强大,Vuex(以及最近的 Pinia)证明是一个出色的官方全局状态管理工具,它足够灵活,可以满足大多数开发需求。
由于其稳定的性能;定义明确的工具,如 Vue Router、Pinia、Vuex 等;以及一个支持性的社区,开发者可以通过选择 Vue 作为他们的开发栈来节省时间和金钱。
在深入研究 SFC 模式和模板语法之前,本节将探讨 Vue 的基本架构。
与 Vue 一起工作
要了解 Vue 架构,我们将首先将 Vue 包导入到我们的编码沙盒中。一种简单的方法是通过官方的 index.html 文件导入 Vue 包,并在 HTML 模板的 <head> 部分添加一个 <script> 标签来加载 Vue CDN,如下面的代码块所示:
<!DOCTYPE html>
<html>
<head>
<title>Vue.js project with CDN</title>
<script src="img/vue@3"></script>
</head>
</html>
当页面加载时,浏览器也会使用在 script 标签中定义的 CDN 加载 Vue 包。一旦完成,您就可以使用 Vue 函数并开始编写 Vue 代码。
但首先,让我们看看 Vue 实例。
理解 Vue 实例
通常,每个 Vue 应用程序只包含 一个 根 Vue 实例,可以使用 Vue.createApp 方法创建:
const vm = Vue.createApp({
// options
})
Vue 类构造函数接受一个 options 对象,用于配置和组件的行为。我们称这种方法为 Options API,我们可以为所有相应的 Vue 组件使用它。然而,它们都被视为嵌套 Vue 实例,具有它们自己的选项和属性。
注意
vm 是一个常用术语,用来指代 vm,它帮助您在代码块中跟踪 Vue 实例。
为了让 Vue 引擎渲染应用程序实例,在我们的 index.html 文件中,我们使用唯一的类名、ID 或数据属性作为应用程序的主要入口点,在 <body> 标签内声明一个 <div> 元素:
<body>
<div id="vue-app"></div>
<script>
const vm = Vue.createApp({
//Options
})
</script>
</body>
要在浏览器中渲染 Vue 应用程序,我们需要触发 vm.mount(),将根组件挂载到具有唯一选择器的目标 HTML 元素上。在这个例子中,它是一个值为 vue-app 的 id:
<body>
<div id="vue-app"></div>
<script>
const vm = Vue.createApp({
//Options
})
vm.mount('#vue-app')
</script>
</body>
现在,您将 <div> 元素绑定到新的 Vue 实例上,其 id 为 vue-app。
接下来,让我们定义一个值为 "Start using Vue.js today!" 的文本,并将其添加为应用程序选项中 data 方法返回值的属性:
const vm = Vue.createApp({
data() {
return {
text: 'Start using Vue.js today!'
}
}
})
在前面的代码示例中,data 是一个返回包含组件本地状态(或本地变量)的对象实例的函数。我们将在本章的后续部分进一步讨论这一点。
要将 text 的内容渲染到 DOM 中,我们使用 Vue 模板语法,由双大括号 ({{}}) 包围的响应式内容表示。在这种情况下,我们使用 {{ text }},如下面的代码所示:
<div id="vue-app">{{ text }}</div>
Vue 引擎将替换标签为 text 的数据属性和花括号占位符,用字符串 Start using Vue.js today! 替换。
上述代码的输出将如下所示:

图 1.1 – 使用本地数据属性显示“今天开始使用 Vue.js!”
在 <head> 标签中,我们也可以使用 DOM API 构建一个 Vue 应用程序实例,并将其绑定到我们的目标元素(ID 选择器为 #vue-app):
<head>
<title>Vue.js CDN</title>
<script src="img/vue@3"></script>
<script>
document.addEventListener('DOMContentLoaded', function
() {
Vue.createApp({
data(){
return {
text: "Start using Vue.js today!"
}
}
}).mount('#vue-app')
})
</script>
</head>
<body>
<div id="vue-app">{{text}}</div>
</body>
两种方法的输出相同。然而,我们强烈建议 不要 使用 DOMContentLoaded。
虽然使用 CDN 的工作方式非常便携,但我们建议将包管理器作为 Vue 的安装方法。从 Vue 3 及以上版本开始,Vue 项目使用 Vite(或 Vite.js)来初始化和打包代码。您可以通过此处访问:vuejs.org/guide/quick-start.html#creating-a-vue-application。
使用打包管理工具对于管理其他第三方库和构建用于生产的优化代码包非常有帮助。在下一节中,我们将探讨一个由包控制的示例。
设置 Vite 驱动的 Vue 应用程序
Vue 项目结构与许多基于 Node 的现代应用程序类似,包含以下内容:
-
一个
package.json文件 -
在项目根目录下的
node_modules文件夹 -
各种其他配置文件通常位于根目录级别,例如
vite.config.js和.eslintrc.js,因为它们通常会对整个项目产生影响。
以下截图显示了默认 Vue 应用程序的文件夹结构:

图 1.2 – 默认 Vue 应用程序文件夹结构
默认情况下,根目录下有一个 index.html 文件,用作加载 Vue 应用的占位符。您可以修改此文件以包含 header 和 footer 脚本,例如 Google Fonts 或作为您的包一部分之外的第三方 JavaScript 库。
Vue 项目结构遵循一种模式,其中您将大部分源代码管理在 /src 目录中。您可以将 Vue 文件细分到各种文件夹中,例如使用 components 文件夹来存储可重用的 Vue 组件。默认情况下,Vite 将创建 assets 和 components 文件夹以进行代码拆分。对于初学者来说,遵循此模式直到您更加熟悉是很好的:

图 1.3 – 默认 Vue 应用程序 src 文件夹结构
public 文件夹是一个特殊的目录,包含需要直接传输到输出位置的文件。以下截图显示了该文件夹的外观:

图 1.4 – 默认 Vue 应用程序 public 文件夹
到目前为止,您应该对 Vue 项目结构的样子有了一定的了解。接下来,我们将讨论 Vue 的独特模式——SFC 架构。
Vue 的 SFC 架构
组件是大多数现代框架的构建块。通常,将代码拆分为特定组件的块可以确保代码可读性,并促进 不要重复自己(DRY)原则。Vue 的 SFC 模式紧密遵循这种方法。
SFC 架构将外观和行为的责任集中到单个文件中,从而简化了您项目的架构。现在,您可以参考 HTML、CSS 和 JavaScript 逻辑,而无需切换文件。您的默认.vue文件结构如下:

图 1.5 – 默认.vue 文件结构
一个一般的好习惯是确保您的components文件不包含超过 500 行代码。如果您遇到这种情况,建议将它们拆分成更小的可重用组件。例如,在应用程序的标题中,您可能有一个在其他页面上重复使用的 logo 元素。您将创建一个如logo.vue的组件:
// logo.vue
<template>
<img src="img/myLogo.png" />
</template>
在header.vue中,您将logo组件导入到script部分,然后将其作为header组件的嵌套组件包含。您可以通过将其声明为components字段的属性来实现这一点:
// header.vue
<script>
import logo from 'components/logo.vue'
export default {
components: {
logo
}
}
</script>
在template部分,您可以将 logo 用作一个普通的 HTML 元素,如下所示:
<template>
<header>
<a href="mywebsite.com">
<logo />
</a>
</header>
</template>
输出将是一个带有渲染的 logo 图像的标题 – 当需要时,您可以在任何其他组件中重用logo组件。
很快,您将拥有许多这些语义结构化的文件,它们使用可重用语法的小块,您的团队可以在各种应用程序区域中实现。
在下一个练习中,您将练习创建您的第一个 Vue 组件并在另一个组件中显示它。
练习 1.01 – 构建您的第一个组件
我们将在 Vue 项目中构建我们的第一个组件Exercise1.01,并使用 ES6 模块语法将其导入到App.vue组件中使用。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Front-End-Development-Projects-with-Vue.js/tree/v2-edition/Chapter01/Exercise1.01。
注意
当您保存新的更改时,您的应用程序将进行热重载,因此您可以立即看到它们。
要开始,执行以下步骤:
-
使用
npm init vue@3生成的应用程序作为起点,或者在使用以下命令在代码仓库的根目录中导航到Chapter01/Exercise1.01文件夹:> cd Chapter01/Exercise1.01/> yarn -
使用以下命令运行应用程序:
yarn dev -
前往
https://localhost:3000。 -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用您首选的 IDE。 -
打开
src/App.vue文件,删除该文件中的所有内容,并保存。 -
在您的浏览器中,一切应该是一个空白、干净的状态,以便开始工作。
-
构成单个文件组件的三个主要组件是
<template>、<script>和<style>块。将以下代码块添加为我们 Vue 组件的脚手架:/** src/App.vue **/<template></template><script>export default {}</script><style></style> -
在
components文件夹中创建另一个名为Exercise1-01.vue的文件,并重复相同的步骤来搭建 Vue 组件:// src/components/Exercise1-01.vue<template></template><script>export default {}</script><style></style> -
在我们的
Exercise1-01.vue组件中,组合一组<div>标签,其中包含一个<h1>元素和<template>标签内的标题:<template><div><h1>My first component!</h1></div></template> -
在
<style>块内,添加以下样式:<style>h1 {font-family: 'Avenir', Helvetica, Arial,sans-serif;text-align: center;color: #2c3e50;margin-top: 60px;}</style> -
使用 ES6 的
import方法将我们的组件导入到App.vue中,并在<script>块中的components对象内定义组件。现在我们可以通过使用其驼峰式或短横线命名法(两者都有效)在 HTML 中引用此组件:<template><Exercise /></template><script>import Exercise from './components/Exercise1-01'export default {components: {Exercise,}}</script>
当你按下 Ctrl + S(或在 macOS 上按下 Cmd + S),https://localhost:3000 应该重新加载并看起来很棒:

图 1.6 – Exercise 1.01 的 localhost 输出
在这个练习中,我们看到了如何使用模板标签构建 Vue 组件,以及如何使用 Vetur 搭建基本的 Vue 组件。我们还创建了一个新的 Vue 组件,并使用 ES6 语法和 components 属性在 App.vue 中重用它。
在下一节中,我们将了解如何使用 data 属性定义组件的本地状态数据。
探索数据属性作为本地状态
在构建 Vue 组件时,最常用到的术语和响应式元素之一是 data 属性。这些在 Vue 实例的 data() 函数中体现出来:
<script>
export default {
data() {
return {
color: 'red'
}
}
}
</script>
你可以使用 data() 函数创建一个本地数据对象,以存储你想要在 Vue 模板中使用的信息。这个本地对象绑定到组件上,我们称之为组件的本地状态数据。当这个本地对象的任何属性更新或更改时,它将在相应的模板中响应式地更新。
一旦我们定义了本地数据,就需要将其绑定到 template 部分以在 UI 中显示其值,这被称为 数据插值。
插值是将不同性质的内容插入到其他内容中的过程。在 Vue 的上下文中,这就是你使用 mustache 语法(双大括号)来定义可以注入数据到组件 HTML 模板中的区域的地方。
考虑以下示例:
<template>
<span> {{ color }}</span>
</template >
<script>
export default {
data() {
return {
color: 'red'
}
}
}
</script>
red 的 data 属性绑定到 Vue.js 的响应式数据,并在运行时根据 UI 和其数据之间的状态变化进行更新。
到目前为止,我们应该看看如何以最经典的 Vue 方式定义和绑定本地数据。随着 Vue 3.0 的推出,我们享受了编写和导入组件的更简短、更简单的方法。让我们接下来探索它。
使用脚本设置编写组件
从 Vue 3.0 开始,Vue 引入了一个新的语法糖 setup 属性用于 <script> 标签。这个属性允许你在 SFC 中使用组合式 API(我们将在 第五章,组合式 API)编写代码,并缩短编写简单组件所需的代码量。
然后,位于 <script setup> 标签内的代码块将被编译成一个 render() 函数,在部署到浏览器之前,提供更好的运行时性能。
要开始使用此语法,请参考以下示例代码:
// header.vue
<script>
import logo from 'components/logo.vue'
export default {
components: {
logo
}
}
</script>
然后,我们将 <script> 替换为 <script setup>,并删除所有 export default… 的代码块。示例代码现在如下所示:
// header.vue
<script setup>
import logo from 'components/logo.vue'
</script>
在 <template> 中,我们像往常一样使用 logo:
<template>
<header>
<a href="mywebsite.com">
<logo />
</a>
</header>
</template>
为了定义和使用局部数据,我们可以在该组件中直接声明常规变量作为局部数据,并声明函数作为局部方法。例如,为了声明并渲染局部数据属性 color,我们使用以下代码:
<script setup>
const color = 'red';
</script>
<template>
<div>{{color}}</div>
</template>
上述代码输出的结果与上一节中的示例相同——red。
如本节开头所述,<script setup> 在需要在使用 SFC 内的 Composition API 时最有用。尽管如此,我们仍然可以充分利用其简洁性来简化组件。
注意
从现在开始,我们将结合两种方法,并在可能的情况下使用 <script setup>。
在以下练习中,我们将更详细地介绍如何使用插值和数据。
练习 1.02 – 带条件的插值
当你想将数据输出到模板或使页面上的元素具有响应性时,通过使用大括号将数据插值到模板中。Vue 可以理解并替换占位符为数据。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.02:
-
使用
npm init vue@3生成的应用程序作为起点,或在代码仓库的根目录中,使用以下命令按顺序导航到Chapter01/Exercise1.02文件夹:> cd Chapter01/Exercise1.02/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你喜欢的 IDE。 -
在
src/components目录中创建一个新的 Vue 组件文件,命名为Exercise1-02.vue。 -
在
Exercise1-02.vue组件内部,让我们通过添加一个名为data()的函数,在<script setup>标签内添加数据,并返回一个名为title的键,其值为你的标题字符串:<script>export default {data() {return {title: 'My first component!',}},}</script> -
通过将
<h1>文本替换为{{title }}的插值表达式来引用title:<template><div><h1>{{ title }}</h1></div></template>
当你保存此文档时,数据 title 将现在出现在你的 h1 标签内。
-
在 Vue 中,插值将解析大括号内的任何 JavaScript。例如,你可以使用
toUpperCase()方法转换大括号内的文本:<template><div><h1>{{ title.toUpperCase() }}</h1></div></template> -
访问
localhost:3000`。你应该会看到一个如下截图所示的输出:

图 1.7 – 大写标题的显示
-
插值也可以处理条件逻辑。在数据对象内部,添加一个布尔键值对,
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 练习输出
-
将此条件添加到大括号中,并在保存时,你应该看到标题为句子大小写。通过将
isUppercase改为true来玩转这个值:<script>export default {data() {return {title: 'My first component!',isUppercase: true,}},}</script>
以下截图显示了运行上述代码后生成的最终输出:

图 1.9 – 显示大写标题
-
现在,让我们将
<script>替换为<script setup>,并将data()函数内声明的所有本地数据分别移动到其自己的变量名中,例如title和isUpperCase,如下所示:<script setup>const title ='My first component!';const isUppercase = true;</script> -
输出应与 图 1**.9 中的相同。
在这个练习中,我们能够通过使用布尔变量在插值标签({{}})内应用内联条件。此功能允许我们在不过度复杂的情况下修改要显示的数据,这在某些用例中可能很有帮助。我们还学会了如何使用 <script setup> 编写组件的更简洁版本。
由于我们现在已经熟悉了使用插值来绑定本地数据,我们将继续探讨下一个主题——如何使用 Vue 属性将数据和方法附加到 HTML 元素的事件和属性上。
理解 Vue 指令
所有基于 Vue 的指令都以 v-* 前缀开始,作为 Vue 特定的属性:
-
v-text:v-text指令与插值具有相同的响应性。使用{{ }}的插值比v-text指令更高效。然而,你可能遇到这样的情况:你从服务器预先渲染了文本,并希望在 Vue 应用程序加载完成后覆盖它。例如,你可以在等待 Vue 引擎最终用从v-text接收到的动态值替换它时,预先定义一个静态占位文本,如下面的代码块所示:<template><div v-text="msg">My placeholder</div></template><script setup>const msg = "My message"</script> -
v-once:当使用时,它表示静态内容的起点。Vue 引擎将仅渲染一次具有此属性及其子元素。它还会忽略在初始渲染之后对此组件或元素的任何数据更新。此属性在不需要某些部分具有响应性的场景中非常有用。你可以将v-once与v-text、插值和任何 Vue 指令结合使用。 -
V-html:Vue 将解析传递给此指令的值,并将你的文本数据作为有效的 HTML 代码渲染到目标元素中。我们不建议使用此指令,尤其是在客户端,因为它会影响性能并可能导致潜在的安全漏洞。script标签可以通过此指令嵌入和触发。 -
v-bind: 这是 Vue 中最受欢迎的功能之一。你可以使用这个指令来为一个数据变量或表达式启用对 HTML 属性的单向绑定,如下例所示:<template><img v-bind:src="img/logo" /></template><script setup>const logo = '../assets/logo.png';</script>
上述代码演示了如何将 logo 数据变量绑定到图像的 src 属性。现在 img 组件从 logo 变量获取源值并相应地渲染图像。
你还可以用它将本地数据变量作为 props 传递给另一个组件。一种更简短的方式是使用 :attr 语法而不是 v-bind:attr。以先前的例子为例。我们可以将模板重写如下:
<template>
<img :src="img/logo" />
</template>
-
v-if: 这是一个强大的指令,你可以用它来有条件地控制组件内部元素的渲染方式。这个指令的操作类似于if…else和if…else if…条件。它附带支持指令,如v-else,代表else的情况,以及v-else-if,代表else if的情况。例如,我们想在count为2、4和6时渲染不同的文本。下面的代码将演示如何做到这一点:<template><div v-if="count === 2">Two</div><div v-else-if="count === 4">Four</div><div v-else-if="count === 6">Six</div><div v-else>Others</div></template> -
v-show: 你还可以使用v-show来控制 HTML 元素的可见状态。与v-if不同,使用v-show时,Vue 引擎仍然将元素挂载到 DOM 树上,但使用display: noneCSS 样式将其隐藏。在检查时,你仍然可以在 DOM 树中看到隐藏元素的文本内容,但对于最终用户来说,它不可见。此指令不与v-else或v-else-if一起使用。如果v-show的结果为true布尔值,它将保持 DOM 元素不变。如果解析为false,它将应用display: none样式到该元素。 -
v-for: 我们使用v-for指令来实现基于数据源进行列表渲染的目标。数据源是一个可迭代的集合,例如array或object。我们将在本章的单独部分深入探讨这个指令的不同用法。
我们已经介绍了 Vue 中最常用的指令。现在让我们通过以下练习来复习和实验如何使用这些指令。
练习 1.03 – 探索基本指令(v-text, v-once, v-html, v-bind, v-if, v-show)
更复杂的组件将使用多个指令来实现预期的效果。在这个练习中,我们将构建一个组件,该组件使用多个指令来绑定、操作并将数据输出到模板视图。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.03。
让我们从以下步骤开始练习:
-
以使用
npm init vue@3生成的应用程序作为起点,或者在每个代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.03文件夹:> cd Chapter01/Exercise1.03/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components目录下创建一个名为Exercise1-03.vue的新 Vue 组件文件。 -
在
Exercise1-03.vue内部,编写以下代码以显示text内容:<template><div><h1>{{ text }}</h1></div></template><script setup>const text = 'Directive text';</script> -
将
{{}}插值替换为v-text属性。输出不应更改:<template><div><h1 v-text="text">Loading...</h1></div></template>
图 1.10显示了上述代码的输出:

图 1.10 – v-text 和插值方法具有相同的输出
-
将
v-once指令添加到同一元素上。这将强制此 DOM 元素只加载v-text数据一次:<template><div><h1 v-once v-text="text">Loading...</h1></div></template> -
在
h1元素下方,包含一个新的h2元素,该元素使用v-html属性。添加一个新的本地数据html,其中包含一个包含 HTML 格式的字符串,如下面的代码块所示:<template><div><h1 v-once v-text="text">Loading...</h1><h2 v-html="html" /></div></template><script setup>const text = 'Directive text';const html = 'Stylise</br>HTML in<br/><b>your data</b>'</script>
运行前面的代码将生成以下输出:

图 1.11 – 使用 v-html 从字符串渲染 HTML 元素
-
添加一个新的本地
link对象,其中包含诸如 URL、目标、标题和标签索引等信息。在模板中,添加一个新的锚点 HTML 元素,并使用v-bind简写语法将该link对象绑定到 HTML 元素上 – 例如,:href="link.url":<template><div><h1 v-once v-text="text">Loading...</h1><h2 v-html="html" /><a:href="link.url":target="link.target":tabindex="link.tabindex">{{ link.title }}</a></div></template><script setup>const text = 'Directive text';const html = 'Stylise</br>HTML in<br/><b>your data</b>'const link = {title: "Go to Google",url: https://google.com,tabindex: 1,target: '_blank',};</script>
以下截图显示了输出:

图 1.12 – 将 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-html="html" v-else-if="false" /><av-else:href="link.url":target="link.target":tabindex="link.tabindex">{{ link.title }}</a></div></template>
由于我们将主要条件语句设置为false,你应该只能在页面上看到<a>标签。
v-else条件将显示以下内容:

图 1.13 – false v-if 语句隐藏整个 HTML 元素从 DOM 中
-
将模板更改为使用
v-show而不是v-if语句,从<a>元素中删除v-else,并将h1中的v-show值更改为true:<template><div><h1 v-show="true" v-once v-text="text">Loading...</h1><h2 v-html="html" v-show="false" /><a:href="link.url":target="link.target":tabindex="link.tabindex">{{ link.title }}</a></div></template>
上述代码的输出将如下所示:

图 1.14 – 将 v-show 设置为 true 将显示主要指令文本
当你打开浏览器 Devtools 的Elements标签页时,你应该能够观察到h2的显示状态被设置为none,如下所示:

图 1.15 – h2 在 false 条件下具有“display: none”
在这个练习中,我们学习了 Vue 的核心指令,用于控制、绑定、显示和隐藏 HTML 模板元素,而无需在本地状态中添加新的数据对象之外使用任何 JavaScript。
在下一节中,我们将学习如何借助 Vue 的 v-model 实现双向绑定。
使用 v-model 启用双向绑定
Vue 通过创建一个专门用于监视 Vue 组件内部数据属性的指令来实现双向数据绑定。当目标数据属性在 UI 上被修改时,v-model 指令会触发数据更新。此指令通常用于需要同时显示和响应式修改数据的 HTML 表单元素,例如 input、textarea、单选按钮等。
我们可以通过向目标元素添加 v-model 指令并将其绑定到我们希望的数据属性上来启用双向绑定:
<template>
<input v-model="name" />
</template>
<script>
export default {
data() {
return {
name: ''
}
}
}
</script>
在 图 1**.16 中,运行前面代码生成的输出将如下所示:

图 1.16 – v-model 示例的输出
注意
使用 v-model 绑定大量数据可能会影响您应用程序的性能。请考虑您的 UI 并将数据拆分到不同的 Vue 组件或视图中。Vue 中的本地状态数据不是不可变的,可以在模板的任何位置重新定义。
在下一个练习中,我们将使用 Vue 的双向数据绑定构建一个组件,并实验双向绑定数据的意义。
练习 1.04 – 使用 v-model 进行双向绑定实验
此类数据模型的上下文通常是表单或您期望输入和输出数据的任何地方。到练习结束时,我们应该能够在表单的上下文中使用 v-model 属性。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.04。
让我们通过以下步骤开始练习:
-
以使用
npm init vue@3生成的应用程序为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise 1.04文件夹:> cd Chapter01/Exercise 1.04/> yarn -
使用以下命令运行应用程序:
yarn dev -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components目录下创建一个名为Exercise1-04.vue的新 Vue 组件文件。 -
在
Exercise1-04.vue内部,首先在template区域内创建一个 HTMLlabel并使用v-model将一个input元素绑定到name数据属性:<div class="form"><label>Name<input type="text" v-model="name" /></label></div> -
通过在
<``script>标签中返回一个名为name的响应式数据属性来完成text输入的绑定:<script>export default {data() {return {name: '',}},}</script> -
接下来,在
template区域内部使用v-model组合一个label和与language数据属性绑定的可选 HTMLselect:<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的响应式数据属性来完成select输入的绑定:<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>标签内添加样式:<style>.form {display: flex;justify-content: space-evenly;max-width: 800px;padding: 40px 20px;border-radius: 10px;margin: 0 auto;background: #ececec;}.overview {display: flex;flex-direction: column;justify-content: space-evenly;max-width: 300px;margin: 40px auto;padding: 40px 20px;border-radius: 10px;border: 1px solid #ececec;}.overview > li {list-style: none;}.overview > li + li {margin-top: 20px;}</style> -
前往
https://localhost:3000。你的输出应该如下所示:

图 1.17 – 更新数据后的最终表单显示
当你在表单中更新数据时,它也应该同步更新概览区域。
在这个练习中,我们使用了v-model指令将名称和 JavaScript 风格的下拉选择绑定到我们本地状态的数据。当你修改数据时,它将反应性地更新我们输出的 DOM 元素。
接下来,我们将进一步讨论我们的v-for指令以及处理 Vue 中迭代数据集合的不同方法。
理解 v-for 数据迭代
要在 Vue 中循环 HTML 元素,你直接在目标元素上使用v-for循环指令。当 Vue 渲染组件时,它将迭代目标以使用和渲染解析到指令中的数据,这与正常的 JavaScriptfor循环的概念相同。
使用 v-for 进行基本迭代
v-for的基本语法如下:
v-for="(item, index) in items" :key="index"
上述语法示例表明我们正在遍历一个items列表。在每次迭代中,我们都可以访问单个item及其在列表中的index外观。:key是一个必需的属性,它作为 Vue 引擎渲染的每个迭代元素的唯一标识符,以便跟踪。
当key或item内容发生变化时,无论是程序性地还是由于用户交互,Vue 引擎都会触发 UI 上更改项的更新。如果你在一个组件中有多个循环,你应该使用额外的字符或与上下文相关的字符串随机化key属性,以避免key重复冲突。
这个方向有各种用例。一个直接的用例是执行匿名循环,其中你可以定义一个数字,X,作为一个符号列表,循环将迭代该X次。这在你需要严格控制迭代次数或渲染一些占位内容的情况下非常有用。
在以下示例中,我们看到一个匿名循环,总迭代次数为2,我们使用loop-1前缀定义key:
<template>
<div v-for="n in 2" :key="'loop-1-' + n">
{{ n }}
</div>
<template>
你还可以使用模板字符串(使用js `` 反引号)来计算字符串而不使用+:
<template>
<div v-for="n in 5" :key="`loop-2-${n}`">
{{ n }}
</div>
<template>
上述两种方法中,代码的输出应该如下所示

图 1.18 – 匿名循环示例输出
现在我们已经介绍了如何使用 v-for 处理基本循环,我们将在下一个练习中利用这个功能。
练习 1.05 – 使用 v-for 遍历字符串数组
在这个练习中,我们将使用 Vue 的 v-for 指令创建一个匿名循环。对于那些以前在 JavaScript 中使用过 for 或 forEach 循环的人来说,这将很熟悉。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.05。
执行以下步骤以完成练习:
-
使用
npm init vue@3生成的应用程序作为起点,或者在每个代码仓库的根目录中,使用以下命令按顺序导航到Chapter01/Exercise1.05文件夹:> cd Chapter01/Exercise1.05/> yarn -
使用以下命令运行应用程序:
yarn dev -
在项目目录中使用
code .命令或在您的首选 IDE 中打开练习项目。 -
在
src/components目录中创建一个名为Exercise1-05.vue的新 Vue 组件文件。 -
在
Exercise1-05.vue内部,我们使用一个<h1>元素来渲染静态标题Looping through arrays,以及一个包含空<li>标签的<ul>元素:<template><h1>Looping through arrays</h1><ul><li></li></ul></template> -
在
script部分,让我们给script标签添加一个setup属性。然后,让我们声明一个包含一些字符串的interests数组,如下所示:<script setup>const interests = ['TV', 'Games', 'Sports']</script> -
现在,让我们回到
template部分,并在<li>标签上添加v-for指令来遍历interests。对于每次迭代,我们都会从interests中获取(item, index)的组合,其中item输出数组的字符串,而index是循环索引。我们将key属性映射到index,并显示item的值,如下面的代码块所示:<template><h1>Looping through arrays</h1><ul><li v-for="(item, index) in interests":key="index">{{ item }}</li></ul></template> -
访问
https://localhost:3000。以下输出如下:

图 1.19 – 遍历字符串数组的结果
在这个练习中,我们学习了如何遍历特定的字符串数组,输出数组的字符串值或索引。我们还了解到,key 属性需要是唯一的,以避免 DOM 冲突并强制 DOM 正确重新渲染组件。
接下来,让我们尝试遍历一组对象。
遍历对象数组
在大多数实际场景中,我们以对象的形式处理数据,尤其是在遍历对象数组时。Vue 通过其指令语法使控制各种数据状态变得容易。就像遍历字符串数组一样,指令语法保持不变:
v-for="(item, index) in items" :key="index"
你现在收到的item是一个对象,具有各种属性。你可以使用到目前为止所学的内容绑定每个属性以显示其值。例如,假设在item中,我们将有id、title、description以及另一个包含一些字符串的数组characteristics。我们可以像这样显示每个item的title和description信息:
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<h2>{{ item.title }}</h2>
<span>{{ item.description }}</span>
</li>
</ul>
</template>
注意这里我们不使用index作为key;相反,我们使用id作为唯一的标识符。使用id或任何其他唯一标识符被认为是一种更安全的方法,在这种情况下我们也不需要将index包含在语法中,因为我们没有使用它。
由于characteristics是一个数组,我们通过再次使用v-for指令来显示其值。你不必使用语法示例中显示的相同名称item。相反,你可以根据你想要变量的方式给它一个不同的名称。
在以下示例中,我们为item.characteristics数组中的每个元素使用str:
<template>
<ul>
<li v-for="item in items" :key="item.id">
<h2>{{ item.title }}</h2>
<span>{{ item.description }}</span>
<ul>
<li v-for="(str, index) in item.characteristics"
:key="index">
<span>{{ str }}</span>
</li>
</ul>
</li>
</ul>
</template>
在script部分,我们如下定义items:
<script setup>
const items = [{
id: 1,
title: "Item 1",
description: "About item 1",
characteristics: ["Summer", "Winter", "Spring", "Autumn"]
}, {
id: 2,
title: 'Item 2",
description: 'About item 2",
characteristics: ["North", "West", "East", "South"]
}]
</script>
前面的代码将输出如图 1.20所示:

图 1.20 – 遍历对象数组后的输出
理解如何使用v-for遍历对象集合对于处理数据,特别是外部数据,是基本且有用的。在下一个练习中,你将结合v-for和v-if来有条件地显示对象列表。
练习 1.06 – 使用 v-for 遍历对象数组并在 v-if 条件下使用它们的属性
在这个练习中,我们将控制 Vue 数据数组并遍历其内部的对象。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.06。
让我们从以下步骤开始练习:
-
使用
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.06文件夹:> cd Chapter01/Exercise1.06/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你喜欢的 IDE。 -
在
src/components目录中创建一个新的 Vue 组件文件,命名为Exercise1-06.vue。 -
在
Exercise1-06.vue内部,创建一个数据对象数组interests作为本地数据。每个兴趣包含一个title字符串和一个字符串数组favorites:<script setup>const interests = [{title: "TV",favorites: ["Designated Survivor","Spongebob"],},{title: "Games",favorites: ["CS:GO"],},{title: "Sports",favorites: [],},];</script> -
在
template中,我们遍历interests并显示interests数组中每个item的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> -
前往
https://localhost:3000,前面代码的输出将如下所示:

图 1.21 – 你现在应该在浏览器中看到一个标题列表
-
让我们创建第二个
v-for循环来遍历每个item的favorites列表。注意,我们为嵌套循环使用了不同的名称 –fav和m:<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.favorites":key="m">{{ fav }}</li></ol></li></ul></div></template>
图 1.22 显示了一个通过对象数组进行循环的输出:

图 1.22 – 详细显示你喜欢的嵌套有序列表
- 当检查 DOM 元素时(按 Ctrl + F12 或打开
<ol>元素,即使favorites是一个空数组):

图 1.23 – 在你的虚拟 DOM 中显示空 DOM 元素
-
现在,我们需要在应用之后隐藏那个空的
<ol>元素。我们将检查favorites数组是否为空(length > 0),然后显示有序列表 HTML 元素。让我们给<ol>添加一个v-if指令,条件为item.favorites.length > 0:<ol v-if="item.favorites.length > 0"><li v-for="(fav, m) in item.favorites" :key="m">{{ fav }}</li></ol>
这不会影响你页面的视觉效果,但当你检查浏览器中的 DOM 树时,你会注意到一个在开发模式下允许你理解 v-if 语句可能为 false 的 HTML 注释。当你为生产构建时,这些 HTML 注释不会在你的 DOM 树中可见:

图 1.24 – 显示生产构建中无 HTML 注释的输出
在这个练习中,我们遍历了复杂对象数组,输出了这些对象的嵌套键,并根据长度条件控制 DOM 元素的视图状态。
接下来,让我们尝试遍历一个键值集合(或对象)。
遍历键值集合(对象)
我们通常可以使用 v-for 来遍历任何迭代数据集合类型。JavaScript 中的对象是一个键值数据集合,我们可以使用 v-for 来遍历其属性。
语法示例类似于之前数组对象和字符串的语法示例,只有一个细微的差别。在这里,我们将命名约定从 (item, index) 改为 (value, key),其中 key 是对象的属性,value 是该 key 属性的值。Vue 还暴露了一个额外的参数 – index – 以指示该属性在目标对象中的出现索引。因此,现在的语法如下:
v-for="(value, key, index) in obj"
这里,obj 是我们要遍历的目标对象。
例如,假设我们有一个名为 course 的对象,它包含标题、描述和讲师(们)的姓名:
<script setup>
const course = {
title: 'Frontend development with Vue',
description: 'Learn the awesome of Vue',
lecturer: 'Maya and Raymond'
}
</script>
在我们的模板中,我们遍历 course 的属性,并以 <index>.<key> : <value> 格式输出它们的值,如下面的代码块所示:
<template>
<ul>
<li v-for="(value, key, index) in course" :key="key">
{{index}}. {{key}}: {{value}}
</li>
</ul>
</template>
输出将如图 图 1.25 所示:

图 1.25 – 遍历和显示课程属性的值
遍历对象属性也是一种联合开发实践。这与遍历任何键集合类型(如哈希表(根据键映射)、查找字典(它也是一个对象)等)的概念相同。由于数组和对象迭代之间的语法保持一致,这有助于减少重构或数据转换的需求。
接下来,你将练习如何编写基本的对象属性循环。
练习 1.07 – 使用 v-for 遍历对象的属性
在这个练习中,我们将控制 Vue 数据对象并遍历其内部的属性。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.07。
让我们从以下步骤开始练习:
-
使用
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.07文件夹:> cd Chapter01/Exercise1.07/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或你的首选 IDE。 -
在
src/components目录下创建一个新的 Vue 组件文件,命名为Exercise1-07.vue。 -
在
Exercise1-07.vue中,让我们在<script setup>内部为局部数据information组合如下:<script setup>const information = {title: "My list component information",subtitle: "Vue JS basics",items: ["Looping", "Data", "Methods"],}</script> -
在
<template>部分,我们将遍历information并显示其属性的值:<template><div><div v-for="(value, key) in information":key="key">{{key}}: {{ value }}</div></div></template> -
访问
https://localhost:3000,输出将如下所示:

图 1.26 – 使用 v-for 在信息对象上输出
-
注意,Vue 以与使用 JavaScript 声明相同的方式渲染值,即字符串数组。为了以更好的格式渲染它,我们使用内置的 JavaScript
toString()函数自动将所有元素的值导出到一个以逗号分隔的字符串中:<template><div><div v-for="(value, key) in information":key="key">{{key}}: {{ value.toString() }}</div></div></template> -
最终输出将渲染列表如下:

图 1.27 – 使用 v-for 和 toString() 在值上输出
理解迭代(或循环)对于不仅与 Vue 一起工作,而且与 JavaScript 一起工作都是至关重要的。现在我们已经介绍了如何使用 v-for 指令处理循环以及 key 属性对于正确增强响应性的重要性,我们将探讨如何在组件中使用、编写和触发方法。
探索方法
在 Vue 2.0 中,Vue 将组件方法定义在methods对象中,作为 Vue 实例的一部分。您将每个组件方法作为常规 JavaScript 函数编写。Vue 方法的作用域限定在您的 Vue 组件中,并且可以在属于该组件的任何地方运行。它还可以访问this实例,这表示组件的实例:
<script>
export default {
methods: {
myMethod() { console.log('my first method'); }
}
}
</script>
从 Vue 3.0 开始,使用<script setup>,与本地数据一样,您可以定义一个方法作为常规函数,并且它将以相同的方式工作。因此,我们可以将前面的代码重写如下:
<script setup>
const myMethod = () => { console.log('my first method'); }
</script>
然后,您可以在template部分将方法绑定到元素的 HTML 事件上作为其事件监听器。在 Vue 中绑定 HTML 元素的事件时,您会使用@符号。例如,v-on:click等同于@click,如下面的代码块所示:
<template>
<button id="click-me" v-on:click="myMethod">Click me
</button>
<button id="click-me" @click="myMethod">Click me
shorter</button>
</template>
点击两个按钮都会触发相同的myMethod()方法并生成相同的结果。
让我们构建一个具有一些方法的自定义组件。
练习 1.08 – 触发方法
在这个练习中,我们将构建一个使用 Vue 的 methods API 的组件。考虑这些 Vue 方法如何与您自己的 JavaScript 命名函数类似编写,因为它们的行为非常相似。到练习结束时,我们应该能够使用方法和从 HTML 模板中触发它们。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.08
我们将构建一个不同元素的列表。对于每个元素,我们将绑定一个onClick事件和一个component方法,并通过执行以下操作来提醒用户点击的元素的索引:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序进入Chapter01/Exercise1.08文件夹:> cd Chapter01/Exercise1.08/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components目录中创建一个名为Exercise1-08.vue的新 Vue 组件文件。 -
在
Exercise1-08.vue的<script setup>部分内部,让我们定义一个名为triggerAlert的方法,它接收一个索引并显示一个警告,告知用户哪个索引已被点击:<script setup>const triggerAlert = (index) => {alert(`${index} has been clicked`)}</script> -
在
template部分,在 HTML 列表上设置一个匿名的v-for循环,并在列表元素内添加一个button元素。将循环设置为迭代5次,并显示每个按钮的index值作为标签:<template><div><h1>Triggering Vue Methods</h1><ul><li v-for="index in 5":key="index"><button>Trigger {{index}}</button></li></ul></div></template> -
添加
@click指令,引用triggerAlert方法,并将index的值作为参数传递:<template><div><h1>Triggering Vue Methods</h1><ul><li v-for="index in 5" :key="index"><button @click="triggerAlert(index)">Trigger{{ index }}</a></li></ul></div></template> -
在每个按钮之间添加边距以提高可读性:
<style>button {margin: 10px;}</style> -
您的页面应包含一个按钮列表,点击时将触发一个包含您点击的按钮编号的消息的警告,如下所示:

图 1.28 – 输出触发器列表
当触发器被点击时,将显示以下提示:

图 1.29 – 显示包含索引编号的浏览器警告
注意
虽然您可以将事件监听器添加到任何 HTML 元素上,但我们建议将它们应用于原生 HTML 交互元素,如锚标签、表单输入或按钮,以帮助提高浏览器可访问性。
到目前为止,您可以使用 Vue 方法 API 在 HTML 模板中定义和触发方法,并将参数动态地解析到每个方法中。在下一个练习中,我们将探讨如何在 Vue 组件中使用 Vue 方法返回数据。
练习 1.09 – 使用 Vue 方法返回数据
通常,在 Web 应用程序中,我们希望元素根据条件是否满足而出现在页面上。例如,如果我们的产品没有库存,我们的页面应显示它已售罄。
那么,让我们弄清楚我们如何根据产品是否有库存来条件性地渲染这些元素。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Front-End-Development-Projects-with-Vue.js/tree/v2-edition/Chapter01/Exercise1.09。
我们将构建一个不同元素的列表,并演示向购物车添加不同数量的操作。然后,我们将通过以下方式以货币格式显示更新后的购物车总价值:
-
以使用
npm init vue@3生成的应用程序为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.09文件夹:> cd Chapter01/Exercise1.09/> yarn -
使用以下命令运行应用程序:
yarn dev -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或使用您首选的 IDE。 -
在
src/components目录下创建一个名为Exercise1-09.vue的新 Vue 组件文件。 -
在
Exercise1-09.vue中,在<script>部分,我们设置了两个数据对象totalItems和totalCost,这些对象将在用户点击我们商店的按钮时更新:<script>export default {data(){return {totalCost: 0,totalItems: 0}}}</script> -
在
template部分,我们相应地显示totalItems和totalCost的值:<template><div><h1>Returning Methods</h1><div>Cart({{ totalItems }}) {{ totalCost }} </div></div></template> -
在
script部分,让我们创建一个addToCart方法,该方法将根据接收到的数字n,使用this.totalCost和this.totalItems更新当前组件的totalCost和totalItems:<script>export default {data() {/*…*/},methods: {addToCart(n) {this.totalItems = this.totalItems + 1this.totalCost = this.totalCost + n},},}</script> -
让我们遍历一个随机数量来创建添加到购物车数量的按钮。数量是按钮的索引。然后,我们将
addToCart方法绑定到每个按钮上,其索引作为函数的输入参数:<template><div><h1>Returning Methods</h1><div>Cart({{ totalItems }}) {{ totalCost }} </div><ul><li v-for="n in 5" :key="n"><button @click="addToCart(n)">Add {{ n }}</button></li></ul></div></template> -
将
button元素添加10px的边距以提高可读性:<style>button {margin: 10px;}</style> -
访问
https://localhost:3000,输出如下:

图 1.30 – 点击任意按钮将演示购物车逻辑
当你点击按钮时,totalItems计数器应该增加1,但totalCost将增加n值,这应该演示正常的购物车功能。例如,当点击添加 2然后添加 5时,输出将如下所示:

图 1.31 – 显示递增 2 和 5 后的返回方法输出
-
现在,让我们格式化
totalCost。创建一个名为formatCurrency的方法,它接受一个参数。我们将给它添加两位小数和一个$符号,然后返回相同的值:<script>export default {data() {/*…*/},methods: {addToCart(n) { /*…*/},formatCurrency(val) {return `$${val.toFixed(2)}`},},}</script> -
要在模板中使用此方法,将其添加到插值大括号中,并将方法内的值作为参数传递:
<template><div><h1>Returning Methods</h1><div>Cart({{ totalItems }}) {{formatCurrency(totalCost) }}</div><ul><li v-for="n in 5" :key="n"><button @click="addToCart(n)">Add {{formatCurrency(n) }}</button></li></ul></div></template>
以下截图显示了前面代码的输出:

图 1.32 – 所有值现在都是货币格式,同时保留购物车计数器
在这个练习中,我们能够利用 Vue 的方法 API 将参数解析为方法,返回修改后的值,并在一个逼真的场景中使用方法来更新本地数据状态。
在下一节中,我们将探讨组件的一个重要部分——Vue 中的生命周期和可用的组件钩子。
理解组件生命周期钩子
Vue 组件的生命周期事件发生在组件的生命周期中,从创建到删除。它们允许我们在组件生命周期的每个阶段添加回调和副作用,当需要时。
Vue 按顺序执行事件,如下所示:
-
setup:这个事件在所有其他钩子之前运行,包括beforeCreate。由于实例在此点尚未创建,它无法访问此实例。它主要用于使用组合 API,并且与 Vue 对待script setup的方式相同。我们将在第五章中更详细地讨论此事件,组合 API。 -
beforeCreate:当你的组件被初始化时运行。data尚未变为响应式,DOM 中的事件也没有设置。 -
created:你将能够访问响应式数据和事件,但模板和 DOM 尚未挂载或渲染。当你需要尽早在虚拟 DOM 挂载之前请求异步数据时,这个钩子通常很有用。 -
beforeMount:这是一个非常不常见的钩子,因为它直接在组件的第一次渲染之前运行,并且不会在服务器端渲染中调用。 -
mounted:挂载钩子是你将最常使用的钩子之一,因为它们允许你访问 DOM 元素,以便集成非 Vue 库。 -
beforeUpdate:这个钩子在组件发生变化后立即运行,在它被重新渲染之前。在渲染之前获取响应式数据的状态是有用的。 -
updated:它在beforeUpdate钩子之后立即运行,并使用新的数据更改重新渲染你的组件。 -
beforeUnMount:这个钩子在组件实例卸载前直接触发。组件在unmounted钩子被调用之前仍然可以正常工作,这允许你停止事件监听和数据订阅以避免内存泄漏。注意,在 Vue 2.x 中,此事件被称为beforeDestroy。 -
unmounted:所有虚拟 DOM 元素和事件监听器都已从你的 Vue 实例中清理。此钩子允许你通知任何需要知道这一点的任何人或任何元素。在 Vue 2.x 中,此事件被称为destroyed。
让我们做一个小的练习,学习如何以及何时使用 Vue 的生命周期钩子,以及它们何时触发。
练习 1.10 – 使用 Vue 生命周期控制数据
在这个练习中,我们将通过使用 JavaScript 弹窗学习如何以及何时使用 Vue 的生命周期钩子,以及它们何时被触发。到练习结束时,我们将能够理解和使用多个 Vue 生命周期钩子。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.10。
我们将构建一个不同元素的列表,展示如何向购物车添加不同数量的商品。然后,我们将通过以下方式以货币格式显示更新后的购物车总价值:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.10文件夹:> cd Chapter01/Exercise1.10/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或你的首选 IDE。 -
在
src/components目录中创建一个名为Exercise1-10.vue的新 Vue 组件文件。 -
在
Exercise1-10.vue中,我们首先创建一个数据数组,用于在列表元素中迭代,将键设置为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()作为属性。在这些钩子内部设置一个警告或控制台日志,以便你可以看到它们何时被触发:<script>export default {data(){ /*…*/ },beforeCreate() {alert('beforeCreate: data is static, thats it')},created() {alert('created: data and events ready, but noDOM')},}</script> -
当你刷新浏览器时,你应该在看到页面上的列表加载之前看到这两个警告:

图 1.33 – 首先观察 beforeCreate() 钩子的警告
- 以下截图显示了 beforeCreate() 钩子之后的 created() 钩子的警告:

图 1.34 – 在 beforeCreate() 钩子之后观察 before() 钩子的警告
-
以与第 6 步相同的方式定义
beforeMount()和mounted()。在这些钩子内部设置一个警告或控制台日志,以便你可以看到它们何时被触发:<script>export default {data() { /*…*/ },/*…*/beforeMount() {alert('beforeMount: $el not ready')},mounted() {alert('mounted: DOM ready to use')},}</script> -
当你刷新浏览器时,你应该在看到页面上的列表加载之前看到这些警告:

图 1.35 – 在 create() 钩子之后观察 beforeMount() 钩子的警告
- 以下截图显示了 beforeMount() 钩子之后的 mounted() 钩子的警告:

图 1.36 – 在 beforeMount() 钩子之后观察 mounted() 钩子的警告
-
在你的
<li>元素内部添加一个新的button元素,该元素渲染item输出。使用@click指令将此按钮绑定到名为deleteItem的方法,并将item值作为参数传递:<template><div><h1>Vue Lifecycle hooks</h1><ul><li v-for="(item, n) in list" :key="n">{{ item }}<button @click="deleteItem(item)">Delete</button></li></ul></div></template> -
在你的 hooks 之上的 methods 对象中添加一个名为
deleteItem的方法,但要在data()函数之下。在这个函数内部,将value作为参数传递,并根据这个值从list数组中过滤出项目。然后,用新的列表替换现有的列表:<script>export default {data() { /*…*/ },/*…*/methods: {deleteItem(value) {this.list = this.list.filter(item => item !==value)},},}</script> -
与第 9 步相同,添加
beforeUpdate()和updated()作为函数,并在它们内部设置一个警告或控制台日志:<script>export default {/*...*/beforeUpdate() {alert('beforeUpdate: we know an update is about tohappen, and have the data')},updated() {alert('updated: virtual DOM will update after youclick OK')},}</script>
当你通过点击 beforeUpdated 删除列表项时将触发:

图 1.37 – 点击任何删除按钮后首先调用 BeforeCreated
然后,updated 触发,如下截图所示:

图 1.38 – 当 Vue 引擎在渲染到 DOM 之前更新组件时调用 updated
-
继续将
beforeUnmount()和unmounted()添加到组件选项中作为函数属性。在这些钩子内部设置一个警告或控制台日志,以便您可以看到它们何时被触发:<script>export default {/*...*/beforeUnmount() {alert('beforeUnmount: about to blow up thiscomponent')},unmounted() {alert('unmounted: this component has beendestroyed')},}</script> -
向您的
list数组添加一个新的字符串 – 例如,testingunmounted hooks:<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','testing unmounted hooks',],}}, -
您应该按照以下顺序看到卸载警告:
beforeUnmount–beforeCreated–created–beforeMount–unmounted–mounted。这里显示了显示beforeUnmount警告的示例输出屏幕:

图 1.39 – 当组件即将卸载时显示警告
注意
mounted和created生命周期钩子将在每次组件初始化时运行。如果这不是期望的效果,请考虑从父组件或视图中运行一次您想要运行的代码,例如App.vue文件。
在这个练习中,我们学习了 Vue 生命周期钩子的概念,它们何时触发以及触发顺序。这将在与触发方法和控制 Vue 组件内的数据结合使用时非常有用。
接下来,我们将讨论如何使用<style>部分来样式化我们的 Vue 组件。
样式组件
当使用 Vue 组件时,Vite 编译器允许您使用几乎任何前端模板语言风格。在 Vue 模板中启用这些表达性库插件的最简单方法是,在初始化项目时安装它们,或者使用npm install(或yarn add)安装包。
当在 Vue 组件内部使用style标签时,您可以使用lang属性指定一个语言,前提是您已安装了该特定语言插件。
例如,如果您选择安装 Stylus 预处理器,首先您需要通过执行以下命令在项目中将stylus包作为依赖项安装:
npm add -D stylus
#OR
yarn add -d stylus
然后,您可以将lang="stylus"属性添加到style标签中,开始使用 Stylus:
<style lang="stylus">
ul
color: #2c3e50;
> h2
color: #22cc33;
</style>
使用 Vue 的另一个好处是使用scoped属性作用域化样式。这是一种创建隔离和组件特定 CSS 样式的有用方法。它还会根据 CSS 规则的具体性覆盖任何其他 CSS 全局规则。
不建议将全局样式作用域化。定义全局样式的常见方法是将这些样式分离到另一个样式表中,并将其导入到您的App.vue文件中。
现在,让我们通过以下练习来练习导入 SCSS,这是 CSS 的预处理器插件,并在您的应用程序中使用它,并编写一些作用域化的样式:
练习 1.11 – 将 SCSS 导入到作用域组件中
在这个练习中,我们将利用style标签向组件添加 SCSS 预处理器样式,并导入外部样式表。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.11。
让我们首先执行以下步骤:
-
使用
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令按顺序导航到Chapter01/Exercise1.11文件夹:> cd Chapter01/Exercise1.11/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你偏好的 IDE。 -
在
src/components目录下创建一个名为Exercise1-11.vue的新 Vue 组件文件。 -
在
Exercise1-11.vue内,让我们编写一些可以使用 SCSS 样式的 HTML。让我们继续练习插值方法:<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> -
将
sassSCSS 包添加为项目依赖项:npm add -D sass -
将
lang属性添加到style标签中,并将scss值添加到style块中以启用 SCSS 语法:<style lang="scss"></style> -
在
src/目录下创建一个名为styles的文件夹。在这个新文件夹内,创建一个名为typography.scss的文件:src/styles/typography.scss -
在
typography.scss内,为你在组件中编写的模板添加一些样式,例如定义颜色变量(green、grey和blue)以便在不同区域的 CSS 规则中重用,并为h1、h2和列表元素添加一些 CSS 样式:/* 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;}}
在 SCSS 中,你可以使用标准的 CSS 选择器来选择组件中的元素。
ul > li 将选择 <ul> 元素内的每个 <li> 元素进行样式化。同样,使用加号符号(+)意味着如果元素满足条件,则放置在第一个元素之后的元素将被样式化。例如,h1 + h2 将规定所有在 h1 之后的所有 h2 元素将以某种方式样式化,但 h3 不会。你可以通过以下示例更好地理解这一点:
在 CSS 中,你会这样呈现这段代码:
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.scss';</style>
这将生成以下输出:

图 1.40 – 保存并重新加载后,你的项目应该已导入样式
-
将
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.41 – 作用域样式的结果
- 检查 DOM,你将注意到在运行时,作用域已将
v-data-*属性应用于你的 DOM 元素,指定了这些特定规则。
<head> 和 <style> 标签:

图 1.42 – 虚拟 DOM 如何使用数据属性来分配作用域样式
-
在
styles文件夹中创建一个新的样式表global.scss,其中只包含对主body元素的样式:/* /src/styles/global.scss */body {font-family: 'Avenir', Helvetica, Arial,sans-serif;margin: 0;} -
将此样式表导入到你的
App.vue中:<style lang="scss">@import './styles/global.scss';</style>
我们的应用应该与之前渲染相同;只是所有元素的字体族应更改为 Avenir,并且主body不应有任何边距,如下所示:

图 1.43 – 练习 1.03 的正确作用域样式
在这个练习中,我们从一个数组中插值了数据,并学习了 SCSS 的一些基本语法。然后,我们使用scoped SCSS 的形式来设计我们的组件,这些scoped SCSS 可以存在于<style>标签内部,或者从另一个目录导入到我们的项目中。
在下一节中,我们将通过 Vue 3 的特性来实验如何为组件编写动态 CSS。
在 Vue 3 中设置状态驱动的动态 CSS
Vue 3.x 引入了一个新的 CSS 函数,v-bind(),用于 Vue SFC 的style部分。我们使用这个函数在本地数据和 CSS 值之间创建单向链接。
在底层,Vue 引擎使用 CSS 自定义属性(或 CSS 变量)来计算从v-bind()接收到的动态样式。对于每个v-bind(),它生成一个带哈希的自定义属性(带有--前缀)并将其添加到组件的根元素。所有自定义属性都作为行内静态样式添加,并且将在链接的本地数据值更改时更新。
例如,让我们有一个组件,它打印出一个title并包含一个本地数据属性,headingStyles。headingStyles数据对象包含多个字段,如marginTop、textAlign和color,表示相关的 CSS 属性:
<template>
<h1>{{ title }}</h1>
</template>
<script>
export default {
data() {
return {
title: 'Binding with v-bind example',
headingStyles: {
marginTop: '10px',
textAlign: 'center',
: '#4fc08d',
}
}
}
}
</script>
到目前为止,输出没有自定义样式,如下所示:

图 1.44 – 不使用 v-bind()和自定义 CSS 显示标题
现在,我们可以通过应用v-bind()将headingStyles绑定到<style>部分中h1的 CSS 样式上:
<style>
h1 {
margin-top: v-bind(headingStyles.marginTop);
text-align: v-bind(headingStyles.textAlign);
color: v-bind(headingStyles.color);
}
</style>
现在的输出将启用自定义 CSS:

图 1.45 – 应用 v-bind()和自定义 CSS 的输出
如果你打开元素选项卡中的h1元素,你会看到它有行内样式,如图1.47所示:

图 1.46 – Devtools 检查显示带有哈希自定义属性的行内样式
由于 v-bind() 是 Vue 3.x 的功能,它也支持使用 script setup 定义的本地变量。您可以使用 script setup 标准重新编写代码,输出保持不变。
v-bind() 也支持 JavaScript 表达式。要使用 JavaScript 表达式,您需要将它们用引号括起来。例如,我们可以从上一个示例中获取 headingStyles 并重新定义 marginTop 为一个数字:
headingStyles: {
marginTop: 10,
textAlign: 'center',
color: '#4fc08d',
}
在 <style> 部分,让我们计算 h1 选择器的 margin-top 并添加 5px,并添加 px 后缀:
<style>
h1 {
margin-top: v-bind('`${headingStyles.marginTop + 5}px`');
text-align: v-bind(headingStyles.textAlign);
color: v-bind(headingStyles.color);
}
</style>
现在的输出现在具有 15px 的上边距,如 图 1**.48 所示:

图 1.47 – 为 margin-top 生成的自定义属性为 15px
使用 v-bind() 对于动态和程序化地定义主题非常有用。然而,它只提供了从本地数据到样式的单向绑定,而不是相反。在下一节中,我们将探索使用 CSS 模块的反向绑定方向。
理解 CSS 模块
最近在响应式框架领域流行的一种模式是 CSS 模块。前端开发始终面临 CSS 类名冲突、结构不良的 BEM 代码和混乱的 CSS 文件结构的问题。Vue 组件通过模块化和允许您在编译时组合生成特定组件的唯一类名的 CSS 来帮助解决这个问题。
在 Vue 中使用 CSS 模块将 CSS 样式从 style 部分导出为 JavaScript 模块,并在模板和逻辑计算中使用这些样式。
要在 Vue 中启用此功能,您需要将 module 属性添加到 style 块中,并使用 :class 和 $style.<class name> 语法引用类,如下所示:
<template>
<div :class="$style.container">CSS modules</div>
</template>
<style module>
.container {
width: 100px;
margin: 0 auto;
background: green;
}
</style>
一旦启用了 CSS 模块,Vue 引擎会暴露一个 $style 对象,其中包含所有定义的选择器作为对象,用于 template 部分的内部使用,以及 this.$style 用于组件的 JavaScript 逻辑内部。在上一个示例中,您使用 $style.container 将为 .container 类选择器定义的 CSS 样式绑定到 div 上。
如果您检查了 DOM 树,该类将被命名为类似 .container_ABC123 的名称。如果您要创建多个组件,这些组件具有像 .container 这样的语义化类名但使用 CSS 模块,您将永远不会再次遇到样式冲突。
现在,让我们练习使用 CSS 模块来对 Vue 组件进行样式设计。
练习 1.12 – 使用 CSS 模块对 Vue 组件进行样式设计
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Exercise1.12。
让我们从执行以下步骤开始:
-
使用
npm init vue@3生成的应用程序作为起点,或在代码仓库的根目录下,使用以下命令按顺序导航到Chapter01/Exercise1.12文件夹:> cd Chapter01/Exercise1.12/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components目录下创建一个名为Exercise1-12.vue的新 Vue 组件文件。 -
在
Exercise1-12.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> -
添加
<style>块,并将module作为属性而不是scoped:<style 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.48 – 使用 CSS 模块生成的输出
- 如果您检查虚拟 DOM,您将看到它如何为绑定元素应用了唯一的类名:

图 1.49 – 生成的 CSS 模块类
在这个练习中,我们看到了如何在 Vue 组件中使用 CSS 模块,以及它与 CSS 命名空间的不同之处。
结合文件拆分和导入 SCSS,使用 CSS 模块是此处作用域组件样式的首选方法。这安全地确保了单个组件样式和业务规则不会相互覆盖,并且不会因组件特定的样式要求而污染全局样式和变量。
可读性很重要。类名也暗示了组件名称,而不是 v-data 属性,这在调试大型项目时可能很有用。
在下一节中,您将应用本章学到的知识,通过结合指令、循环、双向数据和 Vue 组件的方法声明,以及作用域 CSS 样式,构建一个动态购物清单应用。
活动摘要 1.01 – 使用 Vue 构建动态购物清单应用
要访问此活动的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter01/Activity1.01
本活动旨在利用您迄今为止对 SFC 基本特性的了解,例如表达式、循环、双向绑定和事件处理。
此应用程序应允许用户创建和删除单个列表项,并一键清除整个列表。
以下步骤将帮助您完成活动:
-
在一个组件中使用绑定到
v-model的输入创建一个交互式表单。 -
添加一个输入字段,用户可以添加购物清单项目。允许用户通过将方法绑定到
@keyup.enter事件来使用 Enter 键添加项目。 -
用户可以通过删除所有项目或逐个删除它们来清除列表。为了方便起见,你可以使用一个
delete方法,该方法可以将数组位置作为参数传递,或者简单地用空数组[]覆盖整个购物清单数据属性。
预期结果如下:

图 1.50 – 活动 1.01 的预期输出
摘要
在本章中,你学习了如何使用命令提示符和 Vite 创建并运行 Vue 项目。你还学习了如何创建基本的 Vue 组件。在这些 Vue 组件中,你可以构建模板,使用 Vue 的独特指令和 HTML 语法糖来遍历数据或使用条件语句控制 DOM 状态。通过实际例子展示了使用 Vue 方法和生活周期的反应性数据的关键概念,并证明其是有用的。
在下一章中,我们将学习更多高级的反应性数据概念,这些概念将建立在第一章的基础上:使用计算属性和观察者以及从外部源获取异步数据。
第二章:与数据一起工作
在上一章中,您学习了 Vue API 的基本知识以及如何与单文件 Vue 组件一起工作。在这些基础之上,本章进一步探讨了在 Vue 组件中控制数据的不同方法。
您将学习如何通过计算属性利用 Vue 强大的数据响应性和缓存系统,以及如何设置高级监视器来观察组件的数据变化。您还将学习如何利用异步方法获取和处理 Vue 组件的数据。到本章结束时,您将能够监视、管理和操作 Vue.js 组件中的各种来源的数据。
因此,在本章中,我们将涵盖以下主题:
-
理解计算属性
-
理解计算属性设置器
-
探索监视器
-
监视嵌套属性
-
探索异步方法和数据获取
-
比较方法、监视器和计算属性
技术要求
在本章中,您需要按照第一章中“开始您的第一个 Vue 项目”的说明设置一个基本的 Vue 项目。您可以通过创建一个单文件 Vue 组件来轻松练习提到的示例和概念。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02。
理解计算属性
计算属性是独特的数据类型,只有当用于属性的源数据更新时,它们才会响应式地更新。通过将数据属性定义为计算属性,我们可以执行以下操作:
-
在原始数据属性上应用自定义逻辑以计算计算属性的值
-
跟踪原始数据属性的更改以计算计算属性的更新值
-
在 Vue 组件的任何地方重用计算属性作为本地数据
默认情况下,Vue 引擎自动缓存计算属性,这使得它们在更新 UI 方面比使用data返回值的属性或使用 Vue 组件的方法更高效。
计算属性的语法类似于编写一个带有返回值的组件方法,嵌套在 Vue 组件的计算属性下:
export default {
computed: {
yourComputedProperty() {
/* need to have return value */
}
}
}
在计算属性的逻辑中,您可以使用this实例访问任何组件的数据属性、方法或其他计算属性,this实例是对 Vue 组件实例本身的引用。使用this实例的示例如下:
export default {
data() {
return {
yourData: "your data"
}
},
computed: {
yourComputedProperty() {
return `${this.yourData}-computed`;
}
}
}
让我们看看一些应该考虑使用计算属性的示例:
-
input字段,它附加到name数据属性上,而error是一个计算属性。如果name包含一个falsy值(这意味着name是一个空字符串、0、undefined、null或false),则error将被分配一个值为"Name is required"的值。否则,它将为空。组件随后根据error属性的值渲染相应的值:<template><input v-model="name"><div><span>{{ error }}</span></div></template><script>export default {data() {return {name: '',}},computed: {error() {return this.name ? '' : 'Name is required'}}}</script>
当用户修改 name 值时,错误计算属性会自动更新自己。因此,当 name 为空时,输出将如下所示:

图 2.1 – 错误计算属性的输出
当 name 有效时,输出将仅显示填充的输入字段:

图 2.2 – 当 name 包含有效值时的错误输出
-
title和surname– 合并成一个计算字符串,formalName,并使用template渲染其值:<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。此数据对象有一个嵌套的 fields 属性,其中包含几个附加信息对象,例如 author 的全名和一个 entries 对象数组。entries 中的每个条目都包含进一步的信息,例如 title、content 和一个表示条目是否应被特色显示的 feature 标志:
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
}]
}
}
}
},
在此场景中,你需要执行以下步骤:
-
显示帖子的
author的全名。 -
计算并显示包含的
entries的总数。 -
显示具有开启
feature标志的entries列表(feature: true)。 -
通过使用计算属性,我们可以将之前的
post对象解耦成几个计算数据属性,同时保持原始的post对象不变,如下所示:-
fullName用于合并post.fields.author的firstName和lastName:fullName() {const { firstName, lastName } =this.post.fields.author;return `${firstName} ${lastName}`}, -
totalEntries包含post.fields.entries数组的长度:totalEntries () {return this.post.fields.entries.length}, -
featuredEntries包含基于每个条目的feature属性的post.fields.entries过滤列表,通过使用内置的filter数组方法:featuredEntries() {const { entries } = this.post.fields;return entries.filter(entry => !!entry.featured)}
-
然后你使用简化和语义化的计算属性在你的组件模板中渲染信息。完整的代码如下所示:
<template>
<div>
<p>{{ fullName }}</p>
<p>{{ totalEntries }}</p>
<p>{{ featuredEntries }}</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() {
const { firstName, lastName } =
this.post.fields.author;
return `${firstName} ${lastName}`
},
totalEntries () {
return this.post.fields.entries.length
},
featuredEntries() {
const { entries } = this.post.fields;
return entries.filter(entry => !!entry.featured)
}
}
</script>
这将生成以下输出:

图 2.3 – 计算名称输出
计算属性对于创建高性能组件的 Vue 开发者来说非常有价值。在下一个练习中,我们将探讨如何在 Vue 组件中使用它们。
练习 2.01 – 将计算数据实现到 Vue 组件中
在这个练习中,你将使用计算属性来帮助你减少在 Vue 模板中需要编写的代码量,通过简洁地输出基本数据。
要访问此练习的代码,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02/Exercise2.01。
我们将实现一个组件,该组件接收用户的姓氏和名字输入,并相应地显示用户的完整姓名,通过以下步骤进行:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录下,使用以下命令导航到Chapter02/Exercise2.01文件夹:> cd Chapter02/Exercise2.01/> yarn -
在项目目录中打开练习项目(在
code .命令中),或者在你的首选集成开发环境(IDE)中打开。 -
让我们在
./src/components/文件夹中添加一个名为Exercise2-01.vue的新 Vue 组件:

图 2.4 – 组件目录层次结构
-
打开
Exercise2-01.vue,让我们为 Vue 组件创建代码块结构,如下所示:<template></template><script>export default {}</script> -
在
<template>中,创建一个用于名字的input字段,并使用v-model将data属性firstName绑定到该字段:<input v-model="firstName" placeholder="First name" /> -
创建一个用于姓氏的第二个
input字段,并使用v-model将data属性lastName绑定到该字段:<input v-model="lastName" placeholder="Last name" /> -
通过在
data()函数中返回它们,将这些新的v-model数据属性包含在 Vue 实例中:data() {return {firstName: '',lastName: '',}}, -
创建一个名为
fullName的计算数据变量:computed: {fullName() {return '${this.firstName} ${this.lastName}'},}, -
在你的
input字段下方,使用h3标签输出计算数据:<h3 class="output">{{ fullName }}</h3> -
最后,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000,并输入John作为名字,Doe作为姓氏,页面将生成以下输出:

图 2.5 – 计算数据的输出将显示姓氏和名字
本练习演示了如何在计算数据属性中使用从v-model接收的数据编写表达式,然后使用fullName计算属性将名字和姓氏合并成一个可重用的输出变量。
我们现在理解了计算属性的工作原理以及如何编写声明式、可重用和响应的计算属性。接下来,我们将探讨如何拦截计算属性的突变过程,并使用计算属性设置器功能添加额外的逻辑。
理解计算属性设置器
默认情况下,计算数据仅是获取器,这意味着它只会输出你表达式的结果。在一些实际场景中,当计算属性被修改时,你可能需要触发外部 API 或在项目的其他地方修改原始数据。执行此功能的函数称为设置器。
在计算属性中使用设置器允许你响应式地监听数据并触发一个包含从获取器返回的值的回调(设置器),这个值可以可选地用于设置器中。
但首先,让我们看看 JavaScript ES5 的获取器和设置器。从 ES5 开始,你可以使用内置的获取器和设置器来定义对象访问器,如下所示:
-
get用于将对象属性绑定到函数,每当该属性被查询时,该函数都会返回该属性的值,如下所示:const obj = {get example() {return 'Getter'}}console.log(obj.example) //Getter -
set用于将特定对象属性绑定到函数,每当该属性被修改时:const obj = {set example(value) {this.information.push(value)},information: []}obj.example = 'hello'obj.example = 'world'console.log(obj.information) //['hello', 'world']
基于这些功能,Vue.js 为我们提供了类似的功能,get()作为获取器,set()作为设置器,用于特定的计算属性:
computed: {
myComputedDataProp: {
get() {}
set(value) {}
}
}
为了理解设置器和获取器是如何工作的,让我们执行以下步骤:
-
定义
myComputedDataProp返回的值,每当myComputedDataProp被查询时,为this.count + 1:myComputedDataProp: {get() {return this.count + 1}}, -
然后,每当
myComputedDataProp被修改时,使用设置器来更新count数据属性到其新值,然后调用组件内的一个方法callAnotherApi,并使用这个新的this.count值:myComputedDataProp: {set(value) {this.count = value - 1this.callAnotherApi(this.count)},
count和callAnotherApi分别是组件的局部数据和方法的名称。
完整的示例代码如下:
data() {
return {
count: 0
}
},
method: {
callAnotherApi() { //do something }
},
computed: {
myComputedDataProp: {
get() {
return this.count + 1
},
set(value) {
this.count = value - 1
this.callAnotherApi(this.count)
},
},
},
}
在这里,计算属性myComputedDataProp将在你的 Vue 组件中输出1。
你将在以下练习中找到如何使用计算数据作为获取器和设置器的确切方法。
练习 2.02 – 使用计算设置器
在这个练习中,你将使用一个计算属性作为设置器和获取器,这两个属性在用户输入触发时都会输出表达式并设置数据。
我们将实现一个组件,该组件包含一个input字段,用于接收用户输入的数字,计算输入的一半值,然后在 UI 上显示这两个值,通过以下步骤完成:
-
使用通过
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录中,使用以下命令导航到Chapter02/Exercise2.02文件夹:> cd Chapter02/Exercise2.02/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者使用你喜欢的 IDE。 -
让我们创建一个新的 Vue 组件
Exercise2-02,通过将Exercise2-02.vue文件添加到./src/components/文件夹中:

图 2.6 – 组件目录层次结构
-
打开
Exercise2-02.vue,让我们为 Vue 组件创建以下代码块结构:<template></template><script>export default {}</script> -
创建一个
input字段,其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: {// getterget() {return this.count + 1},// setterset(val) {this.count = val - 1},},},}</script>
上述代码的输出将如下所示:

图 2.7 – 计算 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除以2,并将这个新值绑定到divideByTwo变量:set(val) {this.count = val - 1this.divideByTwo = val / 2}, -
最后,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000,并键入输入1000,divideByTwo值的输出应该生成从input字段中输入的值的输出,如下所示:

图 2.8 – divideByTwo 值的输出
这个练习演示了我们可以如何使用计算数据通过将计算变量绑定到v-model来在我们的模板中反应性地获取和设置数据。在下一节中,我们将探讨我们可以如何使用观察者来积极监听组件数据或其属性的变化。
探索观察者
Vue oldVal和newVal。这可以帮助您在写入或绑定新值之前编写表达式来比较数据。观察者可以观察对象以及其他类型,如string、number和array类型。
在第一章《开始您的第一个 Vue 项目》中,我们介绍了在组件生命周期中特定时间运行的生存周期钩子。如果在一个观察者上设置了immediate键为true,那么当这个组件初始化时,它将在创建时运行这个观察者。您可以通过包含键和值deep: true(默认为false)来观察任何给定对象内的所有键。
为了清理您的观察者代码,您可以将一个handler参数分配给定义好的组件的方法,这在大型项目中被认为是最佳实践。
观察者补充了计算数据的用法,因为它们被动地观察值,不能用作正常的 Vue 数据变量,而计算数据必须始终返回一个值,并且可以被查询。记住不要使用箭头函数,如果您需要 Vue 上下文中的this。
以下示例演示了immediate和deep可选键;如果myDataProperty对象中的任何键发生变化,它将触发控制台日志:
watch: {
myDataProperty: {
handler: function(newVal, oldVal) {
console.log('myDataProperty changed:', newVal,
oldVal)
},
immediate: true,
deep: true
},
}
现在,让我们在观察者的帮助下设置一些新值。
练习 2.03 – 使用观察者设置新值
在这个练习中,您将使用观察者参数来观察数据属性的变化,然后使用此观察者通过方法设置变量。
您可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02/Exercise2.03找到此练习的完整代码。
我们创建了一个 Vue 组件,用于显示折扣前后的商店观察者价格,并提供更新折扣价格的功能,按照以下说明进行操作:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录中,使用以下命令导航到Chapter02/Exercise 2.03文件夹:> cd Chapter02/Exercise 2.03./> yarn -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或使用您首选的 IDE。 -
让我们创建一个新的 Vue 组件
Exercise2-03,通过将Exercise2-03.vue文件添加到./src/components/文件夹中:

图 2.9 – 组件目录层次结构
-
打开
Exercise2-03.vue,让我们为 Vue 组件创建代码块结构,如下所示:<template></template><script>export default {}</script> -
通过添加
discount和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> -
我们想监听
discount属性的变化。这可以通过将其添加到watch对象中,并手动将oldDiscount值更新为接收到的oldValue来实现:watch: {discount(newValue, oldValue) {this.oldDiscount = oldValue},}, -
现在,让我们添加一个名为
updateDiscount的组件方法。在方法内部,将oldDiscount数据属性设置为this.discount + 5:methods: {updateDiscount() {this.discount = this.discount + 5},}, -
然后使用
@click指令将此方法绑定到button上,以便在用户点击按钮时触发此方法,并相应地触发观察者:<button @click="updateDiscount">Increase Discount!</button> -
添加一些 CSS 样式,使我们的组件看起来更美观:
<style scoped>.container {margin: 0 auto;padding: 30px;max-width: 600px;font-family: 'Avenir', Helvetica, Arial, sans-serif;margin: 0;}button {display: inline-block;background: rgb(235, 50, 50);border-radius: 10px;font-size: 14px;color: white;padding: 10px 20px;text-decoration: none;}</style> -
最后,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000时,前面命令的输出将如下所示:

图 2.10 – 商店观察者页面示例输出
在这个练习中,我们探讨了如何使用观察者来观察和动态操作数据,当数据发生变化时通过触发 Vue 组件中的其他方法。
接下来,我们将学习如何通过深度观察来主动观察数据对象中的特定嵌套属性。
观察嵌套属性
当使用 Vue.js 观察数据属性时,您可以观察对象嵌套键的变化,而不是观察对象本身的变化。
这通过将可选的deep属性设置为true来完成:
data() {
return {
organization: {
name: 'ABC',
employees: [
'Jack', 'Jill'
]
}
}
},
watch: {
organization: {
handler(v) {
this.sendIntercomData()
},
deep: true,
immediate: true,
},
},
此代码示例演示了如何观察organization数据对象内部的所有可用键的变化。如果organization中的name属性发生变化,organization观察者将触发。
如果你不需要观察对象内的每个键,通过指定 <object>.<key> 字符串语法来为特定键分配观察者会更高效。例如,你可能允许用户编辑他们的公司名称,并在该特定键的值被修改时触发 API 调用。
在以下示例中,观察者明确地观察了 organization 对象的 name 键:
data() {
return {
organization: {
name: 'ABC',
employees: [
'Jack', 'Jill'
]
}
}
},
watch: {
'organization.name': {
handler: function(v) {
this.sendIntercomData()
},
immediate: true,
},
},
我们已经看到了深度观察是如何工作的。现在,让我们尝试下一个练习,并观察数据对象的嵌套属性。
练习 2.04 – 观察数据对象的嵌套属性
在这个练习中,你将使用观察者来观察对象内的键,当用户在 UI 中触发方法时,这些键会更新。
练习的完整代码可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02/Exercise2.04 找到。
按照说明创建一个组件,该组件显示产品的标签和价格,并动态修改折扣价格:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令导航到Chapter02/Exercise2.04文件夹:> cd Chapter02/Exercise2.04/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者使用你偏好的 IDE。 -
让我们通过将
Exercise2-04.vue文件添加到./src/components/文件夹中,创建一个新的 Vue 组件,命名为Exercise2-04:

图 2.11 – 组件目录层次结构
-
在
Exercise2-04.vue中,让我们首先定义一个包含price和label的product对象,以及一个discount键。将这些值输出到模板中:<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> -
为我们的组件添加 CSS 样式:
<style scoped>.container {margin: 0 auto;padding: 30px;max-width: 600px;font-family: 'Avenir', Helvetica, sans-serif;margin: 0;}button {display: inline-block;background: rgb(235, 50, 50);border-radius: 10px;font-size: 14px;color: white;padding: 10px 20px;text-decoration: none;}</style> -
使用以下命令运行应用程序,并在浏览器中访问
http://localhost:3000来查看渲染的组件。yarn dev -
现在,让我们设置一个按钮,该按钮将修改产品的价格。我们通过添加一个
button元素,并将其click事件绑定到一个updatePrice方法(该方法减少价格值)来实现这一点:<template>//…<button @click="updatePrice">Reduce Price!</button>//...</template><script>//...methods: {updatePrice() {if (this.product.price < 1) returnthis.product.price--},},//...</script>
当你点击按钮时,它应该减少价格,如以下截图所示:

图 2.12 – 显示 Blue juice 减少价格的屏幕
-
到了嵌套观察者的时间了。我们将观察
product对象的price属性,并增加discount数据属性:watch: {'product.price'() {this.discount++},},
现在,当你减少 price 时,由于观察者的作用,discount 值将会上升:

图 2.13 – 显示增加折扣值的输出
在这个练习中,我们使用了观察者来观察对象内的一个键,然后使用或未使用观察者解析的可选参数设置新数据。
在下一节中,我们将探讨如何使用 Vue 组件的异步方法获取和处理数据。
探索异步方法和数据获取
JavaScript 中的异步函数由 async 语法定义,并返回一个 Promise。这些函数通过事件循环异步操作,使用隐式 Promise,这是一个可能在未来返回结果的对象。
作为 JavaScript 语言的一部分,你可以在 Vue 组件的方法中声明异步代码块,通过在方法前包含 async 关键字来实现。
你可以使用 Promise 链式方法,例如 then() 和 catch() 函数,或者在 Vue 方法中使用 ES6 的 await 语法,并相应地返回结果。
以下是一个示例,使用内置的 fetch API 在组件方法中作为异步函数使用 async/await 关键字获取数据:
export default {
methods: {
async getAdvice() {
const response =
await fetch('https://api.adviceslip.com/advice')
return response;
},
},
}
Axios 是一个流行的 JavaScript 库,它允许你使用 Node.js 发起外部数据请求。它具有广泛的浏览器支持,使其在制作 HTTP 或 API 请求时成为一个多才多艺的库。我们将在下一个练习中使用这个库。
练习 2.05 – 使用异步方法从 API 获取数据
在这个练习中,你将异步从外部 API 源获取数据,并使用计算属性在前端显示它。
你可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02/Exercise2.05 找到这个练习的完整代码。
我们将创建一个组件,按照以下说明从外部数据源获取引言并在 UI 上显示:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令导航到Chapter02/Exercise2.05文件夹:> cd Chapter02/Exercise2.05/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者使用你偏好的 IDE。 -
让我们通过将
Exercise2-05.vue文件添加到./src/components/文件夹来创建一个新的 Vue 组件Exercise2-05:

图 2.14 – 组件目录层次结构
-
在
Exercise2-05.vue中,让我们首先将axios导入到我们的组件中,并创建一个名为fetchAdvice()的方法。我们使用axios调用api.adviceslip.com/advice的响应,然后使用console.log输出结果。同时,让我们包括一个按钮,该按钮将click事件绑定到fetchAdvice()调用:<template><div class="container"><h1>Async fetch</h1><button @click="fetchAdvice()">Learn somethingprofound</button></div></template><script>import axios from 'axios'export default {methods: {async fetchAdvice() {return axios.get('https://api.adviceslip.com/advice').then((response) => {console.log(response)})},},}</script><style 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> -
最后,使用以下命令运行应用程序:
yarn dev
在浏览器中访问 http://localhost:3000 后,前面命令的输出将如下所示:

图 2.15 – 屏幕显示控制台中的一个非常大的对象
-
我们只对
response对象中的数据对象感兴趣。将此数据对象分配给名为response的 Vue 数据属性,我们可以重用它:export default {data() {return {axiosResponse: {},}},methods: {async fetchAdvice() {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="fetchAdvice()">Learn somethingprofound</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 fetchAdvice() {return axios.get('https://api.adviceslip.com/advice').then(response => {this.axiosResponse = response.data})},},}</script>
图 2.16 显示了前面代码生成的输出:

图 2.16 – 屏幕显示模板中引用输出
-
作为最后的润色,包括一个
loading数据属性,以便用户可以看到 UI 是否正在加载。默认将loading设置为false。在fetchAdvice方法内部,将loading设置为true。当 GET 请求完成(解析/拒绝)时,在finally()链中,使用setTimeout函数在 4 秒后将它设置回false。你可以使用三元运算符在加载状态和默认状态之间更改按钮文本:<template><div class="container"><h1>Async fetch</h1><button @click="fetchAdvice()">{{loading ? 'Loading...' : 'Learn somethingprofound'}}</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 fetchAdvice() {this.loading = truetry {const response = await axios.get(https://api.adviceslip.com/advice);this.axiosResponse = response.data;} catch (error) {console.log(error);} finally {setTimeout(() => {this.loading = false;}, 4000);}},},}</script>
前面代码的输出将如下所示:

图 2.17 – 屏幕显示模板中加载按钮状态输出
在这个练习中,我们看到了如何从外部源获取数据,将其分配给计算属性,在模板中显示它,并应用加载状态到我们的内容上。
到目前为止,我们已经探讨了处理 Vue 组件本地数据的不同方法。在下一节中,我们将检查每种方法的优缺点。
比较方法、监视器和计算属性
方法最好用作 DOM 中发生的事件的处理程序,以及在需要调用函数或执行 API 调用的情况下,例如 Date.now()。所有由方法返回的值都不会被缓存。
例如,你可以组合一个由 @click 标记的动作,并引用一个方法:
<template>
<button @click="getDate">Click me</button>
</template>
<script>
export default {
methods: {
getDate() {
alert(Date.now())
}
}
}
</script>
当用户点击 点击我 按钮时,此代码块将显示一个带有当前 Unix 纪元时间的警告栏。不应使用方法来显示计算数据,因为与计算属性不同,方法的返回值不会被缓存,如果误用,可能会对你的应用程序产生性能影响。
如前所述,计算属性最好用于响应数据更新或在模板中组合复杂表达式。在以下示例中,如果 animalList 数据发生变化,animals 计算属性将通过从数组中切片第二个项目并返回新值来更新:
<template>
<div>{{ animals }}</div>
</template>
<script>
export default {
data() {
return {
animalList: ['dog', 'cat']
}
},
computed: {
animals() {
return this.animalList.slice(1)
}
}
}
</script>
它们的响应性特性使得计算属性非常适合从现有数据中组合新的数据变量,例如当你引用更大的、更复杂对象的特定键时。
计算属性也有助于提高 Vue 组件模板和逻辑的可读性。在以下示例中,我们以两种不同的方式输出作者,但通过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.06 – 使用 Vue 方法、监视器和计算属性处理搜索功能
在这个练习中,你将创建一个组件,允许用户使用 Vue 中的三种不同方法搜索数据数组。到练习结束时,你将能够看到每种不同方法是如何工作的。
我们将创建一个组件,根据三个input字段显示三个不同的过滤列表,每个列表使用本主题中讨论的不同方法,按照以下说明进行:
-
使用
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令导航到Chapter02/Exercise 2.06文件夹:> cd Chapter02/Exercise 2.06/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者使用你偏好的 IDE。 -
让我们通过将
Exercise2-06.vue文件添加到./src/components/文件夹中,创建一个新的 Vue 组件,名为Exercise2-06:

图 2.18 – 组件目录层次结构
-
在
Exercise2-06.vue中,在data对象内,添加一个框架列表到数组中,并将其分配给frameworkList属性。同时声明一个空字符串的input属性和初始值为空数组的methodFilterList:<script>export default {data() {return {// SharedframeworkList: ['Vue','React','Backbone','Ember','Knockout','jQuery','Angular',],// Methodinput: '',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"><inputtype="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 {// SharedframeworkList: ['Vue','React','Backbone','Ember','Knockout','jQuery','Angular',],// Methodinput: '',methodFilterList: [],}},methods: {searchMethod(e) {console.log(e)},},}</script> -
然后添加一些 CSS 样式,使输出看起来更美观:
<style 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> -
在终端中,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000后,前面命令的输出将如下所示:

图 2.19 – 键输入的控制台输出
-
在我们的
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:20*所示:

图 2.20 – 你应该能够使用 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 {...// Computedinput2: '',...}},...computed: {computedList() {return this.frameworkList.filter(item => {return item.toLowerCase().includes(this.input2.toLowerCase())})},},...}</script>
现在你应该能够使用计算属性帮助过滤框架的第二列,如下面的屏幕截图所示:

图 2.21 – 使用计算属性过滤框架的第二列
-
最后,让我们使用监视器来过滤相同的列表。包括一个带有空字符串的
input3属性和一个带有空数组的watchFilterList属性。还要创建一个第三个div列,其中包含一个绑定到input3v-model的输入框,以及输出watchFilterList数组的列表:<template><div class="container">…<div class="col"><input type="text" placeholder="Search withwatcher"v-model="input3" /><ul><li v-for="(item, i) in watchFilterList":key="i">{{ item }}</li></ul></div></div></template><script>export default {data() {return {...// Watcherinput3: '',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.22 – 在第三列使用监视器过滤列表
在这个练习中,我们看到了如何使用方法、计算属性和监视器来实现过滤列表。
本节简要介绍了三种方法。每种方法都有其优缺点,选择最合适的方案或组合方案需要实践和进一步理解每个用例或项目目标。
在下一节中,我们将应用本章学到的知识,通过创建一个使用计算属性、方法和外部数据 API 查询的监视器的博客列表应用程序。
活动二.01 – 使用 Contentful API 创建博客列表
要访问此活动的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter02/Activity2.01
本活动旨在通过构建一个列出文章的博客来利用您关于应用不同方法与外部数据 API 源工作的知识。此应用程序活动将通过使用所有基本的 async 方法从 API 获取远程数据并使用计算属性来组织深层嵌套的对象结构来测试您的 Vue 知识。
Contentful 是一个无头 内容管理系统(CMS),允许您将内容与代码存储库分开管理。您可以使用 API 在所需的任何代码存储库中消费此内容。例如,您可能有一个作为信息主要来源的博客网站,但您的客户希望在另一个域上有一个独立的页面,该页面只拉取最近推出的文章。使用无头 CMS 本质上允许您开发这两个独立的代码库并使用相同的数据源。
本活动将使用 Contentful 无头 CMS。访问密钥和端点将列在解决方案中。
以下步骤将帮助您完成活动:
-
使用带有 Vite 作为打包管理工具的脚手架工具创建 Vue 项目。
-
使用
yarn add命令将Contentful依赖项(www.npmjs.com/package/contentful)安装到您的项目中。 -
使用计算属性输出 API 响应中的深层嵌套数据。
-
使用数据属性输出用户的姓名、职位和描述。
-
使用 SCSS 为页面添加样式。
预期结果如下:

图 2.23 – 使用 Contentful 博客文章的预期结果
活动完成后,您应该能够使用 async 方法从 API 源中提取远程数据到您的 Vue 组件中。您会发现计算属性是将信息分解成更小的可重复数据块的一种复杂方式。
摘要
在本章中,你被介绍了 Vue.js 的计算和观察属性,这些属性允许你观察和控制响应式数据。你还探索了如何使用方法通过 axios 库异步从 API 获取数据。然后,你学习了如何使用计算属性在 Vue 模板中将接收到的数据动态组合成不同的输出。通过构建搜索功能,演示了使用方法和计算及观察属性之间的区别。
下一章将介绍 Vite 并展示如何使用 Vue DevTools 来管理和调试使用这些计算属性和事件的 Vue.js 应用程序。
第三章:Vite 和 Vue Devtools
在上一章中,你学习了如何利用 Vue 组件的数据响应性,并使用方法、计算属性和观察属性将外部数据查询到组件的数据系统中。本章介绍了 Vite 并展示了如何使用 Vue Devtools 调试这些计算属性和事件。
本章将涵盖以下主题:
-
使用 Vite
-
使用 Vue Devtools
技术要求
最好将你的 Node.js 版本至少设置为 14.18+ 或 16+ 以上。要检查你的 Node 版本,请在命令提示符(或 PowerShell)中运行以下命令:
node -v
你应该将 npm 版本设置为 7.x 以上,因为本章中的所有命令都与 npm 7.x 兼容,并且与 6.x 有细微差别。最后,你应该在本章中安装 Yarn 作为我们的主要包管理工具。
本章的完整代码可在 GitHub 上找到:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter03
使用 Vite
Vite.js 是一个构建管理工具,旨在完成以下任务:
-
帮助你更快地开发(使用更节省时间的方法在本地开发你的项目)
-
构建优化(为生产环境打包文件以获得更好的性能)
-
有效地管理你 Web 项目的其他方面(测试、代码检查等)
它在内部使用 Rollup 打包器来执行 JavaScript 项目的块打包和打包。
从 Vue 3 开始,Vite 取代了 Vue CLI 并成为管理你的 Vue 应用程序的默认构建工具。Vite 还支持 TypeScript,并在处理当前 Web 项目时提供更精简的开发者体验。
要使用 Vite 初始化新的 Vue 项目,你可以使用以下特定命令:
npm init vue@latest
在这种情况下,你需要为 Vite 提供额外的配置,如图 3.1 所示:

图 3.1 – 新 Vue 项目的配置提示
图 3.1 展示了名为 chapter-3-vue-app 的新 Vue 项目的配置,包括以下内容:
-
Vue Router 用于路由管理(我们将在 第七章,路由)和 Pinia 用于状态管理(我们将在 第九章,Vue 的状态管理)进行讨论)
-
Vitest 用于为项目启用单元测试覆盖率
-
ESLint 用于代码检查和 Prettier 用于组织项目代码
基于这些配置,Vite 将使用以下文件结构来搭建所需的项目:

图 3.2 – 由 Vite 创建的新 Vue 项目的文件结构
完成后,Vite 包也将成为项目的依赖包之一。现在您可以运行以下命令:
-
npm run dev或yarn dev:在localhost:3000上本地运行您的项目,其中3000是任意分配的,因为它高于其他计算领域中使用的知名端口号1-1023。如果同时运行多个 Vue 项目,端口号将在项目之间有所不同。 -
npm run build或yarn build:运行生产构建,将您的代码打包成一个或几个小型文件,以便部署到生产环境。 -
npm run lint或yarn lint:运行代码检查过程,这将突出显示代码错误或警告,使您的代码更加一致。 -
npm run preview或yarn preview:在特定端口上运行项目的预览版本,模拟生产模式。 -
npm run test:unit或yarn test:unit:使用 Vitest 运行项目的单元测试。
现在您已经了解了 Vite 是什么以及如何使用它从头开始设置和管理 Vue.js 项目,我们将接下来练习使用 Vite 创建 Vue.js 项目。
练习 3.01 – 设置 Vue 项目
在这个练习中,您将使用 Vite 命令创建您的第一个 Vue.js 项目。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter03/Exercise3.01。
您可以通过以下步骤创建一个 Vue.js 项目:
- 在 Windows 上打开命令提示符(终端)或 PowerShell:

图 3.3 – 一个空的命令提示符窗口
-
运行
npm initvue@3命令。 -
运行前面的命令后,您将被要求安装 Vite 的最新版本。确认操作后,终端将安装 Vite 并提示一系列问题以配置 Vue.js 应用程序。
-
使用导航键盘提供以下截图所示的配置:

图 3.4 – 显示已保存的预设列表
目前,我们将使用 Vue Router、Pinia 和 Vitest 为我们的应用程序提供支持,同时使用 ESLint 和 Prettier 来保持我们的代码整洁和有序。
- 完成后,Vite 将显示运行项目的指令列表,如图 图 3.5 所示:

图 3.5 – Vite 生成的说明
-
导航到您创建的项目目录。
-
运行
yarn命令。输出应如下所示:yarn install v1.22.10info No lockfile found.[1/4] Resolving packages... -
一旦安装包程序完成,请使用以下命令在本地运行您的项目:
yarn dev -
运行前面的命令后,您可以在终端中看到本地服务器,如图 图 3.6 所示:

图 3.6 – 本地开发服务器正在运行
- 点击
Local:部分显示的 URL,您将在浏览器中看到一个默认的 Vue 项目屏幕,如下所示:

图 3.7 – 默认 Vue 项目屏幕将在您的浏览器中显示
我们现在已经学会了如何通过命令提示符使用 Vite 和 Yarn 设置 Vue.js 项目。接下来,我们将探索使用 Vue Devtools 来调试您的应用程序。
使用 Vue Devtools
Vue Devtools 是一个适用于 Chrome 和 Firefox 的浏览器扩展程序,以及一个 Electron 桌面应用程序。您可以从浏览器中安装并运行它,以在开发过程中调试您的 Vue.js 项目。此扩展程序在生产环境中或远程运行的项目中不起作用。您可以从 Chrome 扩展程序页面下载 Vue Devtools 扩展程序,如下截图所示:

图 3.8 – Vue.js Devtools Chrome 扩展程序页面
您还可以从 Firefox (addons.mozilla.org/en-US/firefox/addon/vue-js-Devtools/) 下载 Vue Devtools 扩展程序:

图 3.9 – Vue.js Devtools Firefox 扩展程序页面
Devtools 扩展程序在浏览器开发者工具中揭示有用的信息,包括性能和事件跟踪,这些信息在您的应用程序运行期间针对任何 Vue 组件。一旦启用,扩展程序将在开发者控制台中添加一个 Vue 选项卡。Vue 选项卡显示一个带有多个选项卡的视图,我们将在下面查看。
在 Vue Devtools 视图中,有两个主要选项卡:Components 和 Timeline。
组件选项卡
当您打开 Vue Devtools 选项卡时,Components 选项卡(以前称为 Inspector)默认可见。一旦激活,将出现其他选项卡,具体如下。
侧边操作(右上角)
您可以使用页面图标(右上角)中的 Select 组件从浏览器 UI 中选择 Vue 元素,如图 3.10* 所示。

图 3.10 – 选择组件操作图标
第二个快捷操作是 Refresh,它允许您刷新浏览器中的 Devtools 实例:

图 3.11 – 刷新操作图标
最后,您可以通过单击表示 Settings 的三个点图标来自定义选项卡的外观和感觉:

图 3.12 – Vue.js Devtools 中的设置选项卡
当 Components 选项卡处于活动状态时,应用中的组件树将在左侧面板中可用。右侧面板将显示从树中选择的任何组件的本地状态和详细信息。
有一些小快捷操作,例如检查 DOM,它会直接带你到浏览器 DOM 树中突出显示的组件位置,以及滚动到组件,它会自动滚动到 UI 上的组件并突出显示:

图 3.13 – 组件的快捷操作
接下来,让我们看看 Vue.js Devtools 的另一个选项卡——时间线选项卡。
时间线选项卡
此选项卡记录了应用中发生的所有事件,分为四个主要部分:鼠标事件、键盘事件、组件特定事件和性能事件,如图 3所示。你可以使用此选项卡导航和监控组件发出的自定义事件:

图 3.14 – Vue.js Devtools 中的时间线选项卡
其他插件(Pinia,Vue Router)
如果你安装了额外的 Vue 插件,如 Pinia 或 Vue Router,并且它们支持 Vue Devtools,那么在 组件 选项卡旁边将出现额外的选项卡,包含每个插件的相应信息。图 3展示了当启用时 Vue Router 选项卡的样子:

图 3.15 – 如果选择,Vue Router 的路由选项卡
Vue Devtools 帮助你在开发过程中调试和监控你的 Vue 应用程序。接下来,我们将构建一个 Vue 组件,并使用 Vue Devtools 扩展来检查代码和操作组件内部的本地数据状态。
练习 3.02 – 使用 Devtools 调试 Vue 应用程序
在这个练习中,你将基于前几章学到的知识构建一个 Vue 组件,然后使用 Devtools 进行调试。你需要安装 Chrome、Firefox 或 Edge 浏览器,并启用 Vue Devtools 扩展。
你将使用浏览器开发者控制台中的 Vue 选项卡来检查代码和操作组件的本地数据状态。
你可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter03/Exercise3.02找到这个练习的完整代码。
以 npm init vue@3 生成的应用程序作为起点,或者在你的代码仓库的根目录下,使用以下命令按顺序导航到 Chapter03/Exercise3.02 文件夹:
> cd Chapter03/Exercise3.02/
> yarn
在你的 VS Code 编辑器中打开练习项目(在项目目录中使用 code . 命令)或你喜欢的 IDE。
在 src/components 目录下创建一个新的 Exercise3-02.vue 组件,然后按照以下步骤进行:
-
将组件命名为
Exercise,并在<script>部分使用data()字段创建一个本地数据状态。本地数据状态有一个字符串数组列表—frameworks—每个元素代表一个框架,以及一个空的input数据属性:<script>export default {name: 'Exercise',data() {return {frameworks: ['Vue','React','Backbone','Ember','Knockout','jQuery','Angular',],input: '',}},}</script> -
接下来,创建一个名为
computedList的计算属性,根据input属性值过滤frameworks列表:<script>export default {//…computed: {computedList() {return this.frameworks.filter(item => {return item.toLowerCase().includes(this.input.toLowerCase())})},},}//…</script> -
在 Vue
template块中,添加一个input元素,并使用v-model将input数据属性绑定到它。然后,添加一个<ul>元素,并使用v-for循环属性和<li>元素显示computedList的值:<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>
完整的代码如下所示:
<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>
<script>
export default {
name: 'Exercise',
data() {
return {
frameworks: [
'Vue',
'React',
'Backbone',
'Ember',
'Knockout',
'jQuery',
'Angular',
],
input: '',
}
},
computed: {
computedList() {
return this.frameworks.filter(item => {
return item.toLowerCase().includes(this.input.
toLowerCase())
})
},
},
}
</script>
-
在
App.vue中,用以下代码替换默认代码,以便在我们的应用中渲染组件:<template><Exercise /></template><script>import Exercise from "./components/Exercise3-02.vue";export default {components: {Exercise,},};</script> -
使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000,前面的代码将生成一个组件,该组件将显示一个过滤器输入字段和过滤后的框架列表,如图 图 3**.17 所示:

图 3.16 – 应用应显示列表和过滤器输入
- 在显示的页面上,右键单击并选择 Inspect 以打开开发者控制台或使用 Ctrl + Shift + J 快捷键(macOS 用户:Cmd + Shift + J)。然后,导航到 Vue 选项卡。你应该看到选项卡已打开,如下面的截图所示:

图 3.17 – 开发者控制台中的 Vue 选项卡
- 默认情况下,你将处于
V状态。将发生两件事:在右侧面板中,input数据属性现在具有v和计算列表的值。computedList现在只包含一个值为Vue的元素。
在浏览器中,这些数据将在 UI 中反映出来,如图 图 3**.19 所示:

图 3.18 – 应用选项在 Vue 选项卡中的外观
- 通过单击
input属性旁边的铅笔图标直接在右侧面板中编辑数据:

图 3.19 – 鼠标悬停时出现编辑按钮
- 将
input属性的新值输入为R并按 Enter。DOM 将对来自 Devtools 扩展的直接更改做出反应性更新,如下面的截图所示:

图 3.20 – 实时编辑本地数据值
在 Vue.js Devtools 中更改值后,UI 中的值将反应性地更改,在这个例子中,输入值现在是 R,这随后触发反应性的 computedList 数组,只显示包含字母 R 的值,如图 图 3**.21 所示。
- 切换到
A,然后B,然后V。当你将文本输入到输入框中时,你将看到性能指标以蓝色条形显示,如下面的截图所示:

图 3.21 – 每次输入更改时的渲染性能指标
- 选择中间面板中列出的
Exercise记录事件之一。你可以在右侧面板中观察到信息,包括该特定事件的持续时间(以秒为单位)。这个数字反映了你的组件渲染/更新的时间,如下面的截图所示:

图 3.22 – 选择性能记录并查看详细信息
注意
重复测试将允许你比较基准。然而,如果你刷新页面,你将丢失它们。
在这个练习的结尾,你将知道如何使用 Vue Devtools 的 组件 选项卡来调试组件。你还体验了如何使用 Vue Devtools 扩展的可用功能来观察和编辑数据。最后,你知道如何使用 性能 选项卡来监控组件在应用程序生命周期钩子中的性能。
活动 3.01 – 使用 Vite、Pinia 和 Router 创建 Vue 应用程序
要访问此活动的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter03/Activity3.01
在此活动中,你将使用命令行和 Vite 构建 Vue 项目,并安装 Vue Router 和 Pinia。此活动将测试你控制各种 Vue 工具进行开发的能力。
以下步骤将帮助你完成活动:
-
使用 Vite 创建一个启用 Pinia 和 Vue Router 的新 Vue 项目。
-
创建一个组件来渲染两个字符串输入字段,接收姓氏和名字,并显示接收到的全名。
-
打开 Devtools 扩展并观察 Pinia 和 Vue Router 是否可用于调试。
-
添加一些文本并观察组件在 性能 中的渲染情况。
预期结果如下:

图 3.23 – 最终输出
此活动还提供了 Pinia 和 Routes 选项卡,如 图 3.25* 所示:

图 3.24 – Devtools 选项卡显示 Pinia、Routes 和其他信息
活动完成后,你应该能够使用 Vite 和 Vue Devtools 来管理你的未来 Vue 项目。你会发现,在某些情况下,你需要使用这两个工具来增强你的开发体验并使你的应用程序更加优化。
摘要
在本章中,你学习了 Vite 以及如何通过命令行终端创建由 Vite 管理的 Vue 项目。你还学习了 Vue Devtools 以及如何使用其功能,这些功能将帮助你在这本书的旅程中探索更高级的主题。
下一章将更多地关注高级 Vue 组件功能,例如从一个组件向其嵌套组件传递数据,验证从元素外部接收到的数据,使用插槽自定义组件布局,以及保持组件引用以进行外部控制。
第二部分:构建你的第一个 Vue 应用程序
在这部分,我们将学习如何使用 props 和自定义事件从父组件传递数据到子组件,以及如何使用 Composition API 创建可重用组件逻辑。我们还将学习如何使用路由和动画构建复杂的应用程序结构。我们将逐一讲解每个基本主题,并了解 Vue 如何处理这些主题以及如何通过实际练习有效地使用 Vue。
我们在本节中将涵盖以下章节:
-
第四章,组件嵌套(模块化)
-
第五章,Composition API
-
第六章,全局组件组合
-
第七章,路由
-
第八章,动画和过渡
第四章:组件嵌套(模块化)
在上一章中,我们学习了如何初始化、构建和调试一个简单的 Vue 应用程序。在本章中,您将了解如何使用组件层次结构和嵌套来模块化 Vue 应用程序。本章介绍了 props、事件、prop 验证和插槽等概念。它还涵盖了如何在运行时使用 refs 访问 DOM 元素。
到本章结束时,您将能够使用 props、事件和验证器定义组件之间的通信接口,并准备好为您的 Vue 组件库或 Vue 应用程序构建组件。
本章涵盖了以下主题:
-
传递 props
-
理解 prop 类型验证
-
理解插槽、命名插槽和作用域插槽
-
理解 Vue 的 refs
-
使用事件进行子父通信
技术要求
在本章中,您需要按照 第一章 中 启动您的第一个 Vue 项目 的说明设置一个基本的 Vue 项目。建议创建一个单文件 Vue 组件来轻松练习本章涵盖的示例和概念。
本章的完整代码可在 GitHub 上找到:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04。
传递 props
this) 以及在组件的 template 中。
prop 的值取决于父组件在渲染时传递给子组件的 template 中的内容。
定义一个接受 props 的简单组件
让我们看看一个简单的 HelloWorld 单文件组件。您可以在 ./src/components/HelloWorld.vue 找到它,这是在您使用 Vite 创建 Vue 项目时自动生成的。
注意 msg 值是如何在 props 数组中设置的,并且它是如何使用 {{ msg }} 进行插值的。
Vue 组件的 props 属性可以是一个字符串数组或一个对象字面量,其中每个属性字段都是一个组件的 prop 定义。
当在 props 中定义一个值时,它随后作为实例变量在 Vue 组件的 template 部分中可访问:
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<!-- … -->
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: ['msg']
}
</script>
现在,我们将学习如何使用 props 渲染组件。
将 props 传递给组件
下面的内容将演示如何在我们的 Vue 应用程序中使用 HelloWorld 组件。
首先,我们需要在 App.vue 文件中使用 <``script setup> 导入 HelloWorld:
<script setup>
import HelloWorld from "./components/HelloWorld.vue";
</script>
然后,在 template 部分中,我们需要将 <HelloWorld> 渲染为具有 msg 属性设置为 "Vue.js",如下所示:
<template>
<div id="app">
<HelloWorld msg="Vue.js"/>
</div>
</template>
这将在页面上渲染以下内容:
Hello Vue.js
我们已经看到了如何使用具有来自父组件的 props 的组件。这对于代码重用和将应用程序行为抽象成组件大小的块非常有用。
接下来,我们将查看一个 Greeting 组件的实际示例。
练习 4.01 – 实现问候组件
在这个练习中,我们将创建一个组件,允许你使用我们刚刚学到的从父组件到子组件传递 props 的知识来自定义greeting(例如,Hello、Hey或Hola)和要问候的对象(例如,World、Vue.js或JavaScript 开发者)。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Exercise4.01。
按照以下步骤完成这个练习:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令导航到Chapter04/Exercise4.01文件夹:> cd Chapter04/Exercise4.01/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者在你的首选 IDE 中打开。 -
在
./src/components目录下创建一个名为Greeting.vue的新文件。这将是我们单文件组件。 -
首先,使用带有空
template和script标签的组件脚手架:<template><div>Empty</div></template><script>export default {}</script> -
接下来,我们需要告诉 Vue 我们的组件期望 props。为此,我们将在组件选项对象中添加一个
props属性,作为一个包含两个字段的数组,即greeting和who,如下面的代码块所示:export default {props: ['greeting', 'who']} -
现在,我们想在模板中将
greeting和who渲染如下:<template><div>{{ greeting }} {{ who }}</div></template>
Greeting组件现在已准备好在App.vue中使用。
-
打开
src/App.vue文件,并将Greeting组件从./src/components/Greeting.vue导入到script部分:<script setup>import Greeting from './components/Greeting.vue'</script> -
现在我们可以使用
Greeting在template中:<template><div id="app"><Greeting greeting="Hey" who="JavaScript"/></div></template> -
使用以下命令运行应用程序:
yarn dev -
当你在浏览器中访问你的应用时,你会看到以下内容:
Hey JavaScript -
使用
template中的属性值修改greeting和whoprops:<template><div id="app"><Greeting greeting="Hi" who="everyone"/></div></template> -
在浏览器 DevTools 中打开 Vue 标签页,你会看到两个
greeting和whoprops 的值已经更新:

图 4.1 – Greeting 组件在 Vue 标签页中的输出
现在浏览器显示以下内容:
Hi Everyone
在这个练习中,我们学习了如何使用 props 在父组件和子组件之间启用通信,同时保持组件的可复用性。而不是组件渲染静态数据,其父组件传递数据以进行渲染。
在下一节中,我们将学习如何动态设置 prop 值。
将响应式数据绑定到 props
在上一节中,我们看到了如何将静态数据作为 props 传递给组件。如果我们想从父组件传递响应式数据到子组件怎么办?
这就是使用v-bind:(或简写为:)来启用父组件的响应式数据到子组件 props 的单向绑定。
在以下代码示例中,我们将appWho数据绑定到HelloWorld组件的msgprop:
<template>
<div id="app">
<HelloWorld :msg="appWho"/>
</div>
</template>
<script setup>
import HelloWorld from './components/HelloWorld.vue'
const appWho = 'Vue.js'
</script>
输出将如下所示:
Hello Vue.js
让我们添加两个按钮来改变appWho的值,一个用于JavaScript,另一个用于Everyone,通过触发带有适当值的setWho方法,如下所示:
<template>
<div id="app">
<HelloWorld :msg="appWho"/>
<button @click="setWho('JavaScript')">JavaScript
</button>
<button @click="setWho('Everyone')">Everyone</button>
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
components: {
HelloWorld
},
data () {
return {
appWho: 'Vue.js'
}
},
methods: {
setWho(newWho) {
this.appWho = newWho
}
}
}
</script>
浏览器现在显示以下输出:

图 4.2 – 浏览器显示带有两个按钮的组件
当你点击appWho值并重新渲染带有新值传递给msg属性的HelloWorld子组件时。因此,显示为Hello JavaScript,如下所示:

图 4.3 – 点击 JavaScript 按钮后显示的“Hello JavaScript”
类似地,当你点击Hello Everyone时,如下所示:

图 4.4 – 点击 Everyone 按钮后显示的“Hello Everyone”
如我们所见,我们能够将响应式数据绑定到 props,以便在父组件中更新的任何数据都会相应地反映在子组件的数据中。
练习 4.02 – 将频繁更改的响应式数据传递给 props
在这个练习中,我们将实现一个组件,允许用户更改要问候的人的名字,并将其传递给我们构建在练习 4.01中的Greeting组件。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Exercise4.02。
按照以下步骤完成此练习:
-
使用练习 4.01中构建的应用程序或使用以下命令导航到
Chapter04/Exercise4.02文件夹:> cd Chapter04/Exercise4.02/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用您首选的 IDE。 -
在
App.vue的script部分,让我们从script标签中移除setup属性,并在components字段中注册Greeting作为子组件,如下所示:<script>import Greeting from './components/Greeting.vue'export default {components: {Greeting},}</script> -
然后创建一个返回初始
greeting和who的顶级data方法:<script>export default {/*…*/data() {return {greeting: 'Hello',who: 'Vue.js'}}}</script>
浏览器应显示与练习 4.01相同的输出。
-
我们现在将创建一个
input字段,它接受用户输入的who字符串值,并将who数据绑定到Greeting的who属性:<template><div id="app"><input placeholder="What is your name" v-model="who"><Greeting greeting="Hi" :who="who"/></div></template> -
现在,当你在输入字段中输入任何名字时,问候信息将相应地改变,如下面的截图所示:

图 4.5 – 根据用户输入更新问候值的结果输出
接下来,我们将学习如何为我们的组件 props 添加类型提示和验证以确保它们被正确使用。
理解 prop 类型和验证
我们使用属性来定义 Vue 组件的接口,并确保其他开发者正确使用我们的组件。我们需要使用类型和验证来定义它们的接口。Vue 通过改变我们如何将属性作为字符串元素传递给对象中的 props 属性来提供这种能力。
原始属性验证
假设我们想要一个 Repeat.vue 组件,它接受 times 属性和 content 属性,然后根据 times 的值使用 computed 计算出 repetitions 数组。我们可以定义以下内容:
<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>
在 App.vue 中,我们可以如下使用我们的 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.6 – 重复示例动作输出(无点击)
每次点击 Repeat 按钮,Repeat 组件将显示内容一次,如下所示:

图 4.7 – 五次点击后的重复示例输出
现在,为了使此组件正常工作,我们需要 times 是 Number 类型,理想情况下 content 是 String 类型。
注意
Vue 中的属性类型可以是任何类型,包括 String、Number、Boolean、Array、Object、Date、Function 和 Symbol。
让我们定义 times 属性为 Number 类型,并将 content 属性定义为 String 类型:
<script>
export default {
props: {
times: {
type: Number
},
content: {
type: String
}
},
// rest of component definition
}
</script>
让我们看看如果我们将 App 更新为向 Repeat 传递错误的属性类型会发生什么 – 例如,让我们假设 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.8 – 错误输入属性错误
times 属性检查失败,显示一条消息解释说我们传递了一个 String 给期望接收 Number 的属性:
Invalid prop: type check failed for prop "times". Expected Number with value NaN, got String with value "no-number-here"
同样,对于 content 属性检查,也会出现一条消息,解释说我们传递了一个 Number 作为属性,而这个属性本应该是 String:
Invalid prop: type check failed for prop "content". Expected String with value "55", got Number with value 55
接下来,让我们探索自定义属性类型和联合类型。
理解联合和自定义属性类型
Vue 支持联合类型。例如 [String, Number]。我们通过使用该数据属性对象的 type 字段来声明一个接受联合类型的属性。例如,我们将 content 设置为接受一个联合类型,该类型可以是数字或字符串:
<script>
export default {
props: {
// other prop definitions
content: {
type: [String, Number]
}
}
// rest of component definition
}
</script>
在这种情况下,我们可以如下使用 Repeat 组件而不会出现错误:
<template>
<div id="app">
<Repeat :times="3" :content="55" />
</div>
</template>
我们还可以使用任何有效的 JavaScript 构造函数作为属性的类型,例如 Promise 或自定义的 User 类构造函数,如下面的 TodoList 组件示例所示:
<script>
import User from './user.js'
export default {
props: {
todoListPromise: {
type: Promise
},
currentUser: {
type: User
}
}
}
</script>
注意这里我们从一个文件中导入 User 自定义类型。我们可以如下使用此 TodoList 组件:
<template>
<div>
<div v-if="todosPromise && !error">
<TodoList
:todoListPromise="todosPromise"
:currentUser="currentUser"
/>
</div>
{{ 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>
在前面的代码中,我们只在使用 Vue 挂载组件实例时获取 todosPromise,并使用 new User() 创建一个新的 User 实例。
我们已经看到了如何使用联合和自定义类型来验证 Vue props。
注意
Vue 使用 instanceof 验证内部,所以请确保任何自定义类型都使用相关构造函数实例化。
传递 null 或 undefined 将会导致对 Array 和 Object 的检查失败。
传递一个数组将通过 Object 的检查,因为在 JavaScript 中数组也是 Object 的实例。
接下来,我们将探讨如何为特定类型启用对 props 的验证。
数组和对象的自定义验证
Vue 允许使用 validator 属性将自定义验证器用作 props。这允许我们实现关于对象和集合类型的深入检查。
让我们看看 CustomSelect 组件。
在基本层面上,select 的 prop 接口由一个 options 数组和一个 selected 选项组成。
每个选项都应该有一个 label,它代表在 select 中显示的内容,以及一个 value,代表其实际值。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>
以下示例输出一个 select 元素,其中 Salt & Vinegar 是默认选中的选项,如下面的截图所示:

图 4.9 – 已选中 Salt & Vinegar 的折叠 CustomSelect
以下截图显示了下拉菜单打开时显示的三个口味选项:

图 4.10 – 已打开的 CustomSelect,带有口味选项和已选中 Salt & Vinegar
现在,我们可以实现一个 prop validator 方法来为我们的组件逻辑启用进一步的验证,如下所示:
<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.11 – 当自定义验证器失败时的控制台警告
通过这样,我们已经学习了如何使用自定义验证器对复杂 props 进行深入验证。接下来,我们将学习 prop 的 required 属性是如何工作的。
理解必需 props
要将一个 prop 标记为必需,我们可以使用 required prop 类型属性。
在 CustomSelect 示例中,我们可以通过在 prop 定义中添加 required: true 来使 selected 成为必需的 prop,如下所示:
<script>
export default {
// other component properties
props: {
selected: {
type: String,
required: true
}
// other prop definitions
}
}
</script>
现在,如果我们不在父组件的 CustomSelect 的 selected prop 上传递值,我们将看到以下错误:

图 4.12 – 当选定的必需 prop 缺失时的控制台警告
这样,我们就学会了如何标记属性为必需的,并看到了当我们没有传递必需属性的值时会发生什么。接下来,我们将学习如何为属性设置默认值,并了解为什么这样做是一个好的实践。
设置默认属性值
有时为属性设置默认值是遵循良好实践的好方法。
以 PaginatedList 组件为例。该组件接受一个 items 列表,要显示的项目数 limit,以及 offset 数。然后它将根据 limit 和 offset 值显示项目子集 – currentWindow:
<template>
<ul>
<li
v-for="el in currentWindow"
:key="el.id"
>
{{ el.content }}
</li>
</ul>
</template>
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
limit: {
type: Number
},
offset: {
type: Number
}
},
computed: {
currentWindow() {
return this.items.slice(this.offset, this.limit)
}
}
}
</script>
而不是每次都传递 limit 和 offset 的值,可能更好的做法是将 limit 设置为默认值(例如 2),将 offset 设置为 0(这意味着默认情况下,我们将显示第一页,其中包含 2 个结果)。
我们可以使用每个属性定义对象的默认属性来实现这个更改,如下所示:
<script>
export default {
props: {
// other props
limit: {
type: Number,
default: 2,
},
offset: {
type: Number,
default: 0,
}
},
// other component properties
}
</script>
然后,在 App.vue 中,我们可以使用 PaginatedList 而不传递 limit 和 offset。如果没有传递值,Vue 会自动回退到默认值:
<template>
<main>
<PaginatedList :items="snacks" />
</main>
</template>
<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.13 – 默认只显示前两项的零食列表输出
当你传递 offset 或 limit 的值时,Vue 将使用这些值而不是默认值,并相应地渲染组件。
在属性是数组或对象的情况下,我们无法使用静态数组或对象为其分配 default 值。相反,我们需要分配一个返回所需默认值的函数。例如,我们可以将 PaginatedList 组件的 items 的 default 值设置为空数组,如下所示:
<script>
export default {
props: {
items: {
type: Array,
default() {
return []
}
}
// other props
},
// other component properties
}
</script>
到目前为止,我们已经学会了如何为组件属性设置默认值。请注意,一旦设置了 default 值,就不再需要设置 required 字段。我们可以使用 default 值来确保我们的属性始终有值,无论这是必需的还是可选的。
在 <script setup> 中注册属性(setup 钩子)
如果你使用 <script setup>,由于没有选项对象,我们无法使用 props 字段来定义组件的属性。相反,我们使用 vue 包中的 defineProps() 函数,并将所有相关的属性定义传递给它,就像我们使用 props 字段时做的那样。例如,在 MessageEditor 组件中,我们可以将使用 defineEmits() 的事件注册重写如下:
<script setup>
import { defineProps, computed } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true,
},
limit: {
type: Number
},
offset: {
type: Number
}
});
const currentWindow = computed(() => {
return props.items.slice(props.offset, props.limit)
})
</script>
defineProps() 返回一个包含所有属性值的对象。然后我们可以使用 props.items 在 script 部分访问属性,如 items 一样在 template 部分中。在上一个示例中,我们还使用了 computed() 来声明一个响应式数据 currentWindow,我们将在 第五章 《组合 API》 中进一步讨论其用法。
在下一个练习中,我们将练习编写具有默认值、类型和验证器的组件 props。
练习 4.03 – 验证对象属性
在这个练习中,我们将编写一个 Repeat 组件,该组件接受一个 config 数据 prop,用于传递 times(一个 Number)和 content(一个 String)。
我们将编写一个自定义验证器以确保 times 和 content 存在并且类型正确。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Exercise4.03。
按照以下步骤完成此练习:
-
以使用
npm init vue@3生成的应用程序作为起点。否则,在代码仓库的根目录中,使用以下命令导航到Chapter04/Exercise4.03文件夹:> cd Chapter04/Exercise4.03/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你喜欢的 IDE。 -
在
./src/components目录中创建一个名为Repeat.vue的新文件。 -
为
Repeat.vue定义一个名为config的 prop。此 prop 将是Object类型,如下所示:<script>export default {props: {config: {type: Object,required: true,}}}</script> -
Config包含两个字段 –times和content。我们为Repeat组件计算一个名为repetitions的响应式数据数组,其长度基于config.times:<script>export default {// other component propertiescomputed: {repetitions() {return Array.from({ length: this.config.times })}}}</script> -
设置
<template>以便为每个repetitions项目渲染config.content:<template><div><span v-for="r in repetitions" :key="r">{{ config.content }}</span></div></template> -
我们需要通过实现
config的validator来确保content和times将接收到正确的值:<script>export default {props: {config: {type: Object,required: true,validator(value) {return typeof value.times === 'number' &&typeof value.content === 'string'}}},// other component properties}</script> -
接下来,我们在
src/App.vue中导入并使用Repeat:<template><main><Repeat :config="{}" /></main></template><script>import Repeat from './components/Repeat.vue'export default {components: {Repeat}}</script>
不幸的是,这不会渲染任何内容,因为 config 是一个空对象。你将在控制台中观察到警告,如下所示:

图 4.14 – 由于配置属性的自定义验证器检查失败导致的控制台警告
-
我们将在以下情况下看到相同的错误:
-
我们只添加一个
times属性,即<Repeat :config="{ times: 3 }" /> -
我们只添加一个
content属性,即<Repeat :config="{ content: 'Repeat me.' }" /> -
times的类型错误,即<Repeat :config="{ times: '3', content: 'Repeat me.' }" /> -
content的类型属性错误,即<Repeat :config="{ times: 3, content: 42 }" />
-
-
为了使
Repeat正确工作,我们可以修改template中消耗它的行,如下所示:<Repeat :config="{ times: 3, content: 'Repeat me.' }" />
这在控制台中不会显示任何错误,并按如下方式渲染 Repeat me. 三次:
Repeat me.Repeat me.Repeat me.
我们已经展示了如何使用验证器更好地定义具有 props 的组件。
在下一节中,我们将深入了解插槽,这是我们通过延迟模板逻辑来组合组件的一种机制。
理解槽、命名槽和作用域槽
槽是组件中模板/渲染被委派回组件父级的部分。我们可以将槽视为从父组件传递给子组件以在主模板中渲染的模板或标记。
将标记传递给组件进行渲染
最简单的槽类型是默认子槽。
我们可以定义一个具有槽的 Box 组件,如下所示:
<template>
<div>
<slot>Slot's placeholder</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
如果没有传递模板内容给 Box,Vue 将回退到在 Box 组件中定义的默认模板,如下所示:
Slot's placeholder
在幕后,Vue 编译 Box 的 template 部分,并将 <slot /> 替换为父组件 (App) 中 <Box /> 内部包裹的内容。然而,替换内容的范围仍然保持在父组件的作用域内。
考虑以下示例:
<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。它无法访问 Box 实例数据或 props,并将生成以下输出:

图 4.15 – 初始 h3,计数为 0,根据父组件中的初始数据
从父组件增加 count 会更新模板内容,因为传递给 Box 的模板中的 count 变量绑定到了父组件的数据。这将生成以下输出:

图 4.16 – 在父组件作用域中计数增加五次后的 h3,计数为 5
槽是让父组件控制子组件模板部分渲染的一种方式。任何对实例属性、数据或方法的引用都将使用父组件实例。这种类型的槽无法访问子组件的属性、props 或数据。
在下一节中,我们将探讨如何使用命名槽来渲染多个部分。
使用命名槽来委托多个部分的渲染
当子组件想要允许其父组件自定义其模板中的多个部分时,我们使用命名槽。
例如,一个 Article 组件可能会将 title 和 excerpt 的渲染委托给其父组件。
在这种情况下,我们将使用多个 slot 并为每个分配适当的 name 属性值,如下所示:
<template>
<article>
<div>Title: <slot name="title" /></div>
<div>Excerpt: <slot name="excerpt" /></div>
</article>
</template>
通过这样做,我们允许 article 的父组件用其期望的 UI 模板覆盖名为 title 和 excerpt 的槽。
要将内容传递到所需的槽,我们使用带有 v-slot:name 指令的 template(其中 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.17 – 使用命名插槽渲染由父组件定义的模板
如您所见,命名的插槽确实渲染了预期的内容。
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.18 – 在原生元素上使用 v-slot – 编译错误
注意
对默认插槽适用的所有内容也适用于命名插槽。实际上,默认插槽是一个名为 default 的命名插槽。这意味着命名插槽也有权访问父实例,但不能访问子实例。
默认插槽只是一个名为 default 的插槽,我们可以不指定名称来定义它。default 插槽的隐式推断如下:
<template>
<div>
<template>Default template</template>
</div>
</template>
我们也可以使用简写插槽数法表示默认插槽:
<template>
<MyComponent>
<template #default>Default template</template>
</MyComponent>
</template>
或者,我们可以使用长句插槽数法如下表示默认插槽:
<template>
<MyComponent>
<template v-slot:default>Default template</template>
</MyComponent>
</template>
我们已经看到,命名插槽允许组件将某些部分的模板委托给消费者,以及这些命名插槽如何有一个默认模板来应对命名插槽可选的情况。
在下一节中,我们将学习如何使用作用域插槽来封装属性传递逻辑。
使用作用域插槽封装属性传递逻辑
我们迄今为止探索的插槽类型只能访问传递插槽模板内容的组件实例——父组件。
在许多场景中,让父组件决定如何渲染 UI,同时让子组件处理数据并将其传递给插槽会更方便。我们使用作用域插槽来实现这个目的。
slot 元素通过使用 v-bind 或简写 : 将 props 传递给相关的模板内容。
在以下代码示例中,我们将插槽的 item prop 绑定到 el,它是 PaginatedList 组件中 currentWindow 数据的一个元素:
<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>
在消费者端(父组件),Vue 使用包含从子组件传递给插槽的所有数据属性的 props 对象来渲染该插槽的模板。我们需要通过以下语法指定,让 Vue 知道我们想要在模板内容中访问哪些数据属性:
<template #slot-name="{ propName }">
或者,我们可以指定在模板内容中使用的 props 对象的名称如下:
<template #slot-name="slotProps">
然后在模板内容中,我们可以通过使用propName或slotProps.propName来访问数据属性,具体取决于你使用哪种方法。注意,在这里你可以将propName的值更改为任何属性的名称,同样也适用于slotProps。如果插槽没有名称,我们将使用default来表示slot-name。
例如,要访问传递给PaginatedList插槽的item数据属性,我们在其父组件中添加以下内容:
<template #default="{ item }">
{{ item.content }}
</template>
现在,App.vue中PaginatedList的父组件的template部分将如下所示:
<template>
<div>
<PaginatedList :items="snacks">
<template #default="{ item }">
{{ item.content }}
</template>
</PaginatedList>
</div>
</template>
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.19 – 使用作用域插槽显示的零食
我们已经学习了作用域插槽如何使组件具有更大的灵活性,将模板逻辑委托给消费者。
现在,让我们学习如何使用这些命名插槽来实现卡片组件。
练习 4.04 – 使用命名插槽实现卡片组件
在这个练习中,我们将使用命名插槽实现一个卡片组件。该卡片将包含标题、图像和描述部分。我们将使用插槽允许父组件定义title、image和description。
要访问这个练习的代码文件,请参考github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Exercise4.04。
按照以下步骤完成这个练习:
-
使用由
npm init vue@3生成的应用作为起点,或者在任何代码仓库的根目录下,使用以下命令导航到Chapter04/Exercise4.04文件夹:> cd Chapter04/Exercise4.04/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者在你的首选 IDE 中打开。 -
我们将首先创建一个新的
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 thepicture.</p><p>For example what we can see in the photo isa nice landscape.</p></template></Card></div></template> -
运行应用,输出将如下所示:

图 4.20 – 包含图像、标题和描述的卡片组件
通过这样,我们已经学习了不同类型的插槽如何帮助创建更通用的组件。插槽允许子组件将它们自身的某些部分渲染推迟到父组件(消费者)。
正如我们所学的,Vue 在真实 DOM 之上提供了一个抽象层。当直接访问 DOM 元素至关重要时,例如集成 DOM 库,Vue 通过 refs 提供了一种一等的方式来这样做。我们将在下一节学习 Vue 引用。
理解 Vue 引用
在 Vue 中,引用是对已挂载到 DOM 中的 DOM 元素或其他组件实例的引用。
引用的一大主要用例是直接 DOM 操作和与基于 DOM 的库(通常需要一个挂载到的 DOM 节点)的集成(例如动画库)。
我们通过在模板中的原生元素或子组件上使用 ref="name" 语法来定义引用。在下面的示例中,我们将向名为 theInput 的输入元素添加一个引用:
<template>
<div id="app">
<input ref="theInput" />
</div>
</template>
可以通过 this.$refs[refName] 从 Vue 组件实例中访问引用。因此,在前面的示例中,我们定义了一个 ref="theInput",可以通过 this.$refs.theInput 来访问。
现在让我们通过编程方式在点击 Focus Input 按钮时聚焦到 input 字段,如下所示:
<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>
当点击 Focus Input 按钮时,输入字段将被聚焦,如下面的截图所示:

图 4.21 – 点击按钮聚焦输入
注意,我们只能在组件挂载到 DOM 后才能访问 $refs。因此,我们示例中的 this.$refs.theInput 只在 mounted() 生命周期钩子中可用。另外,如果你使用 <script setup>,则没有 $refs 可用,因为没有 this,并且 setup 在组件实例创建之前运行。因此,为了使用 <script setup> 或 setup 钩子中的 DOM 引用,我们使用 Composition API 中的 ref() 函数,我们将在 第五章,Composition API 中进一步讨论。
我们已经学习了如何使用 $refs 从组件中访问 DOM 元素。当你需要直接选择一个 DOM 节点时,我们建议你使用 ref 而不是使用 DOM API (querySelector/querySelectorAll)。
在以下练习中,我们将学习 Countable 库如何帮助提高项目的交互性。
练习 4.05 – 在 Vue 应用程序中包装 Countable.js
Countable 是一个库,给定一个元素(通常是 HTML textarea 或输入),将为段落、单词和字符添加实时计数。对正在捕获的文本的实时度量可以非常有用,尤其是在编辑文本是核心关注点的项目中。
Vue 中引用的一个大型用例是能够与直接作用于 DOM 的库集成。
在这个练习中,我们将使用 Countable.js 和 Vue 引用创建一个具有段落/单词/字符计数功能的组件,用于 textarea 中的内容。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Exercise4.05。
按照以下步骤完成此练习:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录下,使用以下命令导航到Chapter04/Exercise4.05文件夹:> cd Chapter04/Exercise4.05/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者在你的首选 IDE 中打开。 -
创建一个新的
src/components/TextEditorWithCount.vue组件,其中包含一个我们将有ref的textarea:<template><div><textarearef="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.22 – 应用程序渲染的裸textarea字段
-
现在我们需要集成
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.23 – 当空时计数值设置为 0 的textarea
如果我们在textarea中放入一些Lorem ipsum填充文本,计数值将相应更新,如下所示:

图 4.24 – 当填充时更新计数的textarea
-
最后一件我们需要做的是,当组件卸载时移除
Countable事件监听器:<script>// importsexport default {mounted() {Countable.on(this.$refs.textArea, (count) => {this.count = count})},beforeUnmount() {Countable.off(this.$refs.textArea)},// other component properties}</script>
在 Vue 应用内部集成 JavaScript/DOM 库是 Vue refs 的关键应用。Refs 允许我们从现有的生态系统中选择库并将它们包装或集成到组件中。
Vue refs 对于集成 DOM 库或直接访问 DOM API 非常有用。
为了结束我们对组件组合的考察,我们需要了解如何从子组件向父组件传递数据,这将在下一节中探讨。
使用事件进行子父通信
我们已经看到 props 用于从父组件向子组件传递数据。要从子组件向父组件传递数据,Vue 提供了自定义事件。
在组件中,我们可以使用$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 绑定到一些事件处理逻辑上,这可以是一个 JavaScript 表达式或使用 methods 声明的方法。
当适用时,Vue 将触发此事件处理程序,并将事件的有效负载对象传递给它。你可以在模板的 JavaScript 表达式中使用 $event 作为有效负载,如下所示 App 中的 template 部分的以下示例:
<template>
<div id="app">
<p>Message: {{ parentMessage }}</p>
<MessageEditor @send="parentMessage = $event" />
<button @click="parentMessage = null">Reset</button>
</div>
</template>
我们还可以将 JavaScript 表达式提取到组件的 updateParentMessage 方法中,并按如下方式绑定:
<template>
<div id="app">
<p>Message: {{ parentMessage }}</p>
<MessageEditor @send="updateParentMessage" />
<button @click="parentMessage = null">Reset</button>
</div>
</template>
<script>
import MessageEditor from './components/MessageEditor.vue'
export default {
components: {
MessageEditor
},
data() {
return {
parentMessage: null
}
},
methods: {
updateParentMessage(newMessage) {
this.parentMessage = newMessage
}
}
}
</script>
使用任何一种方法都会得到相同的结果。在浏览器中,完整的应用程序应如下所示:

图 4.25 – 从子组件到父组件发出的“Hello World!”消息
自定义事件支持将任何 JavaScript 类型作为负载传递。然而,事件名必须是一个 String。
使用 <script setup> (或 setup 钩子) 注册事件
如果你使用 <script setup>,由于没有组件的选项对象,我们无法使用 emits 字段来定义自定义事件。相反,我们使用 vue 包中的 defineEmits() 函数,并将所有相关事件定义传递给它。
例如,在 MessageEditor 组件中,我们可以使用 defineEmits() 重新编写事件注册功能,如下所示:
<script setup>
import { defineEmits, ref } from 'vue'
const message = ref(null)
const emits = defineEmits(['send'])
emits('send', message.value);
</script>
defineEmits() 返回一个函数,我们可以使用 this.$emits 在相同的概念下触发它。我们肯定需要使用 ref() 来声明一个响应式数据 message,对此组件的使用我们将在第五章,“组合 API”中进一步讨论。
现在,让我们根据到目前为止所学的一切来完成一个活动。
活动 4.01 – 带有可重用组件的本地消息视图
要访问此活动的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter04/Activity4.01
此活动旨在利用组件、props、事件和 refs 来渲染一个聊天界面,用户可以添加消息并显示它们。
按照以下步骤完成此活动:
-
创建一个
MessageEditor组件(在src/components/MessageEditor.vue中),该组件向用户显示一个textarea字段。 -
在
MessageEditor中添加一个名为message的响应式实例变量,默认值为''。 -
监听
textarea的change事件,并将message的值设置为textarea内容的值(它作为事件的值暴露)。 -
添加一个“发送”按钮,当点击时,会发出一个带有
message作为负载的send事件。 -
在
src/App.vue中添加一个main组件,用于渲染MessageEditor。 -
在
App中监听来自MessageEditor的send事件,并将每条消息存储在messages响应式数据变量中(messages是一个数组)。 -
创建一个
MessageFeed(在src/components/MessageFeed.vue中),它有一个必需的messagesprop,它是一个数组。 -
在
MessageFeed中,将messagesprop 传递的每个消息在段落(p元素)中渲染。 -
将
MessageFeed导入并渲染到App中,将messages应用实例变量绑定为MessageFeed的messagesprop。 -
改进
MessageEditor,以便在消息发送后重置并聚焦消息。为此,我们需要使用 ref 设置textarea.value,相应地重置message实例变量,并使用textarea.focus()以编程方式聚焦到textarea。
注意
重置 textarea 的更简单的方法一开始就使用 v-model="message" 而不是绑定 @change 并手动同步 textarea.value 到 message。
预期的输出如下:

图 4.26 – 发送了 Hello World! 和 Hello JavaScript! 的消息应用
摘要
在本章中,我们探讨了如何使用 props 和自定义事件启用组件之间的数据通信。我们探讨了 slots,并看到了如何从其父组件中启用组件的 UI 模板定制。我们还学习了如何使用 refs 通过直接访问 DOM 元素来解锁与第三方 JavaScript 或 DOM 库的集成机会。
我们现在能够创建和组合组件,这些组件通过输入(props 和 slots)和输出(渲染的模板和事件)清楚地定义了它们的接口,同时访问常见的用例(例如包装 DOM 库)。
在下一章中,我们将探讨高级组件组合模式和技巧,这些模式和技巧能够实现更好的代码重用。
第五章:组合式 API
在上一章中,我们学习了如何使用 props、refs 和 slots 在嵌套组件之间建立数据通信。
本章将介绍一种新的可扩展方法,即使用 setup() 生命周期钩子编写组件——组合式 API。到本章结束时,您将能够使用 setup() 方法结合组合式 API 编写独立的可组合函数(或自定义钩子),以便在多个组件中重用,并构建一个超越经典 Options API 的可扩展组件系统。
本章涵盖了以下主题:
-
使用
setup()生命周期方法创建组件 -
处理数据
-
理解可组合生命周期函数
-
创建您的可组合函数(自定义钩子)
技术要求
在本章中,您需要按照第一章中开始您的第一个 Vue 项目的说明设置一个基本的 Vue 项目。建议创建一个单文件 Vue 组件来练习本章中提到的示例和概念。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05。
使用 setup() 生命周期方法创建组件
从 Vue 3.x 版本开始,Vue 团队引入了组合式 API 作为在 setup() 生命周期方法内组合 Vue 组件的新方法。如第一章中所述,开始您的第一个 Vue 项目,setup() 是 Vue 引擎在组件生命周期中运行 beforeCreate() 钩子之前运行的第一个钩子。在这个时候,Vue 还未定义组件实例或任何组件数据。
这个生命周期方法在组件的初始化和创建之前运行一次,是 Options API 的一部分。Vue 团队将 setup() 专门用于与组合式 API 和使用组合式 API 编写的任何自定义钩子(可组合函数)一起工作,作为创建响应式组件的替代方法,除了 Options API 之外。
您可以使用以下语法开始使用 setup() 方法:
setup(props, context) {
// ...
return {
//...
}
}
Setup() 接受两个参数,如下所示:
-
props: 所有响应式属性数据都是从父组件传递给组件的。您需要像往常一样使用 Options API 中的props字段声明属性。请注意,您不应该解构props对象,以避免丢失解构字段的响应性。 -
context: 这些是组件的所有非响应式字段,例如attrs、slots、emit和expose。
setup() 返回一个包含组件内部响应/静态数据状态、方法或渲染函数的对象。
没有 Options API 的setup()的等效版本是<script setup>。Vue 引擎将编译<script setup>部分中定义的代码,将其编译成setup()内部适当代码块,如下面的示例所示:
<script setup>
const message = 'Hello World'
</script>
前面的代码等同于使用setup()的以下代码:
<script>
export default {
setup() {
const message = 'Hello World'
return {
message
}
}
}
</script>
在前面的两个示例中,我们为我们的组件定义了一个内部数据状态message。然后我们可以在<template>部分按需显示message。
使用<script setup>,如果你需要使用props参数,你需要从vue包中导入defineProps(),并在<script setup>部分定义 props,如下面的示例所示:
<script setup>
import { defineProps } from 'vue'
const { userName } = defineProps({ userName: string }
</script>
在前面的示例中,userName现在在模板部分作为组件的数据属性可用。你还可以使用defineEmits()对所有组件的自定义事件做类似处理,使用useSlots()和useAttrs()处理组件的slots,以及使用attrs在<script setup>而不是setup()方法时。
接下来,让我们使用setup()方法创建我们的第一个组件。
练习 5.01 – 使用 setup()创建问候组件
在这个练习中,我们将创建一个组件,使用setup()渲染一个预定义的问候消息,然后使用<script setup>重写它。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.01。
按照以下步骤完成此练习:
-
以
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录下,使用以下命令按顺序导航到Chapter05/Exercise5.01文件夹:> cd Chapter05/Exercise5.01/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你喜欢的 IDE。 -
在
./src/components目录下创建一个名为Greeting.vue的新文件。 -
首先,使用空的
template和script标签搭建组件:<template><div>Empty</div></template><script>export default {}</script> -
接下来,我们将实现
setup()方法的逻辑,该方法将返回一个内部数据状态greeting,其静态值为"Hello",以及另一个内部数据状态who,其静态值为"John":<script>export default {setup() {const greeting = "Hello";const who = "John";return { greeting, who }}}</script> -
在
template部分,让我们显示greeting和who的值,如下面的代码块所示:<template><div>{{ greeting }} {{ who }}</div></template> -
使用以下命令运行应用程序:
yarn dev -
当你在浏览器中访问你的应用时,你会看到以下内容:
Hello John -
将
setup属性添加到你的<script>标签中:<script setup>//…</script> -
然后,将
script标签内的内容更改为以下内容:<script setup>const greeting = "Hello";const who = "John";</script> -
浏览器的输出应该保持不变:
Hello John
接下来,我们将探讨如何将setup()和 Composition API 中的渲染函数h()结合起来创建一个组件。
使用 setup()和 h()创建组件
在许多需要根据接收到的上下文和属性渲染静态功能性组件或静态组件结构的场景中,使用h()和setup()可能会有所帮助。h()函数的语法如下:
h(Element, props, children)
h()函数接收以下参数:
-
表示 DOM 元素(例如
'div')或 Vue 组件的字符串。 -
需要传递给创建的组件节点的属性,包括原生属性和属性,例如
class、style等,以及事件监听器。此参数是可选的。 -
组件或插槽函数的子元素数组。此参数也是可选的。
与返回包含静态内部数据状态的对象并使用template部分不同,setup()将返回一个函数,该函数返回由h()函数根据接收到的参数创建的组件节点。在以下示例中,我们渲染一个包含蓝色"Hello World"消息的div元素:
<script>
import { h } from 'vue';
export default {
setup() {
const message = 'Hello World'
return () => h('div', { style: { color: 'blue' } } ,
message)
}
}
</script>
浏览器将输出如下:

图 5.1 – 蓝色字体的“Hello World”文本
在下一个练习中,我们将练习根据接收到的属性使用setup()和h()创建静态组件。
练习 5.02 – 使用 setup()和 h()函数创建动态问候组件
此练习将创建一个组件,根据接收到的属性渲染预定义的问候消息,使用setup()和h()。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.02。
按照以下步骤完成此练习:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在使用以下命令的代码仓库根目录中,导航到Chapter05/Exercise5.02文件夹:> cd Chapter05/Exercise5.02/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或使用你喜欢的 IDE。 -
在
./src/components目录下创建一个名为Greeting.vue的新文件。 -
首先使用空的
template和script标签搭建组件:<template><div>Empty</div></template><script>export default {}</script> -
接下来,使用
props字段定义Greeting的可接受属性,包括两个字符串属性greeting和who,如下所示:export default {props: ['greeting', 'who']} -
我们从
vue包中导入h()函数,并实现setup()以返回一个渲染函数,该函数渲染一个显示greeting和who的div元素,如下所示:import { h } from "vue";export default {props: ["greeting", "who"],setup(props) {return () => h("div", `${props.greeting} ${props.who}`);},}; -
打开
src/App.vue文件,并将Greeting组件从./src/components/Greeting.vue导入到script部分:<script setup>import Greeting from './components/Greeting.vue'</script> -
我们可以在模板中使用
Greeting:<template><div id="app"><Greeting greeting="Hey" who="JavaScript"/></div></template> -
使用以下命令运行应用程序:
yarn dev -
当你在浏览器中访问你的应用时,你会看到以下内容:
Hey JavaScript -
在
setup()内部,我们想要检查父组件是否传递了greeting或who的值:const hasValue = props.greeting && props.who; -
根据结果,我们将渲染一个
div元素,如果greeting和who都有值,则显示完整的信息,否则在红色中显示错误信息 –"There is not enough information to display"。return () =>hasValue? h("div", `${props.greeting} ${props.who}`): h("div",{ style: { color: "red" } },"There is not enough information todisplay"); -
在父组件(
App.vue)中,让我们移除greeting值:<template><div id="app"><Greeting who= "JavaScript"/></div></template> -
浏览器现在将显示以下信息:

图 5.2 – 当一个 prop 没有值时的错误信息
就这样。你现在已经学会了如何结合setup()和h()来根据其 props 组合动态组件。接下来,我们将探讨如何使用不同的 Composition API,如ref()、reactive()和computed(),为我们的组件创建响应式数据状态。
注意
从现在起,我们将使用<script setup>以提高可读性和代码便捷性。
数据处理
在 Options API 中,我们使用data()方法来初始化组件的本地状态。默认情况下,从data()接收的所有数据属性都是响应式的,这在许多场景中可能是多余的。Vue 引入了ref()和reactive()函数,允许我们决定哪些本地状态应该是响应式的,哪些不应该。
使用ref()设置响应式本地状态
ref()是一个函数,它接受一个输入参数作为响应式数据的初始值,并返回一个用于创建响应式数据状态的引用对象。我们称这个引用对象为ref对象。要开始使用ref(),你首先需要从vue包中导入它。
例如,我们可以创建一个名为isLightOn的响应式数据,其初始值为false,如下所示:
import { ref } from 'vue';
const isLightOn = ref(false);
在template部分,你可以像以前一样访问isLightOn的值,如下面的代码块所示:
<template>
<div>Light status: {{ isLightOn }}</div>
</template>
然而,在setup()方法的<script setup>部分中,如果你想访问isLightOn的值,你需要使用isLightOn.value而不是直接访问,就像在template中那样。在下面的示例中,我们将创建一个组件的方法,toggle,该方法将isLightOn的值输出到控制台:
const toggle = () => {
console.log(isLightOn.value)
};
在template部分,让我们添加一个button元素,当用户点击时触发此方法:
<button @click="toggle">Toggle</button>
当按钮被点击时,控制台输出isLightOn的值,其初始值为false,如下面的截图所示:

图 5.3 – 灯光状态的控制台输出
注意,在这里,如果你输出isLightOn而不是isLightOn.value,控制台将输出 Vue 创建的ref对象,如下所示:

图 5.4 – isLightOn的ref对象的控制台输出
isLightOn是响应式和可变的,这意味着你可以直接使用.value字段设置其值。我们将修改toggle()方法来切换isLightOn的值。代码将变为以下内容:
const toggle = () => {
isLightOn.value = !isLightOn.value;
};
现在,每当用户点击isLightOn时,它将更新其值,Vue 将相应地更新组件,如图所示:

图 5.5 – 点击切换后灯光状态更新为 true
ref()通常足以创建任何数据类型的响应式状态,包括原始类型(boolean、number、string等)和对象类型。然而,对于对象类型,使用ref()意味着 Vue 将使所需的数据对象及其嵌套属性响应式和可变。例如,我们使用ref()声明一个响应式对象livingRoomLight,如下面的代码块所示:
const livingRoomLight = ref({
status: false,
name: 'Living Room'
})
然后,我们添加两个方法,一个用于修改其单个属性status,另一个用于用新对象替换整个对象,如下面的代码块所示:
const toggleLight = () => {
livingRoomLight.value.status =
!livingRoomLight.value.status
}
const replaceLight = () => {
livingRoomLight.value = {
status: false,
name: 'Kitchen'
}
}
在template部分,让我们显示livingRoomLight的详细信息,如下所示:
<div>
<div>Light status: {{ livingRoomLight.status }}</div>
<div>Light name: {{ livingRoomLight.name }}</div>
<button @click="toggleLight">Toggle</button>
<button @click="replaceLight">Replace</button>
</div>
当用户点击lightRoomLight时,现在它变成了具有不同细节的Kitchen灯,如图所示:

图 5.6 – 点击替换后灯光名称更改为 Kitchen
不幸的是,这种使对象及其嵌套属性响应式的机制可能会导致不希望出现的错误和潜在的性能问题,尤其是在具有复杂嵌套属性层次结构的响应式对象中。
在一个场景中,你只想修改整个对象的价值(用新对象替换它),而不修改其嵌套属性,我们建议你使用shallowRef()。在一个场景中,你只需要修改对象的嵌套属性(例如数组对象的元素及其字段),你应该使用reactive()。我们将在下一节中查看reactive()函数。
使用reactive()设置响应式局部状态
与ref()类似,reactive()函数返回一个基于传递给它的初始值的响应式对象的引用。与ref()不同,reactive()只接受对象类型的输入参数,并返回一个可以直接访问其值的引用对象,无需使用.value字段。
以下示例展示了我们如何为BookList组件定义一个响应式数组books和一个响应式对象newBook:
<script setup>
import { reactive } from "vue";
const newBook = reactive({
title: "",
price: 0,
currency: "USD",
description: "",
});
const books = reactive([]);
</script>
在template中,我们定义一个包含多个input字段的fieldset元素,每个字段都使用v-model绑定到newBook数据的一个区域,以及一个button元素Add,如下所示:
<fieldset :style="{ display: 'flex', flexDirection: 'column'}">
<label>
Title:
<input v-model="newBook.title" />
</label>
<label>
Price:
<input v-model.number="newBook.price" />
</label>
<label>
Currency:
<input v-model="newBook.currency" />
</label>
<label>
Description:
<input v-model="newBook.description" />
</label>
<button @click="addBook">Add</button>
</fieldset>
浏览器将显示以下布局:

图 5.7 – 在添加之前填写新书的详细信息
我们需要实现 addBook 方法,该方法将根据 newBook 中的信息将新书添加到 books 列表,并清除 newBook 的属性,如下所示:
const addBook = () => {
books.push({
...newBook,
});
newBook.title = "";
newBook.price = 0;
newBook.currency = "USD";
newBook.description = "";
};
注意,在这里,我们不是直接将 newBook 推送到 books,而是使用扩展字面量 … 将其属性克隆到新对象中。reactive() 只创建传递给它的原始对象的代理版本。因此,如果您在将其添加到 books 列表之前不克隆 newBook,则对其属性所做的任何更改都将反映在添加到 books 列表中的元素中。
现在,在填写完新书的详细信息后,打开您的浏览器开发者工具,在 setup 部分中,如图所示:

图 5.8 – Vue Devtools 中的组件设置部分
使用 reactive() 创建的所有反应性数据都将带有 Reactive 文本指示器(对于 ref(),它将是 Ref 指示器)。一旦您点击更新了新值的 books 数组,而 newBook 在 Devtools 中重置到其原始值,如图 图 5.8 所示:

图 5.9 – 添加新书后书籍数组的外观
您还可以使用 shallowReactive() 来限制反应机制仅应用于根属性,而不包括其子属性。通过这样做,您可以避免在复杂数据对象中由于太多反应字段而引起的性能问题。
到目前为止,我们已经学习了如何使用 ref() 和 reactive() 根据其类型和用例定义反应性数据。接下来,我们将应用所学知识,使用这两个函数编写一个反应性组件。
练习 5.03 – 使用 ref() 和 reactive() 绑定组件
在此练习中,您将使用 ref() 定义博客的搜索框,并使用 reactive() 定义不同的反应性博客列表,在其中您可以收藏博客。
要访问此练习的代码,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.03。
我们将实现一个组件,该组件接收用户的姓氏和名字,并接受多语言输入,并根据接收到的语言数量显示用户的完整姓名,以下是执行以下步骤:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在使用以下命令进入代码仓库的根目录中的Chapter05/Exercise5.03文件夹:> cd Chapter05/Exercise5.03/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令),或使用您首选的 IDE。 -
让我们在
./src/components/文件夹中添加一个名为BlogGallery.vue的新 Vue 组件文件。 -
打开
BlogGallery.vue,让我们为 Vue 组件创建以下代码块结构:<template></template><script setup></script> -
在
<script setup>部分中,我们使用ref()定义我们的响应式数据searchTerm,用于搜索输入,其初始值为空字符串:import { ref, reactive } from 'vue';const searchTerm = ref(''); -
我们将定义我们的响应式博客列表
blogs,其中每个项目包含title、description、author和isFavorite字段,如下所示:const blogs = reactive([{title: 'Vue 3',description: 'Vue 3 is awesome',author: 'John Doe',isFavorite: false}, {title: 'Vue 2',description: 'Vue 2 is awesome',author: 'John Doe',isFavorite: false}, {title: 'Pinia state management',description: 'Pinia is awesome',author: 'Jane Smith',isFavorite: false}, {title: 'Vue Router',description: 'Vue Router is awesome',author: 'Jane Smith',isFavorite: false}, {title: 'Testing with Playwright',description: 'Playwright is awesome',author: 'Minnie Mouse',isFavorite: false}, {title: 'Testing with Cypress',description: 'Cypress is awesome',author: 'Mickey Mouse',isFavorite: false}]); -
在
<template>部分,我们将searchTerm绑定到一个带有placeholder元素"Search by blog's title"和label元素What are you searching for?的输入字段,如下面的代码块所示:<label>What are you searching for?<inputtype="text"v-model="searchTerm"placeholder="Search by blog's title"/></label> -
然后,我们添加一个
<ul>元素,使用v-for遍历blogs,并渲染一个<li>元素列表。每个<li>元素包含<article>,其中包含标题的<h3>,作者名称的<h4>,描述的<p>,以及添加博客到收藏的<button>元素:<ul><li v-for="(blog, index) in blogs" :key="index"><article><h3>{{ blog.title }}</h3><h4>{{ blog.author }}</h4><p>{{ blog.description }}</p><button>Add to favorite</button></article></li></ul> -
返回到
<script setup>部分,我们将添加toggleFavorite()方法的实现,该方法接收index并切换blogs[index].isFavorite的值:const toggleFavorite = (index) => {blogs[index].isFavorite = !blogs[index].isFavorite;} -
返回到
<template>部分。我们将把toggleFavorite()方法绑定到创建的button元素上,并根据isFavorite的状态更改其名称:<button @click="toggleFavorite(index)">{{blog.isFavorite ? 'Remove from favorites' : 'Addto favorites'}}</button> -
我们需要根据
searchTerm过滤博客,因此让我们添加另一个方法来返回一个排序后的列表——getFilteredBlogs()——它将根据博客标题生成一个过滤后的博客数组,如下所示:const getFilteredBlogs = () => {return blogs.filter(blog => blog.title.toLowerCase().includes(searchTerm.value.toLowerCase()));}; -
然后,在
template部分,将v-for迭代中的blogs替换为getFilteredBlogs(),如下面的代码块所示:<li v-for="(blog, index) in getFilteredBlogs()" :key="index"> -
我们需要添加一些基本的 CSS 样式来使我们的组件更美观,如下所示:
<style scoped>label {display: flex;flex-direction: column;}li {list-style: none;gap: 10px;width: 200px;}ul {display: flex;flex-wrap: wrap;gap: 10px;padding-inline-start: 0px;}</style> -
现在,在
App.vue中,将BlogGallery组件导入到<script setup>中:<script setup>import BlogGallery from "./components/BlogGallery.vue";</script> -
在
template部分渲染BlogGallery:<template><BlogGallery /></template> -
最后,使用以下命令运行应用程序:
yarn dev -
打开浏览器。你会看到显示的列表和一个空值的搜索框,如下面的截图所示:

图 5.10 – BlogGallery 在浏览器中的外观
- 当输入搜索词时,应用程序将相应地显示过滤后的列表,如下面的截图所示:

图 5.11 – 根据用户输入仅显示过滤后的博客
通过前面的练习,你已经学会了如何使用ref()和reactive()为组件定义响应式数据。
理论上,你可以使用 ref() 和 reactive() 一起从其他响应式数据创建新的响应式数据。然而,我们强烈建议不要这样做,因为 Vue 中包装/解包响应式机制的性能问题。对于这种场景,你应该使用 computed() 函数,我们将在下一节中探讨。
使用 computed() 从另一个本地状态计算响应式状态
与 Options API 中的 computed() 类似,computed() 是用于为组件创建基于其他响应式数据的新响应式数据。它接受一个函数作为其第一个参数,该函数返回响应式数据值。它将返回一个只读且缓存的引用对象:
<script setup>
import { computed } from 'vue'
const computedData = computed(() => { //… })
</script>
与 reactive() 和 ref() 返回的引用对象不同,我们无法直接重新分配它们的值。在以下示例中,我们将使用 computed() 计算给定 books 数组的过滤版本,根据匹配项 vue:
import { computed, reactive, ref } from 'vue';
const books = reactive([{
title: 'Vue 3',
description: 'Vue 3 is awesome',
}, {
title: 'Vue 2',
description: 'Vue 2 is awesome',
}, {
title: 'Pinia state management',
description: 'Pinia is awesome',
}, {
title: 'Vue Router',
description: 'Vue Router is awesome',
}, {
title: 'Testing with Playwright',
description: 'Playwright is awesome',
}, {
title: 'Testing with Cypress',
description: 'Cypress is awesome',
}]);
const searchTerm = ref('vue')
const filteredBooks = computed(
() => books.filter(book => book.title.toLowerCase()
.includes(searchTerm.value))
);
在 template 中,我们将使用以下代码显示 filteredBooks:
<ul>
<li v-for="(book, index) in filteredBooks"
:key="index">
<article>
<h3>{{ book.title }}</h3>
<p>{{ book.description }}</p>
</article>
</li>
</ul>
在浏览器中,你将只看到以下图中显示的三本书:

图 5.12 – 根据 vue 术语过滤的书籍列表
无论何时 books 列表或用于过滤的搜索词有任何变化,Vue 都会自动更新并缓存计算出的 filteredBooks 值以相应地显示。在 filteredBooks 显示为 setup 部分的一部分,如 图 5.12 中所示,带有 Computed 文本指示器:

图 5.13 – Vue 选项卡中 filteredBooks 的外观
通常,computed() 的工作方式与 Options API 中的 compute 属性相同 (第二章,处理数据)。计算数据是 Vue 的一个有价值的功能,允许开发者创建可重用和可读的代码。你还可以通过将具有设置器和获取器的对象传递给 computed() 而不是函数来使计算数据可写。然而,我们不建议这样做,因为这不符合一般的 Vue 实践。
接下来,我们将练习使用 computed() 为 Vue 组件实现复杂计算数据。
练习 5.04 – 使用 computed() 实现计算数据
在此练习中,你将使用 computed() 定义基于现有数据的复杂响应式数据。
要访问此练习的代码,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.04。
我们将实现一个组件,该组件接收用户的姓氏和名字,并接受多语言输入,通过执行以下步骤相应地显示用户的完整姓名和接收到的语言数量:
-
以
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter05/Exercise5.04文件夹:> cd Chapter05/Exercise5.04/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您的首选 IDE。 -
让我们创建一个新的 Vue 组件
UserInput,通过将UserInput.vue文件添加到./src/components/文件夹中来实现。 -
打开
UserInput.vue并创建 Vue 组件的代码块结构,如下所示:<template></template><script>export default {}</script> -
在
<template>中创建一个用于姓氏的input字段,并使用v-model将firstName绑定到该字段:<input v-model="firstName" placeholder="First name" /> -
创建第二个输入字段用于姓氏,并使用
v-model将lastName数据属性绑定到该字段:<input v-model="lastName" placeholder="Last name" /> -
创建另一个用于语言的输入字段,这次我们将绑定 Enter 键上键 事件到名为
addToLanguageList的方法,如下所示:<inputplaceholder="Add a language"@keyup.enter="addToLanguageList" /> -
在
<script setup>中,使用ref()和reactive()将lastName、firstName和languages定义为反应式,如下所示:<script setup>import { ref, reactive } from 'vue';const firstName = ref('');const lastName = ref('');const languages = reactive([]);</script> -
然后,声明
addToLanguageList方法,该方法接收一个事件并将事件目标值添加到语言列表中,如果它不为空:const addToLanguageList = (event) => {if (!event.target.value) return;languages.push(event.target.value);event.target.value = '';}; -
从
vue包中导入computed():import { ref, reactive, computed } from 'vue'; -
创建一个名为
fullName的计算数据变量:const fullName = computed(()=> '${firstName.value} ${lastName.value}'); -
然后,创建另一个名为
numberOfLanguages的计算变量,如下所示:const numberOfLanguages = computed(() => languages.length); -
在您的
input字段下方,使用h3标签输出计算后的数据:<h3 class="output">{{ fullName }}</h3> -
添加另一个
<p>元素,它将在接收到的语言列表旁边显示语言数量,如下所示:<p>Languages({{ numberOfLanguages }}):{{languages.toString()}}</p> -
我们还添加了一些基本的局部 CSS 样式,使组件看起来更美观:
<style>.container {margin: 0 auto;padding: 30px;max-width: 600px;}input {padding: 10px 6px;margin: 20px 10px 10px 0;}.output {font-size: 16px;}</style> -
最后,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000并为姓氏键入Maya,为名字键入Shavin,并添加几种语言(JavaScript、C++ 等),页面将生成以下输出:

图 5.14 – 计算数据的输出将显示全名和语言列表
这个练习演示了我们可以如何使用 computed() 来定义将姓氏和名字等组合成单个输出变量 fullName 的反应式数据属性,并可以反应性地计算语言数量,这些可以在组件内部重用。
接下来,我们将学习如何使用 Composition API 中的 watch() 函数来定义我们的观察者。
使用 watch() 和观察者
在 第二章 处理数据 中,我们学习了观察者的相关知识以及如何使用 Options API 中的 watch 属性在数据属性上启用观察者。Composition API 引入了具有相同上下文和略微不同语法的 watch() 函数,如下所示:
const watcher = watch(source, handler, options)
watch() 接受三个参数,包括以下内容:
-
source是一个要监视的单个目标数据对象或 getter 函数(它返回数据的值),或者是一个目标数组。 -
handler是 Vue 在source变化时执行的函数。处理函数接收newValue和oldValue作为其源的下一个值和上一个值。它还接受第三个参数作为其副作用清理方法。Vue 将在调用下一个处理器之前触发这个清理函数——如果有下一个处理器的话。 -
options是观察者的附加配置,包括以下内容:-
两个
boolean标志:deep(Vue 是否应该监视源嵌套属性)和immediate(是否在组件挂载后立即调用处理器)。 -
将
flush作为处理器的执行顺序(pre、post或sync)。默认情况下,Vue 在更新之前以pre顺序执行处理器。 -
两个调试回调,
onTrack和onTrigger,用于开发模式。
-
以下示例演示了如何手动将观察者添加到searchTerm:
import { ref, watch } from 'vue';
const searchTerm = ref('');
const searchTermWatcher = watch(
searchTerm,
(newValue, oldValue) => console.log(
`Search term changed from ${oldValue} to ${newValue}`
)
);
Vue 会被动地观察searchTerm的变化,并相应地调用searchTermWatcher的处理函数。在浏览器控制台中,当你更改输入字段中searchTerm的值时,你会看到以下记录:

图 5.15 – 当searchTerm的值发生变化时,输出日志
与 Options API 中的watch属性不同,watch()方法返回一个停止函数,可以在不再需要观察目标数据时停止观察者。此外,在显式希望监视嵌套数据属性的场景中,你可以定义目标源为一个返回特定数据属性的 getter 函数。例如,如果你想监视book数据对象的description属性,你需要使用以下代码,通过 Options API 中的watch属性:
data() {
return {
book:{
title: 'Vue 3',
description: 'Vue 3 is awesome',
}
}
},
watch: {
'book.description': (newValue, oldValue) => { /*…*/ }
}
使用watch(),你只需要设置一个返回book.description的 getter 即可,如下面的代码块所示:
const book = reactive({
title: 'Vue 3',
description: 'Vue 3 is awesome',
})
const bookWatcher = watch(
() => book.description,
(newValue, oldValue) => console.log(
`Book's description changed from ${oldValue} to
${newValue}`
)
);
通过指定你想要观察的确切目标数据,Vue 不会在整个数据对象上触发观察者的处理器,从而避免不必要的性能开销。
现在,让我们在下一个练习中练习使用观察者。
练习 5.05 – 使用观察者设置新值
在这个练习中,你将使用观察者参数来监视数据属性的变化,然后使用这个观察者通过一个方法来设置变量。
你可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.05找到这个练习的完整代码。
我们创建一个 Vue 组件,显示商店观察者的折扣前后的价格,并提供一个选项来更新折扣价格,按照以下说明进行:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录下,使用以下命令按顺序导航到Chapter05/Exercise5.05文件夹:> cd Chapter05/Exercise5.05/> yarn -
在项目目录中使用
code .命令在 VS Code 中打开练习项目,或使用您首选的 IDE。 -
让我们通过将
PizzaItem.vue文件添加到./src/components/文件夹来创建一个新的 Vue 组件PizzaItem。 -
打开
PizzaItem.vue并为 Vue 组件创建代码块结构,如下所示:<template></template><script>export default {}</script> -
通过添加
discount、pizza和newPrice对象来设置文档:import { ref, reactive, watch } from "vue";const discount = ref(5);const pizza = reactive({name: "Pepperoni Pizza",price: 10,}); -
我们想通过从
vue包中导入的watch()函数监听discount属性的变化。当discount发生变化时,我们将手动重新计算newPrice的值,如下所示:watch(discount,(newValue) => {newPrice.value = pizza.price - (pizza.price *newValue)/ 100;},{immediate: true});
注意,在这里,我们将 immediate 设置为 true,这样 Vue 就会在组件挂载后立即触发此处理程序,并使用正确的值更新 newPrice。
-
现在,让我们添加一个名为
updateDiscount的组件方法。在方法内部,将oldDiscount数据属性设置为this.discount + 5:const updateDiscount = () => {discount.value = discount.value + 5;}; -
在
template部分,我们将显示pizza.name、discount、原价以及应用折扣后的新价格,如下所示:<template><div class="container"><h1>{{ pizza.name }}</h1><div class="campaign-wrapper">Monday Special: {{ discount }}% off!<strike>Was ${{ pizza.price }}</strike><strong> Now at ${{ newPrice }} ONLY</strong></div></div></template> -
然后,使用
@click将updateDiscount方法绑定到一个button元素上:<button @click="updateDiscount" class="decrease-btn">Get a discount!</button>
当用户点击前面的按钮时,Vue 将触发 updateDiscount() 以增加 discount 值,从而调用处理程序以相应地更新 newPrice 值。
-
现在,让我们添加一些 CSS 样式来使其看起来更美观:
<style scoped>.container {margin: 0 auto;padding: 30px;max-width: 600px;font-family: "Avenir", Helvetica, Arial, sans-serif;margin: 0;}.campaign-wrapper {margin: 20px 0;display: flex;flex-direction: column;}button {display: inline-block;border-radius: 10px;font-size: 14px;color: white;padding: 10px 20px;text-decoration: none;margin-inline-end: 10px;}.decrease-btn {background: rgb(241, 34, 34);}</style> -
在
App.vue中,将组件导入到<setup script>并在template中按如下方式渲染:<template><PizzaItem /></template><script setup>import PizzaItem from "./components/PizzaItem.vue";</script> -
最后,使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000,上述命令的输出将如下所示:

图 5.16 – 比萨销售的示例输出
-
现在,让我们为
pizza.price字段显式添加一个观察器,并执行相同的newPrice重新计算,如下面的代码块所示:watch(() => pizza.price,(newValue) => {newPrice.value = newValue - (newValue *discount.value) / 100;}); -
我们还添加了一个名为
increasePrice()的方法,以便在触发时增加比萨的价格:const increasePrice = () => {pizza.price = pizza.price + 5;}; -
在
template部分,我们添加了另一个按钮,允许用户点击以增加比萨的价格,从而相应地更新新的折扣价格:<button @click="increasePrice" class="increase-btn">Increase the price!</button> -
在
style部分,我们还为前面的按钮添加了不同的background颜色:.increase-btn {background: rgb(34, 100, 241);} -
返回主浏览器的屏幕,现在您将看到带有额外按钮的更新布局,如下面的截图所示:

图 5.17 – 具有修改价格选项的比萨销售
- 当点击 增加价格! 按钮时,您将看到价格和折扣价格发生变化,如下面的截图所示:

图 5.18 – 点击“增加价格!”按钮后价格发生了变化
在这个练习中,我们探讨了如何在 <script setup> 中使用 watch() 动态观察和操作数据,当应用更改时。
下一个部分将探讨我们如何使用 Composition API 中的生命周期函数来设置生命周期钩子。
理解可组合的生命周期函数
在第一章,“开始 *您的第一个 Vue 项目”,我们学习了组件的生命周期和 Vue 的 Options API 中可用的钩子。在 Composition API 中,这些生命周期钩子现在作为独立的函数提供,并在使用之前需要从 vue 包中导入。
通常,Composition API 中的生命周期函数与 Options API 中的类似,前缀为 on。例如,Options API 中的 beforeMount() 在 Composition API 中是 onBeforeMount(),依此类推。
以下是从 Composition API 可用的生命周期函数列表,可在 setup() 方法或 <script setup> 中使用:
-
onBeforeMount():在组件首次渲染之前 -
onMounted():在将组件渲染并挂载到 DOM 之后 -
onBeforeUpdate():在组件的更新过程开始之后,但在实际渲染更新后的组件之前 -
onUpdated():在渲染更新后的组件之后 -
onBeforeMount():在开始卸载组件的过程之前 -
onUnmounted():在组件实例被销毁之后
由于我们使用 setup() 或 <script setup> 结合其他 Composition API 来定义组件的数据和内部逻辑,因此不需要 Options API 中的 created() 和 beforeCreate() 的等效版本。
Composition API 中的所有生命周期方法都将回调函数作为其参数。Vue 将在应用时调用此回调函数。
让我们做一个练习来学习如何在 Vue 组件中使用这些生命周期方法。
练习 5.06 – 使用生命周期函数控制数据流
在这个练习中,我们将学习如何以及何时使用 Vue 的生命周期钩子,以及它们是如何通过 JavaScript 提示框触发的。到练习结束时,我们将理解并能使用 Composition API 中的多个生命周期函数。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.06。
我们将构建一个不同元素的列表,演示如何向购物车添加不同数量的商品。然后,我们将通过以下方式显示更新后的购物车总价值,并以货币格式显示:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在每个代码仓库的根目录中,使用以下命令按顺序导航到Chapter05/Exercise5.06文件夹:> cd Chapter05/Exercise5.06/> yarn -
使用以下命令运行应用程序:
yarn dev -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components目录下创建一个名为Exercise5-06.vue的新 Vue 组件文件。 -
在
Exercise5-06.vue内部,我们将首先创建一个数据数组,用于在list元素中迭代,将键设置为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 setup>import { ref } from "vue";const list = ref(["Apex Legends","A Plague Tale: Innocence","ART SQOOL","Baba Is You","Devil May Cry 5","The Division 2","Hypnospace Outlaw","Katana ZERO",]); -
从
vue包中导入所有生命周期函数,如下面的代码块所示:import {ref,onMounted,onBeforeMount,onUpdated,onBeforeUpdate,onUnmounted,onBeforeUnmount,} from "vue"; -
定义
onBeforeMount()和onMounted()的回调,以触发显示相关消息的弹窗:onMounted(() => {alert("mounted: DOM ready to use");});onBeforeMount(() => {alert("beforeMount: DOM not ready to use");}); -
当您刷新浏览器时,您也应该在看到页面上的列表加载之前看到这些弹窗:

图 5.19 – 观察到 onBeforeMount() 钩子弹窗
- 以下截图显示了
onBeforeMount()钩子之后的onMounted()钩子弹窗:

图 5.20 – 在 onBeforeMount() 钩子之后观察到 onMounted() 钩子弹窗
-
在您的
<li>元素内添加一个新的button元素,用于渲染item输出。使用@click指令将此按钮绑定到名为deleteItem的方法,并将item值作为参数传递:<template><div><h1>Vue Lifecycle hooks</h1><ul><li v-for="(item, n) in list" :key="n">{{ item }}<button @click="deleteItem(item)">Delete</button></li></ul></div></template> -
在您的钩子之上添加一个名为
deleteItem的方法,将value作为参数传递,并根据此值从list数组中过滤出项目。然后,用新的列表替换现有的列表:const deleteItem = (value) => {list.value = list.value.filter((item) => item !==value);}; -
添加
onBeforeUpdate()和onUpdated(),并在其中设置一个弹窗:onUpdated(() => {alert("updated: virtual DOM will update after youclick OK");});onBeforeUpdate(() => {alert("beforeUpdate: we know an update is about tohappen, and have the data");});
当您通过点击 onBeforeUpdated() 删除列表项时将触发处理程序:

图 5.21 – 点击任何删除按钮后首先调用 onBeforeCreated
然后,onUpdated 如以下截图所示被触发:


-
继续向组件选项中添加
onBeforeUnmount()和onUnmounted()作为函数属性。在这些钩子中设置一个弹窗,以便您可以查看它们何时被触发:onUnmounted(() => {alert("unmounted: this component has beendestroyed");});onBeforeUnmount(() => {alert("beforeUnmount: about to blow up thiscomponent");}); -
在您的
list数组中添加一个新的字符串 – 例如,testingunmounted hooks:const list = ref(["Apex Legends","A Plague Tale: Innocence","ART SQOOL","Baba Is You","Devil May Cry 5","The Division 2","Hypnospace Outlaw","Katana ZERO",'testing unmounted hooks',]); -
您应该按照以下顺序看到卸载弹窗:
onBeforeUnmount–onBeforeMount–onUnmounted–onMounted。以下图显示了显示onBeforeUnmount弹窗的示例输出屏幕:

图 5.23 – 当组件即将卸载时显示的警报
接下来,我们将讨论如何使用 Composition API 中的可用方法,并创建我们的自定义可组合组件(或自定义钩子),以动态地控制组件的状态。
创建你的可组合组件(自定义钩子)
在许多场景中,我们希望将一些组件的逻辑分组为可重用的代码块,供具有相似功能的其他组件使用。在 Vue 2.x 中,我们使用混入(mixins)来实现这个目标。然而,混入并不是最佳的实际解决方案,并且由于合并和调用重叠的数据和生命周期钩子的顺序,它们可能会创建代码复杂性。
从 Vue 3.0 开始,你可以使用 Composition API 将公共数据逻辑划分为小型和独立的可组合组件,使用它们在不同的组件中创建作用域数据控制,并在有数据时返回创建的数据。可组合组件是一个使用 Composition API 方法并内部执行数据状态管理的常规 JavaScript 函数。
要开始,我们创建一个新的 JavaScript(.js)文件,该文件导出一个作为可组合组件使用的函数。在下面的示例中,我们创建了一个 useMessages 可组合组件,它返回一个 messages 列表和一些相应修改消息的方法:
// src/composables/useMessages.ts
import { ref } from 'vue'
export const useMessages = () => {
const messages = ref([
"Apex Legends",
"A Plague Tale: Innocence",
"ART SQOOL",
"Baba Is You",
"Devil May Cry 5",
"The Division 2",
"Hypnospace Outlaw",
"Katana ZERO",
]);
const deleteMessage = (value) => {
messages.value = messages.value.filter((item) => item
!== value);
};
const addMessage = (value) => {
messages.value.push(value)
}
return { messages, deleteMessage, addMessage }
}
要在组件中使用 useMessages(),你可以在组件的 <script setup> 部分导入它,并检索相关数据,如下所示:
<script setup>
import { useMessages } from '@/composables/useMyComposable'
const { messages, deleteMessage, addMessage } = useMessages ()
</script>
然后,我们可以在组件中使用从可组合组件返回的 messages、deleteMessage 和 addMessage 作为其本地数据和方法的本地数据和方法,如下面的代码块所示:
<template>
<button @click="addMessage('test message')">
Add new message
</button>
<ul>
<li v-for="(message, n) in messages" :key="n">
{{ message }}
<button @click="deleteMessage(message)">
Delete</button>
</li>
</ul>
</template>
由于 messages、deleteMessage 和 addMessage 是在 useMessages() 函数内部声明的,每次执行 useMessages 都会返回不同的数据实例,从而保持可组合组件定义的响应式数据是隔离的,并且仅与消费它的组件相关。使用可组合组件,组件共享逻辑,而不是数据。你还可以基于另一个可组合组件创建新的可组合组件,而不仅仅是 Composition API。
就这样 – 你已经学会了如何使用 Composition API 创建一个简单的可组合组件。接下来,让我们应用到目前为止关于 Composition API 的知识,创建我们的第一个可组合组件。
练习 5.07 – 创建你的第一个可组合组件
在这个练习中,你将创建一个可组合组件,它将从外部 API 获取数据,并返回数据、请求的加载/错误状态以及一个可重用的搜索可组合组件。
你可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Exercise5.07找到这个练习的完整代码。
我们将创建一个可组合函数,用于从外部源获取电影,并创建另一个可组合函数,允许你根据以下说明在电影列表中进行搜索:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令按顺序导航到Chapter05/Exercise5.07文件夹:> cd Chapter05/Exercise5.07/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令),或使用你偏好的 IDE。
让我们创建一个新的可组合函数useMovies,通过将useMovies.js添加到./src/composables文件夹中。
-
在
useMovies.js中,我们将添加以下代码以将可组合函数作为模块导出,以便在其他文件中使用:import { } from 'vue';export const useMovies = () => {return {};}; -
我们使用
ref()定义可组合函数的响应式数据,例如movies、isLoading和error,并使用适当的初始值:import { ref } from 'vue';export const useMovies = () => {const movies = ref([]);const isLoading = ref(false);const error = ref(null);return {};}; -
然后,我们将导入
onBeforeMount()方法,并使用fetch()方法从https://swapi/dev/api/films开始获取电影,如下面的代码块所示:import { ref, onBeforeMount } from 'vue';export const useMovies = () => {//…const getMovies = async () => {try {const response = await fetch("https://swapi.dev/api/films");if (!response.ok) {throw new Error("Failed to fetch movies");}const data = await response.json();movies.value = data.results;} catch (err) {} finally {}};onBeforeMount(getMovies);//…}; -
我们还需要重置
isLoading的值以指示获取状态,并在发生错误时将值分配给error:const getMovies = async () => {isLoading.value = true;error.value = null;try {//…} catch (err) {error.value = err;} finally {isLoading.value = false;}}; -
我们返回响应式数据,以便其他 Vue 组件可以使用它:
import { ref, onBeforeMount } from 'vue';export const useMovies = () => {return {movies,isLoading,error,};}; -
接下来,通过在
./src/components/文件夹中添加名为Movies.vue的文件来创建一个新的 Vue 组件,其代码如下:<template></template><script setup></script> -
在
script部分,我们将导入useMovies并使用其返回的数据——movies、isLoading和error:<script setup>import { useMovies } from '../composables/useMovies.js'const { movies, error, isLoading } = useMovies();</script> -
在
template部分,我们将使用v-if根据isLoading和error的状态显示加载状态、错误状态和电影列表:<template><h1>Movies</h1><div v-if="isLoading"><p>Loading...</p></div><div v-else-if="error"><p>{{ error }}</p></div><div v-else><ul><li v-for="movie in movies" :key="movie.id"><article><h3>{{ movie.title }}</h3><h4>Released on: {{ movie.release_date }}</h4><h5>Directed by: {{ movie.director }}</h5><p>{{ movie.opening_crawl }}</p></article></li></ul></div></template> -
导航到浏览器,当组件加载电影时,你会看到以下输出:

图 5.24 – 获取电影时的加载状态
- 当组件完成获取后,Vue 会自动更新视图以显示电影列表,如下面的图所示:

图 5.25 – 获取成功后的电影列表
- 如果遇到错误,组件将显示错误状态,如下面的示例输出所示:

图 5.26 – 获取过程中出现的错误状态
-
接下来,我们将添加另一个可组合函数,使组件具有搜索功能——
useSearch()在./src/composables/useSearch.js中。 -
useSearch()可组合函数接收一个items列表和一个可用于过滤的过滤器列表,默认过滤器为title。该可组合函数返回用于存储搜索输入的searchTerm以及过滤后的项目数组:import { ref, computed } from 'vue';export const useSearch = (items, filters = ['title']) => {const searchTerm = ref('');const filteredItems = computed(() => {return items.value.filter(item => {return filters.some(filter => item[filter].toLowerCase().includes(searchTerm.value.toLowerCase());});});});return {searchTerm,filteredItems,}} -
返回到
Movies.vue。在script部分,我们导入useSearch并执行它以获取searchTerm和过滤后的filteredMovies数组,以便在我们的组件中使用:<script setup>import { useMovies } from '../composables/useMovies.js'import { useSearch } from '../composables/useSearch.js'const { movies, error, isLoading } = useMovies();const {searchTerm,filteredItems: filteredMovies } = useSearch(movies);</script> -
在
template部分中,我们添加了一个输入字段,使用v-model绑定到searchTerm,并用filteredMovies替换v-for迭代中的movies:<div v-else><div><label for="search">Search:</label><input type="text" id="search"v-model="searchTerm" /></div><ul><li v-for="movie in filteredMovies":key="movie.id"><!-- … --></li></ul></div> -
返回浏览器。现在,您可以加载电影列表并按标题搜索电影,如下面的截图所示:

图 5.27 – 包含单词“希望”的电影标题过滤结果
此外,您还可以扩展之前创建的搜索可组合组件,以支持用户从输入选择中获取一个过滤器列表,或者重新格式化接收到的电影字段,使其对您的应用程序更友好。在这个练习中,我们观察了如何在组件中使用 Composition API 创建独立和可重用的逻辑,例如搜索功能。我们还看到可组合组件如何使我们的代码更简洁、更有组织。
活动 5.01 – 使用 Composition API 创建 BlogView 组件
要访问此活动的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter05/Activity5.01
此活动旨在利用您结合不同 Composition APIs 与组件的 props 和事件以创建一个视图的知识,在该视图中用户可以看到博客列表并添加或删除任何博客项。
此活动将需要使用无头 CMS,Contentful。访问密钥和端点在解决方案中列出。
按照以下步骤完成此活动:
-
使用 Vite 创建一个新的 Vue 项目。
-
将
contentful依赖项安装到您的项目中。 -
创建另一个可组合的组件,
useListAction,它接收一个items列表,并返回以下内容:-
addItem:向给定列表添加新项目 -
deleteItem:根据其 ID 删除项目
-
-
创建一个
useBlogs可组合组件,它将从 Contentful 获取blogs,并使用useListActions()获取获取到的blogs的操作。 -
定义
useBlogs以返回blogs列表、loading状态、error(获取状态错误)以及从useListActions()接收到的addItem和deleteItem操作。返回的blogs应该是一个包含以下字段的博客项数组:title、description、heroImage、publishDate和id(一个短名)。 -
创建一个
useSearch可组合组件,它接收一个items列表,并返回以下内容:-
searchTerm:搜索值。 -
filters:基于用户选择的字段列表进行过滤。默认为title。 -
filteredItems:给定项目的过滤列表。
-
-
创建一个
BlogEditor组件,该组件显示几个用于title字段的input字段、作者姓名、博客的id字段、用于博客内容的textarea以及一个用于保存博客的button元素。当点击此按钮时,BlogEditor会发出一个带有新博客详情作为负载的addNewItem事件,并重置字段。 -
创建一个接收博客列表、
isLoading标志和error对象作为其 props 的Blogs组件,并根据isLoading和error以及每个博客项的详细信息在 UI 中渲染组件的状态。 -
在
Blogs组件中,使用useSearch()函数处理接收到的blogs列表作为 props,并显示一个搜索input字段,允许用户根据title过滤博客。 -
将原始列表迭代替换为博客的过滤列表。
-
我们随后添加包含两个
checkbox类型input字段的fieldset,每个字段都与filters数组绑定。这两个input字段还将分别有对应的title和description标签。 -
在博客列表中渲染的每一行博客上添加一个带有移除标签的
button元素。 -
还定义了一个名为
deleteBlog的emit事件,用于Blogs。 -
在点击带有博客项
id值作为负载的deleteBlog事件。 -
创建一个渲染
BlogEditor和Blogs的BlogView组件。 -
在
BlogView中创建一个切换标志showEditor,如果为true,则显示BlogEditor。否则,当点击时,组件将显示showEditor值。 -
BlogView将使用useBlogs()并将从该 composable 接收到的数据(blogs、isLoading、error和deleteItem)作为 props 和事件传递给Blogs。您应将deleteItem绑定到Blogs的deleteBlog自定义事件。 -
BlogView也将useBlogs()返回的addItem方法绑定到BlogEditor的addNewItem事件。 -
根据需要为组件添加一些 CSS 样式。
预期结果如下:

图 5.28 – 没有博客显示且用户未点击添加新博客时的输出
当用户点击添加新博客时,编辑器将如下显示:

图 5.29 – 博客编辑器操作中
摘要
在本章中,我们学习了使用 Composition API 和setup()生命周期钩子(或<script setup>)作为 Options API 的替代方案来组合组件。我们还学习了如何使用不同的 Composition 函数创建 watchers 和生命周期回调来控制组件的本地状态。
最后,我们学习了如何基于 Composition API 和其他 composables 创建自定义组合函数,使我们的组件在逻辑相似组中更加有组织和易于阅读。
在下一章中,我们将探讨如何使用插件和混入(mixins)来创建全局组件,并组合动态组件。
第六章:全局组件组合
在上一章中,我们学习了如何使用 Composition API 来创建组件逻辑,以及如何在 Vue 应用中编写自定义的可复用可组合组件。除了可组合组件之外,还有许多方法可以在其他组件之间共享类似的逻辑。在本章中,我们将学习如何使用混入(mixins)、插件,以及如何渲染动态组件。
到本章结束时,您将准备好使用混入和插件来组织代码,实现全局组合,并在任何项目中保持代码 DRY(不要重复自己)。您还将了解全局组合的优点和缺点,从而决定最佳方法以最大化组件的灵活性。
本章涵盖了以下主题:
-
理解混入
-
理解插件
-
全局注册组件
-
理解组件标签
-
编写函数组件
技术要求
在本章中,您需要按照 第一章 中 启动您的第一个 Vue 项目 的说明设置一个基本的 Vue 项目。建议创建一个单文件 Vue 组件来练习提到的示例和概念。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter06。
理解混入
使用混入,我们可以向组件的 option 对象中添加额外的函数、数据属性和生命周期方法。
在以下示例中,我们首先定义一个包含 greet 方法和 greeting 数据字段的混入:
/** greeter.js */
export default {
methods: {
greet(name) {
return `${this.greeting}, ${name}!`;
}
},
data() {
return {
greeting: 'Hello'
}
}
}
然后,我们可以通过导入并将它作为组件 option 对象中的 mixins 字段的一部分来使用 greeter 混入,如下所示:
<script>
import greeter from './mixins/greeter.js'
export default {
mixins: [greeter]
}
</script>
mixins 是一个数组,它接受任何混入作为其元素,而实际上 mixin 是一个组件的 option 对象。混入允许多个组件独立地共享公共数据和逻辑定义。
一旦我们将混入添加到组件中,Vue 将将所有数据和函数合并到现有的数据和函数中(混入)。然后,混入的属性和方法将可在组件中使用,作为其自己的数据和函数,就像我们在以下模板中使用 greeter 混入的 greet 方法一样:
<template>
<div>{{ greet('World') }}</div>
</template>
在浏览器中,我们将看到以下消息:

图 6.1 – 使用 greeter 混入显示 Hello World
当数据属性或方法名称发生重叠时,Vue 将优先考虑组件自己的选项。我们可以将这种机制解释为组件采用混入作为其默认选项,除非已经存在类似的声明。在这种情况下,实例将忽略混入的定义,而采用组件的定义。
例如,让我们向使用 greeter 混合的组件添加一个 data() 初始化器,该初始化器返回值为 Hi 的 greeting 数据:
<script>
import greeter from './mixins/greeter.js'
export default {
mixins: [greeter],
data() {
return {
greeting: 'Hi'
}
}
}
</script>
greeter 也定义了 greeting,但组件也是如此。在这种情况下,组件会获胜,我们将看到显示的是 Hi 而不是 Hello(如混合中定义的),如下面的截图所示:

图 6.2 – 组件显示带有覆盖的问候数据值的“Hi World”
然而,此机制不适用于生命周期钩子。在混合中定义的钩子将具有优先执行权,Vue 总是最后触发组件的钩子。如果向组件添加了多个混合,则执行顺序遵循它们在 mixins 字段中出现的顺序。
我们可以在以下示例中观察到此执行顺序。让我们创建两个实现 mounted 生命周期钩子的混合,即 firstMixin 和 secondMixin,如下所示:
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')
}
}
完整的代码将如下所示:
<template>
<div>Mixin lifecycle hooks demo</div>
</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>
此组件的浏览器控制台输出如下:

图 6.3 – 控制台日志输出显示了混合和组件钩子的执行顺序
现在,让我们通过在下一个练习中实现你的第一个混合来练习我们迄今为止所学的内容。
练习 6.01 – 创建你的混合
在此练习中,我们将创建一个名为 debugger 的混合。它包含一个接收 Object 作为其参数的 debug 方法,并使用 JSON.stringify() 函数返回表示其结构的字符串。此方法在调试浏览器上的 Vue.js 而不是控制台时打印可读格式的数据时很有用。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter06/Exercise6.01。
执行以下步骤来完成此练习:
-
以使用
npm init vue@3生成的应用程序作为起点,或者在你的代码仓库的根目录中,使用以下命令导航到Chapter06/Exercise6.01文件夹:> cd Chapter06/Exercise6.01/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令),或者使用你喜欢的 IDE。 -
创建一个新的
src/mixins文件夹和一个src/mixins/debugger.js文件,我们将在这里定义我们的混合的框架:export default {} -
我们在
methods下添加一个debug方法。debug方法将接受一个obj参数,并返回该输入参数的JSON.stringify输出。我们将使用JSON.stringify(obj, null, 2)来输出两空格缩进的格式化 JSON:export default {methods: {debug(obj) {return JSON.stringify(obj, null, 2)}}} -
现在,我们可以在
src/App.vue中导入debugger混合,并在mixins属性下注册它:<script>import debug from './mixins/debugger.js'export default {mixins: [debugger],}</script> -
要查看
debug方法的作用,我们将添加一个data方法,它返回一个myObj数据属性,以及一个created钩子,我们将计算myObj的debug输出:<script>// importsexport default {// other component propertiesdata() {return {myObj: {some: 'data',other: 'values'}}},created() {console.log(this.debug(this.myObj))}}</script>
您应该得到以下输出:

图 6.4 – 由于 created 钩子导致的浏览器控制台输出
-
debug在模板中也是可用的。我们可以使用pre标签包裹其输出,以便尊重空白字符:<template><div id="app"><pre>{{ debug(myObj) }}</pre></div></template>
应用程序以及此模板将如下所示:

图 6.5 – 使用混合中的 debug 方法打印 myObj 的浏览器打印
我们已经学习了如何使用混合将共享逻辑和数据以相当明确的方式(mixins属性)注入到多个组件中。然而,由于数据覆盖和钩子执行的机制,混合可能导致大型代码库中的潜在错误和不受欢迎的行为。因此,我们建议在可能的情况下,将共享逻辑和数据作为可组合的 Composition API 创建,而不是使用混合。
现在,我们将探讨如何注入实例和全局功能,并通过插件进行分发。
理解插件
Vue 插件是向 Vue.js 全局添加自定义功能的一种方式。插件候选者的经典例子是翻译/国际化库(例如i18n-next)和 HTTP 客户端(例如axios、fetch和GraphQL客户端)。插件初始化器可以访问Vue实例,因此它是一个很好的方式来包装全局指令和组件,并在整个应用程序中注入资源。
Vue 插件是一个暴露install方法的对象。install函数使用app实例和options调用:
const plugin = {
install(app, options) {}
}
在install方法中,我们可以注册指令和组件,并添加全局和实例属性和方法:
const plugin = {
install(app, options) {
app.directive('fade', { bind() {} })
app.component(/*Register component globally*/)
app.provide(/*Provide a resource to be injectable*/)
app.config.globalProperties.$globalValue = 'very-
global-value'
}
}
我们可以使用use实例方法注册一个插件,如下所示:
import plugin from './plugin'
const app = createApp(/*…*/)
app.use(plugin)
我们可以将选项作为use()方法的第二个参数传递。这些选项将传递给插件:
app.use(plugin, { optionProperty: true })
use()不允许您注册相同的插件两次,避免了在尝试多次实例化或安装相同插件时出现的边缘情况行为。
在与 Vue 结合使用时,Axios 是一个流行的 HTTP 客户端。通常,使用拦截器或 Axios 选项配置 Axios 以实现重试、传递 cookie 或跟随重定向等功能。
可以使用npm install –save axios安装 Axios。在下一个练习中,我们将在应用程序内部创建一个包装 Axios 的插件。
练习 6.02 – 创建自定义 Axios 插件
为了避免添加 import axios from 'axios' 或将我们的自定义 Axios 实例包装在 http 或 transport 内部模块下,我们将我们的自定义 Axios 实例注入到 Vue 对象和 Vue 组件实例中,在 Vue.axios 和 this.axios 下。这将使它在我们的应用程序中使用更加方便和舒适,该应用程序需要调用 API 并使用 Axios 作为 HTTP 客户端。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter06/Exercise6.02。
执行以下步骤以完成此练习:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录中,通过以下命令导航到Chapter06/Exercise6.02文件夹:> cd Chapter06/Exercise6.02/> yarn -
在 VS Code 中打开练习项目(通过在项目目录中使用
code .命令)或使用您首选的 IDE。 -
为了正确组织代码,我们将在
src/plugins中创建一个新的文件夹,并在src/plugins/axios.js中创建一个新的axios插件文件。在新文件中,我们将构建axios插件:import axios from 'axios'export default {install(app, options) {}} -
现在,我们将在
src/main.js中注册我们的axios插件:// other importsimport axiosPlugin from './plugins/axios.js'// Vue instantiation codeconst app = createApp(App)app.use(axiosPlugin)app.mount('#app') -
现在,我们将使用以下命令通过 npm 安装 Axios。这将允许我们导入 Axios 并通过插件将其暴露在 Vue 中:
npm install --save axios -
现在,我们将 Axios 添加到 Vue 中,作为
src/plugins/axios.js中的全局属性:import axios from 'axios'export default {install(app) {app.config.globalProperties.$axios = axios}} -
Axios 现在在 Vue 中可用。在
src/App.vue中,我们可以向 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 } = awaitthis.$axios('https://jsonplaceholder.typicode.com/todos')this.todos = todos},data() {return { todos: [] }}}</script>
下面的输出是预期的:

图 6.6 – 全局 this.$axios todo 显示示例
-
在必要时,我们也可以通过使用
app.provide()如下提供 Axios 作为可注入项,而不是在所有组件中使其可用:// importsexport default {install(app, options) {// other plugin codeapp.provide('axios', axios)}} -
现在,如果我们通过使用
inject属性将'axios'注入到该组件中,我们就可以在src/App.vue中通过this.axios访问 Axios:<script>export default {inject: ['axios'],async mounted() {const { data: todos } = await this.axios('https://jsonplaceholder.typicode.com/todos')this.todos = todos},data() {return { todos: [] }}}</script>
上述代码的输出与 图 6.6 中的输出相同。
有了这个,我们已经使用插件注入了全局和实例级别的属性和方法。
现在,我们将探讨全局注册组件如何帮助减少代码库中高使用组件的样板代码。
全局注册组件
使用插件的原因之一是通过删除 imports 并用对 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'
app.component('CustomButton', CustomButton)
我们现在可以在 App.vue 文件中使用它,而无需本地注册或导入:
<template>
<div>
<CustomButton>Click Me</CustomButton>
</div>
</template>
这将按预期渲染,显示一个名为 Click Me 的按钮:

图 6.7 – 使用 Click Me 按钮渲染 CustomButton
通过这样,我们已经探讨了全局注册组件如何减少在代码库中频繁使用组件时的样板代码。
下一节将致力于通过学习如何在不使用 .vue 文件的情况下使用 Vue.js 组件来加深我们对 Vue.js 组件的理解。
使用非 SFC 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 setup>
import StringTemplate from './components/StringTemplate.js'
</script>
不幸的是,浏览器不会显示 StringTemplate 的内容,在控制台日志中,您将找到以下 Vue 警告:

图 6.8 – Vue 运行时编译器缺失警告
为了使此组件正常工作,我们需要包含 Vue 运行时编译器。我们可以通过手动添加 'vue/dist/vue.esm-bundler.js' 作为 Vite 引擎的 vue 别名来解决。
您的 vite.config.js 文件应如下所示:
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src",
import.meta.url)),
vue: "vue/dist/vue.esm-bundler.js",
},
},
});
重新启动开发服务器后,浏览器中会出现 StringTemplate 组件的消息:
String Template Component
接下来,让我们探索如何使用 render 函数创建组件。
理解渲染函数
在幕后,template 部分在构建时被编译成一个 render 函数。
从 Vue 3 开始,组件选项中的 render 方法不再接受 createElement 参数并返回从 createElement 执行中接收到的虚拟 DOM 节点。相反,Vue 提供了一个 h 函数,它执行相同的功能。
我们可以在一个 JavaScript 文件(RenderFunction.js)中定义一个组件,并具有一个 render 属性,如下所示:
import { h } from 'vue'
export default {
render() {
return h(
'h2',
'Render Function Component'
)
}
}
这可以在 App.vue 文件中如下渲染:
<template>
<div id="app">
<RenderFunction />
</div>
</template>
<script setup>
import RenderFunction from './components/RenderFunction.js'
</script>
此组件在浏览器中显示一个带有文本 Render Function Component 的 h2:
Render Function Component
除了在非 .vue 文件中编写组件外,render 函数对于高度动态的组件也很有用。
JSX
JSX 是 JavaScript 的超集,它允许在 React 中使用 HTML 样式的标签和花括号进行插值,类似于 SFC 到 Vue。
与 Vue 一样,React 也不会将原始 JSX 渲染到 DOM 中。React 也使用 render 将组件的内容渲染到虚拟 DOM 中。然后虚拟 DOM 与真实 DOM 进行 协调(同步)。
Vite 和 Vue CLI 都默认支持 Vue 应用的 JSX 编译器。您只需在安装配置时使用 create-vue 打开 JSX 支持。这意味着我们可以编写以下 JSXRender.js 文件,它是 RenderFunction 组件的等价物:
export default {
render() {
return <h2>JSX Render Function Component</h2>
}
}
没有 JSX 的等效 render 函数如下所示:
import { h } from 'vue'
export default {
render() {
return h(
'h2',
'JSX Render Function Component'
)
}
}
以下 App.vue 文件将 JSXRender 渲染到浏览器:
<template>
<div id="app">
<JSXRender />
</div>
</template>
<script setup>
import JSXRender from './components/JSXRender.js'
</script>
现在,我们可以在屏幕上看到 JSXRender 中的 h2,内容符合预期:
JSX Render Function Component
我们现在将探讨如何使用 Vue.js 的 component 标签从运行时数据动态渲染组件。
理解组件
JSX 和 render 函数非常适合那些需要渲染的组件非常动态的情况。我们也可以使用 Vue 的 component 实现这种能力。
要渲染动态组件,我们使用一个带有绑定 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 和 ImageEntry——并将 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>
在前面的代码中,Vue 将渲染 Card 组件,因为我们设置了 itemComponent 的值为 card。
如果我们将 itemComponent 设置为 image-entry,Vue 将渲染 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="ImageEntry"
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>
您的输出将显示卡片视图中的条目,如下所示:

图 6.9 – 卡片视图中的网格渲染条目
使用 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>
您现在将看到图像视图中的条目,如下面的屏幕截图所示:

图 6.10 – 图像视图中的网格渲染条目
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>
在 FirstStep 组件中,我们将实现一个需要用户姓名的 input 字段,如下所示:
<template>
<label> Name: <input v-model="name" /> </label>
<button @click="next">Next</button>
</template>
<script setup>
import { ref } from "vue";
const name = ref("");
const emits = defineEmits(["next"]);
const next = () => {
emits('next', name.value);
};
</script>
对于 SecondStep.vue,我们将实现另一个带有两个按钮的输入字段,用于导航后退和前进,如下面的代码块所示:
<template>
<label> Address: <input v-model="address" /> </label>
<button @click="next">Next</button>
<button @click="back">Back</button>
</template>
<script setup>
import { ref } from "vue";
const emits = defineEmits(["next", "back"]);
const next = () => {
emits("next", name.value);
};
const back = () => {
emits("back");
};
const address = ref("");
</script>
通过这样做,我们可以在 名称 字段中输入数据:

图 6.11 – “我的名字是”已输入到名称字段中
如果我们使用 下一步 导航到表单的地址部分,然后使用 后退,名称将消失,如下面的屏幕截图所示:

图 6.12 – 点击下一步然后后退到地址步骤时名称字段为空
这是因为组件在不是当前渲染的动态组件时会被卸载。
为了解决这个问题,我们可以在 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>
以这种方式,填写姓名并从表单的地址部分返回将显示以下内容:

图 6.12 – 导航后“我的名字是”仍然是名称字段中的值
我们已经学习了如何使用 component 标签来表示一个区域,在这个区域内我们可以根据字符串或组件本身(如导入的)动态显示组件。我们还探讨了如何解决 component 的主要问题;即如何使用 keep-alive 在 component 标签中维护不是当前正在使用的组件的状态。
现在让我们在下一个练习中练习我们所学的内容。
练习 6.03 – 使用组件标签创建动态卡片布局
现代应用布局是一个由卡片组成的网格。卡片布局的优点是非常适合移动、桌面和平板显示器。在这个练习中,我们将创建一个具有三种不同模式和选择其中一种方式的功能的动态卡片布局。这个布局将允许用户选择屏幕上显示多少信息以适应他们的偏好:
-
Rich视图将显示项目的所有详细信息,包括图片、标题和描述 -
Compressed视图将显示所有详细信息,但不显示图片预览 -
List视图将只显示标题,应该是一个垂直布局
每个卡片视图都将作为一个单独的组件实现,然后使用component标签动态渲染。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter06/Exercise6.03。
执行以下步骤以完成此练习:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在代码仓库的根目录中,使用以下命令按顺序导航到Chapter06/Exercise6.03文件夹:> cd Chapter06/Exercise6.03/> yarn -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
src/components/Rich.vue中创建丰富的布局。它包含三个属性,称为url(图片 URL)、title和description,分别渲染图片、标题和描述:<template><div class="card"><img :src="img/url" width="200" /><h3>{{ title }}</h3><p>{{ description }}</p></div></template><script setup>import { defineProps } from 'vue'const { url, title, description } = defineProps(['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 setup>const items = [{id: '10',title: 'Forest Shot',description: 'Recent shot of a forestoverlooking a lake',url:'https://picsum.photos/id/10/1000/750.jpg',},{id: '1000',title: 'Cold cross',description: 'Mountaintop cross withsnowfall from Jan 2018',url:'https://picsum.photos/id/1000/1000/750.jpg',},]</script> -
将
Rich视图组件导入到src/App.vue并本地注册:<script setup>import Rich from './components/Rich.vue'// other component properties, eg. "data"</script> -
一旦我们得到了
Rich视图组件,将其连接到src/App.vue中的应用程序中,使用component渲染它,并通过以下方式传递相关属性:<template><!-- rest of template --><componentv-for="item in items":key="item.id":is="layout":title="item.title":description="item.description":url="item.url"/><!-- rest of template --></template><script setup>import { shallowRef } from 'vue'const layout = shallowRef(Rich)// other data definitions eg. 'items'</script> -
这是一个添加一些样式使网格看起来像网格的好地方:
<template><!-- rest of template --><div class="grid"><componentv-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>
这将显示以下输出:

图 6.14 – 动态渲染的 Rich 组件
-
现在,在
Compressed.vue文件中实现Compressed视图,它只是没有图片的Rich视图:<template><div class="card"><h3>{{ title }}</h3><p>{{ description }}</p></div></template><script setup>import { defineProps } from 'vue'const { title, description } = defineProps(['title', 'description'])</script><style scoped>.card {display: flex;flex-direction: column;min-width: 200px;}h3 {font-weight: normal;padding-bottom: 0;}p {margin: 0;}</style> -
在
src/App.vue中导入并注册Compressed组件。然后创建我们的layoutOptions数组,包含两个元素。每个元素有两个字段:布局的name和两个组件实例,分别是Rich和Compressed,如下面的代码块所示:<script setup>// other importsimport Compressed from './components/Compressed.vue'const layoutOptions = [ {name: 'Rich',component: Rich}, {name: 'Compressed',component: Compressed}]// other component properties</script> -
添加
select以在视图之间切换。它将从layoutOptions数组中使用v-for获取选项,并使用v-model将其选中值绑定到layout:<template><!-- rest of template -->Layout: <select v-model="layout"><optionv-for="(option, index) in layoutOptions":key="index":value="option.component">{{option.name}}</option></select><!-- rest of template --></template>
使用select,我们可以切换到Compressed布局,其外观如下:

图 6.15 – 带有选项下拉选择器的压缩布局
-
将
List布局添加到src/components/List.vue。List视图是压缩视图,但没有描述:<template><h3>{{ title }}</h3></template><script setup>import { defineProps } from 'vue'const { title } = defineProps(['title'])</script><style scoped>h3 {width: 100%;font-weight: normal;}</style> -
将
List组件导入到src/App.vue并本地注册:<script setup>// other importsimport List from './components/List.vue'const layoutOptions = [ {name: 'Rich',component: Rich}, {name: 'Compressed',component: Compressed}, {name: 'List',component: List}]// other component properties</script>
当切换到List布局时,项目以水平行显示,如下所示:

图 6.16 – 带有错误水平堆叠的列表视图
-
为了修复这种水平堆叠,创建一个新的
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布局如下所示:

图 6.17 – 带有垂直堆叠的列表视图
通过这样,我们已经学会了如何使用component标签通过名称和通过使用组件对象本身动态渲染不同的组件。我们还探讨了有状态动态组件的陷阱,即组件不再显示时的组件拆解以及如何使用keep-alive元素来规避它们。
我们现在将探讨如何使用函数式组件仅通过render函数或template标签来实现简单的组件。
编写函数式组件
在 Vue 2.0 中,您可以通过在组件选项中将functional字段设置为true来声明一个组件为函数式组件:
export default {
functional: true,
}
这也可以通过直接在template标签上设置functional来实现:
<template functional>
<!— template code -->
</template>
您还可以通过组件的render()方法或template部分来设置组件的渲染方式。然而,如果两个字段都存在,Vue 将采用render()方法。
然而,从 Vue 3.0 开始,Vue 移除了functional属性,您只能使用 JavaScript 函数声明函数式组件,这是 Vue 将触发以创建组件的render函数:
const functionComp = (props, context) => {
return h(/*…*/)
}
一旦声明为函数式组件,该组件就没有任何响应式状态,并且由于它不可用,您无法访问this实例。相反,Vue 触发render函数,并将组件的属性和必要上下文传递给它,包括attrs、slots以及传递的事件处理器的emit:
const functionComp = (props, { attrs, slots, emit }) => {
return h(/*…*/)
}
您也可以通过直接设置其字段props和emits来定义接受的属性和事件:
functionComp.props = [/* props */]
functionComp.emits = [/* emits */]
要开始使用 JSX 代码,您需要使用 Vite 创建带有 JSX 支持的 Vue 项目。否则,在vite.config.js中,您需要手动从'@vitejs/plugin-vue-jsx'包导入vueJsx,如下所示:
import vueJsx from '@vitejs/plugin-vue-jsx'
然后将导入的vueJsx插件添加到plugins数组中,如下所示:
plugins: [vue(), vueJsx()],
通过前面的配置,您现在使用 Vite 创建的 Vue 项目支持带有 JSX 的函数式组件,我们将在以下示例中演示。
以下代码块是定义函数式组件 GreetComponent.jsx 的示例:
import { h } from 'vue';
export function GreetComponent(props, context) {
return h(
<div>
<h2>{ props.greeting} {props.audience} </h2>
<button
onClick={() => context.emit('acknowledge', true) }
>
Acknowledge
</button>
</div>
)
}
我们还显式地声明了 GreetComponent 的 props 和 emits:
GreetComponent.props = {
greeting: String,
audience: String,
}
GreetComponent.emits = ['acknowledge']
注意,在这里,由于我们使用 JSX 语法来定义组件,我们需要确保 GreetComponent 的文件扩展名是 .jsx 而不是 .js (GreetComponent.jsx)。此外,我们需要确保在用 create-vue 命令(Vite)创建项目时启用 JSX 支持。
现在,我们可以在 App.vue 中将 GreetComponent 作为常规组件导入和使用:
<script setup>
import { GreetComponent } from './components/GreetComponent.jsx'
const acknowledge = () => {
console.log('Acknowledged')
}
</script>
<template>
<GreetComponent
greeting="Hi"
audience="World"
@acknowledge="acknowledge"
/>
</template>
这将在浏览器中渲染以下内容:

图 6.18 – 函数式组件渲染
您还可以结合 Composition API 创建具有状态的函数式组件。在下面的示例中,我们创建了一个具有响应式消息的 Message 组件:
import { ref } from 'vue'
export function Message(props, context) {
const message = ref('Hello World')
return (
<div>
<span>{ message.value }</span>
</div>
)
}
注意,在这里,我们通过使用 message.value 而不是直接使用 message 来显示 message 值。
到目前为止,我们已经学习了如何使用 Composition API 编写函数式组件 – 无状态或带状态 – 现在,我们将构建一个使用本章中查看的所有模式的待办事项应用。
活动 6.01 – 使用插件和可重用组件构建 Vue.js 应用程序
要访问此活动的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter06/Activity6.01
在这个活动中,我们将构建一个集成 jsonplaceholder 作为数据源的待办事项应用。
我们的待办事项应用将加载待办事项并将它们显示为列表。它将根据待办事项是否完成显示复选框,以及待办事项的名称。
当勾选待办事项时,应用程序将同步它到 API。
我们将作为插件注入 Axios 以查询 jsonplaceholder.typicode.com。
按照以下步骤完成此活动:
-
在项目中安装
axios。 -
要将
axios注入为属性到this组件实例中,创建一个src/plugins/axios.js插件文件,在安装时,这意味着组件实例将有一个可注入的axios属性。 -
为了使插件工作,请将其导入并注册到
src/main.js中。 -
我们还希望将我们的 API 的
$baseUrl注入到所有组件中作为全局作用域。我们将创建一个与src/main.js文件内联的插件来完成此操作。 -
现在,我们想要从
src/App.vue中获取所有待办事项。一个好的地方是在mounted生命周期方法中做这件事。 -
要显示待办事项列表,我们将在
src/components/TodoList.vue中创建一个TodoList组件,该组件接受一个todos属性,遍历项目,并在todo作用域插槽中延迟渲染待办事项,该插槽绑定待办事项。 -
我们现在可以使用
TodoList组件在src/App.vue中渲染我们已获取的 todos。 -
现在,我们需要创建一个
TodoEntry组件,我们将在这里实现大部分待办事项特定的逻辑。对于组件来说,一个好的做法是让 props 非常具体地对应组件的角色。在这种情况下,我们将处理的todo对象的属性是id、title和completed,因此这些应该是我们的TodoEntry组件接收的 props。我们不会将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。
预期输出如下:

图 6.19 – 使用 jsonplaceholder 数据的我们的待办事项应用
摘要
在本章中,我们探讨了全局组合模式和高级组件设置,我们可以利用这些模式为 Vue.js 应用程序创建一个可重用的代码库。我们学习了混合(mixins)、插件,如何使用组件标签进行动态组件渲染,以及有状态和无状态的函数组件。
到目前为止,我们已经学习了如何从组件、混合和插件的角度构建应用程序。要构建跨越多个页面的应用程序,我们需要实现路由。这就是我们在下一章将要解决的问题。
第七章:路由
在上一章中,您学习了使用 mixins 在组件之间共享公共逻辑、创建应用插件以及其他创建组件的方法,如动态和功能组件。
本章将指导您了解路由和 Vue Router 是如何工作的。您将学习如何使用 Vue Router 在您的应用中设置、实现和管理路由系统。您将了解动态路由用于传递参数值和嵌套路由用于在复杂应用中提高复用性。此外,我们还将探讨 JavaScript 钩子,这些钩子对于身份验证和错误处理非常有帮助。
到本章结束时,您将准备好处理任何 Vue 应用中的静态和动态路由。
本章涵盖了以下主题:
-
理解路由
-
理解 Vue Router
-
探索
RouterView元素 -
定义路由
-
为您的应用设置默认布局
-
使用
RouterLink设置导航链接 -
传递路由参数
-
理解路由钩子
-
使用 props 解耦参数
-
动态路由
-
捕获错误路径
-
嵌套路由
-
使用布局
技术要求
在本章中,您需要按照第一章中“开始您的第一个 Vue 项目”的说明设置一个基本的 Vue 项目。建议创建一个单文件 Vue 组件来练习轻松地处理提到的示例和概念。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07。
理解路由
当用户在 URL 栏中输入website.com/about时,将被重定向到关于页面。
在 Web 开发中,路由是我们决定如何将 HTTP 请求与处理它们的代码相连接的匹配机制。每当我们的应用需要 URL 导航时,我们都会使用路由。大多数现代 Web 应用都包含许多不同的 URL,即使是单页应用也是如此。
因此,路由创建了一个导航系统,并帮助用户快速在我们的应用和网络上移动。在单页应用(SPAs)中,路由允许您在应用内部平滑导航,无需刷新页面。
简而言之,路由是应用根据提供的 URL 解释用户想要什么资源的一种方式。它是一个基于 URL 的基于 Web 的资源导航系统,如资产(图像和视频)路径、脚本和样式表。
理解 Vue Router
如 Vue.js 文档所述,Vue Router是任何 Vue.js 应用的官方路由服务。它提供了一个组件与路由之间的通信的单入口点,因此有效地控制了应用的流程,无论用户的行为如何。
安装 Vue Router
Vue Router 默认未安装;然而,当使用 Vite 创建应用程序时,它可以很容易地被启用。通过运行以下命令创建一个新应用程序:
npm init vue@3
选择 图 7.1 中所示的 Yes 选项,将 Vue Router 添加到项目中:

图 7.1 – 在创建项目时添加 Vue Router
注意
如果你想将 Vue Router 添加到现有的 Vue.js 应用程序中,你可以使用以下命令将其作为应用程序的依赖项安装:
npm install vue-router
下一步是了解 Vue Router 如何同步浏览器 URL 和应用程序视图。
首先,让我们看看 RouterView 元素。
探索 RouterView 元素
RouterView 是一个 Vue 组件,其任务是以下内容:
-
渲染不同的子组件
-
根据给定的路由路径,在任何嵌套级别自动挂载和卸载自身
没有使用 RouterView,在运行时正确渲染动态内容几乎是不可能的。例如,当用户导航到 RouterView 知道并只生成与该页面相关的内。
让我们看看我们如何通过 RouterView 将属性传递给视图。
将属性传递给视图
由于 RouterView 是一个组件,它也可以接收属性。它接收的唯一属性是 name,这是在初始化阶段在 router 对象中定义的相应路由记录中注册的相同名称。
Vue 引擎自动将任何其他额外的 HTML 属性传递给 RouterView 渲染的任何视图组件。
以具有 "main-app-view" 类的以下 RouterView 组件为例:
<RouterView class="main-app-view"/>
假设我们有一个视图组件的模板,其代码如下:
<template>
<div>Hello World</div>
</template>
在这种情况下,当子组件是活动视图时,它将接收到 "main-app-view" 属性类。渲染后的实际输出如下:
<div class="main-app-view">Hello World</div>
接下来,让我们看看 RouterView 是如何工作的。
与 RouterView 一起工作
在你新创建的应用程序中,让我们导航到 App.vue 并将 <template> 的默认代码替换为以下内容:
<template>
<div id="app">
<RouterView/>
</div>
</template>
然后,转到 src/router/index.js 并注释掉 routes 数组中生成的代码,如下所示:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// {
// path: '/',
// name: 'home',
// component: HomeView
// },
// {
// path: '/about',
// name: 'about',
// component: () => import('../views/AboutView.vue')
// }
]
})
当你在本地运行应用程序并在浏览器中打开本地服务器 URL 时,输出将如 图 7.2 所示:

图 7.2 – 未定义路由时应用程序的输出
输出是一个空页面,因为我们没有在我们的文件中设置任何路由配置,包括将路径映射到相关视图。没有这一步,路由系统无法动态选择合适的视图组件并将其渲染到我们的 RouterView 元素中。
在下一节中,我们将了解如何设置 Vue Router。
设置 Vue Router
当我们将 Vue Router 添加到我们的项目中时,Vite 会在/src目录中创建并添加一个名为router的文件夹,其中包含一个自动生成的index.js文件。此文件包含我们路由系统的必要配置,我们将在下一节中探讨。
在src/main.js文件中,我们导入定义的配置对象,并使用 Vue 实例方法use()将路由系统安装到应用程序中,如下所示:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use是一个实例方法,具有内置机制,防止您安装插件超过一次。
执行app.use(router)后,以下对象可以在任何组件中访问:
-
this.$router:全局路由对象 -
this.$route:当前路由对象指向上下文中的元素
如果您正在使用setup()和组合式 API(或<script setup>),您可以从vue-router包中导入useRoute()和useRouter()函数,并分别获取当前路由对象(而不是this.$route)和全局路由对象(而不是this.$router)。
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute();
const router = useRouter();
// component's logic…
</script>
既然我们已经在我们的应用程序中注册了 Vue Router 的使用,让我们继续下一步——定义路由实例的配置对象的路线。
定义路由
在 Web 应用程序中,路由是一个 URL 路径模式。Vue Router 将其映射到特定的处理程序。此处理程序是一个 Vue 组件,定义并位于一个物理文件中。例如,当用户输入localhost:3000/home路由时,如果您将HomeView组件映射到该特定路由,路由系统就会知道如何相应地渲染HomeView内容。
如*图 7**.2 所示,设置应用程序内的routes(或路径)对于导航至关重要;否则,您的应用程序将显示为空。
每个路由都是一个使用RouteRecordRaw接口的对象字面量,具有以下属性:
interface RouteRecordRaw = {
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,
sensitive?: Boolean,
strict?: Boolean
}
我们将应用程序所需的所有路由定义为一个routes列表:
const routes = [
//Route1,
//Route2,
//...
]
我们回到src/router/index.js文件,取消注释routes内部的代码。为了方便,将有两个预定义的路由,分别称为home和about,每个都是位于routes数组中的对象。
让我们以第一个路由为例,进行更详细的了解:
{
path: '/',
name: 'home',
component: HomeView
}
path属性是一个必需的字符串,表示目标路由的路径。Vue Router 将此属性解析为浏览器导航的绝对 URL 路径。例如,/about路径将被转换为<app domain>/about(localhost:8080/about或example.com/about)。
在这种情况下,Vue Router 将/(空路径)视为默认路径,用于在没有其他指示符(例如,当用户导航到<app-domain>或<app-domain>/(未设置strict: true)时)加载应用程序。
下一个属性是name,它是一个字符串,表示分配给目标路由的名称。尽管它是可选的,但我们建议为每个路径定义一个名称,以利于代码维护和路由跟踪,我们将在本章后面的传递路由 参数部分讨论这一点。
最后一个属性是component,它是一个 Vue 组件实例。RouterView使用此属性来引用视图组件,在路径处于活动状态时渲染页面内容。
在这里,路由被定义为home路由,映射为应用程序的默认路径,并与HomeView组件关联以显示内容。
Vite 还自动为这两个示例路由生成两个简单的组件 – HomeView和AboutView,位于src/views文件夹中。
在下一节中,我们将介绍一些在使用带有路由的加载组件时可能有所帮助的技巧。
关于为路由配置加载组件的技巧
事实上,我们需要在同一个index.js文件中导入组件,将其与目标路由关联起来。最经典和最受欢迎的方法是在文件顶部导入,如下所示:
import Home from '../views/HomeView.vue'
通常,我们会在主导入下添加此代码行,如图*7.3**所示:

图 7.3 – 在第 2 行导入 HomeView 组件 – src/router/index.js
然而,一种更有效的方法是懒加载组件。
懒加载,也称为按需加载,是一种旨在运行时优化网站或网络应用程序内容的技巧。它有助于减少首次加载应用程序所需的时间和资源消耗。
这种优化对于确保最佳的用户体验至关重要,因为每一毫秒的等待都很重要。除此之外,懒加载还能在路由级别实现更好的代码拆分,并在大型或复杂应用程序中进行性能优化。
我们可以使用 Vite(和 Rollup)来懒加载组件。我们不需要将AboutView组件导入到文件顶部,就像我们导入HomeView(参见图7.3)那样,我们可以在定义about路由名称后动态添加以下内容:
component: () => import('../views/AboutView.vue')
在这里,我们为about路由动态懒加载AboutView视图组件。在编译期间,Vite 为目标路由生成一个具有指定名称(about)的单独块,并且只有在用户访问此路由时才加载它。
在大多数情况下,由于用户很可能会在第一次访问时到达默认路径,因此最好不要懒加载默认组件(在我们的应用程序中是HomeView),而是以通常的方式导入它。因此,这里的建议是在设计路由时确定哪些元素应该被懒加载,并将两种方法结合起来以获得最大效益。
现在我们将看看如何设置路由实例。
设置路由实例
在定义了路由后,最后一步是根据给定的配置选项使用createRouter方法创建基于配置的router实例,如下所示:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
配置是一个包含不同属性的对象,这些属性有助于形成应用程序的 router。我们现在将在以下小节中检查这些属性。
routes
routes是一个必需的选项。没有它,路由器将无法识别路径,并相应地将用户引导到合适的视图内容。
history
history决定了路由器的模式。Vue Router 中有两种 URL 模式:
-
使用
createWebHistory()方法利用默认的history.pushState()API 以及 HTML5 历史 API。它允许我们在不重新加载页面的情况下实现 URL 导航,并使 URL 路径易于阅读 – 例如,yourapplication.com/about。 -
使用
createWebHashHistory()方法创建 hash 模式,这允许你使用 hash 符号(#)来模拟 URL – 例如,yourapplication.com/#about用于应用程序的“主页”URL,而youapplication.com/#/用于应用程序的“主页”URL。
base
base决定了应用程序的基本 URL。例如,当我们将其设置为process.env.BASE_URL时,它允许开发人员从应用程序代码外部控制基本 URL,特别是从.env文件中。因此,开发人员可以在运行时设置托管平台提供代码的目录。
在base配置最终确定后,我们创建了router实例。接下来要做的就是导出它:
export default router
在main.js中,我们导入router实例,并在从createApp接收到的主app实例创建后立即使用它,如下所示:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
在App.vue中,将<template>替换为以下代码:
<template>
<header>
<img alt="Vue logo" class="logo"
src="img/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
</div>
</header>
<RouterView />
</template>
我们的应用程序现在将如下渲染:

图 7.4 – 浏览器中的主页
如果我们导航到/about,假设自动生成的代码中about组件的内容如下所示:
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
网站应该看起来像图 7.5中所示的那样:

图 7.5 – 浏览器中的应用程序“关于”页面
在本节中,我们探讨了如何通过懒加载组件来加速大型和复杂的 SPAs。我们还探讨了设置路由系统的一些选项,例如routes、history和base。
你还看到了 Vue 引擎渲染了两个页面,/about和/home,它们具有相同的标题内容,如图 7.6所示:

图 7.6 – 在图 7.5 的“关于”页面中显示相同标题的主页
原因是 Vue 只替换了占位组件 RouterView,用目标视图的内容替换,而在此作用域之外定义的任何模板都将保持不变。这样,我们可以在应用的所有视图中创建一个默认布局。
为你的应用设置默认布局
为了使我们的模板功能正常,它还应该包含 <RouterView/> 元素。一个标准的设置是在模板中有一个导航菜单 <nav>,并在其下方放置 RouterView。这样,页面之间的内容会变化,而 header 菜单保持不变。
导航到 App.vue 并确保你的模板有如下代码:
<template>
<header>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</header>
<RouterView />
</template>
你的输出现在应该包含一个静态的标题,其中包含两个导航链接 – 首页 和 关于 – 而内容会根据路由变化:

图 7.7 – 首页的内容
一旦你导航到 /about 页面,标题链接不会改变,而内容现在变为以下内容:

图 7.8 – 关于页面的内容
到目前为止,你已经学会了如何创建默认布局,并使用 RouterView 动态渲染目标内容视图。在下一节中,我们将学习如何在 Vue Router 的帮助下实现和添加一个 消息推送 页面。
练习 7.01 – 使用 Vue Router 实现消息推送页面
在这个练习中,你将使用 RouterView 渲染一个新的视图组件,该组件显示消息推送。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Exercise7.01。
我们将创建一个新页面,向用户显示消息列表。用户可以在浏览器中输入 localhost:3000/messages 路径时访问此页面。执行以下步骤:
-
使用由
npm init vue@3生成的应用程序作为起点,或者在你代码仓库的根目录中,使用以下命令按顺序导航到Chapter07/Exercise7.01文件夹:> cd Chapter07/Exercise7.01/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或你的首选 IDE。 -
让我们创建一个新的视图组件
MessageFeed,通过在./src/views/文件夹中添加一个MessageFeed.vue文件来实现:

图 7.9 – 视图目录层次结构
此组件将渲染一个消息列表。我们使用 <script setup> 定义 messages – 一个字符串数组 – 作为我们的本地数据,如下所示:
<template>
<div>
<h2> Message Feed </h2>
<p v-for="(m, i) in messages" :key="i">
{{ m }}
</p>
</div>
</template>
<script setup>
const messages = [
'Hello, how are you?',
'The weather is nice',
'This is the message feed',
'And I am the fourth message'
]
</script>
-
如果不存在,在
src/router/index.js创建一个路由文件。确保你从'vue-router'导入createRoute和createWebHistory,以及HomeView组件,如下面的代码所示:import { createRouter, createWebHistory } from 'vue-router'import HomeView from '../views/HomeView.vue' -
我们声明了一个指向
MessageFeed的路由,命名为messageFeed,其路径设置为/messages。我们还将懒加载该组件。此步骤将通过将包含所需信息的对象附加到routes数组来完成:export const routes = [{path: '/',name: 'home',component: HomeView},{path: '/about',name: 'about',component: () => import('../views/AboutView.vue')},{path: '/messages',name: 'messageFeed',component: () =>import('../views/MessageFeed.vue')}] -
最后,在同一文件中,使用我们导入的
createRouter和createWebHistory函数以及我们定义的routes数组创建一个router实例:const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes})export default router -
确保在
src/main.js中导入创建的router实例,并通过使用app.use(router)将其作为插件附加到app实例上:import router from './router'const app = createApp(App)app.use(router)app.mount('#app') -
在
App.vue中,确保<template>只包含以下代码:<template><RouterView /></template> -
使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
localhost:3000/messages(或 Vite 创建的任何本地服务器),页面应该显示正确的内容 – 如以下截图所示的 消息源 页面:

图 7.10 – 消息源页面
这个练习展示了使用 Vue Router 向 Vue.js 应用程序添加新页面路由是多么简单,同时保持代码组织良好且易于阅读。现在我们已经准备好了可用的路由,我们可以允许用户在页面之间导航,而无需输入完整的路径。
使用 RouterLink 设置导航链接
如我们所知,RouterView 负责根据 URL 路径渲染正确的活动视图内容;另一方面,RouterLink 负责将路由映射到可导航的链接。RouterLink 是一个 Vue 组件,它帮助用户在启用了路由的应用程序内进行导航。默认情况下,RouterLink 会渲染一个带有由其 to 属性生成的有效 href 链接的锚标签 <a>。
在我们由 Vite 生成的示例应用中,由于有两个预填充的路由,因此也在 App.vue 的 <template> 部分添加了两个 RouterLink 实例,作为页眉导航菜单:
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
由于我们使用的是 createWebHistory() 的 web 历史模式,每个 RouterLink 的 to 属性应该接收与目标路由对象中声明的 path 属性相同的值(如 src/router/index.js 中定义的路由列表)。
由于我们命名了路由,使用 to 属性的另一种方法是将其绑定到一个包含路由名称的对象上,而不是路径。使用名称被高度推荐,以避免在需要调整应用中某些路由的路径时进行复杂的链接重构。因此,我们可以将链接重写如下:
<nav>
<RouterLink :to="{ name: 'home'}">Home</RouterLink>
<RouterLink :to="{ name: 'about'}">About</RouterLink>
</nav>
此外,Vue Router 会在活动路由的 <a> 标签上添加一个额外的 CSS 类,router-link-active。我们可以通过 RouterLink 组件的 active-class 属性来自定义这个类选择器。
在以下渲染的 RouterLink 组件中:

图 7.11 – 浏览器元素标签中的 RouterLink
浏览器中的视图将如下所示:

图 7.12 – 带有导航链接的首页
注意,由于我们可以在组件内部访问 this.$router,我们可以通过使用 this.$router.push() 并传递一个路径或路由对象来编程触发导航路由,类似于使用 to:
this.$router.push('/home')
或者,在 <script setup> 中,我们可以执行以下替代代码:
import { useRouter } from 'vue-router'
const router = useRouter();
router.push('/home');
在本节中,我们探讨了如何使用 <RouterLink/> 元素在视图之间导航,就像使用传统的 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
或者,我们可以使用 useRouter() 和 <script setup> 重新编写上述代码,如下所示:
import { useRouter } from 'vue-router'
const router = useRouter();
router.go(1); //forward one page
router.go(-1); //back one page
接下来,我们将利用导航链接将我们的新消息源页面添加到应用程序的 nav 菜单中。
练习 7.02 – 将导航链接添加到 MessageFeed 路由
我们将在练习 7.01 中创建的 MessageFeed 路由中添加一个快速链接,使用 to 属性和 RouterLink,如前文所述。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Exercise7.02。
让我们按以下步骤进行:
-
以使用
npm init vue@3生成的应用程序为起点,或在代码仓库的根目录下,使用以下命令进入Chapter07/Exercise7.02文件夹:> cd Chapter07/Exercise7.02/> yarn -
在 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您的首选 IDE。 -
按照练习 7.01 的说明创建
MessageFeed组件,将其注册到/messages路径,并确保您的路由已在应用程序中启用。 -
在
./src/App.vue文件中,除了为home和about自动生成的RouterLink组件外,再添加一个指向/messages的RouterLink组件:<template><header><nav><RouterLink to="/">Home</RouterLink><RouterLink to="/about">About</RouterLink><RouterLink to="/messages">Message Feed</RouterLink></nav></header><RouterView /></template> -
使用以下命令运行应用程序:
yarn dev
我们将看到在任何视图中都可用导航链接 – 当用户导航离开时,它们不会消失,因为它们不是 RouterView 组件的一部分。我们的屏幕应该如下所示:

图 7.13 – 带有更新导航链接的首页
-
在
App.vue中,让我们将to的值更改为指向名为messageFeed的对象。这是在./src/router/index.js中定义的此路由的name值:<RouterLink :to="{ name: 'messageFeed' }">Message Feed</RouterLink> -
导航应该像以前一样工作;点击以下截图中的
/messages:

图 7.14 – 点击消息源链接后活动页面变为消息源页面
-
现在,打开位于
./src/router/文件夹中的index.js文件,并将messageFeed路由定义的路径从/messages/更改为/messagesFeed:export const routes = [{path: '/',name: 'home',component: HomeView},{path: '/about',name: 'about',component: () => import('../views/AboutView.vue')},{path: '/messagesFeed',name: 'messageFeed',component: () =>import('../views/MessageFeed.vue')}] -
导航到应用的
Home页面并点击/messagesFeed:

图 7.15 – 带有新 URL 路径的消息源页面
注意设置到 /messages 路径的链接是多么简单,只需一行代码,并相应地更新相关路径。
到目前为止,我们已定义了一些简单的路由,针对目标路由没有额外的参数 – 这将是我们的下一个挑战。
传递路由参数
以前我们了解到每个路由都是一个独立的视图,不需要传递或连接到其他路由的数据。但 Vue Router 并没有将路由的强大功能仅限于这一点。通过命名路由,我们还可以轻松地启用路由之间的数据通信。
在我们的示例应用中,我们希望 about 页面能够接收一个名为 user 的数据字符串,作为用户名从触发的链接中获取。在 Vue Router 4.1.4 之前,我们可以通过将 to 属性从字符串字面量更改为具有 name 和 params 属性的对象字面量来实现此功能,如下所示:
<RouterLink :to="{ name: 'about', params: { user: 'Adam' }}">
About
</RouterLink>
此更改通知路由器在用户点击目标链接时将所需的参数传递给 About 页面。这些附加参数在渲染的 href 链接中不可见,如下所示:

图 7.16 - 生成的 href 链接没有参数
输出将如下所示:

图 7.17 – 关于页面渲染通过路由参数传递的用户
然而,这种方法的显著缺点是。
当你仍然在 ./about 路径上时,让我们刷新页面。输出将是一个没有用户名的页面,如下所示:

图 7.18 – 刷新后关于页面丢失用户详情
刷新时,Vue 引擎触发路由而不向路由的params字段传递任何user,这与用户点击特定预定义链接时不同。使用这种方法传递的参数没有被保存或缓存。我们认为这是一种 Vue 实践的反模式。
从 Vue Router 4.1.4 版本开始,直接在to对象上传递参数已被弃用。为了将参数传递到路由,我们应该使用替代方法,例如使用 Pinia 作为全局数据存储,或者使用 URL 的查询参数。
URL 路由的查询参数从问号-开始,如下面的语法所示:
<your-app-url>?<param1>=<value1>&<param2>=<value2>
在上述语法中,每个参数字段由&符号分隔。例如,要将用户参数传递到我们的/about页面,我们将构造以下 URL:
localhost:3000/about?user=Adam
在About组件中,我们将从route中检索query字段
对象,并获取相关字段的值,如下面的代码所示:
<script setup>
import { useRoute} from 'vue-router'
const route = useRoute();
const { user } = route.query;
</script>
在template部分,我们可以将$route.params.user替换为user,输出保持不变,即使在刷新页面时也是如此。
<template>
<div class="about">
<h1>About {{ user }}</h1>
</div>
</template>
在以下部分,我们将学习如何使用路由钩子拦截导航流程并在路由之间动态分配params。
理解路由钩子
要理解 Vue Router 钩子,首先,我们需要了解以下图中描述的路由导航的一般流程:

图 7.19 – 导航解析流程图
一旦触发某个路由的导航,Vue Router 为开发者提供了几个主要的导航守卫或钩子,以保护或拦截该导航过程。这些守卫可以是全局的或组件内的,具体取决于类型。
以下是一些示例:
-
beforeEach、beforeResolve和afterEach -
beforeEnter -
beforeRouteUpdate、beforeRouteEnter和beforeRouteLeave
对于组合式 API,组件内的钩子可用作onBeforeRouteUpdate和onBeforeRouteLeave。没有onBeforeRouteEnter,因为这相当于使用setup()(或script setup)本身。
如图 7.19所示,Vue 引擎仅在所有钩子或守卫都已解决之后才考虑导航,包括任何异步守卫。
现在,让我们看看如何设置beforeEach钩子。
设置beforeEach钩子
beforeEach是一个全局钩子,在导航开始时被调用,即在触发其他全局和组件内钩子之前(除了前一个视图组件中的beforeRouteLeave)。它应该在index.js文件中的初始化期间定义为router实例的全局方法,并采用以下语法:
const router = createRouter({
//...
})
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而没有传递查询参数的user值时显示不同的页面来显示通用消息,我们可以将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.query?.user)) {
next({ name: 'error' })
}
else {
next();
}
})
在这里,我们检查目标路由是否为about,如果没有传递任何额外的参数,也没有为user参数传递任何值,我们将导航到error路由。否则,我们将像往常一样使用next()正常进行。
注意
在任何给定的非重叠流程逻辑中,next()必须恰好调用一次(一次用于if,一次用于else);否则,将出现错误。
我们仍然需要创建一个包含Error.vue视图组件的error页面,该组件显示一条简单的消息:
<template>
<div>
<h2>No param passed.</h2>
</div>
</template>
此外,请确保相应地注册路径:
{
path: '/error',
name: 'error',
component: () => import('../views/Error.vue'),
}
现在,在默认视图中,点击关于链接后,应用将渲染错误页面而不是关于页面,如下面的截图所示:

图 7.20 – 点击关于时未传递参数显示的错误页面
现在,让我们转到App.vue文件,并将to属性分配给路径"/about?user=Adam":
<RouterLink to="/about?user=Adam">About</RouterLink>
在About.vue文件中,我们使用以下模板代码:
<div class="about">
<h1>About {{ $route.query.user }}</h1>
</div>
让我们导航回我们应用中传递的user,输出将如下所示:

图 7.21 – 当查询参数中传递了用户时显示的关于页面
我们现在将探讨一些区分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.query?.user)) {
next({ name: 'error' })
}
else {
next();
}
})
输出结果将与图 7.20和图 7.21中的相同。
让我们现在详细看看afterEach钩子。
afterEach 钩子
afterEach()钩子是在导航确认后(这意味着在beforeResolve()之后)触发的最后一个全局导航守卫。与其他全局守卫不同,传递给afterEach()的hook函数不会接收一个next函数——因此,它不会影响导航。
此外,to和from参数是只读的Route对象。因此,afterEach的最佳用途是保存数据,例如为route目标保存最后访问的Route对象或页面视图跟踪。
例如,我们可以为user设置一个默认值,并在需要时分配和保存它:
let user = 'Adam';
router.beforeEach((to, from, next) => {
if (to.name === 'about' && (!to.query?.user)) {
next({ name: 'about', query: { user }})
}
else {
user = to.query.user;
next()
}
});
router.afterEach((to, from) => {
if (to.name === 'about' && to.query && to.query.user) {
user = to.query.user;
}
})
现在,在App.vue文件中,让我们将user的值改为Alex:
<RouterLink to="/about?user=Alex"> About </RouterLink>
点击关于链接后的输出现在如下所示:


然而,在导航到 "/about" 时,关于 页面现在渲染的是默认用户 – Adam,如下所示:


在本节中,我们探讨了 afterEach 钩子。我们使用 afterEach 钩子将数据传递到 Back 按钮。
按路由个性化钩子
而不是定义一个全局钩子,这可能会引起未知的错误并需要路由检查,我们可以在目标路由的配置对象中直接定义一个 beforeEnter 守卫 – 例如,我们的 about 路由:
beforeEnter: (to, from, next) => {
if (!to.query?.user) {
to.query = { user : 'Adam' }
}
next()
}
采用这种方法,无论是重新加载页面还是点击链接导航到 关于 页面,输出现在是一致的,但 URL 不显示默认参数(图 7**.24)

图 7.24 – 显示未更新 URL 的默认用户(Adam)的“关于”页面
注意
使用 beforeEnter(),to 是可写的,你将能够访问 this(它指向特定的路由 – About)。它只会在用户触发导航到 About 页面时被触发。
在本节中,我们探讨了 Vue 中可用的不同 Router 钩子,包括 beforeEach、beforeResolve 和 afterEach。我们看到了每个钩子在路由过程中的不同点被调用。作为一个实际例子,我们查看了一个路由,如果没有提供参数,则将用户重定向到 错误 页面。这些钩子非常有用,尤其是在设置认证路由时。
在下一节中,我们将探讨设置组件内钩子。
设置组件内钩子
最后,我们还可以在需要将钩子作用域限定在组件级别以更好地维护代码或增强工作流程时,将组件内钩子用作组件生命周期钩子。
我们现在可以为 about 组件定义 beforeRouteEnter() 钩子如下:
<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)可用。
或者,我们可以使用 Composition API 和从 'vue-router' 包导入的钩子 useRoute 重新编写上述代码,如下所示:
import { useRoute } from 'vue-router';
import { ref } from 'vue';
const user = ref('');
const route = useRoute();
if (!route.params || !route.params.user) {
user.value = 'Alex'
}
注意
对于beforeRouteUpdate和beforeRouteLeave,组件已经被创建——因此,这个实例是可用的,不需要为next()设置回调。实际上,回调函数仅在beforeRouteEnter()的使用中支持next()。
当相同的组件用于不同的路由时,会调用beforeRouteUpdate(或onBeforeRouteUpdate)。这适用于我们使用动态路由的情况,将在下一节中讨论。
当组件被停用或用户即将离开当前视图时,会触发beforeRouteLeave(或onBeforeRouteLeave)。这发生在新导航的beforeEach守卫之前,通常用于编辑组件以防止用户在不保存的情况下离开。
在这个守卫中,我们可以通过向next()函数传递false来取消新的导航。
例如,假设我们在AboutView.vue文件中的组件选项中添加以下钩子:
import { onBeforeRouteLeave } from 'vue-router';
onBeforeRouteLeave((to, from, next) => {
const ans = window.confirm(
'You are about to leave the About page. Are you sure?'
);
next(!!ans);
})
当我们从关于页面导航离开时,会出现一个弹出对话框请求确认,如下面的截图所示,然后继续相应地导航:

图 7.25 – 在离开关于页面前请求确认的对话框
在本节中,我们探讨了设置组件内钩子——即作用域限定在特定组件内的钩子。我们为about组件设置了一个组件内钩子,该钩子在用户离开页面前会要求用户进行确认。
我们现在将展示如何将传递的参数解耦到props中。
解耦参数与 Props
在index.js文件中,让我们调整about路由的配置,添加一个名为props的额外属性。
通过将此属性的值设置为接受一个route并返回一个包含基于route.query.user的user字段的对象的函数,路由器将自动理解并将任何route.query参数相应地映射到视图组件的props中:
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
props: route => ({ user: route.query.user || 'Adam' })
}
在AboutView.vue文件中,我们将定义一个名为user的 prop 类型,如下所示:
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
user: String
})
</script>
在<template>部分,我们将用user替换$route.query.user:
<template>
<div class="about">
<h1>About {{user}}</h1>
</div>
</template>
输出结果仍然相同,如下面的截图所示:

图 7.26 – 用户通过路由参数传递并映射到 props
此外,你还可以在route配置的props属性中定义要传递的静态数据。与Function值不同,现在props可以声明为一个包含所需数据的对象,如下面的示例所示:
{
//…
props: { age: 32 },
}
通过类似的步骤,我们将在AboutView.vue中将age声明为一个props组件,并将其作为文本打印到屏幕上:
<template>
<div class="about">
<h2>Age: {{age}}</h2>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
age: Number
})
</script>
现在当点击About页面时,页面将按照以下方式渲染:

图 7.27 – 在路由配置中预设了属性
练习 7.03:将所选消息的内容传递到新消息页面并打印出来
我们将从练习 7.02,将导航链接添加到 MessageFeed 路由继续,在那里我们定义了MessageFeed路由,其 URL 路径为messages。此视图将在视图组件选项的data属性中渲染预定义的消息列表。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Exercise7.03。
在这个练习中,我们将创建一个新的/message页面,用于渲染用户选择的消息内容。它应该是可重用的。
执行以下操作:
-
在
./src/views/文件夹中,我们创建一个新的单文件组件Message.vue。该组件接收一个类型为string的content属性,并在<p>标签下渲染它:<template><div><p>{{content}}</p></div></template><script setup>import { defineProps } from 'vue'const props = defineProps({content: {default: '',type: String}})</script> -
让我们在
./src/router/index.js中注册一个新的路由Message组件到现有的routes中。我们将新路由定义为message,路径为/message:export const routes = [//…,{path: '/message',name: 'message',component: () => import('../views/Message.vue'),}] -
由于路由已注册并准备好使用,我们需要修改
./src/views/MessageFeed.vue中的<template>部分,以确保每条消息现在都是可点击的,并且在点击时将用户重定向到新路由。
让我们用router-click替换<p>标签。因为我们已经将新路由命名为message,所以我们将每个RouterLink的to属性设置为绑定到/message。
<template>
<div>
<h2> Message Feed </h2>
<div v-for="(m, i) in messages" :key="i" >
<RouterLink :to="`/message`">
{{ m }}
</RouterLink>
</div>
</div>
</template>
-
在
template下,我们将添加一个包含我们messages的样本数据的<script setup>标签:<script setup>const messages = ['Hello, how are you?','The weather is nice','This is the message feed','And I am the fourth message']</script> -
在
message路由定义(router/index.js)中,我们添加props: route => ({ content: route.query.content })以将传递给路由的所有content查询映射到相关的属性。export const routes = [//…,{path: '/message',name: 'message',component: () => import('../views/Message.vue'),props: route => ({ content: route.query.content })}] -
当您打开
./messages页面时,所有消息现在都是可点击的,如下面的截图所示:

图 7.28 – 每条消息现在都是一个可导航的链接
- 现在当用户点击一条消息时,它将打开一个新页面。然而,页面内容将是空的,因为我们没有向
<RouteLink>组件传递任何内容参数,如下面的截图所示:

图 7.29 – 空消息页面
-
让我们回到
./src/views/MessageFeed.vue,并在消息的路由链接中添加?content=${m},其中m是messages列表中索引为i的message,在<template>部分:<div><h2> Message Feed </h2><div v-for="(m, i) in messages" :key="i" ><RouterLink :to="`/message?content=${m}`">{{ m }}</RouterLink></div></div></template> -
现在当您点击第一条消息
Hello, how are you?时,输出将是以下内容:

图 7.30 – 带有传递内容的消息页面
-
接下来,让我们从
./src/views/MessageFeed.vue中提取messages静态数据,并将其保存到./src/assets/messages.js中:const messages = ['Hello, how are you?','The weather is nice','This is the message feed','And I am the fourth message'];export default messages; -
在
./src/views/MessageFeed.vue中,我们将用具有messages数组类型的props替换本地数据属性,如下所示:<script setup>import { defineProps } from 'vue'const props = defineProps({messages: {default: [],type: Array}})</script> -
现在,我们需要在导航到
/messages路由时加载messages列表并分配给其props。我们将通过使用路由定义的props函数和beforeEnter()钩子来将数据规范化为相关的props以进行渲染。您可以通过修改src/router/index.js中定义的messageFeed路由来实现这一点,如下所示:{path: '/messages',name: 'messageFeed',component: () =>import('../views/MessageFeed.vue'),props: route => ({messages: route.query.messages?.length > 0? route.query.messages : []}),async beforeEnter(to, from, next) {next()}}, -
在
beforeEnter中,我们将使用import懒加载消息列表:const module = await import ('../assets/messages.js'); -
然后,我们可以按照以下方式检索所需的信息:
const messages = module.default;if (messages && messages.length > 0) {to.query.messages = messages;} -
src/router/index.js中路由的完整代码应该是以下内容:{path: '/messages',name: 'messageFeed',component: () =>import('../views/MessageFeed.vue'),props: route => ({messages: route.query.messages?.length > 0? route.query.messages : []}),async beforeEnter(to, from, next) {if (!to.query || !to.query.messages) {const module = await import('../assets/messages.js');const messages = module.default;if (messages && messages.length > 0) {to.query.messages = messages;}}next()}},
在查看网站时,我们应该看到类似于图 7.28的消息源。
到目前为止,我们已经学习和实践了如何使用不同的路由钩子配置路由、传递参数以及拦截应用中页面之间的导航。在下一节中,我们将探讨一个更高级的主题——动态路由。
动态路由
如果有大量遵循相同格式的数据,例如用户列表或消息列表,并且需要为每个数据创建一个页面,那么我们需要使用路由模式。使用路由模式,我们可以根据一些附加信息从相同的组件动态创建新路由。
例如,我们想要为每个用户渲染User视图组件,但具有不同的id值。Vue Router 为我们提供了使用冒号(:)表示的动态段来实现动态路由的能力。
与使用params不同,params在刷新页面或出现在 URL 中时不会持久化其值,我们直接在路径中定义所需的params,如下所示:
{
path: '/user/:id',
name: 'user',
component: () => import('../views/User.vue'),
props: true,
}
在前面的代码中,:id表示这里的params不是静态的。当路由与给定的模式匹配时,Vue Router 将渲染相应的组件,同时保持 URL 不变。:id的值将作为$route.params.id在该视图组件实例中暴露:
<template>
<div>
<h1>About a user: {{$route.params.id}}</h1>
</div>
</template>
当用户选择类似于/user/1或/user/2(./src/App.vue)的 URL 时,Vue 将自动使用我们的模板生成子页面。
导航路径将被映射到相同的路由模式和组件,但具有不同的信息,如下面的截图所示:

图 7.31 – 导航到 /user/2
当您点击用户 1时,您将看到以下内容:

图 7.32 – 导航到 /user/1
我们也可以使用props: true将id规范化为User组件的props,并在实例创建和渲染之前加载数据:
<script setup>
import users from '../assets/users.js';
import { ref } from 'vue'
const name = ref('');
const age = ref(0);
const props = defineProps(['id'])
name.value = users[props.id - 1].name;
age.value = users[props.id - 1].age;
</script>
现在,我们可以调整 <template> 来打印出用户的详细信息:
<template>
<div>
<h1>About a user: {{id}}</h1>
<h2>Name: {{name}}</h2>
<p>Age: {{age}}</p>
</div>
</template>
选择 /user/1 时的输出现在将如下所示:

图 7.33 – 使用更新后的 UI 导航到 /user/1
在本节中,我们通过设置一个从给定 URL 提取参数的路由来探讨了动态路由。这项技术允许你创建用户友好的 URL 并动态地将信息传递给路由。在下一节中,我们将探讨捕获错误路径。
捕获错误路径
除了 '/' 之外,我们还需要记住处理的其他重要路由包括错误路由,例如当 URL 路径不匹配任何已注册路径时的 404 Not found 等。
对于 404 Not found,我们可以使用正则表达式 /:pathMatch(.*)*,它代表 匹配所有其他 URL,来收集所有不匹配定义的路由的情况。路由器的配置应位于数组 routes 的末尾,以避免匹配错误的路径:
{
path: '/:pathMatch(.*)*',
name: '404',
component: () => import('../views/404.vue'),
}
当我们输入错误的 /users 路径时,输出将如下所示:

图 7.34 – 当找不到 /users 路径时重定向到 404
在本节中,我们探讨了如何使用正则表达式模式创建一个显示给任何导航到不存在路由的人的 404 页面。接下来,我们将实现一个消息路由,使用动态路由模式将相关数据传递给 URL 本身。
练习 7.04 – 使用动态路由模式为每个消息实现消息路由
本练习将使你熟悉在与其他导航 Hook 结合使用的情况下创建和维护动态路由。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Exercise7.04。
让我们回到 练习 7.03 的消息源,我们将重构 Message 路径以使用路由模式在用户选择时动态导航到特定的 message 路径,并执行以下步骤:
-
让我们打开
./src/router/index.js并将消息路由的路径配置更改为/message/:id,其中id将是给定message在消息列表中的索引:{path: '/message/:id',name: 'message',component: () => import('../views/Message.vue'),//…} -
现在,导航到
./src/views/MessageFeed.vue,并将每个消息的RouterLink的to属性更改为以下内容:<RouterLink :to="'/message/${i}'"> -
让我们回到
./src/router/index.js。在这里,将beforeEnter定义为一个异步 Hook 用于/message路径,并将消息内容懒加载到路由的query字段的content字段中:async beforeEnter(to, from, next) {if (to.params && to.params.id) {const id = to.params.id;const module = await import('../assets/messages.js');const messages = module.default;if (messages && messages.length > 0 && id <messages.length) {to.query.content = messages[id];}}next()}, -
然后,我们将 props 字段定义为返回一个对象的函数,该对象包含原始的 params.id 和 query.content,分别作为 id 和 content 字段。
props: route => ({id: route.params.id,content: route.query.content}),
完整的路由应该看起来像这样:
const routes = [
//…
{
path: '/message/:id',
name: 'message',
component: () => import('../views/Message.vue'),
props: route => ({
id: route.params.id,
content: route.query.content
}),
async beforeEnter(to, from, next) {
if (to.params && to.params.id) {
const id = to.params.id;
const module = await import ('../assets/messages.js');
const messages = module.default;
if (messages && messages.length > 0 && id <
messages.length) {
to.query.content = messages[id];
}
}
next()
},
}
]
-
使用以下命令运行应用程序:
yarn dev
当点击消息流中的第一条消息时,下一页将如下所示:

图 7.35 – 访问/message/0 路径时显示的页面
-
或者,你也可以设置 props: true,而不是在 beforeEnter 钩子中将内容映射到 query.content,你也可以直接将其映射到 route.params,如下所示:
{path: '/message/:id',name: 'message',component: () => import('../views/Message.vue'),props: true,async beforeEnter(to, from, next) {if (to.params && to.params.id) {const id = to.params.id;const module = await import('../assets/messages.js');const messages = module.default;if (messages && messages.length > 0 && id <messages.length) {to.params.content = messages[id];}}next()},}
输出应保持不变。
现在你已经学会了如何使用动态路由,你可以进一步使用更多层的路由模式进行实验,例如message/:id/author/:aid。对于这些场景,我们通常使用更好的方法——嵌套路由。
嵌套路由
许多应用程序由由多个多级嵌套组件组成的组件组成。例如,/user/settings/general表示一个通用视图嵌套在settings视图中,而这个settings视图又嵌套在user视图中。它代表用户设置页面的通用信息部分。
大多数时候,我们希望 URL 与以下截图所示的结构相对应:

图 7.36 – 具有两个嵌套视图(信息和额外信息)的用户
Vue Router 通过使用nested路由配置和RouterView组件使实现这种结构变得容易。
让我们回到之前例子中的User.vue视图(位于./src/views/),并在<template>部分添加一个嵌套的RouterView组件:
<div>
<h1>About a user: {{$route.params.id}}</h1>
<RouterLink :to="`/user/${$route.params.id}/info`">
Info
</RouterLink> |
<RouterLink :to="`/user/${$route.params.id}/extra`">
Extra
</RouterLink>
<RouterView />
</div>
要开始将组件渲染到这个RouterView,我们将配置user路由以具有children选项,该选项接受一个子路由配置数组的路由配置。对于我们的例子,我们将为每个用户添加一个info和extra页面。
这些子路由将作为/user/:id/info和/user/:id/extra访问,为每个用户提供唯一的info和extra页面:
{
path: '/user/:id',
name: 'user',
component: () => import('../views/User.vue'),
props: true,
children: [{
path: 'info',
name: 'userinfo',
component: () => import('../views/UserInfo.vue'),
props: true,
}, {
path: 'extra',
component: () => import('../views/UserExtra.vue')
}]
}
当然,我们必须创建两个新的视图。第一个是UserInfo,它将根据接收到的id值渲染有关用户的所有信息:
<template>
<div>
<h2>Name: {{name}}</h2>
<p>Age: {{age}}</p>
</div>
</template>
<script setup>
import users from '../assets/users.js';
import { ref } from 'vue';
import { onBeforeRouteUpdate } from 'vue-router';
const props = defineProps(['id'])
const name = ref('')
const age = ref(0)
const user = users[props.id - 1];
name.value = user.name;
age.value = user.age;
onBeforeRouteUpdate((to, from, next) => {
const user = users[props.id - 1];
name.value = user.name;
age.value = user.age;
next();
})
</script>
我们还创建了UserExtra.vue,它将渲染任何额外信息(如果有)。在这个例子中,它将只渲染简单的文本:
<template>
<div>
<h2>I'm an extra section</h2>
</div>
</template>
嵌套视图已准备好!每当用户点击UserInfo视图并更新 URL 如下所示:

图 7.37 – 包含嵌套 UserInfo 视图的用户页面
当用户点击额外信息时,他们将看到以下截图所示的内容:

图 7.38 – 包含嵌套 UserExtra 视图的用户页面
在本节中,我们探讨了嵌套路由——具有多个子路由的路由。在我们的示例中,子路由是 /user/:id/info 和 /user/:id/extra。这种模式允许我们创建扩展其父页面的页面。
在前面的示例中,我们现在可以编辑 关于用户 的标题,并将其应用到所有子路由上。随着项目的增长,利用这种模式将有助于你避免在多个视图中重复代码。
在下一节中,我们将利用到目前为止所学的内容来创建消息视图组件的导航标签。
练习 7.05 – 在消息视图中构建导航标签
我们将把在 嵌套路由 部分学到的知识应用到从 练习 7.04 构建的 Message 视图中。
要访问此练习的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Exercise7.05。
执行以下步骤:
-
首先,让我们通过添加以下
author和sent字段来修改我们的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'},]; -
在
MessageFeed.vue中,我们将字段更新为渲染为message.content,而不是message,因为message已不再是字符串:<RouterLink :to="`/message/${i}`">{{ m.content }}</RouterLink> -
接下来,我们将创建一个仅渲染消息创建者名称的
MessageAuthor.vue视图:<template><h3>Author:</h3><p>{{message.author}}</p></template><script setup>import { defineProps } from 'vue'const { message } = defineProps({id: {default: '',type: String},message: {default: () => ({ author: '' }),type: Object}})</script> -
然后,我们将创建一个渲染
message.sent值的MessageInfo.vue视图:<template><div><h3>Message info: </h3><p>{{message.sent}}</p></div></template><script setup>import { defineProps } from 'vue'const { message } = defineProps({id: {default: '',type: String},message: {default: () => ({ sent: '' }),type: Object}})</script> -
一旦我们完成了组件,我们需要在我们的路由器
src/router/index.js中message路由的子路由下注册新的嵌套路由:{path: '/message/:id',name: 'message',component: () => import('../views/Message.vue'),async beforeEnter(to, from, next) { ... },props: true,children: [{path: 'author',name: 'messageAuthor',props: true,component: () =>import('../views/MessageAuthor.vue'),}, {path: 'info',props: true,name: 'messageInfo',component: () =>import('../views/MessageInfo.vue'),}]} -
我们需要将
message路由的beforeEnter逻辑移动到单独的函数beforeEnterMessage:async function beforeEnterMessage(to, from, next) {const id = to.params.id;const module = await import('../assets/messages.js');const messages = module.default;if (messages && messages.length > 0 && id <messages.length) {to.params.message = messages[id];}next()} -
然后,将其绑定到
message路由的beforeEnter,以及每个子路由的beforeEnter钩子,如下面的代码块所示:{path: '/message/:id',name: 'message',component: () => import('../views/Message.vue'),beforeEnter: beforeEnterMessage,props: true,children: [{path: 'author',name: 'messageAuthor',props: true,component: () =>import('../views/MessageAuthor.vue'),beforeEnter: beforeEnterMessage,}, {path: 'info',props: true,name: 'messageInfo',component: () =>import('../views/MessageInfo.vue'),beforeEnter: beforeEnterMessage,}]} -
最后,在
Message.vue中,我们将对代码进行重构,如下所示:<template><div><p>Message content: {{message.content}}</p><RouterLink :to="{ name: 'messageAuthor'}">Author</RouterLink> |<RouterLink :to="{ name: 'messageInfo'}">Info</RouterLink><RouterView/></div></template><script setup>import { defineProps } from 'vue'const { message } = defineProps({message: {default: () => ({ content: '' }),type: Object},id: {default: '',type: String}})</script> -
使用以下命令运行应用程序:
yarn dev
当你选择 作者 选项时,你会看到以下内容:

图 7.39 – 已选择“作者”的消息页面
当我们导航到 信息 标签时,输出变为:

图 7.40 – 已选择“信息”的消息页面
通过这个练习,我们几乎涵盖了 Vue Router 的所有基本功能,特别是在处理动态和嵌套路由方面。在最后一节中,我们将介绍如何通过模板化我们的应用程序来创建视图的可重用布局。
使用布局
在 Vue 应用程序中实现布局有许多方法。其中之一是使用槽和创建一个静态包装器layout组件在RouterView之上。尽管这种方法具有灵活性,但它会导致高昂的性能成本,包括不必要的组件重建以及每次路由变化所需的额外数据获取。
在本节中,我们将讨论一种更好的方法,即利用动态组件的强大功能。组件如下:
<component :is="layout"/>
让我们创建一个src/layouts文件夹,并创建一个default布局组件。此组件具有简单的页眉导航,一个main槽来渲染实际内容(这是<RouterView>渲染的任何内容),以及页脚:
<template>
<div class="default">
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink to="/about">About</RouterLink>
</nav>
<main class="main">
<slot/>
</main>
<footer>
<div>Vue Workshop Chapter 07</div>
</footer>
</div>
</template>
在App.vue文件中,我们将更改 Vite 生成的默认视图,使其仅包含<RouterView>及其周围的包装器。这个包装器是一个动态组件,它将渲染layout变量中的任何内容:
<template>
<component :is="layout">
<RouterView/>
</component>
</template>
我们还将初始化layout为default.vue组件:
<script setup>
import Default from './layouts/default.vue'
const layout = Default
</script>
现在,为了响应相应的路由变化来渲染layout组件,RouterView应该控制要渲染哪个布局。换句话说,layout应该是可更新的,并由RouterView内部渲染的视图组件决定。
为了实现这一点,我们将layout传递给currentLayout属性,并在<RouterView>中使用@update事件更新layout:
<component :is="layout">
<RouterView
:currentLayout="layout"
@update:currentLayout="newLayout => layout = newLayout"
/>
</component>
在<script setup>部分,我们使用shallowRef将layout更改为一个响应式变量,如下所示:
import Default from './layouts/default.vue'
import { shallowRef } from 'vue'
const layout = shallowRef(Default)
在创建HomeView.vue组件的实例时,我们将发出一个update:currentLayout事件来更新并相应地渲染所需的布局:
<script setup>
import TheWelcome from '@/components/TheWelcome.vue'
import DefaultLayout from '../layouts/default.vue';
const props = defineProps(['currentLayout']);
const emits = defineEmits(["update:currentLayout"]);
emits('update:currentLayout', DefaultLayout);
</script>
输出将如下所示:

图 7.41 - 使用布局渲染的首页
由于layout组件不是RouterView组件的一部分,它只会在视图内部布局发生变化时重新渲染。这将保持应用程序在用户导航时的性能。
在本节中,我们探讨了如何使用动态组件为不同的路由提供不同的布局。这使我们能够拥有不同的通用布局 - 例如,一个用于面向用户的页面的全局菜单和另一个用于管理页面的菜单,这些菜单基于使用的路由进行渲染。
在下一节中,我们将通过创建一个具有动态嵌套路由和布局的消息应用程序来构建我们在这里学到的内容。
活动七点零一 - 创建一个具有动态、嵌套路由和布局的消息 SPA
要访问此活动的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter07/Activity7.01
该活动旨在利用您关于 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的消息内容,以及一个back按钮返回到上一个视图。默认情况下,它应跳转到messages。 -
在
src/router/index.js中将Message视图与message/:id动态路由注册。 -
通过创建两个不同的简单布局来改进 UI,一个用于
messages(仅包含标题)和一个用于message(包含标题和back按钮)。
预期输出如下:
- 通过编辑器路由添加消息后,显示消息源流的
/list视图应如下所示:

图 7.42 – 消息应用中的/list 视图
- 允许用户编写并发送新消息的
/editor视图如下:

图 7.43 – 消息应用中的/editor 视图
- 消息应用中的
/message/:id动态路由(此处为/message/0,表示id值为0的消息)如下所示:

图 7.44 – 消息应用中的/message/0 视图
当用户尝试在不保存消息的情况下导航离开时,将显示一个警告,如下截图所示:

图 7.45 – 用户尝试在不保存消息的情况下离开页面时的/编辑器视图
摘要
在本章中,我们学习了 Vue Router 提供的最基本和有用的功能,这些功能可以有效地以有组织的方式为任何 Vue.js 应用程序构建路由。
RouterView和RouterLink允许应用开发者轻松设置导航路径到相关视图,并保持 SPA 概念。它们本身就是 Vue 组件的事实为我们开发者提供了 Vue 架构的好处,使我们能够在实现嵌套视图或布局时具有灵活性。
将路由定义为具有不同属性的对象简化了架构过程,包括重构现有路径和向系统中添加新路由。使用路由参数和模式提供了动态路由,具有可重用视图,并允许页面之间的通信和数据保留。
最后,通过 Hooks,我们看到了如何拦截导航流程,在需要的地方设置身份验证,重定向到期望的路径,甚至在用户到达目标页面之前加载并保留某些重要数据。这些 Hooks 在无数的使用场景中都可以非常有用,例如在实现返回按钮时。有了 Vue Router,我们现在能够为用户提供一个合适的导航系统,从而构建 Vue.js 应用程序。
在下一章中,我们将探讨如何通过向我们的应用程序添加过渡和动画来增强用户体验。
第八章:动画和过渡
在上一章中,你学习了路由以及如何使用 Vue Router 设置一个基本的路由导航系统。实现不同路由之间的平滑过渡或为用户提供与应用程序交互时的适当动画效果是达到更高水平的下一步。
在本章中,你将探索 Vue 过渡的要点——如何创建你的过渡,包括单元素动画和使用一组元素的动画,以及如何将它们与外部库结合以实现更复杂的自定义动画。你还将学习如何使用过渡路由创建全页动画。
到本章结束时,你将准备好实现和处理任何 Vue 应用程序的基本过渡和动画效果。
本章涵盖了以下主题:
-
理解 Vue 过渡
-
探索过渡的 JavaScript 钩子
-
过渡元素组
-
检查过渡路由
-
使用 GSAP 库进行动画
技术要求
在本章中,你需要根据第一章中“开始你的第一个 Vue 项目”的说明设置一个基本的 Vue 项目。你还需要添加 Vue Router,正如在第七章中“路由”部分所了解的,在一些示例和练习中使用它。建议创建一个单独的 Vue 组件来方便地练习提到的示例和概念。
你可以在这里找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08。
理解 Vue 过渡
与其他框架不同,Vue.js 为开发者提供了内置的动画支持,包括过渡和动画。过渡的实现方式非常简单,开发者可以轻松地配置并将其添加到他们的应用程序中。Vue.js 的过渡机制支持 CSS 过渡、使用 JavaScript 进行程序性操作,甚至可以与第三方动画库如Animate.css集成。
在深入这个主题之前,让我们讨论一下过渡和动画之间的区别。过渡发生在组件(或元素)从一个状态移动到另一个状态时,例如在按钮上悬停、从一个页面导航到另一个页面、显示弹出模态等。与此同时,动画类似于过渡,但并不局限于仅两个状态。
理解过渡的基础知识将使你能够开始学习动画。
使用过渡元素
要访问本例的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Example8.01
在本例中,为了使单个组件或元素启用过渡效果,Vue.js 提供了内置的 transition 组件,该组件将围绕目标元素包裹,如 ./Example8.01/src/components/Example8-01.vue 中所示:
<transition name="fade-in">
<h1 v-show="show">{{ msg }}</h1>
</transition>
transition 组件为任何目标元素或组件添加两个过渡状态——enter 和 leave——包括具有条件渲染 (v-if) 和条件显示 (v-show) 的组件:
-
enter:当组件 进入 DOM 时发生此过渡状态 -
leave:当组件 离开 DOM 时发生此过渡状态
transition 组件接收一个名为 name 的属性,表示过渡的名称——在本例中是 fade-in——同时也是过渡类名的前缀,接下来将讨论这一点。
探索过渡类
Vue.js 实现了基于 CSS 和基于类的 leave 和 enter 过渡效果——因此,过渡将应用于目标组件,使用一组类选择器。
这些类选择器都有 v- 前缀,以防 transition 组件未提供 name 属性。标准类分为两组。
第一组过渡类是组件首次显示时的 enter 过渡。以下是 enter 过渡类的列表:
-
v-enter-from(或<name>-enter-from):这是起始状态,在组件添加或更新之前添加到组件中。此类将在组件插入 DOM 并完成过渡后移除。在./Example8.01/src/components/Example8-01.vue的<style>部分中,我们将设置.fade-in-enter-from起始状态为完全隐藏,使用opacity: 0:<style>.fade-in-enter-from {opacity: 0;}</style> -
v-enter-active(或<name>-enter-active):此类定义了组件在过渡过程中活跃时的延迟、持续时间和缓动曲线。它将在组件插入之前添加到组件上,在整个进入阶段应用于组件,并在效果完成后移除。使用上一节中的Example 8-01,让我们添加.fade-in-enter-active,它将在3秒内过渡到调整后的opacity状态:.fade-in-enter-active {transition: opacity 3s ease-in;} -
v-enter-to(或<name>-enter-to):这是进入的最后子状态,其中在组件插入后添加效果帧,并在效果完成后移除。在我们的例子中,我们不需要定义任何内容,因为此状态应具有opacity值为1。
第二组类包括 leave 过渡,当组件被禁用或从视图中移除时触发:
-
v-leave-from(或<name>-leave-from):这是离开过渡的起始状态。与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类似。由于组件将从视图中消失,我们将重用为fade-in-enter-from的起始阶段定义的样式来为fade-in-leave-to:.fade-in-enter-from, .fade-in-leave-to {opacity: 0;}
以下截图是到目前为止描述的所有 transition 状态的总结:

图 8.1 – 过渡阶段图
在本节中,我们探讨了三种不同的 enter 过渡状态和三种 leave 过渡状态。我们还介绍了使用过渡状态在文本组件进入用户视图时缓慢淡入一些文本,当文本从用户视图中消失时也适用。
在下一节中,我们将探讨如何使用我们学到的这些过渡状态为组件添加动画效果。
组件的动画
要访问此示例的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Example8.02
由于动画基本上是过渡(具有两个以上状态)的扩展形式,因此它以与过渡相同的方式应用,唯一的区别是 v-enter 只会在 Vue.js 触发的 animationend 事件中移除。
注意
animationend 是一个 DOM 事件,当 CSS 动画执行完成后会触发,条件是目标元素仍然存在于 DOM 中,并且动画仍然附加到该元素上。
例如,在 练习 8.02 的 <template> 部分中,我们可以定义一个新的过渡 slid,使用动画 CSS 效果作为显示 msg 的 h1 元素的包装器。这个过渡提供了从屏幕左侧滑入中间并在离开时相反的动画效果。
要开始,使用 CLI 生成一个 vue 起始项目,命令如下:
npm init vue@3
接下来,打开项目,进入 Example8.02/src/components/Example8-02.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-from, .slide-leave-to {
transform: translateX(-100px);
}
.slide-enter-active {
animation: slide 5s;
}
.slide-leave-active {
animation: slide 5s reverse;
}
这意味着在enter的起始阶段和leave的结束阶段,文本位置将位于页面指定位置-100px处。浏览器将使用slide关键帧在 5 秒内动画化元素,并且在离开的active状态下,动画将与进入的active状态中的动画正好相反。
你还想要添加show作为本地数据变量。你可以通过修改<script setup>部分来实现,如下面的代码块所示:
import { ref } from 'vue'
const show = ref(true);
const msg = "Welcome to your Vue.js App";
const toggle = () => show.value = !show.value;
这样,我们就实现了我们的动画。接下来,下一个挑战是:如果我们想将不同的动画或过渡效果与leave和enter状态结合使用,或者为这些状态使用外部 CSS 库,该怎么办?让我们看看自定义过渡类。
探索自定义过渡类
在本节中,我们再次从使用npm init vue@3创建的默认起始项目开始。我们不是设置过渡名称,让 Vue.js 机制填充所需的类名,而是可以通过以下属性提供自定义类,并替换传统默认值。
对于进入状态,使用以下方法:
-
enter-from-class -
enter-active-class -
enter-to-leave
对于离开状态,使用以下方法:
-
leave-from-class -
leave-active-class -
leave-to-class
我们将从一个基于之前示例(练习 8.02)的文件开始创建,但现在我们将为进入状态使用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;
}
上述代码演示了以下动画类:
-
tada,在执行前后都应用于其目标元素的由tada定义的 CSS 动画样式,持续时间为 2 秒 -
swing,在执行前后都应用于其目标元素的由swing定义的 CSS 动画样式,变换的起点设置为顶部中心边缘(transform-origin),持续时间为 2 秒
为了设置动画 CSS 样式,我们添加了专用的关键帧:
@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); }
}
对于tada动画,我们为每个目标关键帧(动画序列的时间百分比)设置了不同的 CSS 样式,例如在 3D 空间中调整元素的大小(scale3d())和旋转(rotate3d())。对于swing动画,我们为20%、40%、60%、80%和100%的关键帧设置了不同的旋转效果。
你还想要添加一个show data变量。你可以通过修改现有的export来实现,如下面的代码块所示:
export default {
data() {
return {
msg: "Welcome to your Vue.js App",
show: true,
};
},
methods: {
toggle() {
this.show = !this.show;
},
},
}
当我们使用yarn dev命令运行应用程序时,我们将分别设置进入和离开的动画。以下截图显示了屏幕现在的样子:

图 8.2 – 动画效果的实际应用
您应该看到欢迎文本在旋转的同时缩小,从 图 8**.2 中显示的内容过渡到 图 8**.3 中显示的内容:

图 8.3 – tada 动画效果的实际应用
在本节中,我们探讨了创建自定义过渡效果。作为一个例子,我们创建了 swing 和 tada。我们通过在样式表中定义过渡类,然后为每个效果添加关键帧来实现这一点。这种技术可以用来创建各种自定义过渡效果。在下一节中,我们将探讨 JavaScript 钩子及其如何用于更复杂的动画。
探索用于过渡的 JavaScript 钩子
要访问此示例的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Example8.03
如我们在上一节所学,我们可以使用自定义过渡类来集成外部第三方 CSS 动画库以实现样式效果。然而,也有一些基于 JavaScript 而不是 CSS 的外部库,如 Velocity.js 或 GSAP,它们需要通过 JavaScript 事件和外部动画处理程序设置钩子。
要在 Vue 应用中使用 Velocity.js 或 GSAP 库,您需要分别使用 npm install 或 yarn add 命令进行安装,如下所示:
-
要安装 Velocity.js,请使用以下命令:
npm install velocity-animate#Oryarn add velocity-animate -
要安装 GSAP,请使用以下命令:
npm install gsap#oryarn add gsap
作为 Vue.js 组件,transition 组件支持将自定义处理程序绑定到事件列表的 props。让我们看看以下示例:
<transition
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<h1 v-if="show">{{ msg }}</h1>
</transition>
我们通过编程方式将动画方法绑定到 transition 元素上的相应事件:
-
beforeEnter是组件插入之前的动画状态——类似于v-enter-from阶段。 -
enter是整个进入阶段的动画状态——类似于v-enter-active阶段。 -
leave在整个离开阶段应用于动画。这与v-leave-active阶段类似。
我们需要在 Example8.03/src/components/Example8-03.vue 组件配置的 methods 部分定义这些事件处理程序:
<script setup>
export default {
name: 'HelloWorld',
props: {
msg: String
},
data() {
return {
msg: '
show: false
}
},
methods: {
beforeEnter() {
//...
},
enter() {
//...
},
leave() {
//...
}
}
}
</script>
在此示例中,我们将使用 GSAP 库提供的 gsap.to() 和 gsap.timeline() 功能来创建我们的动画事件,如下所示:
beforeEnter(el) {
el.style.opacity = 0;
},
enter(el, done) {
gsap.to(el, {
duration: 2,
opacity: 1,
fontSize: "20px",
onComplete: done,
});
},
leave(el, done) {
const tl = gsap.timeline({
onComplete: done,
});
tl.to(el, { rotation: -270, duration: 1,
ease: "elastic" })
.to(el, { rotation: -360 })
.to(el, {
rotation: -180,
opacity: 0,
});
}
对于 gsap.to 和 gsap.timeline().to 的 return 变量,语法相当简单:
gsap.to(<element>, { <effect properties>, <time position> })
gsap.timeline().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>
</transition>
在本节中,我们探讨了如何使用外部 JavaScript 库进行动画。我们还使用 GSAP 库实现了一个简单的缓动动画,利用了动画和时间轴动画功能。
现在我们来学习如何使用动画效果添加新消息。
练习 8.01 – 添加带有动画效果的新消息
在这个练习中,你将使用 transition 组件及其 CSS 转换类来为组件添加动画效果。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Exercise8.01。
我们将创建一个消息编辑器,用户可以在其中编写并提交新消息。新消息将立即通过从右滑动的动画效果显示。为此,请参阅以下内容:
-
以使用
npm init vue@3生成的应用程序作为起点,或者在每个代码仓库的根目录中,使用以下命令按顺序导航到Chapter08/Exercise8.01文件夹:> cd Chapter08/Exercise8.01/> yarn -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
在
/src/components/文件夹中创建一个名为Exercise8-01.vue的新组件。在这个组件中,<template>将包含两个元素部分:-
textarea,用于编写新消息并带有提交按钮。 -
section,其中将显示新编写的消息:<template><div><div class="editor--wrapper"><textarea ref="textArea" class="editor" /><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>
-
-
接下来,将整个
message部分包裹在transition元素中,为我们的动画做准备:<transition name="slide-right"><section v-if="message" class="message--display"><h4>Your saved message: </h4><span>{{message}}</span></section></transition> -
让我们定义一个名为
onSendClick的方法来更新消息文本,通过添加以下script代码:<script>export default {data() {return {message: ''}},methods: {onSendClick() {const message = this.$refs.textArea.value;this.message = message;this.$refs.textArea.value = '';}}}</script> -
接下来,我们将使用
@keyframes在我们的style部分中定义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-from {border-top: 0;} -
接下来,使用我们关于自定义过渡类的知识,让我们将
enter-active绑定到slide-right类,并将leave-active绑定到slide-left。然后,我们将这三个属性添加到在步骤 4中添加的transition元素中:<transitionname="slide-right"enter-active-class="slide-right"leave-active-class="slide-left"> -
使用 CSS Flexbox 添加 CSS 样式,使编辑器看起来更美观:
.editor--wrapper {display: flex;flex-direction: column;}.editor {align-self: center;width: 200px;}.editor--submit {margin: 0.5rem auto;align-self: center;}.message--display {margin-top: 1rem;border-top: 1px solid lightgray;} -
最后,使用以下命令运行应用程序:
yarn dev
在浏览器中访问http://localhost:3000,前面的代码将生成一个组件,该组件将以滑动动画效果显示输入的消息,如图图 8**.4所示:

图 8.4 – 消息编辑器文本区域
以下截图显示了消息组件在滑动从左到右动画效果下的外观:

图 8.5 – 用于显示的消息过渡
在从左侧动画进入后,组件应停在居中位置,如图图 8**.6所示:

图 8.6 – 动画后的消息
这个练习帮助你熟悉了 CSS 中的一些transform效果,例如translateX和transition。它还展示了在 Vue 应用中添加动画是多么简单。
但如果同一组中有多个元素需要过渡,比如一个列表,怎么办呢?我们将在下一个主题中找到答案。
元素组的过渡
要访问此示例的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Example8.04
到目前为止,我们已经了解了 Vue 过渡元素的基本知识,用于简单组件和元素,同时支持仅 CSS 和仅 JavaScript 的动画。接下来,我们将探讨如何将过渡效果应用到一组组件上——例如,使用v-for同时渲染的项目列表。
Vue.js 为这个特定目的提供了一个组件,即transition-group组件。
我们现在假设我们有一个显示在信息流上的消息列表,并且我们希望为这个列表添加一个过渡效果,以便在屏幕上每个项目出现时提供某种效果。以下是一个组件代码示例(./Example8.04/src/components/Example8-04.vue):
<template>
<div>
<button @click="showList">Show list</button>
<p v-for="message in messages" :key="message"
v-show="show">
{{ message }}
</p>
</div>
</template>
<script>
export default {
data() {
return {
messages: [
"Hello, how are you?",
"The weather is nice",
"This is the message feed",
"And I am the fourth message",
"Chapter 8 is fun",
"Animation is super awesome",
"Sorry, I didn't know you called",
"Be patient, animation comes right up",
],
show: false,
};
},
methods: {
showList () {
this.show = true;
},
},
};
</script>
组件包含一个绑定到showList方法的button元素,以及一个基于show变量的条件列表元素。每当点击显示列表按钮时,此列表元素会渲染messages列表。
让我们将列表元素包裹在transition-group组件中,并传递我们之前用于transition组件的相同属性——name="fade"。transition-group和transition都接收相同的属性:
<transition-group name="fade" >
<p v-for="message in messages":key="message"
v-show="show">
{{message}}
</p>
</transition-group>
我们需要为传递给fade的过渡效果设置 CSS 样式效果,遵循与transition类相同的语法规则:
.fade-enter-active, .fade-leave-active {
transition: all 2s;
}
.fade-enter-from, .fade-leave-active {
opacity: 0;
transform: translateX(30px);
}
使用yarn dev命令运行应用程序后,列表项在出现时将具有淡入效果。以下截图显示了您的屏幕应该如何显示:

图 8.7 – 列表项的淡入
注意,与完全不渲染任何包装容器元素的transition组件不同,如果使用tag属性定义元素的标签名(例如,以下代码中的div包装元素),transition-group将渲染一个实际元素:
<transition-group
name="fade"
tag="div"
>
<p v-for="message in messages" :key="message"
v-show="show">
{{message}}
</p>
</Transition-group>
在浏览器中,实际的 HTML 输出将如下所示:

图 8.8 – 根据标签属性渲染的过渡容器元素
此外,所有transition类仅应用于具有v-for属性的列表项元素,而不是包装器。
最后,为了使 Vue.js 能够索引并知道要将过渡应用于哪个项目,每个列表项都必须有:key属性。
我们现在将在列表上创建一个移动效果。
在转换列表时创建移动效果
在许多情况下,我们希望在列表项的位置转换过程中添加额外的动画,而不仅仅是其可见性转换过程中。位置转换发生在对给定列表进行洗牌、排序、过滤等操作时。列表项已经可见,只是位置发生变化——因此,使用enter和leave是不起作用的。
为了实现这个动画目标,transition-group提供了另一个属性,v-move;这个属性允许我们在目标元素更改其位置时,为列表元素添加额外的动画效果:
<transition-group name= "fade"
v-move="moveItemEffect">
<!—list element --!>
</transition-group>
也可以使用move-class属性手动分配:
<transition-group name= "fade"
move-class="fade-move-in">
<!—list element --!>
</transition-group>
<style>
.fade-move-in {
transition: transform 2s ease-in;
}
</style>
或者,我们可以简单地定义一个与name属性匹配的前缀的 CSS 类,如下所示:
<transition-group name= "fade">
<!—list element --!>
</transition-group>
<style>
.fade-move {
transition: transform 2s ease-in;
}
</style>
当适用时,Vue 引擎将自动检测并附加相关的transition效果类。
接下来,我们将探讨在页面或组件的初始渲染上制作动画。
配置初始渲染时的动画
通常,项目列表将在第一次页面加载时显示,并且我们的动画将不起作用,因为元素已经处于视图状态。要触发动画,我们需要使用不同的过渡属性 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>
注意,如果您使用与 appear 相关的 JavaScript 钩子,您需要将 appear 属性设置为 true 并绑定钩子。否则,它将不起作用。
在渲染时进行动画是常用功能,适用于许多情况,例如淡入组件,就像我们在这里做的那样。在下一节中,我们将探讨使用动画对消息列表进行排序。
练习 8.02 – 使用动画对消息列表进行排序
在这个简短的练习中,我们将使用 transition-group 组件在元素列表上实现动画效果。
要访问此练习的代码文件,请参阅 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Exercise8.02。
基于 练习 8.01 的代码,在 Exercise8-01.vue 组件中,我们将向消息列表添加额外的功能:排序。在排序(A-Z 或 Z-A)时,将对列表应用翻转动画效果。要实现这一点,请参阅以下内容:
-
使用
npm init vue@3生成的应用程序作为起点,或在代码仓库的根目录下,使用以下命令按顺序导航到Chapter08/Exercise8.02文件夹:> cd Chapter08/Exercise8.02/> yarn -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您偏好的 IDE。 -
让我们用
transition-group组件包裹消息列表。别忘了将tag名称设置为div,并使用name属性添加flip动画:<transition-groupname="flip"tag="div"><pv-for="message in messages":key="message"class="message--item">{{message}}</p></transition-group> -
添加
appear="true",或简写为appear,以在页面加载完成后仅对元素进行动画处理:<transition-groupappearname="flip"tag="div">//…</transition-group> -
使用以下命令运行应用程序:
yarn dev -
在浏览器中访问
http://localhost:3000,输出将如下所示:

图 8.9 – 动画前的消息列表
-
到目前为止,没有动画,因为我们还没有为
flip定义 CSS 动画样式。在src/components/Exercise8-02.vue的<style>部分中,我们将添加opacity: 0并将列表中的每个元素垂直(在 y 轴上)从其原始位置移动20px。这应该是元素进入flip-enter-from或即将离开过渡的flip-leave-to时的初始状态:<style scoped>.flip-enter-from, .flip-leave-to {opacity: 0;transform: translateY(20px);}</style> -
在相同的
<style>部分中,为每个message元素(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代码,以及我们的消息源数据。您可以使用任何您喜欢的messages内容:export default {data() {return {messages: ["Hello, how are you?","The weather is nice","This is the message feed","And I am the fourth message","Chapter 8 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);}}
点击其中一个按钮后的输出将如下所示:

图 8.10 – 排序时的消息列表
在这个练习中,我们学习了如何根据元素顺序的变化,使用transition-group动态地为组件列表添加flip动画效果。请注意,用于洗牌消息列表的算法是简单的,由于其实际应用中的性能复杂性,您不应在实际场景中使用它。
接下来,让我们探索如何在页面间导航时应用过渡效果。
检查过渡路由
通过结合 Vue Router 的router-element组件和transition组件,我们可以轻松地设置用户从一个 URL(路由)导航到另一个 URL 时的过渡效果。
为了让您有一个更基本的理解,我们在下面的部分中演示了一个用户从网站的home页面重定向到about页面的底层案例。
要启用跨路由的过渡,在 Vue Router 4.x 及以上版本中,我们需要将v-slot API 与动态的component元素结合使用。我们使用v-slot属性将当前路由的视图Component绑定到嵌套在transition元素下的component元素的is属性,如下所示:
<router-view v-slot="{ Component }">
<transition :name="zoom">
<component :is="Component" />
</transition>
</router-view>
在这里,我们在从一个页面导航到另一个页面时添加了一个zoom过渡效果。我们还可以使用mode属性来指示过渡模式。目前有两种模式可供设置:
-
in-out:新元素首先进入,然后当前元素才会从视图中消失。 -
out-in:当前元素首先消失,然后新元素才会进入。我们将使用这个例子,它比之前的例子更常见。
然后,我们只需像往常一样设置过渡 CSS 效果,使用transition类,就完成了。就这么简单:
/**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;
}
}
在本节中,我们探讨了过渡路由。过渡效果是在渲染路由之间发生的动画,例如从一个页面导航到另一个页面。在下一个练习中,我们将探讨为应用中导航的每个路由创建过渡效果。
练习 8.03 – 为每个导航的路由创建过渡效果
在这个练习中,我们将根据检查过渡路由部分中关于router元素学到的知识,为不同的路由创建不同的过渡效果。
要访问这个练习的代码文件,请参考github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Exercise8.03。
我们将使用练习 8.02中的代码创建一个新的路由视图来显示消息,并在导航到这个视图时添加过渡效果。默认效果将是fade:
-
使用
npm init vue@3生成的应用作为起点,并添加 Vue Router。或者,在代码仓库的根目录下,使用以下命令顺序导航到Chapter08/Exercise8.03文件夹:> cd Chapter08/Exercise8.03/> yarn -
在你的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或你偏好的 IDE。 -
在
src/views/文件夹中为Messages.vue视图创建一个新的路由视图。为此视图组件渲染/messages页面路由,重用练习 8.02中的Exercise8-02.vue代码。 -
通过向
routes中添加一个新的route对象来注册这个/messages路由,如下所示:const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: "/",name: "home",component: HomeView,},{path: "/messages",name: "messages",meta: {transition: "zoom",},component: () =>import("../views/Messages.vue"),},],}); -
在
App.vue中添加指向这个新创建的路由的链接:<nav><RouterLink to="/">Home</RouterLink><RouterLink to="/messages">Messages</RouterLink></nav> -
接下来,在
App.vue中,我们将全局route实例(见第七章,路由)绑定到slot,并使用meta属性(或使用本地transition数据)动态分配为特定路由定义的transition。我们还绑定过渡模式到本地mode数据:<router-view v-slot="{ Component, route }"><transition :name="route.meta.transition ||transition" :mode="mode"><component :is="Component" /></transition></router-view> -
在
App.vue的script部分,确保我们定义了transition和mode的默认值:<script setup>import { RouterLink, RouterView } from "vue-router";let transition = "fade";const mode = "out-in";</script> -
在
App.vue中使用以下 CSS 添加淡入淡出样式:<style>.fade-enter-from, .fade-leave-to {opacity: 0;}.fade-enter-active, .fade-leave-active {transition: opacity 1s ease-in;}</style> -
到目前为止,所有页面都加载了
fade效果,包括/messages。但我们要让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中路由配置的meta属性中添加一个名为transition的字段:{path: '/messages',name: 'messages',meta: {transition: 'zoom',},component: () => import '../views/Messages.vue')} -
检查你的
routes对象的代码,以确认它与以下代码相同。在这里,我们将我们的应用程序的每个 URL 与一个视图文件相匹配:const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: "/",name: "home",component: HomeView,},{path: "/messages",name: "messages",meta: {transition: "zoom",},component: () =>import("../views/Messages.vue"),},],}); -
使用以下命令运行应用程序:
yarn dev -
现在,如果你在浏览器中打开
localhost:3000并导航到/messages,你应该会看到类似于图 8.11的界面:

图 8.11 – 在进行缩放效果的同时导航到/messages
在导航到其他路由时,我们应该看到图 8.12中显示的默认过渡效果。

图 8.12 – 导航到/home 时带有淡入效果的提示信息
这个练习展示了我们如何通过结合合适的钩子和方法,以最小的努力为不同的页面设置不同的过渡效果。你可以通过使用外部库进一步实验,使你的应用动画更加平滑和生动。
使用 GSAP 库进行动画
GSAP 是一个开源的脚本库,专注于使用 JavaScript 进行快速动画,并提供跨平台的兼容性支持。它支持在广泛的元素类型上动画,例如矢量图形(SVG)、React 组件、画布等。
GSAP 非常灵活,易于安装,并且可以适应任何配置,从 CSS 属性或 SVG 属性到将对象渲染到画布上的数值。
核心库是一套不同的工具,分为核心工具和其他工具,例如插件、缓动工具和实用工具。
安装 GSAP
使用npm install或yarn add命令安装 GSAP 非常简单:
yarn add gsap
#or
npm install gsap
安装完成后,你应该会看到以下截图所示的输出结果:

图 8.13 – 安装成功后的结果
现在我们已经安装了 GSAP,我们将查看 GSAP 中的基本缓动。
基本缓动
缓动是由 GSAP 库的创建者定义的一个概念,它是一个高性能的设置器,用于执行所有基于用户配置输入的所需动画工作。我们可以使用目标对象、一个周期或任何特定的 CSS 属性作为动画的输入。在执行动画时,缓动会根据给定的持续时间确定 CSS 属性的值,并相应地应用它们。
以下是一些创建基本缓动的基本方法。
gsap.to()
最常用的缓动是gsap.to(),它根据以下两个主要参数创建动画:
-
#myId。 -
opacity: 0、rotation: 90或fontSize: '20px';动画属性如duration: 1、stagger: 0.2或ease: "elastic";以及事件处理属性如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});
上述代码定位具有 green 类的元素,并在该元素进入视图时在水平方向上旋转 360 度,距离为 500 像素。
gsap.from() 和 gsap.fromTo
我们并不总是想为视图中元素定义预期的动画效果。相反,我们定义动画应从目标元素开始的默认值 - 这就是使用 gsap.from() 的时候。
例如,假设一个盒子的当前 opacity 值为 1,scale 值为 1,x 位置为 0,我们想要设置一个动画,从 x 位置 300,opacity 值为 0 和 scale 值为 0.5 开始。换句话说,动画将从 {x: 300, opacity: 0, scale: 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 的缓动动画。
练习 8.04 – 使用 GSAP 进行缓动
本练习的目的是让你熟悉使用外部库,如 GSAP。
要访问此练习的代码文件,请访问 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Exercise8.04。
我们将设置一个简单的动画,但您可以在 Vue 代码的任何地方应用此相同的模式。我们将应用动画在挂载时,但 JavaScript 动画可以根据计时器、随机整数或按钮等输入动态触发:
-
使用
npm init vue@3生成的应用程序作为起点,并添加 Vue Router。或者,在代码仓库的根目录下,使用以下命令顺序进入Chapter08/Exercise8.04文件夹:> cd Chapter08/Exercise8.04/> yarn -
在您的 VS Code 中打开练习项目(在项目目录中使用
code .命令)或您首选的 IDE。 -
使用以下命令之一使用
yarn或npm安装 GSAP:yarn add gsap# ORnpm install gsap -
在
src/App.vue中找到现有的img标签,并按如下方式添加ref="logo":<img ref="logo" alt="Vue logo" src="img/logo.png"> -
在
src/App.vue的<script setup>部分导入 GSAP:import gsap from 'gsap' -
我们使用
ref()钩子将logo定义为响应式变量,它包含在步骤 4中设置的img元素的引用:import { ref } from 'vue'const logo = ref(); -
然后,我们使用
onMounted()生命周期钩子添加一个动画,该动画是10次旋转,持续30秒:import { onMounted, ref } from 'vue'onMounted(() => {gsap.from(logo.value, { duration: 30, rotation: 3600});});
完整的script部分组件代码将如下所示:
<script setup>
import HelloWorld from "./components/HelloWorld.vue";
import gsap from "gsap";
import { onMounted, ref } from 'vue'
const logo = ref();
onMounted(() => {
gsap.from(logo.value, { duration: 30, rotation: 3600
});
});
</script>
-
接下来,通过在终端中运行
yarn dev来启动应用程序。 -
打开您的浏览器到
localhost:3000,你应该看到默认的 Vue 启动页面,但带有旋转的 logo,如下面的截图所示:

图 8.14 – 使用 GSAP 的简单动画
在这个练习中,我们学习了如何在 Vue 中使用 GSAP 实现简单的旋转动画。接下来,我们将看到如何通过缓动修改动画的外观和感觉。
使用缓动效果修改外观和感觉
缓动很重要,因为它决定了动画的原点与目的地之间的运动风格。它控制了缓动过程中的变化率;因此,用户有时间看到效果,无论是平滑、突然、弹跳还是其他类型的过渡效果:
gsap.from(".bubble", {
duration: 2,
scale: 0.2,
rotation: 16,
ease: "bounce",
})
在前面的示例代码中,duration是以毫秒为单位,表示动画活跃的时间长度。
此外,GSAP 中还有额外的内置插件,提供了配置缓动效果的额外功能,例如power、back、elastic等。
为了使运动在一定程度的平滑,我们使用以下语法:
ease: "<ease-name>.<ease-type>(<addition-inputs>)"
以bubble效果为例 – 我们可以通过以下代码启用平滑的elastic缓动效果:
gsap.to(".bubble", 4, {
duration: 4,
scale: 1.2,
rotation: '-=16',
ease: 'elastic(2.5, 0.5)',
})
或者,按照以下方式添加elastic缓动:
gsap.to(".bubble", 4, {
duration: 4,
scale: 1.2,
rotation: '-=16',
ease: 'elastic.in(2.5, 0.5)',
})
使用ease,我们可以根据设置的样式使相同的动画看起来完全不同。接下来,我们将查看stagger,这是另一种影响动画外观和感觉的选项。
使用交错效果修改外观和感觉
在前面的章节中,我们已经介绍了如何使用 Vue 过渡动画来动画化一系列项目。交错是对于一系列对象我们应该考虑的一种动画,因为它使得此类目标的动画变得简单,并且每个项目动画之间有适当的延迟。
例如,通过为stagger属性赋值,我们可以在除了延迟duration数字(以毫秒为单位)之外创建并应用一些配置选项:
gsap.to('.stagger-box', 2, {
duration: 2,
scale: 0.1,
y: 60,
yoyo: true,
repeat: 1,
ease: "power3.inOut",
delay:1,
stagger: {
amount: 1.5,
grid: "auto",
from: "center"
}
})
您可以使用repeat来定义动画应重复的次数。负数将使其无限重复。
使用时间轴
时间轴是您完全控制的缓动调度,用于定义缓动之间的重叠或间隔。当您需要根据顺序控制一组动画、构建一系列动画、链式动画以进行最终回调或模块化动画代码以实现可重用时,它非常有用。
为了使用时间轴,你可以使用内置的gsap.timeline()创建一个timeline实例,并按照以下方式设置实例配置:
import gsap from 'gsap';
const tl = gsap.timeline( { onComplete: done });
我们将简要介绍时间轴的两个主要用途 – 排序和链接。
通过排序创建动画效果链
与 GSAP 类似的核心功能,时间轴也提供了to()、from()和fromTo()方法。默认情况下,所有动画都可以一个接一个地排序,可以通过使用position属性来强制控制何时或在哪里进行,这是一个可选参数,如下面的代码所示:
const 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 时间轴功能来安排一系列动画,这些动画一个接一个地运行,有些有间隔,有些有重叠。在下一节中,我们将进一步探讨使用链接的概念来排序动画。
链接
与排序类似,链接将动画排列成顺序。而不是每次都使用实例方法单独调用每个动画,它将被放置在链中。在子缓动器之间使用的所有特殊值都可以定义。在创建为defaults的实例中,或者在第一次调用中,获取链中的其他时间轴(动画列表)以继承这些值:
const 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 的了解来制作一个带有动画过渡的消息查看应用。
活动八.01 – 使用过渡和 GSAP 构建消息应用
要访问此活动的代码文件,请参阅github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter08/Activity8.01
在此活动中,您将使用 CSS 编写自定义过渡,使用过渡组和路由设置更复杂的过渡,并使用第三方过渡库,如 GSAP,在应用中创建动画和过渡。
您将创建一个简单的消息应用,该应用利用过渡效果。
以下步骤将帮助您完成此活动:
-
使用
npm init vue@3作为起点创建一个新的 Vue 应用,并添加 Vue Router。 -
创建一个
Messages路由(在src/views/Messages.vue),它渲染两个嵌套视图:Messages(src/views/MessageList.vue),用于显示消息列表,以及MessageEditor(src/views/MessageEditor.vue),包含一个textarea和一个用于创建新消息的submit按钮。 -
创建一个
Message路由(在src/views/Message.vue),它渲染一个具有给定 ID 的单条消息视图。 -
注册所有路由。
-
在
src/App.vue文件中的主router-view上添加一个简单的过渡名称fade和out-in模式。 -
通过使用自定义过渡类,将过渡添加到
src/views/Messages.vue中的嵌套router-view。 -
创建一个动画效果,在进入路由时放大,在离开路由时缩小。
-
为离开事件创建另一个淡入动画效果。
-
在
MessageList.vue中的消息列表上添加一个弹入效果。 -
使用 GSAP 动画弹入效果。
-
为出现的项目添加移动效果。
-
当从 列表 页面导航到 编辑 页面时,你应该看到内容流向左滑动,编辑 页面出现,如图 图 8.15 所示:

图 8.15 – 从消息列表视图导航到编辑视图时淡出
当从消息视图导航到编辑视图时,你应该看到文本输入向左滑动,如图 图 8.16 所示:

图 8.16 – 从编辑视图导航到消息列表视图时淡出
接下来,消息列表将以弹跳效果显示数字,如图 图 8.17 所示:

图 8.17 – 在消息列表视图中显示消息流时的弹跳效果
当点击特定的消息时,0 或 1 在我们的例子中,我们的列表将向左滑动,你应该看到消息内容,如图 图 8.18 所示:

图 8.18 – 单条消息视图
到目前为止,你已经了解了 GSAP 的基础知识,例如缓动和时间轴。你还通过在 Vue 组件中结合过渡和 GSAP 功能来创建缓动和交错动画进行了实验。
摘要
在本章中,我们探讨了 Vue.js 内置的过渡和动画支持,包括单组件和多组件,并看到了如何轻松设置。到这一点,你已经为路由和组件创建了过渡和动画效果,并见证了 Vue.js 过渡的所有基本功能:自定义过渡类、分组过渡和过渡模式。此外,你还了解了其他领先的动画第三方库,如 GSAP,并看到了如何将它们集成到你的 Vue 应用程序中,以获得更好的网页动画效果。
下一章将重点介绍构建生产就绪的 Vue 应用程序的关键主题之一 – 状态管理和应用程序内组件之间如何使用 Pinia(一个状态管理库)进行通信。
第三部分:全局状态管理
在这部分,我们将探讨如何在 Vue 应用程序中管理和存储数据。我们将从如何原生地处理 Vue 中的状态的示例开始,然后继续展示 Pinia 库如何使这一过程变得更简单。
我们在本节将涵盖以下章节:
-
第九章, Vue 状态管理的状态
-
第十章, 使用 Pinia 进行状态管理
第九章:Vue 状态管理的现状
您现在已经看到了如何构建 Vue.js 应用程序,并且已经开始将多个不同的组件组合成您的第一套真实应用程序。随着应用程序规模的扩大,其复杂性也在增加。在这一章中,是时候看看您如何可以通过集成状态管理来开始管理这种复杂性了。
在这里,您将首先了解状态问题是如何产生的,状态管理如何帮助解决这些问题,以及 Vue.js 3 有哪些特性可以帮助您直接处理这些问题。您将在构建一个简单的配置文件卡片应用程序的同时学习这些内容,该应用程序使用多个组件,这些组件之间需要同步状态。下一章将介绍一个进一步帮助这一过程的工具,称为Pinia。
因此,在本章中,我们将涵盖以下主题:
-
理解组件架构和状态问题
-
在公共祖先组件中持有状态
-
添加简单的状态管理
-
决定何时使用局部状态或全局状态
技术要求
除了您之前用于使用 Vue.js 构建应用程序的npm CLI 之外,本章没有其他技术要求。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter09
理解组件架构和状态问题
在前面的章节中,我们看到了如何使用局部状态和props来持有状态并在父子组件层次结构中共享状态。
现在,我们将开始展示如何利用状态、属性和事件在无父子配置的组件之间共享状态。这类组件被称为兄弟组件。

图 9.1 – 子组件 1 和子组件 2 是“兄弟”组件
在本章的整个过程中,我们将构建一个配置文件生成器应用程序,演示状态如何在应用程序中以 props 的形式沿着组件树流动,以及如何通过事件、事件总线以及存储更新来传播更新。
既然我们想要构建一个配置文件生成器,我们可以将应用程序分解为三个部分:一个头部,我们将在这里有全局控制和显示页面标题;一个配置文件表单,我们将在这里捕获数据;最后,一个配置文件显示,我们将在这里显示配置文件卡片。在图 9.2 中,您可以看到我们的根组件(App(根)),以及三个兄弟子组件。

图 9.2 – 展示配置文件卡片应用程序组件树
我们现在已经看到了如何将我们的应用程序视为组件树,以及我们的应用程序如何作为组件树进行结构化。在下一节中,我们将演示将所有共享状态放入根组件中。
在共享祖先组件中持有状态
要仅使用state组件和props持有状态,并通过events更新它,我们将将其存储在最近的共享祖先组件中。
state仅通过props传播,并且仅通过events更新。在这种情况下,所有state组件都将生活在需要它们的组件的共享祖先中。由于App组件是根组件,因此它是持有共享状态的默认选择。

图 9.3 – 带有 props 和事件传播的共享祖先组件持有状态
要更改state,组件需要向持有我们state(共享祖先)的组件发出events。共享祖先需要根据数据和events的类型更新state。这反过来又会导致重新渲染,在此期间,祖先组件将更新的props传递给读取state的组件。

图 9.4 – 当祖先持有状态时更新兄弟组件
让我们通过在个人资料卡片编辑器上工作来查看这个例子。为了构建标题,我们需要在AppHeader.vue文件中创建一个AppHeader组件,它将包含一个模板和一个带有 TailwindCSS 类的h2标题。
注意
你可以在这里了解更多关于在 Vue3 中使用 Tailwind CSS 的信息:tailwindcss.com/docs/guides/vite。
要做到这一点,请添加以下代码:
<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文件中渲染它:
<script setup>
import AppHeader from '@/components/AppHeader.vue'
</script>
<template>
<div id="app">
<AppHeader/>
</div>
</template>
前面代码的输出将如下所示:

图 9.5 – 在个人资料卡片生成器中显示的 AppHeader
我们将类似地创建一个AppProfileForm文件;此组件的目的是布局用于编辑个人资料的标签和表单字段:
<template>
<section class="md:w-2/3 flex flex-col p-12 items-center
bg-red-200">
<!-- Inputs -->
</section>
</template>
然后,我们将创建一个AppProfileDisplay文件;此组件负责显示个人资料,以便用户可以预览他们的编辑:
<template>
<section class="md:w-1/3 h-64 bg-blue-200 flex">
<!-- Profile Card -->
</section>
</template>
我们的两个容器(AppProfileForm和AppProfileDisplay)现在都可以导入并在App中渲染:
<script setup>
import AppHeader from '@/components/AppHeader.vue'
import AppProfileDisplay from '@/components/AppProfileDisplay.vue'
import AppProfileForm from '@/components/AppProfileForm.vue'
</script>
<template>
<div id="app">
<AppHeader/>
<div class="flex flex-col md:flex-row">
<AppProfileForm />
<AppProfileDisplay />
</div>
</div>
</template>
前面代码的输出将如下所示:

图 9.6 – 包含 AppHeader、AppProfileForm 和 AppProfileDisplay 的应用骨架
要添加一个表单字段,在这种情况下,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>
前面的代码将显示如下:

图 9.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 setup>
import { ref } from 'vue'
const emit = defineEmits(['submit'])
const name = ref('');
</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 setup>
import { ref } from 'vue'
const emit = defineEmits(['submit'])
const name = ref('');
function submitForm() {
emit('submit', {
name: this.name
});
}
</script>
这将显示如下:

图 9.8 – 配有已连接提交按钮的 AppProfileForm
下一步是将表单的状态存储在App组件中。由于它是AppProfileForm和AppProfileDisplay的共同祖先,因此它是存储表单状态的理想选择。
首先,我们需要一个由reactive()返回的formData属性。我们还需要一种更新formData的方法。因此,我们将添加一个update(formData)方法:
<script setup>
import AppHeader from '@/components/AppHeader.vue'
import AppProfileDisplay from '@/components/AppProfileDisplay.vue'
import AppProfileForm from '@/components/AppProfileForm.vue'
import { reactive } from 'vue'
const formData = reactive({name:''});
function update€ {
formData.name = e.name;
}
</script>
接下来,我们需要将update()绑定到由AppProfileForm发出的submit事件。我们将使用@submit简写和事件对象记法update($event)来完成此操作:
<template>
<!-- rest of template -->
<AppProfileForm @submit="update($event)" />
<!--rest of template -->
</template>
要在AppProfileDisplay内部显示名称,我们需要添加formData作为 prop:
<script setup>
const props = defineProps({formData:Object});
</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>
现在我们可以更新表单上的名称。当你点击提交按钮时,它将在个人资料卡片显示中显示如下:

图 9.9 – 存储状态并在 AppProfileDisplay 中传递 props 的 App
我们现在已经看到了如何在App组件中存储共享状态,以及如何从AppProfileForm更新它并在AppProfileDisplay中显示它。
在下一个主题中,我们将看到如何向个人资料卡片生成器添加一个额外的字段。
练习 9.01 – 向个人资料卡片生成器添加职业字段
在存储name共享状态的示例之后,另一个有趣的个人资料卡片中要捕获的字段是个人的职业。为此,我们将在AppProfileForm中添加一个occupation字段来捕获这个额外的状态部分,并在AppProfileDisplay中显示它。
本练习的完整代码可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter09/Exercise9.01找到
按照以下步骤添加字段:
-
首先要做的就是在
src/components/AppProfileForm中添加新的occupation字段。我们也将借此机会从section元素中移除h-64和bg-red-200类(如果存在),这意味着表单将无背景和固定高度显示:<template><section class="md:w-2/3 flex flex-col p-12items-center"><!-- rest of template --><div class="flex flex-col mt-2"><label class="flex text-gray-800 mb-2"for="occupation">Occupation</label><inputid="occupation"type="text"name="occupation"class="border-2 border-solid border-blue-200rounded px-2 py-1"/></div><!-- rest of template --></section></template>
上述代码的输出将如下所示:

图 9.10 – 添加了新职业字段的 AppProfileForm
-
为了跟踪
occupation值的双向数据绑定,我们将添加一个新的ref()函数实例:<script setup>// rest of componentconst occupation = ref('');// rest of component} -
我们现在将使用
v-model从occupation响应式数据属性到occupation输入实现双向数据绑定:<template><!—rest of template --><inputid="occupation"type="text"name="occupation"v-model="occupation"class="border-2 border-solid border-blue-200rounded px-2 py-1"/><!-- rest of template --></template> -
当点击
submit时,要传输occupation值,我们需要将其添加到submitForm方法作为submit事件负载的属性:<script setup>import { ref } from 'vue'const emit = defineEmits(['submit'])const name = ref('');const occupation = ref('');function submitForm() {emit('submit', {name: this.name,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>
我们的浏览器应该看起来如下:

图 9.11 – AppProfileForm
正如我们刚才看到的,使用共同祖先来管理状态添加新字段是一个在事件中向上传递数据并在 props 中向下传递到读取组件的案例。
现在我们将看到如何使用清除按钮重置表单和资料显示。
练习 9.02 – 向资料卡片生成器添加清除按钮
当使用我们的应用程序创建新资料时,能够重置资料非常有用。为此,我们将添加一个清除按钮。
AppProfileDisplay。完整的代码可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter09/Exercise9.02找到。
现在让我们看看执行此练习的步骤:
-
我们希望有一个
src/components/AppProfileForm.vue):<template><!-- rest of template --><div class="w-1/2 flex md:flex-row mt-12"><buttonclass="flex md:w-1/2 justify-center"type="button">Clear</button><buttonclass="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 setup>// rest of the componentfunction clear() {this.name = '';this.occupation = '';}// rest of the component</script> -
我们希望将
clear方法绑定到Clear按钮的click事件以重置表单(在src/components/AppProfileForm.vue中):<template><!-- rest of template --><buttonclass="flex md:w-1/2 justify-center"type="button"@click="clear()">Clear</button><!-- rest of template --></template>
因此,我们现在可以输入表单数据并按照以下截图进行提交:

图 9.12 – 填写姓名和职业字段的 AppProfileForm
当点击以下AppProfileDisplay:

图 9.13 – 填充并提交数据的 AppProfileForm 和 AppProfileDisplay
不幸的是,AppProfileDisplay仍然有陈旧的数据,如下面的截图所示:

图 9.14 – 只有 AppProfileForm 被清除而 AppProfileDisplay 仍然有陈旧数据的 AppProfileForm 和 AppProfileDisplay
-
为了清除
AppProfileDisplay的内容,我们需要在src/components/AppProfileForm.vue中通过发出一个带有空有效负载的submit事件来更新App.vue中的formData:<script setup>// rest of the componentfunction clear() {this.name = '';this.occupation = '';emit('submit', {});}// rest of the component</script>
当我们填写表单并提交时,它将如下所示:

图 9.15 – 填充并提交数据的 AppProfileForm 和 AppProfileDisplay
我们可以按照以下截图点击AppProfileDisplay和AppProfileForm:

图 9.16 – 清除数据后的 AppProfileForm 和 AppProfileDisplay(使用清除按钮)
我们已经看到了如何通过一个共同的祖先设置兄弟组件之间的通信。
注意
为了跟踪需要在应用程序中保持同步的所有状态片段,需要做大量的记录和琐碎的工作。
在下一节中,我们将探讨 Vue 3 内置对响应式数据的支持意味着我们可以自己实现简单的状态管理。
添加简单的状态管理
对于我们的简单应用程序,如果我们使用reactive() API 构建简单的存储,我们可以替换大量的模板代码:
-
让我们从创建一个新的文件
store.js开始,该文件使用reactive对象来存储我们的配置值:import { reactive } from 'vue';export const store = reactive({name:'',occupation:''});
这个非常简单的对象将非常强大,归功于 Vue 3 的响应式支持的使用。任何使用这里值的组件都将能够依赖当值变化时,它将立即反映出来。立即,我们可以看到这如何简化了事情,当我们转向存储时。
-
在
AppProfileForm中,让我们首先导入存储:<script setup>import { store } from '@/store.js';</script> -
接下来,将两个字段更新为指向商店而不是本地数据。在下面的代码中,
v-model的值已更改,并且移除了提交按钮——它不再必要:<!-- rest of component --><div class="flex flex-col"><label class="flex text-gray-800 mb-2" for="name">Name</label><inputid="name"type="text"name="name"class="border-2 border-solid border-blue-200rounded px-2 py-1" v-model="store.name"/></div><div class="flex flex-col mt-2"><label class="flex text-gray-800 mb-2"for="occupation">Occupation</label><inputid="occupation"type="text"name="occupation"v-model="store.occupation"class="border-2 border-solid border-blue-200rounded px-2 py-1"/></div> -
现在,我们可以编辑脚本块以删除大部分之前的逻辑。
clear方法需要更新以更改存储值:<script setup>import { store } from '@/store.js';function clear() {store.name = '';store.occupation = '';}</script> -
接下来,我们可以对
AppProfileDisplay进行类似的修改。首先,导入存储:<script setup>import { store } from '@/store.js';</script>
然后,修改模板:
<template>
<section class="md:w-1/3 flex flex-col p-12">
<!-- Profile Card -->
<h3 class="font-bold font-lg">{{ store.name }}</h3>
<p class="mt-2">{{ store.occupation }}</p>
</section>
</template>
我们现在已经从从组件广播事件到更简单、共享状态的一个系统转换。我们的代码更简单,这将使更新更加容易。
练习 9.03 – 将清除按钮移动到应用程序标题个人资料卡片生成器并更新清除逻辑
在我们的个人资料卡片生成应用程序中,清除按钮会清除整个应用程序的状态。由于它位于表单内部,这使得清除按钮的功能不明确,因为它看起来可能只会影响表单。
为了反映清除按钮具有全局功能的事实,我们将将其移动到标题中。
我们还将更新我们的 store 以处理清除状态的相关逻辑。我们的简单状态实用工具不仅可以定义变量,还可以定义方法。由于脚本正在处理保持值,因此它处理与这些值相关的逻辑是有意义的。
你也可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter09/Exercise9.03找到完整的代码。
以下步骤将帮助我们完成这项练习:
-
我们将首先在
src/components/AppHeader.vue中创建一个button组件:<template><header class="w-full flex flex-row p-4 border-bbg-blue-300 border-gray-700"><h2 class="text-xl flex text-gray-800">Profile Card Generator</h2><button class="flex ml-auto text-gray-800items-center">Reset</button></header></template> -
在我们的 store 中,让我们在
store.js中添加一个新的clear函数。它负责将值重置回初始状态:import { reactive } from 'vue';export const store = reactive({name:'',occupation:'',clear() {this.name = '';this.occupation = '';}}); -
在
AppHeader中,我们需要导入 store:<script setup>import { store } from '@/store.js';</script> -
现在,我们需要将
Reset按钮绑定到调用 store 的clear方法:<!-- rest of template --><button class="flex ml-auto text-gray-800items-center" @click="store.clear()">Reset</button><script> -
最后一步是删除
Clear按钮和Submit按钮:<template><!-- rest of template --><div class="w-1/2 flex md:flex-row mt-12"><buttonclass="flex md:w-1/2 justify-center"type="submit"@click="submitForm()">Submit</button></div><!-- rest of template --></template>
当表单填写并提交时,表单看起来如下:

图 9.17 – 填写并提交的表单
现在重置表单会清除表单字段以及AppProfileDisplay:

图 9.18 – 使用重置按钮重置表单和显示
你现在已经看到了 Vue 3 的内置响应式支持如何使你在组件中处理状态管理变得简单。
活动 9.01 – 向个人资料卡片生成器添加组织、电子邮件和电话号码字段
在个人资料生成器中,你查看个人资料以查找有关个人的信息。一个人的组织、电子邮件和电话号码通常是个人资料卡片上寻找的最关键信息。这项活动是关于将这些详细信息添加到个人资料卡片生成器中。
要做到这一点,我们将在AppProfileForm和AppProfileDisplay中添加Organization、Email和Phone Number字段:
-
首先向
AppProfileForm添加organization输入字段和标签。 -
接下来,向
AppProfileForm添加一个新的email输入字段和标签,用于Email字段。 -
然后,我们可以在
AppProfileForm中添加一个新的phone输入字段(tel类型)和标签,用于Phone Number字段。
新字段看起来如下:

图 9.19 – 带有新电子邮件和电话号码字段的程序
然后,我们可以在src/store.js中的初始状态中添加organization、email和phone字段,以便设置值并更新clear以重置新值。
-
为了使
organization显示,我们在src/components/AppProfileDisplay.vue中将其添加到occupation之后。我们将使用"at"字面字符串作为前缀,并且只有当有值时才显示。最终结果是包含职业和组织的一段文本。 -
为了使
email显示,我们需要在src/components/AppProfileDisplay.vue中使用条件段落(在没有设置Email标签时隐藏Email标签)来渲染它。 -
为了使
phone显示,我们需要在src/components/AppProfileDisplay.vue中使用条件 span(在没有设置Phone Number标签时隐藏Phone Number标签)来渲染它。
当表单填写并提交时,应用程序应如下所示:

图 9.20 – 带有电子邮件和电话号码字段的应用程序
注意
此活动的解决方案可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter09/Activity9.01找到。
现在,你已经将应用程序从更复杂的事件驱动状态管理系统转变为使用共享状态,这种状态更容易处理和更新。既然你已经看到了如何处理全局状态,那么回顾一下何时使用它是明智的,是个好时机。
决定何时使用本地状态或全局状态
如通过示例所见,Vue.js 生态系统有解决方案来管理共享和全局状态。我们现在要探讨的是如何决定某事物属于本地状态还是全局状态。
一个好的经验法则是,如果prop通过三个组件的深度传递,那么最好将这部分状态放在全局状态中,并以此方式访问它——例如,一个值从父组件传递到子组件,然后到孙组件。这也适用于两个兄弟和一个父组件,有三个组件但深度较小。
判断某个状态是局部还是全局的第二个方法是问自己:当页面重新加载时,用户是否希望这个信息持续存在? 这为什么很重要呢?嗯,全局状态比局部状态更容易保存和持久化。这是因为全局状态的本质只是一个 JavaScript 对象,而不是组件状态,后者与组件树和 Vue.js 的联系更为紧密。浏览器支持在客户端持久化数据的一些强大方法,从简单的 Web 存储到更复杂的 IndexedDB。这些浏览器功能中的任何一个都可以用来存储 Vue 应用程序的状态,在加载时恢复它们,如果用于全局状态,则可以应用于应用程序中的各个组件。
另一个需要记住的关键思想是,在组件中混合全局状态和局部状态是完全可能的。每个组件都可能使用仅适用于自身的东西与影响整个应用程序的数据的组合。
与大多数事情一样,仔细规划并思考组件需要哪些数据以及可能需要共享什么,可以帮助提前进行适当的规划。
摘要
本章是 Vue.js 状态管理领域的入门介绍。在本章中,我们探讨了在 Vue.js 应用程序中实现共享和全局状态管理的不同方法。
我们首先探讨了将全局状态存储在一个共享祖先中。这允许通过 props 和事件在兄弟组件之间共享数据。虽然这可行,但它确实需要额外的代码来处理数据传递的架构。
然后,你使用了 Vue 内置的反应性来创建一个简单、共享的存储。这使得应用程序变得更加简单,因为之前版本中的大部分代码都可以被移除。
最后,我们探讨了可以使用哪些标准来决定状态应该存在于局部组件状态还是更全局或共享状态解决方案中。
下一章将深入探讨使用新的推荐方式处理共享状态,即 Pinia 库,来编写大规模 Vue.js 应用程序。
第十章:使用 Pinia 进行状态管理
在上一章中,你被介绍到 状态 的概念以及它如何用于在 Vue 应用程序中同步多个组件之间的数据。你首先看到了通过事件广播处理状态的一个例子,然后通过包括一个简单的状态库来改进这一点。
在本章中,你将了解 Pinia 项目,并了解它如何帮助管理 Vue 应用程序中的复杂状态交互。你将学习如何安装库并立即开始使用它。
在本章中,我们将介绍以下主题:
-
Pinia 是什么
-
安装 Pinia
-
使用 Pinia 创建 store
-
在你的 Pinia store 中添加和使用 getters
-
使用 Pinia 动作进行工作
-
在 Devtools 中调试 Pinia
技术要求
除了你现在已经使用的 git CLI 以外,本章没有其他技术要求。你可以在这里找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter10
Pinia 是什么
Pinia (pinia.vuejs.org) 是 Vue.js 应用的状态管理库。正如你在 第九章 中所看到的,“Vue 状态管理状态”,处理需要在多个不同组件之间同步的数据需要某种形式的管理。Pinia 通过提供一种简单的方法来设置一个包含你的应用程序状态的中央 store 来帮助解决这个问题。你的组件使用这个 store 来确保它们都在使用相同的信息。
Pinia 最初是 Vue 3 的一个实验,但后来也支持了 Vue 2。现在,Pinia 是 Vue 应用程序的 推荐 状态管理库,而备受尊敬的 Vuex (vuex.vuejs.org/) 现已进入维护模式:

图 10.1 – Pinia 网站
除了状态管理之外,使用 Pinia 还提供了以下其他好处:
-
通过 Vue 扩展提供的 Devtools 支持。此扩展支持 Chrome、Edge 和 Firefox。还有一个独立的 Electron 桌面应用程序。
-
热模块替换(HMR),这让你可以在开发环境中编辑你的 store 并更新它,而无需重新加载整个网页。
-
可选的 TypeScript 支持。
-
服务器端渲染(SSR)支持。
-
扩展 Pinia 功能的插件。
通常,你需要了解 Pinia 的几个核心方面。有 Vuex 经验的开发者会认出这些。
从高层次来看,存储是需要在应用程序中共享的数据和逻辑的组合。Pinia 应用程序的状态是应用程序存储的数据。Pinia 提供了读取和写入此数据的 API。获取器在 Vue 应用程序中类似于虚拟属性。动作允许您为存储定义自定义逻辑——例如,使用 AJAX 调用来验证在提交之前对数据的更改。虽然 Pinia 有更多内容,但这三个核心概念将是本章的重点,也是任何 Pinia 使用的主要部分。
安装 Pinia
要在 Vue 应用程序中使用 Pinia,您有两种添加它的方法。首先,当通过标准方法创建新的 Vue 应用程序时(npm init vue@latest),其中一个问题将是您是否希望包含 Pinia。在这里简单地回答 是:

图 10.2 – 指示您是否希望将 Pinia 添加到新的 Vue 项目中
如果您有一个现有的 Vue 3 应用程序,添加支持几乎同样简单。首先,在项目中通过 npm 添加 Pinia:npm install pinia。接下来,您需要在应用程序中包含 Pinia。您的 main.js 文件(位于 /src 目录)将如下所示:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
首先,导入 Pinia:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).mount('#app')
然后,修改 createApp 行。我们将它拆分成几行,以便我们可以注入 Pinia:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
现在我们已经介绍了 Pinia 的基本概念以及如何在 Vue 应用程序中包含它,让我们开始我们的第一个示例。
使用 Pinia 创建存储
让我们通过演示如何在其中定义存储以及如何在应用程序中使用状态数据来开始使用 Pinia:
-
创建一个新的 Vue 应用程序并启用 Pinia,如图 10.2 所示。这将为您提供一个已经创建了存储的 Vue 应用程序。您可以在
src/stores/counter.js下找到它:import { defineStore } from 'pinia'export const useCounterStore = defineStore({id: 'counter',state: () => ({counter: 1}),getters: {doubleCount: (state) => state.counter * 2},actions: {increment() {this.counter++}}})
这个简单的 Pinia 文件展示了我们之前定义的三个主要方面——状态、获取器和动作。在本节中,我们只关注状态。安装后,Pinia 定义了一个名为 counter 的数据项,其值为 1。我们如何在我们的应用程序中访问它?
-
切换到
App.vue文件并删除所有内容。我们将极大地简化它。首先,让我们定义布局以简单地输出counter的值:<template><p>Counter: {{ store.counter }}</p></template> -
接下来,我们需要使我们的存储对组件可用。我们将在一个
scriptsetup块中定义它:<script setup>import { useCounterStore } from './stores/counter'const store = useCounterStore()</script> -
我们首先导入存储。一旦导入,我们就创建存储的一个实例,以便它可以在我们的模板中使用。虽然这并不十分令人兴奋,但 图 10**.3 展示了它在浏览器中的样子:

图 10.3 – 在 App 组件中正确显示的存储值
-
要从共享状态中获得任何好处,我们至少需要另一个组件。在
components文件夹中创建一个新的文件,EditCounter.vue,并使用以下简短的代码片段:<script setup>import { useCounterStore } from '@/stores/counter';const store = useCounterStore()</script><template><h2>Edit Counter</h2><input type="text" v-model="store.counter"></template> -
与
App.vue组件一样,我们使用setup块来导入 store 并创建一个实例。这次,我们使用一个简单的编辑字段和 v-model 来将其值绑定到 store 的counter值。回到App.vue并编辑它以导入和使用EditCounter组件:<script setup>import EditCounter from './components/EditCounter.vue'import { useCounterStore } from './stores/counter'const store = useCounterStore();</script><template><p>Counter: {{ store.counter }}</p><EditCounter></EditCounter></template>
现在我们正在取得进展。我们有一个组件 App,它简单地渲染共享状态,还有一个组件 EditCounter,它也显示它,但以可编辑的形式。现在,你可以编辑值并看到它更新:

图 10.4 – 使用相同共享状态的多个组件
现在,我们已经看到了如何安装和初始化 Pinia,以及使用一个简单的 store,我们可以创建一个简单的演示来展示其作用。
练习 10.01 – 使用共享状态构建颜色预览应用程序
现在,我们已经看到了使用 Pinia 共享状态的一个简单示例,让我们构建一个将使用它的简单应用程序。我们的应用程序将允许你使用滑块来指定颜色的红色、绿色和蓝色值。一个组件将用于编辑,另一个组件将提供预览。
本练习的完整代码可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter10/Exercise10.01 找到:
-
首先,创建一个新的 Vue.js 应用程序,并确保选中 Pinia 支持。按照提示,切换到目录,运行
npm install,然后运行npm run dev以启动应用程序。清空App.vue组件的内容,并输入以下内容:<script setup>import RGBEdit from './components/RGBEdit.vue'import PreviewColor from './components/PreviewColor.vue'</script><template><h1>Color Editor</h1><p>Use the sliders below to set the red, green, and blue values for a color.</p><div class="twocol"><RGBEdit></RGBEdit><PreviewColor></PreviewColor></div></template><style>.twocol {display: grid;grid-template-columns: 1fr 1fr;column-gap: 10px;}</style>
我们首先导入两个组件(我们将在下一节定义)。模板部分包含一些简单的解释性文本,然后渲染这两个组件。最后,使用一些 CSS 来以网格形式显示这些组件。注意,App.vue 完全没有使用 store,但我们的两个组件会使用。
-
现在,让我们定义子组件。我们将创建它们作为基本上空的,这样我们就可以简单地测试应用程序是否工作。在
src/components中创建RGBEdit.vue:<template><div><h2>Edit RGB</h2></div></template>
接下来,创建 PreviewColor.vue:
<template>
<div>
<h2>Preview Color</h2>
</div>
</template>
注意,你可以移除 Vue 初始化脚本创建的现有组件。我们不需要那些。此时,你应该能在浏览器中看到以下内容:

图 10.5 – 颜色应用程序开始成形
-
现在,让我们定义我们的站点 store。在
src/stores中创建一个新文件(并且可以随意删除默认文件),命名为color.js:import { defineStore } from 'pinia'export const useColorStore = defineStore({id: 'color',state: () => ({red: 0,blue: 0,green: 0})})
我们有三种状态值,每个值代表颜色的一部分,如 0 中定义的那样。
-
接下来,让我们完善我们的
RGBEdit.vue。首先,我们将导入并创建 store 的一个实例:<script setup>import { useColorStore } from '@/stores/color';const store = useColorStore()</script>
接下来,我们将编辑模板部分,添加三个range风格的编辑字段。这将使用户能够快速预览颜色变得容易得多:
<template>
<div>
<h2>Edit RGB</h2>
<label for="red">Red</label> <input type="range"
min="0" max="255" id="red" v-model="store.red">
<br/>
<label for="green">Green</label>
<input type="range" min="0" max="255" id="green"
v-model="store.green"><br/>
<label for="blue">Blue</label>
<input type="range" min="0" max="255" id="blue"
v-model="store.blue"><br/>
</div>
</template>
每个range控件的最小值为0,最大值为255,这代表了在 Web 应用中颜色的有效范围。接下来,我们将添加一些样式来控制label元素的大小:
<style>
label {
display: inline-block;
width: 50px;
}
</style>
保存这个,现在在浏览器中确认你有了编辑颜色的控件:

图 10.6 – 我们的应用现在有了编辑颜色的控件
-
到目前为止,我们已经有一个编辑组件,但我们需要完成
preview组件。打开PreviewColor.vue,首先导入 store:<script setup>import { useColorStore } from '@/stores/color';const store = useColorStore()</script>
为了渲染颜色的预览,我们需要将数值颜色转换为十六进制,这是在网络上定义颜色的方式。鉴于我们在 store 中有三个数字,比如红色、绿色和蓝色分别为100、50和100,我们需要将它们转换为#64324。
我们可以编写一个计算属性来为我们处理这个转换。编辑script部分以包含计算支持并定义以下computed属性:
<script setup>
import { computed } from 'vue'
import { useColorStore } from '@/stores/color';
const store = useColorStore()
const previewRGB = computed(() => {
return {
backgroundColor: "#" + Number(store.red)
.toString(16).padStart(2, '0') +
Number(store.green).toString(16)
.padStart(2, '0') + Number(store.blue)
.toString(16).padStart(2, '0')
}
});
</script>
进入到模板部分,让我们在显示中使用这个:
<template>
<div>
<h2>Preview Color</h2>
<div class="previewColor" :style="previewRGB"></div>
<p>
CSS color string: {{ previewRGB.backgroundColor }}
</p>
</div>
</template>
注意,空的div正在使用computed属性动态更新元素的背景颜色。最后,还需要为那个div元素添加一个基本的大小:
<style>
.previewColor {
width: 250px;
height: 250px;
}
</style>
- 最后一步,只需在应用中尝试并找到一个看起来很棒的颜色!

图 10.7 – 颜色应用的最终版本
在下一节中,我们将介绍 Pinia 中的 getter,并演示如何使用它们。
在你的 Pinia store 中添加和使用 getter
如前所述,Pinia 中的 getter 就像计算属性一样工作。它们允许你请求一个由函数中编写的自定义逻辑生成的简单值。
如果你回到默认创建的原始 Pinia store,你会看到它定义了一个 getter:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore({
id: 'counter',
state: () => ({
counter: 0
}),
getters: {
doubleCount: (state) => state.counter * 2
},
// rest of file...
})
doubleCount getter 只是简单地取counter的当前值,并返回它的两倍。如演示所示,getter 会自动传递当前状态作为参数,然后可以在你特定的 getter 函数中用于任何合理的逻辑。
正如在状态中定义的常规值一样,getter 可以在你的组件中被引用,如下所示:
<template>
<p>
Counter: {{ store.counter }}
</p>
<p>
Double Count: {{ store.doubleCount }}
</p>
</template>
让我们基于上一个练习继续前进,并尝试这个功能。
练习 10.02 – 使用 getter 改进颜色预览应用
在上一个练习中,你使用了 Pinia 来存储由三个组件组成的一个颜色值的状态——红色、绿色和蓝色。在应用中,PreviewColor组件显示了组合颜色的hex值。在这个练习中,将移除组件中的自定义逻辑,并将其存储在 store 中的 getter 中。
本练习的完整代码可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter10/Exercise10.02找到。
-
在存储中,
src/stores/color.js,为获取器添加一个新的部分:import { defineStore } from 'pinia'export const useColorStore = defineStore({id: 'color',state: () => ({red: 0,blue: 0,green: 0}),getters: {hex: (state) => {return "#" + Number(state.red).toString(16).padStart(2, '0') +Number(state.green).toString(16).padStart(2, '0') +Number(state.blue).toString(16).padStart(2, '0');}}})
hex 获取器返回一个字符串,以井号符号开头,后跟 RGB 数字的 hex 值。鉴于所有值都是 255(白色),结果将是 #FFFFFF。
-
在
PreviewColor.vue中,我们需要更新代码以使用获取器。移除所有用于转换各种值的自定义代码,并简单地使用获取器:<script setup>import { computed } from 'vue'import { useColorStore } from '@/stores/color';const store = useColorStore()const previewRGB = computed(() => {return {backgroundColor: store.hex}});</script>// Rest of component, unchanged
如你所见,现在逻辑已移动到 Pinia 存储中,组件的代码变得更加简单,如果我们扩展应用程序,我们还可以在其他地方重用该逻辑。
获取器的附加功能
在继续讨论动作之前,让我们快速讨论你可以使用获取器做的两件事。第一是创建一个利用另一个获取器的获取器。你可以通过使用箭头函数并简单地使用状态来实现这一点:
doubleCount: (state) => state.counter * 2,
superDuperState: (state) => state.doubleCount * 999,
如果你使用的是常规函数语法,可以通过 this 或通过传入的参数访问存储:
doubleCount: (state) => state.counter * 2,
superDuperState: (state) => state.doubleCount * 999,
doubleDoubleCount() {
return this.doubleCount * 2;
},
最后,虽然获取器不允许额外的参数,但你可以创建一个返回函数本身的获取器,而不是常规值,如下所示:
countPlusN: (state) => x => Number(state.counter) + Number(x)
在组件中使用此功能时,你需要向 countPlusN 传递一个值,如下所示:
doublePlusN: {{ store.countPlusN(9) }}
注意,以这种方式定义的获取器将不会应用任何缓存。
现在我们已经通过获取器增强了我们的存储,让我们看看动作如何进一步增加 Pinia 的灵活性。
使用 Pinia 动作
动作是 Pinia 的组件方法的等价物。它们允许你为存储定义自定义逻辑,也可以是异步的。这在需要调用服务器端逻辑来验证状态更改时非常有用。动作通过 Pinia 对象的 actions 块定义,你可以在 Pinia 默认创建的存储中看到一个示例:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore({
id: 'counter',
state: () => ({
counter: 0
}),
// rest of store...
actions: {
increment() {
this.counter++
}
}
})
在这个例子中,increment 动作只是简单地取 counter 值并加一。动作通过使用 this 作用域访问状态值,如前所述,也可以是异步的。一个带有一些逻辑的动作示例可能如下所示:
decrement() {
if(this.counter > 0) this.counter--
}
此动作在减少 counter 值之前会进行一些验证,并确保其永远不会低于零。
让我们通过添加一些动作来改进我们的颜色编辑器。
练习 10.03 – 向颜色预览应用程序添加亮度和暗度功能
上一个练习要求你通过将生成十六进制字符串的逻辑移动到 Pinia 存储的获取器中,来改进颜色预览应用程序。在这个练习中,你将添加两个新功能——按钮,可以调整当前颜色的亮度或暗度。
本练习的完整代码可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter10/Exercise10.03 找到。
- 我们将开始使用第三方库来处理我们的颜色逻辑。
pSBC库是由在 Stack Overflow 上帮忙的用户开发的。作者将他的 Stack Overflow 答案转换成了一个迷你库,您可以免费使用。
这段代码在他的 GitHub 上有文档说明,地址为 github.com/PimpTrizkit/PJs/wiki/12.-Shade,-Blend-and-Convert-a-Web-Color-(pSBC.js)。如果您滚动到 src/stores/color.js。一旦复制到商店中,pSBC 函数就可以在商店中使用。
要使十六进制颜色变亮,您传递一个正值——例如,pSBC(0.5, "#0022FF")。数字代表百分比——在这种情况下,50%。要使颜色变暗,您只需传递一个负值:pSBC(-0.5, "#0022FF")。
-
将
pSBC库粘贴到商店中后,向商店添加一个新的actions块:actions: {} -
接下来,添加
lighten函数。这个函数将获取当前的十六进制值(现在有了我们的 getter,这要容易得多!),将其传递给库,然后将结果转换回十进制数字:lighten() {let newHex = pSBC(0.4, this.hex);// parse out hex back to decthis.red = parseInt(newHex.substring(1,3), 16);this.green = parseInt(newHex.substring(3,5), 16);this.blue = parseInt(newHex.substring(5,), 16);}, -
现在,添加
darken函数:darken() {let newHex = pSBC(-0.4, this.hex);// parse out hex back to decthis.red = parseInt(newHex.substring(1,3), 16);this.green = parseInt(newHex.substring(3,5), 16);this.blue = parseInt(newHex.substring(5,), 16);} -
现在商店已经包含了我们需要的操作,让我们添加按钮来使用它们。在
src/components/RGBEdit.vue中,在最后一个标签下面添加以下内容:<p><button @click="store.darken()">Darken</button><button @click="store.lighten()">Lighten</button></p>
每个按钮都会调用商店中的相应操作。当在浏览器中运行并查看时,您可以看到新的用户界面:

图 10.8 – 带有新的变暗和变亮按钮的颜色预览应用程序
要测试功能,只需移动滑块,然后尝试点击按钮。
在 Devtools 中调试 Pinia
在 第三章 中,Vite 和 Vue Devtools,您被介绍了 Vue Devtools。Devtools 是调试和优化 Web 应用程序的一种非常强大的方式,Vue 插件使它们对 Vue 开发者来说更加重要。Vue Devtools 更强大的地方在于自动识别和支持使用 Pinia 的应用程序。
让我们快速浏览一下这种支持的外观,通过使用最后修改于 练习 10.03 的颜色预览应用程序。从命令行运行应用程序,在您的浏览器中打开 URL,并打开开发者工具。注意 图 10.9* 中的右侧的 Pinia 选项卡:

图 10.9 – Vue Devtools 中的 Pinia 支持
立即,您可以看到您有权访问完整的状态以及任何获取器。如果您开始修改 RGB 值,您可以看到它们立即反映出来:

图 10.10 – 状态值在用户与应用交互时更新
如果你将鼠标悬停在状态中的某个值上,你将看到一个铅笔图标和一个三个点的菜单图标。铅笔图标允许你直接编辑状态值,而三个点的菜单则允许你将值复制到剪贴板或路径:

图 10.11 – 编辑或复制状态值的工具
上右上角的图标允许你将整个状态复制到剪贴板,用你的状态内容替换状态,将状态保存到文件系统,或导入一个保存的状态。例如,如果你将状态保存到文件系统,它将看起来像这样:
{"color":{"red":0,"blue":"69","green":"217"}}
如果你点击 时间线 选项卡,你将获得与你的 Pinia 商店相关的更改历史:

图 10.12 – Pinia 修改历史
在 图 10.12 中,你可以看到显示更改的详细信息,包括先前和新的值。你可以点击任何先前的突变来查看历史更改。
希望这能展示出使用 Vue Devtools 的有用性以及 Pinia 的良好集成。确保在尝试解决未来遇到的任何棘手问题时充分利用它!
活动 10.01 – 创建一个简单的购物车和价格计算器
想象一个假设的硬件公司网站,允许员工选择需要运送到办公室的产品。这个购物车比典型的电子商务网站简单得多,因为它不需要处理信用卡,甚至不需要询问人员的位置(IT 知道你在哪里!)。
它仍然需要向你提供一个项目列表,让你选择你想要的数量,然后提供一个总计价格,该价格将记入你的部门账单。
在这个活动中,你需要构建一个代表可用产品和它们价格的 Pinia 商店。你需要多个组件来处理应用程序的不同方面,并正确与商店数据交互:
-
首先,创建一个新的 Pinia 商店。你的商店应该在状态中使用两个值,一个包含硬编码名称和值的数组,以及一个空的购物车数组。以下是一个产品列表的示例:
products: [{ name: "Widgets", price: 10 },{ name: "Doodads", price: 8 },{ name: "Roundtuits", price: 12 },{ name: "Fluff", price: 4 },{ name: "Goobers", price: 7 }], -
你的应用程序将包括三个组件。第一个是
Products组件,用于列出产品。第二个是Cart组件,用于渲染当前的购物车。最后是Checkout组件,它渲染总计以及一个实际上并不起作用的 结账 按钮。 -
Products.vue组件应该渲染每个产品,并有一个按钮来添加和移除它到购物车中。这些按钮应该调用 Pinia 商店中的操作,并从购物车中添加或移除一个项目:

图 10.13 – 产品组件
Cart.vue组件渲染购物车中的项目表格。它应该显示产品的名称和当前数量。如果某项商品的数量降到零,则不应在表格中显示。以下图示展示了这一点:

图 10.14 – 购物车组件
Checkout.vue组件将渲染两个东西。首先,它将渲染总费用。这是基于购物车中的产品和数量。其次,它将渲染一个 结算 按钮,但只有当实际存在费用时才会渲染。结算 按钮不需要做任何事情:

图 10.15 – 结算组件
注意
该活动的解决方案可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter10/Activity10.01 找到。
恭喜!你现在已经构建了一个真实、尽管简单的应用,它利用了 Pinia。你的应用有三个组件,通过 Pinia 的存储保持完美同步,并且你已经掌握了使用官方推荐库进行状态管理的方法。
摘要
本章向您介绍了 Pinia,这是 Vue 推荐的用于处理复杂、多组件应用中共享状态的库。
我们从讨论如何安装 Pinia 开始。接下来,我们介绍了状态,并展示了如何在组件中使用这些值。
你将获取器视为处理 虚拟 属性和封装逻辑的方法。
最后,你看到了如何使用操作定义与你的状态一起工作的自定义方法。
在下一章中,你将了解如何使用 Vue 进行测试,特别是单元测试,这将为你准备下一章的端到端测试。
第四部分:测试和应用部署
在本书的最后部分,我们将深入探讨 Vue.js 应用程序的测试方面。我们将学习测试的基础知识、不同类型的测试以及何时何地需要测试,并开始使用 Jest 测试框架进行单元和快照测试,以及使用 Cypress 进行端到端测试来测试我们的应用程序。通过编写测试,我们将确保我们的应用程序按预期的方式运行。最后一章将涵盖如何将我们的 Vue 应用程序部署到网络。
在本部分,我们将涵盖以下章节:
-
第十一章,单元测试
-
第十二章,端到端测试
-
第十三章,将您的代码部署到网络
第十一章:单元测试
在前面的章节中,我们看到了如何构建合理的复杂 Vue.js 应用程序。本章是关于测试它们以维护代码质量和防止缺陷。我们将探讨对 Vue.js 应用程序进行单元测试的方法,以提高我们应用程序的质量和交付速度。
我们还将探讨使用测试来驱动开发,即测试驱动开发(TDD)。随着我们的进展,我们将了解为什么代码需要测试,以及可以在 Vue.js 应用程序的不同部分采用哪些类型的测试。
本章将涵盖以下主题:
-
理解测试和测试代码的必要性
-
构建你的第一个测试
-
组件测试
-
测试方法
-
测试路由
-
使用 Pinia 测试状态管理
-
快照测试
技术要求
对于本章,除了 git 命令行界面外,没有其他技术要求,您现在应该已经使用过了。您可以在以下位置找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter11
理解测试和测试代码的必要性
测试是确保代码按预期执行的关键过程。高质量的生产软件是经验上正确的。这意味着对于开发人员和测试人员发现的列举案例,应用程序的行为符合预期。
这与经过验证的正确的软件形成对比,这是一个非常耗时的工作,通常是学术研究项目的一部分。我们仍然处于这样一个阶段,即正确的软件(经过验证)仍在构建,以展示可以构建哪些类型的系统,同时受到正确性的限制。
测试可以防止引入缺陷,如错误和回归(即当功能停止按预期工作时)。在下一节中,我们将了解各种测试类型。
不同类型的测试
测试范围从端到端测试(通过操作用户界面)到集成测试,最后到单元测试。
端到端测试
端到端测试测试一切,包括用户界面、底层 HTTP 服务,甚至数据库交互;没有任何东西被模拟。如果您有一个电子商务应用程序,端到端测试实际上可能会使用真实信用卡下订单,或者可能会使用测试信用卡下测试订单。
端到端测试的运行和维护成本很高。它们需要使用通过程序性驱动程序(如 Selenium、WebdriverIO 或 Cypress)控制的完整浏览器。这种类型的测试平台运行成本很高,应用程序代码中的微小变化可能导致端到端测试开始失败。
集成测试
集成测试或系统级测试确保一组系统按预期工作。这通常涉及决定测试的系统所在的范围,并允许它运行,通常是对模拟或存根的上游服务和系统进行测试(因此这些服务和系统不在测试范围内)。
由于外部数据访问被存根,可以减少许多问题,如超时和故障(与端到端测试相比)。集成测试套件通常足够快,可以作为持续集成步骤运行,但完整的测试套件通常不会由工程师在本地运行。
单元测试
单元测试在开发过程中提供快速反馈方面非常出色。单元测试与 TDD(测试驱动开发)相结合是极限编程实践的一部分。单元测试非常适合测试复杂的逻辑或从预期的输出构建系统。单元测试通常足够快,可以在将代码提交审查和持续集成测试之前运行开发者的代码。
以下图表是对测试金字塔的解释。它可以理解为你应该有大量的低成本、快速的单元测试,合理数量的系统测试,以及仅仅几个端到端 UI 测试:

图 11.1 – 测试金字塔图
既然我们已经讨论了为什么我们应该测试应用程序,那么让我们开始编写一些测试。
构建你的第一个测试
为了说明在 Vue 3 项目中开始自动化测试有多快、有多简单,我们将首先创建一个简单的测试,使用 Vitest (vitest.dev/),这是 Vue 3 的官方推荐测试框架,也是开始时最简单的,因为新应用程序的安装步骤允许你立即选择它。
在下面的图中,你可以看到安装 Vitest 的提示:

图 11.2 – 创建应用程序并选择是使用 Vitest
应用程序搭建完成后,你将发现它已经在 components 目录下创建了一个 __tests__ 文件夹,并创建了一个测试文件。不过,现在请删除该文件(但不要删除文件夹),并在项目根目录下直接创建一个新的 __tests__ 文件夹。接下来,创建一个 App.test.js 文件。
我们将使用 shallowMount 来渲染应用程序并测试它是否显示 The Vue.js Workshop Blog。shallowMount 进行的是 浅渲染,这意味着只有组件的最顶层被渲染;所有子组件都被模拟。
这对于单独测试组件很有用,因为子组件的实现并未运行:
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import App from '../src/App.vue'
describe('App', () => {
it('App renders blog title correctly', () => {
const wrapper = shallowMount(App)
expect(wrapper.text()).toContain('The Vue.js Workshop
Blog')
})
})
保存此文件,然后在终端(确保你已经运行了 npm install 以完成新应用程序的创建),运行 npm run test:unit。
当你在安装提示中表明你想要包含 Vitest 时,它将以下脚本添加到 package.json 文件中:
"test:unit": "vitest --environment jsdom"
你将获得关于测试运行及其立即失败的报告:

图 11.3 – 单元测试运行失败
要使测试通过,我们可以编辑我们的App.vue文件以包含我们想要的标题(注意,我们还删除了默认创建的大部分代码):
<script setup>
</script>
<template>
<header>
<h1>The Vue.js Workshop Blog</h1>
</header>
</template>
<style>
</style>
保存文件后,你会立即看到结果:

图 11.4 – 测试通过!
你刚刚完成了你的第一个 TDD。这个过程从编写一个失败的测试开始。随后是对测试代码(在这种情况下是App.vue组件)的更新,这使得失败的测试通过。
TDD 过程让我们有信心我们的功能已经被正确测试,因为我们可以看到在我们更新驱动我们功能的代码之前,测试是失败的。
在下一节中,我们将展示如何将我们所学应用到 Vue 组件中。
测试组件
组件是 Vue.js 应用程序的核心。使用 Vitest 编写单元测试非常简单。拥有测试来锻炼大多数组件可以让你有信心它们按设计运行。理想的组件单元测试运行快速且简单。
我们将继续构建博客应用程序示例。我们现在已经构建了标题,但一个博客通常还需要一个帖子列表来显示。
我们将创建一个PostList组件。目前,它将只渲染一个div包装器并支持一个posts数组属性:
<script setup>
defineProps({
posts: {
type: Array,
default: () => []
}
})
</script>
<template>
<div>
</div>
</template>
我们可以在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组件:
<script setup>
import PostList from './components/PostList.vue'
</script>
<template>
<header>
<h1>The Vue.js Workshop Blog</h1>
</header>
<PostList :posts="posts" />
</template>
我们的PostList组件将渲染每个帖子到一个PostListItem组件中,我们将按照以下方式创建它。
PostListItem接受两个属性:title(它是一个字符串)和description(也是一个字符串)。它分别用h3标签和p标签渲染它们:
<script setup>
defineProps({
title: {
type: String
},
description: {
type: String
}
})
</script>
<template>
<div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
</template>
我们现在需要遍历帖子并在PostList.vue组件中渲染一个带有相关属性的PostListItem组件:
<script setup>
import PostListItem from './PostListItem.vue';
defineProps({
posts: {
type: Array,
default: () => []
}
})
</script>
<template>
<div>
<PostListItem v-for="post in posts"
:key="post.slug"
:title="post.title"
:description="post.description"/>
</div>
</template>
为了测试PostListItem组件,我们可以使用一些任意的标题和描述属性进行浅渲染,并检查它们是否被渲染。在src/__tests__目录下添加一个名为PostListItem.test.js的新文件:
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils';
import PostListItem from '../components/PostListItem.vue';
describe('PostListItem', () => {
it('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命令单独运行(如图 11.5.5 所示):

图 11.5 – PostListItem测试输出
接下来,我们将看到浅渲染的一个陷阱。在测试PostList组件时,我们所能做的就是测试它渲染的PostListItem组件的数量。
将此测试保存为__tests__/PostList.test.js:
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils';
import PostList from '../src/components/PostList.vue';
import PostListItem from '../src/components/PostListItem.vue';
describe('PostList', () => {
it('PostList renders the right number of PostListItem',
() => {
const wrapper = shallowMount(PostList, {
propsData: {
posts: [
{
title: "Blog post title",
description: "Blog post description"
}
]
}
})
expect(wrapper.findAllComponents(PostListItem))
.toHaveLength(1);
})
})
这通过了,但我们测试的是用户不会直接与之交互的内容,即 PostList 中渲染的 PostListItem 实例的数量,如下面的屏幕截图所示:

图 11.6 – PostList 测试输出
一个更好的解决方案是使用 mount 函数,它渲染完整的组件树,而 shallowMount 函数只会渲染正在渲染的组件的子组件。使用 mount,我们可以断言标题和描述被渲染到页面上。
这种方法的缺点是我们在测试 PostList 组件和 PostListItem 组件,因为 PostList 组件不会渲染标题或描述;它渲染一组 PostListItem 组件,这些组件反过来渲染相关的标题和描述。
代码如下:
import { describe, it, expect } from 'vitest'
import { shallowMount, mount } from '@vue/test-utils';
import PostList from '../src/components/PostList.vue';
import PostListItem from '../src/components/PostListItem.vue';
describe('PostList', () => {
// Previous test…
it('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.test.js 命令的输出所示,新的测试通过:

图 11.7 – 对 PostList 进行浅渲染和挂载测试的测试运行
我们已经看到了如何使用 Vitest 和 vue-test-utils 为 Vue.js 编写单元测试。这些测试可以经常运行,并且测试运行在几秒内完成,这在我们处理新或现有组件时提供了几乎即时的反馈。
练习 11.01:构建和单元测试标签列表组件
在创建 posts 的 fixture 时,我们用 vue、angularjs 和 react 填充了标签字段,但没有显示它们。
此练习的完整代码可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter11/Exercise11.01找到
要使标签有用,我们将在帖子列表中显示标签:
- 我们可以开始编写一个单元测试,该测试将解释当将一组标签作为属性传递给
PostListItem组件时,我们期望该组件执行的操作。它期望每个标签都将带有前缀的哈希符号渲染出来。
例如,react 标签将显示为 #react。在 __tests__/PostListItem.test.js 文件中,我们可以添加一个新的测试:
// rest of test and imports
it('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 命令运行时,此测试失败,如图 图 11.8 所示:

图 11.8 – PostListItem 标签测试失败
-
接下来,我们应该在
src/components/PostListItem.vue中实现标签列表渲染。我们将添加标签作为Array类型的属性,并使用v-for来渲染标签:<script setup>defineProps({title: {type: String},description: {type: String},tags: {type: Array,default: () => []}})</script><template><div><h3>{{ title }}</h3><p>{{ description }}</p><ul><liv-for="tag in tags":key="tag">#{{ tag }}</li></ul></div></template>
在实现了 PostListItem 组件后,单元测试现在应该通过:

图 11.9 – PostListItem 单元测试通过
然而,标签在应用程序中不会显示:

图 11.10 – 尽管实现了正确的 PostListItem 实现,但 PostList 仍然没有显示标签
- 我们可以编写一个针对
PostList的单元测试,以展示这种行为。本质上,我们将向我们的posts列表传递一些标签,并运行PostListItem.test.js文件中已经存在的相同断言。
我们将在 __tests__/PostList.test.js 中这样做:
it('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')
})
根据我们的应用程序输出显示在 图 11.11 中,当使用 npm run test:unit 命令运行 __tests__/PostList.test.js 时,测试失败:

图 11.11 – PostList 标签测试失败
-
为了修复这个测试,我们可以在
src/components/PostList.vue中找到问题,其中PostListItem的标签属性未绑定。通过将src/components/PostList.vue更新为绑定tags属性,我们可以修复单元测试:<!-- rest of template --><PostListItem v-for="post in posts":key="post.slug":title="post.title":description="post.description":tags="post.tags"/>
失败的单元测试现在通过了,如下面的截图所示。

图 11.12 – PostList 标签测试通过
我们已经看到了如何使用浅渲染和组件挂载来测试渲染组件的输出。让我们简要了解这些术语的含义:
-
1,这意味着如果子项是组件,它们将仅作为组件标签渲染;它们的模板将不会运行 -
挂载:以与在浏览器中渲染相似的方式渲染完整的组件树
接下来,我们将探讨如何测试组件方法。
测试组件方法
在 Vue 的上一个版本中,建议对过滤器进行测试,对混入进行测试,但通常避免编写方法的测试,因为它们不是由用户直接调用的。
在 Vue 3 中,过滤器已经弃用,并被 常规 方法计算属性所取代。话虽如此,找到适合测试的方法可能需要一些思考。
考虑一个截断其输入为八个字符的 computed 属性:
// rest of file…
import { computed } from 'vue';
const props = defineProps({
title: {
type: String
},
description: {
type: String
},
tags: {
type: Array,
default: () => []
}
})
const truncated = computed(() => {
return props.description && props.description.slice(0,8)
})
defineExpose({ truncated })
在前面的代码示例中,truncated 被定义为基于传递给属性的 description 值的 computed 属性。最后,defineExpose 被用来使属性可用于测试。在 script setup 中指定的项被认为是 封闭的,并且不在组件本身之外可用。通过使用 defineExpose,我们就可以编写针对 truncated 的测试。
我们可以通过两种方式测试 computed 属性的逻辑。首先,较长的字符串应该被截断。其次,较短的字符串应按原样返回。
这里是添加到 PostListItem.test.js 的附加测试:
it('truncated properly returns only the first 8 characters', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Blog post description"
}
})
expect(wrapper.vm.truncated).toMatch('Blog pos')
})
it('truncated properly doesnt change shorter values', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Test"
}
})
expect(wrapper.vm.truncated).toMatch('Test')
})
第一个新测试传入一个长的描述值,并确认截断后的版本更短。注意使用vm来访问组件的 Vue 实例,然后是truncated计算属性。下一个测试确认如果使用较短的值,截断不会缩短它。
记住,用户实际上不会直接调用truncated。作为直接测试计算属性的替代,我们可以确认任何模板使用都正常工作。在这种情况下,使用wrapper.text()来返回渲染结果是有意义的。
练习 11.02:构建和测试省略号方法
我们已经看到了如何测试任意的truncated计算方法;我们现在将实现一个ellipsis计算方法并对其进行测试。
本练习的完整代码可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter11/Exercise11.02找到。
- 我们可以从编写一组测试用例开始,用于
ellipsis计算方法(它将位于src/components/PostListItem.vue中)。一个测试应该检查如果传入的值少于 50 个字符,过滤器不会做任何事情;另一个测试应该检查传入的值是否超过 50 个字符,如果是,则截断值为 50 并附加…。
我们将在一个__tests__/ellipsis.test.js文件中完成这项工作:
// rest of script
describe('ellipsis', () => {
it('ellipsis should do nothing if value is less
than 50 characters', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Test"
}
})
expect(wrapper.vm.truncated).toMatch('Test')
})
it('ellipsis should truncate to 50 and append "..."
when longer than 50 characters', () => {
const wrapper = shallowMount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Should be more than
the 50 allowed characters by a small amount"
}
})
expect(wrapper.vm.truncated).toMatch('Should be
more than the 50 allowed characters by a...')
})
})
-
我们现在可以在
src/components/PostListItem.vue中实现ellipsis的逻辑。我们将添加一个带有ellipsis的computed对象,如果传入的值超过 50 个字符,它将使用String#slice,否则不做任何事情:<script setup>// rest of scriptconst ellipsis = computed(() => {return props.description && props.description.length> 50 ? `${props.description.slice(0,50)}...` :props.description;})defineExpose({ truncated, ellipsis })</script><template><div><h3>{{ title }}</h3><p>{{ ellipsis }}</p><ul><li v-for="tag in tags" :key="tag">#{{ tag }}</li></ul></div></template>
如您所见,ellipsis计算方法作用于description属性,并处理超过 50 个字符的值。测试现在如图 11**.13所示通过:

图 11.13 – 省略号测试现在通过
我们已经看到了如何测试 Vue.js 组件的方法和计算属性。
接下来,我们将看到如何处理使用 Vue.js 路由的应用程序。
测试 Vue 路由
我们目前有一个渲染我们的博客主页或视图的应用程序。
接下来,我们应该有帖子页面。为此,我们将使用 Vue Router,如前几章所述,并确保我们的路由通过单元测试按设计工作。
Vue Router 是通过npm安装的,具体来说,npm install vue-router@4,然后在main.js文件中进行连接:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router';
createApp(App).use(router).mount('#app')
接下来,我们可以在src/router/index.js中创建一个文件来定义我们的路由。这将实例化路由并定义我们的初始路径。我们将从一个根路径(/)开始,以显示PostList组件:
import { createRouter, createWebHistory } from
'vue-router';
import PostList from '@/components/PostList.vue';
const routes = [
{
path: '/',
component: PostList
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
现在我们已经有了初始路由,我们应该更新 App.vue 文件以利用由路由器渲染的组件。我们将渲染 render-view 而不是直接使用 PostList。然而,posts 绑定保持不变:
<!—rest of file… -->
<template>
<header>
<h1>The Vue.js Workshop Blog</h1>
</header>
<router-view :posts="posts"></router-view>
</template>—-- rest of file... -->
现在,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: 'This is the content of the Vue.js for
React developers post.',
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: 'This is the content of the Vue.js for
AngularJS developers post.',
tags' ['vue', 'angularjs']
}
]
}
}
}
</script>
现在,我们可以开始工作在 SinglePost 组件上。目前,我们只是在模板中添加一些占位符。此外,SinglePost 将接收帖子作为 prop,因此我们也可以填写它:
<script setup>
defineProps({
posts: {
type: Array,
default: () => []
}
})
</script>
<template>
<div>
<h2>Post: RENDER ME</h2>
<p>Placeholder for post.content.</p>
</div>
</template>
接下来,我们在 router/index.js 中注册 SinglePost,使用 /:postId 路径(该路径将在组件的 this.$route.params.postId 下可用):
import { createRouter, createWebHistory } from
'vue-router';
import PostList from '@/components/PostList.vue';
import SinglePost from '@/components/SinglePost.vue';
const routes = [
{
path: '/',
component: PostList
},
{
path: '/:postId',
component: SinglePost
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
如果我们切换回实现 SinglePost 组件,我们将访问 postId,它将映射到 posts 数组中的 slug,并且我们也访问到 posts,因为它被 App 绑定到 render-view。
现在,我们可以创建一个计算属性 post,它根据 postId 查找帖子:
// other imports
import { useRoute } from 'vue-router';
// props code
const route = useRoute();
const post = computed(() => {
const { postId } = route.params;
return props.posts.find(p => p.slug === postId);
})
从这个计算 post 属性中,如果我们有 post(我们必须注意不存在的帖子),我们可以提取 title 和 content。因此,仍然在 SinglePost 中,我们可以添加以下计算属性:
const title = computed(() => {
return post && post.value.title;
})
const content = computed(() => {
return post && post.value.content;
})
然后,我们可以用计算属性的值替换模板中的占位符。因此,我们的模板最终如下所示:
<template>
<div>
<h2>Post: {{ title }}</h2>
<p>{{ content }}</p>
</div>
</template>
现在让我们更新应用程序,以便我们可以链接到单个帖子。在 PostList.vue 中,将 slug 作为新的属性传递:
<!-- rest of template -->
<PostListItem v-for="post in posts"
:key="post.slug"
:title="post.title"
:description="post.description"
:tags="post.tags"
:slug="post.slug"
/>
<!-- rest of template -->
接下来,在 PostListItem 中,我们首先添加一个新的 slug 属性:
// rest of the props...
slug: {
type: String
}
然后我们编辑模板以与 slug 属性链接:
<template>
<div>
<router-link :to="`/${slug}`">
<h3>{{ title }}</h3>
</router-link>
<p>{{ ellipsis }}</p>
<ul>
<li v-for="tag in tags" :key="tag">
#{{ tag }}
</li>
</ul>
</div>
</template>
router-link 是 Vue Router 特定的链接,这意味着在 PostList 页面上,点击帖子列表项时,我们将被带到正确的帖子 URL,如下面的截图所示:

图 11.14 – 浏览器中显示的帖子列表视图
点击标题后,显示正确的帖子:

图 11.15 – 在浏览器中显示的单个帖子视图
要测试 vue-router,我们需要构建我们的测试来处理路由的异步特性。我们将从测试点击一个帖子是否正确加载单个帖子的信息开始。我们可以通过查找初始页面上的所有博客帖子,并在点击路由时只查找一个特定的帖子来实现这一点:
import { describe, it, expect } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils'
import App from '../src/App.vue';
import router from "@/router";
describe('SinglePost', () => {
it('Router renders single post page when clicking a post
title', async () => {
router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.text()).toMatch("Vue.js for React
developers");
expect(wrapper.text()).toMatch("Migrating an AngularJS
app to Vue.js");
await wrapper.find('a').trigger('click');
await flushPromises();
expect(wrapper.text()).toMatch("Vue.js for React
developers");
expect(wrapper.text()).not.toMatch("Migrating an
AngularJS app to
Vue.js2");
})
})
在顶部,我们导入 mount 和一个新工具 flushPromises,我们将在稍后使用。我们还导入了我们的路由。在测试中,我们首先导航到根路径,如前所述,由于路由的异步特性,我们等待它完成。
然后,我们检查两个博客帖子。之后,我们在第一个帖子上触发一个点击事件,等待它完成flushPromises,然后检查是否只渲染了我们的第一个帖子。
我们应该检查直接导航到有效的帖子 URL 将产生正确的结果。为了做到这一点,我们将使用router.replace('/')清除任何设置的状态,然后使用带有帖子短语的router.push()。然后我们将使用类似的断言来确保我们只渲染一个帖子:
It('Router renders single post page when a slug is set',
async () => {
await router.replace('/');
await router.push('/vue-react');
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.text()).toMatch("Vue.js for React
developers");
expect(wrapper.text()).not.toMatch("Migrating an
AngularJS app to
Vue.js");
})
当使用npm run test:unit __tests__/SinglePost.test.js命令运行这两个测试时,它们按预期工作。以下截图显示了所需的输出:

图 11.16 – 单个帖子路由测试通过
现在你已经看到了如何测试你的路由,让我们用一个例子来练习一下。
练习 11.03:构建标签页面并测试其路由
就像我们构建单个帖子页面一样,我们现在将构建一个标签页面,它类似于PostList组件,除了只显示具有特定标签的帖子,每个帖子都是一个链接到相关单个帖子视图。
-
我们可以从在
src/components/TagPage.vue中创建一个新的TagPage组件开始。我们知道它将接收posts作为属性,并且我们想要渲染一个PostList组件:<script setup>import PostList from './PostList.vue';defineProps({posts: {type: Array,default: () => []}})</script><template><h3>#INSERT_TAG_NAME</h3></template> -
接下来,我们想在
src/router.js中将TagPage组件连接到路由器。我们将导入它并将其作为routes的一部分添加,路径为/tags/:tagName:// other importsimport TagPage from '@/components/TagPage.vue';const routes = [// other routes{path:'/tags/:tagName',component: TagPage}];// router instantiation and export -
在
TagPage.vue中,我们现在可以使用tagName参数并创建一个tagName计算属性以及一个基于标签的tagPosts计算属性。import { computed } from 'vue';import { useRoute } from 'vue-router';const props = defineProps({posts: {type: Array,default: () => []}})const route = useRoute();const tagName = computed(() => {return route.params.tagName;})const tagPosts = computed(() => {return props.posts.filter(p =>p.tags.includes(route.params.tagName));}) -
现在我们有了对
tagPosts和tagName的访问权限,我们可以替换模板中的占位符。我们将渲染#{{ tagName }}并将tagPosts绑定到PostList的posts属性:<template><h3># {{ tagName }}</h3><PostLists :posts="tagPosts" /></template>
现在,如果我们导航到例如/tags/angularjs,页面将显示如下内容:

图 11.17 – angularjs 的标签页面
-
下一步是将
PostListItem中的标签锚点(a)转换为指向/tags/${tagName}的router-link(在src/components/PostListItem.vue中):<router-link :to="`/tags/${tag}`"v-for="tag in tags" :key="tags">#{{ tag }}</router-link> -
现在是时候编写一些测试了。我们首先检查在主页上点击
angularjs标签页面的情况。我们将在__tests__/TagPage.test.js中这样编写:// rest of test...describe('TagPage', () => {it('Router renders tag page when clicking a tag inthe post list item', async () => {router.push('/');await router.isReady();const wrapper = mount(App, {global: {plugins: [router]}})expect(wrapper.text()).toMatch("Vue.js for Reactdevelopers");expect(wrapper.text()).toMatch("Migrating anAngularJS app toVue.js");await wrapper.find('a[href="/tags/angularjs"]').trigger('click');await flushPromises();expect(wrapper.text()).toMatch("Migrating anAngularJS app toVue.js");expect(wrapper.text()).not.toMatch("Vue.js forReactdevelopers");})}) -
我们还应该测试直接访问标签 URL 是否按预期工作;也就是说,我们看不到不相关的内容:
// rest of test...it('Router renders tag page when a URL is set',async () => {await router.replace('/');await router.push('/tags/angularjs');const wrapper = mount(App, {global: {plugins: [router]}})expect(wrapper.text()).toMatch("Migrating anAngularJS app toVue.js");expect(wrapper.text()).not.toMatch("Vue.js forReactdevelopers");})
测试通过,因为应用程序按预期工作。因此,输出将如下所示:

图 11.18 – TagPage 路由测试在命令行上通过
然而,在继续之前,让我们运行所有的单元测试。你会注意到,虽然我们的测试通过了,但现在有各种警告:

图 11.19 – 关于 router-link 和 router-view 的警告
由于这些警告不会使我们的测试失败,我们应该移除它们。我们有一些处理这个问题的方法。
一种方法是通过简单地模拟,或伪造,我们不需要的组件。在这种情况下,我们希望我们的测试忽略我们当时没有测试的路由组件。我们可以通过使用 mount 和 shallowMount 都支持的一个选项 stubs 来解决这个问题。通过使用 stubs 选项,我们告诉 Vue 的测试工具模拟或创建一组标签的空组件。
我们在新的全局参数中添加此选项,以供 mount 或 shallowMount 使用。以下是在 ellipsis.test.js 中的示例:
const wrapper = shallowMount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Test"
},
global: {
stubs:['router-link'],
}
})
一旦在 ellipsis.test.js 中的两个测试中添加,这些警告就会消失。接下来,我们将修复 App.test.js:
describe('App', () => {
it('App renders blog title correctly', () => {
const wrapper = shallowMount(App, {
global: {
stubs:['router-link','router-view'],
}
})
expect(wrapper.text()).toContain('The Vue.js Workshop
Blog')
})
})
注意我们还模拟了 router-view。接下来,我们将修复 PostList.test.js 和 PostListItem.test.js。这两个测试实际上使用了 router-link,因此我们无法模拟它们,但我们可以将它们作为插件提供给 mount 和 shallowMount。在 PostList.test.js 中,我们首先导入我们的路由器:
import router from '@/router';
然后在三个测试中的每一个中,将路由器作为 global 对象中的一个插件传递,例如:
const wrapper = mount(PostList, {
propsData: {
posts: [
{
tags: ['react', 'vue']
},
{
tags: ['html', 'angularjs']
}
]
},
global: {
plugins: [ router ]
}
})
接下来,我们可以更新 PostListItem.test.js,但在这里我们需要进行另一个更改。之前这个测试使用了 shallowMount,但我们需要切换到 mount 以确保 router-link 正确渲染其输出。以下是包含插件更改和切换到 mount 的整个测试:
// rest of test...
describe('PostListItem', () => {
it('PostListItem renders title and description
correctly', () => {
const wrapper = mount(PostListItem, {
propsData: {
title: "Blog post title",
description: "Blog post description"
},
global: {
plugins: [ router ]
}
})
expect(wrapper.text()).toMatch("Blog post title")
expect(wrapper.text()).toMatch("Blog post description")
})
it('PostListItem renders tags with a # prepended to
them', () => {
const wrapper = mount(PostListItem, {
propsData: {
tags: ['react', 'vue']
},
global: {
plugins: [ router ]
}
})
expect(wrapper.text()).toMatch('#react')
expect(wrapper.text()).toMatch('#vue')
})
})
到目前为止,我们的警告已经解决。我们已经看到了如何实现和测试一个包含 vue-router 的应用程序。在下一节中,我们将详细了解如何测试 Pinia。
使用 Pinia 测试状态管理
为了展示如何测试一个依赖于 Pinia(Vue 的官方全局状态管理解决方案)的组件,我们将实现并测试一个通讯订阅横幅。
首先,我们应该创建横幅模板。横幅将包含一个订阅通讯的调用操作和一个关闭按钮。
<script setup>
</script>
<template>
<div>
<strong>Subscribe to the newsletter</strong>
<button>Close</button>
</div>
</template>
<style scoped>
div {
background-color: #c0c0c0;
size: 100%;
padding: 10px;
}
div button {
float: right;
}
</style>
我们可以在 App.vue 文件中如下显示 NewsletterBanner 组件:
<script setup>
import NewsletterBanner from './components/NewsletterBanner.vue';
</script>
<template>
<NewsletterBanner />
<header>
<h1>The Vue.js Workshop Blog</h1>
</header>
<router-view :posts="posts"></router-view>
</template>
<!-- rest of template -->
然后,我们将使用 npm install –save pinia 命令安装 Pinia。一旦 Pinia 安装完成,我们可以在 store.js 文件中初始化我们的商店,如下所示:
import { defineStore } from 'pinia'
export const userPreferencesStore = defineStore({
id: 'userPreferences',
state: () => ({
}),
getters: {
},
actions: {
}
})
我们的 Pinia 商店也已在 main.js 文件中注册:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router';
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.mount('#app');
为了决定通讯横幅是否应该显示,我们需要在我们的商店中添加一个初始状态:
state: () => ({
dismissedSubscriberBanner: false
}),
要关闭横幅,我们需要一个将 dismissedSubscribeBanner 设置为 true 的动作:
actions: {
dismissSubscriberBanner() {
this.dismissedSubscriberBanner = true;
}
}
现在,我们可以使用商店状态和 dismissSubscribeBanner 动作来决定是否显示横幅(使用 v-if)以及是否关闭它(绑定到关闭按钮的点击):
<script setup>
import { computed } from 'vue';
import { userPreferencesStore } from '@/store.js';
const store = userPreferencesStore();
const showBanner = computed(() => {
return !store.dismissedSubscriberBanner;
})
</script>
<template>
<div v-if="showBanner">
<strong>Subscribe to the newsletter</strong>
<button @click="store.dismissSubscriberBanner()">
Close</button>
</div>
</template>
<!-- rest of template -->
在这一点上,横幅在浏览器中的样子如下:

图 11.20 – 浏览器中显示的新闻通讯横幅
在我们编写单元测试之前,查看我们的现有测试是否工作可能是个好主意。(而且你可能已经猜到了,这会引出一些东西。)如果你继续运行所有测试,你会看到一系列全新的问题。
在接下来的第一个图中,请注意警告:

图 11.21 – 与 Pinia 相关的测试失败
如你所见,我们现在有两个测试因为 Pinia 而失败。正如我们看到的 Vue Router 的问题一样,我们需要做一些工作来确保我们的测试不会仅仅因为添加了 Pinia 就抛出错误。首先,我们将通过npm安装一些特定的 Pinia 测试工具:
npm install --save @pinia/testing
这使我们能够导入一个实用工具来帮助测试 Pinia 存储,同时帮助处理新抛出的错误。在SinglePost.test.js中,导入 Pinia 的测试工具:
import { createTestingPinia } from '@pinia/testing'
然后,就像我们通过插件传递 Vue Router 一样,我们也将传递一个针对测试的 Pinia 版本:
const wrapper = mount(App, {
global: {
plugins: [router,
createTestingPinia({createSpy:vi.fn})]
}
})
createSpy参数用于模拟动作调用并使用vi.fn。在我们的单元测试的第一行中,我们可以像这样修改从vitest的导入:
import { describe, it, expect, vi } from 'vitest';
更新SinglePost.test.js中所有包装器的plugins属性,并对TagPage.test.js重复相同的修复。
下一个检查应该是,如果商店有dismissedSubscriberBanner: true,则横幅不应显示。这是通过使用createTestingPinia的initialState功能来实现的。它允许你根据我们商店的 ID 值定义初始状态值。
it('Newsletter Banner should not display if store is initialised with it dismissed', () => {
const wrapper = shallowMount(NewsletterBanner, {
global: {
plugins: [createTestingPinia({
initialState: {
userPreferences: {
dismissedSubscriberBanner: true
}
},
createSpy:vi.fn
})]
}
})
expect(wrapper.text()).not.toMatch("Subscribe to the
newsletter");
})
我们将要编写的最后一个测试是为了确保点击横幅的关闭按钮会触发对商店的操作。我们可以通过检查在点击关闭按钮时是否被调用来实现这一点:
it('Newsletter Banner should hide on "close" button
click', async () => {
const wrapper = shallowMount(NewsletterBanner, {
global: {
plugins: [createTestingPinia(
{ createSpy:vi.fn })]
}
})
const store = userPreferencesStore();
await wrapper.find('button').trigger('click');
expect(store.dismissSubscriberBanner)
.toHaveBeenCalledTimes(1);
})
当使用npm run test:unit __tests__/NewsletterBanner.test.js命令运行测试时,测试将会通过,如下所示:

图 11.22 – NewsLetterBanner 的单元测试在命令行上通过
现在你已经有机会使用 Pinia 状态管理和测试了,让我们现在进行一个练习来展示你所学的知识。
练习 11.04:构建和测试一个饼干声明横幅
现在,我们将探讨如何使用 Pinia 实现一个饼干声明横幅以及如何对其进行测试。我们将在 Pinia 中存储是否显示饼干横幅(默认为true);当横幅关闭时,它将更新为false。
-
创建一个带有加粗的
Cookies Disclaimer标题的绿色 cookie 横幅,免责声明和我同意按钮。我们将在src/components/CookieBanner.vue中创建这个:<template><div><strong>Cookies Disclaimer</strong>We use cookies to improve our experience.<button>I agree</button></div></template><style scoped>div {background-color: green;size: 100%;padding: 10px;margin-top: 50px;}div button {float: right;}</style> -
接下来,我们需要在
src/App.vue中导入并渲染CookieBanner以下的内容:<script setup>import NewsletterBanner from './components/NewsletterBanner.vue';import CookieBanner from './components/CookieBanner.vue';</script><template><NewsletterBanner /><header><h1>The Vue.js Workshop Blog</h1></header><router-view :posts="posts"></router-view><CookieBanner /></template><!-- rest of template --> -
添加一个状态值来控制是否显示 cookie 横幅。在我们的 Pinia store 中,我们将
acceptedCookie初始化为false:// rest of Pinia store...state: () => ({dismissedSubscriberBanner: false,acceptedCookie: false}),// rest of Pinia store... -
我们还需要一个
acceptCookie动作来关闭横幅:// rest of Pinia store...actions: {dismissSubscriberBanner() {this.dismissedSubscriberBanner = true;},acceptCookie() {this.acceptedCookie = true;}}// rest of Pinia store... -
接下来,我们将暴露 store 状态作为
acceptedCookie计算属性:<script setup>import { computed } from 'vue';import { userPreferencesStore } from '@/store.js';const store = userPreferencesStore();const acceptedCookie = computed(() => {return store.acceptedCookie;})</script> -
如果尚未接受 cookies,我们将使用
v-if显示横幅。当点击 我同意 按钮时,将调用 store 动作关闭横幅:<template><div v-if="!acceptedCookie"><strong>Cookies Disclaimer</strong>We use cookies to improve our experience.<button @click="store.acceptCookie">I agree</button></div></template>
现在,我们有一个在点击 我同意 之前显示的 cookie 横幅,如下面的截图所示:

图 11.23 – 浏览器中显示的 Cookie 横幅
-
我们现在将编写一个测试来检查
CookieBanner默认显示:import { describe, it, expect, vi } from 'vitest'import { shallowMount } from '@vue/test-utils';import CookieBanner from '../src/components/CookieBanner.vue';import { createTestingPinia } from '@pinia/testing'import { userPreferencesStore } from '@/store.js';describe('CookieBanner', () => {it('Cookie Banner should display if store isinitialized with it not dismissed', () => {const wrapper = shallowMount(CookieBanner, {global: {plugins:[createTestingPinia({createSpy:vi.fn})]}})expect(wrapper.text()).toMatch("CookiesDisclaimer");})}) -
我们还将编写一个测试来检查 store 中的
acceptedCookie是否为true,如果是,则不会显示 cookie 横幅:it('Cookie Banner should not display if store is initialised with it dismissed', () => {const wrapper = shallowMount(CookieBanner, {global: {plugins: [createTestingPinia({initialState: {userPreferences: {acceptedCookie: true}},createSpy:vi.fn})]}})expect(wrapper.text()).not.toMatch("CookiesDisclaimer");}) -
最后,我们想要检查当触发
acceptCookie动作时:it('Cookie Banner should hide on "I agree" button click', async () => {const wrapper = shallowMount(CookieBanner, {global: {plugins:[createTestingPinia({ createSpy:vi.fn })]}})const store = userPreferencesStore();await wrapper.find('button').trigger('click');expect(store.acceptCookie).toHaveBeenCalledTimes(1);})
当我们使用 npm run test:unit __tests__/CookieBanner.test.js 运行时,我们编写的三个测试都通过了,如下所示:

图 11.24 – Cookie 横幅测试通过
我们已经看到了如何测试依赖于 Pinia 进行状态和更新的组件。接下来,我们将探讨快照测试,看看它是如何简化渲染输出的测试的。
快照测试
快照测试提供了一种为快速变化的代码片段编写测试的方法,而不需要将断言数据与测试内联。快照的变化反映了输出的变化,这在代码审查中非常有用。
例如,我们可以在 PostList.test.js 文件中添加一个快照测试:
it('PostList renders correctly', () => {
const wrapper = mount(PostList, {
propsData: {
posts: [
{
title: "Title 1",
description: "Description 1"
},
{
title: "Title 2",
description: "Description 2"
}
]
},
global: {
plugins: [ router ]
}
})
expect(wrapper.text()).toMatchSnapshot();
});
第一次运行此测试时,将快照文件写入 __tests__/__snapshots__:
// Vitest Snapshot v1
exports[`PostList > PostList renders tags for each post 2 1`] = `"Title 1Description 1Title 2Description 2"`;
这使得快速看到更改在具体输出方面的含义变得容易。
我们已经看到了如何使用快照测试。接下来,我们将把本章中我们所学到的所有工具结合起来,添加一个新页面。
活动 11.01:添加一个简单的按标题搜索页面并包含测试
我们已经构建了一个帖子列表页面、单个帖子视图页面和按标签分类的帖子页面。在博客上重新展示旧内容的一个好方法是通过实现良好的搜索功能。我们将向 PostList 页面添加搜索功能:
-
在新文件
src/components/SearchForm.vue中创建一个带有输入和按钮的搜索表单。 -
现在,我们将通过导入并在
src/App.vue上渲染表单来使表单显示:
现在我们可以按照以下方式在应用程序中搜索搜索表单:

图 11.25 – 带有搜索表单的帖子视图
-
现在,我们准备为搜索表单添加一个快照测试。在
__tests__/SearchForm.test.js中,我们应该添加SearchForm should matchexpected HTML。 -
我们希望使用
v-model跟踪搜索表单输入的内容,以双向绑定searchTerm实例变量和输入内容。 -
当提交搜索表单时,我们需要更新 URL 以包含正确的参数。这可以通过
this.$router.push()完成。我们将把搜索存储在q查询参数中。 -
我们希望反映
q查询参数的状态在搜索表单输入中。我们可以通过从this.$route.query中读取q并将其设置为SearchForm组件状态中searchTerm数据字段的初始值来实现这一点。 -
接下来,我们希望过滤传递给主页上
PostList的帖子。我们将使用route.query.q在一个计算属性中过滤帖子标题。这个新的计算属性将替代src/App.vue中的帖子。 -
接下来,我们应该添加一个测试,更改搜索查询参数,并检查应用程序是否显示正确的结果。为此,我们可以导入
src/App.vue和@/router.js,并使用存储和路由渲染应用程序。然后我们可以更新搜索字段的内容。最后,我们可以通过点击测试 ID 为Search的元素(这是搜索按钮)来提交表单。
注意
这个活动的解决方案可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter11/Activity11.01 找到。
摘要
在本章中,我们探讨了测试不同类型 Vue.js 应用程序的不同方法。
通常,测试对于实证地展示系统正在工作是有用的。单元测试是构建和维护成本最低的,应该是测试功能的基础。系统测试是测试金字塔的下一级,可以让你有信心大多数功能按预期工作。端到端测试表明整个系统的主要流程是正常工作的。
我们已经看到了如何对组件和方法进行单元测试,以及通过层进行测试,以及以黑盒方式测试组件输出而不是检查组件内部以测试功能。使用 Vitest 测试库,我们测试了利用 Pinia 的高级功能,如路由和应用程序。
最后,我们研究了快照测试,并看到了它如何成为为代码块中模板密集型部分编写测试的有效方法。
在下一章中,我们将探讨可以应用于 Vue.js 应用的端到端测试技术。
第十二章:端到端测试
在本章中,我们将探讨如何使用 Cypress 为 Vue.js 应用程序创建一个 端到端(E2E)测试套件。为了编写健壮的测试,我们将探讨常见的陷阱和最佳实践,例如拦截 HTTP 请求和等待元素出现而不设置超时。
随着我们继续前进,你将了解端到端测试及其用例。你将看到如何配置 Cypress 来测试 Vue.js 应用程序,以及如何使用它来与用户界面(UI)交互和检查。在整个章节中,你将熟悉任意超时的陷阱以及如何使用 Cypress 的等待功能来避免它们。
在本章的结尾,你还将学习何时、为什么以及如何使用 Cypress 拦截 HTTP 请求。
在本章中,我们将涵盖以下主题:
-
理解端到端测试及其用例
-
为 Vue.js 应用程序配置 Cypress
-
使用 Cypress 与 Vue.js UI 交互和检查
-
使用 Cypress 触发和等待 UI 更新
-
拦截 HTTP 请求
技术要求
本章没有技术要求,除了 git 命令行界面,你现在已经使用过了。你可以在这里找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter12
理解端到端测试及其用例
大多数开发者都见过以下图中显示的测试金字塔版本:

图 12.1 – 测试金字塔的示意图
端到端测试属于 UI 测试类别。本章我们将探讨的是使用 Cypress 自动化的端到端测试。
端到端和 UI 测试提供的信心水平高于单元测试或集成测试。它们测试的是最终用户使用的应用程序。最终用户不关心错误发生的原因或地点,只关心存在错误。
错误的位置和原因通常是单元测试和系统级测试的担忧。单元测试和系统级测试检查系统的内部是否按规范或代码描述的方式工作。UI 级测试验证应用程序流程是否按预期工作。
一个运行快速、假阴性(测试失败但应用程序工作)少、假阳性(所有测试通过但应用程序损坏)更少的强大端到端测试套件能够实现 持续部署(CD)。正如其名所示,持续部署涉及持续部署项目或应用程序。
在这种设置中,应用程序版本由端到端测试套件验证,然后自动部署到生产环境。
为 Vue.js 应用程序配置 Cypress
Cypress 是一个 JavaScript 端到端测试框架。它旨在解决使用 JavaScript 编写端到端测试的特定需求。这与其他全功能的浏览器自动化解决方案形成对比,例如 WebdriverIO (webdriver.io/)、Selenium WebDriver (www.selenium.dev/)、Puppeteer (developers.google.com/web/tools/puppeteer/)和 Playwright (github.com/microsoft/playwright),这些通常用于编写端到端测试。
与其他解决方案相比,Cypress 最大的不同之处在于它专注于编写端到端测试(而不是通用的浏览器自动化)。测试只能使用 JavaScript 编写(Selenium 支持其他语言),并且需要 Chrome、Edge 或 Firefox(WebKit 支持正在开发中)。
Cypress 有一个图形用户界面(GUI)来本地运行和调试测试,并附带内置的断言和存根/模拟库。
要将 Cypress 添加到新的 Vue 项目中,只需在提示时启用它:

图 12.2 – 在创建新的 Vue 3 项目时启用 Cypress
要将 Cypress 添加到现有项目中,使用npm install @cypress/vue@next --dev。
插件添加了一个test:e2e脚本,我们可以使用以下两个命令运行它。第一个准备 Vue 应用程序的构建。第二个实际上启动了 Cypress 应用程序:
npm run build
npm run test:e2e
你将首先被要求使用浏览器进行测试,如图图 12.3所示:

图 12.3 – Cypress 询问用于测试的首选浏览器
选择浏览器后,主 Cypress UI 将显示:

图 12.4 – Cypress 测试 UI
如果你点击示例链接,你会看到测试正在运行和输出:

图 12.5 – Cypress 运行测试
Cypress 为我们创建了一个默认测试,位于cypress/e2e/example.cy.js。该测试导航到 Vue 应用程序的根目录,并查找包含You did it!的h1标签:
// https://docs.cypress.io/api/introduction/api.html
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})
这将在默认的 Vue 3 项目中工作。
我们可以尝试使用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 窗口将自动加载并运行新的测试:

图 12.6 – Cypress 在 Chrome 中运行测试的同时访问 Google 首页
我们现在已经看到了如何安装和使用 Cypress 访问网页。在下一节中,我们将看到如何使用 Cypress 与 UI 交互和检查。
使用 Cypress 与 Vue.js UI 交互和检查
为了端到端测试新的应用程序 Commentator Pro,我们应该先添加一些要测试的内容。在这种情况下,我们将有一个带有应用程序名称的标题 (h2)。在 App.vue 文件中,我们将有如下代码:
<template>
<h2>Commentator Pro</h2>
</template>
为了使用 Cypress 进行测试,我们可以将 cypress/e2e/example.cy.js 文件更改为以下代码。我们将使用 cy.visit('/') 访问运行中的应用程序,然后检查页面上的 h2 是否包含 Commentator Pro 使用 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 运行 example.cy.js,如下截图所示:

图 12.7 – 在 Chrome 中成功运行的标题内容测试
现在我们已经看到了如何访问页面并断言其内容,我们将看到如何使用 Cypress 自动化 Vue.js 应用程序中一个新功能的测试。
练习 12.01 – 添加一个“新增评论”按钮及其相应的端到端测试
为了使 Commentator Pro 应用程序变得有用,我们应该有一个“新增评论”按钮,允许用户添加评论。
我们将添加一个带有“新增评论”文本的蓝色巨型按钮,并使用 Cypress 编写相应的端到端测试。
要做到这一点,请执行以下步骤:
-
要在应用程序中添加按钮,我们将在
src/App.vue中添加一个带有一些文本的button元素:<template><h2>Commentator Pro</h2><button>Add a New Comment</button></template>
输出应如下所示:

图 12.8 – 带有“新增评论”按钮的 Commentator Pro 应用程序
-
接下来,我们将在
cypress/e2e/add-new-comment.cy.js创建一个新的端到端测试。我们将设置测试套件的名称和测试的描述为Adding a New Comment,主页应该有一个带有正确文本的按钮:describe('Adding a New Comment', () => {it('the homepage should have a button with the righttext', () => {// test will go here})}) -
为了测试主页,我们需要使用
cy.visit('/')导航到它:describe('Adding a New Comment', () => {it('the homepage should have a button with the righttext', () => {cy.visit('/')})}) -
最后,我们可以编写断言,即页面上有一个包含“新增评论”文本的
button实例:describe('Adding a New Comment', () => {it('the homepage should have a button with the righttext', () => {cy.visit('/')cy.contains('button', 'Add a New Comment')})}) -
我们可以通过首先运行一个新的构建(
npm run build),然后运行npm run test:e2e来使用 Cypress UI 运行此测试。如果你已经运行了 Cypress,你不需要重新启动它。你可以通过点击左侧菜单中的 Specs 导航项来访问测试列表。

图 12.9 – 在 Cypress UI 中显示的 add-new-comment.cy.js 测试
- 当我们运行测试(通过点击它)时,我们将在 Chrome 中得到以下输出。测试通过,因为主页上有一个相关文本的按钮:

图 12.10 – Cypress 在 Chrome 中运行我们的添加新评论测试
我们已经看到了如何访问页面并对其内容进行断言。
在下一节中,我们将探讨如何使用 Cypress 测试交互行为。Cypress 具有自动选择器重试功能,这使得它非常适合测试高度交互的 Vue.js 应用程序。我们将看到如何使用 Cypress 与 UI 交互并断言我们交互的效果。
使用 Cypress 触发和等待 UI 更新
我们迄今为止编写的测试相当简单,仅检查应用程序在浏览器加载时不会崩溃。
E2E 测试的一个优点是,可以以高保真度测试用户与 UI 交互时 UI 的行为是否符合预期。在本节中,我们将使用 Cypress 的选择(.get()函数)、事件触发(.click()函数)和断言(.should()函数)功能来测试 Vue.js 应用程序。
Cypress 在 DOM 选择上的自动重试将允许我们编写 E2E 测试,而无需显式的等待或超时条件。等待和超时是其他 E2E 测试系统的基本要素,并且往往是测试不稳定的原因。
首先,我们将向我们的 Commentator Pro 应用程序添加一个评论编辑器。通过点击添加新评论按钮来切换显示编辑器(一个简单的textarea)。
为了在不处理复杂且脆弱的选择器的情况下继续编写测试,我们将开始添加data-test-id属性;首先,我们可以在App.vue文件中添加一个:
<template>
<h2>Commentator Pro</h2>
<button data-test-id="new-comment-button">
Add a New Comment
</button>
</template>
接下来,我们将在App组件的 Vue.js data()方法中添加一个showEditor属性。我们将使用这个表达式在v-if中为编辑器设置。我们还可以设置添加新评论按钮来切换这个实例属性:
<template>
<h2>Commentator Pro</h2>
<button @click="showEditor = !showEditor"
data-test-id="new-comment-button">
Add a New Comment
</button>
</template>
<script>
export default {
data() {
return {
showEditor: false
}
}
}
</script>
我们可以使用new-comment-editor data-test-id来添加我们的编辑器,该data-test-id可以通过showEditor来切换:
<template>
<!-- rest of template -->
<div v-if="showEditor">
<p>
<textarea data-test-id="new-comment-editor"></textarea>
</p>
</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.exist')
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.exist')
})
})
上述代码将在 Cypress 中生成以下结果:

图 12.11 – Cypress 运行添加新评论测试,包括新的编辑器切换测试
我们已经看到了如何编写 Cypress 测试来选择和断言 DOM 元素。
注意
data-test-id 实例,按照惯例,是一种将测试与特定于应用程序和样式的选择器解耦的方法。这在编写测试的人不总是编写代码的人时特别有用。在这种情况下,使用 data-test-id 允许标记结构和类发生变化,但只要 test-id 实例保持在正确的元素上,测试就会继续通过。
练习 12.02 – 添加新的评论编辑器输入和提交功能
为了能够将新的评论文本发送到 API,我们需要在 Vue.js 状态中存储文本。添加评论的另一个先决条件是拥有一个虚拟的 提交 按钮。
完整的代码可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter12/Exercise12.02 找到。
要完成这项任务,请执行以下步骤:
-
为了将
textarea(编辑器)内容存储在内存中,我们将使用v-model。我们将创建一个新的数据(状态)变量newComment,它被初始化为""。现在,v-model将双向绑定textarea内容和newComment:<template><!-- rest of template --><p><textarea data-test-id="new-comment-editor"v-model="newComment"></textarea></p><!-- rest of template --></template><script>export default {data() {return {showEditor: false,newComment: ''}}}</script> -
我们将在编辑器内添加一个
submit按钮,它仅在编辑器开启时才出现。我们还确保包含一个data-test-id="new-comment-submit"属性,以便稍后可以用 Cypress 选择它:<!-- rest of template --><div v-if="showEditor"><p><textarea data-test-id="new-comment-editor"v-model="newComment"></textarea></p><p><button data-test-id="new-comment-submit">Submit</button></p></div><!-- rest of template --> -
现在是时候添加一个端到端测试来测试当我们向其中输入文本时
new-comment-editor是否按预期工作。为了实现这一点,我们需要加载应用程序并点击new-comment按钮,以便编辑器显示。
然后,我们可以选择 new-comment-editor(通过 data-test-id),并使用 Cypress 的 .type 函数添加一些文本。我们可以将 .should('have.value', 'Just saying...') 连接到一起以验证我们对 textarea 的交互是否成功。记得在添加新测试时运行 npm run build:
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 运行时,这个 add-new-comment 测试套件应该产生以下结果:

图 12.12 – Cypress 运行 add-new-comment 测试,包括新的编辑器文本输入测试
-
最后,我们可以添加一个端到端测试来检查
submit按钮默认情况下不会出现,但当我们点击new-comment按钮时会出现。我们还可以检查new-comment-submit按钮的文本内容:describe('Adding a New Comment', () => {// other testsit('the new comment editor should have a submitbutton', () => {cy.visit('/')cy.get('[data-test-id="new-comment-submit"]').should('not.exist')// Get the editor to showcy.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 运行时,我们看到以下结果:

图 12.13 – Cypress 运行 add-new-comment 测试,包括新的提交按钮测试
-
我们还可以添加的一个功能是在文本编辑器中有文本之前禁用
submit按钮。为此,我们可以在new-comment-submit按钮上绑定:disabled到!newComment。顺便说一句,我们添加newComment和textarea之间的双向绑定的主要原因之一是启用此类 UI 验证:<button data-test-id="new-comment-submit":disabled="!newComment">Submit</button> -
相关的测试将检查
new-comment-submit按钮是否在文本编辑器内容为空时被禁用,使用 Cypress 的should('be.disabled')和should('not.be.disabled')断言:describe('Adding a New Comment', () => {// other testsit('the new comment submit button should be disabledbased on "new comment" content', () => {cy.visit('/')// Get the editor to showcy.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 自动化运行时,这会产生以下输出:

图 12.14 – Cypress 运行添加新评论测试,包括新评论提交按钮禁用测试
我们现在已经看到了如何使用 Cypress 来选择、点击和输入文本。我们还看到了检查元素可见性、文本内容、输入值和禁用状态的方法。
任何熟悉其他自动化框架的人都会注意到,在 Cypress 测试中,没有显式的等待或重试。这是因为 Cypress 会自动等待和重试断言和选择。我们编写的大多数测试都没有以这种方式展示这一点,但下一个练习,我们将引入异步性,将会展示这一点。
练习 12.03 – 向新评论编辑器添加提交状态
为了展示 Cypress 强大的自动重试/等待功能,我们将探讨为新的评论编辑器添加并测试一个submitting状态。
实际上,我们将对点击进行反应,持续 2.5 秒来模拟一个合理的慢速 HTTP 请求到后端 API。加载状态只是一个 CSS 类,它使按钮具有斜体文本。
这个测试将是一个展示 Cypress 自动等待和重试选择能力的示例。这个功能减少了任意等待及其相关的不稳定性。
让我们按照以下步骤进行:
-
为了显示加载状态,我们在组件中添加一个新的类:
<style scoped>.submitting {font-style: italic;}</style> -
接下来,我们需要在 Vue.js 应用中的
data()中添加一个isSubmitting状态,这将允许我们切换submit按钮的状态。我们将它初始化为false,因为我们还没有提交任何内容,直到用户点击提交按钮:<script>export default {data() {return {// other propertiesisSubmitting: false}}}</script> -
接下来,我们将为
submit按钮添加一个点击处理程序(作为methods.submitNewComment)。它将使用setTimeout模拟 2.5 秒的加载时间:<script>export default {// other component propertiesmethods: {submitNewComment() {this.isSubmitting = truesetTimeout(() => {this.isSubmitting = false;this.newComment = '';}, 2500)}}}</script> -
现在我们已经有一个假的
submit处理函数,我们应该将其绑定到new-comment-submit按钮的点击事件上:<template><!-- rest of template --><div v-if="showEditor"><!-- rest of editor --><button data-test-id="new-comment-submit":disabled="!newComment"@click="submitNewComment()">Submit</button></div></template> -
接下来是我们要对
submit按钮做出反应的部分。当isSubmitting为true时,将显示submitting类。为此,我们只需将submitting类设置为在isSubmitting为true时添加。除此之外,当isSubmitting为true时,我们还将禁用按钮:<button data-test-id="new-comment-submit":disabled="!newComment || isSubmitting":class="{submitting:isSubmitting}"@click="submitNewComment()">Submit</button> -
最后,我们可以添加一个测试来检查当点击
submit按钮时,按钮是否应用了submitting类。首先,我们需要设置文本编辑器,以便在点击add-new-comment按钮并设置评论的文本值时,文本编辑器显示并启用。
接下来,我们可以点击启用的new-comment-submit按钮,并检查它是否被禁用并且具有submitting类(使用should()函数)。之后,我们应该编写另一个断言,按钮不显示submitting类:
it('the new comment editor should show a submitting
class 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', 'submitting')
.should('be.disabled')
// eventually, the submitting class should be
removed
cy.get('[data-test-id="new-comment-submit"]')
.should('not.have.class', 'submitting')
})
尽管在显示submitting类时持续了2.5秒,但由于 Cypress 的自动重试/等待功能,这个测试仍然通过:

图 12.15 – Cypress 运行添加新评论测试,包括评论提交加载状态测试
我们已经看到了 Cypress 如何通过自动等待/重试来无缝地处理应用程序中的异步性,当断言或选择失败时。
截获 HTTP 请求
如前几节所述,Cypress 被设计为 JavaScript 端到端(E2E)测试解决方案。这意味着它自带断言、自动等待/重试、运行应用程序的合理默认值以及广泛的模拟功能。
HTTP 请求可能会很慢,并且倾向于在测试中引入不稳定的(flaky)行为。所谓的 flaky 是指间歇性的假阴性——也就是说,失败不是由应用程序问题引起的,而是由连接问题(例如,测试运行的服务器和后端主机之间的连接)引起的。
我们还会测试后端系统的实现。当使用持续集成(CI)时,这意味着需要在需要运行端到端测试的任何 CI 管道步骤中运行后端系统。
通常,当拦截后端请求并发送模拟响应时,我们也会说 HTTP 请求被stubbed,以避免测试不稳定(意味着间歇性失败与应用程序更改无关)。
由于请求并没有完全通过整个堆栈(包括后端 API),因此这从技术上讲不再是系统的完整端到端(E2E)测试。然而,我们可以将其视为前端应用的端到端测试,因为整个应用由独立的练习组成,并且不是特定于实现的。
为了在 Cypress 中模拟请求,我们需要使用cy.intercept()。
为了展示 HTTP 拦截,我们将从JSONPlaceholder获取评论列表,并将它们存储在comments响应式实例变量下。我们可以在mounted()生命周期事件中使用fetch来完成此操作,如下所示:
<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、正文和电子邮件等属性。
这意味着我们可以通过创建一个div容器来渲染评论,该容器仅在存在评论时显示(comments.length > 0)。在div容器内部,我们可以使用v-for渲染一个div元素的列表。每张卡片将渲染评论的正文和作者的电子邮件,并在mailto:链接内。
注意我们是如何为列表容器和列表项分别设置comments-list和comment-card的data-test-ids:
<div v-if="comments.length > 0" data-test-id="comments-list">
<div v-for="(comment, index) in comments":key="comment.id + index"data-test-id="comment">
<p>{{ comment.body }}</p>
<p><a :href="'mailto:' + comment.email">
{{ comment.email }}</a>
</p>
</div>
</div>
如果我们不使用 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 运行的以下测试运行通过,但测试相当通用。我们无法对特定评论的数量或内容做出任何断言:

图 12.16 – Cypress 运行加载评论测试,包括通用加载和显示测试
为了拦截请求,我们使用cy.intercept。它允许我们定义一个路由和一个静态响应 – 在我们的情况下,是一个评论数组。在我们的模拟中,我们将使用一个虚构的电子邮件地址:
it('should load and display comments correctly', () => {
cy.intercept('**/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')将评论的fetch路由的别名设置为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 运行测试套件,并查看它是否通过:

图 12.17 – Cypress 运行加载评论测试,包括我们的模拟评论测试
我们现在已经看到了如何以及为什么我们可能会使用 Cypress 来模拟 HTTP 请求。
练习 12.04 – 在提交时将评论 POST 到 API
new-comment-submit按钮目前仅设置加载状态几秒钟后重置 – 实际上并没有将评论发送到任何地方。
让我们使用JSONPlaceholder API 作为发送我们新评论的地方。当对 API 的POST请求成功时,我们将评论添加到评论列表的顶部。
为了完成练习,我们将执行以下步骤:
-
首先,让
submitNewComment方法使用fetch实际发送数据。新评论需要一个电子邮件地址,而我们的应用程序没有,但我们可以设置一个假电子邮件地址在我们的数据中:<script>// importsexport default {// other component propertiesdata: {// other dataemail:'fakeemail@email.com'},methods: {submitNewComment() {this.isSubmitting = truefetch('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 = ''
})
.catch(() => {
this.isSubmitting = false
})
}
}
}
</script>
现在我们应该向cypress/e22/add-new-comment.js测试套件添加新的测试。
-
首先,为了成为好的 JSON placeholder 用户,我们将为
add-new-comment套件中的所有GET请求到/comments进行存根处理。为了做到这一点,我们将使用一个beforeEach钩子来拦截匹配**/comments通配符的请求,并返回一个空数组:describe('Adding a New Comment', () => {beforeEach(() => {cy.intercept('GET','**/comments', []);})// tests -
然后,我们可以继续更新
the new comment editor should show a submitting class on submit测试,因为我们不再使用setTimeout,而是使用 HTTP 请求。首先,我们需要对/commentsPOST请求进行存根处理,我们将使用cy.intercept的配置对象语法来引入 HTTP 请求的延迟,以便它不会立即响应。
我们使用.as('newComment')来别称这个请求:
it('the new comment editor should show a submitting class on submit', () => {
cy.intercept('POST', '**/comments', (req) => {
req.reply({
delay: 1500, response: {}
});
}).as('newComment');
-
而不是
// 最终,应该移除提交类,我们现在可以使用cy.wait()等待newCommentHTTP 请求完成,然后再检查旋转器是否消失:describe('Adding a New Comment', () => {// setup & testsit('the new comment editor should show a spinner onsubmit', () => {// test setup// click the "submit" button// check the submitting class appearscy.wait('@newComment')// check that the submitting class is gone})}) -
在成功发布一条新评论后,评论文本会被清除。我们应该测试在发布评论时是否会发生这种情况。我们将使用与更新后的加载状态测试类似的骨架,设置
POST评论路由的cy.intercept('POST', '**/comments', {}),别名为.as('newComment')。
然后,我们可以显示新的评论编辑器,添加一些文本,并提交表单。然后,我们将等待POST请求完成,然后再检查评论是否已被清除:
it('adding a new comment should clear the comment
text', () => {
cy.intercept('POST', '**/comments', {
body: {
body: 'Just saying...',
email: 'hi@raymondcamden.com'
}
}).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('have.value','')
});
这个测试现在可以用 Cypress GUI 运行,并且会通过:

图 12.18 – Cypress 运行添加新评论测试,包括清除编辑器评论文本
-
我们添加的第二项功能是在 HTTP 请求完成后,将新评论添加到评论列表的顶部。为了测试这一点,最好将评论的
GET请求的响应更改至少包含一个元素(这样我们就可以检查新评论是否添加到列表的顶部):describe('Adding a New Comment', () => {// setup & other testsit('submitting a new comment should POST to/comments and adds response to top of commentslist', () => {cy.intercept('GET', '**/comments', [{email: 'evan@vuejs.org',body: 'Existing comment'}]).as('newComment')})}) -
我们可以然后模拟
POST请求使用一些模拟数据,添加文本到编辑器,并提交表单:describe('Adding a New Comment', () => {// setup & other testsit('submitting a new comment should POST to/comments and adds response to top of commentslist', () => {// GET request stubbingcy.intercept({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 testsit('submitting a new comment should POST to/comments and adds response to top of commentslist', () => {// setup & wait for POST completioncy.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('fakeemail@email.com')})})
当使用 Cypress GUI 运行add-new-comment测试套件时,我们可以看到新的测试通过:

图 12.19 – Cypress 运行添加新评论测试,包括将新评论添加到列表顶部的测试
你现在已经看到了如何在测试中处理网络操作。由于大多数应用程序都会使用某种形式的 API 调用,这将非常有帮助,以确保你的测试尽可能覆盖更多范围。
活动十二.01 – 添加设置用户电子邮件和测试的能力
你会记得我们将fakeemail@email.com硬编码为任何评论的电子邮件。在这个活动中,我们将添加一个电子邮件输入,它将为评论设置email属性。我们将在新的cypress/e2e/enter-email.cy.js测试套件中添加相关的测试:
-
为了跟踪电子邮件,我们将它在
data()中设置为一个响应式状态,并在页面上添加一个电子邮件输入,它将使用v-model双向绑定到email。我们还添加了一个标签和相应的标记。请注意,我们将在电子邮件输入上设置一个data-test-id属性,设置为email-input。 -
现在,我们将添加一个
beforeEach钩子,让 Cypress 拦截并模拟GET评论(列表)请求。评论列表请求应该被别名为getComments。 -
我们将添加我们的第一个测试,该测试检查输入电子邮件输入是否正确工作。我们将进入应用程序,输入电子邮件,并检查我们输入的内容现在是否是输入值。
当使用 Cypress UI 运行时,我们应该得到以下通过测试:

图 12.20 – Cypress 运行输入电子邮件测试的添加新评论测试
-
拥有
email属性是添加评论的先决条件,因此我们将禁用email为空(!email)。我们将根据电子邮件字段是否填充来绑定到disabled属性。 -
使用这个新的
当电子邮件为空时禁用添加新评论按钮功能,我们应该添加一个新的端到端测试。我们将加载页面,并在初始加载时检查电子邮件输入是否为空,以及not是否被禁用,这意味着它是启用的。
当使用 Cypress UI 运行时,我们应该看到新的测试通过,以下为输出:

图 12.21 – Cypress 运行带有禁用添加评论按钮的 enter-email 测试
-
现在我们有了捕获电子邮件的方法,我们应该在提交新评论(即提交新评论时)调用后端 API 时传递它。为了做到这一点,我们应该修改
methods.submitNewComment中电子邮件被硬编码为fakeemail@email.com的位置。 -
现在我们正在使用用户输入的电子邮件,我们应该编写一个端到端测试来检查它是否被发送。我们将模拟
POST请求,将其别名为newComment,并返回一个任意值。然后我们可以访问页面,填写电子邮件输入,打开评论编辑器,填写内容,并提交。然后我们将等待newComment请求,并在请求体中确认正文和电子邮件与我们完成它们时相同。
注意
我们也可以选择不模拟POST请求,而是检查新插入到页面上的评论是否包含正确的电子邮件和正文。
当使用 Cypress UI 运行时,我们得到以下测试运行输出:

图 12.22 – Cypress 运行带有电子邮件输入测试的 enter-email 测试
注意
该活动的解决方案可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter12/Activity12.01找到
摘要
在本章中,我们探讨了利用 Cypress 进行 Vue.js 应用程序端到端测试的方法。端到端测试通常非常有用,可以给我们一个高度信心,即测试的流程将按预期工作,而不是单元或集成测试,后者以更低的开销验证我们的代码是否按预期工作。
我们已经看到了如何使用 Cypress 检查、交互和断言 UI。我们还展示了 Cypress 的默认等待/重试功能在编写健壮测试时的巨大优势。我们利用 Cypress 的 HTTP 拦截库来模拟 HTTP 请求,使测试更加可预测和快速。
最后,我们探讨了如何使用 Cypress 设置视觉回归测试。在下一章中,我们将探讨如何将 Vue.js 应用程序部署到网络上。
第十三章:将代码部署到网络
在前两章中,你深入了解了测试以及它如何对你的应用程序产生益处。现在,你对 Vue.js 应用程序的稳定性和可用性有了信心,是时候深入探讨如何将代码上传到网络上了。
在本章中,你将能够解释 CI/CD 工作流程的好处以及它与发布周期、发布节奏和开发工作流程的联系。为此,你将能够阐述 Vue.js 开发和生产构建之间的差异以及所做出的权衡。
要测试和部署 Vue.js 应用程序,你需要配置 GitLab CI/CD,使用管道、作业和步骤。你将熟悉 Netlify、亚马逊网络服务简单存储服务(AWS S3)和 AWS CloudFront,以及它们的关键相似之处和不同之处。
在本章中,我们将涵盖以下主题:
-
探索 CI/CD 作为敏捷软件开发过程一部分的好处
-
为生产构建我们的应用程序
-
使用 GitLab CI/CD 测试我们的代码
-
部署到 Netlify
-
使用 S3 和 CloudFront 部署到 AWS
技术要求
对于本章,你需要git CLI,你之前已经使用过了。你还需要 Netlify 和亚马逊 AWS 的账户。
你可以在这里找到本章的源代码:github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter13
探索 CI/CD 作为敏捷软件开发过程一部分的好处
持续集成(CI)是指每天多次将代码集成到一起的实践。为了支持这一做法,需要一个现代的版本控制系统(VCS),例如 Git,它在一个仓库中支持多个工作状态(分支),这样开发者可以独立地工作在代码上,同时仍然能够安全地协作和集成他们的更改。
为了增强 VCS 的功能,围绕仓库(如 GitLab 或 GitHub)创建了一些托管和协作工具,这些工具允许开发者通过网页用户界面(UI)更高效地查看和管理代码更改。
作为这些托管平台以及它们提供的协作工具的一部分,或作为补充,自动检查对于在集成前、集成中和集成后保持对代码质量的信心至关重要。
采用 CI 方法通常包括包括额外的代码质量步骤,例如单元或集成测试、覆盖率检查,以及每次任何新代码集成到主线分支(更改被集成的分支)时在主线分支上构建工件。团队遵循的用于代码协作和 CI 的 Git 使用惯例被称为Git 工作流程,通常简称为Git flow。
Git 流程将预测分支命名约定,以及何时以及如何集成更改。例如,一个团队可能会决定分支应该以票号开头,后面跟着一个简短的短划线小写描述,例如WRK-2334-fix-ie-11-scroll。
作为 Git 流程的一部分,决定并遵守的其他约定示例包括提交消息长度和标题、应该通过或允许失败的自动检查,以及合并更改请求所需的审阅者数量,在 GitHub 和 GitLab 的术语中分别对应于拉取请求或合并请求。
Git 流程分为两大类:(功能)分支开发和主干开发。我们将首先介绍分支开发,因为其局限性已经变得非常明显,而且大多数项目倾向于使用主干开发。
在基于分支的 Git 工作流程中,多个工作分支被保存在仓库中。基于分支的流程可以用来保持反映环境状态的分支。
下面的图显示了三个分支——生产、预发布和开发:

图 13.1 – 具有三个环境分支的基于分支的 Git 提交/分支树
生产分支不包含来自预发布或开发的任何更改。预发布分支领先于生产分支,但除了生产分支上的更改外,与开发分支没有共同的变化。开发分支领先于预发布和生产分支:它与预发布分支在同一个提交上分支出来,但它不与预发布分支共享任何进一步的提交。
基于分支的工作流程也可以用来跟踪进入发布线的更改。在项目需要维护应用程序或库的两个版本,但需要对两个版本都应用错误修复或安全补丁的情况下,这很有用。
在以下示例中,我们得到了一个与环境分支类似的分支示例。版本 1.0.0 包含了一些在 1.0.1 和 1.1.0 中不存在的变化,但并不共享任何新的代码。版本 1.0.1 和 1.1.0 同时从 1.0.0 分支出来,但它们没有共享进一步的更改:

图 13.2 – 具有三个发布分支的基于分支的 Git 提交/分支树
在基于主干的工作流程中,团队中的每个成员都会从一个分支创建新的分支,通常是从master分支。这个过程通常被称为从:

图 13.3 – 一个基于主分支的 Git 提交/分支树示例,有两个从主分支分叉出来的功能分支
基于主分支的工作流程的一个极端情况是拥有一个单一的分支,每个人都提交到这个分支。
注意
在基于主分支的环境中,除了发布分支之外,还可以使用 Git 标签来跟踪发布快照。这提供了与维护分支相同的优势,但减少了分支噪音,并且由于标签一旦创建就不能更改,因此具有不可变性的额外好处。
持续交付(CD)是团队能够将每个良好的构建部署到生产环境的能力。
CD 的一个先决条件是 CI,因为 CI 为构建的质量提供了一些初始的信心。作为 CD 的一部分,除了 CI 之外,还需要新的系统、工具和实践。
下图显示了与 CI 和 CD 实践相关的工具和那些更相关的工具:

图 13.4 – CI 和 CD 实践之间的关系
采用 CD 所需的额外成分是对应用程序将继续按预期(对于最终用户)工作以及新缺陷没有无意中引入的高度信心。这意味着在 CI 检查期间或之后需要额外的端到端测试步骤,以在部署之前验证构建。
这些端到端测试可以手动进行,也可以自动化。在一个理想的 CD 设置中,后者(自动端到端测试)是首选的,因为这意味着部署不包括手动交互。如果端到端测试通过,构建可以自动部署。
为了便于持续部署(CD),以前用于部署软件的系统必须重新思考。作为 CD 的一部分,部署不能是一个冗长的手动过程。这导致公司采用云原生技术,如 Docker,以及基础设施即代码(IaC)工具,如 HashiCorp 的Terraform。
重视转向 CD 实践导致了GitOps和ChatOps等想法的诞生。在 GitOps 和 ChatOps 中,部署和运营任务是由开发人员和利益相关者每天交互的工具驱动的。
在 GitOps 中,部署可以通过 GitHub/GitLab(或另一个 Git 托管提供商)直接进行,使用 GitHub Actions 或 GitLab CI/CD,或者通过 CI/CD 软件(如 CircleCI 或 Jenkins)进行,这些软件与 GitHub/GitLab 有紧密的集成和报告功能。
在 ChatOps 的情况下,使用对话界面来部署和操作软件。某些 ChatOps 的变体可以被认为是 GitOps 的一个子集——例如,通过 GitHub 拉取请求的评论与工具(如Dependabot,一个保持项目依赖项更新的工具)进行交互。
ChatOps 也可以直接集成到实时聊天工具中,如 Slack 或 Microsoft Teams。有人可能会发送一条消息,例如 deploy <service-name> <environment>,这将把服务部署到相关环境。请注意,聊天界面非常类似于命令行界面,开发者可能已经习惯了,但其他利益相关者可能需要一些时间来适应。
现在我们已经探讨了 CI 和 CD 的方法,让我们讨论使用 CI 和 CD 的优势:
| 持续集成 | 持续交付 |
|---|---|
| 确保正在集成的更改集很小(最多几天的工作量) | 更频繁、更安全地向生产交付价值 |
| 减少了在代码库中造成未预知错误的巨大全面更改的可能性 | 一小部分更改集(几天的工作量)可以无问题回滚 |
| 测试、代码质量和审查步骤为干净的集成提供了信心 | 由于较长的固定(每月、每周或每冲刺)发布周期(与 CD 相比),较大的更改集可能产生未预见的后果;回滚对大型发布的影响难以理解 |
图 13.5 – CI 和 CD 的优势
这两种实践也对团队的心态和表现产生影响。能够在一天内看到你做出的更改集成,并在一周内投入生产,这意味着贡献者可以立即看到他们的工作产生影响。
CI/CD 还有助于推广敏捷原则,其中更改是迭代应用和部署的。这与项目长期时间表形成对比,对于这些项目,估计不准确会累积并可能导致重大延误。
通过这样,你已经深入了解了 CI 和 CD。虽然两者确实意味着你的流程中会有更多的工作,但长远来看,这些好处将因更好的稳定性和更灵活地响应问题和添加新功能而得到回报。现在,让我们将其付诸实践。
为生产构建我们的应用程序
将应用程序部署到生产环境始于创建一个可以部署的工件。在 Vue.js 的情况下,我们构建的是客户端应用程序,这意味着我们的构建工件将包含 HTML、JavaScript 和 CSS 文件。
使用 Vite 框架搭建的 Vue 项目将包含一个 build 命令。在构建过程中,Vite 会处理 JavaScript、Vue 单文件组件以及相互导入的模块,并将它们 打包。打包意味着相互依赖的相关代码块将输出为一个单一的 JavaScript 文件。
Vue CLI 构建步骤还包括一个 dead code elimination 步骤。这意味着它可以分析正在生成的代码,如果其中任何部分从未使用过——例如,一个如 if (false) { /* do something */} 的语句——那么它将不会出现在构建输出中。
默认情况下,当我们在 Vue 项目中调用 vite build 时,Vite 会为生产环境构建,在 Vue 项目中,这被别名设置为 build 脚本,可以通过 npm run build 或 yarn build 来运行。
在一个示例 Vue 项目中,我们会看到类似以下的内容:

图 13.6 – 新建 Vue 项目中“npm run build”的输出
dist 文件夹现在可以使用 Netlify 或 AWS S3 和 CloudFront 等静态托管解决方案进行部署。
通过这些,我们已经看到了如何使用 Vite 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/ssh/index.html。
一旦你创建了账户,你可以通过创建空白项目操作创建一个新的存储库,如下面的截图所示。如果你是现有用户,你可以在项目页面的右上角使用创建新项目按钮。

图 13.7 – 带有新建项目按钮的 GitLab 项目页面
不论你的选择如何,你都会被带到新项目页面,在那里你可以通过给它一个名称和一个缩写来创建一个项目,如下面的截图所示:

图 13.8 – GitLab 新建项目页面
一旦你点击创建项目,GitLab 项目页面将以空状态出现,显示如何克隆它的说明。你应该运行克隆存储库所需的命令,这很可能类似于以下内容(你需要在你的机器上运行):
git clone <repository-url>
你可以通过点击蓝色的克隆按钮来找到正确的 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)镜像上运行,截至 2022 年初将是 Node.js 16。
注意
您可以访问最新的 Node.js LTS 发布计划,网址为nodejs.org/en/about/releases/。
我们可以在作业中定义的另一个属性是阶段。默认情况下,GitLab CI/CD 流水线有三个阶段:构建、测试和部署。当团队的工作流程不适合这三个类别时(例如,如果需要部署到多个环境),可以使用自定义阶段来替换这些阶段。我们的流水线目前只有一个阶段和一个作业,所以大部分前面的内容不适用于我们。
注意
如果您好奇,stages用于定义作业可以使用的阶段,并且它是全局定义的(docs.gitlab.com/ee/ci/yaml/#stages)。
stages的指定允许灵活的多阶段流水线。阶段中元素的顺序定义了作业执行的顺序:
a) 同一阶段的作业并行运行
b) 下一个阶段的作业将在上一个阶段的作业成功完成后运行
有关更多信息,请参阅文档:docs.gitlab.com/ee/ci/yaml/#stages。
我们设置的最后一个属性是script,它定义了作业运行时应运行的步骤,以及artifacts,它配置了工件存储。在我们的例子中,我们将运行npm ci来安装所有依赖项,然后是npm run build,这将运行生产 Vue.js 构建。我们的工件已设置为保留一周,并包含dist文件夹(这是 Vite 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 项目的存储库,我们将在存储库视图中看到以下内容,其中在最新提交上正在运行一个步骤的流水线:

图 13.9 – GitLab 存储库视图,构建作业在最新提交上运行
注意
GitLab 现在在运行流水线之前需要用户验证。这可以通过信用卡完成;GitLab不会向您的信用卡收费。它仅作为验证过程的一部分使用。
如果我们点击Build(表示状态流水线,我们将其设置为build),它表示作业名称(我们定义为build),我们将看到相同的进行中指示器,直到作业完成,如下所示:

图 13.10 – GitLab CI 流水线视图,构建作业已完成
作业完成后,我们将看到一个成功图标(绿色勾号)。我们可以在作业运行时或完成后(无论它是否失败或成功)点击此图标或作业名称来访问作业视图。当作业完成后,我们还将看到一个重试图标,如果我们希望重试失败的管道步骤,这可能很有用。
以下截图显示作业运行成功:

图 13.11 – 构建作业通过的 GitLab CI 管道视图
点击作业后,我们将看到docker_machine执行器,它加载 Node.js Docker 镜像,我们可以看到运行脚本的步骤,以及缓存和工件恢复,如下所示:

图 13.12 – 成功构建作业的 GitLab CI 作业视图
如果我们想在 GitLab CI/CD 运行中添加一个test步骤,我们需要在一个支持单元测试的项目中。安装和添加单元测试在第十一章,单元测试中进行了详细说明。
我们需要在.gitlab-ci.yml文件中添加一个新的作业;我们将称之为test,使用node:lts镜像,并将作业分配给test状态。在作业中,我们必须运行npm ci,然后是npm run test:unit(这是由unit-jestCLI 插件添加的npm脚本):
# rest of .gitlab-ci.yml
test:
image: node:lts
stage: test
script:
- npm ci
- npm run test
一旦我们推送这个新的.gitlab-ci.yml文件,我们将在主仓库页面上看到以下视图:

图 13.13 – GitLab CI/CD 运行带有新测试步骤的管道的仓库视图
我们可以点击进入管道视图。GitLab CI/CD 使用管道的原因是,在某个阶段失败的步骤将意味着后续任何阶段的步骤都不会运行。例如,如果我们得到一个失败的build作业,属于test阶段的作业将不会运行:

图 13.14 – 带有停止测试作业/阶段的失败构建作业的 GitLab CI/CD 管道视图
如果我们推送另一个提交或重试构建步骤(如果失败不是由更改引起的)并再次导航到管道视图,我们将看到以下内容:

图 13.15 – 构建阶段所有作业都成功后运行的测试作业的 GitLab CI/CD 管道视图
一旦test作业成功,我们将看到以下管道:

图 13.16 – GitLab CI/CD 管道视图,所有作业在构建和测试阶段均成功
我们已经添加了一个包含build和test阶段的 GitLab CI/CD 管道,以确保在每次向 GitLab 仓库推送时,代码仍然按预期集成。
练习 13.01 – 向我们的 GitLab CI/CD 管道添加代码风格检查步骤
代码风格检查是一种获取自动化格式化和代码风格检查的方法。将其集成到 CI 过程中可以确保所有合并到主线分支的代码都遵循团队的代码风格指南。它还可以减少代码风格审查评论的数量,这些评论可能会很嘈杂,并可能分散对变更请求基本问题的关注。
您可以在github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter13/Exercise13.01找到此练习的完整代码。
要添加代码风格检查,请按照以下步骤操作:
-
首先,我们需要确保我们的
package.json文件包含lint脚本。如果它缺失,我们需要添加它。eslint-plugin-vue网站(eslint.vuejs.org/)对此进行了说明。一旦安装,就可以在代码风格检查脚本中使用它,如下所示:{"// other": "properties","scripts": {"// other": "scripts","lint": "eslint --ext .js,.vue src","// other": "scripts"},"// more": "properties"} -
要在 GitLab CI/CD 上运行代码风格检查,请添加一个新的
lint作业,该作业将在 GitLab CI/CD 管道的test阶段运行 Node.js LTS Docker 镜像。我们将在.gitlab-ci.yml文件中这样做:lint:image: node:ltsstage: test -
为了让
lint作业按照package.json中的设置运行lint脚本,我们需要在.gitlab-ci.yml文件中添加一个script部分。首先,它需要运行npm ci来安装依赖项,然后运行npm run lint以启动代码风格检查过程:lint:image: node:ltsstage: testscript:- npm ci- npm run lint -
最后,使用以下命令将代码
commit和push到 GitLab:git add .git commit -m "add linting"git push
一旦代码被推送,我们将在 GitLab CI/CD UI 中看到管道运行,如下所示(注意test阶段的所有作业是如何并行运行的):

图 13.17 – GitLab CI/CD 管道视图,所有作业均成功,包括并行运行的“测试”和“代码风格检查”
我们已经了解了如何使用 GitLab CI/CD 在每次提交时运行构建和测试。接下来,我们将学习如何将 Vue.js 应用程序部署到 Netlify。
部署到 Netlify
Netlify 是一家专注于静态网站托管和相关支持服务的托管提供商,它提供完全交互式的网站,使用静态网站托管。这包括 Netlify Functions(无服务器函数)、Netlify Forms(无后端表单提交系统)和 Netlify Identity(身份/认证提供商)等服务。
以下部分需要您拥有一个免费的 Netlify 账户(您可以在app.netlify.com/signup注册一个账户)。
将网站部署到 Netlify 最简单的方法是使用拖放界面。您可以在登录视图的 站点 页面的底部找到它,如下所示:

图 13.18 – Netlify 应用程序主页底部的拖放部署区域
现在,我们可以选择一个已经运行了 npm run build 命令的项目,并通过简单地将其拖动到拖放部署区域来部署 dist 文件夹,如下面的截图所示:

图 13.19 – 将 dist 文件夹拖放到 Netlify 拖放部署区域
一旦上传成功,Netlify 将将您重定向到您新网站的管理页面:

图 13.20 – Netlify 拖放站点的新应用页面
我们可以点击到站点的链接。我们会看到默认的 Vue CLI 主页模板,如下所示:

图 13.21 – Netlify 新应用显示问候信息
通过这样,我们已经学会了如何使用拖放界面手动将网站部署到 Netlify。
接下来,我们将学习如何从 GitLab 部署我们的网站到 Netlify。
在 Netlify 应用程序的 站点 页面上,我们需要点击以下截图所示的 添加新站点 按钮:

图 13.22 – Netlify 主页带有从 Git 创建新站点的按钮
在下拉菜单中,选择 导入现有项目。我们将看到一个页面,要求我们选择一个 Git 提供商进行连接。在这个例子中,我们将使用 GitLab。以下截图显示了屏幕将看起来像什么:

图 13.23 – Netlify – 创建新站点 | 连接到 Git 提供商
点击 GitLab 后,我们将收到 GitLab 的 OAuth 授权挑战,我们需要通过点击以下截图所示的 授权 按钮来接受它:

图 13.24 – GitLab OAuth 授权对话框
然后,我们将被重定向到 Netlify,并被要求选择一个要部署的仓库,如下所示:

图 13.25 – 选择 GitLab 仓库进行部署
选择我们想要部署的仓库后,我们将遇到一个配置页面。由于我们现在是在 Netlify 的构建服务器上构建,我们需要配置 Netlify 以构建应用程序并部署正确的文件夹。
默认情况下,Netlify 会确定正确的build命令(npm run build)和发布目录(dist)。如果你需要出于某种原因更改这些值,你可以这样做,但默认值应该适用于你。
然后,我们必须点击部署站点按钮,这将开始部署过程,如下所示:

图 13.26 – Netlify 构建配置标签页,已配置设置
然后,我们将被重定向到新创建的应用程序页面,如图所示:

图 13.27 – 新 Netlify 应用
我们已经看到了如何使用 GitLab 作为 Git 托管提供商时,使用手动上传方法将应用程序部署到 Netlify。
练习 13.02 – 从 GitHub 部署站点到 Netlify
在上一个练习中,你看到了如何从 GitLab 部署站点到 Netlify。在这个练习中,我们将修改这个过程以从 GitHub 部署。与从 GitLab 部署相比,有什么不同?答案是它们非常相似;唯一的显著区别是连接到 Git 提供者标签中的第一步:
- 首先,在站点页面点击添加新站点按钮,操作如下:

图 13.28 – 在 Netlify 仪表板上添加新站点
- 选择导入现有项目,并将GitHub作为 Git 托管提供商,如图下所示:

图 13.29 – 持续部署
- 当我们遇到如图所示的 GitHub OAuth 授权挑战时,我们必须授权 Netlify:

图 13.30 – GitHub 授权挑战
- 从仓库列表中选择我们想要部署的 Vue CLI 项目,如下所示:

图 13.31 – 选择正确的仓库
-
在
master(或main,取决于你的仓库)作为部署的分支。 -
设置
npmrun build。 -
设置
dist。
最后两点如下所示:

图 13.32 – Netlify 构建配置标签页已填写正确的设置
- 点击部署站点以开始部署过程。
我们现在已经看到了如何使用手动上传方法以及使用 GitLab 或 GitHub 作为 Git 托管提供商将应用程序部署到 Netlify。接下来,我们将学习如何使用 AWS S3 和 AWS CloudFront 部署 Vue.js 应用程序。
使用 S3 和 CloudFront 部署到 AWS
Amazon S3 是一种静态存储服务,可以用作静态文件的托管,例如由 Vue CLI 的build脚本生成的文件。
CloudFront 是 AWS 的内容分发网络(CDN)。CDN 可以通过从边缘位置提供静态内容来提高 Web 应用程序的性能。这些服务器遍布全球,并且更有可能位于比源服务器(提供内容的服务器)更靠近最终用户的地方。如果 CDN 的边缘服务器没有缓存资源,它们将从源请求资源,但会为后续请求提供服务。
让我们学习如何配置 S3 以托管 Vue.js 应用程序(为此,请确保您有一个 AWS 账户):
- 首先,创建并配置一个 S3 存储桶。
要这样做,请转到 S3 产品页面。它看起来类似于以下内容:

图 13.33 – 从 AWS 服务列表中选择 S3
- 在 S3 控制台主页面上,点击创建存储桶按钮,这将带我们到存储桶创建页面,如下所示:

图 13.34 – AWS S3 控制台中的创建存储桶按钮
- 要开始,我们将从命名我们的存储桶开始。存储桶名称必须是唯一的,因此考虑使用
vue-workshop-yourname。在这个例子中,我们将其命名为vue-workshop-ray:

图 13.35 – 在存储桶创建页面上输入存储桶名称
- 我们还需要将 S3 存储桶设置为公开。这可以通过取消选择阻止所有公开访问复选框来完成。一旦完成,我们必须检查确认复选框,如下所示:

图 13.36 – 将 S3 存储桶设置为公开并确认警告
- 完成此操作后,我们将被重定向到存储桶列表页面。我们想要点击进入我们的新存储桶。然后,我们需要访问属性标签以找到静态网站****托管选项:

图 13.37 – S3 存储桶属性标签中的静态网站托管选项
- 我们可以填写
index.html:

图 13.38 – 填写静态网站托管 S3 属性
- 保存您的更改后,注意端点 URL,您将需要它用于 CloudFront:

图 13.39 – 记录端点 URL
- 我们现在可以回到
dist文件夹,如下面的屏幕截图所示:

图 13.40 – 通过拖放将文件添加到 vue-workshop S3 存储桶
-
一旦文件被拖放到 概览 页面,我们必须点击 上传 以完成此过程。
-
接下来,我们需要设置一个存储桶策略,以允许读取我们刚刚上传的文件。为此,点击 权限 选项卡,然后在 存储桶策略 部分中点击 编辑。在 策略编辑器 区域,粘贴以下 JSON:
{"Version": "2012-10-17","Statement": [{"Sid": "PublicReadGetObject","Effect": "Allow","Principal": "*","Action": ["s3:GetObject"],"Resource": ["arn:aws:s3:::vue-workshop-ray/*"]}]}
请确保将 vue-workshop-ray 更改为您选择的存储桶名称,然后点击 保存更改:

图 13.41 – 将上传到 S3 存储桶的文件权限设置为公共
- 我们现在应该已经配置了 S3 存储桶以托管静态内容。通过访问网站端点(在 属性 | 静态网站托管 下可用),我们将看到以下 Vue.js 应用程序(这是我们上传的):

图 13.42 – 从我们的 AWS S3 存储桶提供服务的 Vue.js 应用程序
注意,S3 只能通过 HTTP 提供网站服务,并且无法直接从 S3 存储桶配置域名。除了性能和健壮性之外,能够设置自定义域名和 HTTPS 支持也是将 AWS CloudFront 设置为我们的网站 CDN 的其他原因。
- 我们首先导航到 CloudFront 控制台,并点击 创建分发 按钮,如下所示:

图 13.43 – 从 AWS 服务列表中选择 CloudFront
- 现在,我们必须填写
example.s3-website.us-west-1.amazonaws.com,这是位于us-westwest-1区域的example存储桶。以下截图显示了这一过程:

图 13.44 – 在 CloudFront 分发的源域名字段中输入网站的端点域名
- 当我们在设置分发时,选择 查看器协议策略 字段的 将 HTTP 重定向到 HTTPS 选项是个好主意;这可以在 默认缓存行为 部分找到,如下所示:

图 13.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 命令。
我们将使用 AWS URI 语法将 ./dist 同步到 vue-workshop S3 存储桶 – 即 s3://vue-workshop。我们还想要确保我们上传的文件,就像存储桶配置一样,允许 public-read。完整的命令如下所示:
aws s3 sync ./dist s3://vue-workshop --acl=public-read
现在,让我们将我们所学应用到我们的 GitLab 流程中。
练习 13.03 – 从 GitLab CI/CD 部署到 S3
S3 是一种非常经济高效且性能出色的解决方案,用于大规模存储静态文件。在本练习中,我们将学习如何集成 GitLab CI/CD 和 AWS S3 来部署 Vue.js 应用程序。这将自动化 Vue.js 应用程序的部署。部署将在每次向 GitLab 推送时运行,无需任何手动干预。
您可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter13/Exercise13.03 找到本练习的完整代码。
要从 GitLab CI/CD 部署到 S3 存储桶,我们需要设置凭证管理:
- 在 GitLab 的 设置 部分导航到 CI/CD,如下所示:

图 13.46 – 设置菜单中的 CI/CD
- 我们将想要添加变量,因此让我们展开该部分。您将看到一个空变量列表,如下面的截图所示:

图 13.47 – GitLab CI/CD 设置的变量部分已展开
- 接下来,我们将使用 UI(这些值未显示,因为它们是敏感的 API 密钥)添加两个变量,
AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY,如下所示:

图 13.48 – 输入 AWS_ACCESS_KEY_ID 环境变量
- 现在,我们可以使用 UI 添加默认的
AWS_REGION变量(这不是那么敏感,所以其值在下面的截图中显示):

图 13.49 – 输入 AWS_DEFAULT_REGION 环境变量
现在我们已经在 GitLab CI/CD 上设置了环境变量,我们可以开始更新我们的 .gitlab-ci.yml 文件。
-
首先,我们希望在
build步骤之后开始缓存dist目录。为此,我们需要在build作业中添加一个cache属性:build:# other propertiescache:key: $CI_COMMIT_REF_SLUGpaths:- 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 jobsdeploy:image: python:lateststage: deploycache:key: $CI_COMMIT_REF_SLUGpaths:- distbefore_script:- pip install awscliscript:- aws s3 sync ./dist s3://vue-workshop-ray --acl=public-read
注意
由于 Vite build 命令内置了缓存清除功能,我们不需要清除缓存。它是通过在文件名中对文件内容进行指纹识别来实现的。指纹识别意味着如果文件内容发生变化,其名称/URL 将相应地更改。当请求这个新文件时,它将从未缓存的 URL 加载,因此将获取文件的最新版本。
一旦将此配置更新推送到 GitLab 仓库,我们将看到管道运行三个阶段,所有阶段都通过,如下所示:

图 13.50 – 通过构建、测试和部署作业
我们已经看到了如何使用 AWS CLI 和 GitLab CI/CD 配置和部署 Vue.js 应用程序到 S3 和 CloudFront。
活动 13.01 – 使用 GitLab 将 CI/CD 添加到图书搜索应用程序并部署到 Netlify
现在,让我们拿一个完全构建的从 Google Books API 加载数据的图书搜索 Vue.js 应用程序,并将其部署到 GitLab CI/CD 和 Netlify。我们将从在本地运行生产构建并检查输出开始。
然后,我们将切换到在 GitLab CI/CD 上运行构建和代码质量步骤(linting)。最后,我们将设置一个新的 Netlify 应用程序,该应用程序源自 GitLab 仓库。
此活动的起始代码可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter13/Activity13.01_initial 找到;我们将从一个使用 Vue CLI 构建的 图书搜索 应用程序开始。解决方案可以在 github.com/PacktPublishing/Frontend-Development-Projects-with-Vue.js-3/tree/v2-edition/Chapter13/Activity13.01_solution 找到。
首先,按照以下步骤操作:
- 我们将在本地运行一个生产构建。我们可以使用用于构建所有 Vue CLI 项目的命令。我们还将检查相关的资产(JavaScript、CSS 和 HTML)是否正确生成。
我们期望 dist 文件夹具有以下类似的结构:

图 13.51 – 生产构建运行后 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 作业(注意月亮图标):

图 13.52 – 运行中的 GitLab CI/CD 构建作业
另一方面,以下截图显示了构建作业完成后处于 passed 状态的 GitLab CI/CD 流程(寻找绿色勾选标记):

图 13.53 – 构建作业通过时的 GitLab CI/CD 流程
-
接下来,我们希望在 GitLab CI/CD 的
test阶段添加一个代码质量作业(通过更新.gitlab-ci.yml)。我们将把这个作业命名为lint,它将运行依赖项的安装以及通过 Vue CLI 进行代码风格检查。 -
一旦我们使用
git add .gitlab-ci.yml并提交和推送更改,我们应该看到以下 GitLab CI/CD 流程运行,其中包含正在运行的lint作业:

图 13.54 – 运行中的 GitLab CI/CD 流程的 lint 作业
以下截图显示了 lint 作业完成时的 GitLab CI/CD 流程:

图 13.55 – GitLab CI/CD 流程的 lint 作业已完成
- 要部署我们的应用程序,我们需要创建一个新的 Netlify 应用程序。从 站点 菜单中添加一个新的站点,并将提供者选择为 GitLab。您应该在仓库列表中看到您的仓库:

图 13.56 – 选择 Netlify 网站的仓库
- 在下一步中,您可以确认 Netlify 自动识别如何构建和部署您的网站。
build命令和publish目录应设置如下:

图 13.57 – 启用 Web 托管并配置索引和错误页面为 index.html 的 S3 存储桶属性页面
- 然后,点击部署站点。Netlify 将从 GitLab 获取代码并运行其构建脚本:

图 13.58 – Netlify 正在运行其部署过程
- 完成后,您可以点击 Netlify 创建的站点 URL,并看到应用程序正在运行:

图 13.59 – 在 Netlify 上运行的 Vue 应用程序
您现在已经走过了将一个真实(尽管简单)的 Vue 应用程序转化为 CI/CD 流程的过程,该流程可以让您以自动化和安全的模式从开发到生产。恭喜!
摘要
在本章中,我们探讨了如何将 CI 和 CD 实践引入 Vue.js 项目,以便我们可以安全高效地部署到生产环境。我们还看到了 CI 和 CD 在敏捷交付过程中的好处。
我们使用 GitLab 的 CI/CD 功能在每次提交时运行测试、代码检查和构建。我们还学习了如何通过将 Netlify 连接到我们的托管提供商来利用 Netlify 托管静态网站。最后,我们探讨了如何设置并部署到 AWS S3 和 CloudFront。
在本书的整个过程中,您学习了如何使用 Vue.js 成功构建强大且易于构建的 Web 应用程序。您使用数据、动画、表单等构建了多种不同类型的应用程序,并具有各种用户交互风格。您还学习了如何测试应用程序的所有方面,并最终采取了将应用程序部署到实时生产环境的步骤!


浙公网安备 33010602011771号