Vue-学习指南-全-

Vue 学习指南(全)

原文:zh.annas-archive.org/md5/660ac9441c91912abc7d220966acea5d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JavaScript 框架在现代 Web 前端开发中扮演着重要角色。在开发 Web 项目时,公司会出于多种原因选择框架,包括最终产品的质量、开发成本、编码标准和开发便利性。因此,学习使用 JavaScript 框架(如 Vue)对于任何现代 Web 开发者(或前端开发者或全栈开发者)都是至关重要的。

本书适用于希望使用 Vue 库从头到尾开发 Web 应用程序的程序员,使用 JavaScript 和 TypeScript。它专注于 Vue 及其生态系统如何帮助您在最简单和最舒适的方向上构建可伸缩和交互式 Web 应用程序。在介绍基础知识的同时,我们还将深入 Vue Router 和 Pinia 用于状态管理、测试、动画、部署和服务器端渲染,确保您可以立即开始开发复杂的 Vue 项目。

如果您对 Vue 或虚拟 DOM 的概念不熟悉也没关系。本书不假设您具备 Vue 或任何类似框架的任何先验知识。我将从零开始介绍和引导您了解 Vue 的基础知识。在第二章中,我还将为您讲解 Vue 中的虚拟 DOM 概念和响应系统,作为本书其余部分的基础。

本书不要求您了解 TypeScript,但如果您熟悉 TypeScript 基础,则会更有准备。如果您事先掌握了 HTML、CSS 和 JavaScript 的基础知识,那么您也将更好地准备好阅读本书的内容。在深入任何网络(或前端)JavaScript 框架之前,这三者的扎实基础始终至关重要。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

常宽

用于程序清单,以及在段落内用于引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常宽粗体

显示用户应直接输入的命令或其他文本。

常宽斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般性说明。

警告

此元素指示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/mayashavin/learning-vue-app下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分,否则无需获得我们的许可。例如,编写一个程序使用本书中几个代码块不需要许可。出售或分发奥莱利书籍中的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Learning Vue by Maya Shavin (O’Reilly). Copyright 2024 Maya Shavin, 978-1-492-09882-9.”

如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时与我们联系,邮件至 permissions@oreilly.com

奥莱利在线学习

注意

超过 40 年来,奥莱利媒体 提供技术和商业培训,知识和洞察力,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自奥莱利和其他 200 多个出版商的广泛文本和视频资源。更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版社:

  • 奥莱利媒体,公司

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问此网页 https://oreil.ly/learning-vue-1e

欲了解有关我们的图书和课程的新闻和信息,请访问 https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

在撰写这本书的旅程中,我的家庭正经历着动荡不安的时期,充满高低起伏。尽管享受每一个时刻,但写作这本书需要大量的时间、精力和奉献精神。没有家人的支持,特别是我的丈夫 Natan 的支持,我无法全身心投入其中。他对我的编程能力的鼓励和信任,在前端开发方面的幽默感,以及在我工作出差期间帮助照顾孩子、倾听我的日常怨言、帮助我平衡工作和个人生活的支持,都是无价的。没有 Natan,我今天不会有现在的成就。

就像优质的代码需要彻底审查一样,这本书的卓越之处很大程度上归功于 Jakub Andrzejewski、Chris Fritz、Lipi Patnaik、Edward Wong 和 Vishwesh Ravi Shrimali 的关键技术见解和鼓励。你们宝贵的反馈在提升我的专注力和提高这部作品的质量方面起到了关键作用。

我由衷感谢我的 O'Reilly 团队:Zan McQuade 和 Amanda Quinn,在Learning Vue的收购过程中给予我的指导;还有我出色的编辑 Michele Cronin。Michele,你在书稿最后阶段特别是在挑战性阶段期间提供的深刻反馈、专业精神和同理心是非凡的。Ashley Stussy 的制作编辑技能和 Beth Richards 的文稿编辑专业技能对提升我的手稿质量至关重要。没有你们的共同努力,这本书不会如期望般实现。

特别感谢 Vue 核心团队开发了如此优秀的框架和生态系统,以及 Vue 社区成员和朋友们的支持和启发。我从你们那里获得的知识和见解是无法估量的,且每天都在丰富着我。

最后,我对你们读者表达深深的感激之情。从众多资源中选择这本书,包括无数的视频和教程,展示了你们对我的工作的信任,我深表感激。我希望Learning Vue能成为你们在 Web、前端或全栈开发之旅中的宝贵工具。

由衷感谢你们。请记住,在 Web 开发的世界里,永远要“用 Vue 来反应”。

第一章:欢迎来到 Vue.js 的世界!

Vue.js 最初发布于 2014 年,特别是在 2018 年迅速被广泛采用。Vue 在开发者社区中非常受欢迎,这要归功于其易用性和灵活性。如果您正在寻找一个出色的工具来构建和发布优秀性能的 Web 应用程序给最终用户,Vue.js 就是您的答案。

本章突出介绍了 Vue.js 的核心概念,并引导您了解您的 Vue.js 开发环境所需的工具。它还探讨了使您的 Vue.js 开发过程更加可管理的有用工具。通过本章的学习,您将拥有一个简单的 Vue.js 应用程序的工作环境,准备好开始学习 Vue.js 的旅程。

Vue.js 是什么?

Vue.js,或简称 Vue,在法语中意为视图;它是一个用于构建前端应用程序中渐进式、可组合和响应式 用户界面(UI)的 JavaScript 引擎。

从现在开始,我们将使用 Vue 来表示 Vue.js。

Vue 基于 JavaScript 编写,并提供了一种组织机制来构建和组织 Web 应用程序。它还作为转译器,在部署前将 Vue 代码(作为单文件组件,我们将在“Vue 单文件组件结构”中进一步讨论)编译和转换为等效的 HTML、CSS 和 JavaScript 代码。在独立模式下(使用生成的脚本文件),Vue 引擎会在运行时执行代码转换。

Vue 遵循 MVVM(Model–View–ViewModel)模式。与 MVC(Model–View–Controller^(1) 不同,ViewModel 是将数据绑定在视图和模型之间的绑定器。允许视图和模型之间的直接通信,逐步实现组件的响应性。

简言之,Vue 被创建的目的是专注于视图层,但可以逐步适应与其他外部库集成,以实现更复杂的用途。

由于 Vue 专注于视图层,它能够推动单页面应用程序(SPA)的开发。SPA 可以快速、流畅地移动,并且可以持续与后端通信数据。

Vue 官方网站包含 API 文档、安装指南以及主要用例供参考。

Vue 在现代 Web 开发中的优势

Vue 的显著优势在于其写作清晰易懂的文档。此外,围绕 Vue 构建的生态系统和支持社区,如 Vue Router、Vuex 和 Pinia,帮助开发者以最小的努力设置和运行其项目。

Vue 的 API 直接明了,对于之前使用过 AngularJS 或 jQuery 的开发者来说非常熟悉。其强大的模板语法最大程度地减少了学习成本,并且使得在应用程序中处理数据或监听文档对象模型(DOM)事件变得更加容易。

Vue 提供的另一个显著优势是其大小。框架的大小是应用性能的重要因素,特别是交付时的初始加载时间。在撰写本文时,Vue 是最快和最轻量级的框架(约 10kB 的大小)。这一优势导致下载时间较短,并从浏览器的角度提供更好的运行时性能。

随着 Vue 3 的发布,内置对 TypeScript 的支持现在为开发人员提供了类型输入的好处,并使其代码库在长期内更易读、组织和维护。

安装 Node.js

使用 Vue 需要设置开发生态系统和先前的编码知识以跟上学习过程。在开始任何应用程序开发之前,安装 Node.js 和 NPM(或 Yarn)是必要的开发工具。

Node.js(或 Node)是建立在 Chrome 的 V8 JavaScript 运行时引擎上的开源 JavaScript 服务器环境。Node 允许开发人员在本地或托管服务器上编写和运行 JavaScript 应用程序,而无需浏览器。

注意

基于 Chromium 的浏览器(如 Chrome 和 Edge)也使用 V8 引擎将 JavaScript 代码解释为高效的低级计算机代码并执行。

Node 是跨平台支持的,安装也很容易。如果您不确定是否已安装 Node,请打开您的终端(或 Windows 中的命令提示符),并运行以下命令:

node -v

输出应该是一个 Node 版本,如果未安装 Node,则显示“命令未找到”。

如果您尚未安装 Node,或者您的 Node 版本低于12.2.0,请访问Node 项目网站,根据您的操作系统下载最新版本的安装程序(见图 1-1)。

下载完成后,单击安装程序并按照说明进行设置。

安装 Node 时,除了node命令外,还会将npm命令添加到命令行工具中。如果您输入node -v命令,应该会显示安装的版本号。

Node.js 网站上带有下载版本的图片

图 1-1. Node 官方网站上的最新版本下载

NPM

Node 包管理器(NPM)是 Node 的默认包管理器。它会随 Node.js 一起安装。它允许开发人员轻松下载和安装其他远程 Node 包。Vue 和其他前端框架是有用的 Node 包的示例。

NPM 是开发复杂 JavaScript 应用程序的强大工具,具有创建和运行任务脚本(例如启动本地开发服务器)以及自动下载项目包依赖项的能力。

类似于 Node 版本检查,您可以通过npm命令执行 NPM 版本检查:

npm -v

要更新您的 NPM 版本,请使用以下命令:

npm install npm@latest -g

使用参数@latest,你的当前 NPM 工具会自动将其版本更新到最新版。你可以再次运行npm -v来确保它已正确更新。你也可以替换latest为任何指定的 NPM 版本(格式为xx.x.x)。此外,你需要使用-g标志指示全局范围内的安装,以便在本地计算机的任何位置使用npm命令。例如,如果你运行命令npm install npm@6.13.4 -g,该工具将会针对 NPM 包版本 6.13.4 进行安装和更新。

本书中的 NPM 版本

我建议安装 NPM 版本 7.x,以便能够在本书中遵循所有的 NPM 代码示例。

一个 Node 项目依赖于一组 Node 包[²](或依赖项),以便运行。在项目目录中的package.json文件中,你可以找到这些已安装的包。这个package.json文件还描述了项目,包括名称、作者(们)以及其他专门应用于项目的脚本命令。

当你在项目文件夹内运行命令npm install(或npm i)时,NPM 将参考此文件,并将所有列出的包安装到一个名为node_modules的文件夹中,准备供项目使用。此外,它还将添加一个package-lock.json文件,以跟踪安装的包版本和常见依赖之间的兼容性。

要从头开始启动一个带有依赖项的项目,请在项目目录中使用以下命令:

npm init

此命令会引导你回答一些与项目相关的问题,并初始化一个包含你的答案的package.json文件的空项目。

你可以在NPM 官方网站上搜索任何公共开源包。

Yarn

如果 NPM 是标准的包管理工具,那么 Yarn 是由 Facebook 开发的替代性和流行的包管理器。[³] 由于其并行下载和缓存机制,Yarn 更快速、更安全、更可靠。它与所有 NPM 包兼容,因此可以作为 NPM 的即插即用替代品使用。

你可以根据你的操作系统访问Yarn 官方网站来安装最新版本的 Yarn。

如果你正在使用 macOS 计算机并安装了 Homebrew,你可以直接使用以下命令安装 Yarn:

brew install yarn

此命令将全局安装 Yarn 和 Node.js(如果尚未安装)。

你也可以使用以下命令使用 NPM 包管理工具全局安装 Yarn:

npm i -g yarn

现在你应该已经在你的机器上安装了 Yarn 并且准备好使用了。

要检查 Yarn 是否安装并验证其版本,请使用以下命令:

yarn -v

要添加一个新的包,请使用以下命令:

yarn add <node package name>

要为一个项目安装依赖项,而不是使用npm install,你只需要在项目目录中运行yarn命令。一旦完成,类似于 NPM,Yarn 也会在你的项目目录中添加一个yarn.lock文件。

注意

在本书中提供的代码中,我们将使用 Yarn 作为我们的包管理工具。

到目前为止,您已经设置了 Vue 开发的基本编码环境。在下一节中,我们将看看 Vue 开发者工具及其在 Vue 工作中提供的功能。

Vue 开发者工具

Vue 开发者工具(或 Vue Devtools)是官方工具,可帮助您在本地处理 Vue 项目。这些工具包括 Chrome 和 Firefox 的扩展,以及其他浏览器的 Electron 桌面应用程序。您应该在开发过程中安装其中一个工具。

Chrome 用户可以前往Chrome Web Store中的扩展链接安装扩展,如图 1-2 所示。

在 Chrome Webstore 中安装 Vue Devtools 扩展的截图

图 1-2. Chrome 的 Vue Devtools 扩展页面

对于 Firefox,您可以使用Firefox Add-on 页面中的扩展链接,如图 1-3 所示。

在 Firefox Addons 商店中安装 Vue Devtools 扩展的截图

图 1-3. Firefox 的 Vue Devtools 扩展页面

扩展安装并启用后,您可以检测当前是否有任何网站在生产中使用 Vue。当一个站点使用 Vue 构建时,浏览器工具栏上的 Vue 图标将突出显示,如图 1-4 所示。

在 Chrome 工具栏中突出显示的 Vue 官方网站与 Vue Devtools 扩展图标的截图

图 1-4. 图标确认 Vue 官方网站是由 Vue 构建的

Vue Devtools 允许您在浏览器的开发者控制台中检查 Vue 组件树、组件 props 和数据、事件以及路由信息。Vue Devtools 将信息分成各种选项卡,为调试和检查项目中任何 Vue 组件的行为提供有益的见解。

Vite.js 作为构建管理工具

Vite.js(或简称 Vite)是一个 JavaScript 开发服务器,于 2020 年推出,开发过程中使用原生 ES 模块^(4) 导入,而不像 Webpack、Rollup 等将您的代码打包成 JavaScript 文件块。

注意

从现在开始,我们将使用术语 Vite 来指代 Vite.js。

这种方法允许 Vite 在开发过程中以极快的速度进行热重载^(5),使开发体验更加流畅。它还提供许多开箱即用的功能,如对 TypeScript 的支持和按需编译,快速在开发者社区中获得了广泛的认可和适应。

Vue 社区已将 Vue CLI 工具^(6)(使用 Webpack 作为底层)替换为 Vite,成为创建和管理 Vue 项目的默认构建工具。

创建一个新的 Vue 应用程序

使用 Vite,有多种方式创建新的 Vue 应用程序项目。最简单的方法是在命令提示符或终端中使用以下命令语法:

npm init vue@latest

此命令将首先安装create-vue,一个官方的脚手架工具,然后会提出一系列关键问题,以配置您的 Vue 应用程序。

如图 1-5 所示,本书中用于 Vue 应用程序的配置包括:

Vue 项目名称,全小写格式

Vite 使用此值在当前目录中创建一个新的项目目录。

TypeScript

基于 JavaScript 构建的类型化编程语言。

JSX^(7)

在第二章,我们将讨论 Vue 如何支持使用 JSX 标准编写代码(直接在 JavaScript 代码块中编写 HTML 语法)。

Vue Router

在第八章,我们将使用 Vue Router 实现应用程序的路由。

Pinia

在第九章,我们将讨论如何使用 Pinia 在整个应用程序中管理和共享数据。

Vitest

这是任何 Vite 项目的官方单元测试工具,我们将在第十一章进一步探讨。

ESLint

此工具根据一组 ESLint 规则检查您的代码,帮助维护您的编码标准,使其更易读,避免隐藏的编码错误。

Prettier

这个工具可以自动格式化您的代码样式,保持代码整洁、美观,并遵循编码标准。

一个 Vue 应用程序在创建过程中选择的配置图像

图 1-5. 新 Vue 应用程序项目的配置

收到所需配置后,create-vue会相应地为项目创建脚手架。完成后,它将呈现一系列按顺序执行的命令,让您可以在本地运行项目(见图 1-6)。

一些在命令行界面中按顺序执行的命令图像

图 1-6. 为新创建的项目执行的按顺序命令

接下来,我们将探索我们新创建的项目的文件结构。

文件存储库结构

新的 Vue 项目在src目录中包含以下初始结构:

assets

可以放置项目图像、图形和 CSS 文件的文件夹。

components

遵循单文件组件(SFC)概念创建和编写 Vue 组件的文件夹。

router

存放所有路由配置的文件夹。

stores

通过使用 Pinia 在存储中创建和管理项目全局数据的文件夹。

views

所有绑定到定义路由的 Vue 组件的文件夹。

App.vue

主 Vue 应用程序组件,用于承载应用程序中的所有其他 Vue 组件。

main.ts

包含将根组件(App.vue)挂载到 DOM 页面上的 TypeScript 代码。此文件还用于在应用程序中设置插件和第三方库,例如 Vue Router、Pinia 等。

图 1-7 展示了我们 Vue 项目的结构。

新创建的 Vue 应用程序的折叠文件结构图像,带有专用文件图标

图 1-7. 我们创建的 learning-vue-app 项目的文件结构

在项目的根目录中有一个 index.html 文件,这是在浏览器中加载应用程序的入口点。它使用 <script> 标签导入 main.ts 文件,并为 Vue 引擎提供目标元素,通过执行 main.ts 中的代码加载 Vue 应用程序。在开发过程中,此文件可能保持不变。

您可以在 专用 Github 仓库 中找到所有示例代码。我们按章节组织这些代码文件。

概要

在本章中,我们学习了 Vue 的好处以及如何为 Vue 开发环境安装必要的工具。我们还讨论了 Vue 开发者工具和其他有效构建 Vue 项目的工具,例如 Vite。现在我们已经创建了我们的第一个 Vue 项目,准备学习 Vue 的基础知识:Vue 实例、内置指令以及 Vue 如何处理响应性。

^(1) MVC 模式通过将应用程序结构分为 UI(视图)、数据(模型)和控制逻辑(控制器)来帮助实现应用程序。虽然视图和控制器可以进行双向绑定,但只有控制器可以操作模型。

^(2) 这些通常被称为 NPM 包。

^(3) 自 2021 年以来,Facebook 已被称为 Meta。

^(4) ES 模块代表 ECMAScript 模块,自 ES6 发布以来,这是一个在 Node.js 和最近在浏览器中使用的流行标准。

^(5) 热重载自动将新的代码更改应用于正在运行的应用程序,无需重新启动应用程序或刷新页面。

^(6) Vue 命令行界面。

^(7) JavaScript XML,在 React 中常用

第二章:Vue 工作原理:基础知识

在上一章中,您学习了构建 Vue 应用程序的基本工具,并创建了第一个 Vue 应用程序,为学习通过编写 Vue 代码了解 Vue 工作原理做好了准备。

本章介绍了虚拟文档对象模型(Virtual DOM)的概念和使用 Vue 选项 API 编写 Vue 组件的基础知识。它还探讨了更多的 Vue 指令和 Vue 响应性机制。通过本章末尾,您将理解 Vue 的工作原理,并能够编写和注册用于应用程序的 Vue 组件。

虚拟 DOM 内部运行机制

Vue 不直接与文档对象模型(DOM)一起工作。相反,它实现了虚拟 DOM 来优化应用程序在运行时的性能。

要建立对虚拟 DOM 工作原理的扎实理解,我们首先从 DOM 的概念开始。

DOM 在 Web 上代表 HTML(或 XML)文档内容,以内存中树状数据结构的形式表示(如图 2-1 所示)。它充当一个连接网页和实际编程代码(如 JavaScript)的编程接口。HTML 文档中的标签,例如 <div><section>,在 DOM 中被表示为编程节点和对象。

一个示意图,展示了不同 HTML 元素连接在一起,通过嵌套级别进行分布

图 2-1. DOM 树示例

浏览器解析 HTML 文档后,DOM 将立即可供交互使用。在任何布局更改时,浏览器会在后台持续地绘制和重绘 DOM。我们称这个过程为解析,而绘制 DOM 屏幕的过程称为光栅化或像素到屏幕流水线。图 2-2 展示了光栅化的工作原理:

一个示意图,展示了由五个主要步骤组成的流程图,包括解析 HTML 和 CSS 代码、计算元素的 CSS 样式、规划屏幕布局、然后绘制视觉元素,最后在浏览器上应用组合层。它还突出显示了每当布局更改时重绘和回流发生的位置。

图 2-2. 浏览器光栅化过程

布局更新问题

每次绘制对浏览器性能都是昂贵的。由于 DOM 可能包含许多节点,查询和更新单个或多个节点可能非常昂贵。

这里是 DOM 中 li 元素列表的一个简单示例:

<ul class="list" id="todo-list">
  <li class="list-item">To do item 1</li>
  <li class="list-item">To do item 2</li>
  <!--so on…-->
</ul>

添加/移除 li 元素或修改其内容需要使用 document.getElementById(或 document.getElementsByClassName)查询该项的 DOM。然后,您需要使用适当的 DOM API 执行所需的更新。

例如,如果您想向前面的示例中添加一个新项目,则需要执行以下步骤:

  1. 通过其 id 属性的值查询包含的列表元素——"todo-list"

  2. 使用 document.createElement() 添加新的 li 元素

  3. 使用 setAttribute() 设置 textContent 和相关属性以匹配其他元素的标准。

  4. 将该元素作为其子元素附加到步骤 1 中找到的列表元素中,使用 appendChild()

const list = document.getElementById('todo-list');

const newItem = document.createElement('li');
newItem.setAttribute('class', 'list-item');
newItem.textContent = 'To do item 3';
list.appendChild(newItem);

类似地,假设您想要更改第二个 li 元素的文本内容为 "购买杂货"。那么,您需要执行第一步来获取包含的列表元素,然后使用 getElementsByClassName() 查询目标元素,最后将其 textContent 更改为新内容:

const secondItem = list.getElementsByClassName('list-item')[1];
secondItem.textContent = 'Buy groceries'

在小规模上查询和更新 DOM 通常不会对性能产生巨大影响。但是,如果在更复杂的网页上重复(在几秒钟内)进行这些操作,则可能会减慢页面。当连续进行小更新时,性能影响尤为显著。许多框架,如 Angular 1.x,在代码基础增长时未能认识和解决这个性能问题。虚拟 DOM 的设计目的就是解决布局更新问题。

什么是虚拟 DOM?

虚拟 DOM 是浏览器中实际 DOM 的内存虚拟副本版本,但它更轻量且具有额外的功能。它模仿了真实 DOM 结构,使用不同的数据结构(通常是 Object)(见 图 2-3)。

图 2-3. 浏览器 DOM vs. 虚拟 DOM

图 2-3. 浏览器 DOM vs. 虚拟 DOM

在幕后,虚拟 DOM 仍然使用 DOM API 在浏览器中构建和渲染更新的元素。因此,它仍会导致浏览器的重绘过程,但更高效。

简而言之,虚拟 DOM 是一个抽象模式,旨在解放 DOM 免受一切可能导致性能低下的操作,比如操纵属性、处理事件以及手动更新 DOM 元素。

Vue 中虚拟 DOM 的工作原理

虚拟 DOM 位于真实 DOM 和 Vue 应用程序代码之间。以下是虚拟 DOM 中节点的示例:

const node = {
 tag: 'div',
 attributes: [{ id: 'list-container', class: 'list-container' }],
 children: [ /* an array of nodes */]
}

让我们称这个节点为 VNode。VNode 是虚拟 DOM 中的虚拟节点,表示实际 DOM 中的 DOM 元素。

通过用户界面交互,用户告诉 Vue 他们希望元素处于的状态;然后 Vue 触发虚拟 DOM 以将该元素的表示对象(node)更新到所需形状,并跟踪这些变化。最后,它与实际 DOM 通信,并根据变更的节点执行准确的更新。

由于虚拟 DOM 是一组自定义 JavaScript 对象的树,更新组件等同于更新自定义 JavaScript 对象。这个过程不会花费太长时间。因为我们不调用任何 DOM API,所以这个更新动作不会引起 DOM 重绘。

一旦虚拟 DOM 完成更新,它会批量与实际 DOM 同步,从而使更改反映在浏览器上。

图 2-4 展示了在添加新列表项并更改列表项文本时,虚拟 DOM 到实际 DOM 的更新过程。

一个图示展示了如何通过比较实际 DOM 和虚拟 DOM 之间的差异,并对实际 DOM 执行补丁更新来实现更新的过程。

图 2-4. 从虚拟 DOM 到实际 DOM 的更新,添加新元素并更新列表中现有元素的文本的过程

由于虚拟 DOM 是对象树,当修改虚拟 DOM 时,我们可以轻松地跟踪需要与实际 DOM 同步的特定更新。现在,我们不再直接查询和更新实际 DOM,而是可以在一个更新周期中调度和调用更新的 API,并通过单个渲染函数维护性能效率。

现在我们理解了虚拟 DOM 的工作原理,我们将探讨 Vue 实例和 Vue 选项 API。

Vue 应用实例和选项 API

每个 Vue 应用程序从一个单一的 Vue 组件实例作为应用程序根开始。同一应用程序中创建的任何其他 Vue 组件都需要嵌套在此根组件中。

注意

你可以在我们的 Vue 项目的main.ts中找到初始化代码示例。Vite 会在其脚手架过程中自动生成这段代码。

你也可以在这个文件中找到本章的示例代码。

在 Vue 2 中,Vue 为您暴露了一个Vue类(或 JavaScript 函数),您可以根据一组配置选项创建一个 Vue 组件实例,使用以下语法:

const App = {
  //component's options
}
const app = new Vue(App)

Vue接收一个组件,或者更精确地说是组件的配置。组件的配置是一个包含所有组件初始配置选项的对象。我们称这个参数结构为选项 API,这是 Vue 的另一个核心 API。

从 Vue 3 开始,你不能再直接调用new Vue()。相反,你需要使用vue包中的createApp()方法来创建应用程序实例。这种功能上的变化增强了每个 Vue 实例的隔离性,包括依赖关系和共享组件(如果有的话),并提升了代码可读性:

import { createApp } from 'vue'

const App = {
  //component's options
}

const app = createApp(App)

createApp()还接受一个组件配置的对象。根据这些配置,Vue 创建一个 Vue 组件实例作为其应用程序根app。然后,您需要使用app.mount()方法将根组件app挂载到所需的 HTML 元素上,如下所示:

app.mount('#app')

#app是应用程序根元素的唯一 ID 选择器。Vue 引擎使用此 ID 查询元素,将应用实例挂载到它,然后在浏览器中呈现应用程序。

下一步是提供配置,使 Vue 可以根据选项 API 构建组件实例。

注意

从这一点开始,我们根据 Vue 3 API 标准编写代码。

探索选项 API

选项 API 是 Vue 的核心 API,用于初始化 Vue 组件。它以对象格式结构化组件的配置。

我们将其基本属性分为四个主要类别:

状态处理

包括data(),它返回组件的本地数据状态,computedmethodswatch用于对特定本地数据进行观察,以及用于传入数据的props

渲染

template用于 HTML 视图模板,render()用作组件的渲染逻辑。

生命周期钩子

例如beforeCreate()created()mounted()等,用于处理组件生命周期的不同阶段。

其他

例如provide()inject()用于处理不同组件之间的定制和通信。以及components,这是一组嵌套组件模板,可在组件内使用。

以下是基于选项 API 的根App组件的示例结构:

import { createApp } from 'vue'

const App = {
 template: "This is the app's entrance",
}

const app = createApp(App)
app.mount('#app')

在前面的代码中,HTML 模板显示了常规文本。我们还可以使用data()函数定义本地data状态,我们将在“使用数据属性创建本地状态”中进一步讨论。

您还可以重写先前的代码以使用render()函数:

import { createApp } from 'vue'

const App = {
 render() {
  return "This is the app's entrance"
 }
}

const app = createApp(App)
app.mount('#app')

两个代码将生成相同的结果(图 2-5)。

一张图片显示了一个文本,说这是应用程序的入口。

图 2-5. 使用选项 API 编写根组件的示例输出

如果您在浏览器的开发者工具中打开元素选项卡,您将看到实际的 DOM 现在包含一个带有id="app"的 div 和文本内容*这是应用程序的入口*(图 2-6)。

一张图片显示了包含渲染的实际 DOM 的 HTML 代码。

图 2-6. 浏览器中的 DOM 树包含一个包含应用程序文本内容的 div

您还可以创建一个名为Description的新组件,用于呈现静态文本,并将其传递给Appcomponents。然后您可以在template中将其作为嵌套组件使用,如示例 2-1 所示。

示例 2-1. 声明一个内部组件模板以在App中使用
import { createApp } from 'vue'

const Description = {
 template: "This is the app's entrance"
};

const App = {
 components: { Description },
 template: '<Description />'
}

const app = createApp(App)
app.mount('#app')

输出与图 2-6 中相同。

注意这里必须声明templaterender()函数(参见“渲染函数和 JSX”)用于组件。但是,如果您按照单文件组件(SFC)标准编写组件,则不需要这些属性。我们将在第三章中讨论这个组件标准。

接下来,让我们看看template属性的语法。

模板语法

在 Options API 中,template接受一个包含有效 HTML 代码的字符串,表示组件的 UI 布局。Vue 引擎解析此值并将其编译为优化的 JavaScript 代码,然后相应地渲染相关的 DOM 元素。

下面的代码演示了我们的根组件App,其布局是一个单一的div显示文本—这是应用程序的入口

import { createApp } from 'vue'

const App = {
 template: "<div>This is the app's entrance</div>",
}

const app = createApp(App)
app.mount('#app')

对于多级 HTML 模板代码,我们可以使用反引号字符(JavaScript 模板字面量),由 ` 符号表示,并保持可读性。我们可以重写前面示例中App的模板,以包括其他h1h2元素,如下所示:

import { createApp } from 'vue'

const App = {
 template: `
 <h1>This is the app's entrance</h1>
 <h2>We are exploring template syntax</h2>
`,
}

const app = createApp(App)
app.mount('#app')

Vue 引擎将使用两个标题渲染到 DOM 中(见图 2-7)。

该图像显示两个标题,一个说这是应用程序的入口,用大粗体字显示,另一个说我们正在探索模板语法,用小粗体字显示

图 2-7. 组件多级模板的输出

template属性语法对于使用指令和专用语法创建特定 DOM 元素与组件本地数据之间的绑定至关重要。接下来我们将探讨如何定义我们想在 UI 中显示的数据。

使用数据属性创建本地状态

大多数组件保持其本地状态(或本地数据)或从外部源接收数据。在 Vue 中,我们使用 Options API 的data()函数属性来存储组件的本地状态。

data()是一个匿名函数,返回表示组件本地数据状态的对象。我们称该返回的对象为数据对象。初始化组件实例时,Vue 引擎将此数据对象的每个属性添加到其响应系统中,以跟踪其更改并相应地触发 UI 模板的重新渲染。

简而言之,数据对象是组件的响应式状态。

要在模板中注入数据属性,我们使用mustache语法,用双花括号 {{}} 表示。在 HTML 模板中,我们在需要注入数值的地方用花括号包裹数据属性,如在示例 2-2 中所见。

示例 2-2. 注入标题以在 HTML 模板中显示
import { createApp } from 'vue'

type Data = {
  title: string;
}

const App = {
 template: `
 <div>{{ title }}</div>
`,
 data(): Data {
  return {
   title: 'My first Vue component'
  }
 }
}

const app = createApp(App)
app.mount('#app')

在前面的代码中,我们声明了本地数据属性title,并通过使用{{ title }}表达式将其值注入到App的模板中。DOM 中的输出等于以下代码:

<div>My first Vue component</div>

您还可以在同一元素标记内将内联静态文本与双花括号结合起来使用:

const App = {
 template: `
 <div>Title: {{ title }}</div>
`,
 /**... */
}

Vue 自动保留静态文本,并仅用正确的值替换表达式。结果等同于以下内容:

<div>Title: My first Vue component</div>

所有数据对象属性都可以直接通过组件实例this在组件的本地方法、计算属性和生命周期钩子中访问。例如,在使用created()钩子创建组件后,我们可以将title打印到控制台上:

import { createApp, type ComponentOptions } from 'vue'

const App = {
 /**... */
 created() {
  console.log((this as ComponentOptions<Data>).title)
 }
}

const app = createApp(App)
app.mount('#app')
注意

我们将this转换为ComponentOptions<Data>类型。我们将在 “使用 defineComponent() 支持 TypeScript” 中进一步讨论如何为 Vue 3 启用完整的 TypeScript 支持。

您可以通过使用 Vue Devtools 调试数据属性的响应性。在应用程序的主页面上,打开浏览器的开发者工具,转到 Vue 选项卡,并选择检查器面板中显示的Root组件。一旦选择了这个组件,右侧将出现一个面板,显示组件数据对象的属性。当您悬停在title属性上时,会出现一个铅笔图标,允许您编辑属性值(图 2-8)。

一张截图显示了 Vue Devtools,突出显示了标题属性,并在属性值旁边的行右侧出现了铅笔图标。

图 2-8. 如何使用 Vue Devtools 调试和编辑数据属性

点击编辑图标按钮,修改title的值,然后按回车;应用程序 UI 立即反映新值。

您已经学会如何使用data()和双大括号{{}}将本地数据注入到 UI 模板中。这是一种单向数据绑定。

在我们探索 Vue 中的双向绑定和其他指令之前,让我们先了解一下 Vue 中的响应性。

Vue 中响应性的工作原理

要理解响应性的工作原理,让我们快速看一下虚拟 DOM 如何处理所有接收到的信息,创建并跟踪创建的 VNode,然后将其传递给实际 DOM(图 2-9)。

一张图表展示了当组件数据发生变化时,从虚拟 DOM 到实际 DOM 的渲染过程,有五种不同的流程状态。

图 2-9. 虚拟 DOM 渲染过程的流程

我们可以将前面的流程图描述如下:

  1. 一旦定义了本地数据,在 Vue.js 2.0 中,内部 Vue 引擎使用 JavaScript 内置的Object.defineProperty()为每个相关的数据片段建立getter 和 setter,并启用相关的数据响应性。然而,在 Vue.js 3.0 中,Vue 引擎使用基于 ES5 代理机制的机制^(1) 来提升性能,运行时性能翻倍,内存需求减半。我们将在 第三章 中更详细地解释这种响应性机制。

  2. 设置了响应式机制后,Vue 引擎使用观察者对象来跟踪由设置器触发的任何数据更新。观察者帮助 Vue 引擎检测更改并通过队列系统更新虚拟 DOM 和实际 DOM。

  3. Vue 使用队列系统来避免在短时间内无效的多次 DOM 更新。当相关组件的数据发生变化时,观察者将自身添加到队列中。Vue 引擎按照特定顺序对其进行消费。直到 Vue 引擎完成对队列中观察者的消费和刷新,即使有多个数据变化,同一组件的观察者在队列中也只存在一个。这个消费过程是通过nextTick() API 完成的,这是一个 Vue 函数。

  4. 最后,在 Vue 引擎消费和刷新所有观察者之后,它会触发每个观察者的run()函数来自动更新组件的真实 DOM 和虚拟 DOM,并使应用程序渲染。

让我们进行另一个例子。这次我们使用data()并借助created()来展示应用程序中的响应性。created()是 Vue 引擎在创建组件实例后但在将其挂载到 DOM 元素之前触发的生命周期钩子。在这一点上,我们不会进一步讨论这个钩子,而是利用这个钩子来对数据属性counter执行定时器更新,使用setInterval

import { createApp, type ComponentOptions } from 'vue'

type Data = {
  counter: number;
}

const App = {
 template: `
 <div>Counter: {{ counter }}</div>
`,
 data(): Data {
  return {
   counter: 0
  }
 },
 created() {
  const interval = setInterval(() => {
   (this as ComponentOptions<Data>).counter++
  }, 1000);

  setTimeout(() => {
   clearInterval(interval)
  }, 5000)
 }
}

const app = createApp(App)
app.mount('#app')

此代码每秒增加counter。^(2) 我们还使用setTimeout()来在 5 秒后清除间隔。在浏览器上,您可以看到每秒从 0 到 5 变化的显示值。最终输出将等于字符串:

Counter: 5

在理解了 Vue 中的响应性和渲染概念之后,我们准备探讨如何执行双向数据绑定。

使用v-model实现双向绑定

双向绑定指的是如何在组件逻辑和视图模板之间同步数据。当组件的数据字段在程序中改变时,新值会反映在其 UI 视图上。反之,当用户在 UI 视图上对数据字段进行更改时,组件会自动获取并保存更新后的值,保持内部逻辑和 UI 的同步。一个很好的双向绑定例子是表单输入字段。

双向数据绑定是应用开发中复杂但有益的用例。双向绑定的一个常见场景是表单输入同步。正确实现可以节省开发时间,并减少在实际 DOM 和组件数据之间维护数据一致性的复杂性。但实现双向绑定是一个挑战。

幸运的是,Vue 通过v-model指令使双向绑定变得更简单。将v-model指令绑定到组件的数据模型将自动触发在数据模型更改时更新模板,反之亦然。

语法很简单;传递给v-model的值是在data返回对象中声明的名称别名。

假设我们有一个NameInput组件,它从用户那里接收文本输入,具有以下template代码:

const NameInput = {
 template: `
 <label for="name">
 <input placeholder="Enter your name" id="name">
 </label>`
}

我们希望将接收到的输入值与名为name的本地数据模型同步。为此,我们在input元素中添加v-model="name",并相应地在data()中声明数据模型:

const NameInput = {
 template: `
 <label for="name">
 Write your name:
 <input
 v-model="name"
 placeholder="Enter your name"
 id="name"
 >
 </label>`,
 data() {
  return {
   name: '',
  }
 }
}

当用户在运行时更改输入字段时,name的值也会相应更改。

要使此组件在浏览器中渲染,我们将NameInput添加为应用程序的组件之一:

import { createApp } from 'vue'

const NameInput = {
  /**... */
}

const app = createApp({
 components: { NameInput },
 template: `<NameInput />`,
})

app.mount('#app')

您可以通过在浏览器的开发者工具中打开 Vue 选项卡来跟踪此数据更改。在检查器选项卡中,找到并选择Root元素下的NameInput元素,您将在 Vue 选项卡的右侧面板上看到组件的数据显示(图 2-10)。

屏幕截图显示了开发者工具打开在浏览器底部,Vue 是活动选项卡,并在右侧面板显示了组件的信息。

图 2-10. 使用 Vue 选项卡在开发者工具中调试输入组件

当您更改输入字段时,右侧 Vue 选项卡下的data中的name属性也会得到更新的值(图 2-11)。

屏幕截图显示了当在 Vue 选项卡中看到“名称”数据属性上的新值输入字段时,输入字段如何反映。

图 2-11. 输入值更改与相关组件的数据模型同步

您可以使用相同的方法构建包含多个选项的清单。在这种情况下,您需要将数据模型声明为Array并在每个复选框输入字段上添加v-model绑定。示例 2-3 演示了如何为CourseChecklist设置。

示例 2-3. 使用v-model和复选框输入创建课程清单
import { createApp } from 'vue'

const CourseChecklist = {
 template: `
 <div>The course checklist: {{list.join(', ')}}</div>
 <div>
 <label for="chapter1">
 <input
 v-model="list"
 type="checkbox"
 value="chapter01"
 id="chapter1"
 >
 Chapter 1
 </label>
 <label for="chapter2">
 <input
 v-model="list"
 type="checkbox"
 value="chapter02"
 id="chapter2"
 >
 Chapter 2
 </label>
 <label for="chapter3">
 <input
 v-model="list"
 type="checkbox"
 value="chapter03"
 id="chapter3"
 >
 Chapter 3
 </label>
 </div>
 `,
 data() {
  return {
   list: [],
  }
 }
}

const app = createApp({
 components: { CourseChecklist },
 template: `<CourseChecklist />`,
})

app.mount('#app')

根据用户的交互,Vue 会自动将输入值添加或从list数组中移除(图 2-12)。

屏幕截图显示了课程清单,提供了三个复选框选项供选择。

图 2-12. 用户进行选择后列表值的屏幕截图

使用 v-model.lazy 修饰符

在用户每次按键时更新数据值可能会过于频繁,特别是在其他位置显示输入值时。请记住,Vue 会根据数据更改重新渲染模板 UI。通过使用v-model.lazy修饰符而不是常规的v-model来绑定数据模型,可以减少此开销:

const NameInput = {
 template: `
 <label for="name">
 Write your name:
 <input
 v-model.lazy="name"
 placeholder="Enter your name"
 id="name"
 >
 </label>`,
 data() {
  return {
   name: '',
  }
 }
}

此修饰符确保v-model仅跟踪由该输入元素的onChange事件触发的更改。

使用v-model.numberv-model.trim修饰符

如果要绑定到v-model的数据模型应为数字类型,则可以使用修饰符v-model.number将输入值转换为数字。

同样地,如果你想确保字符串数据模型没有尾随的空白字符,你可以使用v-model.trim

这就是双向绑定的全部内容。接下来我们将详细讨论更常见的指令v-bind,用于单向绑定。

绑定响应数据和传递 Props 数据与 v-bind

之前我们学习了使用v-model进行双向绑定,使用双大括号{{}}进行单向数据注入。但是要将数据进行单向绑定到另一个元素的属性值或其他 Vue 组件的 props,我们使用v-bind

v-bind,以:表示,是任何应用程序中最常用的 Vue 指令。我们可以将元素的属性(或组件的 props)或更多绑定到 JavaScript 表达式,遵循以下语法:

v-bind:<attribute>="<expression>"

或者,使用: 语法:

:<attribute>="<expression>"

例如,我们有imageSrc数据,一个图片的 URL。为了使用<img>标签显示图片,我们对其src属性执行以下绑定:

示例 2-4. 绑定图片的源
import { createVue } from 'vue'

const App = {
 template: `
 <img :src="imageSrc" />
 `,
 data() {
  return {
   imageSrc: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat"
  }
 }
}

const app = createApp(App)

app.mount('#app')

Vue 获取imageSrc的值并将其绑定到src属性,从而在 DOM 上生成如下的代码:

<img src="https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat" >

imageSrc的值发生变化时,Vue 会更新src

你也可以将v-bind作为独立属性添加到元素上。v-bind接受一个包含要绑定的所有属性和它们值的表达式的对象。示例 2-5 重新编写示例 2-4 以演示此用例:

示例 2-5. 使用对象将源和 alt 文本绑定到图片
import { createVue } from 'vue'

const App = {
 template: `
 <img v-bind="image" />
 `,
 data() {
  return {
   image: {
    src: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat",
    alt: "A random cute cate image"
   }
  }
 }
}

const app = createApp(App)

app.mount('#app')

在示例 2-5 中,我们绑定一个对象image,包含两个属性,src表示图片的 URL,alt表示其 alt 文本,绑定到<img>元素上。Vue 引擎将自动根据属性名称解析image为相应的属性,并在 DOM 中生成以下 HTML 代码:

<img
 src="https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat"
 alt="A random cute cate image"
>

绑定到 Class 和 Style 属性

当绑定到classstyle属性时,你可以以数组或对象类型传递表达式。Vue 引擎知道如何解析并将它们合并为适当的样式或类名字符串。

例如,在示例 2-5 中给我们的img添加一些类:

import { createVue } from 'vue'

const App = {
 template: `
 <img v-bind="image" />
 `,
 data() {
  return {
   image: {
    src: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat",
    alt: "A random cute cate image",
    class: ["cat", "image"]
   }
  }
 }
}

const app = createApp(App)

app.mount('#app')

这段代码生成了一个<img>元素,类名为单个字符串"cat image",如下所示:

<img
 src="https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat"
 alt="A random cute cate image"
 class="cat image"
>

你还可以通过将class属性绑定到一个对象来执行动态类名绑定,对象的属性值根据布尔型数据值isVisible而定:

import { createVue } from 'vue'

const isVisible = true;

const App = {
 template: `
 <img v-bind="image" />
 `,
 data() {
  return {
   image: {
    src: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat",
    alt: "A random cute cate image",
    class: {
     cat: isVisible,
     image: !isVisible
     }
   }
  }
 }
}

const app = createApp(App)

app.mount('#app')

这里我们定义了img元素,当isVisibletrue时具有cat类,否则具有image类。当isVisibletrue时生成的 DOM 元素现在是:

<img
 src="https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat"
 alt="A random cute cate image"
 class="cat" >

isVisiblefalse时,输出与之类似,但class名为image而非cat

你可以使用相同的方法处理style属性,或者传递一个包含以驼峰格式命名的 CSS 规则的对象。例如,在示例 2-5 中给我们的图片添加一些边距:

import { createVue } from 'vue'

const App = {
 template: `
 <img v-bind="image" />
 `,
 data() {
  return {
   image: {
    src: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat",
    alt: "A random cute cate image",
    style: {
     marginBlock: '10px',
     marginInline: '15px'
    }
   }
  }
 }
}

const app = createApp(App)

app.mount('#app')

这段代码为img元素生成内联样式,应用了margin-block: 10pxmargin-inline: 15px

你也可以将几个样式对象合并为一个单独的style数组。Vue 知道如何将它们统一成一个单一的样式规则字符串,如下所示:

import { createVue } from 'vue'

const App = {
 template: `
 <img v-bind="image" />
 `,
 data() {
  return {
   image: {
    src: "https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat",
    alt: "A random cute cate image",
    style: [{
     marginBlock: "10px",
     marginInline: "15px"
    }, {
     padding: "10px"
    }]
   }
  }
 }
}

const app = createApp(App)

app.mount('#app')

输出的 DOM 元素将是:

<img
 src="https://res.cloudinary.com/mayashavin/image/upload/TheCute%20Cat"
 alt="A random cute cate image"
 style="margin-block: 10px; margin-inline: 15px; padding: 10px" >

使用 v-bind 进行样式绑定

通常来说,内联样式并不是一个良好的实践。因此,我不推荐使用v-bind来组织组件的样式。我们将在第三章中讨论在 Vue 中正确处理样式的方法。

接下来,让我们在 Vue 组件中遍历一个数据集合。

使用 v-for 遍历数据集合

动态列表渲染对于减少重复代码、增加代码可重用性以及保持类似元素类型组的格式一致性至关重要。例如文章列表、活跃用户和你关注的 TikTok 账号列表。在这些示例中,数据是动态的,而内容类型和 UI 布局保持相似。

Vue 提供了v-for指令,用于完成对迭代数据集(如数组或对象)的迭代目标。我们可以直接在元素上使用该指令,遵循以下语法:

v-for = "elem in list"

elem只是数据源list中每个元素的别名。

例如,如果我们想要遍历一个数字数组[1, 2, 3, 4, 5]并打印出元素的值,我们可以使用以下代码:

import { createApp } from 'vue'

const List = {
 template: `
 <ul>
 <li v-for="number in numbers" :key="number">{{number}}</li>
 </ul>
 `,
 data() {
  return {
   numbers: [1, 2, 3, 4, 5]
  };
 }
};

const app = createApp({
 components: { List },
 template: `<List />`
})

app.mount('#app')

这段代码等同于编写以下的原生 HTML 代码:

<ul>
 <li>1</li>
 <li>2</li>
 <li>3</li>
 <li>4</li>
 <li>5</li>
</ul>

使用v-for的一个重要优势是保持模板的一致性,并动态地将数据内容映射到相关元素,无论数据源如何随时间变化。

每个由v-for迭代生成的块都可以访问其他组件的数据和特定的列表项。例如,可以看示例 2-6。

示例 2-6. 使用v-for编写任务列表组件
import { createApp } from 'vue'

const List = {
 template: `
 <ul>
 <li v-for="task in tasks" :key="task.id">
 {{title}}: {{task.description}}
 </li>
 </ul>
 `,
 data() {
  return {
   tasks: [{
    id: 'task01',
    description: 'Buy groceries',
   }, {
    id: 'task02',
    description: 'Do laundry',
   }, {
    id: 'task03',
    description: 'Watch Moonknight',
   }],
   title: 'Task'
  }
 }
}

const app = createApp({
 components: { List },
 template: `<List />`
})

app.mount('#app')

图 2-13 显示了输出:

输出是一个包含每行任务描述的列表

图 2-13. 每行具有默认标题的任务列表输出

保持唯一性与键属性

在这里,我们必须为每个迭代的元素定义一个唯一的key属性。Vue 使用此属性来跟踪每个渲染的元素,以便后续更新。有关其重要性的讨论,请参见“使用 Key 属性使元素绑定唯一”。

此外,v-for支持一个可选的第二个参数index,表示当前元素在迭代集合中的索引位置。我们可以如下重写示例 2-6:

import { createApp } from 'vue'

const List = {
 template: `
 <ul>
 <li v-for="(task, index) in tasks" :key="task.id">
 {{title}} {{index}}: {{task.description}}
 </li>
 </ul>
 `,
 //...
}

//...

这段代码块生成以下输出(图 2-14):

输出是包含 3 行列表,每行都有从 0 到 2 的索引前缀

图 2-14. 每个任务索引输出的任务列表

到目前为止,我们已经涵盖了数组集合的迭代。接下来让我们看看如何遍历对象的属性。

遍历对象属性

在 JavaScript 中,Object是一种键值映射表类型,其中每个对象属性都是表的唯一键。要遍历对象的属性,我们使用与数组迭代类似的语法:

v-for = "(value, name) in collection"

这里,value表示属性的值,name表示属性的键。

下面展示了如何遍历对象集合的属性,并根据格式<name>: <value>打印出每个属性的namevalue

import { createApp } from 'vue'

const Collection = {
 data() {
  return {
   collection: { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    title: 'Watch Moonknight',
    description: 'Log in to Disney+ and watch all the chapters',
    priority: '5'
   }
  }
 },
 template: ` <ul>
  <li v-for="(value, name) in collection" :key="name"> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png) {{name}}: {{value}}
  </li>
 </ul> `,
}

const app = createApp({
 components: { Collection },
 template: `<Collection />`
})

app.mount('#app')

1

定义一个包含三个属性titledescriptionprioritycollection对象。

2

遍历collection属性。

图 2-15 显示了输出。

输出是列表,每行显示集合对象的标题、描述和优先级

图 2-15. 默认标题的集合对象输出。

我们仍然可以访问当前对的索引外观作为第三个参数,如以下语法所示:

v-for = “(value, name, index) in collection”

如前所述,我们总是必须为每个迭代元素定义key属性值。该属性在使元素更新绑定唯一方面非常重要。接下来我们将探讨key属性。

使用key属性使元素绑定唯一。

Vue 引擎通过一种简单的就地修补策略跟踪和更新使用v-for渲染的元素。然而,在各种场景中,我们需要完全控制列表重新排序或防止列表元素依赖其子组件状态时出现不希望的行为。

Vue 提供了一个额外的属性:key,作为每个节点元素的唯一标识符,绑定到特定的迭代列表项。Vue 引擎将其用作提示,以跟踪、重用和重新排序渲染的节点及其嵌套元素,而不是直接修补。

key属性的语法用法很简单。我们使用v-bind:key(简写为:key),并为该列表元素绑定一个唯一值:

<div v-for="(value, name, index) in collection" :key="index">

保持key的唯一性。

key应该是项目的唯一标识符(id)或列表中的外观索引

作为良好的实践,使用v-for时必须始终提供key属性。

然而,如果没有提供key,Vue 会在浏览器控制台上抛出警告。此外,如果在应用程序中启用了 ESLint,它会抛出错误并立即警告您缺少key属性,如图 2-16 所示。

当我们尝试使用项目键迭代列表时,ESLint 工具会突出显示并显示警告

图 2-16. 当没有提供key时的 ESLint 警告

key属性的有效值

key应为字符串或数字值。对象或数组不是有效的键使用。

key属性非常有用,甚至超出了v-for的范围。没有key属性,无法应用内置的列表过渡和动画效果。我们将在第八章进一步讨论key的好处。

使用v-on为元素添加事件监听器

为了将 DOM 事件绑定到监听器,Vue 为元素标签暴露了内置指令v-on(缩写为@)。v-on指令接受以下值类型:

  • 一些内联的 JavaScript 语句,形式为字符串

  • methods属性下声明的组件选项中的组件方法名称

我们使用以下格式与v-on一起使用:

v-on:<event>= “<inline JavaScript code / name of method>”

或者使用较短版本的@

@<event>=”<inline JavaScript code / name of method>”
注意

从现在起,我们将使用@来表示v-on

然后直接在任何元素上作为属性添加该指令:

<button @click= "printMsg='Button is clicked!'">
Click me
</button>

为了代码可读性,特别是在复杂的代码库中,建议将 JavaScript 表达式保持在组件方法内部,并通过其名称在指令上公开使用,如示例 2-7。

示例 2-7. 使用v-on指令在按钮点击时改变printMsg的值
import { createApp, type ComponentOptions } from 'vue'

type Data = {
  printMsg: string;
}

const App = {
 template: `
 <button @click="printMessage">Click me</button>
 <div>{{ printMsg }}</div>
 `,
 methods: {
  printMessage() {
   (this as ComponentOptions<Data>).printMsg = "Button is clicked!"
  }
 },
 data(): Data {
  return {
   printMsg: "Nothing to print yet!",
  }
 }
}

const app = createApp(App)

app.mount("#app");

如果用户尚未点击按钮,则按钮下方的显示消息将是“尚未打印任何内容”(见图 2-17)。

一张截图显示一个消息'尚未打印任何内容!'

图 2-17. 默认显示“尚未打印任何内容”消息

否则,消息将变成“按钮已点击!”(见图 2-18)。

一个文本显示'按钮已点击!'在点击我按钮后出现

图 2-18. 用户点击按钮后显示“按钮已点击!”消息

使用v-on事件修饰符处理事件

在浏览器将事件分派到目标元素之前,它会使用当前 DOM 树结构构建该事件的传播路径列表。此路径的最后一个节点是目标元素本身,前面的节点依次是其祖先。一旦分派,事件将通过一个或所有三个主要事件阶段传播(见图 2-19):

捕获(或捕获阶段)

事件从顶层祖先元素传播到目标元素。

目标

事件在目标元素上。

冒泡

事件从目标元素向上冒泡到其祖先。

我们通常在监听器逻辑内部通过编程干预事件传播流程。利用v-on的修饰符,我们可以直接干预指令级别。

使用以下格式跟随v-on修饰符:

v-on:<event>.<modifier>

图示显示传播阶段的级别,从底部向上和从顶部向下。

图 2-19. 点击事件传播流程图

修饰符的一个优势是使监听器尽可能通用和可重用。我们无需在内部担心特定于事件的细节,如preventDefaultstopPropagation

参考示例 2-8。

示例 2-8. 使用stopPropagation()手动停止事件传播
const App = {
 template: `
 <button @click="printMessage">Click me</button>
 `,
 methods: {
  printMessage(e: Event) {
   if (e) {
    e.stopPropagation()
   }

   console.log("Button is clicked!")
  }
 },
}

在这里,我们需要自己使用e.stopPropagation停止事件传播,添加另一个验证层确保e存在。示例 2-9 展示了我们如何使用@click.stop修饰符重写示例 2-8。

示例 2-9. 使用@click.stop修饰符停止事件传播
const App = {
 template: `
 <button @click.stop="printMessage">Click me</button>
 `,
 methods: {
  printMessage() {
   console.log("Button is clicked!")
  }
 },
}

表 2-1 显示了可用的事件修饰符的完整列表,并简要解释了等效的事件功能或行为。

表 2-1. v-on指令的事件修饰符

Modifier Description
.stop 而不是调用event.stopPropagation()
.prevent 而不是调用event.preventDefault()
.self 仅当事件的目标是我们附加监听器的元素时才触发事件监听器。
.once 最多触发一次事件监听器
.capture 而不是将{ capture: true }作为addEventListener()的第三个参数传递,或者在元素上添加capture="true"。这个修饰符以捕获阶段的顺序触发监听器,而不是常规的冒泡阶段顺序。
.passive 主要是为了选择更好的滚动性能并防止触发event.preventDefault()。我们使用它来代替将{ passive: true }作为addEventListener()的第三个参数传递,或者在元素上添加passive="true"

修饰符链式调用

事件修饰符支持链式调用。这意味着你可以在元素标签上写诸如@click.stop.prevent="printMessage">的表达式。此表达式相当于在事件处理程序中依次调用event.stopPropagation()event.preventDefault()

使用键码修饰符检测键盘事件

虽然事件修饰符用于干预事件传播流程,键修饰符帮助检测键盘事件的特殊键,如keyupkeydownkeypress

通常,要检测特定的键,我们需要执行两个步骤:

  1. 识别键码、key或由该键表示的code。例如,Enter键的keyCode是 13,其keyEnter,其codeEnter

  2. 在触发事件处理程序时,我们需要在处理程序内部手动检查event.keyCode(或event.codeevent.key)是否与目标键码匹配。

这种方法不适合在大型代码库中维护可重用和清晰的代码。v-on提供了内置的键修饰符作为更好的选择。如果我们想要检测用户是否按下了Enter键,我们可以在相关的keydown事件上添加修饰符.enter,使用事件修饰符时遵循相同的语法。

假设我们有一个输入元素,当用户按Enter键时,我们会在控制台记录一条消息,如示例 2-10 所示。

示例 2-10. 手动检查keyCode是否为 13 表示 Enter 键
const App = {
 template: `<input @keydown="onEnter" >`,
 methods: {
  onEnter(e: KeyboardEvent) {
   if (e.keyCode === '13') {
    console.log('User pressed Enter!')
   }

   /*...*/
  }
 }
}

现在我们可以用@keydown.enter来重写它。

示例 2-11. 使用 @keydown.enter 修饰符检查 Enter 键是否按下
const App = {
 template: `<input @keydown.enter="onEnter" >`,
 methods: {
  onEnter(e: KeyboardEvent) {
    console.log('User pressed Enter!')
   /*...*/
  }
 }
}

应用在两种情况下的应用程序行为相同。

几个其他常用的关键修饰符是.tab.delete.esc.space

另一个常见用例是捕获特殊键组合,例如 Ctrl & Enter(MacOS 上的 CMD & Enter)或 Shift + S。在这些场景中,我们将系统键修饰符(.shift.ctrl.alt 和 MacOS 中的 .meta 用于 CMD 键)与键码修饰符链接起来,就像以下示例中所示:

<!-- Ctrl + Enter -->
<input @keyup.ctrl.13=”onCtrlEnter”>

或者在按下 S 键(keyCode83)时链式调用 Shift 修饰符和键码修饰符:

<!-- Shift + S -->
<input @keyup.shift.83=”onSave”>

链接系统修饰符和键码修饰符

在这种链接中,您必须使用键码修饰符而不是标准键修饰符,例如.13代替.enter

此外,为了捕获触发事件的确切键组合,我们使用 .exact 修饰符:

<button @click.shift.exact=”onShiftEnter” />

结合 .shift.exact 确保在用户仅按下 Shift 键点击按钮时触发点击事件。

使用 v-ifv-elsev-else-if 进行条件渲染元素

我们还可以根据需要在 DOM 中生成或删除元素,这称为条件渲染

假设我们有一个布尔数据属性 isVisible,用于决定 Vue 是否应将文本元素渲染到 DOM 中并向用户显示。通过将 v-if="isVisible" 绑定到文本元素,只有当 isVisibletrue 时才会反应性地渲染元素(参见示例 2-12)。

示例 2-12. 使用 v-if 的示例用法
import { createVue } from 'vue'

const App = {
 template: `
 <div>
 <div v-if="isVisible">I'm the text in toggle</div>
 <div>Visibility: {{isVisible}}</div>
 </div>
 `,
 data() {
  return {
   isVisible: false
  }
 }
}

const app = createApp(App)

app.mount('#app')

当将isVisible设置为false时,生成的 DOM 元素如下所示:

<div>
 <!--v-if-->
 <div>Visibility: false</div>
</div>

否则,文本元素将在 DOM 中可见:

<div>
 <div>I'm the text in toggle</div>
 <div>Visibility: true</div>
</div>

如果我们想要为相反条件(isVisiblefalse)渲染不同的组件,则 v-else 是正确的选择。与 v-if 不同,您无需绑定任何数据属性即可使用 v-else。它基于同一上下文级别中即将使用的 v-if 的正确条件值。

使用 v-else

v-else 仅在存在 v-if 时起作用,并且必须始终出现在链式条件渲染的最后。

例如,如示例 2-13 所示,我们可以创建一个组件,其中包含以下代码块,同时具有 v-ifv-else

示例 2-13. 使用 v-ifv-else 进行条件显示不同文本
import { createVue } from 'vue'

const App = {
 template: `
 <div>
 <div v-if="isVisible">I'm the visible text</div>
 <div v-else>I'm the replacement text</div>
 </div>
 `,
 data() {
  return {
   isVisible: false
  }
 }
}

const app = createApp(App)

app.mount('#app')

简而言之,您可以将上述条件转换为类似的逻辑表达式:

<!--if isVisible is true, then render -->
<div>I'm the visible text</div>
<!-- else render -->
<div>I'm the replacement text</div>

就像在任何 if…else 逻辑表达式中一样,我们始终可以通过 else if 条件块扩展条件检查。此条件块等同于 v-else-if 指令,并且还需要一个 JavaScript 条件语句。示例 2-14 展示了当 isVisiblefalseshowSubtitletrue 时如何显示文本 I’m the subtitle text

示例 2-14. 使用 v-ifv-else-ifv-else 进行条件链接
import { createVue } from 'vue'

const App = {
 template: `
 <div v-if="isVisible">I'm the visible text</div>
 <div v-else-if="showSubtitle">I'm the subtitle text</div>
 <div v-else>I'm the replacement text</div>
 `,
 data() {
  return {
   isVisible: false,
   showSubtitle: false,
  }
 }
}

const app = createApp(App)

app.mount('#app')

v-else-if 的顺序

如果使用 v-else-if,我们必须将其呈现在分配了 v-if 属性的元素之后。

虽然使用 v-if 意味着有条件地渲染元素,但在需要频繁挂载/卸载元素的情况下效率并不高。

在这种情况下,最好使用 v-show

使用 v-show 有条件地显示元素

v-if 不同,v-show 只是切换目标元素的可见性。Vue 仍然会根据条件检查的状态渲染目标元素。一旦渲染,Vue 使用 CSS 的 display 规则有条件地隐藏/显示元素。

我们可以参考示例 2-12,将指令从 v-if 更改为 v-show,如同示例 2-15 中那样。

示例 2-15. 使用 v-show 隐藏/显示元素
import { createVue } from 'vue'

const App = {
 template: `
 <div>
 <div v-show="isVisible">I'm the text in toggle</div>
 <div>Visibility: {{isVisible}}</div>
 </div>
 `,
 data() {
  return {
   isVisible: false
  }
 }
}

const app = createApp(App)

app.mount('#app')

UI 输出与使用 v-if 时相同。然而,在浏览器 DOM(您可以在开发者工具的元素标签中进行调试)中,文本元素存在于 DOM 中但对用户不可见:

<div>
 <div style="display: none;">I'm the text in toggle</div>
 <div>Visibility: false</div>
</div>

目标元素具有带有 display:none 的内联 style。当将 isVisible 切换为 true 时,Vue 将移除此内联样式。

注意

如果运行时切换频率高,则 v-show 更有效,而如果条件不太可能改变,则 v-if 是终极选择。

使用 v-html 动态显示 HTML 代码

我们使用 v-html 将纯 HTML 代码以字符串形式动态注入到 DOM 中,例如示例 2-16。

示例 2-16. 使用 v-html 渲染内部 HTML 内容
import { createVue } from 'vue'

const App = {
 template: `
 <div v-html="innerContent" />
 `,
 data() {
  return {
   innerContent: `
 <div>Hello</div>
 `
  }
 }
}

const app = createApp(App)

app.mount('#app')

Vue 引擎会将指令值解析为静态 HTML 代码,并将其放置在 div 元素的 innerHTML 属性中。结果应如下所示:

<div>
 <div>Hello</div>
</div>

使用 v-html 存在安全问题

你应该仅用 v-html 渲染受信任的内容或执行服务器端渲染。

此外,有效的 HTML 字符串可以包含 script 标签,浏览器将触发此 script 标签中的代码,可能导致安全威胁。因此,不建议在客户端渲染时使用此指令。

使用 v-text 显示文本内容

v-text 是注入数据作为元素内容的替代方法,除了双花括号 {{}}。然而,与 {{}} 不同,如果有任何更改,Vue 不会更新渲染的文本。

当需要预定义占位文本并在组件加载完成后仅一次覆盖文本时,此指令非常有益:

import { createVue } from 'vue'

const App = {
 template: `
 <div v-text="text">Placeholder text</div>
 `,
 data() {
  return {
   text: `Hello World`
  }
 }
}

const app = createApp(App)

app.mount('#app')

Vue 将渲染应用程序,显示占位文本,并最终用从text接收到的“Hello World”替换它。

使用 v-oncev-memo 优化渲染

v-once 有助于渲染静态内容并保持性能不受重新渲染静态元素的影响。Vue 使用此指令渲染的元素仅一次,并且无论重新渲染多少次,都不会更新它。

要使用 v-once,直接将指令放置在元素标签上:

import { createVue } from 'vue'

const App = {
 template: `
 <div>
 <input v-model="name" placeholder="Enter your name" >
 </div>
 <div v-once>{{name}}</div>
 `,
 data() {
  return {
   name: 'Maya'
  }
 }
}

const app = createApp(App)

app.mount('#app')

在前面的示例中,Vue 为 div 标签仅渲染 name 一次,不管 name 从用户通过 input 字段和 v-model 接收到的值是什么,该 div 的内容都不会更新(见图 2-20)。

输入字段显示新值为“Maya Shavin”,而下面的文本仍为“Maya”。

图 2-20. 尽管输入值已更改,文本保持不变

虽然 v-once 适用于将一组元素定义为静态内容,但我们使用 v-memo 条件性地记忆模板中的一部分(或组件)。

v-memo 接受一个 JavaScript 表达式数组作为其值。我们将其放在我们想要控制重新渲染的顶级元素上及其子元素。Vue 然后验证这些 JavaScript 条件表达式,并且仅在满足条件时触发目标元素块的重新渲染。

以渲染图像卡片库为例。假设我们有一个图像数组。每个图像是一个带有 titleurlid 的对象。用户可以通过点击卡片选择图像,选择的卡片将具有蓝色边框。

首先,让我们在组件数据对象中定义 images 数据数组和 selected 图像卡片 id:

const App = {
  data() {
    return {
    selected: null,
    images: [{
      id: 1,
      title: 'Cute cat',
      url:
'https://res.cloudinary.com/mayashavin/image/upload/w_100,h_100,c_thumb/TheCute%20Cat',
    }, {
      id: 2,
      title: 'Cute cat no 2',
      url:
'https://res.cloudinary.com/mayashavin/image/upload/w_100,h_100,c_thumb/cute_cat',
    }, {
      id: 3,
      title: 'Cute cat no 3',
      url:
'https://res.cloudinary.com/mayashavin/image/upload/w_100,h_100,c_thumb/cat_me',
    }, {
      id: 4,
      title: 'Just a cat',
      url:
'https://res.cloudinary.com/mayashavin/image/upload/w_100,h_100,c_thumb/cat_1',
    }]
    }
  }
}

然后我们为列表渲染定义布局到 template,添加一个条件记忆 v-memo,以便仅在图像项目不再选择或相反时重新渲染列表项:

const App = {
 template: ` <ul>
  <li
   v-for="image in images"
   :key="image.id"
   :style=" selected === image.id ? { border: '1px solid blue' } : {}"
   @click="selected = image.id"
   v-memo="[selected === image.id]" ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png) >
   <img :src="image.url">
   <div>{{image.title}}</h2>
  </li>
 </ul> `,
 data() {
  /*..*/
 }
}

1

我们设置重新渲染条件仅在检查 selected === image.id 的结果与先前检查结果不同时触发。

输出将类似于图 2-21。

屏幕截图显示一张猫图片库,每张图片底部有标题文字。

图 2-21. 图像库输出

每次通过点击图像卡片选择图像时,Vue 只重新渲染两个项目:先前选择的项目和当前选择的项目。对于优化大型列表渲染,这个指令非常强大。

v-memo 可用性

v-memo 仅在 Vue 3.2 及以上版本中可用。

我们已经学习了如何使用 template 语法编写组件和一些常见的 Vue 指令,除了 v-slot。我们将在 第三章 中继续讨论 v-slot 的强大之处。

接下来,我们将学习如何全局注册组件,使其可以在同一应用程序的其他组件中使用,而无需显式导入它们。

全局注册组件

使用 Options API 的 components 属性注册组件仅允许显式在当前组件内部使用注册的组件。任何当前组件的嵌套元素将无法使用已注册的组件。

Vue 提供了实例方法 Vue.component(),接收两个输入参数作为参数:

  • 字符串表示组件的注册名称(别名)。

  • 组件实例,无论是作为模块导入的 SFC 还是包含组件配置的对象,都遵循选项 API。

要全局注册一个组件,我们在创建的 app 实例上触发 component(),正如在 例子 2-17 中所见。

例子 2-17. 将 MyComponent 注册为全局组件并在 App 模板中使用
/* main.ts */
import { createApp } from 'vue'

//1\. Create the app instance
const app = createApp({
 template: '<MyComponent />'
});

//2\. Define the component
const MyComponent = {
 template: 'This is my global component'
}

//3\. Register a component globally
app.component('MyComponent', MyComponent)

app.mount('#app')

如果你有一个作为 SFC 文件的 MyComponent(见第三章),你可以将 例子 2-17 重写为以下形式:

/* main.ts */
import { createApp } from 'vue'
import App from './App.vue'
import MyComponent from './components/MyComponent.vue'

//1\. Create the app instance
const app = createApp(App);

//2\. Register a component globally
app.component('MyComponent', MyComponent);

并且 MyComponent 将始终在 app 实例内任何嵌套组件中可复用。

在每个组件文件中再次导入同一组件可能会显得重复且不方便。实际上,有时你需要在应用程序中多次重用一个组件。在这种情况下,将组件注册为全局组件是一个很好的实践。

摘要

本章探讨了虚拟 DOM 及其在 Vue 中实现性能目标的方法。我们学习了如何使用 JSX 和函数组件控制组件渲染,处理内置 Vue 指令,并使用它们来处理组件的本地数据以在 UI 模板上进行响应显示。我们还学习了响应性基础知识以及如何使用选项 API 和模板语法创建和注册 Vue 组件。这些是深入理解下一章节中 Vue 组件机制的基础。

^(1) 访问JavaScript 代理文档

^(2) 1 秒 = 1000 毫秒

第三章:组合组件

在上一章中,您已经学习了 Vue 的基础知识,并使用 Options API 编写了具有常见指令的 Vue 组件。现在,您已经准备好深入探讨下一个层次:使用响应式和钩子组合更复杂的 Vue 组件。

本章介绍了 Vue 单文件组件(SFC)标准、组件生命周期钩子,以及其他高级响应式特性,如计算属性、监听器、方法和引用。您还将学习如何使用插槽来动态渲染组件的不同部分,并保持样式结构。通过本章的学习,您将能够在应用程序中编写复杂的 Vue 组件。

Vue 单文件组件结构

Vue 引入了一种新的文件格式标准,Vue SFC,以.vue扩展名表示。使用 SFC,您可以在同一文件中为组件编写 HTML 模板代码、JavaScript 逻辑和 CSS 样式,每个部分都有专门的代码区域。Vue SFC 包含三个必要的代码部分:

模板

此 HTML 代码块渲染了组件的 UI 视图。它应该只在每个组件的最高级元素一次出现。

脚本

此 JavaScript 代码块包含组件的主要逻辑,每个组件文件最多出现一次

样式

此 CSS 代码块包含组件的样式设置。它是可选的,可以根据需要出现多次

示例 3-1 是名为MyFirstComponent的 Vue 组件的 SFC 文件结构示例。

示例 3-1. MyFirstComponent 组件的 SFC 结构
<template>
 <h2 class="heading">I am a a Vue component</h2>
</template>
<script lang="ts">
export default {
 name: 'MyFistComponent',
};
</script>
<style>
.heading {
  font-size: 16px;
}
</style>

我们还可以将非 SFC 组件代码重构为 SFC,如图 3-1 所示。

使用单文件组件概念创建的 Vue 组件示例

图 3-1. 从非 SFC 格式重构为 SFC 格式的组件

如图 3-1 所示,我们进行了以下重构:

  • 将 HTML 代码作为template字段的字符串值移至单文件组件的<template>部分。

  • MyFirstComponent的其余逻辑移至单文件组件的<script>部分,作为export default {}对象的一部分。

使用 TypeScript 的提示

您应该为 TypeScript 在<script>语法中添加lang="ts"属性,如<script lang="ts">,以便 Vue 引擎知道如何处理代码格式。

由于.vue文件格式是一种独特的扩展标准,您需要使用特殊的构建工具(编译器/转译器),如 Webpack、Rollup 等,将相关文件预编译为适合在浏览器端服务的 JavaScript 和 CSS。在使用 Vite 创建新项目时,Vite 已经将这些工具设置为脚手架过程的一部分。然后,您可以将组件作为 ES 模块导入,并声明为内部的components以在其他组件文件中使用。

以下是导入 components 目录中的 MyFirstComponent 并在 App.vue 组件中使用的示例:

<script lang="ts">
import MyFirstComponent from './components/MyFirstComponent.vue';

export default {
 components: {
  MyFirstComponent,
 }
}
</script>

如 示例 3-2 所示,您可以通过在 template 部分引用其名称(无论是驼峰还是蛇形命名法),来使用导入的组件:

示例 3-2. 如何使用导入的组件
<template>
 <my-first-component />
 <MyFirstComponent />
</template>

此代码将 MyFirstComponent 组件的内容呈现两次,如 图 3-2 所示。

组件如何通过其嵌套组件的重复内容进行渲染

图 3-2. MyFirstComponent 的输出
注意

在 示例 3-2 中的组件 template 包含两个根元素。此分割能力仅在 Vue 3.x 及更高版本中可用。

我们学习了如何使用 SFC 格式创建和使用 Vue 组件。正如您所注意到的,我们在 script 标签中定义 lang="ts",以告知 Vue 引擎我们使用了 TypeScript。因此,Vue 引擎将在组件的 scripttemplate 部分对任何代码或表达式应用更严格的类型验证。

然而,为了充分享受 TypeScript 在 Vue 中的优势,我们需要在定义组件时使用 defineComponent() 方法,这将在下一节中学习。

用于 TypeScript 支持的 defineComponent() 方法

defineComponent() 方法是一个包装函数,接受一个配置对象并返回相同的东西,用于定义组件时进行类型推断。

注意

defineComponent() 方法仅在 Vue 3.x 及更高版本中可用,并且只有在需要 TypeScript 时才相关。

示例 3-3 演示了使用 defineComponent() 来定义一个组件。

示例 3-3. 使用 defineComponent() 定义组件
<template>
  <h2 class="heading">{{ message }}</h2>
</template>
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'MyMessageComponent',
  data() {
    return {
      message: 'Welcome to Vue 3!'
    }
  }
});
</script>

如果您使用 VSCode 作为您的 IDE,并安装了 Volar 扩展,当在 template 部分的 message 上悬停时,您将看到 message 的类型为 string,如 图 3-3 所示。

悬停在模板部分的  单词上时生成的  的  属性的字符串类型

图 3-3. 在悬停时显示的 MyMessageComponentmessage 属性的生成类型

对于复杂组件,如通过 this 实例访问组件属性时,应仅在需要 TypeScript 支持时使用 defineComponent()。否则,可以使用标准的 SFC 组件定义方法。

注意

在本书中,您将看到传统组件定义方法与需要时使用 defineComponent() 方法的组合。您可以自由决定哪种方法更适合您。

接下来,我们将探讨组件的生命周期及其钩子函数。

组件生命周期钩子

Vue 组件的生命周期从 Vue 实例化组件开始,到销毁组件实例(或卸载)结束。

Vue 将组件的生命周期划分为不同阶段(图 3-4)。

Vue 组件生命周期的流程图

图 3-4. Vue 组件生命周期流程图

初始化阶段

Vue 渲染器加载组件的选项配置,并准备创建组件实例。

创建阶段

Vue 渲染器创建组件实例。如果模板需要编译,将在继续下一个阶段之前进行额外的编译步骤。

第一次渲染阶段

Vue 渲染器在组件的 DOM 树中创建并插入组件的 DOM 节点。

挂载阶段

组件的嵌套元素已经挂载并附加到组件的 DOM 树中,如图 3-5 所示。然后 Vue 渲染器将组件附加到其父容器中。从此阶段开始,你可以访问组件的 $el 属性,表示其 DOM 节点。

更新阶段

仅当组件的响应式数据发生变化时才相关。在此阶段,Vue 渲染器使用新数据重新渲染组件的 DOM 节点,并执行修补更新。类似于挂载阶段,更新过程以子元素优先,然后是组件本身结束。

卸载阶段

Vue 渲染器从 DOM 中分离组件并销毁实例及其所有响应式数据效果。此阶段是生命周期的最后阶段,在应用程序中不再使用组件时发生。类似于更新和挂载阶段,组件只能在其所有子组件卸载后自行卸载。

显示组件及其子组件挂载顺序的图表,从 1 到 3

图 3-5. 组件及其子组件的挂载顺序

Vue 允许你将一些事件附加到这些生命周期阶段之间的特定过渡,以更好地控制组件流程。我们称这些事件为生命周期钩子。Vue 中可用的生命周期钩子在以下章节中描述。

setup

setup 是在组件生命周期开始前的第一个事件钩子。此钩子在 Vue 实例化组件之前运行 一次。在这个阶段,没有组件实例存在,因此 无法访问 this

export default {
  setup() {
    console.log('setup hook')
    console.log(this) // undefined
  }
}
注意

setup 钩子的一个替代方案是将 setup 属性添加到组件的 script 标签部分(<script setup>)。

setup 钩子主要用于组合 API(我们将在第五章中详细学习)。其语法如下:

setup(props, context) {
  // ...
}

setup() 接受两个参数:

props

包含传递给组件的所有 props 的对象,使用组件选项对象的 props 字段声明。每个 props 的属性都是响应式数据。你不需要在 setup() 返回对象的一部分中返回 props

context

包含组件上下文的非响应式对象,如 attrsslotsemitexpose

注意

如果你使用 <script setup>,你需要使用 defineProps() 来定义和访问这些 props。参见 “使用 defineProps() 和 withDefaults() 声明 Props”。

setup() 返回一个包含组件内部响应式状态、方法和任何静态数据引用的对象。如果你使用 <script setup>,则不需要显式返回任何内容。Vue 在编译期间将声明在此语法内的所有变量和函数自动转换为适当的 setup() 返回对象。然后,你可以在模板或组件选项对象的其他部分使用 this 关键字访问它们。

示例 3-4 展示了使用 setup() 钩子来定义一个打印静态消息的组件。

示例 3-4. 使用 setup() 钩子定义组件
import { defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const message = 'Welcome to Vue 3!'
    return {
      message
    }
  }
})

这里注意,message 不是响应式数据。要使其响应式,你必须使用 Composition API 中的 ref() 函数来包裹它。我们将在 “使用 ref() 和 reactive() 处理数据” 中详细了解这一点。此外,我们也不再需要将 message 定义为 data() 对象的一部分,从而减少了组件中不必要的响应式数据量。

或者,如同 示例 3-5 所示,你也可以使用 <script setup> 语法来编写前述组件。

示例 3-5. 使用 <script setup> 语法定义组件
<script setup lang='ts'>
const message = 'Welcome to Vue 3!'
</script>

使用 <script setup> 而不是 setup() 的一个很大的优点是它具有内置的 TypeScript 支持。因此,不再需要 defineComponent(),编写组件所需的代码更少。

当使用 setup() 钩子时,你还可以结合 h() 渲染函数来根据 propscontext 参数返回组件的渲染器,就像 示例 3-6 所示的那样。

示例 3-6. 使用 setup() 钩子和 h() 渲染函数定义组件
import { defineComponent, h } from 'vue';

export default defineComponent({
  setup(props, context) {
    const message = 'Welcome to Vue 3!'
    return () => h('div', message)
  }
})

当你想要创建一个基于传递给它的 props 或无状态函数组件渲染不同静态 DOM 结构的组件时,使用 setup()h() 是非常有帮助的(图 3-6 展示了在 Chrome Devtools 的 Vue 标签页中 示例 3-6 的输出)。

使用 h() 渲染函数输出无状态组件的结果

图 3-6. 使用 h() 渲染函数的无状态组件在 Vue Devtools 中的展示
注意

从这一点开始,我们将使用 <script setup> 语法来展示组件的 setup() 钩子用法,因为它简单易用,在适用的情况下。

beforeCreate

beforeCreate 在 Vue 渲染器创建组件实例之前运行。在这里,Vue 引擎已经初始化了组件,但还没有触发 data() 函数或计算任何 computed 属性,因此没有可用的响应式数据。

创建

这个钩子在 Vue 引擎创建组件实例之后运行。在这个阶段,组件实例存在具有响应式数据、观察者、计算属性和定义方法。但是,Vue 引擎尚未将其挂载到 DOM 上。

created钩子在组件的第一次渲染之前运行。它有助于执行需要this可用的任何任务,比如从外部资源加载数据到组件中。

beforeMount

这个钩子在created之后运行。在这里,Vue 渲染器已经创建了组件实例并编译了其模板以在组件的第一次渲染之前进行渲染。

mounted

这个钩子在组件第一次渲染后运行。在这个阶段,组件渲染的 DOM 节点通过++属性可以访问。您可以使用这个钩子来执行与组件 DOM 节点相关的额外副作用计算。

beforeUpdate

当本地数据状态发生变化时,Vue 渲染器会更新组件的 DOM 树。这个钩子在更新过程开始之后运行,并且您仍然可以使用它来在内部修改组件的状态。

updated

这个钩子在 Vue 渲染器更新组件的 DOM 树之后运行。

注意

updatedbeforeUpdatebeforeMountmounted钩子在服务器端渲染(SSR)中不可用。

谨慎使用这个钩子,因为它在组件发生任何 DOM 更新之后运行

updated钩子中更新本地状态

在这个钩子中不能改变组件的本地数据状态。

beforeUnmount

这个钩子在 Vue 渲染器开始卸载组件之前运行。此时,组件的 DOM 节点$el仍然可用。

unmounted

这个钩子在卸载过程成功完成并且组件实例不再可用之后运行。这个钩子可以清理额外的观察者或效果,比如 DOM 事件监听器。

注意

在 Vue 2.x 中,您应该分别使用beforeDestroydestroyed来替代beforeUnmountmounted

beforeUnmountedunmounted钩子在服务器端渲染(SSR)中不可用。

总之,我们可以使用生命周期钩子重新绘制组件的生命周期图,就像图 3-7 中所示。

Vue 组件生命周期的钩子流程图

图 3-7. Vue 组件生命周期的流程图

我们可以通过示例 3-7 中的组件来实验每个生命周期钩子的执行顺序。

示例 3-7. 生命周期钩子的控制台日志
<template>
    <h2 class="heading">I am {{message}}</h2>
    <input v-model="message" type="text" placeholder="Enter your name" />
</template>
<script lang="ts">
  import { defineComponent } from 'vue'

  export default defineComponent({
    name: 'MyFistComponent',
    data() {
      return {
        message: ''
      }
    },
    setup() {
      console.log('setup hook triggered!')
      return {}
    },
    beforeCreate() {
      console.log('beforeCreate hook triggered!')
    },
    created() {
      console.log('created hook triggered!')
    },
    beforeMount() {
      console.log('beforeMount hook triggered!')
    },
    mounted() {
      console.log('mounted hook triggered!')
    },
    beforeUpdate() {
      console.log('beforeUpdate hook triggered!')
    },
    updated() {
      console.log('updated hook triggered!')
    },
    beforeUnmount() {
      console.log('beforeUnmount hook triggered!')
    },
  });
</script>

当我们在浏览器的检查器控制台中运行此代码时,将会看到图 3-8 中显示的输出。

上述组件在第一次渲染时的控制台日志输出

图 3-8. MyFirstComponent在第一次渲染时的控制台日志输出钩子顺序

当我们改变message属性的值时,组件将重新渲染,并且控制台输出如图 3-9 所示。

上述组件第二次渲染的控制台输出

图 3-9. 第二次渲染仅触发beforeUpdateupdated钩子

我们还可以在 Vue Devtools 的 Timeline 标签页-性能部分中查看生命周期顺序,例如第一次渲染中的图 3-10。

上述组件第一次渲染的时间轴

图 3-10. MyFirstComponent在第一次渲染中的时间轴

当组件重新渲染时,Vue Devtools 标签页显示时间轴事件记录,就像图 3-11 中展示的那样。

上述组件第二次渲染的时间轴

图 3-11. MyFirstComponent在第二次渲染中的时间轴

每一个之前的生命周期钩子都可以提供帮助。在表 3-1 中,您将找到每个钩子的最常见用例。

表 3-1. 为合适的目的使用合适的钩子

生命周期钩子 用例
beforeCreate 当需要加载外部逻辑而不修改组件数据时使用。
created 当需要将外部数据加载到组件中时使用。相较于mounted,这个钩子更适合从外部资源读取或写入数据。
mounted 当你需要执行任何 DOM 操作或者访问组件的 DOM 节点this.$el时。

到目前为止,我们已经了解了组件的生命周期顺序及其可用的钩子。接下来,我们将看看如何将常见的组件逻辑创建和组织成具有method属性的方法。

方法

方法是不依赖于组件数据的逻辑,尽管我们可以在方法中使用this实例访问组件的局部状态。组件的方法是在methods属性内定义的函数。正如示例 3-8 所示,我们可以定义一个方法来反转message属性。

示例 3-8. 定义一个方法来反转message属性
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'ReversedMessage',
  data() {
    return {
      message: '',
    };
  },
  methods: {
    reverseMessage():string {
      return this.message.split('').reverse().join('')
    },
  },
});
</script>

示例 3-9 展示了如何在组件的模板中使用reverseMessage方法。

示例 3-9. 在模板上输出反转的消息
<template>
  <h2 class="heading">I am {{reverseMessage()}}</h2>
  <input v-model="message" type="text" placeholder="Enter your message" />
</template>

当用户在浏览器中输入消息的值时,我们在图 3-12 中看到输出。

屏幕截图显示基于 Hello Vue 消息的反转消息

图 3-12. 基于message值的反转消息

您还可以修改reverseMessage方法,使其接受一个字符串参数,这样可以更具重用性,并减少对this.message的依赖,就像示例 3-10 中所示。

示例 3-10. 定义一个反转字符串的方法
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'MyFistComponent',
  data() {
    return {
      message: '',
    };
  },
  methods: {
    reverseMessage(message: string):string {
      return message.split('').reverse().join('')
    },
  },
});
</script>

template部分,我们重构了示例 3-9,并将message作为reverseMessage方法的输入参数传递:

<template>
  <h2 class="heading">I am {{reverseMessage(message)}}</h2>
  <input v-model="message" type="text" placeholder="Enter your message" />
</template>

输出结果与图 3-12 中一致。

另外,我们可以在组件的其他属性或生命周期钩子中使用this实例触发组件的方法。例如,我们可以将reverseMessage拆分为两个更小的方法,reverse()arrToString(),如下面的代码所示:

/**... */
  methods: {
    reverse(message: string):string[] {
      return message.split('').reverse()
    },
    arrToString(arr: string[]):string {
      return arr.join('')
    },
    reverseMessage(message: string):string {
      return this.arrToString(this.reverse(message))
    },
  },

方法有助于保持组件逻辑的组织性。Vue 仅在相关时触发方法(例如在模板中调用,如示例 3-9 中所示),允许我们动态地从本地数据计算新的数据值。然而,对于方法,Vue 不会缓存每次触发的结果,并且每次重新渲染时都会重新运行该方法。因此,在需要计算新数据的场景中,最好使用计算属性,接下来我们将进行探讨。

计算属性

计算属性是 Vue 的独特功能,允许您从组件的任何响应式数据中计算新的响应式数据属性。每个计算属性都是一个返回值的函数,并位于computed属性字段内。

示例 3-11 展示了我们如何定义一个新的计算属性reversedMessage,它以反向顺序返回组件的本地数据message

示例 3-11. 返回组件本地消息的计算属性,以反向顺序返回
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'ReversedMessage',
  data() {
    return {
      message: 'Hello Vue!'
    }
  },
  computed: {
    reversedMessage() {
      return this.message.split('').reverse().join('')
    }
  }
})

您可以像访问任何组件的本地数据一样访问reversedMessage计算属性。示例 3-12 展示了如何基于message的输入值输出计算得到的reversed Message

示例 3-12. 计算属性示例
<template>
  <h2 class="heading">I am {{ reversedMessage }}</h2>
  <input v-model="message" type="text" placeholder="Enter your message" />
</template>

示例 3-12 的输出与图 3-12 相同。

您还可以在 Vue Devtools 的组件选项卡中跟踪计算属性(图 3-13)。

屏幕截图显示 Vue Devtools 中组件选项卡中的计算属性

图 3-13. 组件选项卡中的计算属性reversedMessage

同样地,您可以通过this实例在组件逻辑中访问计算属性的值作为其本地数据属性。您还可以基于计算属性的值计算新的计算属性。如示例 3-13 所示,我们可以将reversedMessage属性值的长度添加到一个新的属性reversedMessageLength中。

示例 3-13. 添加reversedMessageLength计算属性
import { defineComponent } from 'vue'

export default defineComponent({
  /**... */
  computed: {
    reversedMessage() {
      return this.message.split('').reverse().join('')
    },
    reversedMessageLength() {
      return this.reversedMessage.length
    }
  }
})

Vue 引擎自动缓存计算属性的值,并仅在相关的响应式数据更改时重新计算该值。如示例 3-12 中所示,Vue 仅在message更改时更新reversedMessage计算属性的值。如果您希望在组件的其他位置显示或重用reversedMessage值,则 Vue 无需重新计算其值。

使用计算属性有助于将复杂数据修改组织为可重用的数据块。因此,它减少了所需的代码量并保持代码清晰,同时提高组件的性能。使用计算属性还允许您快速设置任何响应式数据属性的自动观察器,方法是将它们出现在计算属性函数的实现逻辑中。

然而,在某些情况下,此自动观察器机制可能会增加组件性能的开销。在这种情况下,我们可以考虑通过组件的watch属性字段使用观察器。

观察者

观察者允许您以编程方式监视组件中任何响应式数据属性的变化并处理它们。每个观察器是一个函数,接收两个参数:观察数据的新值(newValue)和当前值(oldValue)。然后基于这两个输入参数执行任何逻辑。我们通过将观察器添加到组件选项的watch属性字段来定义响应式数据的观察器,遵循以下语法:

watch: {
  'reactiveDataPropertyName'(newValue, oldValue) {
    // do something
  }
}

您需要用目标组件的数据名称reactiveDataPropertyName替换它,以便观察我们想要观察的目标组件数据。

示例 3-14 展示了如何定义一个新的观察器来观察组件的本地数据message的变化。

示例 3-14. 一个观察器,观察组件的本地message的变化
export default {
  name: 'MyFirstComponent',
  data() {
    return {
      message: 'Hello Vue!'
    }
  },
  watch: {
    message(newValue: string, oldValue: string) {
      console.log(`new value: ${newValue}, old value: ${oldValue}`)
    }
  }
}

在本例中,我们定义了一个message观察器,它观察message属性的变化。当message的值发生变化时,Vue 引擎会触发该观察器。图 3-14 展示了此观察器的控制台日志输出。

屏幕截图显示每当变化时的控制台日志输出,新值在前,旧值在后

图 3-14. 当message变化时的控制台日志输出

我们可以使用一个观察器在示例 3-11 中实现reservedMessage,观察messagedata()字段,而不是使用计算属性,正如示例 3-15 中所见。

示例 3-15. 一个观察器,观察组件的本地message的变化,并更新reversedMessage的值
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'MyFirstComponent',
  data() {
    return {
      message: 'Hello Vue!',
      reversedMessage: 'Hello Vue!'.split('').reverse().join('')
    }
  },
  watch: {
    message(newValue: string, oldValue: string) {
      this.reversedMessage = newValue.split('').reverse().join('')
    }
  }
})

输出结果与图 3-12 中的相同。然而,在这种特定情况下,不推荐使用此方法,因为它比使用计算属性效率低。

注意

副作用是观察器或计算属性内触发的任何附加逻辑。副作用可能会影响组件的性能;您应谨慎处理它们。

您可以直接将处理程序函数分配给观察器名称。Vue 引擎将自动使用一组默认配置调用处理程序。但是,您也可以将对象传递给观察器的名称,以自定义观察器的行为,使用表 3-2 中的字段。

表 3-2. 观察器对象的字段

Watcher's field 描述 接受的类型 默认值 是否必需?
handler 当目标数据的值变化时触发的回调函数。 函数 N/A
deep 表示 Vue 是否应该观察目标数据(如果有)的嵌套属性的变化。 布尔值 false
immediate 指示是否在挂载组件后立即触发处理程序。 布尔值 false
flush 指示处理程序执行的时间顺序。默认情况下,Vue 在更新 Vue 组件之前触发处理程序。 预处理,后处理 pre

观察嵌套属性的变化

deep选项字段允许您观察所有嵌套属性的变化。例如,在一个UserWatcherComponent组件中有一个user对象数据,其中有两个嵌套属性:nameage。我们使用deep选项字段定义一个观察user对象嵌套属性变化的user观察器,如示例 3-16 所示。

示例 3-16. 监视器观察用户对象嵌套属性的变化
import { defineComponent } from 'vue'

type User = {
  name: string
  age: number
}

export default defineComponent({
  name: 'UserWatcherComponent',
  data(): { user: User } {
    return {
      user: {
        name: 'John',
        age: 30
      }
    }
  },
  watch: {
    user: {
      handler(newValue: User, oldValue: User) {
        console.log({ newValue, oldValue })
      },
      deep: true
    }
  }
})

正如示例 3-17 所示,在UserWatcherComponent组件的模板部分,我们接收user对象字段nameage的输入。

示例 3-17. UserWatcherComponent的模板部分
<template>
  <div>
    <div>
      <label for="name">Name:
        <input v-model="user.name" placeholder="Enter your name" id="name" />
      </label>
    </div>
    <div>
      <label for="age">Age:
        <input v-model="user.age" placeholder="Enter your age" id="age" />
      </label>
    </div>
  </div>
</template>

在这种情况下,当user.nameuser.age的值更改时,Vue 引擎会触发user观察器。图 3-15 显示了当我们更改user.name的值时,此观察器的控制台日志输出。

屏幕截图显示每当用户对象的嵌套属性更改时,控制台日志输出,新值在前,旧值在后

图 3-15. 当用户对象的嵌套属性更改时,控制台日志输出

图 3-15 显示user的新值和旧值相同。这是因为user对象仍然是同一个实例,只有其name字段的值发生了变化。

此外,一旦打开deep标志,Vue 引擎将遍历user对象及其嵌套属性的所有属性,然后观察它们的变化。因此,当user对象结构包含更复杂的内部数据结构时,可能会导致性能问题。在这种情况下,最好指定您希望监视的哪些嵌套属性,如示例 3-18 所示。

示例 3-18. 监视用户名称变化的观察器
//...
export default defineComponent({
  //...
  watch: {
    'user.name': {
      handler(newValue: string, oldValue: string) {
        console.log({ newValue, oldValue })
      },
    },
  }
});

在这里,我们仅观察user.name属性的变化。图 3-16 显示了此观察器的控制台日志输出。

屏幕截图显示每当用户对象的名称更改时,控制台日志输出,新值在前,旧值在后

图 3-16. 仅当用户对象的名称更改时,控制台日志才会输出

使用点分隔路径方法,可以启用观察特定的子属性,无论其嵌套多深。例如,如果 user 有以下内容:

type User = {
  name: string;
  age: number;
  address: {
    street: string;
    city: string;
    country: string;
    zip: string;
  };
}

假设您需要观察 user.address.city 的更改;您可以通过使用 user.address.city 作为观察器名称来实现。通过采用这种方法,您可以避免深度观察带来的性能问题,并将观察器的范围缩小到仅限您需要的属性。

使用 this.$watch() 方法

在大多数情况下,watch 选项足以处理您的观察器需求。但是,有些情况下,当不必要时您不希望启用某些观察器。例如,您可能只想在 user 对象的 address 属性不为 null 时才启用 user.address.city 观察器。在这种情况下,您可以使用 this.$watch() 方法在创建组件时有条件地创建观察器。

this.$watch() 方法接受以下参数:

  • 要观察的目标数据的名称作为字符串

  • 回调函数作为观察器的处理程序,用于在目标数据的值发生变化时触发

this.$watch() 返回一个函数,您可以调用它来停止观察器。示例 3-19 中的代码展示了如何使用 this.$watch() 方法创建一个观察 user.address.city 的观察器。

示例 3-19. 观察 user 的地址中城市字段的更改的观察器
import { defineComponent } from "vue";
import type { WatchStopHandle } from "vue";

//... export default defineComponent({
  name: "UserWatcherComponent",
  data(): { user: User; stopWatchingAddressCity?: WatchStopHandle } {
    return {
      user: {
        name: "John",
        age: 30,
        address: {
          street: "123 Main St",
          city: "New York",
          country: "USA",
          zip: "10001",
        },
      },
      stopWatchingAddressCity: undefined, ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    };
  },
  created() {
    if (this.user.address) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
      this.stopWatchingAddressCity = this.$watch(
        "user.address.city",
        (newValue: string, oldValue: string) => {
          console.log({ newValue, oldValue });
        }
      );
    }
  },
  beforeUnmount() {
    if (this.stopWatchingAddressCity) { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
      this.stopWatchingAddressCity();
    }
  },
});

1

定义一个 stopWatchingAddressCity 属性来存储观察器的返回函数。

2

仅当 user 对象的 address 对象属性可用时,才为 user.address.city 创建一个观察器。

3

在卸载组件之前,如果相关,则触发 stopWatchingAddressCity 函数以停止观察器。

采用这种方法,我们可以限制创建不必要观察器的数量,例如在 user.address 不存在时,对 user.address.city 的观察器。

接下来,我们将看一下 Vue 的另一个有趣功能,即 slot 组件。

插槽的强大之处

构建组件不仅涉及其数据和逻辑。我们经常希望保持当前组件的感觉和现有设计,但仍然允许用户修改 UI 模板的部分。在任何框架中构建可定制组件库时,这种灵活性至关重要。幸运的是,Vue 提供了 <slot> 组件,允许我们在需要时动态替换元素的默认 UI 设计。

例如,让我们构建一个布局组件 ListLayout 来渲染项目列表,每个项目的类型如下:

interface Item {
  id: number
  name: string
  description: string
  thumbnail?: string
}

对于列表中的每个项目,默认情况下,布局组件应该渲染其名称和描述,如 示例 3-20 所示。

示例 3-20. ListLayout 组件的第一个模板实现
<template>
  <ul class="list-layout">
    <li class="list-layout__item" v-for="item in items" :key="item.id">
      <div class="list-layout__item__name">{{ item.name }}</div>
      <div class="list-layout__item__description">{{ item.description }}</div>
    </li>
  </ul>
</template>

我们还在 ListLayoutscript 部分定义了要渲染的项目示例列表(示例 3-21)。

示例 3-21. ListLayout 组件的脚本部分
import { defineComponent } from 'vue'

//...

export default defineComponent({
  name: 'ListLayout',
  data(): { items: Item[] } {
    return {
      items: [
        {
          id: 1,
          name: "Item 1",
          description: "This is item 1",
          thumbnail:
"https://res.cloudinary.com/mayashavin/image/upload/v1643005666/Demo/supreme_pizza",
        },
        {
          id: 2,
          name: "Item 2",
          description: "This is item 2",
          thumbnail:
"https://res.cloudinary.com/mayashavin/image/upload/v1643005666/Demo/hawaiian_pizza",
        },
        {
          id: 3,
          name: "Item 3",
          description: "This is item 3",
          thumbnail:
"https://res.cloudinary.com/mayashavin/image/upload/v1643005666/Demo/pina_colada_pizza",
        },
      ]
    }
  }
})

图 3-17 显示了使用之前模板(示例 3-20)和数据(示例 3-21)渲染的单个项目的默认界面。

截图显示了  组件中项目的示例界面布局

图 3-17. ListLayout 组件中项目的示例界面布局

基于这个默认界面,我们可以为用户提供自定义每个项目界面的选项。为此,我们将代码块包装在一个带有 slot 元素的 li 元素中,如 示例 3-22 所示。

示例 3-22. 带有 slotListLayout 组件
<template>
  <ul class="list-layout">
    <li class="list-layout__item" v-for="item in items" :key="item.id">
      <slot :item="item">
        <div class="list-layout__item__name">{{ item.name }}</div>
        <div class="list-layout__item__description">{{ item.description }}</div>
      </slot>
    </li>
  </ul>
</template>

注意我们如何使用 : 语法将每次 v-for 迭代接收的 item 变量绑定到 slot 组件的同一 item prop 属性。通过这样做,我们确保 slot 将相同的 item 数据提供给其后代。

注意

slot 组件不与其主机组件(如 ListLayout)共享相同的数据上下文。如果您想访问主机组件的任何数据属性,您需要使用 v-bind 语法将其作为 slot 的 prop 传递。我们将在 “Vue 中的嵌套组件和数据流” 中进一步学习如何向嵌套元素传递 props。

然而,仅仅拥有 item 可用于自定义模板内容并不足以使其工作。在 ListLayout 的父组件中,我们在 <ListLayout> 标签上添加 v-slot 指令,以获取传递给其 slot 组件的 item,以下是语法示例:

<ListLayout v-slot="{ item }">
  <!-- Custom template content -->
</ListLayout>

在这里,我们使用对象解构语法 { item } 来创建对我们想要访问的数据属性的作用域插槽引用。然后,我们可以在自定义模板内容中直接使用 item,如 示例 3-23 中所示。

示例 3-23. 从 ListLayout 组合 ProductItemList
<!-- ProductItemList.vue -->
<template>
  <div id="app">
    <ListLayout v-slot="{ item }">
      <img
        v-if="item.thumbnail"
        class="list-layout__item__thumbnail"
        :src="item.thumbnail"
        :alt="item.name"
        width="200"
      />
      <div class="list-layout__item__name">{{ item.name }}</div>
    </ListLayout>
  </div>
</template>

在 示例 3-23 中,我们改变了界面,只显示缩略图和项目名称。您可以在 图 3-21 中看到结果。

这个例子是 slot 组件的最简单的用例,当我们想要在元素的单个插槽中启用定制时。但是对于像包含缩略图、主要描述区域和行动区域的产品卡组件这样更复杂的情景,我们仍然可以利用 slot 的强大功能,带有命名能力。

截图显示了  组件的界面布局

图 3-18. ProductItemList 组件的界面布局

使用带有模板标签和 v-slot 属性的命名插槽

在示例 3-22 中,我们仅将项目名称和描述的 UI 自定义为单个插槽。为了将自定义拆分为缩略图、主要描述区域和操作页脚的多个插槽部分,我们使用具有属性名称的 slot,如示例 3-24。

示例 3-24. 带有命名插槽的 ListLayout 组件
<template>
  <ul class="list-layout">
    <li class="list-layout__item" v-for="item in items" :key="item.id">
      <slot name="thumbnail" :item="item" />
      <slot name="main" :item="item">
        <div class="list-layout__item__name">{{ item.name }}</div>
        <div class="list-layout__item__description">{{ item.description }}</div>
      </slot>
      <slot name="actions" :item="item" />
    </li>
  </ul>

我们分配了每个插槽名称为 thumbnailmainactions。对于 main 插槽,我们添加了一个备用内容模板,以显示项目的名称和描述。

当我们想要将自定义内容传递给特定插槽时,我们使用 template 标签将内容包裹起来。然后,我们将声明所需插槽的名称(例如 slot-name)传递给 templatev-slot 指令,遵循以下语法:

<template v-slot:slot-name>
  <!-- Custom content -->
</template>

我们还可以使用简写语法 # 替代 v-slot

<template #slot-name>
  <!-- Custom content -->
</template>
注意

从现在开始,我们将使用 # 语法来表示 template 标签的 v-slot

就像在组件标签上使用 v-slot 一样,我们也可以访问插槽的数据:

<template #slot-name="mySlotProps">
  <!--<div> Slot data: {{ mySlotProps }}</div>-->
</template>

使用多个插槽

对于多个插槽,必须对每个相关的 template 标签使用 v-slot 指令,而不能用于组件标签。否则,Vue 将抛出错误。

让我们回到我们的 ProductItemList 组件(示例 3-23)并重构组件以渲染以下产品项目的自定义内容部分:

  • 一个缩略图图像

  • 一个用于将产品添加到购物车的操作按钮

示例 3-25 展示了如何使用 templatev-slot 实现这一点。

示例 3-25. 使用命名插槽组合 ProductItemList
<!-- ProductItemList.vue -->
<template>
  <div id="app">
    <ListLayout>
      <template #thumbnail="{ item }">
        <img
          v-if="item.thumbnail"
          class="list-layout__item__thumbnail"
          :src="item.thumbnail"
          :alt="item.name"
          width="200"
        />
      </template>
      <template #actions>
        <div class="list-layout__item__footer">
          <button class="list-layout__item__footer__button">Add to cart</button>
        </div>
      </template>
    </ListLayout>
  </div>
</template>

代码的输出如图 3-19 所示。

ProductItemList 组件的 UI 布局截图

图 3-19. 使用自定义插槽内容的 ProductItemList 输出

这就是全部。现在您已经准备好使用插槽来自定义您的 UI 组件了。使用插槽,您现在可以为应用程序创建一些基本的标准可重复使用的布局,例如带有页眉和页脚的页面布局,侧边栏布局,或者可以是对话框或通知的模态组件。然后,您将发现插槽在保持代码组织和可重用性方面是多么方便。

注意

使用 slot 也意味着浏览器不会应用组件中定义的所有相关作用域样式。要启用此功能,请参阅“将作用域样式应用于插槽内容”。

接下来,我们将学习如何使用 refs 访问已安装组件实例或 DOM 元素。

理解 Refs

虽然 Vue 通常会为您处理大部分 DOM 交互,但在某些场景中,您可能需要直接访问组件内的 DOM 元素以进行进一步操作。例如,当用户点击按钮时要打开模态对话框,或者在组件安装时聚焦特定输入字段。在这种情况下,您可以使用 ref 属性来访问目标 DOM 元素实例。

ref 是一个 Vue 内置属性,允许你直接引用一个 DOM 元素或已挂载的子实例。在 template 部分,你将 ref 属性的值分配给一个表示目标元素上引用名称的字符串。示例 3-26(#example_03_27_1)展示了如何创建 messageRef,它引用了 input DOM 元素。

示例 3-26. 具有分配给 messageRefref 属性的输入组件
<template>
  <div>
    <input type="text" ref="messageRef" placeholder="Enter a message" />
  </div>
</template>

然后你可以在 script 部分访问 messageRef 来操作 input 元素,通过 this.$refs.messageRef 实例。例如,你可以使用 this.$refs.messageRef.focus() 以编程方式聚焦 input 元素。

访问 ref 属性

只有在组件挂载后,ref 属性才能访问。

引用实例包含特定 DOM 元素或子组件实例的所有属性和方法,具体取决于目标元素类型。在使用 v-for 对循环元素使用 ref 属性的场景中,引用实例将是包含循环元素但无顺序的数组。

以任务列表为例。如 示例 3-27 所示,你可以使用 ref 属性访问任务列表。

示例 3-27. 具有分配给 taskListRefref 属性的任务列表
<template>
  <div>
    <ul>
      <li v-for="(task, index) in tasks" :key="task.id" ref="tasksRef">
        {{title}} {{index}}: {{task.description}}
      </li>
    </ul>
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  name: "TaskListComponent",
  data() {
    return {
      tasks: [{
        id: 'task01',
        description: 'Buy groceries',
      }, {
        id: 'task02',
        description: 'Do laundry',
      }, {
        id: 'task03',
        description: 'Watch Moonknight',
      }],
      title: 'Task',
    };
  }
});
</script>

一旦 Vue 挂载了 TaskListComponent,你可以看到 tasksRef 包含三个 li DOM 元素,并嵌套在组件实例的 refs 属性中,如 图 3-20 中 Vue Devtools 的截图所示。

现在你可以使用 this.$refs.tasksRef 访问任务元素列表,并在需要时进行进一步修改。

ref 也可以接受函数作为其值,通过给它添加前缀 ::ref)。该函数接受引用实例作为其输入参数。

我们已经学习了 ref 属性以及它如何在许多现实世界的挑战中提供帮助,例如构建可重用的模态系统(参见 “使用 Teleport 和

元素实现模态框”)。接下来的部分将探讨如何通过混入(mixins)创建和共享组件间的标准配置。

Vue Devtools 显示具有三个 li 元素的 tasksRef 引用实例

图 3-20. Vue Devtools 显示 tasksRef 引用实例

使用混入共享组件配置

实际上,某些组件共享类似的数据和行为并不罕见,比如咖啡馆和餐厅组件。这两个元素共享预订和接受付款的逻辑,但每个都有独特的特点。在这种情况下,你可以使用 mixins 属性来跨这两个组件共享标准功能。

例如,你可以创建一个包含两个组件 DiningComponentCafeComponent 标准功能的 restaurantMixin 对象,如示例 3-28 所示。

示例 3-28. 一个 restaurantMixin 混入对象
/** mixins/restaurantMixin.ts */
import { defineComponent } from 'vue'

export const restaurantMixin = defineComponent({
  data() {
    return {
      menu: [],
      reservations: [],
      payments: [],
      title: 'Restaurant',
    };
  },
  methods: {
    makeReservation() {
      console.log("Reservation made");
    },
    acceptPayment() {
      console.log("Payment accepted");
    },
  },
  created() {
    console.log(`Welcome to ${this.title}`);
  }
});

然后,你可以在 DiningComponentmixins 属性中使用 restaurantMixin 对象,如示例 3-29 所示。

示例 3-29. 使用 DiningComponentrestaurantMixin 混入属性
<template>
<!-- components/DiningComponent.vue -->
  <h1>{{title}}</h1>
  <button @click="getDressCode">getDressCode</button>
  <button @click="makeReservation">Make a reservation</button>
  <button @click="acceptPayment">Accept a payment</button>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import { restaurantMixin } from '@/mixins/restaurantMixin'

export default defineComponent({
  name: 'DiningComponent',
  mixins: [restaurantMixin],
  data() {
    return {
      title: 'Dining',
      menu: [
        { id: 'menu01', name: 'Steak' },
        { id: 'menu02', name: 'Salad' },
        { id: 'menu03', name: 'Pizza' },
      ],
    };
  },
  methods: {
    getDressCode() {
      console.log("Dress code: Casual");
    },
  },
  created() {
    console.log('DiningComponent component created!');
  }
});
</script>

示例 3-30 显示类似的 CafeComponent

示例 3-30. 使用 CafeComponentrestaurantMixin 混入属性
<template>
<!-- components/CafeComponent.vue -->
  <h1>{{title}}</h1>
  <p>Open time: 8am - 4pm</p>
  <ul>
    <li v-for="menuItem in menu" :key="menuItem.id">
      {{menuItem.name}}
    </li>
  </ul>
  <button @click="acceptPayment">Pay</button>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import { restaurantMixin } from '@/mixins/restaurantMixin'

export default defineComponent({
  name: 'CafeComponent',
  mixins: [restaurantMixin],
  data() {
    return {
      title: 'Cafe',
      menu: [{
        id: 'menu01',
        name: 'Coffee',
        price: 5,
      }, {
        id: 'menu02',
        name: 'Tea',
        price: 3,
      }, {
        id: 'menu03',
        name: 'Cake',
        price: 7,
      }],
    };
  },
  created() {
    console.log('CafeComponent component created!');
  }
});
</script>

在创建组件时,Vue 引擎将混入逻辑合并到组件中,组件的数据声明优先。在示例 3-29 和 3-30 中,DiningComponentCafeComponent 将拥有相同的属性 menureservationspaymentstitle,但具有不同的值。此外,restaurantMixin 中声明的方法和钩子将对两个组件都有效。这类似于继承模式,尽管组件不会覆盖混入钩子的行为。相反,Vue 引擎先调用混入的钩子,然后调用组件的钩子。

当 Vue 挂载 DiningComponent 时,你将在浏览器控制台中看到 Figure 3-21 的输出。

DiningComponent 输出显示 restaurantMixin 创建钩子首先被调用,然后是 DiningComponent 的创建钩子

图 3-21. DiningComponent 控制台日志输出顺序

类似地,当 Vue 挂载 CafeComponent 时,你将在浏览器控制台中看到 Figure 3-22 的输出。

CafeComponent 输出显示 restaurantMixin 创建钩子首先被调用,然后是 CafeComponent 的创建钩子

图 3-22. CafeComponent 控制台日志输出顺序

注意 title 的值在两个组件之间已更改,Vue 首先触发 restaurantMixincreated 钩子,然后是组件本身声明的钩子。

注意

多个混入的合并和触发顺序根据混入数组的顺序。Vue 总是 最后调用组件的钩子。当组合多个混入时,请考虑此顺序。

如果你打开 Vue Devtools,你会看到 restaurantMixin 是不可见的,而 DiningComponentCafeComponent 则带有各自的数据属性,如图 3-23 和 3-24 所示。

Vue Devtools 显示 DiningComponent

图 3-23. Vue Devtools 显示 DiningComponent

Vue Devtools 显示 CafeComponent

图 3-24. Vue Devtools 显示 CafeComponent

Mixin 对于在组件之间共享常见逻辑并保持代码组织非常有用。但是,过多的 Mixin 可能会让其他开发人员在理解和调试时感到困惑,并且在大多数情况下被认为是不良实践。我们建议在选择 Mixin 而不是 Composition API(第 5 章)等替代方案之前,验证您的使用案例。

到目前为止,我们已经探讨了如何使用 templatescript 部分的高级功能来组成组件的逻辑。接下来,让我们学习如何通过 Vue 内置的样式特性在 style 部分使您的组件更加美观。

带作用域的样式组件

就像常规的 HTML 页面结构一样,我们可以使用 <style> 标签为 SFC 组件定义 CSS 样式:

<style>
h1 {
  color: red;
}
</style>

<style> 部分通常在 Vue 单文件组件的顺序中位于最后,并且可以出现多次。将组件挂载到 DOM 后,Vue 引擎将应用 <style> 标签内定义的 CSS 样式到应用程序中所有元素或匹配的 DOM 选择器上。换句话说,组件中 <style> 中出现的所有 CSS 规则在挂载后会全局应用。以 示例 3-31 中展示的 HeadingComponent 为例,它渲染了一个带有一些样式的标题。

示例 3-31. 在 HeadingComponent 中使用 <style> 标签
<template>
  <h1 class="heading">{{title}}</h1>
  <p class="description">{{description}}</p>
</template>
<script lang='ts'>
export default {
  name: 'HeadingComponent',
  data() {
    return {
      title: 'Welcome to Vue Restaurant',
      description: 'A Vue.js project to learn Vue.js',
    };
  },
};
</script>
<style>
.heading {
  color: #178c0e;
  font-size: 2em;
}

.description {
  color: #b76210;
  font-size: 1em;
}
</style>

在 示例 3-31 中,我们为组件的 h1 元素和 p 元素创建了两个 CSS 类选择器:headingdescription。当 Vue 挂载组件时,浏览器将使用适当的样式绘制这些元素,如 图 3-25 中所示。

标题元素具有红色和大字体大小,而描述元素具有浅灰色和小字体大小

图 3-25. 应用了样式的 HeadingComponent

示例 3-32 展示了在父组件 App.vue 中的 HeadingComponent 外部添加了一个带有相同 heading 类选择器的 span 元素。

示例 3-32. 在父组件 App.vue 中添加相同的类选择器
<!-- App.vue -->
<template>
  <section class="wrapper">
    <HeadingComponent />
    <span class="heading">This is a span element in App.vue component</span>
  </section>
</template>

然后浏览器仍然将相同的样式应用于 span 元素,如 图 3-26 所示。

屏幕截图显示浏览器中的 span 元素和 h1 元素都具有红色颜色和相同的字体大小

图 3-26. App.vue 中的 span 元素具有与 HeadingComponent 中的 h1 元素相同的 CSS 样式

但如果我们不使用 HeadingComponent,或者在运行时应用程序中它尚不存在,span 元素将不会具有 heading 类选择器的 CSS 规则。

为了避免这种情况并更好地控制样式规则和选择器,Vue 提供了一个独特的特性,即 scoped 属性。通过 <style scoped> 标签,Vue 确保 CSS 规则仅适用于组件内相关的元素,而不会泄露到应用程序的其他部分。Vue 通过执行以下步骤实现了这一机制:

  1. 在目标元素标记上添加随机生成的数据属性,使用前缀语法data-v

  2. 将在<style scoped>标签中定义的 CSS 选择器转换为包含生成的数据属性。

让我们看看这在实践中是如何工作的。在 Example 3-33 中,我们为HeadingComponent<style>标签添加了scoped属性。

Example 3-33。为HeadingComponent<style>标签添加 scoped 属性。
<!-- HeadingComponent.vue -->
<!--...-->
<style scoped>
.heading {
  color: #178c0e;
  font-size: 2em;
}

.description {
  color: #b76210;
  font-size: 1em;
}
</style>

App.vue中定义的 span 元素(Example 3-32)将不会具有与HeadingComponent中的h1元素相同的 CSS 样式,如 Figure 3-27 所示。

截图显示浏览器中的 span 元素和 h1 元素具有不同的颜色和字体大小

Figure 3-27。现在,App.vue中的 span 元素具有默认的黑色。

当您在浏览器开发者工具的元素选项卡中打开时,您可以看到h1p元素现在具有 data-v-xxxx 属性,如 Figure 3-28 所示。

在 HeadingComponent 中,h1 和 p 元素具有 data-v-xxxx 属性

Figure 3-28。HeadingComponent中的h1p元素具有data-v-xxxx属性。

如果您选择h1元素并查看右侧面板上的样式,您会看到 CSS 选择器.heading已变成.heading[data-v-xxxx],如 Figure 3-29 所示。

CSS 选择器被转换为

Figure 3-29。CSS 选择器.heading被转换为.heading[data-v-xxxx]

我强烈建议您在组件中使用scoped属性作为良好的编码习惯,以避免项目增长时出现不必要的 CSS 错误。

注意

浏览器在决定应用样式的顺序时遵循CSS 特异性。因为 Vue 的作用域机制使用属性选择器[data-v-xxxx],仅使用.heading选择器是不足以覆盖父组件中的样式。

在作用域样式中应用 CSS 到子组件

从 Vue 3.x 开始,您可以使用:deep()伪类从父级覆盖或扩展子组件的作用域样式。例如,如 Example 3-34 所示,我们可以覆盖父级AppHeadingComponent中段落元素p的作用域样式。

示例 3-34。覆盖父级AppHeadingComponent中段落元素p的作用域样式。
<!-- App.vue -->
<template>
  <section class="wrapper">
    <HeadingComponent />
    <span class="heading">This is a span element in App.vue component</span>
  </section>
</template>
<style scoped>
.wrapper :deep(p) {
  color: #000;
}
</style>

HeadingComponent中,p元素的颜色将是黑色,而不是其作用域颜色#b76210,如 Figure 3-30 所示。

在 HeadingComponent 中,p 元素的颜色是黑色,而不是其作用域颜色#b76210

Figure 3-30。HeadingComponent中的 p 元素是黑色的。
注意

浏览器将新定义的 CSS 规则应用于 App 的任何子组件中嵌套的任何 p 元素及其子级。

将作用域样式应用于 Slot 内容

根据设计,<style scoped> 标签中定义的任何样式仅与组件的默认 template 相关联。Vue 无法转换任何分发内容以包括 data-v-xxxx 属性。要为任何分发内容设置样式,您可以使用 :slot([CSS selector]) 伪类,或在父级别创建专用的 style 部分,并保持代码整洁。

使用 v-bind() 伪类在 Style 标签中访问组件的数据值

我们经常需要访问组件的数据值,并将该值绑定到有效的 CSS 属性,例如根据用户偏好更改应用程序的暗模式或主题颜色。对于这类用例,我们使用 v-bind() 伪类。

v-bind() 接受组件的数据属性和 JavaScript 表达式作为其唯一参数的字符串。例如,我们可以根据 titleColor 数据属性的值更改 HeadingComponenth1 元素的颜色,如 示例 3-35 所示。

示例 3-35. 根据 titleColor 数据属性的值更改 h1 元素的颜色
<!-- HeadingComponent.vue -->
<template>
  <h1 class="heading">{{title}}</h1>
  <p class="description">{{description}}</p>
</template>
<script lang='ts'>
export default {
  //...
  data() {
    return {
      //...
      titleColor: "#178c0e",
    };
  },
};
</script>
<style scoped>
.heading {
  color: v-bind(titleColor);
  font-size: 2em;
}
</style>

v-bind() 伪类然后将 titleColor 数据属性的值转换为内联哈希 CSS 变量,如 图 3-31 所示。

titleColor 数据属性的值现在是内联样式中的哈希 CSS 属性

图 3-31. titleColor 数据属性的值现在是内联样式中的哈希 CSS 属性

让我们在浏览器的开发者工具中打开元素选项卡,查看元素的样式。您可以看到 .heading 选择器生成的颜色属性保持静态,并且具有与 titleColor 的开发哈希 CSS 属性相同的值(图 3-32,参见 #figure_03_27)。

.heading 选择器生成的颜色属性与 titleColor 的生成哈希 CSS 属性具有相同的值

图 3-32. .heading 选择器生成的颜色属性与 titleColor 的生成哈希 CSS 属性具有相同的值

v-bind() 帮助检索组件的数据值,然后将所需的 CSS 属性绑定到该动态值。然而,这只是单向绑定。如果您希望检索模板中定义的 CSS 样式以绑定到模板的元素上,您需要使用 CSS 模块,在下一节我们将介绍它。

使用 CSS 模块为组件设置样式

另一个用于按组件范围限定 CSS 样式的替代方法是使用 CSS 模块。[¹] CSS 模块是一种允许您正常编写 CSS 样式,然后在我们的 templatescript 部分中以 JavaScript 对象(模块)的形式消耗它们的方法。

要在 Vue 的 SFC 组件中开始使用 CSS 模块,您需要在style标签中添加module属性,就像我们在示例 3-36 中的HeadingComponent中展示的那样。

示例 3-36. 在HeadingComponent中使用 CSS 模块
<!-- HeadingComponent.vue -->
<style module>
.heading {
  color: #178c0e;
  font-size: 2em;
}

.description {
  color: #b76210;
  font-size: 1em;
}
</style>

现在,您将作为组件的$style属性对象的字段访问这些 CSS 选择器。我们可以在template部分删除为h1p元素分配的静态类名headingdescription。而是将这些元素的类绑定到$style对象的相关字段,如示例 3-37 所示。

示例 3-37. 使用$style对象动态绑定类
<!-- HeadingComponent.vue -->
<template>
  <h1 :class="$style.heading">{{title}}</h1>
  <p :class="$style.description">{{description}}</p>
</template>

浏览器上的输出与图 3-27 相同。但是,当查看浏览器开发工具中的元素标签时,您会看到 Vue 已对生成的类名进行了哈希处理,以便在组件内部保持样式隔离,如图 3-33 所示。

Vue 对类名进行了哈希处理

图 3-33. 生成的类名headingdescription现在已经被哈希化

此外,您可以通过为module属性赋予名称来重命名 CSS 样式对象$style,如示例 3-38 中所示。

示例 3-38. 将 CSS 样式对象$style重命名为headerClasses
<!-- HeadingComponent.vue -->
<style module="headerClasses">
.heading {
  color: #178c0e;
  font-size: 2em;
}

.description {
  color: #b76210;
  font-size: 1em;
}
</style>

template部分,您可以将h1p元素的类绑定到headerClasses对象,而不是示例 3-39 中所示的template部分。

示例 3-39. 使用headerClasses对象动态绑定类
<!-- HeadingComponent.vue -->
<template>
  <h1 :class="headerClasses.heading">{{title}}</h1>
  <p :class="headerClasses.description">{{description}}</p>
</template>
注意

如果您在组件中使用了<script setup>setup()函数(见第五章),则可以使用useCssModule()钩子来访问样式对象的实例。此函数仅接受样式对象的名称作为其唯一参数。

现在,该组件的设计比在style标签中使用scoped属性时更加隔离。代码看起来更有组织性,从外部覆盖该组件的样式更具挑战性,因为 Vue 会随机哈希相关的 CSS 选择器。然而,根据项目的需求,一种方法可能比另一种更好,或者结合使用scopedmodule属性以实现所需的结果可能至关重要。

总结

在本章中,我们学习了如何按照 SFC 标准创建 Vue 组件,并使用defineComponent()全面支持 Vue 应用程序的 TypeScript。我们还学会了使用slots在不同上下文中创建具有隔离样式和共享混合配置的可重用组件。我们进一步探讨了使用组件生命周期钩子、computedmethodswatch选项 API 属性来组合组件。接下来,我们将在这些基础上构建,创建自定义事件,并使用提供/注入模式开发组件之间的交互。

^(1) CSS 模块 最初是为 React 开发的一个开源项目。

第四章:互动组件之间的交互

在 第三章 中,我们深入探讨了如何使用生命周期钩子、计算属性、观察器、方法及其他功能来组合组件。我们还学习了插槽的强大之处,以及如何使用 props 从其他组件接收外部数据。

基于这个基础,本章指导您如何使用自定义事件、provide/inject 模式构建组件之间的交互。还介绍了 Teleport API,允许您在 DOM 树中移动元素,同时保持它们在 Vue 组件中出现的顺序。

Vue 中的嵌套组件和数据流

Vue 组件可以嵌套其他 Vue 组件。在复杂的 UI 项目中,这个特性非常有用,可以让用户将代码组织成更小、更可管理和可重用的片段。我们将嵌套的元素称为子组件,包含它们的组件称为父组件。

Vue 应用程序中的数据流默认是单向的,这意味着父组件可以向子组件传递数据,但反之则不行。父组件可以使用 props 将数据传递给子组件(在 “探索选项 API” 中简要讨论),而子组件可以通过自定义事件 emits 向父组件发出事件。图 4-1 展示了组件之间的数据流动。

图示显示了组件之间单向数据流

图 4-1 Vue 组件中的单向数据流

将函数作为 props 传递

与其他框架不同,Vue 不允许将函数作为 prop 直接传递给子组件。相反,您可以将函数绑定为自定义事件发射器(参见 “使用自定义事件进行组件间通信”)。

使用 props 将数据传递给子组件

在对象或数组形式下,Vue 组件的 props 字段包含了组件可以从父组件接收的所有可用数据属性。每个 props 的属性都是目标组件的一个 prop。要开始从父组件接收数据,您需要在组件选项对象中声明 props 字段,如 示例 4-1 所示。

示例 4-1 在组件中定义 props
export default {
  name: 'ChildComponent',
  props: {
    name: String
  }
}

在 示例 4-1 中,ChildComponent 组件接受一个类型为 Stringname 属性。然后父组件可以使用这个 name 属性向子组件传递数据,如 示例 4-2 所示。

示例 4-2 将静态数据作为 props 传递给子组件
<template>
  <ChildComponent name="Red Sweater" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  name: 'ParentComponent',
  components: {
    ChildComponent
  },
}
</script>

在前面的示例中,ChildComponent 收到了一个静态的“Red Sweater”作为 name 值。如果您想将动态数据变量(例如 children 列表中的第一个元素)传递和绑定到 name,您可以使用 v-bind 属性,即 :,如 示例 4-3 所示。

示例 4-3 将动态变量作为 props 传递给子组件
<template>
  <ChildComponent :name="children[0]" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  //...
  data() {
    return {
      children: ['Red Sweater', 'Blue T-Shirt', 'Green Hat']
    }
  }
}
</script>

前述代码的输出与将静态字符串Red Sweater传递给name属性相同。

注意

如果name属性不是String类型,则仍然需要使用v-bind属性(或:)将静态数据传递给子组件,例如对于Boolean类型使用:name="true",或对于Array类型使用:name="["hello", "world"]"

在 示例 4-3 中,每当children[0]的值变化时,Vue 也会更新ChildComponent中的name属性,并且如果需要,子组件将重新呈现其内容。

如果子组件中有多个属性,可以采用相同的方法将每个数据传递给相关的属性。例如,要将产品的nameprice传递给ProductComp组件,可以执行此操作(示例 4-4)。

示例 4-4. 向子组件传递多个属性
/** components/ProductList.vue */
<template>
  <ProductComp :name="product.name" :price="product.price" />
</template>
<script lang="ts">
import ProductComp from './ProductComp.vue'
export default {
  name: 'ProductList',
  components: {
    ProductComp
  },
  data() {
    return {
      product: {
        name: 'Red Sweater',
        price: 19.99
      }
    }
  }
}
</script>

我们可以定义ProductComp组件如同 示例 4-5 中所示。

示例 4-5. 在ProductComp中定义多个属性
<template>
  <div>
    <p>Product: {{ name }}</p>
    <p>Price: {{ price }}</p>
  </div>
</template>
<script lang="ts">
export default {
  name: 'ProductComp',
  props: {
    name: String,
    price: Number
  }
}
</script>

输出如下所示:

Product: Red Sweater
Price: 19.99

或者,您可以使用v-bind不是 :)来传递整个对象user,并将其属性绑定到相关子组件的属性:

<template>
  <ProductComp v-bind="product" />
</template>

请注意,只有子组件将接收相关声明的属性。因此,如果在父组件中有另一个字段product.description,它将无法在子组件中访问。

注意

另一种声明组件props的方法是使用一个字符串数组,每个字符串表示它接受的属性名称,例如props: ["name", "price"]。当您想快速原型化一个组件时,这种方法是实用的。然而,我强烈建议您使用props的对象形式,并为了代码可读性和错误预防的良好实践,声明所有的属性类型。

我们已经学习了如何声明带有类型的属性,但在需要时如何验证传递给子组件属性的数据?如何在未传递值时为属性设置回退值?让我们接着了解。

声明带有验证和默认值的属性类型

回到 示例 4-1,我们声明了name属性为String类型。在运行时,Vue 会警告如果父组件向name属性传递了非字符串值。然而,为了能够享受 Vue 的类型验证带来的好处,我们应该使用完整的声明语法:

{
  type: String | Number | Boolean | Array | Object | Date | Function | Symbol,
  default?: any,
  required?: boolean,
  validator?: (value: any) => boolean
}

在其中:

  • type 是属性的类型。它可以是一个构造函数(或自定义类),也可以是内置类型之一。

  • default 是属性的默认值,如果没有传递值则使用该值。对于ObjectFunctionArray类型,默认值必须是一个返回初始值的函数。

  • required 是一个布尔值,指示属性是否是必需的。如果requiredtrue,则父组件必须向属性传递一个值。默认情况下,所有属性都是可选的。

  • validator 是一个验证传递给属性值的函数,主要用于开发调试。

我们可以声明更具体的nameprop,包括一个默认值,如示例 4-6 所示。

示例 4-6. 将 prop 定义为具有默认值的字符串
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      default: 'Child component'
    }
  }
}

如果父组件没有传递值,则子组件将回退到默认值“Child component”

我们还可以将name设置为子组件的必需 prop,并为其接收到的数据添加验证器,如示例 4-7 所示。

示例 4-7. 使用 prop 验证器将 name 定义为必需项
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      required: true,
      validator: value => value !== "Child component"
    }
  }
}

在这种情况下,如果父组件未向nameprop 传递值,或者传递的值匹配Child component,Vue 将在开发模式下抛出警告(见图 4-2)。

控制台警告屏幕截图,因未能验证名称 prop 而显示

图 4-2. 开发环境下控制台警告未能验证 prop
注意

对于default字段,Function类型是一个返回 prop 初始值的函数。您不能使用它将数据传回父组件或在父级上触发数据更改。

除了 Vue 提供的内置类型和验证之外,您还可以结合 JavaScript 的Class或函数构造函数和 TypeScript 来创建自定义的 prop 类型。我将在下一节中介绍它们。

声明带有自定义类型检查的 props

使用像ArrayStringObject这样的原始类型非常适合基本用例。然而,随着您的应用程序的增长,原始类型可能过于通用,无法保证组件的类型安全性。以以下模板代码为例的PizzaComponent

<template>
  <header>Title: {{ pizza.title }}</header>
  <div class="pizza--details-wrapper">
    <img :src="pizza.image" :alt="pizza.title" width="300" />
    <p>Description: {{ pizza.description }}</p>
    <div class="pizza--inventory">
      <div class="pizza--inventory-stock">Quantity: {{pizza.quantity}}</div>
      <div class="pizza--inventory-price">Price: {{pizza.price}}</div>
    </div>
  </div>
</template>

此组件接受一个强制的pizzaprop,它是一个包含一些有关pizza的详细信息的Object

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object,
      required: true
    }
  }
}

直接了当。但是,通过将pizza声明为Object类型,我们假设父组件始终会传递适当的带有所需字段(titleimagedescriptionquantityprice)的对象给pizza以渲染。

这种假设可能会导致问题。由于pizza接受Object类型的数据,任何使用PizzaComponent的组件都可以向pizzaprop 传递任何对象数据,而不必是一个真正用于pizza的字段,如示例 4-8 所示。

示例 4-8. 使用错误数据的 Pizza 组件
<template>
  <div>
    <h2>Bad usage of Pizza component</h2>
    <pizza-component :pizza="{ name: 'Pinia', description: 'Hawaiian pizza' }" />
  </div>
</template>

上述代码导致PizzaComponent的 UI 渲染错误,只有一个description可用,其余字段为空(图像损坏),如图 4-3 所示。

渲染没有标题、价格、数量和图像的披萨的屏幕截图

图 4-3. 没有图像链接和缺少披萨字段导致 UI 错误

在这里,TypeScript 也无法检测到数据类型不匹配,因为它根据pizza的声明类型,即通用的Object执行类型检查。另一个潜在的问题是,以错误的嵌套属性格式传递pizza可能导致应用程序崩溃。因此,为了避免此类事故,我们使用自定义类型声明。

我们可以定义Pizza类,并声明类型为Pizza的 prop pizza,如示例 4-9 所示。

示例 4-9. 声明 Pizza 自定义类型
class Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;

  constructor(
    title: string,
    description: string,
    image: string,
    quantity: number,
    price: number
  ) {
    this.title = title
    this.description = description
    this.image = image
    this.quantity = quantity
    this.price = price
  }
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Pizza, ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
      required: true
    }
  }
}

1

直接将pizza props 的类型声明为Pizza

或者,您可以使用 TypeScript 的interfacetype来定义自定义类型,而不是Class。然而,在这种情况下,您必须使用vue包中的PropType,并采用以下语法将声明的类型映射到目标 prop:

type: Object as PropType<Your-Custom-Type>

让我们将Pizza类重写为interface(见示例 4-10)。

示例 4-10. 使用 TypeScript 接口 API 声明 Pizza 自定义类型
import type { PropType } from 'vue'

interface Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object as PropType<Pizza>, ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
      required: true
    }
  }
}

1

pizza props 的类型声明为带有PropType帮助的Pizza接口。

当您使用错误的数据格式化PizzaComponent时,TypeScript 将检测并适当地突出显示错误。

注意

Vue 在运行时执行类型验证,而 TypeScript 在编译时执行类型检查。因此,使用 Vue 的类型检查和 TypeScript 的类型检查结合起来,以确保您的代码没有错误是一个很好的实践。

使用defineProps()withDefaults()声明 props

正如我们在“设置”中学到的,从 Vue 3.x 开始,Vue 提供了<script setup>语法,用于声明功能组件,而无需经典的 Options API。在这个<script setup>块内,你可以使用defineProps()来声明 props,如示例 4-11 所示。

示例 4-11. 使用defineProps()<script setup>声明 props
<script setup>
import { defineProps } from 'vue'

const props = defineProps({
  name: {
    type: String,
    default: "Hello from the child component."
  }
})
</script>

多亏了 TypeScript,我们还可以声明每个组件接受的defineProps()类型,同时在编译时进行类型验证,如示例 4-12 所示。

示例 4-12. 使用defineProps()TypeScript type声明 props
<script setup >
import { defineProps } from 'vue'

type ChildProps = {
  name?: string
}

const props = defineProps<ChildProps>()
</script>

在这种情况下,为了声明message prop 的默认值,我们需要使用withDefaults()来包装defineProps()调用,如示例 4-13 所示。

示例 4-13. 使用defineProps()withDefaults()声明 props
import { defineProps, withDefaults } from 'vue'

type ChildProps = {
  name?: string
}

const props = withDefaults(defineProps<ChildProps>(), {
  name: 'Hello from the child component.'
})

使用defineProps()与 TypeScript 类型检查

当使用defineProps()时,我们不能同时结合运行时和编译时类型检查。我建议在示例 4-11 中采用defineProps()的方法,以获得更好的可读性和 Vue 与 TypeScript 类型检查的结合。

我们已经学习了如何声明 props 以传递 Vue 组件中的原始数据,进行类型检查和验证。接下来,我们将探讨如何将函数作为自定义事件发射器传递给子组件。

使用自定义事件在组件之间进行通信

Vue 将通过 props 将数据传递给子组件,视为只读和原始数据。单向数据流确保只有父组件可以更新数据 prop。我们经常希望更新特定的数据 prop 并将其与父组件同步。为此,我们使用组件选项中的emits字段声明自定义事件。

以待办事项列表或ToDoList组件为例。这个ToDoList将使用ToDoItem作为其子组件,使用示例中的代码渲染任务列表示例 4-14。

示例 4-14. ToDoList组件
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem :task="task" />
    </li>
  </ul>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import ToDoItem from './ToDoItem.vue'
import type { Task } from './ToDoItem'

export default defineComponent({
  name: 'ToDoList',
  components: {
    ToDoItem
  },
  data() {
    return {
      tasks: [
        { id: 1, title: 'Learn Vue', completed: false },
        { id: 2, title: 'Learn TypeScript', completed: false },
        { id: 3, title: 'Learn Vite', completed: false },
      ] as Task[]
    }
  }
})
</script>

ToDoItem是一个组件,接收一个task属性,并渲染一个作为复选框的input,供用户标记任务是否完成。这个input元素将task.completed作为其checked属性的初始值。让我们看一下示例 4-15。

示例 4-15. ToDoItem组件
<template>
  <div>
    <input
      type="checkbox"
      :checked="task.completed"
    />
    <span>{{ task.title }}</span>
  </div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'

export interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'ToDoItem',
  props: {
    task: {
      type: Object as PropType<Task>,
      required: true,
    }
  },
})
</script>

当用户切换此input复选框时,我们希望触发名为task-completed-toggle的事件,以通知父组件有关特定任务的task.completed值。我们可以通过在组件选项的emits字段中首先声明事件来实现这一点(示例 4-16)。

示例 4-16. 带有 emits 的ToDoItem组件
/** ToDoItem.vue */
export default defineComponent({
  //...
  emits: ['task-completed-toggle']
})

然后,我们创建一个新的方法onTaskCompleted来发出task-completed-toggle事件,该事件包含复选框的新值task.completedtask.id作为事件的负载(示例 4-17)。

示例 4-17. 带有方法来发出task-completed-toggle事件的ToDoItem组件
/** ToDoItem.vue */
export default defineComponent({
  //...
  methods: {
    onTaskCompleted(event: Event) {
      this.$emit("task-completed-toggle", {
        ...this.task,
        completed: (event.target as HTMLInputElement)?.checked,
      });
    },
  }
})
注意

我们使用defineComponent将组件选项包装起来,创建一个与 TypeScript 兼容的组件。对于简单的组件并不需要使用defineComponent,但是在组件的方法、钩子或计算属性中访问this的其他数据属性时需要使用它。否则,TypeScript 将会抛出错误。

然后,我们将onTaskCompleted方法绑定到input元素的change事件上,如示例 4-18 所示。

示例 4-18. ToDoItem组件更新后的模板
<div>
  <input
    type="checkbox"
    :checked="task.completed"
    @change="onTaskCompleted"
  />
  <span>{{ task.title }}</span>
</div>

现在,在ToDoItem的父组件<ToDoList>中,我们可以使用@符号将task-completed-toggle事件绑定到一个方法上,模板在示例 4-19 中。

示例 4-19. ToDoList组件更新后的模板
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem
        :task="task"
        @task-completed-toggle="onTaskCompleted"
      />
    </li>
  </ul>
</template>

父组件<ToDoList>中的onTaskCompleted方法将接收task-completed-toggle事件的负载,并更新tasks数组中特定任务的task.completed值,就像示例 4-20 中那样。

Example 4-20. ToDoList 组件的脚本,带有处理 task-completed-toggle 事件的方法
//...

export default {
  //...
  methods: {
    onTaskCompleted(payload: { id: number; completed: boolean }) {
      const index = this.tasks.findIndex(t => t.id === payload.id)

      if (index < 0) return

      this.tasks[index].completed = payload.completed
    }
  }
}

这些代码块将渲染出 Figure 4-4 中显示的页面。

带有三个任务的待办事项列表的屏幕截图,每个任务都有复选框和任务标题

图 4-4. 带有三个项目的 ToDoList 组件

Vue 将更新 ToDoList 中的相关数据,并相应地渲染相关的 ToDoItem 组件实例。你可以切换复选框来标记待办事项为完成状态。Figure 4-5 显示了我们可以使用 Vue Devtools 检测组件的事件。

Vue Devtools 屏幕截图,显示由 +ToDoItem+ 组件发出的事件

图 4-5. 将待办事项标记为完成,并使用 Vue Devtools 调试发出的事件

使用 defineEmits() 定义自定义事件

类似于 “使用 defineProps() 和 withDefaults() 声明 Props”,在 <script setup> 代码块中,你可以使用 defineEmits() 来定义自定义事件。defineEmits() 函数接受与 emits 相同的输入参数类型:

const emits = defineEmits(['component-event'])

然后它返回一个函数实例,我们可以用它来调用组件中的特定事件:

emits('component-event', [...arguments])

因此,我们可以像 Example 4-21 中那样编写 ToDoItem 的脚本部分。

Example 4-21. 使用 defineEmits()ToDoItem 组件,具有自定义事件
<script lang="ts" setup>
//...
const props = defineProps({
  task: {
    type: Object as PropType<Task>,
    required: true,
  }
});

const emits = defineEmits(['task-completed-toggle'])

const onTaskCompleted = (event: Event) => {
  emits("task-completed-toggle", {
    id: props.task.id,
    completed: (event.target as HTMLInputElement)?.checked,
  });
}
</script>

注意这里我们不需要使用 defineComponent,因为在 <script setup> 代码块中没有 this 实例可用。

为了更好的类型检查,你可以对 task-completed-toggle 事件使用仅类型声明,而不是单个字符串。让我们改进 Example 4-21 中的 emits 声明,使用类型 EmitEvents,如 Example 4-22 所示。

Example 4-22. 使用 defineEmits() 和仅类型声明的自定义事件
// Declare the emit type
type EmitEvents = {
  (e: 'task-completed-toggle', task: Task): void;
}

const emits = defineEmits<EmitEvents>()

这种方法有助于确保你将正确的方法绑定到声明的事件上。正如对于 task-complete-toggle 事件所见,任何事件声明都应遵循相同的模式:

(e: 'component-event', [...arguments]): void

在上述语法中,e 是事件的名称,而 arguments 是传递给事件发射器的所有输入。在 task-completed-toggle 事件的情况下,其发射器的参数是类型为 Tasktask

emits 是一个强大的功能,允许你在不破坏 Vue 数据流机制的情况下,启用父子组件之间的双向通信。然而,propsemits 仅在需要直接数据通信时才有利。

你必须使用不同的方法来将数据从组件传递到其孙子或后代。在下一节中,我们将看到如何使用 provideinject API 将数据从父组件传递到其子组件或孙子组件。

使用 provide/inject 模式在组件之间进行通信

要在祖先组件和其后代之间建立数据通信,provide/inject API 是一个合理的选择。provide 字段从祖先传递数据,而 inject 确保 Vue 将提供的数据注入到任何目标后代中。

使用 provide 传递数据

组件选项字段 provide 接受两种格式:数据对象或函数。

provide 可以是一个包含要注入的数据的对象,每个属性表示一个(键,值)数据类型。在下面的示例中,ProductList 提供了一个数据值 selectedIds,其值为 [1],传递给其所有后代(示例 4-23)。

示例 4-23. 在 ProductList 组件中使用 provide 传递 selectedIds
export default {
  name: 'ProductList',
  //...
  provide: {
    selectedIds: [1]
  },
}

provide 的另一种格式类型是一个返回包含可供后代注入的数据的对象的函数。此格式类型的一个好处是可以访问 this 实例,并将动态数据或组件方法映射到返回对象的相关字段中。从 示例 4-23 开始,我们可以将 provide 字段重写为一个函数,如 示例 4-24 所示。

示例 4-24. 在 ProductList 组件中使用 provide 传递 selectedIds 作为一个函数
export default {
//...
  provide() {
    return {
      selectedIds: [1]
    }
  },
//...
}
</script>
注意

props 不同,您可以传递一个函数,并让目标后代使用 provide 字段触发它。这样做可以使数据返回到父组件。然而,Vue 认为这种方法是一种反模式,您应该谨慎使用。

到此为止,我们的 ProductList 使用 provide 将一些数据值传递给其后代。接下来,我们必须注入提供的值以在后代中操作。

使用 inject 接收数据

props 类似,inject 字段可以接受一个字符串数组,每个字符串代表一个提供的数据键(inject: [*selectedId*])或一个对象。

当作为对象字段使用 inject 时,其每个属性都是一个对象,键表示组件内部使用的本地数据键,并具有以下属性:

{
  from?: string;
  default: any
}

在这里,如果属性键与祖先提供的键相同,则 from 是可选的。例如,根据 示例 4-23,ProductList 向其后代提供的数据 selectedIds,我们可以计算一个 ProductComp,它从 ProductList 接收提供的数据 selectedIds 并将其重命名为 currentSelectedIds 以在本地使用,如 示例 4-25 所示。

示例 4-25. 在 ProductComp 中注入提供的数据
<script lang='ts'>
export default {
  //...
  inject: {
    currentSelectedIds: {
      from: 'selectedIds',
      default: []
    },
  },
}
</script>

在这段代码中,Vue 将注入的 selectedIds 的值赋给一个本地数据字段 currentSelectedIds,如果没有注入的值,则使用其默认值 []

在浏览器开发者工具中的 Vue 标签的组件部分中,当从组件树(左侧面板)选择 ProductComp 时,您可以调试注入数据的重命名指示(右侧面板),如 图 4-6 所示。

屏幕截图显示浏览器开发工具中 Vue 标签的组件标签页,显示有关组件提供和注入数据的信息。

图 4-6. 使用 Vue Devtools 调试 provideinject 提供和注入的数据
注意

在 Composition API 中,provide/inject 的等效钩子分别为 provide()inject()

现在我们了解如何使用 provideinject 在组件之间高效地传递数据,避免了 Props 钻取。让我们来探索如何使用 <Teleport> 组件将元素的特定内容区域渲染到 DOM 的另一个位置。

Teleport API

由于样式限制,我们经常需要实现一个包含元素的组件,Vue 应该将其在实际 DOM 中的不同位置进行渲染,以达到完整的视觉效果。在这种情况下,我们通常需要通过开发复杂的解决方案来“传送”这些元素,从而导致性能影响、时间消耗等问题。为了解决这种“传送”挑战,Vue 提供了 <Teleport> 组件。

<Teleport> 组件接受一个 to 属性,该属性指示目标容器,可以是元素的查询选择器或所需的 HTML 元素。假设我们有一个 House 组件,其中包含需要 Vue 引擎将其传送到指定 #sky DOM 元素的 天空和云 部分,如 示例 4-26 中所示。

示例 4-26. Teleport 带有的房屋组件
<template>
  <div>
    This is a house
  </div>
  <Teleport to="#sky">
    <div>Sky and clouds</div>
  </Teleport>
</template>

在我们的 App.vue 中,我们在 House 组件上方添加了一个目标 id 为 skysection 元素,如 示例 4-27 中所示。

示例 4-27. App.vue 模板,包含 House 组件
<template>
  <section id="sky" />
  <section class="wrapper">
      <House />
  </section>
</template>

图 4-7 显示了代码输出。

反转顺序显示两个文本的屏幕截图

图 4-7. 使用 Teleport 组件时的实际显示顺序

当您使用浏览器开发者工具的元素选项卡检查 DOM 树时,“天空和云”显示为嵌套在 <section id="sky"> 中,而不是 (图 4-8)。

显示 DOM 树的屏幕截图

图 4-8. 使用 Teleport 组件时的实际 DOM 树

你也可以通过其布尔属性 disabled 暂时禁用 <Teleport> 组件实例内部的内容移动。这个组件在你希望保持 DOM 树结构的同时,只在需要时将指定内容移动到目标位置时非常有用。Teleport 的一个日常使用场景是模态框,接下来我们将实现它。

在一个父级下包装两个部分

在传送到 <Teleport> 前,目标组件必须在 DOM 中存在。在 示例 4-27 中,如果将两个 section 实例包裹在 main 元素中,<Teleport> 组件将无法按预期工作。有关更多细节,请参见“使用 Teleport 时的渲染问题”。

使用 Teleport 和 <dialog> 元素实现模态框

模态框是一个对话框窗口,出现在屏幕顶部,阻止用户与主页面的交互。用户必须与模态框交互以关闭它,然后返回主页面。

模态框非常方便地显示需要用户完全注意的重要通知,并且应该只出现一次。

让我们设计一个基本的模态框。与对话框类似,模态框应包含以下元素(图 4-9):

  • 背景覆盖整个屏幕,在其上出现模态框,阻止用户与当前页面的交互。

  • 模态框窗口包含模态框的内容,包括具有标题和关闭按钮的 header,一个 main 内容部分,以及一个默认关闭按钮的 footer 部分。这三个部分可以使用插槽进行自定义。

显示基本模态框设计的截图。

图 4-9. 基本模态框设计

基于上述设计,在 示例 4-28 中使用 <dialog> HTML 元素实现 Modal 组件模板。

示例 4-28. Modal 组件
<template>
  <dialog :open="open">
    <header>
      <slot name="m-header"> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        <h2>{{ title }}</h2>
        <button>X</button>
      </slot>
    </header>
    <main>
      <slot name="m-main" /> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    </main>
    <footer>
      <slot name="m-footer"> ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
        <button>Close</button>
      </slot>
    </footer>
  </dialog>
</template>

在上述代码中,我们使用三个插槽部分,允许用户进行自定义:

1

模态框的标题 (m-header)

2

主内容 (m-main)

3

模态框的页脚 (m-footer)

我们还将 <dialog> 元素的 open 属性绑定到一个名为 open 的本地数据属性,用于控制模态框的可见性(显示/隐藏)。此外,我们将 title 属性渲染为模态框的默认标题。现在,让我们实现 Modal 组件的选项,它接收两个 props:opentitle,就像 示例 4-29 中描述的那样。

示例 4-29. 为 Modal 组件添加 props
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  props: {
    open: {
      type: Boolean,
      default: false,
    },
    title: {
      type: String,
      default: 'Dialog',
    },
  },
})
</script>

当用户点击模态框的关闭按钮或标题栏上的“X”按钮时,模态框应关闭自身。由于我们使用 open prop 控制模态框的可见性,我们需要在 Modal 组件中声明 emits 和一个 close 方法,以发送带有新的 open 值的 closeDialog 事件,如 示例 4-30 中描述的那样。

示例 4-30. 声明 Modal 发出的事件 closeDialog
<script lang="ts">
/** Modal.vue */
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  //...
  emits: ["closeDialog"], ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
  methods: {
    close() { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
      this.$emit("closeDialog", false);
    },
  },
})
</script>

1

emits 一个事件,closeDialog

2

close 方法将以 false 作为新值发出 closeDialog 事件

然后,我们使用 @ 符号将其绑定到 <dialog> 元素中的相关动作元素,如 示例 4-31 所示。

Example 4-31. 绑定点击事件的事件监听器
<template>
  <dialog :open="open" >
    <header>
      <slot name="m-header" >
        <h2>{{ title }}</h2>
        <button @click="close" >X</button> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
      </slot>
    </header>
    <main>
      <slot name="m-main" />
    </main>
    <footer>
      <slot name="m-footer" >
        <button @click="close" >Close</button> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
      </slot>
    </footer>
  </dialog>
</template>

1

@click 事件处理器用于标题栏上的“X”按钮

2

@click 事件处理器用于页脚上的默认关闭按钮

接下来,我们需要使用 <Teleport> 组件将 dialog 元素包装起来,以将其移出父组件的 DOM 树。我们还向 <Teleport> 组件传递 to 属性,指定目标位置:一个带有 id 为 modal 的 HTML 元素。最后,我们将 disabled 属性绑定到组件的 open 值上,以确保 Vue 仅在可见时将模态组件内容移动到指定位置(示例 4-32)。

Example 4-32. 使用 <Teleport> 组件
<template>
  <teleport ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    to="#modal" ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    :disabled="!open" ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
  >
    <dialog ref="dialog" :open="open" >
      <header>
      <slot name="m-header">
        <h2>{{ title }}</h2>
        <button @click="close" >X</button>
      </slot>
      </header>
      <main>
        <slot name="m-main" />
      </main>
      <footer>
        <slot name="m-footer">
          <button @click="close" >Close</button>
        </slot>
      </footer>
    </dialog>
  </teleport>
</template>

1

<Teleport> 组件

2

to 属性与具有 id 选择器 modal 的目标位置

3

当组件的 open 值为假值时,带有 disabled 属性

现在让我们尝试在 WithModalComponent 中使用我们的 Modal 组件,通过在 示例 4-33 中添加以下代码。

Example 4-33. 在 WithModalComponent 中使用模态组件
<template>
  <h2>With Modal component</h2>
  <button @click="openModal = true">Open modal</button>
  <Modal :open="openModal" title="Hello World" @closeDialog="toggleModal"/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Modal from "./Modal.vue";

export default defineComponent({
  name: "WithModalComponent",
  components: {
    Modal,
  },
  data() {
    return {
      openModal: false,
    };
  },
  methods: {
    toggleModal(newValue: boolean) {
      this.openModal = newValue;
    },
  },
});
</script>

最后,在 index.html 文件的 body 元素中添加一个带有 id 为 modal<div> 元素:

<body>
  <div id="app"></div>
  <div id="modal"></div> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
  <script type="module" src="/src/main.ts"></script>
</body>

1

带有 id modaldiv 元素

这样做可以使 Vue 在 open 属性设置为 true 时,将 Modal 组件的内容呈现到带有 id 为 modal 的此 div 中(图 4-10)。

当可见时,模态组件呈现到带有 id 为 +modal+ 的 +div+ 中

图 4-10. 当可见时,模态组件呈现到带有 id 为 modaldiv

图 4-11 展示了它在屏幕上的样子:

在  中,当模态框可见时的输出

图 4-11. WithModalComponent 在模态框可见时的输出

open 属性为 false 时,带有 id modaldiv 是空的(图 4-12),并且模态框在屏幕上是不可见的(图 4-13)。

当隐藏时,模态组件不呈现到带有 id 为 +modal+ 的 +div+ 中

图 4-12. 当隐藏时,模态组件不呈现到带有 id 为 modaldiv

当隐藏时,模态组件不可见

图 4-13. 当隐藏时,模态组件不可见

到此时,您已经有一个工作的模态框组件。然而,模态框的视觉外观并不完全符合我们的预期;当模态框可见时,主页面内容应该有一个黑暗的遮罩层。让我们通过模态框元素的<style>部分中的::backdrop选择器来修复这个问题:

<style scoped>
  dialog::backdrop {
    background-color: rgba(0, 0, 0, 0.5);
  }
</style>

然而,这不会改变模态框背景的外观。这种行为是因为浏览器仅在我们使用dialog.showModal()方法打开对话框时,而不是通过更改open属性时,应用::backdrop CSS 选择器规则到对话框上。为了解决这个问题,我们需要在我们的Modal组件中执行以下修改:

  • 通过将“dialog”值分配给ref属性,直接引用<dialog>元素:

    <dialog :open="open" ref="dialog">
      <!--...-->
    </dialog>
    
  • 每当open属性变化时,在dialog元素上分别触发$refs.dialog.showModal()$refs.dialog.close(),使用watch

    watch: {
      open(newValue) {
        const element = this.$refs.dialog as HTMLDialogElement;
        if (newValue) {
          element.showModal();
        } else {
          element.close();
        }
      },
    },
    
  • 移除<dialog>元素的原始绑定open属性:

    <dialog ref="dialog">
      <!--...-->
    </dialog>
    
  • 移除<teleport>组件中disabled属性的使用:

    <teleport to="#modal">
      <!--...-->
    </teleport>
    

使用内置的showModal()方法打开模态框时,浏览器会在 DOM 中的实际<dialog>元素上添加一个::backdrop伪元素,并动态移动元素内容到目标位置会禁用此功能,导致模态框无法显示所需的背景。

我们还通过将以下 CSS 规则添加到dialog选择器中,将模态框重新定位到页面中心并置于其他元素之上:

dialog {
  position: fixed;
  z-index: 999;
  inset-block-start: 30%;
  inset-inline-start: 50%;
  width: 300px;
  margin-inline-start: -150px;
}

当模态框可见时,输出将如图 4-14 所示。

具有背景和样式的模态框组件

图 4-14. 具有背景和样式的模态框组件

我们已经学习了如何使用Teleport实现可重用的Modal组件,并探索了使用每个内置<dialog>元素功能的不同用例。我们还学习了如何使用::backdrop CSS 选择器为模态框的背景样式。

正如您注意到的那样,我们将模态框的目标位置div设置为body的直接子元素,而不是 Vue 应用程序入口元素<div id="app">之内。如果我们希望将模态框的目标div移动到 Vue 应用程序的入口组件App.vue内部会发生什么?让我们在下一节中找出答案。

使用 Teleport 渲染时的渲染问题

要理解在App.vue组件的子组件内使用Teleport渲染模态框的问题,请首先将<div id="modal"></div>index.html移动到App.vue之后,位于WithModalComponent实例之后:

<template>
  <section class="wrapper">
    <WithModalComponent />
  </section>
  <div id="modal"></div>
</template>

在运行应用程序后,您会发现无论您多次点击“打开模态框”按钮,浏览器都不会渲染模态框。控制台显示以下错误:

在 App.vue 中渲染模态框时的错误消息

图 4-15. 在App.vue中渲染模态框时的控制台错误消息

由于 Vue 渲染顺序机制,父级在自身渲染之前会等待子级完成渲染。子级按照在父级template部分出现的顺序进行渲染。在这种情况下,WithModalComponent先渲染。因此,Vue 渲染<dialog>元素并开始将组件内容移动到目标位置,然后再渲染ParentComponent。然而,由于ParentComponent仍在等待WithModalComponent完成其渲染,因此<div id="modal">元素尚不存在于 DOM 中。因此,Vue 无法定位目标位置并执行正确的移动操作,也无法将<dialog>元素渲染到<div id="modal">元素内,从而导致错误。

绕过此限制的一种解决方法是在WithModalComponent之前将目标元素<div id="modal">放置出现:

<template>
  <div id="modal"></div>
  <section class="wrapper">
    <WithModalComponent />
  </section>
</template>

此解决方案确保在 Vue 渲染Modal元素并移动内容之前,目标div已经可用。另一种方法是在渲染期间使用disabled属性推迟Modal的内容移动过程,直到用户点击打开模态框按钮。这两种选择都有利有弊,您应选择最适合您需求的选项。

最常见的解决方案是将目标元素作为body元素的直接子元素插入,并使其与 Vue 渲染上下文隔离。

使用<Teleport>的一个显著优势是在保持代码层次结构、组件隔离和可读性的同时,实现最大的视觉显示效果(如全屏模式、模态框、侧边栏等)。

概述

本章探讨了使用内置的 Vue 特性(如propsemitsprovide/inject)在组件通信中采用不同方法的概念。我们学习了如何利用这些特性在组件之间传递数据和事件,同时保持 Vue 的数据流机制完整。我们还学习了如何使用 Teleport API 将元素渲染到父组件的<template>中,但将其显示顺序保持在父组件中。 <Teleport>对于构建需要与主页面元素对齐显示的组件非常有益,例如弹出窗口、对话框、模态框等。

在下一章中,我们将进一步探讨组合 API 及其如何用于组合 Vue 组件。

第五章:组合 API

在前一章中,您已经学习了如何使用经典的 Options API 组合 Vue 组件。尽管 Options API 自 Vue 2 以来一直是组合 Vue 组件最常见的 API,但使用 Options API 可能会导致不必要的代码复杂性、大型组件代码的不可读性以及它们之间逻辑重用的问题。针对这些用例,本章介绍了组合 Vue 组件的另一种方法,即组合 API。

在本章中,我们将探索不同的组合挂钩,以创建 Vue 中的功能状态元素。我们还将学习如何结合 Options API 和组合 API,以获得更好的响应控制,并为应用程序组合我们自己可重用的组合物。

使用组合 API 设置组件

在 Vue 中,使用 Options API 组合组件是一种常见的实践。然而,在许多情况下,我们希望重用部分组件逻辑,而不必担心数据和方法的重叠,如混入(见 第五章)。组合 API 在这种情况下非常有帮助。

组合 API 是在 Vue 3.0 中引入的,它提供了一种用 setup() 挂钩(“setup”)或 <script setup> 标记初始化和创建组件实例之前运行 一次 的替代方式,用于组合有状态和响应式组件。

只能在此挂钩或其等效语法 <script setup> 标记中使用组合 API 函数或可组合物(“创建可重用的组合物”)。这种组合创建了一个有状态的功能性组件,并提供了一个绝佳的地方来定义组件的响应状态和方法,并初始化其他生命周期挂钩(参见 “使用生命周期挂钩”),使代码更易读。

让我们探索组合 API 的强大之处,从使用 ref()reactive() 函数处理组件的响应数据开始。

使用 ref()reactive() 处理数据

在 第二章 中,您学习了 Options API 中的 data() 函数属性,用于初始化组件的数据(“使用数据属性创建本地状态”)。从 data() 返回的所有数据属性都是响应式的,这意味着 Vue 引擎会自动监视每个声明的数据属性的更改。然而,默认功能可能会在组件中造成过多的开销,特别是当您有许多静态数据属性时。在这种情况下,Vue 引擎仍会为这些静态值启用观察器,这是不必要的。为了限制过多的数据观察器数量并更好地控制要观察的数据属性,Vue 在组合 API 中引入了 ref()reactive() 函数。

使用 ref()

ref() 是一个接受单一参数并返回具有该参数作为初始值的响应式对象的函数。我们称这个返回的对象为 ref 对象:

import { ref } from 'vue'

export default {
  setup() {
    const message = ref("Hello World")
    return { message }
  }
}

或者在 <script setup> 中:

<script setup>
import { ref } from 'vue'

const message = ref("Hello World")
</script>

然后我们可以在 script 部分通过其单一的 value 属性访问返回对象的当前值。例如,示例 5-1 中的代码创建了一个初始值为 0 的响应式对象。

示例 5-1. 使用 ref() 创建一个具有初始值“Hello World”的响应式消息
import { ref } from 'vue'

const message = ref("Hello World")

console.log(message.value) //Hello World
注意

如果你使用 Options API 和 setup() 钩子,你可以在组件的其他部分访问 message 而不需要 .value,即 message 就足够了。

然而,在 template 标签部分,你可以直接获取其值而不需要 value 属性。例如,示例 5-2 中的代码将会将同样的 message 输出到浏览器,就像 示例 5-1 一样。

示例 5-2. 在 template 部分访问 message 的值
<template>
    <div>{{ message }}</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'

const message = ref("Hello World")
</script>
注意

ref() 函数根据传入的初始值推断返回对象的类型。如果你想显式定义返回对象的类型,可以使用 TypeScript 语法 ref<type>(),例如 ref<string>()

由于 ref 对象是响应式且可变的,我们可以通过给其 value 属性赋新值来改变其值。Vue 引擎将会触发相关的 watcher 并更新组件。

在 示例 5-3 中,我们将重新创建 MyMessageComponent(从 示例 3-3 使用 Options API 创建),该组件接受用户输入并改变显示的 message

示例 5-3. 使用 ref() 创建一个响应式的 MyMessageComponent
<template>
    <div>
        <h2 class="heading">{{ message }}</h2>
        <input type="text" v-model="message" />
    </div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'

const message = ref("Welcome to Vue 3!")
</script>

当我们改变输入字段的值时,浏览器将相应地显示更新后的 message 值,如 图 5-1 所示。

浏览器截图显示更新的消息值

图 5-1. 当我们改变输入字段的值时,显示的值也会随之改变。

在浏览器的 Vue 标签页中的开发者工具中,我们可以看到 message 被列在 setup 部分的 ref 对象下,带有 Ref 标识(见 图 5-2)。

浏览器 Vue 标签页显示的  对象截图

图 5-2. messageref 对象列在 setup 部分下

如果我们向组件添加另一个静态数据 title(示例 5-4),Vue 标签页将显示 title 数据属性而没有标识(见 图 5-3)。

示例 5-4. 向 MyMessageComponent 添加静态的 title
<template>
    <div>
        <h1>{{ title }}</h1>
        <h2 class="heading">{{ message }}</h2>
        <input type="text" v-model="message" />
    </div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'

const title = "My Message Component"
const message = ref("Welcome to Vue 3!")
</script>

浏览器 Vue 标签页显示没有标识的数据属性

图 5-3. 列出了 title 数据属性而没有标识

前面的代码(示例 5-4)等同于使用 setup() 钩子的 示例 5-5。

示例 5-5. 使用 setup() 钩子创建一个响应式的 MyMessageComponent
<template>
    <div>
        <h2 class="heading">{{ message }}</h2>
        <input type="text" v-model="message" />
    </div>
</template>
<script lang="ts">
import { ref } from 'vue'

export default {
    setup() {
        const message = ref("Welcome to Vue 3!")
        return {
            message
        }
    }
}
</script>

您可以使用ref()函数为任何原始类型(如stringnumberbooleannullundefined等)和任何对象类型创建响应式对象。但是,对于数组和对象等对象类型,ref()返回一个强烈响应式对象,意味着ref对象及其嵌套属性均可变,如示例 5-6 所示。

示例 5-6. 使用ref()创建深度响应式对象
import { ref } from 'vue'

const user = ref({
    name: "Maya",
    age: 20
})

user.value.name = "Rachel"
user.value = {
    name: "Samuel",
    age: 20
}

console.log(user.value) // { name: "Samuel", age: 20 }

在示例 5-6 中,我们可以替换user的属性name和整个user对象的属性值。在 Vue 中,我们认为这种情况是不良实践,可能会导致大型数据结构的性能问题和意外行为。为了避免陷入这种情况,我建议您根据具体情况使用shallowRef()reactive()函数:

  • 如果您想创建一种响应式对象类型的数据,并稍后用新值替换它,请使用shallowRef()。一个很好的例子是将组件与异步数据获取集成到生命周期组合钩子中,如示例 5-7 所示。

  • 如果您想创建一种仅更新其属性的响应式对象类型数据,请使用reactive(),我们将在下一节中介绍。

示例 5-7. 使用shallowRef()管理外部数据获取
<script lang="ts" setup>
import { shallowRef } from "vue";

type User = {
    name: string;
    bio: string;
    avatar_url: string;
    twitter_username: string;
    blog: string;
};

const user = shallowRef<User>({ ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    name: "",
    bio: "",
    avatar_url: "",
    twitter_username: "",
    blog: "",
});

const error = shallowRef<Error | undefined>(); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

const fetchData = async () => {
    try {
        const response = await fetch("https://api.github.com/users/mayashavin");

        if (response.ok) {
            user.value = (await response.json()) as User; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
        }
    } catch (e) {
        error.value = e as Error; ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
    }
};

fetchData();
</script>

1

使用shallowRef创建一个初始数据为User类型的响应式user变量。

2

使用shallowRef创建一个可以是undefinedError类型的响应式error变量。

3

用响应数据替换user的值,假设它是User类型。

4

当发生错误时,更新error的值。

使用reactive()

reactive()函数类似于ref()函数,除了:

  • 它接受对象类型的数据作为其参数。

  • 您可以直接访问不带value及其属性的响应式返回对象。

仅返回对象的嵌套属性可变,尝试直接修改返回对象的值或使用value属性将导致错误:

import { reactive } from 'vue'

const user = reactive({
    name: "Maya",
    age: 20
})

/*
TypeScript error - property 'value' does not exist
on type '{ name: string; age: number; }'
*/
user.value = {
    name: "Samuel",
    age: 20
}

/*
TypeScript error - cannot reassign a read-only variable
*/
user = {
    name: "Samuel",
    age: 20
}

但您可以修改user对象的属性,例如nameage

import { reactive } from 'vue'

const user = reactive({
    name: "Maya",
    age: 20
})

user.name = "Rachel"
user.age = 30
注意

在幕后,ref()触发reactive()

一个重要的注意事项是,reactive()函数返回原始传递对象的响应式proxy版本。因此,如果我们对响应式返回对象进行任何更改,将反映在原始对象上,反之亦然,如示例 5-8 所示。

示例 5-8. 修改原始对象和响应式对象
import { reactive } from 'vue'

const defaultUser = {
    name: "Maya",
    age: 20
}

const user = reactive(defaultUser)

user.name = "Rachel"
user.age = 30

console.log(defaultUser) // { name: "Rachel", age: 30 }

defaultUser.name = "Samuel"

console.log(user) // { name: "Samuel", age: 30 }

在这个例子中,当user变化时,defaultValueuser的属性都会改变,反之亦然。因此,在使用reactive()函数时需要特别小心。在传递给reactive()之前,应使用展开语法(…​)创建一个新对象(见示例 5-9)。

示例 5-9. 使用带展开语法的reactive()
import { reactive } from 'vue'

const defaultUser = {
    name: "Maya",
    age: 20
}

const user = reactive({ ...defaultUser })

user.name = "Rachel"
user.age = 30

console.log(defaultUser) // { name: "Maya", age: 20 }

defaultUser.name = "Samuel"

console.log(user) // { name: "Rachel", age: 30 }

reactive()函数能够深度转换初始对象的响应性。因此,对于大型数据结构可能导致不希望的性能问题。在只想观察根对象属性而不是它们的后代的情况下,应改用shallowReactive()函数。

您也可以结合使用ref()reactive(),尽管我不建议这样做,因为其复杂性和响应性解包机制。如果需要从另一个响应式对象创建响应式对象,应改用computed()(参见“使用 computed()”)。

Table 5-1 总结了ref()reactive()shallowRef()shallow``Reactive()的用例。

Table 5-1. ref()reactive()shallowRef()shallowReactive()函数的用例

钩子 使用时机
ref() 用于一般情况下的原始数据类型,或在需要重新分配对象及其属性时的对象类型。
shallowRef() 仅作为占位符的对象类型,稍后可重新分配且无属性观察。
reactive() 用于观察对象类型数据的属性,包括嵌套属性。
shallowReactive() 用于观察对象类型数据的属性,但不包括嵌套属性。

接下来,我们将查看生命周期组合钩子及其提供的功能。

使用生命周期钩子

在“组件生命周期钩子”中,我们了解了组件的生命周期钩子及其在经典 Vue 的 Options API 中作为组件选项对象的属性显示。使用 Composition API 时,生命周期钩子是我们需要从vue包导入的独立函数,然后在组件生命周期的特定点使用它们来执行逻辑。

Composition API 的生命周期钩子与 Options API 中的钩子类似,唯一区别是现在的语法包含前缀on(例如,在 Composition API 中,mounted变成了onMounted)。Table 5-2 展示了一些生命周期钩子从 Options API 到 Composition API 的映射。

Table 5-2. 从 Options API 到 Composition API 的生命周期钩子

Options API Composition API 描述
beforeMount() onBeforeMount() 在组件首次渲染之前调用。
mounted() onMounted() 在 Vue 渲染并将组件挂载到 DOM 后调用。
beforeUpdate() onBeforeUpdate() 在组件更新过程开始后调用。
updated() onUpdated() 在 Vue 渲染更新后调用。
beforeUnmount() onBeforeUnmount() 在卸载组件之前调用。
unmounted() onUnmounted() 在 Vue 删除和销毁组件实例后调用。

您可能注意到,不像 Options API 中的 beforeCreate()created() 钩子,Composition API 中并非所有生命周期钩子都有等价项。相反,我们使用 setup()<script setup> 与其他 Composition API 钩子来实现相同的结果,甚至更有组织地定义组件的逻辑。

我们使用上述钩子注册回调函数,当适当时 Vue 将执行这些回调函数。例如,要注册一个回调函数到 beforeMount() 钩子,我们可以这样做:

<script setup lang="ts">
import { onBeforeMount } from 'vue'

onBeforeMount(() => {
    console.log('beforeMount triggered')
})
</script>

由于 Vue 在创建组件实例之前触发 setup(),因此在其中注册的钩子和 setup() 中都无法访问 this 实例。在使用时,以下代码将输出 undefined(Figure 5-4):

import { onMounted } from 'vue'
onMounted(() => {
    console.log('component instance: ', this)
})

访问 Composition 生命周期钩子中的 this 返回 undefined 的控制台日志截图

Figure 5-4. 在 Composition 生命周期钩子中访问 this 返回 undefined

但是,通过使用 ref() 钩子和 ref 指令,我们可以像在本例中定义 inputRef 一样访问组件的 DOM 实例(类似于 Options API 中的 this.$el):

import { ref } from 'vue'

const inputRef = ref(null)

然后在模板中将其绑定到 ref 指令:

<template>
    <input
        ref="inputRef"
        v-model="message" type="text" placeholder="Enter your name"
    />
</template>

最后,我们可以在 onMounted()onUpdated() 钩子中访问 DOM 实例:

import { onUpdated, onMounted } from 'vue'

onMounted(() => {
    console.log('DOM instance: ', inputRef.value)
})

onUpdated(() => {
    console.log('DOM instance after updated: ', inputRef.value)
})

组件挂载后,inputRef 将引用输入元素的正确 DOM 实例。每当用户更改输入字段时,Vue 将触发 onUpdated() 钩子,并相应更新 DOM 实例。Figure 5-5 显示了挂载后控制台日志以及用户在输入字段中键入的情况。

挂载后和用户在输入字段中键入后显示控制台日志的截图

Figure 5-5. 组件挂载后控制台日志以及用户更改输入字段

Composition API 的生命周期钩子在许多情况下与 Options API 的生命周期钩子相比非常有帮助,特别是当您希望保持函数组件逻辑简洁和有组织时。您还可以将生命周期钩子与其他 Composition API 钩子结合使用,以实现更复杂的逻辑,并创建可重用的自定义钩子(参见 “创建您的可重用组合”)。在下一节中,我们将查看其他重要的 Composition API 钩子,首先是 watch()

在 Composition API 中理解 Watchers

类似于 Options API 中的 watch(),Composition API 中的 watch() 钩子用于观察变化并在响应式数据中调用回调函数。watch() 接受三个参数,如下所示的语法:

watch(
    sources: WatchSource,
    cb: (newValue: T, oldValue: T, cleanup: (func) => void)) => any,
    options?: WatchOptions
): WatchStopHandle
  • sources是 Vue 观察的反应性数据。它可以是单个反应性数据、返回反应性数据的 getter 函数或这些的数组。

  • cb是 Vue 在sources任一变化时执行的回调函数。此函数接受两个主要参数:newValueoldValue,以及一个可选的副作用清理函数,在下一次调用之前触发。

  • optionswatch()钩子的选项,这是可选的,包含表 5-3 中描述的字段。

表 5-3. watch()选项字段

属性 描述 接受的类型 默认值 是否必需?
deep 指示 Vue 是否应观察目标数据(如果有)的嵌套属性的更改。 布尔值 false
immediate 指示在挂载组件后立即触发处理程序。 布尔值 false
flush 指示处理程序执行的时间顺序。默认情况下,Vue 在更新 Vue 组件之前触发处理程序。 prepostsync pre
onTrack 用于调试时追踪反应性数据,仅在开发模式下 函数 undefined
onTrigger 用于调试时触发回调,仅在开发模式下 函数 undefined

并返回一个WatchStopHandle函数,我们可以随时用来停止观察器。

让我们来看看UserWatcherComponent组件,其模板与第三章的示例 3-17 相同,其中允许基于默认的user对象修改user.nameuser.age。我们将重新编写其<script>,采用 Composition API,如示例 5-10 所示。

示例 5-10. 使用setup()ref()UserWatcherComponent组件
<script setup lang='ts'>
import { reactive } from 'vue'

//...

const user = reactive<User>({
  name: "John",
  age: 30,
});
</script>

然后,我们为user对象添加一个观察器,就像示例 5-11 中所示。

示例 5-11. 使用watch()钩子监视user数据
import { reactive, watch } from 'vue'

watch(user, (newValue, oldValue) => {
    console.log('user changed from: ', oldValue, ' to: ', newValue)
})

默认情况下,Vue 仅在user更改时触发回调函数。在前面的示例中,因为我们使用reactive()创建user,Vue 将自动启用deep以观察其属性的更改。如果您希望 Vue 仅观察user的特定属性,例如user.name,我们可以创建一个返回该属性的 getter 函数,并将其作为sources参数传递给watch(),如示例 5-12 所示。

示例 5-12. 使用watch()钩子监视user的特定属性
import { reactive, watch } from 'vue'

watch(
    () => user.name,
    (newValue, oldValue) => {
        console.log('user.name changed from: ', oldValue, ' to: ', newValue)
    }
)

当您更改user.name时,控制台日志将显示图 5-6 中显示的消息。

更改用户名称后控制台日志的截图

图 5-6. 更改user.name后的控制台日志

如果需要在挂载组件后立即触发观察器,可以将 { immediate: true } 作为 watch() 的第三个参数传递,例如 Example 5-13。

Example 5-13. 使用带有 immediate 选项的 watch() 钩子
import { reactive, watch } from 'vue'

watch(
    () => user.name,
    (newValue, oldValue) => {
        console.log(
            'user.name changed from: ',
            oldValue,
            ' to: ',
            newValue
        )
    },
    { immediate: true }
)

控制台日志将显示从 undefinedJohnuser.name 更改,就在挂载组件后立即。

您还可以将一个反应性数据数组 sources 传递给 watch(),Vue 将使用新旧值的两个集合触发回调函数,每个集合与 sources 数组中的反应性数据按相同顺序对应,如 Example 5-14 中所示。

Example 5-14. 使用带有反应性数据数组的 watch() 钩子
import { reactive, watch } from 'vue'

watch(
    [() => user.name, () => user.age],
    ([newName, newAge], [oldName, oldAge]) => {
        console.log(
            'user changed from: ',
            { name: oldName, age: oldAge },
            ' to: ',
            { name: newName, age: newAge }
        )
    }
)

user.nameuser.age 变化时,上述观察器将被触发,并且控制台日志将相应地显示差异。

注意

如果您想要观察并触发多个数据更改的副作用操作,watchEffect() 可能是一个更好的选择。它将跟踪观察器函数中使用的反应性依赖项,立即在组件渲染后运行函数,并在任何依赖项更改其值时重新运行该函数。但是,使用此 API 时需要注意性能问题,特别是如果依赖项列表很长且它们之间的更新频率很高。

使用 watch() 钩子是在特定反应性数据或其属性上创建动态观察的好方法。但是,如果我们想要基于现有数据创建新的响应式数据,我们应该使用 computed(),接下来我们将看看它。

使用 computed()

类似于计算属性,我们使用 computed() 来创建一个从其他反应性数据派生的响应式和缓存的数据值。与 ref()reactive() 不同,computed() 返回一个 只读 的引用对象,意味着我们不能手动重新分配其值。

让我们以 Example 3-11 中 Options API 中写的保留消息示例为例,并使用 computed() 钩子重写,如 Example 5-15 中所示。

Example 5-15. 使用 computed() 创建 PalindromeCheck 组件
<script lang="ts" setup>
import { ref, computed } from 'vue'

const message = ref('Hello World')
const reversedMessage = computed<string>(
    () => message.value.split('').reverse().join('')
)
</script>

script 部分内,我们使用返回对象的 value 属性(reversedMessage.value)来访问其值,就像 ref()reactive() 一样。

Example 5-16 中的代码展示了我们如何基于 reversedMessage 创建另一个 computed 数据点来检查消息是否为回文。

Example 5-16. 使用 computed() 创建新的响应式 isPalindrome 数据
<script lang="ts" setup>
import { ref, computed } from 'vue'

//...
const isPalindrome = computed<boolean>(
    () => message.value === reversedMessage.value
)
</script>

在这里,我们明确声明了 reservedMessageisPalindrome 的类型为 stringboolean,以避免类型推断错误。现在您可以在模板中使用这些 computed 数据(Example 5-17)。

Example 5-17. 在模板中使用 computed() 创建的数据
<template>
  <div>
    <input v-model="message" placeholder="Enter your message"/>
    <p>Reversed message: {{ reversedMessage }}</p>
    <p>Is palindrome: {{ isPalindrome }}</p>
  </div>
</template>

当用户更改消息输入时,此代码将导致 Figure 5-7 中显示的输出。

显示输入消息的回文检查的截图

图 5-7. 使用computed()进行消息的回文检查组件

当您在浏览器的开发者工具中打开 Vue 选项卡时,您可以在PalindromeCheck组件的setup部分下看到这些计算数据值(见图 5-8)。

显示浏览器开发者工具中 Vue 选项卡中组件设置部分下的三个数据值的截图

图 5-8. 在PalindromeCheck组件的开发者工具中显示的计算和反应性数据
注意

默认情况下,computed()返回一个只读的反应性数据引用。但是,您可以通过将一个{ get, set }对象作为第一个参数传递给computed()来故意将其声明为一个可写对象。此机制与 Options API 中的computed属性保持一致。不过,我不建议使用这个功能。您应该改用ref()reactive()来结合使用。

我们已经学习了如何使用computed()watch()来实现与经典的 Options API 中的computedwatch选项属性相同的结果。您可以根据自己的喜好选择使用其中的任何一个。您还可以使用这些钩子函数来创建自己的钩子,称为可组合函数,并在其他组件中重用它们,接下来我们将探讨这一点。

创建您的可重复使用的可组合函数

Vue 3 最令人兴奋的功能之一是能够从可用的 Composition API 函数中创建可重复使用和有状态的钩子,称为可组合函数^(2)。我们可以将常见逻辑分割和组合成可读的可组合函数,然后在不同的组件中使用它们来管理特定数据状态的变化。这种方法有助于分离状态管理逻辑和组件逻辑,从而减少组件的复杂性。

要开始编写,您可以创建一个新的 TypeScript(.ts)文件,并导出一个返回反应性数据对象作为您的可组合的函数,如示例 5-18 所示。

示例 5-18. 创建一个示例可组合函数useMyComposable
// src/composables/useMyComposable.ts
import { reactive } from 'vue'

export const useMyComposable = () => {
    const myComposableData = reactive({
        title: 'This is my composable data',
    })

    return myComposableData
}

在前面的代码中,我们在src/composables文件夹下创建了一个名为useMyComposable.ts的新 TypeScript 文件,并导出了一个名为useMyComposable的函数。该函数使用reactive()函数创建了一个名为myComposableData的反应性数据对象。

注意

您可以将可组合文件放置在项目的任何位置,但我建议将其放在src/composables文件夹下以保持组织结构。另外,建议将可组合文件命名为use前缀,后跟简洁明了的名称。

然后,您可以像示例 5-19 中所示那样导入和使用useMyComposable在您的组件中。

示例 5-19. 在 Vue 组件中使用useMyComposable可组合函数
<script lang="ts" setup>
import { useMyComposable } from '@/composables/useMyComposable'

const myComposableData = useMyComposable()
</script>

现在您可以在组件模板及其它组件逻辑中访问myComposableData作为其本地反应性数据。

让我们创建一个useFetch可组合项,使用fetch API 从外部 API 查询数据,如示例 5-20 所示。

示例 5-20. 创建useFetch可组合项
import { ref, type Ref, type UnwrapRef } from "vue";

type FetchResponse<T> = {
    data: Ref<UnwrapRef<T> | null>;
    error: Ref<UnwrapRef<Error> | null>;
    loading: Ref<boolean>;
}

export function useFetch<T>(url: string): FetchResponse<T> {
    const data = ref<T | null>(null);
    const loading = ref<boolean>(false);
    const error = ref<Error | null>(null);

    const fetchData = async () => { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        try {
            loading.value = true;
            const response = await fetch(url);

            if (!response.ok) {
                throw new Error(`Failed to fetch data for ${url}`);
            }

            data.value = await response.json();
        } catch (err) {
            error.value = (err as Error).message;
        } finally {
            loading.value = false;
        }
    };

    fetchData(); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

    return { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
        data,
        loading,
        error,
    };
};

1

声明获取数据的内部逻辑。

2

在组件创建期间触发数据获取并自动更新数据。

3

返回声明的响应式变量。

然后,您可以重用useFetch来组合另一个异步可组合项,例如useGitHubRepos,从 GitHub API 查询和管理用户的仓库数据(示例 5-21)。

示例 5-21. 创建useGitHubRepos可组合项
// src/composables/useGitHubRepos.ts
import { useFetch } from '@/composables/useFetch'
import { ref } from 'vue'

type Repo = { /**... */ }

export const useGitHubRepos = (username: string) => {
    return useFetch<Repo[]>(
        `https://api.github.com/users/${username}/repos`
    );
}

完成后,我们可以在GitHubRepos.vue组件中使用useGitHubRepos(示例 5-22)。

示例 5-22. 在GitHubRepos组件中使用useGitHubRepos
<script lang="ts" setup>
import { useGitHubRepos } from "@/composables/useGitHubRepos";
const { data: repos } = useGitHubRepos("mayashavin"); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
</script>
<template>
    <h2>Repos</h2>
    <ul>
    <li v-for="repo in repos" :key="repo.id"> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
      <article>
        <header>{{ repo.name }}</header>
        <p>{{ repo.description }}</p>
      </article>
    </li>
  </ul>
</template>

1

获取data并将其重命名为repos

2

迭代repos并显示每个repo的信息。

并且在浏览器上,在获取完成后,我们将看到显示一系列仓库的列表(图 5-9)。

Maya Shavin 的 GitHub 账户检索到的仓库列表的截图

图 5-9. 使用useGitHubRepos可组合项检索并显示仓库列表

在可组合项之间映射数据

如果需要重新映射从另一个可组合项接收的任何响应式数据,请使用computed()watch()以保留响应性。示例 5-23 演示了在useGitHubRepos内部错误使用useFetch不起作用示例。

示例 5-23. 在useGitHubRepos内部错误使用useFetch
export const useGitHubRepos = (username: string) => {
  const response = useFetch<Repo[]>(
    `https://api.github.com/users/${username}/repos`
  );

  return {
    repos: response.data,
    loading: response.loading,
    error: response.error,
  };
};

通过可组合项,您可以以模块化和可组合的方式创建应用程序的状态管理逻辑。您甚至可以构建自己的可组合库,以便在其他 Vue 项目中重用,例如主题控制、数据获取、商店支付管理等。一个很好的可组合资源是VueUse,您可以在那里找到许多有用、即用即用且经过测试的 Vue 组合实用程序,以满足您的需求。

由于所有响应式状态仅在使用钩子时初始化,我们可以避免像混入中那样的数据重叠问题。此外,测试组件变得更加简单,您可以分别测试元素中使用的每个可组合项,并保持组件逻辑的小型和可维护性。

在学习了 Composition API 和可组合项之后,您可以考虑创建自己的可组合项系统,并在组件中使用它们。

总结

本章探讨了如何从选项 API 重写组件,使用组合 API 的函数,如 setup 函数、响应性和生命周期钩子。我们还学习了如何基于现有的组合函数创建我们自己的自定义组合,增强代码的可重用性。基于这一基础,我们现在理解了每个 API 的优缺点,因此可以更好地理解它们的使用场景以便于开发。

你已经准备好进入下一章,学习如何将来自 API 或数据库资源的外部数据整合到你的 Vue 应用程序中。

^(1) 当你使用 mixin 时,你在编写一个新组件的配置。

^(2) 一般来说,可组合(composable)是一个自定义的钩子。

第六章:整合外部数据

前面的章节为你提供了与组件工作的基本要点,包括在组件之间传递数据以及处理组件内的数据变化和事件。现在你可以使用 Vue 组件将应用程序的数据展示在屏幕上,供用户使用了。

在大多数情况下,一个应用程序不会在其内部拥有所需的数据。相反,我们通常从外部服务器或数据库请求数据,然后将接收到的数据填充到我们应用程序的合适界面中。本章涵盖了开发强大的 Vue 应用程序的这一方面:如何使用 Axios 作为 HTTP 请求工具,与外部资源通信和处理外部数据。

Axios 是什么?

对于向外部资源发出 HTTP 请求,Vue 开发者有多种选择,包括内置的 fetch 方法、经典的 XMLHttpRequest 和第三方库如 Axios。虽然内置的 fetch 适合仅用于获取数据的 HTTP 请求,但 Axios 在长期使用中提供了额外的功能,特别是在处理更复杂的外部资源 API 时非常实用。

Axios 是一个 JavaScript 开源轻量级库,用于发起 HTTP 请求。与 fetch 类似,它是基于 Promise 的 HTTP 客户端,在服务器端和浏览器端都能使用。

使用 Axios 的一些显著优势包括能拦截和取消 HTTP 请求以及在客户端提供内置的跨站请求伪造保护。另一个 Axios 的优点是它自动将响应数据转换为 JSON 格式,使你在处理数据时比使用内置的 fetch 更具开发者体验。

Axios 官方网站 提供了 API 文档、安装说明以及主要用例参考(图 6-1)。

Axios 官方网站截图

图 6-1:Axios 官方网站

安装 Axios

要将 Axios 添加到你的 Vue 项目中,在项目的根目录下,可以使用以下命令:

yarn add axios

安装完 Axios 后,你可以在需要的组件中引入 Axios 库,使用以下代码:

import axios from 'axios';

然后,你可以使用 axios 开始查询应用程序的数据。让我们探讨如何结合 Axios 和生命周期钩子来加载和显示数据。

使用生命周期钩子和 Axios 加载数据

正如您在第三章中学到的那样,您可以使用beforeCreatecreatedbeforeMounted生命周期钩子执行诸如数据获取之类的副作用调用。然而,在需要加载外部数据并在组件内部使用且使用选项 API 的情况下,beforeCreate不是一个选择。Vue 在使用beforeCreate时会忽略任何数据赋值,因为它尚未初始化任何响应式数据。在这种情况下,使用createdbeforeMounted更为合适。但是,beforeMounted在服务器端渲染中不可用,如果我们想使用组合 API(在第五章中介绍),则组合 API 中没有等效的生命周期函数来替代created钩子。

加载外部数据的更好选项是使用相关的响应式组合函数setup()<script setup>

让我们通过使用axios.get()方法向以下 URL 发出异步 GET 请求来获取有关我的 GitHub 公开信息:https://api.github.com/users/mayashavin,如下面的代码所示:

/**UserProfile.vue */
import axios from 'axios';
import { ref } from 'vue';

const user = ref(null);

axios.get('https://api.github.com/users/mayashavin')
    .then(response => {
        user.value = response.data;
    });

axios.get()返回一个 promise,可以使用 promise 链式方法then()来处理响应数据的解析。Axios 自动将 HTTP 响应体中的响应数据解析为适当的 JSON 格式。在这个例子中,我们将接收到的数据分配给组件的user数据属性。我们还可以重写此代码以使用await/async语法:

/**UserProfile.vue */
//...

async function getUser() {
    const response = await axios.get(
        'https://api.github.com/users/mayashavin'
    );
    user.value = response.data;
}

getUser();

我们还应该将代码包装在try/catch块中,以处理可能在请求期间发生的任何错误。因此,我们的代码变成:

/**UserProfile.vue */
import axios from 'axios';
import { ref } from 'vue';

const user = ref(null);
const error = ref(null); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

async function getUser() {
    try { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
        const response = await axios.get('https://api.github.com/users/mayashavin');

        user.value = response.data;
    } catch (error) {
        error.value = error; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
    }
}

getUser();

1

添加一个error数据属性来存储从请求中接收到的任何错误。

2

将代码包装在try/catch块中,以处理请求期间发生的任何错误。

3

将错误分配给error数据属性,以便在浏览器中向用户显示错误消息。

GitHub 通过一个包含在示例 6-1 中显示的主要字段的 JSON 对象响应我们的请求。

示例 6-1. UserProfile 类型
type User = {
  name: string;
  bio: string;
  avatar_url: string;
  twitter_username: string;
  blog: string;
  //...
};

使用这些响应数据,我们现在有了在屏幕上显示用户个人资料所需的必要信息。让我们将以下代码添加到我们组件的template部分:

<div class="user-profile" v-if="user">
    <img :src="user.avatar_url" alt="`${user.name} Avatar`" width="200"  />
    <div>
        <h1>{{ user.name }}</h1>
        <p>{{ user.bio }}</p>
        <p>Twitter: {{ user.twitter_username }}</p>
        <p>Blog: {{ user.blog }}</p>
    </div>
</div>

请注意,这里添加了v-if="user"以确保 Vue 仅在user可用时渲染用户资料。

最后,在示例 6-2 中,我们需要对组件的script部分进行一些修改,使代码完全兼容 TypeScript,包括将响应数据映射为User数据类型,然后将其分配给user属性,以及error

示例 6-2. 用户资料组件
<template>
    <div class="user-profile" v-if="user">
        <!-- ... -->
    </div>
</template>
<script lang="ts" setup>
import axios from 'axios';
import { ref } from 'vue';

type User = { /**... */ }

const user = ref<User | null>(null) ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
const error = ref<Error | null>(null)

async function getUser () {
    try {
        const response = await axios.get<User>(
            "https://api.github.com/users/mayashavin"
        )

        user.value = await response.data ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    } catch (err) {
        error.value = err as Error ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
    }
}

getUser();
</script>

1

user添加User类型声明。

2

将响应数据分配给user属性。

3

在分配给error属性之前将错误转换为Error类型。

当请求成功解析时,您将看到我的 GitHub 个人资料信息显示在屏幕上,如图 6-2 所示。

显示 Maya Shavin 的照片以及她的姓名、职位、Twitter 账号和博客地址的截图。

图 6-2. 成功获取 GitHub 个人资料请求的示例输出

类似地,您还可以添加一个带有v-else-if="error"条件的部分,以在请求失败时向用户显示错误消息:

<template>
<div class="user-profile" v-if="user">
    <!--...-->
</div>
<div class="error" v-else-if="error">
    {{ error.message }}
</div>
</template>

此时,当组件在创建过程中执行异步请求时,您可能想知道背后的实际操作。组件的生命周期操作是同步的,这意味着 Vue 仍然会继续创建组件,而不管异步请求的状态如何。这就带来了在运行时处理不同组件中的不同数据请求的挑战,我们将在接下来进行探讨。

运行时异步数据请求:挑战

类似于 JavaScript 引擎的工作方式,Vue 也是同步工作的。如果在途中有任何异步请求,Vue 不会等待请求完成再继续下一步骤。相反,Vue 完成组件的创建过程,然后根据执行顺序处理异步请求的解析或拒绝。

让我们退一步,在我们的组件中的onBeforeMountedonMountedonUpdated钩子中添加一些控制台日志,并查看执行顺序:

//<script setup> import { onBeforeMount, onMounted, onUpdated } from "vue";

//... async function getUser() {
  try {
    const response = await axios.get<User>(
        'https://api.github.com/users/mayashavin'
    );
    user.value = response.data;

    console.log('User', user.value.name) ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
  } catch (err) {
    error.value = err;
  }
}

onBeforeMount(async () => {
    console.log('created') ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    getUser();
})

onMounted(() => {
    console.log("mounted"); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
});

onUpdated(() => {
    console.log("updated"); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
})

1

记录user完成获取后的详细信息到控制台。

2

记录生命周期状态:挂载前

3

记录生命周期状态:已挂载

4

记录生命周期状态:组件已更新

查看浏览器控制台日志,我们看到显示的顺序如图 6-3 中所示。

显示异步请求时执行顺序的截图。

图 6-3. 异步请求的执行顺序

一旦异步请求解析或拒绝,并且有组件数据更改,Vue 渲染器将触发组件的更新过程。Vue 在将组件挂载到 DOM 之前,组件还未获取响应数据。因此,我们仍然需要处理组件在接收服务器数据之前的加载状态。

为此,我们可以向组件的数据添加另一个loading属性,并在请求解析/拒绝后禁用加载状态,如示例 6-3。

示例 6-3. 带有加载状态和错误状态的用户配置文件组件
//... const loading = ref<boolean>(false); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

async function getUser() {
    loading.value = true; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

    try {
        const response = await axios.get<User>(
            "https://api.github.com/users/mayashavin"
        )

        user.value = await response.data
    } catch (err) {
        error.value = err as Error
    } finally {
        loading.value = false; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
    }
}

getUser();

1

创建一个响应式的loading变量。

2

在获取数据之前将loading设置为true

3

在请求解析/拒绝后将loading设置为false

然后在组件的template部分添加v-if="loading"条件以显示加载消息,如示例 6-4。

示例 6-4. 带有加载状态和错误状态的用户配置文件组件模板
<template>
    <div v-if="loading">Loading...</div>
    <div class="user-profile" v-else-if="user">
        <!--...-->
    </div>
    <div class="error" v-else-if="error">
        {{ error.message }}
</div>
</template>

此代码在异步请求进行时呈现加载消息,并在请求解析时显示用户的配置文件信息或发送错误消息。

您还可以创建可重用的包装组件来处理组件的不同状态,例如在加载一组组件时使用骨架占位符组件(图 6-4)或者一个获取组件(接下来介绍)。

显示加载状态的骨架组件的截图。

图 6-4. 用于加载状态的骨架组件

创建您的可重用获取组件

处理 Vue 组件的异步数据请求状态是一种常见的挑战。这些状态的 UI 通常遵循相同的模式:在加载状态下显示旋转器或加载消息,在出现错误时显示错误消息或更时尚的错误组件。因此,我们可以创建一个处理这些情况的通用组件,我们称之为FetchComponent

FetchComponenttemplate部分使用slotv-if划分为三个主要区域:

#loading slot 用于显示加载消息

此 slot 的渲染条件是组件处于isLoading状态。

#error slot 用于显示错误消息

我们还将error对象作为 slot props 传递,以便根据需要进行自定义,同时确保 Vue 仅在error可用时渲染此 slot。

#default slot 用于在接收到data时显示组件的内容

我们还将data作为 props 传递给 slot。

我们还使用命名的slot来允许自定义错误和加载组件,而不是使用默认消息:

<template>
  <slot name="loading" v-if="isLoading">
    <div class="loadin-message">Loading...</div>
  </slot>
  <slot :data="data" v-if="data"></slot>
  <slot name="error" :error="error" v-if="error">
    <div class="error">
      <p>Error: {{ error.message }}</p>
    </div>
  </slot>
</template>

在我们的script setup部分,我们需要声明我们的数据类型FetchComponentData,以便组件包含类型为泛型ObjectisLoadingerrordata属性:

const data = ref<Object | undefined>();
const error = ref<Error | undefined>();
const loading = ref<boolean>(false);

组件接收两个 props:url用于请求 URL,method用于请求方法,默认为GET

//...

const props = defineProps({
    url: {
        type: String,
        required: true,
    },
    method: {
        type: String,
        default: "GET",
    },
});
//...

最后,当 Vue 创建组件时,我们进行异步请求并更新组件的状态:

async function fetchData () {
    try {
        loading.value = true;
        const response = await axios(props.url, {
            method: props.method,
            headers: {
                'Content-Type': 'application/json',
            },
        });
        data.value = response.data;
    } catch (error) {
        error.value = error as Error;
    } finally {
        loading.value = false;
    }
};

fetchData();
注意

如果你提前知道data的类型,应该使用它们而不是anyObject来确保完整的 TypeScript 类型检查覆盖。除非没有其他方法,不要使用any

现在我们可以重写示例 6-2 以使用新的FetchComponent组件,就像在示例 6-5 中一样。

示例 6-5. 使用FetchComponent的 UserProfile 组件
<template>
    <FetchComponent url="https://api.github.com/users/mayashavin"> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        <template #default="defaultProps"> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
            <div class="user-profile"> ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
                <img
                    :src="(defaultProps.data as User).avatar_url"
                    alt="`${defaultProps.data.name} Avatar`"
                    width="200"
                />
                <div>
                    <h1>{{ (defaultProps.data as User).name }}</h1>
                    <p>{{ (defaultProps.data as User).bio }}</p>
                    <p>Twitter: {{(defaultProps.data as User).twitter_username }}</p>
                    <p>Blog: {{ (defaultProps.data as User).blog }}</p>
                </div>
            </div>
        </template>
    </FetchComponent>
</template>
<script lang="ts" setup> ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
import FetchComponent from "./FetchComponent.vue";
import type { User } from "../types/User.type";
</script>

1

使用FetchComponent组件,并将url属性作为请求的目标 URL 传递给它(https://api.github.com/users/mayashavin)。

2

将组件的主要内容包装在template的主插槽#default中。我们还将此插槽接收的 props 绑定到defaultProps对象。由于defaultProps.dataObject类型,我们将其转换为User以通过 TypeScript 验证。

3

使用defaultProps.data来访问从请求接收到的数据并在 UI 上显示它。

4

移除所有相关的原始逻辑代码以进行获取。

在这里,我们从我们的FetchComponent实现将data传递到这个插槽,这在我们的原始user属性的情况下代表我们。因此,我们用defaultProps.data替换了以前实现中的user出现。输出保持不变。

使用 Composition API 实现 FetchComponent

你可以在setup()函数(或<script setup>标签)中使用useFetch()重写FetchComponent(参见示例 5-20)。

现在你理解如何创建一个简单的FetchComponent来在 Vue 组件的 UI 上获取和处理数据请求状态。你可能希望扩展它以处理更复杂的数据请求,如 POST 请求。通过在一个地方隔离数据请求和控制逻辑,你可以更快地减少复杂性并在其他组件中重用它。

将你的应用程序连接到外部数据库

此时,你可以在 Vue 组件的 UI 上处理外部数据请求和错误。然而,每次 Vue 创建组件时获取数据可能不是最佳实践,特别是如果组件的数据不太可能经常更改。

在 Web 应用程序中切换页面是一个完美的例子,我们只需要在首次加载视图时获取页面数据一次。在这种情况下,我们可以使用浏览器的本地存储作为外部本地数据库进行缓存数据,或者使用诸如 Vuex 和 Pinia 等状态管理服务(详见第 9 章)。

要使用本地存储,我们可以使用内置的浏览器localStorageAPI。例如,要将用户的 GitHub 配置文件数据保存到本地存储中,我们可以编写:

localStorage.setItem('user', JSON.stringify(user));

注意浏览器的 localStorage 将项目保存为字符串,因此在保存之前需要将对象转换为字符串。需要时,我们可以使用以下代码:

const user = JSON.parse(localStorage.getItem('user'));

您可以将上述代码添加到您的 UserProfile 组件中(示例 6-2),如下所示:

<script lang="ts">
import axios from 'axios';

//...

async function getUser() {
    try {
        const user = JSON.parse(localStorage.getItem('user'));
        if (user) return user.value = user;

        const response = await axios.get<User>(
            'https://api.github.com/users/mayashavin'
        );

        user.value = response.data;
        localStorage.setItem('user', JSON.stringify(user.value));
    } catch (error) {
        error.value = error as Error;
    }
}

getUser();
</script>

它将仅在第一次加载页面时触发异步调用。当再次加载页面时,如果我们已成功保存了数据,它将直接从本地存储中加载。

在实际应用中使用 localStorage

我不建议在实际应用中采用这种方法。它有几个限制,例如,浏览器会在私密/无痕会话中重置任何本地存储数据,或者用户可以禁用他们端上的本地存储。更好的方法是使用像 Vuex 或 Pinia 这样的状态管理工具(见 第九章)。

摘要

本章介绍了在 Vue 组件中处理异步数据的技术,借助 Axios 库和 Composition API 的帮助。我们学习了如何创建可重用的组件来获取和处理 UI 上的数据请求状态,以保持代码的整洁和可读性。我们还探讨了将应用连接到像本地存储这样的外部数据库服务。

下一章将介绍 Vue 的更高级渲染概念,包括使用函数式组件,在 Vue 应用程序中全局注册自定义插件,以及使用动态渲染条件和动态组合布局。

第七章:高级渲染、动态组件和插件组合

在前几章中,您已经了解了 Vue 的工作原理,如何使用选项 API 和组合 API 组合组件,以及如何使用 Axios 将外部资源数据整合到 Vue 应用程序中。

本章将介绍 Vue 渲染的更高级方面。我们将探讨如何使用渲染函数和 JSX 计算功能组件,以及如何使用 Vue 组件标记动态和条件渲染元素。我们还将学习如何注册自定义插件以在应用程序中使用。

渲染函数和 JSX

使用 Vue 编译器 API,Vue 在渲染时将所有用于 Vue 组件的 HTML 模板处理并编译成虚拟 DOM。当 Vue 组件的数据更新时,Vue 触发内部渲染函数,将最新值发送到虚拟 DOM。

在特定场景下,如优化性能、工作在服务器端渲染应用程序或工作在动态组件库中,我们需要绕过 HTML 模板解析器过程。通过直接返回虚拟 DOM 中的渲染虚拟节点,并跳过模板编译过程,render() 是解决这类问题的解决方案。

使用渲染函数

在 Vue 2 中,render() 函数属性接收一个 createElement 回调参数。通过适当的参数触发 createElement,它返回一个有效的 VNode^(1)。我们通常将 createElement 表示为 h 函数。^(2)

示例 7-1 展示了在 Vue 2 语法中创建组件的方式。

示例 7-1. 在 Vue 2 中使用渲染函数
const App = {
 render(h) {
  return h(
   'div',
   { id: 'test-id' },
   'This is a render function test with Vue'
  )
 }
}

这段代码等同于编写以下模板代码:

const App = {
 template: `<div id='test-id'>This is a render function test with Vue</div>`
}

在 Vue 3 中,render 的语法发生了显著变化。它不再接受 h 函数作为参数。相反,vue 包公开了一个全局函数 h,用于创建 VNodes。因此,我们可以将 示例 7-1 中的代码重写为 示例 7-2 中所示。

示例 7-2. 在 Vue 3 中使用渲染函数
import { createApp, h } from 'vue'

const App = {
 render() {
  return h(
   'div',
   { id: 'test-id' },
   'This is a render function test with Vue'
  )
 }
}

输出保持不变。

使用渲染函数支持多根节点

由于 Vue 3 支持组件模板的多个根节点,render() 可以返回一个包含多个 VNode 的数组,每个节点将以同级方式插入到 DOM 中。

使用 h 函数创建 VNode

Vue 设计 h 函数非常灵活,有三个不同类型的输入参数,详见 表格 7-1。

表格 7-1. h 函数的不同参数

参数 是否必需? 可接受的数据类型 描述
组件 字符串、对象或函数 它接受字符串作为文本或 HTML 标签元素,组件函数或选项对象。
props No Object 此对象包含从其父级接收的所有组件 props、属性和事件,与我们在 template 中编写的方式类似。
嵌套子节点 No String、array 或 object 此参数包括一组 VNodes,或者仅包含文本的字符串组件,或者具有不同 slots(参见第三章)作为组件子节点的对象。

h 函数的语法如下:

h(component, { /*props*/ }, children)

例如,我们想创建一个组件,其根元素使用 div 标签,并具有 id、内联边框样式以及一个输入子元素。我们可以像这样调用 h 函数:

const inputElem = h(
 'input',
 {
  placeholder: 'Enter some text',
  type: 'text',
  id: 'text-input'
 })

const comp = h(
 'div',
 {
  id: 'my-test-comp',
  style: { border: '1px solid blue' }
 },
 inputElem
)

在实际 DOM 中,组件的输出将是:

<div id="my-test-comp" style="border: 1px solid blue;">
 Text input
 <input placeholder="Enter some text" type="text" id="text-input">
</div>

您可以使用以下完整的工作代码进行实验,并尝试不同的 h 函数配置:

import { createApp, h } from 'vue'

const inputElem = h(
 'input',
 {
  placeholder: 'Enter some text',
  type: 'text',
  id: 'text-input'
 })

const comp = h(
 'div',
 {
  id: 'my-test-comp',
  style: { border: '1px solid blue' }
 },
 inputElem
)

const App = {
 render() {
  return comp
 }
}

const app = createApp(App)

app.mount("#app")

在渲染函数中编写 JavaScript XML

JavaScript XML(JSX)是由 React 框架引入的 JavaScript 扩展,允许开发人员在 JavaScript 中编写 HTML 代码。JSX 格式中的 HTML 和 JavaScript 代码如下所示:

const JSXComp = <div>This is a JSX component</div>

上述代码输出一个渲染带有文本“这是一个 JSX 组件”的 div 标签的组件。剩下的工作就是在渲染函数中直接返回这个组件:

import { createApp, h } from 'vue'

const JSXComp = <div>This is a JSX component</div>

const App = {
 render() {
  return JSXComp
 }
}

const app = createApp(App)

app.mount("#app")

Vue 3.0 支持开箱即用的 JSX 编写。JSX 的语法与 Vue 模板不同。要绑定动态数据,我们使用单花括号 {},如示例 7-3。

示例 7-3. 使用 JSX 编写简单的 Vue 组件
import { createApp, h } from 'vue'

const name = 'JSX'
const JSXComp = <div>This is a {name} component</div>

const App = {
 render() {
  return JSXComp
 }
}

const app = createApp(App)

app.mount("#app")

我们使用相同的方法绑定动态数据。无需用 '' 包裹表达式。以下示例显示了如何将值绑定到 divid 属性上:

/**... */
const id = 'jsx-comp'
const JSXComp = <div id={id}>This is a {name} component</div>
/**... */

然而,与 React 中的 JSX 不同,我们在 Vue 中不会将诸如 class 之类的属性转换为 className。相反,我们保留这些属性的原始语法。对于元素的事件监听器(在 React 中是 onClick,在 Vue 中仍然是 onclick 等)也是如此。

您还可以像 Options API 中的其他 Vue 组件一样注册 JSX 组件作为 components 的一部分。在编写动态组件时结合 render 函数使用非常方便,并在许多情况下提供更好的可读性。

接下来,我们将讨论如何编写函数组件。

函数组件

函数组件是一个无状态组件,绕过了典型的组件生命周期。与使用 Options API 的标准组件不同,函数组件是一个函数,用于表示该组件的渲染函数。

由于它是一个无状态组件,因此无法访问 this 实例。相反,Vue 将组件的外部 propscontext 作为函数参数暴露出来。函数组件必须使用 vue 包中的全局函数 h() 创建并返回一个虚拟节点实例。因此,语法将是:

import { h } from 'vue'

export function MyFunctionComp(props, context) {
 return h(/* render function argument */)
}

context 公开了组件的上下文属性,包括用于组件的事件发射器 emits,从父组件传递给组件的 attrs,以及包含组件嵌套元素的 slots

例如,功能组件 myHeading 会在标题元素内显示传递给它的任何文本。我们使用 level props 指定标题的级别。如果我们想要将文本 “Hello World” 显示为级别 2 的标题(<h2>),则使用 myHeading 如下所示:

<my-heading level="2">Hello World</my-heading>

并且输出应该是:

<h2>Hello World</h2>

为此,我们使用 vue 包中的 h 渲染函数,并执行 Example 7-4 中显示的代码。

示例 7-4. 使用 h 函数创建自定义标题组件
import { h } from 'vue';

export function MyHeading(props, context) {
 const heading = `h${props.level}`

 return h(heading, context.$attrs, context.$slots);
}

Vue 将跳过功能组件的模板渲染过程,并将虚拟节点声明直接添加到其渲染器管道中。这种机制导致功能组件不可用嵌套插槽或属性。

为功能组件定义 Props 和 Emits

您可以按照以下语法明确定义功能组件的可接受 propsemits

MyFunctionComp.props = ['prop-one', 'prop-two']
MyFunctionComp.emits = ['event-one', 'event-two']

如果不定义,context.props 将与 context.attrs 具有相同的值,其中包含传递给组件的所有属性。

当您需要以编程方式控制组件渲染时,功能组件非常强大,特别适用于组件库作者,他们需要为用户需求提供组件的低级别灵活性。

注意

Vue 3 提供了一种额外的方式,使用 <script setup> 编写组件。只有在以SFC格式编写组件时才相关,详见 “setup”。

接下来,我们将探讨如何使用插件为 Vue 应用程序添加外部功能。

使用 Vue 插件全局添加自定义功能

我们使用插件在 Vue 应用程序中全局添加第三方库或额外的自定义功能。Vue 插件是一个对象,公开一个名为 install() 的方法,其中包含逻辑代码,并负责安装插件本身。以下是一个示例插件:

/* plugins/samplePlugin.ts */
import type { App  } from 'vue'

export default {
 install(app: App<Element>, options: Object) {
  // Installation logic
 }
}

在此代码中,我们在位于 plugins 目录中的 samplePlugin 文件中定义了我们的示例插件代码。install() 接收两个参数:一个 app 实例和一些作为插件配置的 options

例如,让我们编写一个 truncate 插件,它将添加一个新的全局函数属性 $truncate。如果字符串长度超过 options.limit 字符,则 $truncate 将返回截断的字符串,如 Example 7-5 所示。

示例 7-5. 编写截断插件
/* plugins/truncate.ts */
import type { App } from 'vue';

export default {
  install(app: App<Element>, options: { limit: number }) {
    const truncate = (str: string) => {
      if (str.length > options.limit) {
        return `${str.slice(0, options.limit)}...`;
      }

      return str;
    }
    app.config.globalProperties.$truncate = truncate;
  }
}

要在我们的应用程序中使用此插件,我们在 main.ts 中创建的 app 实例上调用 app.use() 方法:

/* main.ts */
import { createApp } from 'vue'
import truncate from './plugins/truncate'

const App = {}

//1\. Create the app instance
const app = createApp(App);

//2\. Register the plugin
app.use(truncate, { limit: 10 })

app.mount('#app')

Vue 引擎将安装 truncate 插件,并初始化为 10 个字符的 limit。该插件将在 app 实例中的每个 Vue 组件中可用。您可以在 script 部分使用 this.$truncate 或在 template 部分直接使用 $truncate 来调用此插件:

import { createApp, defineComponent } from 'vue'
import truncate from './plugins/truncate'

const App = defineComponent({
 template: `
 <h1>{{ $truncate('My truncated long text') }}</h1>
 <h2>{{ truncatedText }}</h2>
 `,
 data() {
  return {
   truncatedText: this.$truncate('My 2nd truncated text')
  }
 }
});

const app = createApp(App);
app.use(truncate, { limit: 10 })
app.mount('#app')

输出应如 图 7-1。

展示两个标题文本的显示,都作为截断文本的结果来自调用截断插件。

图 7-1. 组件输出文本被截断

然而,只有在 <template> 部分使用时或在 script 部分的 Options API 中作为 this.$truncate,才能使用 $truncate。在 <script setup>setup() 中访问 $truncate不可能的。为此,我们需要使用提供/注入模式(见“使用提供/注入模式在组件之间通信”),从以下位置的插件 install 函数开始,位于 plugins/truncate.ts 文件中:

/* plugins/truncate.ts */
export default {
  install(app: App<Element>, options: { limit: number }) {
    //...
    app.provide("plugins", { truncate });
  }
}

Vue 将 truncate 作为 plugins 对象的一部分传递给所有应用程序的组件。通过这样,我们可以使用 inject 接收我们想要的插件 truncate 并继续计算 truncatedText

<script setup lang="ts">
import { inject } from 'vue';

const { truncate } = inject('plugins');
const truncatedText = truncate('My 2nd truncated text');
</script>

插件在组织全局方法和在其他应用程序中重用时非常有帮助。在安装外部库时编写逻辑,例如axios用于获取外部数据,i18n用于本地化等,也是非常有益的。

在我们的应用程序中注册 Pinia 和 Vue Router

在我们的应用程序脚手架期间,Vite 使用与在 main.ts 中生成的原始代码中反映的相同方法添加 Pinia 和 Vue Router 作为应用程序插件。

下一节将介绍如何使用 Vue <component> 标签在运行时渲染动态组件。

使用 <component> 标签进行动态渲染

<component> 标签充当渲染 Vue 组件的占位符,根据传递给其 is props 的组件引用名称进行渲染,遵循此语法:

<component is="targetComponentName" />

假设您的目标组件可以从 Vue 实例访问(注册为应用程序的组件或在嵌套 <component> 时作为父组件),Vue 引擎将根据名称字符串查找目标组件,并用目标组件替换标签。目标组件还将继承传递给 <component> 的所有额外 props。

假设我们有一个 HelloWorld 组件,用于呈现文本“Hello World”:

<template>
  <div>Hello World</div>
</template>

我们将此组件注册到 App,然后使用 <component> 标签动态渲染,如下所示:

<template>
  <component is="HelloWorld" />
</template>
<script lang="ts">
import HelloWorld from "@/components/HelloWorld";
import { defineComponent } from "vue";

export defineComponent({
 components: { HelloWorld },
});
</script>

您还可以使用 v-bind 指令(简写为 :)将组件绑定为 is props 的引用。我们可以通过以下方式将前两个代码块缩短为单个 App 组件:

<template>
  <component :is="myComp" />
</template>
<script lang="ts">
import HelloWorld from "@/components/HelloWorld";
import { defineComponent } from "vue";

export defineComponent({
 data() {
  return {
   myComp: {
    template: '<div>Hello World</div>'
   }
  }
 }
});
</script>

注意,组件引用myComp遵循 Options API 语法。你也可以传递导入的 SFC 组件。两种情况的输出应该是相同的。

<component>标签非常强大。例如,如果你有一个画廊组件,可以选择将每个画廊项条件地渲染为一个Card组件或Row组件,使用<component>来进行部分切换。

然而,切换组件意味着 Vue 完全卸载当前元素并删除所有组件的当前数据状态。切换回该组件相当于创建一个具有新数据状态的新实例。为了防止这种行为并为未来的切换维护一个被动元素的状态,我们使用<keep-alive>组件。

使用保持组件实例的存活状态

<keep-alive>是一个内置的 Vue 组件,用于包装动态元素,并在非活动模式下保留组件状态。

假设我们有两个组件,StepOneStepTwo。在StepOne组件中,有一个字符串input字段,使用v-model与本地数据属性name进行双向绑定:

<!--StepOne.vue-->
<template>
  <div>
    <label for="name">Step one's input</label>
    <input v-model="name" id="name" />
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';

const name = ref<string>("");
</script>

StepTwo组件渲染一个静态字符串:

<!--StepTwo.vue-->
<template>
  <h2>{{ name }}</h2>
</template>
<script setup lang="ts">
const name = "Step 2";
</script>

在主App模板中,我们将使用component标签将本地数据属性activeComp作为组件引用进行渲染。activeComp的初始值是StepOne,我们有一个按钮可以在StepOneStepTwo之间切换:

<template>
  <div>
    <keep-alive>
      <component :is="activeComp" />
    </keep-alive>
    <div>
      <button @click="activeComp = 'StepOne'" v-if="activeComp === 'StepTwo'">
      Go to Step Two
      </button>
      <button @click="activeComp = 'StepTwo'" v-else>Back to Step One</button>
    </div>
    </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import StepOne from "./components/StepOne.vue";
import StepTwo from "./components/StepTwo.vue";

export default defineComponent({
  components: { StepTwo, StepOne },
  data() {
    return {
      activeComp: "StepOne",
    };
  },
});
</script>

每当你在StepOneStepTwo之间切换时,Vue 都会保留从输入字段接收的name属性的任何值。当切换回StepOne时,你可以继续使用先前的值,而不是从初始值开始。

你还可以使用其max属性为keep-alive定义最大缓存实例数量:

<keep-alive max="2">
  <component :is="activeComp" />
 </keep-alive>

这段代码通过设置max="2"来定义keep-alive应保持的最大实例数。一旦缓存实例数超过限制,Vue 将从缓存列表中移除最近最少使用(LRU)的实例,以便缓存新的实例。

概要

本章探讨了如何使用 JSX 和函数组件控制组件渲染,全局注册 Vue 自定义插件,并使用<component>标签动态和条件地渲染组件。

下一章将介绍 Vue Router,Vue 的官方路由管理库,并讨论如何使用 Vue Router 在应用程序中处理不同路由之间的导航。

^(1) 虚拟节点

^(2) 代表超文本,意味着使用 JavaScript 代码创建 HTML

第八章:路由

在前几章中,我们学习了 Vue 组件的基础知识以及组合 Vue 组件的不同方法。我们继续将可重用的组件逻辑作为独立的可组合部分使用 Composition API。我们还学习了有关渲染和自定义插件创建的更高级概念。

本章将通过向您介绍 Vue Router 的路由系统概念来探讨构建 Vue 应用程序的不同方面,Vue Router 是 Vue 应用程序的官方路由管理库,并介绍其核心 API。然后,我们学习如何配置应用程序的路由,通过路由守卫传递和处理数据,并为应用程序构建动态和嵌套路由。

什么是路由?

当用户在 Web 上导航时,他们在浏览器地址栏中输入统一资源定位符(URL)。URL 是 Web 中资源的地址。它包含许多部分,我们可以分为以下重要部分(图 8-1):

位置

包括协议、应用程序的域名(或 Web 服务器的 IP 地址)和用于访问请求资源的端口。

路径

请求资源的路径。在 Web 开发中,我们使用它来根据预定义的路径模式在浏览器端确定要呈现的页面组件。

查询参数

一组用 & 符号分隔的键值对,用于向服务器传递附加信息。我们主要使用查询参数在页面之间传递数据。

锚点

# 符号后的任何文本。我们使用锚点在同一页面上导航到特定元素,通常与匹配的 id 值或媒体元素的时间间隔匹配。

图示显示组件之间的单向数据流

图 8-1. URL 结构

当浏览器从用户接收到 URL 后,根据接收到的 URL 与服务器进行通信,返回请求的资源(如果有)。该资源可以是静态文件,如图像或视频,也可以是动态页面,如网页或 Web 应用程序。

在单页面应用程序(SPA)中,我们在浏览器端执行路由机制,从而实现平滑的页面导航而无需刷新浏览器。由于 URL 是页面的地址,我们使用路由系统将其路径模式连接到在应用程序中表示它的特定组件。

前端框架如 Vue 提供了构建单页面应用(SPA)组件的布局,但并未提供路由服务。为了创建完整的用户导航体验,我们必须自行设计和开发应用程序的路由,包括解决 SPA 的历史记录和书签等问题。

或者我们可以将 Vue Router 作为我们主要的路由引擎。

使用 Vue Router

作为 Vue 应用程序的官方路由服务,Vue Router 提供了一个控制机制,用于处理 Vue 应用程序中的页面导航。我们使用 Vue Router 来设置应用程序的路由系统,包括配置组件和页面之间的映射,为 SPA 的流程在客户端提供良好的用户体验。

注意

官方 Vue Router 文档可在Vue Router 网站找到,包含安装、API 和主要用例的相关信息供参考。

由于 Vue Router 是 Vue 框架中的独立包,因此我们需要执行额外的步骤来安装并准备在我们的应用程序中使用它,接下来我们将讨论这些步骤。

安装 Vue Router

使用 Vite 为新的 Vue 项目安装 Vue Router 的最简单方法是在设置期间选择“是”以安装 Vue Router(参见“创建新的 Vue 应用程序”)。然后,Vite 将负责安装 Vue Router 包,并用相关文件和文件夹(如图 8-2)搭建您的项目结构:

  • router文件夹中有一个名为index.ts的文件,其中包含应用程序的路由配置。

  • views文件夹中有两个示例 Vue 组件,AboutViewHomeView。每个组件是相关 URL 路径的视图,稍后我们将讨论它们。

一个图表显示了组件之间单向数据流

图 8-2 在使用 Vite 启用 Vue Router 搭建后的项目结构

Vite 还会向main.ts文件中注入一些代码,以初始化 Vue Router。因此,创建的应用程序将启用主路由,并使其准备就绪。

然而,为了全面理解 Vue Router 的工作原理,我们将跳过搭建选项,并通过以下命令手动将 Vue Router 添加到我们现有的项目中:

yarn add -D vue-router@4
注意

在本书中,我们使用的是 Vue Router 4.1.6 版本,这是撰写本文时的最新版本。您可以从Vue Router NPM 页面中使用@后的最新版本号替换它。

对于 Vue 3 项目,您应该使用 4 及以上版本。

为了展示 Vue Router 的功能,我们将构建一个 SPA,代表一个比萨订购系统。应用程序标题栏将包含以下页面链接:主页(Home)、关于(About)、比萨(Pizzas)、联系(Contact)和登录(Login)(参见图 8-3)。

一个屏幕截图显示了 Pizza House 应用程序标题栏的布局

图 8-3 比萨之家应用程序带有导航标题

每个应用程序链接都指向一个由 Vue 组件表示的页面。对于每个应用程序页面,我们创建一个占位符组件,并将其保存在views文件夹中。我们的 Pizza House 代码库现在包含以下视图组件:

HomeView

我们应用程序的主页包含欢迎消息和比萨列表。

AboutView

关于页面将包含应用程序的简短描述。

PizzasView

显示用于订购的披萨列表。

ContactView

显示联系表单。

LoginView

显示用户登录表单。

我们需要将这些组件映射到适当的页面链接,示例见 第 8-1 表。

第 8-1 表。Pizza House 中可用路由及其对应组件和页面 URL 的表格

页面链接 组件 路由路径模式
https://localhost:4000 HomeView /
https://localhost:4000/about AboutView /about
https://localhost:4000/pizzas PizzasView /pizzas
https://localhost:4000/contact Contact /contact
https://localhost:4000/login LoginView /login

第 8-1 表 还显示了每个页面链接的相应路由模式。我们将使用这些模式来定义应用程序中的路由。

注意

localhost4000 端口是 Vite 的开发服务器的本地端口号。它可以根据您的 Vite 配置和本地运行项目时可用的端口而变化。

定义路由

路由是对页面 URL 的响应路径模式。我们在 Vue Router 中基于配置对象使用 RouteRecordRaw 接口定义路由。该配置对象包含以下在 第 8-2 表 中描述的属性。

第 8-2 表。路由配置对象的属性

属性 类型 描述 必须?
path string 用于检查浏览器位置(浏览器 URL)的模式
component Component 当浏览器位置匹配路由路径模式时要渲染的组件
name string 路由的名称。我们可以使用它来避免在代码中硬编码 URL。
components { [name: string]: Component } 基于匹配路由名称渲染的组件集合
redirect stringLocationFunction 重定向路径
props booleanObjectFunction 传递给组件的 props
alias stringArray<string> 别名路径
children Array<RouteConfig> 子路由
beforeEnter Function 导航守卫回调
meta any 路由的元数据。我们可以使用它传递不在 URL 上可见的附加信息。
sensitive Boolean 路由是否区分大小写。默认情况下,所有路由都是不区分大小写的;例如,/pizzas/Pizzas 是同一个路由。
strict Boolean 是否允许尾部斜杠(例如 /about//about

我们通常不使用所有可用字段来定义路由。例如,采取默认应用程序路径 (/)。仅需定义以下 home 路由对象即可,path 属性设置为 /component 属性设置为 HomeView

/**router/index.ts */
//import the required component modules

const homeRoute = {
  path: '/',
  name: 'home',
  component: HomeView
}

在上述代码中,Vue Router 将默认入口 URL(例如https://localhost:4000)映射到/情况,除非启用了strict模式。如果斜杠/后没有指示符,则 Vue Router 将HomeView组件渲染为默认视图。这种行为适用于以下两种情况:当用户访问https://localhost:4000https://localhost:4000/时。

现在我们可以继续在router文件夹下的index.ts文件中配置我们应用的routes,将其配置为RouteRecordRaw配置对象的数组,如下所示的代码:

/**router/index.ts */
import { type RouteRecordRaw } from "vue-router";
//import the required component modules

const routes:RouteRecordRaw[]  = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: AboutView
  },
  {
    path: '/pizzas',
    name: 'pizzas',
    component: PizzasView
  },
  {
    path: '/contact',
    name: 'contact',
    component: ContactView
  },
  {
    path: '/login',
    name: 'login',
    component: LoginView
  }
]

使用命名路由

本章节使用具有name属性的命名路由。我建议在您的应用程序中使用这种方法,使代码更具可读性和可维护性。

这已经很简单了。我们已经为我们的 Pizza House 定义了必要的路由。但是我们的路由系统需要更多东西才能工作。我们必须从给定的路由创建一个路由器实例,并在初始化时将其插入到我们的 Vue 应用程序中。接下来我们将这样做。

创建路由实例

我们可以使用vue-router包中的createRouter方法创建路由器实例。此方法接受一个RouterOptions类型的配置对象作为参数,其中包含以下主要属性:

history

历史模式对象可以基于哈希或基于 Web(HTML 历史模式)。基于 Web 的方法利用 HTML5 历史 API 使 URL 可读,允许我们在不重新加载页面的情况下导航。

routes

用于在路由器实例中使用的路由数组。

linkActiveClass

用于激活链接的类名。默认情况下是router-link-active

linkExactActiveClass

用于精确激活链接的类名。默认情况下是router-link-exact-active

注意

RouterOptions接口的其他不太常见属性在RouterOptions 文档中可用。

我们使用vue-route包中的createWebHistory方法来创建基于 web 的history对象。该方法接受一个可选参数,表示基本 URL 的字符串:

/**router/index.ts */
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw
} from 'vue-router';

const routes: RouteRecordRaw[] = [/**... */]

export const router = createRouter({
  history: createWebHistory("https://your-domain-name"),
  routes
})

但是,将基本 URL 作为静态字符串传递并不是一个好习惯。我们希望保持基本 URL 可配置并独立,适用于不同的环境,如开发和生产。为此,Vite 提供了环境对象import.meta.env,其中包含一个BASE_URL属性。您可以在专用的环境文件中设置BASE_URL值,通常以.env前缀表示,或者通过运行 Vite 服务器时的命令行设置。然后,Vite 会提取相关的BASE_URL值并将其注入到import.meta.env对象中,我们可以在我们的代码中使用它,如下所示:

/**router/index.ts */
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw
} from 'vue-router';

const routes: RouteRecordRaw[] = [/**... */]

export const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

使用环境文件中的BASE_URL

您不必在开发中的.env文件中设置BASE_URL值。Vite 会自动将其映射到本地服务器 URL。

大多数现代托管平台(如 Netlify)在部署期间会为您设置BASE_URL值,通常为应用程序的域名。

我们已经从给定的 routes 和所需的 history 模式创建了路由实例。下一步是将这个实例插入到我们的 Vue 应用程序中。

将路由实例插入到 Vue 应用程序中

main.ts 文件中初始化应用程序实例 app 的地方,我们将导入创建的 router 实例,并将其作为参数传递给 app.use() 方法:

/**main.ts */
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'

const app = createApp(App)

app.use(router)

app.mount('#app')

我们的应用程序现在具有了用于在页面之间导航的路由系统。但是,如果现在运行应用程序,您会看到在导航到 /about 路径时仍然没有渲染 AboutView 组件。我们必须修改我们的 App.vue 组件以显示适合路由路径的组件配置。接下来我们就来做这件事。

使用 RouterView 组件渲染当前页面

为了动态生成特定 URL 路径的所需视图,Vue Router 提供了 RouterView(或 router-view)作为占位符组件。在运行时,Vue Router 将根据提供的配置替换为与当前 URL 模式匹配的元素。我们可以在 App.vue 组件中使用此组件来渲染当前页面:

/**App.vue */
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
  <RouterView />
</template>

在运行应用程序时,默认主页现在是 HomeView(参见 图 8-4)。当使用浏览器的位置栏导航到 /about 时,您会看到 AboutView 组件被渲染了(参见 图 8-5)。

主页视图

图 8-4. 应用程序显示了 HomeView 组件,对应路径为 "/"

关于视图

图 8-5. 应用程序显示了 AboutView 组件,对应路径为 "/about"

由于 RouterView 是一个 Vue 组件,我们可以向其传递 props、attributes 和事件监听器。RouterView 将把它们传递给渲染的视图来处理。例如,我们可以使用 RouterView 添加一个类:

/**App.vue */
<template>
  <RouterView class="view" />
</template>

渲染的组件——例如 AboutView——将会接收 class 作为主要容器元素(参见 图 8-6),我们可以相应地用它来进行 CSS 样式设置。

RouterView 类

图 8-6. AboutView 组件从 RouterView 组件接收 class 属性

到目前为止,我们已经学习了如何设置应用程序的路由并使用 RouterView 组件渲染当前页面。然而,通过手动在浏览器地址栏设置 URL 路径来导航似乎对用户来说并不是很方便。为了增强我们应用程序的用户体验,我们可以组合一个包含导航链接的页眉,使用 a 元素和完整路径。或者,我们可以使用内置的 RouterLink 组件来构建到我们的路由的链接,接下来我们将讨论这个内容。

Vue Router 提供了 RouterLink(或 router-link)组件,用于根据给定的 to 属性生成交互和可导航的元素,用于特定路由路径。路由路径可以是字符串,其值与路由配置中的 path 相同,如以下示例用于导航到关于页面的链接:

  <router-link to="/about">About</router-link>

或者,我们可以传递一个代表路由位置对象的对象,包括路由参数的 nameparams

  <router-link :to="{ name: 'about' }">About</router-link>

默认情况下,该组件将使用带有 href 和类似于 router-link-activerouter-link-exact-active 的类的锚元素 (a) 进行渲染。我们可以使用布尔型 custom 属性和 v-slot 将默认元素更改为任何其他元素,通常是另一个交互元素,如 button,如下例所示:

  <router-link custom to="/about" v-slot="{ navigate }" >
    <button @click="navigate">About</button>
  </router-link>

此代码将呈现一个 button 元素,而不是默认的 a 元素,绑定 navigate 函数以在点击时导航到给定的路由。

使用 custom 属性

如果您使用 custom 属性,必须将 navigate 函数绑定为点击处理程序或将 href 链接到自定义元素。否则,导航将无法正常工作。

当自定义元素激活时,不会添加类名如 router-link-activerouter-link-exact-active

让我们使用 RouterLink 构建我们的导航栏 NavBar,如 示例 8-1 所示。

示例 8-1. NavBar 组件
/**NavBar.vue */

<template>
  <nav>
    <router-link :to="{ name: 'home' }">Home</router-link>
    <router-link :to="{ name: 'about' }">About</router-link>
    <router-link :to="{ name: 'pizzas' }">Pizzas</router-link>
    <router-link :to="{ name: 'contact' }">Contact</router-link>
    <router-link :to="{ name: 'login' }">Login</router-link>
  </nav>
</template>

我们还向导航栏和活动链接添加了一些 CSS 样式:

/**NavBar.vue */

<style scoped>
nav {
  display: flex;
  gap: 30px;
  justify-content: center;
}

.router-link-active, .router-link-exact-active {
  text-decoration: underline;
}
</style>

使用 activeClassexactActiveClass 属性

您可以使用 RouterLinkactiveClassexactActiveClass 属性来自定义活动链接的类名,而不是使用默认的类名。

一旦我们将 NavBar 添加到 App 组件中,我们将在页面顶部看到导航栏 (图 8-7)。

显示带有主页链接激活和下划线的导航栏的屏幕截图

图 8-7. 应用程序的导航栏

现在,我们的用户可以使用导航栏在页面之间导航。但是,我们仍然需要处理页面之间的数据流。在接下来的几节中,我们将看到如何使用路由参数在路由之间传递数据。

在路由之间传递数据

要在路由之间传递数据,我们可以使用传递给 to 的路由器对象中的 query 字段:

<router-link :to="{ name: 'pizzas', query: { id: 1 } }">Pizza 1</router-link>

query 字段是一个对象,包含我们要传递给路由的查询参数。Vue Router 将其转换为带有查询参数的完整 href 路径,以 ? 语法开始:

<a href="/pizzas?id=1">Pizza 1</a>

然后,我们可以使用 useRoute() 函数在路由组件 PizzasView 中访问查询参数。

<template>
  <div>
    <h1>Pizzas</h1>
    <p v-if="pizzaId">Pizza ID: {{ pizzaId }}</p>
  </div>
</template>
<script lang="ts" setup>
import { useRoute } from "vue-router";

const route = useRoute();
const pizzaId = route.query?.id;
</script>

此代码将呈现以下页面,其中浏览器的 URL 是 http://localhost:4000/pizzas?id=1 (图 8-8)。

显示带有查询参数的披萨页面的屏幕截图

图 8-8. 带有查询参数的披萨页面

您还可以在浏览器地址栏中传递查询参数,路由器实例将相应地将其解耦为route.query对象。这种机制在许多情况下都很方便。例如,考虑我们的PizzasView页面。此页面显示了使用usePizzas钩子从中获取的披萨列表,使用PizzaCard组件,如示例 8-2 所示。

示例 8-2. PizzasView组件
<template>
  <div class="pizzas-view--container">
    <h1>Pizzas</h1>
    <ul>
      <li v-for="pizza in searchResults" :key="pizza.id">
        <PizzaCard :pizza="pizza" />
      </li>
    </ul>
  </div>
</template>
<script lang="ts" setup>
import PizzaCard from "@/components/PizzaCard.vue";
import { usePizzas } from "@/composables/usePizzas";

const { pizzas } = usePizzas();
</script>

现在我们想要添加一个搜索功能,用户可以通过标题使用查询参数search搜索披萨,并获取经过筛选的披萨列表。我们可以添加一个useSearch钩子,它接收route.query.search的值作为初始值,并返回经过筛选的披萨列表以及响应式的search值,如示例 8-3 所示。

示例 8-3. 实现useSearch钩子
import { computed, ref, type Ref } from "vue";

type UseSearchProps = {
  items: Ref<any[]>;
  filter?: string;
  defaultSearch?: string;
};

export const useSearch = ({
  items,
  filter = "title",
  defaultSearch = "",
}: UseSearchProps) => {
  const search = ref(defaultSearch);
  const searchResults = computed(() => {
    const searchTerm = search.value.toLowerCase();

    if (searchTerm === "") {
      return items.value;
    }

    return items.value.filter((item) => {
      const itemValue = item[filter]?.toLowerCase()
          return itemValue.includes(searchTerm);
        });
  });

  return { search, searchResults };
};

然后我们在PizzasView组件中使用useSearch钩子,并将迭代方式更改为遍历searchResults而不是pizzas

<template>
  <!--...other code -->
    <li v-for="pizza in searchResults" :key="pizza.id">
      <PizzaCard :pizza="pizza" />
    </li>
  <!--...other code -->
</template>
<script lang="ts" setup>
/**...other imports */
import { useRoute } from "vue-router";
import { useSearch } from "@/composables/useSearch";
import type { Pizza } from "@/types/Pizza";

/**...other code */
const route = useRoute();

type PizzaSearch = {
  search: Ref<string>;
  searchResults: Ref<Pizza[]>;
};

const { search, searchResults }: PizzaSearch = useSearch({
  items: pizzas,
  defaultSearch: route.query?.search as string,
});
</script>

现在当您访问/pizzas?search=hawaii时,列表将仅显示标题为Hawaii的披萨(图 8-9)。

显示具有搜索查询参数的披萨页面的屏幕截图

图 8-9. 具有查询参数中搜索词的披萨页面

考虑允许用户在页面上进行搜索,然后将更新后的搜索词与查询参数同步?为此,我们需要进行以下更改:

  • template中添加一个输入字段,并将其绑定到search变量:
<template>
  <!--...other code -->
  <input v-model="search" placeholder="Search for a pizza" />
  <!--...other code -->
</template>
  • 使用useRouter()方法获取router实例:
/**...other imports */
import { useRoute, useRouter } from "vue-router";

/**...other code */
const router = useRouter();
  • 使用watch函数监听search值的变化,并使用router.replace更新查询参数。
/**...other imports */
import { watch } from 'vue';

/**...other code */
watch(search, (value, prevValue) => {
  if (value === prevValue) return;
  router.replace({ query: { search: value } });
});

当您在搜索栏中输入时,路由器实例将使用新的查询值更新 URL。

注意

如果您使用的是 Vue 2.x 及以下版本或 Options API(没有使用setup()),可以分别使用this.$routerthis.$route来访问routerroute实例。

到目前为止,我们已经学会了如何使用route实例检索查询参数。在每个需要访问查询参数的组件中使用route实例可能会很麻烦。相反,我们可以使用 props 解耦查询参数,接下来我们将学习这一点。

使用 Props 解耦路由参数

在路由配置对象中,我们可以定义静态 props 以作为对象传递给视图组件的静态值或返回 props 的函数。例如,在以下代码中,我们改变了我们的pizzas路由配置以传递searchTerm prop,其值来自route.query.search,传递给PizzaView组件:

import {
  type RouteLocationNormalizedLoaded,
  type RouteRecordRaw,
} from "vue-router";

const routes: RouteRecordRaw = [
  /** other routes */
  {
    path: "/pizzas",
    name: "pizzas",
    component: PizzasView,
    props: (route: RouteLocationNormalizedLoaded) => ({
      searchTerm: route.query?.search || "",
    }),
  },
];

PizzasView组件中,我们可以移除对useRoute的使用,并使用props对象访问searchTerm prop:

const props = defineProps({
  searchTerm: {
    type: String,
    required: false,
    default: "",
  },
});

const { search, searchResults }: PizzaSearch = useSearch({
  items: pizzas,
  defaultSearch: props.searchTerm,
});

应用程序的行为与以前相同。

你还可以使用props: trueroute.params对象作为 props 传递给视图组件,而不需要关心任何特定的 props。当路由改变时,我们可以将这种方法与导航卫兵结合起来,以执行路由参数的副作用。关于导航卫兵的更多信息将在下一节中介绍。

理解导航卫兵

导航卫兵是帮助我们更好地控制导航流程的函数。我们还可以使用它们在路由改变或导航发生之前执行副作用。有三种类型的导航卫兵和钩子:全局、组件级别和路由级别。

全局导航卫兵

对于每个路由器实例,Vue Router 公开了一组全局级别的导航卫兵,包括:

router.beforeEach

在每次导航之前调用

router.beforeResolve

在 Vue Router 解析路由的所有异步组件和所有组件内卫兵(如果有的话)之后,但在确认导航之前调用

router.afterEach

在确认导航之后和 DOM 的下一次更新之前调用

全局卫兵帮助在导航到特定路由之前执行验证。例如,我们可以使用router.beforeEach来检查用户是否在导航到/pizzas路由之前进行了身份验证。如果没有,我们可以将用户重定向到/login页面:

const user = {
  isAuthenticated: false,
};

router.beforeEach((to, from, next) => {
  if (to.name === "pizzas" && !user.isAuthenticated) {
    next({ name: "login" });
  } else {
    next();
  }
});

在这段代码中,to是要导航到的目标路由对象,from是当前路由对象,next是一个函数,用于调用以解析钩子/卫兵。我们需要在最后触发next(),要么没有任何参数继续到原始目标,要么用新的路由对象作为其参数重定向用户到不同的路由。否则,Vue Router 将阻止导航流程。

注意

或者,我们可以使用router.beforeResolve来执行相同的验证。router.beforeEachrouter.beforeResolve之间的关键区别在于,Vue Router 在解析所有组件内卫兵后触发后者。然而,当你想要在确认导航之前避免加载适当的异步组件时,等待一切解决后再调用回调将不太有价值。

router.afterEach怎么样?我们可以利用这个钩子执行动作,如将某些页面数据保存为缓存,跟踪页面分析,或者在从登录页面导航时对用户进行身份验证:

router.afterEach((to, from) => {
  if (to.name === "login") {
    user.isAuthenticated = true;
  }
});

虽然全局卫兵帮助执行副作用并控制整个应用程序的重定向,但在某些情况下,我们只想为特定路由实现副作用。在这种情况下,使用路由级别卫兵是一个不错的选择。

路由级别导航卫兵

对于每个路由,我们可以定义 beforeEnter 守卫的回调函数,当从一个路径进入另一个路径时,Vue Router 将触发此守卫。例如我们的 /pizzas 路由。与其将 props 字段与函数映射,我们可以通过手动设置 to.params.``searchTerm 字段为 to.query.search,在进入路由之前将搜索查询映射为视图的一个 prop:

const routes: RouteRecordRaw = [
  /** other routes */
  {
    path: "/pizzas",
    name: "pizzas",
    component: PizzasView,
    props: true,
    beforeEnter: async (to, from, next) => {
      to.params.searchTerm = (to.query.search || "") as string;

      next()
    },
  },
];

注意,我们在比萨路由中设置了 props: true。UI 仍将显示与之前相同的比萨列表(见 图 8-10)。

一个 Pizzas 列表页面的截图。

图 8-10. 比萨列表

我们可以在这个守卫内手动修改 to.query.searchTerm。然而,这些更改不会反映在浏览器地址栏中的 URL 路径上。如果我们想要更新 URL 路径,我们可以使用 next 函数将用户重定向到一个新的路由对象,并带有所需的查询参数。

将回调函数数组传递给 beforeEnter

beforeEnter 也接受一个回调函数数组,Vue Router 将按顺序触发这些回调。因此,我们可以为特定路由执行多个副作用,然后再进入它。

像其他全局守卫一样,beforeEnter 守卫在您想要执行认证到特定路由、在将路由参数传递给视图组件之前进行额外修改等情况时非常有用。接下来,我们将学习如何利用组件级别的守卫来为特定视图执行副作用。

组件级别的路由守卫

从 Vue 3.x 开始,Vue Router 在组件级别也提供了可组合的守卫,以帮助控制路由的离开和更新流程,例如 onBeforeRouteLeaveonBeforeRouteUpdate。当用户从当前路径视图导航离开时,Vue Router 触发 onBeforeRouteLeave,而用户在同一路径视图但参数不同的情况下导航时,则调用 onBeforeRouteUpdate

我们可以使用 onBeforeRouteLeave 显示一个消息来确认用户是否导航离开联系页面,代码如下:

import { onBeforeRouteLeave } from "vue-router";

onBeforeRouteLeave((to, from, next) => {
  const answer = window.confirm("Are you sure you want to leave?");

  next(!!answer);
});

当您位于联系页面并尝试导航到另一页时,您将看到一个确认弹窗询问您确认导航,如图 8-11 所示。单击取消按钮将阻止导航,单击确定按钮将继续导航。

一个确认弹窗的截图。

图 8-11. 确认弹窗
注意

如果您的组件使用 Options API,那么 beforeRouteLeavebeforeRouteUpdate 守卫将在选项对象中可用,以实现相同的功能。

还有一个 beforeRouteEnter 钩子,在 Vue 初始化视图组件之前,路由器会触发这个守卫。此守卫类似于 setup() 钩子;因此,Vue Router 的 API 中没有等价的可组合函数。

我们已经探索了路由系统中不同级别的导航守卫及其执行顺序,如 图 8-12 所示。

导航守卫流程的图示。

Figure 8-12. 触发导航守卫及其等效组合的顺序

理解导航流程和守卫执行顺序对于构建强大的路由系统至关重要。接下来,我们将学习如何为我们的应用程序创建嵌套路由。

创建嵌套路由

到目前为止,我们已经为我们的应用程序构建了基本的单级路由系统。实际上,大多数路由系统都更复杂。有时,我们希望为特定页面创建子页面,例如常见问题解答(FAQ)页面和联系页面的表单页面:

  /contact/faq
  /contact/form

/contact 页面的默认 UI 将是 ContactView 页面,用户可以通过在此页面上显示的链接点击转到 Form 页面。在这种情况下,我们需要使用路由配置对象的 children 字段为 /contact 页面创建嵌套路由。

首先创建 ContactFaqViewContactFormView 组件,以便路由匹配时渲染它们,然后修改我们的 /contact 路由:

const routes = [
  /**...other routes */
  {
    path: "/contact",
    name: "contact",
    component: ContactView,
    children: [
      {
        path: "faq",
        name: "contact-faq",
        component: ContactFaqView,
      },
      {
        path: "form",
        name: "contact-form",
        component: ContactFormView,
      },
    ],
  },
];

我们还必须在 ContactView 内部桩设占位符组件 RouterView,以渲染嵌套路由。例如,让我们向 ContactView 添加以下代码:

<template>
  <div class="contact-view--container">
    <h1>This is the contact page</h1>
    <nav>
      <router-link to="/contact/faq">FAQs</router-link>
      <router-link to="/contact/form">Contact Us</router-link>
    </nav>
    <router-view />
  </div>
</template>

现在,当用户分别导航到 http://localhost:4000/contact/faq(图 8-13)和 http://localhost:4000/contact/form 时,该 Contact 组件将渲染 ContactFaqViewContactFormView

当导航到 http://localhost:4000/contact/faq 时,显示 Contact 页面内的 FAQ 视图的屏幕截图

Figure 8-13. 访问 http://localhost:4000/contact/faq 时的示例输出

当我们想要为包含嵌套视图及其嵌套路由的页面创建特定的 UI 布局时,这种方法非常有益。

我们已经看到如何在父布局中创建嵌套路由。然而,在某些情况下,我们希望在没有父布局的情况下创建嵌套方式,因此必须将父路由的默认路径声明为其嵌套路由对象的路径。例如,我们可以将父 /contact 路由的 namecomponent 移动到空路径模式的嵌套路径中。

const routes = [
  /**...other routes */
  {
    path: "/contact",
    children: [
      /**... other children */,
      {
        path: "",
        name: "contact",
        component: ContactView,
      }
    ],
  },
];

这样,当用户导航到 http://localhost:4000/contact/faq 时,只有 ContactFaqView 组件将作为单独的页面渲染,而不包含 ContactView 的内容(图 8-14)。

当导航到 http://localhost:4000/contact/faq 时,显示 Contact 页面内的 FAQ 视图的屏幕截图

Figure 8-14. 访问 http://localhost:4000/contact/faq 时的示例输出
注意

正如您在屏幕截图中所看到的,导航栏中的联系人链接仍然处于活动状态。这种行为发生的原因是联系人页面的链接元素仍然具有router-link-active类,但不具有router-link-exact-active类。我们可以通过仅为确切活动的链接定义 CSS 规则来解决这个样式问题。

在现实世界的应用程序中,使用嵌套路由非常普遍;实际上,我们的routes数组已经作为应用程序路由器实例的嵌套子级。声明嵌套路由是组织路由结构和创建动态路由的一个很好的方法,我们将在下一节中探讨。

创建动态路由

Vue Router 最有用的功能之一是设置具有路由参数(路由参数)的动态路由,这些参数是从 URL 路径中提取的变量。当我们有一个动态数据驱动的路由结构时,路由参数非常有用。每个路由共享一个典型模式,只有唯一标识符(例如用户或产品 id)不同。

让我们修改 Pizza House 的路由并添加一个动态路径,以便每次显示一个 pizza。一种选项是定义一个新的路由/pizza,并将 pizza 的 id 作为其查询参数传递,如我们在“在路由之间传递数据”中学到的那样。然而,更好的选择是修改/pizzas路由,并向其添加一个新的嵌套路由,路径模式为:id,如下所示:

const routes = [
  /**...other routes */
  {
    path: "/pizzas",
    /**...other configurations */
    children: [{
        path: ':id',
        name: 'pizza',
        component: PizzaView,
    }, {
        path: '',
        name: 'pizzas',
        component: PizzasView,
    }]
  },
]

通过使用:id,Vue Router 将匹配任何具有类似格式的路径,例如/pizzas/1234-pizza-id,并将提取的 id(例如1234-pizza-id)保存为route.params.id字段。

由于我们已经了解了路由配置对象中的props字段,我们可以将其值设置为true,从而使路由参数自动映射到PizzaView的 props 中:

const routes = [
  /**...other routes */
  {
    path: "/pizzas",
    /**...other configurations */
    children: [{
        path: ':id',
        name: 'pizza',
        component: PizzaView,
        props: true,
    },
    /**...other nested routes */
    ],
  },
]

在绑定的PizzaView组件中,我们使用defineProps()id声明为组件的 props,并使用useRoute钩子从pizzas数组中检索 pizza 的详细信息:

import { usePizzas } from "@/composables/usePizzas";

const props = defineProps({
  id: {
    type: String,
    required: true,
  },
});

const { pizzas } = usePizzas();

const pizza = pizzas.value.find((pizza) => pizza.id === props.id);

PizzaView组件中,我们可以如下显示pizza的详细信息:

<template>
  <section v-if="pizza" class="pizza--container">
    <img :src="pizza.image" :alt="pizza.title" width="500" />
    <div class="pizza--details">
      <h1>{{ pizza.title }}</h1>
      <div>
        <p>{{ pizza.description }}</p>
        <div class="pizza-stock--section">
          <span>Stock: {{ pizza.quantity || 0 }}</span>
          <span>Price: ${{ pizza.price }}</span>
        </div>
      </div>
    </div>
  </section>
  <p v-else>No pizza found</p>
</template>

现在,当您导航到/pizzas/1时,其中1是列表中现有 pizza 的 id 时,PizzaView组件将显示 pizza 的详细信息,如图 8-15 所示。

显示 pizza id 为 1 的 pizza 详细页面的屏幕截图

图 8-15. Pizza 详细页面

从服务器获取数据

理想情况下,您应该避免再次从服务器获取数据,例如在PizzaView组件中的pizzas。相反,您应该使用数据存储管理,如 Pinia(第 9 章),将获取的pizzas存储起来,并在需要时从存储中检索它们。

到目前为止,我们已经探讨了如何创建嵌套和动态路由,并将路由的参数解耦为 props。在下一节中,我们将学习如何使用 Vue Router 为我们的应用程序实现自定义后退和前进按钮。

使用路由实例进行前进和后退

在网页应用程序中实现自定义返回按钮是一个常见功能,除了使用浏览器的原生返回按钮外,我们还可以使用 router.back() 方法导航到历史堆栈中的前一页,其中 router 是从 useRouter() 接收到的应用程序路由器实例:

<template>
  <button @click="router.back()">Back</button>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";

const router = useRouter();
</script>

要在历史堆栈中前进,我们可以使用 router.forward() 方法:

<template>
  <button @click="router.forward()">Forward</button>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";

const router = useRouter();
</script>

使用 router.go() 导航到历史堆栈中的特定页面

您还可以使用 router.go() 方法,该方法接受历史堆栈中要向后或向前移动的步数作为参数。例如,router.go(-2) 将导航到两步之前的页面,而 router.go(2) 将向前跳两步(如果存在)。

我们已经探讨了 Vue Router 的基础知识,并为我们的应用程序创建了一个基本的路由系统,包含所有我们需要的页面。但有一件事情我们需要处理:如果尝试导航到不存在的路径,将会看到一个空白页面。这种情况发生是因为 Vue Router 无法找到匹配的组件来渲染,当用户尝试导航到不存在的路径时。这将是我们下一个话题。

处理未知路由

在大多数情况下,我们无法控制用户在使用我们的应用程序时尝试导航的所有路径。例如,用户可能尝试访问 https://localhost:4000/pineapples,对于这些路径,我们尚未定义路由。在这种情况下,我们可以使用正则表达式模式 /:pathMatch(.**)** 作为新 error 路由中的 path 来向用户显示 404 页面:

/**router/index.ts */

const routes = [
  /**... */
  {
    path: '/:pathMatch(.*)*',
    name: 'error',
    component: ErrorView
  }
]

Vue Router 将根据模式 /:pathMatch(.**)** 匹配未找到的路径,并将匹配的路径值存储在路由位置对象的 pathMatch 参数中。

使用正则表达式匹配未知路径

可以将pathMatch替换为任何其他你想要的名字。它的目的是让 Vue 可以将 pathMatch 替换为你想要的任何名称。其目的是让 Vue Router 知道要存储匹配路径的值。

ErrorView 组件中,我们可以向用户显示一条消息:

<!--ErrorView.vue -->

<template>
  <h1>404 - Page not found</h1>
</template>

现在,当我们尝试访问 https://localhost:4000/pineapples 或任何未知路径时,将会显示 404 页面。

此外,我们还可以使用 vue-router 包的 useRoute() 方法来访问当前路由位置并显示其路径的值:

<!--ErrorView.vue -->

<template>
  <h1>404 - Page not found</h1>
  <p>Path: {{ route.path }}</p>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router'

const route = useRoute()
</script>

此代码将显示当前路由的路径,例如在本例中为 /pineapples(参见图 8-16](#figure_07_error))。

显示 404 页面的截图

图 8-16. 404 页面

或者,我们可以在路由配置中使用 redirect 属性将用户重定向到特定路由,例如主页,当他们访问未知路径时。例如,我们可以重写我们的 error 路由如下:

/**router/index.ts */

const routes = [
  /**... */
  {
    path: '/:pathMatch(.*)*',
    redirect: { name: 'home' }
  }
]

当我们访问一个未知路径时,路由实例会自动将我们重定向到首页,我们不再需要 ErrorView 组件。

总结

在本章中,我们探讨了如何利用 Vue Router 在我们的应用程序中使用不同的 API 构建路由系统。

在路由之间移动需要数据流保持一致,就像处理不直接处于父子关系的组件之间的数据流一样。为了解决这个挑战,我们需要为我们的应用程序设计一个高效的数据管理系统。下一章介绍了 Pinia,Vue 官方的数据管理库,以及如何利用 Pinia 的 API 构建一个高效、可重复使用的数据管理系统。

第九章:使用 Pinia 进行状态管理

前一章指导我们使用 Vue Router 构建应用程序的路由,包括嵌套路由、路由守卫和动态路由导航。

在本章中,我们将学习使用 Pinia 管理状态以及如何在 Vue 应用程序中管理数据流。Pinia 是 Vue 官方推荐的状态管理库,我们还将探讨如何构建应用程序的可重用和高效的数据状态管理系统。

理解 Vue 中的状态管理

数据使应用程序栩栩如生,并连接各个组件。组件通过数据状态与用户及其他组件进行交互。无论大小和复杂程度如何,状态管理对于构建能够使用实际数据的应用程序至关重要。例如,我们可以仅显示产品卡片画廊,包括比萨的列表及其详细信息。一旦用户在此画廊组件中将产品添加到购物车中,我们需要更新购物车的数据,并同时更新所选产品的剩余库存。

以我们的 Pizza House 应用为例。在主视图(App.vue)中,我们有一个标题组件(HeaderView)和一个比萨卡片画廊(PizzasView)。标题包含一个购物车图标,显示购物车中物品的数量,而画廊包括一系列比萨卡片,每张卡片都有一个按钮,允许用户将所选项添加到购物车中。图 9-1 展示了主视图中组件的层次结构。

显示应用程序组件树结构的屏幕截图,其中标题组件和画廊组件是 App 的子组件,彼此是同级

图 9-1. Pizza House 主视图组件的层次结构

当用户将比萨添加到购物车时,购物车图标将显示更新后的物品数量。为了实现标题组件与画廊组件之间的数据通信,我们可以让App管理cart数据,并将其作为 props 传递给标题,同时使用事件updateCart与画廊通信,如图 9-2 中所示。

显示组件之间数据流的屏幕截图,其中 App 组件管理购物车数据并将其作为 props 传递给标题组件,同时使用事件 updateCart 与画廊组件通信

图 9-2. 画廊与标题之间的数据流,App 作为中间人

这种方法对于小型应用程序非常有效。然而,假设我们希望将PizzasView拆分为子组件,如PizzasGallery,并且让PizzasGallery为每个比萨渲染PizzaCard组件。对于每个新的父子层,我们需要传播updateCart事件,以确保在画廊和标题之间的数据流传播,就像图 9-3 中所示。

当应用程序增长时,我们会有更多组件和层次结构,这种方法可能会导致大量不必要的 props 和事件,从而降低可扩展性和可维护性。

为了减少这种开销并管理应用程序内的状态流动,我们需要一个全局状态管理系统,一个集中存储和管理应用程序数据状态的地方。该系统负责管理数据状态并将数据分发到必要的组件中。

为了为开发者提供顺畅的体验,其中一个最受欢迎的方法是使用状态管理库,例如 Pinia。

显示组件之间数据流动的屏幕截图,其中 App 组件管理购物车数据并将其作为 props 传递给 header 组件,并使用事件更新 cart 与 gallery 组件进行通信

理解 Pinia

受 Vuex^(1) 和 Vue 组合 API 的启发,Pinia 是 Vue 当前的官方状态管理库。尽管如此,你仍然可以使用其他支持 Vue 的状态管理 JavaScript 库,例如 Vuex、MobX 和 XState。

Pinia 遵循 Vuex 的存储模式,但采用更灵活和可扩展的方法。

注意

官方 Pinia 文档可在 Pinia 网站 上找到,提供安装、API 和主要用例的相关信息。

不同于在应用程序中使用所有数据集的单一系统,使用 Pinia,我们可以将每个数据集拆分为其状态模块(或存储)。然后,我们可以使用自定义的可组合方式,遵循组合 API 模式,从任何组件中访问存储中的相关数据。

在使用 Vite 从头开始创建 Vue 项目时,我们可以选择在脚手架过程中安装 Pinia 作为状态管理(参见“创建新的 Vue 应用程序”)。Vite 将会安装并配置 Pinia,并提供一个示例 counter 存储,通过 src/stores/counter.ts 暴露为 useCounterStore

然而,要完全理解 Pinia 的工作原理,我们将跳过脚手架选项,并使用以下命令手动添加 Pinia:

yarn add pinia
注意

在本书中,我们使用的是 Pinia 2.1.3,在撰写时是最新版本。你可以根据需要从 Pinia NPM 页面 替换版本号。

安装 Pinia 后,导航到 src/main.ts,从 pinia 包中导入 createPinia,使用它创建一个新的 Pinia 实例,并将其插入应用程序中:

import { createApp } from 'vue'
import { createPinia } from 'pinia' ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

import App from './App.vue'
import router from './router'

const app = createApp(App)
const pinia = createPinia() ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

app.use(pinia) ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)

app.mount('#app')

1

pinia 包中导入 createPinia

2

创建一个新的 Pinia 实例

3

将 Pinia 实例插入应用程序以供使用

安装和插入 Pinia 后,我们将为我们的应用程序创建第一个存储:一个管理应用程序可用披萨的 pizzas 存储。

为 Pizza House 创建一个 Pizzas 存储

由于 Pinia 遵循 Vuex 的存储模式,Pinia 存储包含以下基本属性:

State

使用 ref()reactive() 方法从 Composition API 创建的存储的响应式数据(状态)。

Getters

使用 computed() 方法创建的存储的计算属性和只读属性。

Actions

更新存储状态或在存储数据(状态)上执行自定义逻辑的方法。

Pinia 提供了一个 defineStore 函数来创建一个新的存储,接受两个参数:存储的名称和属性,以及其他组件中可用的方法。存储的属性和方法可以是一个对象,包含键字段 stategettersactions,遵循 Options API(Example 9-1),或者是一个使用 Composable API 的函数,返回一个公开的字段对象(Example 9-2)。

示例 9-1. 使用对象配置定义一个存储
import { defineStore } from 'pinia'

export const useStore = defineStore('storeName', () => {
    return {
        state: () => ({
            // state properties
            myData: { /**... */}
        }),
        getters: {
            // getters properties
            computedData: () => { /**... */ }
        },
        actions: {
            // actions methods
            myAction(){ /**... */ }
        }
    }
})
示例 9-2. 使用函数定义一个存储
import { defineStore } from 'pinia'
import { reactive, computed } from 'vue'

export const useStore = defineStore('storeName', () => {
    //state properties
    const myData = reactive({ /**... */ })

    // getters properties
    const computedData = computed(() => { /**... */})

    // actions methods
    const myAction = () => { /**... */ }

    return {
        myData,
        computedData,
        myAction
    }
})
注意

本章将重点介绍在 Vue 3.x Composition API 中使用 Pinia 存储,通常称为 setup stores

让我们回到我们的 pizzas 存储。我们添加一个新文件,src/stores/pizzas.ts,其中包含 Example 9-3 中显示的代码。

示例 9-3. Pizzas 存储
/** src/stores/pizzas.ts */
import { defineStore } from 'pinia'
import type { Pizza } from '../types/Pizza';
import { ref } from 'vue'

export const usePizzasStore = defineStore('pizzas', () => { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    const pizzas = ref<Pizza[]>([]); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

    const fetchPizzas = async () => { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
        const response = await fetch(
            'http://exploringvue.com/.netlify/functions/pizzas'
        );
        const data = await response.json();
        pizzas.value = data;
    }

    return {
        pizzas,
        fetchPizzas
    }
})

然后在 PizzasView(基于上一章的 Example 8-2 组件)中,我们将使用 pizzas 存储的 pizzasfetchPizzas 属性来从我们的 API 获取并显示披萨列表,如 Example 9-4。

示例 9-4. PizzasView 组件使用 pizzas 存储
<template>
  <div class="pizzas-view--container">
    <h1>Pizzas</h1>
    <ul>
      <li v-for="pizza in pizzasStore.pizzas" :key="pizza.id"> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        <PizzaCard :pizza="pizza" />
      </li>
    </ul>
  </div>
</template>
<script lang="ts" setup>
/**.... */
import { watch, type Ref } from "vue";
import { usePizzasStore } from "@/stores/pizzas";

//...
const pizzasStore = usePizzasStore(); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)

pizzasStore.fetchPizzas(); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
</script>

1

使用 pizzasStore.pizzas 渲染披萨列表。

2

pizzas 存储导入 usePizzasStore 函数,并使用它获取 pizzasStore 实例。

3

异步在组件挂载时从 API 获取披萨。

使用前面的代码,我们的 PizzasView 组件现在使用 pizzas 存储从我们的 API 获取并显示披萨列表(Figure 9-4)。

显示披萨列表的屏幕截图

Figure 9-4. PizzasView 组件使用 pizzas 存储

很好。然而,请注意,我们不再具有使用前一章节的useSearch()组合功能的搜索功能,它使用pizzasStore.pizzas作为items直接传递给useSearch()组合,将失去反应性,searchResults不会在pizzasStore.fetchPizzas()解析后重新计算。为了解决这个问题,我们使用pinia中的storeToRefs()pizzasStore中提取pizzas并保持其反应性,然后传递给useSearch()(示例 9-5)。

示例 9-5. useSearch()组合工作与披萨存储
/** src/views/PizzasView.vue */
import { useSearch } from '@/composables/useSearch';
import { storeToRefs } from 'pinia';

//...
const pizzasStore = usePizzasStore();
const { pizzas } = storeToRefs(pizzasStore);
const { search, searchResults }: PizzaSearch = useSearch({
  items: pizzas,
  defaultSearch: props.searchTerm,
});

//...

现在我们的模板使用searchResults而不是pizzasStore.pizzas,我们可以重新引入搜索input字段(示例 9-6)。

示例 9-6. PizzasView组件使用披萨存储进行搜索
<template>
  <div class="pizzas-view--container">
    <h1>Pizzas</h1>
    <input v-model="search" placeholder="Search for a pizza" />
    <ul>
      <li v-for="pizza in searchResults" :key="pizza.id">
        <PizzaCard :pizza="pizza" />
      </li>
    </ul>
  </div>
</template>

接下来,我们将创建一个购物车存储来管理当前用户的购物车数据,包括添加的项目列表。

为披萨店创建一个购物车存储

要创建我们的cart存储,我们使用以下属性定义我们的cart存储:

  • 一个已添加到购物车的items列表;每个项目包含披萨的idquantity

  • 购物车的total项目数

  • 一个add方法用于从购物车中添加项目

要创建我们的cart存储,我们添加一个新文件,src/stores/cart.ts,其代码如示例 9-7 所示。

示例 9-7. 购物车存储
import { defineStore } from 'pinia'

type CartItem = { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    id: string;
    quantity: number;
}

export const useCartStore = defineStore('cart', () => {
    const items = reactive<CartItem[]>([]); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    const total = computed(() => { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
        return items.reduce((acc, item) => {
            return acc + item.quantity
        }, 0)
    })

    const add = (item: CartItem) => { ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
        const index = items.findIndex(i => i.id === item.id)
        if (index > -1) {
            items[index].quantity += item.quantity
        } else {
            items.push(item)
        }
    }

    return {
        items,
        total,
        add
    }
})

1

定义购物车项目的类型

2

使用一个空数组来初始化items状态

3

创建一个total getter 来计算购物车中的总项目数

4

创建一个add动作来向购物车添加项目。如果项目已经在购物车中,则更新数量而不是添加新项目。

现在我们创建了cart存储,可以在我们的应用程序中使用它。

在组件中使用购物车存储

让我们创建一个新组件,src/components/Cart.vue,用于显示购物车的总项目数。在<script setup()>部分内,我们导入useCartStore()方法并调用它以获取cart实例。然后在模板中,通过使用cart.total getter 来显示购物车中的总项目数,正如示例 9-8 所示。

示例 9-8. 购物车组件
<template>
    <div class="cart">
        <span class="cart__total">Cart: {{ cart.total }}</span>
    </div>
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'

const cart = useCartStore();
</script>
<style scoped>
.cart__total {
    cursor: pointer;
    text-decoration: underline;
}
</style>

然后,当我们在App.vue中使用<Cart />组件时,我们看到购物车显示初始值为0(图 9-5)。

<!-- App.vue -->
<template>
    <header>
        <div>Pizza House</div>
        <Cart />
    </header>
    <RouterView />
</template>

应用程序中显示的购物车组件

图 9-5. 在应用程序标题中显示的购物车组件

接下来,让我们允许从我们的披萨画廊向购物车添加项目,以每个由PizzaCard渲染的披萨。

从披萨画廊添加项目到购物车

PizzaCard中,我们将添加一个按钮,并使用click事件处理程序调用cart.add()操作以将披萨添加到购物车中。PizzaCard组件将如示例 9-9 所示。

示例 9-9. PizzaCard组件
<template>
  <article class="pizza--details-wrapper">
    <img :src="pizza.image" :alt="pizza.title" height="200" width="300" />
    <p>{{ pizza.description }}</p>
    <div class="pizza--inventory">
      <div class="pizza--inventory-price">$ {{ pizza.price }}</div>
    </div>
    <button class="pizza--add" @click="addToCart">Add to cart</button> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
  </article>
</template>
<script setup lang="ts">
import { useCartStore } from "@/stores/cart";
import type { Pizza } from "@/types/Pizza";
import type { PropType } from "vue";

const props = defineProps({
  pizza: {
    type: Object as PropType<Pizza>,
    required: true,
  },
});

const cart = useCartStore(); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
const addToCart = () => {
  cart.add({ id: props.pizza.id, quantity: 1 }); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
};
</script>

1

添加一个按钮将披萨添加到购物车中。

2

使用useCartStore()方法从cart实例获取。

3

addToCart()方法内调用cart.add()操作以将披萨添加到购物车。

通过上述代码,在浏览器中,我们可以通过点击“添加到购物车”按钮向购物车中添加披萨,并查看购物车的总商品数量更新(图 9-6)。

带有添加选项和更新购物车总金额的披萨卡片

图 9-6. 带有添加选项和更新购物车总金额的披萨卡片

我们还可以使用cart.items来检测当前披萨是否已经在购物车中,并在披萨卡片上显示其状态,如示例 9-10 所示。

示例 9-10. 带有状态的PizzaCard组件
<template>
  <article class="pizza--details-wrapper">
    <!--...-->
    <div class="pizza--inventory">
      <!--...-->
      <span v-if="isInCart">In cart</span> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    </div>
    <button class="pizza--add" @click="addToCart"> Add to cart </button>
  </article>
</template>
<script setup lang="ts">
//...

const isInCart = computed(():boolean => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
  return !!cart.items.find((item) => item.id === props.pizza.id);
});
</script>

如果披萨已经在购物车中,则在披萨卡片上显示“在购物车中”的状态(图 9-7)。

带有状态的披萨卡片

图 9-7. 带有状态的披萨卡片

我们已成功创建了一个购物车存储并在 Pizza House 中使用它。CartPizzaCard组件现在通过cart存储同步和通信。

此时,Cart组件当前仅显示购物车中的总商品数量,这通常不足以让用户了解他们已添加的内容。在接下来的部分中,我们将通过在用户点击购物车时显示购物车商品来改善这一体验。

显示带有操作的购物车商品

Cart.vue中,我们将添加一个显示购物车商品列表和showCartDetails变量以控制列表可见性的部分。当用户点击购物车文本时,我们将切换列表的可见性,如示例 9-11 所示。

示例 9-11. 带有购物车商品的购物车组件
<template>
    <div class="cart">
        <span
            class="cart__total"
            @click="showCartDetails.value = !showCartDetails.value;" ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        > Cart: {{ cart.total }} </span>
        <ul class="cart__list" v-show="showCartDetails"> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
            <li v-for="item in cart.items" :key="item.id" class="cart__list-item"> ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
                <span>Id: {{ item.id }}</span> | <span>Quantity: {{ item.quantity }}</span>
            </li>
        </ul>
    </div>
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
import { ref } from 'vue'

const cart = useCartStore();
const showCartDetails = ref(false); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
</script>

1

当用户点击购物车文本时,切换显示购物车商品列表的可见性。

2

showCartDetailstrue时显示购物车商品列表。

3

循环遍历购物车商品并显示商品 ID 和数量。

4

使用ref()方法初始化showCartDetails变量。

我们还向Cart组件添加了一些 CSS 样式,以使列表的位置看起来像下拉菜单:

.cart {
    position: relative; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
}

.cart__list {
    position: absolute; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    list-style: none;
    border: 1px solid #e3e0e0;
    padding: 10px;
    inset-inline-end: 0; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
    box-shadow: 2px 2px 3px #e3e0e0; ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
    background-color: white;
    min-width: 200px;
}

1

.cart容器的位置设置为relative以使absolute列表容器在容器内浮动。

2

将列表容器的位置设置为 absolute,使其相对于 .cart 容器的 relative 位置浮动。

3

inset-inline-end 属性设置为 0,使列表容器浮动到 .cart 容器的右侧。

4

添加阴影和边框给列表容器,使其看起来像下拉框。

当我们点击购物车文本时,将显示购物车商品列表(图 9-8)。

点击购物车文本时显示的购物车商品列表

图 9-8. 点击购物车文本时显示的购物车商品列表

但等等,出现了问题。列表只显示了项目的 idquantity,这对用户理解添加的项目以及总费用不够描述。我们还需要显示项目的名称和价格。为此,我们可以修改 cart.items 以保留项目的标题和价格,但这会使 cart 存储的结构复杂化,并且需要额外的逻辑修复。

可以创建一个计算的 detailedItems 列表,借助披萨存储的帮助。

cart.ts 存储中,我们将添加一个 detailedItems 计算属性,它将是从 items 和披萨存储中的 pizzasStore.pizzas 连接的数组,如 示例 9-12 所示。

示例 9-12. 带有 detailedItems 计算属性的购物车存储
import { defineStore } from 'pinia';
import { usePizzasStore } from './pizzas';

export const useCartStore = defineStore('cart', () => {
    //... 
    const detailedItems = computed(() => {
        const pizzasStore = usePizzasStore(); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

        return items.map(item => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
            const pizza = pizzasStore.pizzas.find(
                pizza => pizza.id === item.id
            )

            const pizzaPrice = pizza?.price ? +(pizza?.price) : 0;

            return { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
                ...item,
                title: pizza?.title,
                price: pizza?.price,
                total: pizzaPrice * item.quantity
            }
        })
    })

    return {
        //...
        detailedItems ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
    }
});

1

使用 usePizzaStore 从存储获取披萨的初始列表

2

过滤显示在购物车中的相关披萨

3

格式化购物车商品信息以返回

4

返回经过筛选和格式化的 detailedItems 数组

Cart.vue 中,我们将在 v-for 循环中使用 cart.detailedItems 替换 cart.items,如 示例 9-13 所示。

示例 9-13. 使用 detailedItems 显示更多信息
<ul class="cart__list" v-show="showCartDetails">
    <li
        v-for="(item, index) in cart.detailedItems" ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        :key="item.id"
        class="cart__list-item">
        <span>{{index + 1}}. {{ item.title }}</span>
        <span>${{ item.price }}</span> x <span>{{ item.quantity }}</span>
        <span>= ${{ item.total }}</span>
    </li>
</ul>

1

迭代 cart.detailedItems 数组以显示购物车商品

现在,当我们点击购物车文本时,购物车商品列表将显示项目的名称、价格、数量和每件商品的总费用(图 9-9)。

显示更多信息的购物车商品列表

图 9-9. 显示更多信息的购物车商品列表

我们已成功显示了购物车商品的详细信息。接下来我们可以添加删除购物车商品的功能。

从购物车存储中移除商品

对于购物车列表中的每个项目,我们将添加一个 Remove 按钮以便从购物车中删除它。我们还将添加一个 Remove all 按钮以从购物车中移除所有项目。Cart.vuetemplate 部分看起来像 示例 9-14。

示例 9-14. 带有 Remove 和 Remove all 按钮的 Cart 组件
<div class="cart__list" v-show="showCartDetails">
    <div v-if="cart.total === 0">No items in cart</div>
    <div v-else>
        <ul>
            <li
                v-for="(item, index) in cart.detailedItems"
                :key="item.id" class="cart__list-item"
            >
                <span>{{index + 1}}. {{ item.title }}</span>
                <span>${{ item.price }}</span> x <span>{{ item.quantity }}</span>
                <span>= ${{ item.total }}</span>
                <button @click="cart.remove(item.id)">Remove</button> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
            </li>
        </ul>
        <button @click="cart.clear">Remove all</button> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    </div>
</div>

1

Remove 按钮绑定到 cart.remove 方法,该方法以项目的 id 作为参数

2

Remove all 按钮绑定到 cart.clear 方法

cart.ts 文件中,我们将添加 removeclear 方法,如示例 示例 9-15 所示。

示例 9-15. 带有 removeclear 方法的 Cart store
//...

export const useCartStore = defineStore('cart', () => {
    //...
    const remove = (id: string) => {
        const index = items.findIndex(item => item.id === id)
        if (index > -1) {
            items.splice(index, 1)
        }
    }

    const clear = () => {
        items.length = 0
    }

    return {
        //...
        remove,
        clear
    }
})

就是这样!当我们点击 Remove 按钮时,Vue 会从购物车中移除该项目。当我们点击 Remove all 按钮时,它将清空购物车;参见 图 9-10。

带有 Remove 和 Remove all 按钮的购物车项目

图 9-10. 带有 Remove 和 Remove all 按钮的购物车项目
注意

如果您正在使用 Options API 构建 cart store,您可以使用 cart.$reset() 将 store 的状态重置为初始状态。否则,您必须手动重置 store 的状态,就像在 clear 方法中所做的那样。

我们还可以使用浏览器开发工具中的 Vue Devtool 选项卡(“Vue Developer Tools”)检查 cart store 的状态和 getters。cartpizzas store 将在 Pinia 选项卡下列出(见 图 9-11)。

购物车和披萨 store 在 Vue Devtools 中

图 9-11. Vue Devtools 中的购物车和披萨 store

我们已经探讨了如何使用 Pinia 和 Composition API 构建 store。我们还探讨了不同的方法,如合并 store 和在外部可组合中使用 store 的状态。那么测试 Pinia stores 呢?让我们在下一节中探索这个问题。

单元测试 Pinia Stores

单元测试一个 store 类似于普通单元测试一个函数。对于 Pinia,在运行实际测试之前,我们需要使用 pinia 包中的 createPinia 方法创建一个 Pinia 实例,并使用 setActivePinia() 方法激活它。示例 9-16 展示了如何编写测试,将一个项目添加到我们的 cart store 中。

示例 9-16. 用于添加项目的 Cart store 测试套件
import { setActivePinia, createPinia } from 'pinia';
import { useCartStore } from '@/stores/cart';

describe('Cart store', () => {
    let cartStore;

    beforeEach(() => { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        setActivePinia(createPinia());
        cartStore = useCartStore();
    });

    it('should add item to cart', () => {
        cartStore.add({ id: '1', quantity: 1 });
        expect(cartStore.items).toEqual([{ id: '1', quantity: 1 }]);
    });
});

1

在每次测试运行之前,我们会创建并激活一个新的 Pinia 实例。

此代码遵循 Jest 和 Vitest 测试框架支持的常见测试语法。我们将进一步探讨如何编写和运行单元测试的细节,在 “Vitest 作为单元测试工具” 中详细介绍。目前,我们将探讨如何订阅 store 的更改并为 store 操作添加副作用。

订阅 store 更改的副作用

Pinia 的一个重要优势是可以通过插件扩展存储的功能并实现副作用。借助此功能,我们可以轻松订阅所有存储或特定存储的变化,以执行诸如在需要时与服务器同步数据等其他操作。

cartPlugin为例:

//main.ts
import { cartPlugin } from '@/plugins/cartPlugin'
//...

const pinia = createPinia()
pinia.use(cartPlugin)

app.use(pinia)
//...

cartPlugin是一个函数,接收一个包含对app实例、pinia实例、store实例和一个选项对象的引用的对象。Vue 将为我们应用程序中的每个存储触发此函数一次。为了确保我们仅订阅cart存储,我们可以检查存储的 id(见示例 9-17)。

示例 9-17. 购物车插件
//src/plugins/cartPlugin.ts
export const cartPlugin = ({ store}) => {
    if (store.$id === 'cart') {
        //...
    }
}

然后,我们可以使用store.$subscribe方法订阅购物车存储的变化,如示例 9-18。

示例 9-18. 购物车插件订阅存储变化
//src/plugins/cartPlugin.ts
export const cartPlugin = ({ store}) => {
    if (store.$id === 'cart') {
        store.$subscribe((options) => {
            console.log('cart changed', options)
        })
    }
}

当我们向购物车添加物品时,cartPlugin会在控制台记录消息(图 9-12)。

购物车插件记录存储变化

图 9-12. 使用插件记录存储变化

$subscribe方法接收的options对象包含events对象,其中包含当前事件类型(add)、前一个值(oldValue)、传递给事件的当前值(newValue)、storeId以及事件类型(direct)。

类似地,我们可以使用store.$onActioncart存储的add操作添加副作用(见示例 9-19)。

示例 9-19. 购物车插件订阅存储的添加操作
//src/plugins/cartPlugin.ts

export const cartPlugin = ({ store}) => {
    if (store.$id === 'cart') {
        store.$onAction(({ name, args }) => {
            if (name === 'add') {
                console.log('item added to cart', args)
            }
        })
    }
}

当我们向购物车添加物品时,cartPlugin会记录添加到购物车的新物品(图 9-13)。

购物车插件记录存储的添加操作

图 9-13. 购物车插件记录存储的添加操作

使用$subscribe$onAction,我们可以添加诸如记录日志和与外部 API 服务通信(例如在服务器上更新用户购物车等)的副作用。此外,如果在同一个插件中同时使用$onAction$subscribe,Vue 将首先触发$onAction,然后是相关的$subscribe

使用副作用

需要注意的是,Vue 会触发我们添加到存储的每个副作用。例如,对于示例 9-19,Vue 将为存储中执行的每个操作激活副作用函数。因此,在向存储添加副作用时,我们必须非常谨慎,以避免性能问题。

摘要

在本章中,我们学习了如何使用 Pinia 构建存储,并在应用程序中利用组合 API 使用它们。我们还学习了如何解构和传递存储的状态给外部的可组合函数,利用响应性订阅存储的变化,并为存储操作添加副作用。现在,您已经准备好创建完整的数据流,从构建集中式数据存储,到在不同组件中使用它,并通过存储在组件之间建立连接。

下一章将探讨 Vue 的另一个方面,即如何通过添加动画和过渡来增强用户体验。

^(1) Vuex 曾是 Vue 应用程序的官方状态管理工具。

第十章:Vue 中的过渡和动画

我们已经探讨了构建工作中 Vue 应用的所有关键方面,包括使用适当的状态管理处理路由和数据流。本章将探讨增强用户体验的独特 Vue 功能:动画和过渡,使用过渡组件、钩子和 CSS。

理解 CSS 过渡和 CSS 动画

CSS 动画是在特定元素或组件状态变化时的视觉效果,状态数量不限。CSS 动画可以自动启动并循环播放,无需显式触发。相比之下,CSS 过渡是仅响应于两个状态之间的动画,例如按钮从普通状态到悬停状态或提示框从隐藏到可见状态。通常使用@keyframes规则定义 CSS 动画,然后通过animation属性将其应用于目标元素。例如,我们可以为按钮定义一个简单的动画效果:

@keyframes pulse {
  0% {
    box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.5);
  }
  100% {
    box-shadow: 0 0 0 20px rgba(0, 0, 0, 0);
  }
}

.button {
  animation: pulse 2s infinite;
  box-shadow: 0px 0px 1px 1px #0000001a;
}

我们定义了一个简单的动画效果pulse,并将其应用于所有带有button类的元素,其中阴影框将在循环中扩展和收缩,持续两秒。如果元素存在于 DOM 中,此效果将无限循环运行。

屏幕截图显示具有脉冲效果的按钮

图 10-1. 无限脉冲动画效果

与此同时,我们可以使用transition属性为用户悬停在特定元素上时定义一个过渡效果:

.button {
  transition: background-color 0.5s ease-in-out;
}

.button:hover {
  background-color: #ff0000;
}

在此代码中,我们为button元素创建了一个简单的过渡效果:背景颜色在悬停时从默认颜色变为红色,延迟0.5秒,平滑效果为ease-in-out。此外,我们还可以使用 JavaScript 和其他动画库通过 JavaScript 编程方式定义过渡和动画。

当使用应用程序时,过渡和动画可以显著提供用户更流畅的体验。然而,处理过渡和动画有时可能具有挑战性。作为专注于视图层的框架,Vue 提供了一组 API 来帮助我们更简单地为组件和路由创建平滑、美观的动画和过渡效果,无论是使用 CSS 还是 JavaScript。其中之一是transition组件,我们将在接下来的部分中讨论它。

Vue.js 中的过渡组件

transition组件是一个包装组件,允许我们为单个元素创建过渡,具有两个可用的过渡状态:进入和离开。组件提供一个name作为所需过渡效果的名称属性。Vue 将计算相关的过渡类,name作为其前缀,过渡状态(toactivefrom)作为其后缀,如下所示:

<name>-[enter | leave]-<transition-direction-state>

例如,我们可以在元素上使用slidein过渡效果:

<transition name="slidein">
    <ul class="pizza-list">
        /** code for rendering pizza's card... */
    </ul>
  </transition>

Vue 将生成一组类,这些类在表 10-1 中描述。

表 10-1. 用于滑入过渡效果的生成过渡类

描述
.slidein-enter-from 定义进入过渡的起始状态的类选择器
.slidein-enter-active 定义元素在进入过渡期间的持续时间和延迟的类选择器
.slidein-enter-to 定义进入过渡的结束状态的类选择器
slidein-leave-from 定义离开过渡的起始状态的类选择器
slidein-leave-to 定义离开过渡的结束状态的类选择器
slidein-leave-active 定义元素在离开过渡期间的持续时间和延迟的类选择器

进入状态 表示元素开始过渡到浏览器显示的可见模式,而 离开状态 则表示相反。我们可以与 v-show 结合,它切换元素的 CSS display 属性,或者 v-if 属性,有条件地将片段插入到 DOM 中。我们将 v-show 添加到代码示例中 ul 组件:

<transition name="slidein">
    <ul class="pizza-list" v-show="showList">
        /** code for rendering pizza's card... */
    </ul>
</transition>

现在,我们可以使用先前的类来定义名为 slidein 的过渡,使用 CSS 的 transition 属性以及目标 CSS 属性或属性来执行效果。

下面是滑入过渡效果的示例实现:

.slidein-enter-to {
  transform: translateX(0);
}

.slidein-enter-from {
  transform: translateX(-100%);
}

.slidein-leave-to {
  transform: translateX(100%);
}

.slidein-leave-from {
  transform: translateX(0);
}

.slidein-enter-active,
.slidein-leave-active {
  transition: transform 0.5s;
}

在此代码中,在进入过渡之前,浏览器将通过 slidein-enter-toul 元素水平重新定位到视口的左侧,然后通过 translateX(0) 将其移回正确位置。离开过渡也是同样的过程,只是元素将向右侧的视口移动而不是左侧。这两个变化都将在 transform 属性上进行,持续时间为 0.5 秒,如 slidein-enter-activeslidein-leave-active 类所述。

要查看效果,请添加一个小的超时来改变 searchResults 数据属性的值:

import { ref } from "vue";

const showList = ref(false);

setTimeout(() => {
  showList.value = true;
}, 1000);

Vue 引擎会根据需要添加或删除每个类。我们再次添加一个超时来将 showList 的值改回 false,Vue 将再次触发过渡效果,但这次是离开状态(图 10-2)。

我们使用 transition 组件实现了一个简单的效果,只有一个 slidein 的影响。如何结合不同的效果,例如进入状态使用 slidein,离开状态使用 rotate?对于这种情况,我们使用自定义过渡类属性,在下一节将会讨论。

屏幕截图显示浏览器为一系列元素列表执行的一些过渡效果

图 10-2. 当 showListtrue 时,披萨列表的过渡效果

使用自定义过渡类属性

除了根据name属性自动生成类之外,Vue 还允许我们使用以下相关属性为每个过渡类指定自定义类名:enter-classenter-active-classenter-to-classleave-classleave-active-classleave-to-class。例如,我们可以定义离开状态时rotate过渡效果的自定义类:

<transition name="slidein" leave-active-class="rotate">
    <ul class="pizza-list" v-show="showList">
        /** code for rendering pizza's card... */
    </ul>
</transition>

style部分,我们使用@keyframes控制定义了rotate过渡的动画效果,关键帧偏移为 0%,50%,90%和 100%。

@keyframes rotate {
  0% {
    transform: rotate(0);
  }
  50% {
    transform: rotate(45deg);
  }
  90% {
    transform: rotate(90deg);
  }
  100% {
    transform: rotate(180deg);
  }
}

然后,我们可以将动画效果rotate分配给rotate类的animation属性,持续时间为 0.5 秒:

.rotate {
  animation: rotate 0.5s;
}

让我们将showList的初始值设置为true,并设置一个1000毫秒的超时将其更改为false。虽然ul元素的进入效果仍然是slidein,但离开时的效果现在是从 45 度开始旋转的动画,然后是 90 度,最后是 180 度。参见图 10-3 进行说明。

import { ref } from "vue";

const showList = ref(true);

setTimeout(() => {
  showList.value = false;
}, 1000);

一个截图显示浏览器为一组元素执行的某些旋转效果

图 10-3. 使用关键帧进行过渡的旋转效果

您可以将多个类分隔以单个空格的方式分配给这些属性,以应用于特定过渡状态的各种效果。当您希望将来自外部 CSS 库(如 Bootstrap、Tailwind CSS 或 Bulma)的动画集成到 Vue 中时,此功能非常有用。

我们的组件现在在切换showList值时具有过渡效果。然而,我们经常希望在页面加载后元素首次出现时进行动画处理,而不需要额外的交互。为此,我们可以使用appear属性。

在初始渲染时使用appear添加过渡效果

当我们在transition元素上将appear属性设置为true时,Vue 将在组件挂载到 DOM 时自动添加enter-activeenter-to类,触发过渡效果。例如,要在ul组件的初始渲染时应用slidein效果,我们只需在transition元素上添加appear属性即可:

<transition name="slidein" appear>
    <ul class="pizza-list">
        /** code for rendering pizza's card... */
    </ul>
</transition>

现在,浏览器将在 UI 的初始出现时应用ul元素的slidein效果。

我们已经学习了如何使用transition组件为单个元素创建平滑的过渡效果。然而,当我们希望同时和有序地对多个部分进行动画化时,这个组件就不够用了。为此,我们有transition-group,接下来我们将讨论它。

构建一组元素的过渡效果

transition-group组件是transition的特殊版本,旨在为一组元素提供动画支持。它接受与transition相同的 props,在我们想要为列表中的每个项(如披萨或用户列表)添加动画时非常有用。然而,与transition元素不同,transition-group支持使用tag属性渲染包装元素,并且所有子元素将接收相同的过渡类,但不包括包装器(如果存在)。

以披萨列表为例。我们可以使用transition-group为屏幕上出现的每个披萨卡片添加fadein效果,并将卡片包裹在ul元素下:

<transition-group name="fadein" tag="ul" appear>
    <li v-for="pizza in searchResults" :key="pizza.id">
        <PizzaCard :pizza="pizza" />
    </li>
</transition-group>

使用key属性

您必须在每个列表元素上使用key属性,以便 Vue 跟踪列表中的变化,并相应地应用过渡效果。

Vue 会为我们定义的每个li元素添加相关的类名fadein-enter-activefadein-enter-tofadein-leave-activefadein-leave-to,这些类名是根据以下 CSS 规则定义的:

.fadein-enter-active,
.fadein-leave-active {
  transition: all 2s;
}

.fadein-enter-from,
.fadein-leave-to {
  opacity: 0;
  transform: translateX(20px);
}

现在,我们的列表中的每个披萨卡片都会在首次加载组件时以淡出效果和从右侧轻微滑入的过渡效果出现。每当我们使用搜索框筛选列表时,新卡片将以相同的效果出现,而旧卡片则会以相反的效果消失:淡出并向右滑出(参见图 10-4)。

屏幕截图显示在键入搜索词时产品淡出的效果

图 10-4. 列表搜索时的淡出效果

添加更多移动效果

您还可以使用<effect>-move类(例如fadein-move)为移动的项目添加更多效果。在列表中物品移动时,这种解决方案可能更加平滑。

到目前为止,一切顺利。我们已经探讨了如何使用transitiontransition-group组件。接下来的步骤是学习如何将这些组件与router-view元素结合起来,在路由之间导航时创建平滑的过渡效果。

创建路由过渡效果

从 Vue Router 4.0 开始,我们不再可以用transition元素包裹router-view组件。相反,我们结合了由router-view暴露的Component属性和动态component的使用,如下面的代码所示:

<router-view v-slot="{ Component }">
    <transition name="slidein">
        <component :is="Component" />
    </transition>
</router-view>

Component属性指的是 Vue 在router-view占位符所在位置渲染的目标组件。然后,我们可以使用component元素动态生成组件,并用transition元素包裹它以应用slidein效果。通过这样做,无论何时我们导航到不同的路由,都会有动画效果:页面进入时滑入,页面离开时滑出。

但这里有一个小问题。当我们导航到不同的路由时,请注意新页面的内容可能会在前一页的内容完成离开动画并消失之前出现。在这种情况下,我们可以使用mode属性,其值为out-in,以确保新内容只有在前一个内容完全从屏幕上消失后才进入并开始动画:

<router-view v-slot="{ Component }">
    <transition name="slidein" mode="out-in">
        <component :is="Component" />
    </transition>
</router-view>

现在,每当我们导航到不同的路由时,例如从//about,关于视图只有在主页视图消失后才会出现。

到目前为止,我们已经探讨了如何使用name和自定义过渡类来创建过渡效果。虽然这些在大多数情况下足以为我们的应用程序创建具有自定义动画类的平滑过渡效果,但在其他情况下,我们可能会发现需要使用第三方 JavaScript 动画库来获得更好的过渡效果。对于这种情况,我们需要一种不同的方法,允许我们使用 JavaScript 插入自定义动画控制。我们将在下一节中学习如何做到这一点。

使用过渡事件控制动画

与自定义类不同,Vue 为过渡组件提供了一些适当的过渡事件来发出。这些事件包括元素进入状态的before-enterenterafter-enterenter-cancelled,以及离开状态的before-leaveleaveafter-leaveleave-cancelled。我们可以将这些事件绑定到所需的回调函数上,并使用 JavaScript 控制过渡效果。

例如,我们可以使用before-enterenterafterEnter事件来控制页面过渡中slidein效果的动画:

<router-view v-slot="{ Component }">
    <transition
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    :css="false"
    >
        <component :is="Component" />
    </transition>
</router-view>

使用css属性

在使用回调函数方法时,我们可以使用css属性来禁用默认的 CSS 过渡类和任何可能的重叠。

script部分,我们可以为每个事件定义回调函数:

import { gsap } from 'gsap'

const beforeEnter = (el: HTMLElement) => {
  el.style.transform = "translateX(20px)";
  el.style.opacity = "0";
};

const enter = (el: HTMLElement, done: gsap.Callback) => {
  gsap.to(el, {
    duration: 1,
    x: 0,
    opacity: 1,
    onComplete: done,
  });
};

const afterEnter = (el: HTMLElement) => {
  el.style.transform = "";
  el.style.opacity = "";
};

在此代码中,我们使用gsap(GreenSock 动画平台)库来在元素进入 DOM 时对其进行动画处理。我们定义如下:

beforeEnter

回调函数来设置元素的初始状态,包括将opacity设置为隐藏并将元素重新定位到原点 20px 处

enter

回调函数以使用gsap.to函数来动画元素^(1)

afterEnter

回调函数来设置元素的可见状态和动画完成后的位置

类似地,我们可以使用before-leaveleaveafter-leave事件来在元素离开 DOM 时(如滑出效果)使用所选的动画库进行动画。

总结

在本章中,我们学习了如何使用过渡组件和可用的钩子来创建从一个路由到另一个路由的平滑过渡效果。我们还学习了如何创建组过渡,并使用过渡组件来在段落内动画化元素。

在下一章中,我们将探讨 Web 开发的另一个重要方面:测试。我们将学习如何使用 Vitest 测试组合组件,使用 Vue Test Utils 库测试组件,然后为我们的应用程序开发完整的端到端测试计划。

^(1) 此函数接收一个目标元素和可选的包含所有动画属性的对象。详见 https://oreil.ly/XNgFb

第十一章:Vue 中的测试

到目前为止,我们已经学习了如何从头开始开发完整的 Vue 应用程序,使用不同的 Vue API。我们的应用程序现在已准备部署,但在此之前,我们需要确保我们的应用程序没有 bug,并且可以投入生产。这就是测试发挥作用的地方。

测试对于任何应用程序开发都至关重要,因为它有助于在发布到生产环境之前增强代码的信心和质量。在本章中,我们将学习不同类型的测试以及如何在 Vue 应用程序中使用它们。我们还将探讨各种工具,如 Vitest 和 Vue Test Utils,用于单元测试,以及 PlaywrightJS 用于端到端(E2E)测试。

单元测试和端到端测试简介

软件开发中有手动和自动化测试实践和技术,以确保您的应用程序按预期工作。手动测试要求测试人员手动与软件交互,可能成本较高,而自动化测试主要是执行包含一组测试的预定义测试脚本的自动化方式。自动化测试集合可以验证从单个函数到不同部分的简单到复杂的应用程序场景。

自动化测试比手动测试更可靠和可扩展,假设我们正确编写了测试,并执行以下测试过程:

单元测试

软件开发中最常见且最低级别的测试。我们使用单元测试来验证执行特定操作的代码单元(或代码块),如函数、钩子和模块。我们可以将单元测试与测试驱动开发(TDD)^(1)结合使用作为标准开发实践。

集成测试

此测试类型验证不同代码单元块的集成。集成测试旨在断言逻辑功能、组件或模块的流程。组件测试将内部逻辑与单元测试集成测试。我们还会模拟大多数上游服务和测试范围外的其他函数,以确保测试质量。

端到端(E2E)测试

软件开发中的最高级别测试。我们使用端到端测试来验证整个应用程序流程,从客户端到后端,通常通过模拟实际用户行为来完成。在端到端测试中不会有任何模拟服务或函数,因为我们希望测试整个应用程序流程。

注意

测试驱动开发(TDD)意味着你首先设计并编写测试用例(红色阶段),然后编写代码以通过测试(绿色阶段),最后改进代码实现(重构阶段)。这有助于在实际开发之前验证逻辑和设计。

这三种测试类型构成了一个测试金字塔,如 图 11-1 所示,其中重点应主要放在单元测试上,然后是集成测试,最后才是 E2E 测试,因为后者主要用于健全性检查,并且触发成本较高。由于我们从各种组件、服务和模块创建应用程序,因此对每个独立函数或特性进行单元测试足以以最低成本和工作量保持代码库的质量。

作为我们应用程序中测试系统的主要基础,我们从使用 Vitest 进行单元测试开始。

显示一个金字塔截图,分为三个层级,分别代表单元测试、集成测试和端到端(E2E)测试

图 11-1. 测试金字塔

Vitest 作为单元测试工具

Vitest 是基于 Vite 构建的用于 Vite 项目的单元测试运行器。其 API 类似于 Jest 和 Chai,同时提供更模块化的测试方法。Vitest 专注于速度和开发者体验,提供多线程工作器、支持 TypeScript 和 JSX,以及针对 Vue 和 React 等框架的组件测试等显著特性。

要使用 Vitest,我们需要将其安装为项目中的开发依赖项。

yarn add -D vitest

然后在 package.json 文件中,我们可以添加一个新的脚本命令以在观察模式下运行我们的测试:

"script": {
    "test": "vitest"
}
注意

或者,在 Vue 项目初始化期间,我们可以选择将 Vitest 安装为单元测试工具(“创建新的 Vue 应用程序”),Vite 将处理其余内容,包括一些示例测试作为起始。

当我们在终端(或命令行)中运行 yarn test 命令时,Vitest 将自动检测项目目录中文件名包含 .spec..test. 模式的测试文件。例如,useFetch 钩子的测试文件可以是 useFetch.spec.tsuseFetch.test.ts。每当您更改任何测试文件时,Vitest 将在本地环境中重新运行测试。

使用 vitest 与额外命令

您可以指定 vitest 命令的模式,例如显式指定观察模式的 vitest watch,或一次性运行所有测试的 vitest run。在持续集成(CI)环境中,仅使用 vitest 命令时,Vite 将自动切换到单次运行模式。

在接下来的部分,我们可以通过命令参数或 Vite 配置文件 vite.config.js 进一步定制 Vitest 的设置。

使用参数和配置文件配置 Vitest

默认情况下,Vitest 将从项目文件夹作为当前目录开始扫描测试。我们可以通过将文件夹路径作为测试命令的参数传递,例如在源 src 目录中的 tests 文件夹:

"script": {
    "test": "vitest --root src/tests"
}
注意

在本章中,我们将把我们的测试放在tests文件夹下,测试文件名采用<test-file-name.test>.ts的约定(例如myComponent.test.ts)。

我们还可以通过将文件路径作为yarn test命令的参数来指定要运行的测试文件:

yarn test src/tests/useFetch.test.ts

当您在文件上工作并希望为该测试文件启用独占的观察模式时,此命令非常方便。

我们还需要将environment参数设置为jsdom(JSDOM^(2)作为我们 Vue 项目的 DOM 环境运行器:

"script": {
    "test": "vitest --root src/tests --environment jsdom"
}

如果不设置环境,Vitest 将使用默认环境node,这对于测试 UI 组件和交互是不合适的。

与其使用命令参数,我们也可以修改vite.config.js文件来配置我们的 Vitest 运行器,使用字段test和相关属性rootenvironment

export default defineConfig({
  /**other settings */
  test: {
    environment: 'jsdom',
    root: 'src/tests
  }
})

您还需要通过在vite.config.ts文件顶部添加以下行来使用<reference>标签将 Vitest 的引用添加到此文件中:

/// <reference types="vitest" />

因此,Vite 将知道我们正在使用 Vitest 作为测试运行器,并将为 TypeScript 类型检查配置文件中的test字段提供相关的类型定义。

我们还可以在整个项目中为 Vitest API 打开全局模式,因此我们不需要在测试文件中显式导入任何来自vitest包的函数。我们可以通过在vite.config.ts中的test对象的globals标志来实现这一点:

/// <reference types="vitest" />
/*...imports...*/

export default defineConfig({
  /**other settings */
  test: {
    environment: 'jsdom',
    root: 'src/tests
    globals: true,
  }
})

一旦启用了globals,为了使 TypeScript 能够检测到 Vitest API 作为全局的可用性,我们还需要执行一个步骤:将vitest/globals类型定义添加到tsconfig.json文件中的types数组中。

//tsconfig.json
"compilerOptions": {
  "types": ["vitest/globals"]
}

有了这些设置,我们现在可以开始编写我们的测试了。

编写您的第一个测试

遵循 TDD 方法,让我们从一个简单的测试开始,检查基于给定字符串和数组元素属性键来过滤数组的函数是否按预期工作。

我们将在src/tests文件夹中创建一个名为filterArray.test.ts的新文件,另一个文件filterArray.tssrc/utils文件夹中。filterArray.ts应该导出一个名为filterArray的函数,该函数接受三个参数(要过滤的原始数组类型为ArrayObject,一个string属性键,以及要过滤的string术语),并返回类型为ArrayObject的过滤元素:

type ArrayObject = { [key: string]: string };

export function filterArray(
  array: ArrayObject[],
  key: string,
  term: string
): ArrayObject[] {
  // code to filter the array
  return [];
}
注意

{ [key: string]: string }是一个具有string键和string值的对象类型。指定使用类型而不是通用的Object(类似于使用any)来避免将错误的对象类型传递给函数的潜在错误。

filterArray.test.ts文件中,我们将导入filterArray函数并对其功能进行建模。我们将使用@vitest包中的it()方法和expect()方法分别定义单个测试用例和断言预期结果:

import { it, expect } from '@vitest'
import { filterArray } from '../utils/filterArray'

it('should return a filtered array', () => {
  expect()
})
注意

如果我们在vite.config.ts文件中的globals设置为true或使用命令行的--globals参数,则可以删除import { it, expect } from *@vitest*行。

it()方法接受一个字符串,表示测试用例的名称(should return a filtered array),一个包含要运行的测试逻辑的函数,以及一个可选的超时时间,用于等待测试完成。默认情况下,测试的超时时间为五秒。

现在我们可以为我们的第一个测试用例实现测试逻辑了。我们还假设我们有一个需要按包含Hawaiiantitle来过滤的比萨列表:

import { it, expect } from '@vitest'
import { filterArray } from '../utils/filterArray'

const pizzas = [
  {
    id: "1",
    title: "Pina Colada Pizza",
    price: "10.00",
    description:
      "A delicious combination of pineapple, coconut, and coconut milk.",
    quantity: 1,
  },
  {
    id: "4",
    title: "Hawaiian Pizza",
    price: "11.00",
    description:
      "A delicious combination of ham, pineapple, and pineapple.",
    quantity: 5,
  },
  {
    id: "5",
    title: "Meat Lovers Pizza",
    price: "13.00",
    description:
      "A delicious combination of pepperoni, sausage, and bacon.",
    quantity: 3,
  },
]

it('should return a filtered array', () => {
  expect(filterArray(pizzas, 'title', 'Hawaiian'))
})

expect()返回一个测试实例,具有各种修改器,如notresolvesrejects,以及匹配器函数如toEqualtoBe。而toEqual在目标对象上执行深度比较以验证相等性,toBe则额外检查目标值在内存中的实例引用。在大多数情况下,使用toEqual已经足够验证逻辑,比如检查返回的值是否与我们期望的数组匹配。我们将定义我们的目标result数组如下:

const result = [
  {
    id: "4",
    title: "Hawaiian Pizza",
    price: "11.00",
    description:
      "A delicious combination of ham, pineapple, and pineapple.",
    quantity: 5,
  },
]

让我们修改我们的pizzas,以确保在将其传递给filterArray函数之前包含result的元素:

const pizzas = [
  {
    id: "1",
    title: "Pina Colada Pizza",
    price: "10.00",
    description:
      "A delicious combination of pineapple, coconut, and coconut milk.",
    quantity: 1,
  },
  {
    id: "5",
    title: "Meat Lovers Pizza",
    price: "13.00",
    description:
      "A delicious combination of pepperoni, sausage, and bacon.",
    quantity: 3,
  },
  ...result
]

然后我们使用.toEqual()来断言预期结果:

it('should return a filtered array', () => {
  expect(filterArray(pizzas, 'title', 'Hawaiian')).toEqual(result)
})

让我们使用yarn test命令以观察模式运行测试。测试将失败,Vitest 将显示失败的详细信息,包括预期结果和实际结果,如图 11-2 所示。

显示每个测试的堆栈跟踪和失败测试的详细信息的截图

图 11-2. 测试失败详细信息

TDD 方法的一部分是在实现实际代码之前定义测试并观察其失败。下一步是修改filterArray函数以使用最少的代码使测试通过。

这里是使用filter()toLowerCase()实现filterArray的示例:

type ArrayObject = { [key: string]: string };

export function filterArray(
  array: ArrayObject[],
  key: string,
  term: string
): ArrayObject[] {
  const filterTerm = term.toLowerCase();

  return array.filter(
    (item) => item[key].toLowerCase().includes(filterTerm)
  );
}

使用这段代码,我们的测试应该通过(图 11-3)。

显示每个测试的堆栈跟踪和失败测试的详细信息的截图

图 11-3. 测试通过

此时,您可以创建更多的测试来覆盖函数场景的其余部分。例如,当数组元素中的键不存在(item[key]undefined)或term不区分大小写时:

it("should return a empty array when key doesn't exist", () => {
  expect(filterArray(pizzas, 'name', 'Hawaiian')).toEqual([])
})

it('should return matching array when term is upper-cased', () => {
  expect(filterArray(pizzas, 'name', 'HAWAIIAN')).toEqual(result)
})

在终端中,您将看到以扁平顺序显示的测试,带有相关名称(图 11-4)。

显示每个测试的堆栈跟踪和失败测试的详细信息的截图

图 11-4. 以扁平顺序显示的测试

随着文件中的测试数量和测试文件数量的增加,扁平顺序可能会变得难以阅读和理解。为了使每个功能单元可读,可以使用describe()将测试分组到逻辑块中,每个块具有适当的块名称:

describe('filterArray', () => {
  it('should return a filtered array', () => {
    expect(filterArray(pizzas, 'title', 'Hawaiian')).toEqual(result)
  })
  it(`should return a empty array when key doesn't exist`, () => {
    expect(filterArray(pizzas, 'name', 'Hawaiian')).toEqual([])
  })

  it('should return matching array when term is upper-cased', () => {
    expect(filterArray(pizzas, 'name', 'HAWAIIAN')).toEqual(result)
  })
})

Vitest 将以更有组织的层次结构显示测试,如图 11-5 所示。

显示每个测试的堆栈跟踪和失败测试的详细信息的屏幕截图

图 11-5. 每组测试的显示
注意

我们可以将pizzasresult移至describe块内部。这样做可以确保这些变量的作用域仅在filterArray测试组内相关。否则,一旦这个测试套件运行,这两个变量将在全局测试作用域中可用,并且可能与具有相同名称的其他变量重叠,导致不希望的行为。

到目前为止,我们已经学会了如何使用 TDD 方法编写函数测试,使用it()expect()编写测试用例,并使用describe()分组。虽然 TDD 在我们理解函数的所有期望场景时非常方便,但对于初学者来说,要适应和遵循这种方法可能有挑战性。考虑结合 TDD 和其他方法,而不是单一过程。

注意

您还可以使用test()代替it()assert()expect()作为它们的替代品。虽然它的名称应以“should do something”开头,表示一个连贯的句子(例如“它应返回一个过滤后的数组”),但test可以是任何有意义的名称。

由于 Vue 中的组合式是使用 Vue 的组合 API 的 JavaScript 函数,因此使用 Vitest 测试它们非常简单。接下来,我们将探讨如何为组合式编写测试,从非生命周期函数开始。

测试非生命周期的组合函数

我们将从一个组合函数useFilter开始,该函数返回一个包含以下变量的对象:

filterBy

过滤的关键

filterTerm

用于过滤的术语

filteredArray

过滤后的数组

order

过滤数组的顺序,默认值为asc

它接受一个响应式数组arr,一个过滤键key和一个过滤术语term作为过滤数组、过滤键和过滤术语的初始值。

useFilter的实现如下:

/** composables/useFilter.ts */
import { ref, computed, type Ref } from 'vue'

type ArrayObject = { [key: string]: string };

export function useFilter(
  arr: Ref<ArrayObject[]>,
  key: string,
  term: string
) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
  const filterBy = ref(key) ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
  const filterTerm = ref(term)
  const order = ref('asc')

  const filteredArray = computed(() => ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
    arr.value.filter((item) =>
      item[filterBy.value]?.toLowerCase().includes(
        filterTerm.value.toLowerCase())
    ).sort((a, b) => {
      if (order.value === 'asc') {
        return a[filterBy.value] > b[filterBy.value] ? 1 : -1
      } else {
        return a[filterBy.value] < b[filterBy.value] ? 1 : -1
      }
    })
  );

  return {
    filterBy,
    filterTerm,
    filteredArray,
    order,
  }
}

1

arr声明为响应式Ref类型的ArrayObject,并将keyterm声明为string类型

2

创建filterByfilterTermorder作为具有初始值的ref()

3

filteredArray创建为computed(),以反应filterByfilterTermorderarr的更改

tests/文件夹中,我们创建一个名为useFilter.test.ts的文件来测试useFilter,具体设置如下:

import { useFilter } from '@/composables/useFilter'

const books = [
  {
    id: '1',
    title: 'Gone with the wind',
    author: 'Margaret Mitchell',
    description:
    'A novel set in the American South during the Civil War and Reconstruction',
  },
  {
    id: '2',
    title: 'The Great Gatsby',
    description:
      'The story primarily concerns the mysterious millionaire Jay Gatsby',
    author: 'F. Scott Fitzgerald',
  },
  {
    id: '3',
    title: 'Little women',
    description: 'The March sisters live and grow in post-Civil War America',
    author: 'Louisa May Alcott',
  },
]

describe('useFilter', () => {
})

由于books是一个常量数组,而不是 Vue 响应式对象,在我们的测试案例中,我们将在将其传递给函数进行测试之前,用ref()包装它以启用其响应性:

import { useFilter } from '@/composables/useFilter'
import { ref } from 'vue'

const books = ref([
  //...
]);

const result = [books.value[0]]

我们还根据books数组的值声明了预期的result。现在我们可以编写我们的第一个响应性测试用例,在更改filterTerm时,断言useFilter函数返回更新后的过滤数组:

it(
  'should reactively return the filtered array when filterTerm is changed',
  () => {
  const { filteredArray, filterTerm } = useFilter(books, 'title', '');

  filterTerm.value = books.value[0].title;
  expect(filteredArray.value).toEqual(result);
})

当我们运行测试时,输出应如 图 11-6 所示,测试应该通过。

+useFilter+的测试通过截图

图 11-6。所有针对useFilter的测试都通过了。

我们可以继续编写针对filterByorder的测试用例,并完全覆盖useFilter。在此useFilter示例中,我们断言了一个使用底层refcomputed的可组合体。我们可以将相同的断言实践应用于具有类似 API 的可组合体,如watchreactiveprovide等。然而,对于使用onMountedonUpdatedonUnmounted等的组合件,我们使用不同的方法来测试它们,下面讨论。

使用生命周期钩子测试组合件

下面的可组合体useFetch使用onMounted从 API 中获取数据。

/** composables/useFetch.ts */
import { ref, onMounted } from 'vue'

export function useFetch(url: string) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  const fetchData = async () => {
    try {
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(`Failed to fetch data for ${url}`);
      }

      data.value = await response.json();
    } catch (err: any) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  };

  onBeforeMount(fetchData);

  return { data, error, loading }
}

该函数接收一个url参数;在挂载组件之前从给定的url获取数据;根据情况更新数据、错误和加载值;然后返回它们。由于这个可组合依赖于组件生命周期的onBeforeMount来获取数据,我们必须创建一个 Vue 组件并模拟挂载过程来测试它。

我们可以通过使用vue包中的createApp并创建一个在其setup钩子中使用useFetch的组件/应用程序来实现这一点:

/** tests/useFetch.test.ts */
import { createApp, type App } from 'vue'

function withSetup(composable: Function): [any, App<Element>] {
    let result;

    const app = createApp({
        setup() {
            result = composable();
            return () => {};
        },
    });

    app.mount(document.createElement("div"));

    return [result, app];
}

withSetup函数接受一个composable并返回一个result的数组,其中包含可组合执行的结果和创建的app实例。然后,我们可以在所有测试案例中使用withSetup来模仿使用useFetch的组件的创建过程:

import { useFetch } from '@/composables/useFetch'

describe('useFetch', () => {
  it('should fetch data from the given url', async () => {
    const [result, app] = withSetup(() => useFetch('your-test-url'));

    expect();
  });
});

然而,这里有一个问题。useFetch正在使用fetch API 来获取数据;在测试中使用实际的 API 不是一个好的做法,原因如下:

  • 如果 API 崩溃或 URL 无效,测试将失败。

  • 如果 API 响应慢,测试将失败。

因此,我们需要模拟fetch API 以使用vi.spyOn方法模拟响应:

import { vi } from 'vitest'

const fetchSpy = vi.spyOn(global, 'fetch');

我们可以将fetchSpy声明放在describe部分中,以确保这个 spy 与其他测试套件隔离开来。并且在beforeEach钩子中,我们需要在运行测试用例之前重置每个模拟的实现和值,使用mockClear()方法:

describe('useFetch', () => {
  const fetchSpy = vi.spyOn(global, 'fetch');

  beforeEach(() => {
    fetchSpy.mockClear();
  });

  it('should fetch data from the given url', async () => {
    //...
  });
});

让我们写我们的测试。我们将首先模拟fetch API 以使用mockResolvedValueOnce方法返回成功的响应:

it('should fetch data from the given url', async () => {
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: () => Promise.resolve({ data: 'test' }),
  } as any);

  const [result, app] = withSetup(() => useFetch('your-test-url'));
});

在那之后,我们可以断言resultdata值等于模拟数据:

it('should fetch data from the given url', async () => {
  //...

  const [result, app] = withSetup(() => useFetch('your-test-url'));

  expect(result?.data.value).toEqual({ data: 'test' });
});

我们还可以期望使用toHaveBeenCalledWith方法调用给定urlfetch

it('should fetch data from the given url', async () => {
  //...

  expect(fetchSpy).toHaveBeenCalledWith('your-test-url');
});

最后,我们需要卸载应用程序以清理测试环境:

it('should fetch data from the given url', async () => {
  //...
  await app.unmount();
});

此时,我们期望测试能够成功通过。不幸的是,测试仍然会失败。原因是,虽然fetch API 是异步的,但组件的生命周期钩子beforeMount不是。在fetch API 解析之前,钩子执行可能完成,导致data值保持不变(图 11-7)。

显示 useFetch 测试失败详情的截图

图 11-7. useFetch 测试未通过

要解决此问题,我们需要另一个包的帮助,Vue Test Utils(@vue/test-utils),Vue 的官方测试实用工具库。此包提供了一组实用方法来帮助测试 Vue 组件。我们将从该包中导入并使用 flushPromises 来等待 fetch API 解析完成后再断言 data 值:

import { flushPromises } from '@vue/test-utils'

it('should fetch data from the given url', async () => {
  //...

  await flushPromises();

  expect(result.data.value).toEqual({ data: 'test' });
});

测试应该成功通过(图 11-8)。

显示 useFetch 通过测试的截图

图 11-8. useFetch 通过测试

您还可以通过在调用 flush Promises 之前放置断言来断言 loading 值:

it('should change loading value', async () => {
  //...

  expect(result.loading.value).toBe(true);

  await flushPromises();

  expect(result.loading.value).toBe(false);
});

使用 mockRejectedValueOnce 方法模拟 fetch API 的失败响应还有一个好处,即我们可以测试我们组合函数的错误处理逻辑:

it('should change error value', async () => {
  fetchSpy.mockRejectedValueOnce(new Error('test error'));

  const [result, app] = withSetup(() => useFetch('your-test-url'));

  expect(result.error.value).toBe(null);

  await flushPromises();

  expect(result.error.value).toEqual(new Error('test error'));
});

就这样。您可以将相同的模拟方法应用于应用程序中的外部测试 API,或者模拟已经测试过的任何依赖函数,并减少测试套件的复杂性。我们已成功使用 Vitest 和 Vue Test Utils 测试了我们的 useFetch 方法。

接下来,我们将探讨如何使用 Vitest 和 Vue Test Utils 测试 Vue 组件。

使用 Vue Test Utils 进行组件测试

Vue 引擎使用 Vue 组件的配置来创建和管理浏览器 DOM 上组件实例的更新。测试组件意味着我们将测试组件渲染到 DOM 上的结果。我们在 vite.config.ts 中将 test.environment 设置为 jsdom,用于模拟浏览器环境,这在运行测试的 Node.js 环境中是不存在的。我们还使用 @vue/test-utils 包中的 mountshallowMount 等方法来帮助挂载组件,并断言从虚拟 Vue 节点到 DOM 元素的渲染结果。

让我们看看我们的 PizzaCard.vue 组件,如 示例 11-1 所示。

示例 11-1. PizzaCard 组件
<template>
  <article class="pizza--details-wrapper">
    <img :src="pizza.image" :alt="pizza.title" height="200" width="300" />
    <p>{{ pizza.description }}</p>
    <div class="pizza--inventory">
      <div class="pizza--inventory-stock">Stock: {{ pizza.quantity || 0 }}</div>
      <div class="pizza--inventory-price">$ {{ pizza.price }}</div>
    </div>
  </article>
</template>
<script setup lang="ts">
import type { Pizza } from "@/types/Pizza";
import type { PropType } from "vue";

const props = defineProps({
  pizza: {
    type: Object as PropType<Pizza>,
    required: true,
  },
});
</script>

我们将创建一个名为 tests/PizzaCard.test.ts 的测试文件来测试该组件。我们将从 @vue/test-utils 中导入 shallowMount 方法来在文件中进行挂载。shallowMount 函数接收两个主要参数:要挂载的 Vue 组件,以及包含用于挂载组件的额外数据的对象,如 props 的值、stub 等。以下代码展示了测试文件的外观,包括 pizza prop 的初始值:

/** tests/PizzaCard.test.ts */
import { shallowMount } from '@vue/test-utils';
import PizzaCard from '@/components/PizzaCard.vue';

describe('PizzaCard', () => {
  it('should render the pizza details', () => {
    const pizza = {
      id: 1,
      title: 'Test Pizza',
      description: 'Test Pizza Description',
      image: 'test-pizza.jpg',
      price: 10,
      quantity: 10,
    };

    const wrapper = shallowMount(PizzaCard, {
      props: {
        pizza,
      },
    });

    expect();
  });
});

使用 shallowMount vs mount

shallowMount 方法是 mount 方法的一个包装,其 shallow 标志处于激活状态。最好使用 shallowMount 渲染和测试组件,而不必关心其子组件。如果您想测试子组件,请改用 mount 方法。

shallowMount方法返回一个 Vue 实例wrapper,具有一些辅助方法,允许我们模拟与组件的 UI 交互。一旦我们有了包装器实例,我们可以编写我们的断言。例如,我们可以使用find方法来查找具有类选择器pizza- -details-wrapper的 DOM 元素并断言其存在:

/** tests/PizzaCard.test.ts */
//...

expect(wrapper.find('.pizza--details-wrapper')).toBeTruthy();

类似地,我们可以使用text()方法断言.pizza- -inventory-stock.pizza- -inventory-price元素的文本内容:

/** tests/PizzaCard.test.ts */
//...

expect(
  wrapper.find('.pizza--inventory-stock').text()
).toBe(`Stock: ${pizza.quantity}`);
expect(wrapper.find('.pizza--inventory-price').text()).toBe(`$ ${pizza.price}`);

shallowMount方法还提供html属性来断言组件的渲染 HTML。然后,我们可以使用toMatchSnapshot来测试元素的 HTML 快照:

/** tests/PizzaCard.test.ts */

expect(wrapper.html()).toMatchSnapshot();

在运行测试时,测试引擎将创建一个快照文件PizzaCard.test.ts.snap,并存储组件的 HTML 快照。在下一次测试运行时,Vitest 将根据现有快照验证组件的 HTML 渲染,确保组件在复杂应用开发中的稳定性。

使用快照

如果更改组件的模板,快照测试将失败。要解决此问题,您必须通过带有-u标志运行测试来更新快照,例如yarn test -u

由于快照测试的限制,您应仅在不太可能更改的组件上使用它。更推荐的方法是使用 PlaywrightJS 在 E2E 测试中测试 HTML 渲染。

find()方法获取的实例是 DOM 元素周围的包装器,具有各种方法来断言元素的属性和属性。我们将添加另一个测试案例,其中我们将使用attributes()方法来断言img元素的srcalt属性:

/** tests/PizzaCard.test.ts */

describe('PizzaCard', () => {
  it('should render the pizza image and alt text', () => {
    //...

    const wrapper = shallowMount(PizzaCard, {
      props: {
        pizza,
      },
    });

    const img = wrapper.find('img')

    expect(img.attributes().alt).toEqual(pizza.title);
    expect(img.attributes().src).toEqual(pizza.image);
  });
});

让我们通过将pizza.title更改为Pineapple pizza的文本来使测试失败。如图 11-9 所示,测试将失败并显示此消息。

测试失败消息,尝试测试图像的 alt 文本

图 11-9. 图像 alt 文本断言失败

正如此屏幕截图所示,接收到的值为Test Pizza,突出显示为红色,而预期值为绿色。我们也知道失败的原因:“预期Test PizzaPineapple pizza深度相等”,并指向测试失败的行。这些信息让我们可以快速修复测试或检查我们的实现,以确保预期行为的正确性。

断言组件的交互和数据通信的其他实用方法包括 DOM 包装器实例的trigger()方法和包装器实例的emitted()方法。我们将修改PizzaCard组件的实现,以添加一个“加入购物车”按钮并测试按钮的行为。

测试组件的交互和事件

我们将在PizzaCard组件中添加以下代码,以新增一个“加入购物车”按钮:

/** src/components/PizzaCard.vue */

<template>
  <section v-if="pizza" class="pizza--container">
    <!-- ... -->
    <button @click="addCart">Add to cart</button>
  </section>
</template>
<script lang="ts" setup>
//...
const emits = defineEmits(['add-to-cart'])

const addCart = () => {
  emits('add-to-cart', { id: props.pizza.id, quantity: 1 })
}
</script>

按钮接受 click 事件,触发 addCart 方法。addCart 方法将以 add-to-cart 事件的形式发出,其中包含 pizza.id 和新数量作为负载。我们可以通过断言发出的事件及其负载来测试 addCart 方法。首先,我们将使用 find() 方法查找按钮,然后使用 trigger() 方法触发 click 事件:

/** tests/PizzaCard.test.ts */

describe('PizzaCard', () => {
  it('should emit add-to-cart event when add to cart button is clicked', () => {
    //...

    const wrapper = shallowMount(PizzaCard, {
      props: {
        pizza,
      },
    });

    const button = wrapper.find('button');
    button.trigger('click');
  });
});

我们将执行 wrapper.emitted() 函数以接收发出事件的映射,键为事件名称,值为接收的负载数组。每个负载是传递给 emits() 函数的参数数组,除了事件名称。例如,当我们使用负载 { id: 1, quantity: 1 } 发出 add-to-cart 事件时,发出的事件将为 { *add-to-cart*: [[{ id: 1, quantity: 1 }]] }

现在,我们可以使用以下代码断言发出的事件及其负载:

/** tests/PizzaCard.test.ts */

describe('PizzaCard', () => {
  it('should emit add-to-cart event when add to cart button is clicked', () => {
    //...

    expect(wrapper.emitted()['add-to-cart']).toBeTruthy();
    expect(wrapper.emitted()['add-to-cart'][0]).toEqual([
      { id: pizza.id, quantity: 1 }
    ]);
  });
});

测试使用 Pinia 存储的组件

你可以使用 @pinia/testing 包中的 createTestingPinia() 方法来创建一个测试 Pinia 实例,并在组件挂载时将其作为全局插件引入。这样可以在不模拟存储或使用真实存储实例的情况下测试组件。

如预期地,测试通过。此时,我们已经涵盖了使用 Vitest 和 Vue Test Utils 进行组件和组合的基本测试。接下来的部分将介绍如何在带有 GUI 的 Vitest 中使用。

使用带有 GUI 的 Vitest

在某些场景下,查看终端(或命令行)输出可能会很复杂,使用图形用户界面 (GUI) 可能更为方便。对于这种情况,Vitest 提供了 @vitest/ui 作为其额外的依赖项,并通过命令参数 --ui 使用。要开始使用 Vitest UI,您需要在终端中使用以下命令安装 @vitest/ui

yarn add -D @vitest/ui

运行命令 yarn test --ui 后,Vite 将启动一个本地服务器用于其 UI 应用,并在浏览器中打开,如 图 11-10 所示。

Vitest UI 仪表板截图

图 11-10. Vitest UI

在左侧窗格中,我们可以看到测试文件列表及其状态,状态通过相关的颜色和图标表示。主仪表板上显示了测试结果的快速摘要,包括测试总数、通过测试数和失败测试数。我们可以使用左侧窗格选择单个测试,并查看每个测试用例报告、模块图和测试代码的实现。图 11-11 显示了 PizzaCard 组件的测试报告。

PizzaCard 组件的 Vitest UI 测试报告

图 11-11. PizzaCard 组件的 Vitest UI 测试报告

您还可以通过单击图 11-12. 的“运行(或重新运行所有)测试”图标来使用 GUI 运行测试。

运行测试图标

图 11-12. 使用 GUI 运行测试

在某些情况下,使用 GUI 可能会很有益,但在项目开发过程中需要观看测试时,它也可能会分散注意力。在这种情况下,使用终端可能是一个更好的选择,要查看测试结果,可以选择 GUI 或测试覆盖运行器,接下来我们将讨论这一点。

使用 Vitest 与覆盖运行器

编写测试很简单,但要知道我们是否编写了足够的测试来覆盖我们测试目标的所有场景是不容易的。为了为我们的应用程序创建一个足够的测试系统,我们使用代码覆盖实践,它可以衡量我们用测试覆盖的代码量。

有各种工具用于测量代码覆盖率并生成易于理解的报告。其中一个最常见的工具是 Istanbul,一个 JavaScript 测试覆盖工具。通过 Vitest,我们可以使用@vitest/coverage-istanbul包将 Istanbul 集成到我们的测试系统中。要安装该包,请在终端中运行以下命令:

yarn add -D @vitest/coverage-istanbul

安装完包之后,我们可以在vite.config.ts文件中配置test.coverage部分,提供者为istanbul

/** vite.config.ts */
export default defineConfig({
  //...
  test: {
    //...
    coverage: {
      provider: 'istanbul'
    }
  }
})

我们还在package.json中添加了一个新的脚本命令来运行带覆盖报告的测试:

{
  //...
  "scripts": {
    //...
    "test:coverage": "vite test --coverage"
  }
}

当我们使用命令yarn test:coverage运行测试时,将在终端中看到覆盖报告,如图 11-13 所示。

终端上的覆盖报告

图 11-13. 终端中的覆盖报告

Istanbul 报告工具将在测试执行过程中显示您的代码在每个文件中被测试覆盖的百分比,将其分为四个类别:语句、分支、函数和行。它还将在最后一列中告知您未覆盖代码的行号。例如,在图 11-13 中,对于composables/useFetch.ts,我们看到未覆盖行列中的13,18,表明我们对该文件的测试未覆盖第 13 行和第 18 行的代码。

然而,终端报告并不总是易读的。出于这样的目的,Istanbul 还将在定义的vite.config.ts中的test.root目录或项目根目录中生成一个coverage文件夹。该文件夹包含 HTML 覆盖率报告,用index.html表示。您可以在浏览器中打开此文件以查看更漂亮和更可读的覆盖报告,如图 11-14 所示。

HTML 中的覆盖报告

图 11-14. HTML 中的覆盖报告
注意

如果将root设置为指向src/tests文件夹,则应将其更改为src。否则,Istanbul 将无法定位和分析源文件的覆盖率。

HTML 版本显示了按文件夹和文件显示的测试覆盖率,第一列显示它们的名称,File。第二列是进度条,显示每个文件的覆盖百分比,以颜色表示(绿色表示完全覆盖,黄色表示部分覆盖,红色表示未达到接受的覆盖水平)。其他列显示语句、分支、函数和行的覆盖率详细信息。

我们可以点击每个文件夹名称来查看此文件夹内每个文件的详细报告,比如在 /composables 中的 Figure 11-15。

composables 的覆盖报告

Figure 11-15. composables 的覆盖报告

您可以点击每个文件名以查看未测试代码行的突出显示(红色)以及覆盖的次数(例如 3x),如 Figure 11-16 所示。

useFetch 的覆盖报告

Figure 11-16. useFetch 的覆盖报告

HTML 报告版本在监视模式下也是交互式的,这意味着当您更改代码或测试时,它将自动更新覆盖报告。这种机制在开发过程中非常方便,因为您可以实时查看覆盖报告的变化。

我们还可以在 vite.config.tstest.coverage 部分设置每个类别的覆盖率阈值:

/** vite.config.ts */

export default defineConfig({
  //...
  test: {
    //...
    coverage: {
      provider: 'istanbul',
      statements: 80,
      branches: 80,
      functions: 80,
      lines: 80
    }
  }
})

在这段代码中,我们将每个类别的覆盖率阈值设置为 80%。如果任何类型的覆盖率低于阈值,测试将失败并显示错误消息,如 Figure 11-17 所示。

覆盖阈值错误

Figure 11-17. 当测试未达到覆盖率阈值时出现的错误

代码覆盖对于测试非常重要,因为它提供了帮助您保护代码免受错误和确保应用程序质量的基准。但是,它只是帮助您管理测试的工具,您仍然需要编写良好的测试来确保代码质量和标准。

设置阈值数

尝试将您的覆盖阈值保持在 80% 到 85% 之间。如果设置超过 85%,可能会过度,如果低于 80%,可能会过低,因为您可能会错过一些会导致应用程序中出现错误的边缘情况。

我们已经探索了使用 Vitest 进行单元测试以及像 Vue Test Utils(用于 Vue 特定测试)和 Istanbul(用于代码覆盖)等其他工具。接下来,我们将进入下一个测试级别,学习如何使用 PlaywrightJS 编写应用程序的端到端测试。

使用 PlaywrightJS 进行端到端测试

PlaywrightJS,或称 Playwright,是一个快速、可靠的跨浏览器端到端测试框架。除了 JavaScript,它还支持其他编程语言,如 Python、Java 和 C#。它还支持多个浏览器渲染引擎,如 WebKit、Firefox 和 Chromium,允许我们在同一代码库中的跨浏览器环境中进行测试。

要开始使用 Playwright,请运行以下命令:

yarn create Playwright

当 Yarn 运行 Playwright 创建脚本时,会提示询问测试位置(e2e)、是否要将 GitHub Actions 安装为 CI/CD 管道工具以及是否应安装 Playwright 浏览器。图 11-18 展示了初始化 Playwright 在我们应用中的配置示例。

初始化 Playwright 时的提示截图

图 11-18. 使用提示初始化 Playwright

初始化过程完成后,我们将在项目根目录下看到一个新的e2e文件夹,并包含一个名为example.spec.ts的单个文件。此外,Playwright 将为我们的项目生成一个名为playwright.config.ts的配置文件,并通过相关包修改package.json,另外还会生成一个名为test-examples的文件夹,其中包含一个使用 Playwright 测试待办事项组件的工作测试示例。

现在我们可以在package.json中添加一个新的脚本命令来使用 Playwright 运行我们的 E2E 测试:

"scripts": {
  //...
  "test:e2e": "npx playwright test"
}

类似地,我们可以添加以下命令来在我们的测试上运行覆盖率报告生成器:

"scripts": {
  //...
  "test:e2e-report": "npx playwright show-report"
}

默认情况下,Playwright 带有 HTML 覆盖率报告生成器,并且此报告生成器在测试运行期间任何测试失败时运行。我们可以尝试使用这些命令运行测试并查看示例测试是否通过。

让我们看看playwright.config.ts并查看其内容:

import { defineConfig, devices } from '@playwright/test';

/** playwright.config.ts */
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ]
})

配置文件通过defineConfig()方法导出一个实例,基于一组配置选项,主要包括以下主要属性:

testDir

存储测试的目录。通常我们在初始化过程中定义它(在我们的情况下为e2e)。

projects

运行测试的浏览器项目列表。我们可以从同一@playwright/test包中导入devices并选择相关设置,以定义 Playwright 使用的浏览器配置,如devices[*Desktop Chrome*]用于 Chromium 浏览器。

worker

并行运行测试的工作线程数。当我们有许多测试需要并行运行以加快测试过程时,此功能非常有用。

use

测试运行程序的配置对象,包括可选的baseURL作为基础 URL 和trace以在重试时启用失败测试的跟踪记录。

其他属性可以根据需要自定义我们的 Playwright 测试运行程序。请参阅Playwright 文档获取完整的配置选项列表。

我们将保留文件的现状并为我们的应用编写第一个 E2E 测试。让我们转到vite.config.ts并确保我们有以下本地服务器配置:

//...
export default defineConfig({
  //...
  server: {
    port: 3000
  }
})

通过将端口设置为 3000,我们确保我们的本地 URL 始终是http://localhost:3000。接下来,我们将在e2e文件夹中创建名为PizzasView.spec.ts的新 E2E 测试文件,专门用于测试“/pizzas”页面。“/pizzas” 页面使用PizzasView视图组件以以下模板显示 pizza 列表:

<template>
  <div class="pizzas-view--container">
    <h1>Pizzas</h1>
    <input v-model="search" placeholder="Search for a pizza" />
    <ul>
      <li v-for="pizza in searchResults" :key="pizza.id">
        <PizzaCard :pizza="pizza" />
      </li>
    </ul>
  </div>
</template>
<script lang="ts" setup>
import { usePizzas } from "@/composables/usePizzas";
import PizzaCard from "@/components/PizzaCard.vue";
import { useSearch } from "@/composables/useSearch";

const { pizzas } = usePizzas();
const { search, searchResults }: PizzaSearch = useSearch({
  items: pizzas,
  defaultSearch: '',
});
</script>

我们希望为此页面编写测试。与 Vitest 类似,我们首先使用来自@playwright/test包的test导入,将测试文件包装在test.describe()块中。然后,我们确保测试运行器在测试页面内容之前始终导航到我们的目标页面,使用test.beforeEach()钩子:

/** e2e/PizzasView.spec.ts */
import { expect, test } from '@playwright/test';

test.describe('Pizzas View', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/pizzas');
  });
});

我们还确保在完成测试后使用test.afterEach()钩子关闭页面:

/** e2e/PizzasView.spec.ts */

test.describe('Pizzas View', () => {
  //...

  test.afterEach(async ({ page }) => {
    await page.close();
  });
});

我们可以开始编写页面的第一个测试,例如检查页面标题。我们可以使用page.locator()方法定位页面元素。在本例中,它是h1元素,并断言其内容为文本Pizzas

/** e2e/PizzasView.spec.ts */

test.describe('Pizzas View', () => {
  //...

  test('should display the page title', async ({ page }) => {
    const title = await page.locator('h1');
    expect(await title.textContent()).toBe('Pizzas');
  });
});

我们可以使用yarn test:e2e命令运行测试,并查看测试是否通过(图 11-19)。

使用 Playwright 通过 E2E 测试通过

图 11-19. 使用 Playwright 通过 E2E 测试的测试报告

太棒了!我们可以向文件添加更多测试,例如检查搜索功能。我们可以使用标签名称或data-testid属性定位搜索input元素作为更好的方法。要使用data-testid属性,我们需要在PizzasView组件模板中的input中添加它:

<input
  v-model="search"
  placeholder="Search for a pizza"
  data-testid="search-input"
/>

然后,我们可以在我们的新测试中使用data-testid属性定位元素,并使用搜索词Hawaiian进行fill

/** e2e/PizzasView.spec.ts */

test.describe('Pizzas View', () => {
  //...

  test('should search for a pizza', async ({ page }) => {
    const searchInput = await page.locator('[data-testid="search-input"]');

    await searchInput.fill('Hawaiian');
  });
});

要断言搜索结果,请前往PizzaCard实现,并将data-testid属性添加到包含值pizza.title的容器元素中:

<!-- src/components/PizzaCard.vue -->
<template>
  <article class="pizza--details-wrapper" :data-testid="pizza.title">
    <!--...-->
  </article>
</template>

回到我们的PizzasView.spec.ts文件,我们可以使用包含搜索词的data-testid属性断言页面上 pizza 卡片的可见性:

/** e2e/PizzasView.spec.ts */

test.describe('Pizzas View', () => {
  //...
  test('should search for a pizza', async ({ page }) => {
    const searchInput = await page.locator('[data-testid="search-input"]');

    await searchInput.fill('Hawaiian');

    expect(await page.isVisible('[data-testid*="Hawaiian"]')).toBeTruthy();
  });
});

我们可以重新运行测试套件,并查看测试是否通过(图 11-20)。

使用 Playwright 通过 E2E 测试通过

图 11-20. 显示搜索测试通过的测试报告

我们还可以点击报告中显示的每个测试,以查看测试详细信息,包括测试步骤、执行时间以及在目标浏览器环境中执行测试时发生的任何错误(图 11-21)。

在 Chromium 中使用 Playwright 的测试详细报告

图 11-21. Chromium 上单次测试运行的详细报告

对于page.isVisible()方法,您必须使用await,因为它返回一个Promise。否则,由于 Playwright 在isVisible()进程返回结果之前执行断言,测试将失败。

让我们编辑我们的搜索测试,将搜索词从Hawaiian更改为Cheese,使其失败:

/** e2e/PizzasView.spec.ts */

test.describe('Pizzas View', () => {
  //...
  test('should search for a pizza', async ({ page }) => {
    const searchInput = await page.locator('[data-testid="search-input"]');

    await searchInput.fill('Cheese');

    expect(await page.isVisible('[data-testid*="Hawaiian"]')).toBeTruthy();
  });
});

我们可以重新运行测试套件,查看测试是否失败(参见 图 11-22)。

使用 Playwright 在搜索词上进行 E2E 测试失败的屏幕截图

图 11-22. 显示搜索测试失败的测试报告

报告显示测试在哪一步骤失败了。让我们来调试一下。

使用 Playwright 测试扩展进行 E2E 测试调试

我们可以安装 VSCode 的 Playwright 测试扩展 来调试一个失败的测试。该扩展将在 VSCode 的测试选项卡上添加另一个部分,并自动检测项目中的相关 Playwright 测试,如 图 11-23 所示。

在 VSCode 的测试选项卡中列出项目中 Playwright 测试的屏幕截图

图 11-23. 测试选项卡显示项目中的 Playwright 测试

我们可以使用此视图上可用的操作来运行测试或单个测试。我们还可以添加断点(由红点表示),以调试目标测试(参见 图 11-24)。

添加断点以调试测试

图 11-24. 添加断点以调试测试

要开始调试,请导航到测试资源管理器窗格中的搜索测试,并单击“调试”图标(参见 图 11-25)。将鼠标悬停在“调试”图标上将显示文本“调试文本”。

调试测试按钮

图 11-25. 以调试模式运行测试

运行时,Playwright 将打开一个浏览器窗口(如 Chromium)并执行测试步骤。一旦测试运行器达到断点,它将停止并等待我们手动继续执行。然后我们可以悬停在变量上查看它们的值,或者前往测试浏览器检查元素(参见 图 11-26)。

调试一个测试

图 11-26. 调试搜索测试

剩下的工作是修复测试并继续调试过程,直到测试通过。

我们学习了如何使用 Playwright 创建基本的 E2E 测试,并如何借助外部工具进行调试。Playwright 还提供许多其他功能,例如根据与应用程序的实际交互生成测试,或使用 @axe-core/playwright 包执行辅助功能测试。了解其他功能,看看 Playwright 如何帮助您为应用程序创建更好的 E2E 测试。

总结

本章介绍了测试的概念以及如何在 Vue 应用程序中使用 Vitest 作为单元测试工具。我们学习了如何使用 Vitest 和 Vue Test Utils 为组件和可组合项编写基本测试,以及如何使用覆盖率运行器和 Vitest UI 等外部包提供更好的用户界面体验。我们还探讨了如何使用 PlaywrightJS 创建 E2E 测试,确保在整个应用程序中的代码可信度。

^(1) 如果你对 TDD 还不熟悉,可以从《学习测试驱动开发》 by Saleem Siddiqui (O’Reilly) 开始。

^(2) JSDOM 是一个开源库,充当无头浏览器,实现了 Web 标准,为测试任何与 Web 相关的代码提供了模拟环境。

第十二章:Vue.Js 应用的持续集成/持续部署

上一章向我们展示了如何为我们的 Vue 应用程序设置测试,从使用 Vite 的单元测试到使用 Playwright 的 E2E 测试。通过为我们的应用程序覆盖适当的测试,我们可以继续进行下一步:部署。

本章将介绍 CI/CD 的概念,以及如何使用 GitHub Actions 为您的 Vue 应用程序设置 CI/CD 流水线。我们还将学习如何使用 Netlify 作为我们的应用程序的部署和托管平台。

软件开发中的 CI/CD

持续集成(CI)和持续交付(CD)是结合的软件开发实践,旨在加快和稳定软件开发和交付过程。CI/CD 包括通过自动化集成、测试和持续软件部署到生产的过程,有效监控软件生命周期。

CI/CD 为软件开发带来了许多好处,包括:

  • 通过自动化部署实现更快的软件交付

  • 不同团队之间更强的协作

  • 通过自动化测试提高软件质量

  • 更加敏捷的方法响应错误和软件问题

简而言之,CI/CD 包含三个主要概念:持续集成、持续交付和持续部署,当它们结合在一起时,就形成了一个强大的软件开发流程,称为 CI/CD 流水线(图 12-1)。

显示 CI/CD 流水线方法的截图,包含持续集成、持续交付和持续部署

图 12-1. CI/CD 流水线

持续集成

持续集成使开发人员能够在独立工作的同时频繁且同时地将代码集成到共享仓库中。通过每次代码集成(或合并),我们使用应用程序的自动构建和不同级别测试的自动化系统进行验证。如果新旧代码版本之间存在冲突或新代码存在任何问题,我们可以迅速检测并修复它们。持续集成的标准工具包括 Jenkins、CircleCI 和 GitHub Actions,我们将在“使用 GitHub Actions 的 CI/CD 流水线”中讨论它们。

持续交付

成功的持续集成之后的下一步是持续交付。持续交付将验证的应用程序代码自动发布到共享仓库,使其为生产部署做好准备。持续交付需要持续集成,因为它假设代码始终是经过验证的。它还包括另一系列的自动化测试和发布自动化。

持续部署

连续部署是 CI/CD 流水线的最后一步,自动将经过验证的代码部署到生产环境。它在很大程度上依赖于对代码库进行了充分测试的自动化系统。连续部署是 CI/CD 流水线的最高级阶段,对某些项目尤其重要,特别是在需要在生产部署之前进行手动批准的情况下。

CI/CD 流水线的三个阶段形成了更安全和灵活的应用程序开发和部署流程。在下一节中,我们将学习如何为我们的 Vue 应用程序使用 GitHub Actions 设置 CI/CD 流水线。

使用 GitHub Actions 的 CI/CD 流水线

GitHub Actions 由 GitHub 提供,是一个与平台无关、语言无关和云无关的 CI/CD 平台。它易于使用,并且对于托管在 GitHub 平台上的项目是免费的。GitHub Actions 中的每个 CI/CD 流水线包含一个或多个工作流,以 YAML 文件表示。每个工作流包括一系列作业,可以并行或顺序执行。每个作业都有一系列步骤,其中包含许多顺序执行的操作。每个操作都是在指定的运行环境中执行的独立命令或脚本(见示例 12-1)。

示例 12-1。示例 GitHub 工作流文件
name: Example workflow
on: [push, pull_request]
jobs:
    first-job:
        steps:
        - name: First step
            run: echo "Hello world"
        - name: Second step
            run: echo "Second step"
    second-job:
        steps:
        - name: First step
            run: echo "Do something in second job."
注意

工作流文件遵循 YAML 语法。您可以在GitHub Actions 文档的工作流语法中学习如何使用 YAML 语法。

要开始使用 GitHub Actions,在我们的 Vue 项目目录中,我们将创建一个名为.github/workflows的新目录,其中包含一个名为ci.yml的工作流文件。这个文件将包含我们的 CI/CD 流水线的配置。例如,以下是一个简单的工作流文件,用于运行我们的单元测试:

name: CI for Unit tests
on:
    push:
        branches: [ main ] ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    pull_request:
        branches: [ main ] ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
jobs:
    unit-tests:
      timeout-minutes: 60
      runs-on: ubuntu-latest
      steps:
      - uses: actions/checkout@v3   ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)
      - uses: actions/setup-node@v3 ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)
        with:
          node-version: 18
      - name: Install dependencies ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/5.png)
        run: npm i
      - name: Execute unit tests
        run: npm run test:coverage ![6](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/6.png)
      - name: Uploading artifacts ![7](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/7.png)
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: test-results/
          retention-days: 30

1

main分支有推送时,工作流将被触发。

2

当有合并请求要合并到main

3

使用内置的 GitHub Actions,通过actions/checkout将测试分支检出到运行环境

4

使用内置的 GitHub Actions,使用 Node.js 版本 18.x 设置节点环境,actions/setup-node

5

安装依赖项

6

运行带有覆盖率报告的单元测试

7

将测试报告作为工件上传到 GitHub Actions

注意

每个作业都是独立的进程,并且不共享相同的环境。因此,在其步骤中,我们需要分别为每个作业安装依赖项。

在 GitHub 上,我们可以进入 Actions 标签页查看工作流的状态(参见图 12-2)。

显示 GitHub Actions 页面,CI 工作流正在运行的截图

图 12-2. 正在运行的 GitHub Actions 页面

GitHub 根据提交显示工作流,包括其状态和目标分支(main)。我们可以通过点击作业名称查看工作流中每个作业的状态,例如我们可以查看单元测试作业的状态在图 12-3 中。

显示单元测试作业状态的截图

图 12-3. 作业运行状态下的单元测试步骤

一旦工作流运行完成,我们可以看到测试报告上传到 Artifacts 部分(参见图 12-4)。

显示上传的 Artifacts,带有测试报告的截图

图 12-4. 带有测试报告的 Artifacts 部分

我们还可以通过点击工作流名称(如图 12-5)检查工作流的状态结果,按作业拆分。

显示工作流执行状态的截图

图 12-5. 工作流状态页面

GitHub Actions 将标记任何失败的作业,并提供失败的摘要注释。我们也可以通过点击 Re-run jobs 按钮重新运行失败的作业。

通过这样,我们为我们的 Vue 应用程序创建了第一个 CI/CD 管道。或者,您可以使用官方的GitHub Actions 市场中提供的模板来创建您的工作流,该市场内置支持不同的编程语言、框架、服务和云提供商(参见图 12-6)。

显示 GitHub Actions 市场的截图

图 12-6. GitHub Actions 市场

根据我们的工作流示例,如果需要,可以为应用程序创建更多工作流,或者扩展当前工作流以包括更多步骤,例如部署。在下一节中,我们将学习如何使用 Netlify 为我们的应用程序设置持续部署。

使用 Netlify 进行持续部署

Netlify 是一个云平台,为托管现代 Web 应用程序提供广泛的服务,包括托管、无服务器函数 API 和 CI/CD 集成。个人项目免费,商业项目提供慷慨的免费层级。^(1)

要在 Netlify 上部署我们的 Vue 项目,我们需要创建一个 Netlify 账户并登录。一旦登录到仪表板,我们可以转到 Sites 标签,并点击 Add new site 按钮,从 GitHub 提供者导入我们的项目进行自动部署,或者手动部署(参见图 12-7)。

显示 Netlify 仪表板的屏幕截图

图 12-7. Netlify 仪表板

接下来,我们选择我们项目的 Git 提供者(GitHub),并授权 Netlify 访问我们的 GitHub 帐户。确认后,我们可以选择项目的存储库,然后点击部署站点按钮开始部署过程。完成部署后,我们可以在仪表板的站点概述选项卡上查看我们站点部署的状态及其他详细信息,例如 PR 预览(图 12-8)。

显示 Netlify 站点概述的屏幕截图

图 12-8. Netlify 站点概述

成功部署后,Netlify 将提供一个临时 URL 用于访问应用程序。实际上,您可以通过导航到域管理部分(图 12-9)配置您站点的自定义域名。

显示 Netlify 域设置的屏幕截图

图 12-9. Netlify 域设置

默认情况下,一旦集成,Netlify 将在每次将新提交合并到main分支时自动部署您的应用程序。此外,它将为每个拉取请求生成预览构建。在此视图中,您还可以配置其他设置,如构建命令、用于持续部署的部署上下文以及应用程序的环境变量。Netlify 还提供构建钩子作为唯一 URL,可通过 HTTP 请求触发与 GitHub Actions 工作流等第三方服务的构建和部署(图 12-10)。

显示构建钩子设置的屏幕截图

图 12-10. 站点设置中的构建钩子部分
注意

您可以在本地使用命令yarn build手动构建您的应用程序,然后将dist文件夹拖放到Netlify 应用程序,以部署您的应用程序到 Netlify 提供的临时 URL。

使用 Netlify CLI 部署

或者,我们可以将 Netlify CLI 作为全局工具安装在我们的本地机器上,使用命令npm install -g netlify-cli。安装了此 CLI 后,我们可以使用命令netlify init初始化我们的项目以用于 Netlify。此命令将提示我们登录相关帐户(GitHub)并准备我们的项目进行部署。初始化并准备就绪后,我们可以运行命令netlify deploy将项目部署到临时 URL 进行预览,或者运行netlify deploy --prod直接部署到生产环境。

我们已成功将第一个 Vue 应用程序部署到 Netlify。Netlify 提供的其他高级功能包括无服务器函数、表单处理和分割测试。您可以根据项目要求使用Netlify 官方文档来探索这些功能。

概要

在本章中,我们学习了关于 CI/CD 的概念,以及如何利用 GitHub Actions 为我们的 Vue 应用程序建立一个简单的 CI/CD 流程。我们还了解了 Netlify,并学习了如何自动将我们的应用程序部署到 Netlify 托管平台。在下一章中,我们将探讨 Vue 生态系统的最后几个方面,即使用 Nuxt.js 进行服务器端渲染(SSR)和静态站点生成(SSG)。

^(1) 其他选择包括 Azure 静态 Web 应用和 Vercel。

第十三章:使用 Vue 进行服务器端渲染

在前一章中,我们学习了如何设置 Vue 应用程序的完整 CI/CD 管道。我们还学习了如何使用 Netlify 进行生产部署。我们的应用程序现在已准备好供用户通过网络访问。因此,我们几乎完成了使用 Vue 学习的旅程。本章将探讨 Vue 的另一个方面,即使用 Nuxt.js 进行服务器端渲染和静态站点生成。

Vue 中的客户端渲染

默认情况下,Vue 应用程序用于客户端渲染,具有占位符index.html文件、JavaScript 文件(通常由 Vite 编译为优化的代码块)、以及其他文件如 CSS、图标、图像等,以提供完整的 UI 体验。在初始加载时,浏览器向服务器发送请求获取index.html文件。服务器将返回原始的占位符文件(通常包含一个带有唯一 id 选择器app的单个元素,用于 Vue 引擎挂载应用程序实例,以及一个指向包含主要代码的script标签)。一旦浏览器接收到 HTML 文件,它将开始解析并请求其他资源,如所需的main.js文件,然后执行它以相应地渲染其余内容(参见图 13-1)。

包含单个带有 id 为 app 的 div 和包含主要代码的 script 标记的 HTML 占位符文件的屏幕截图

图 13-1. 客户端 Vue 应用程序渲染流程

从这一点开始,应用程序完成初始化,用户可以开始与其进行交互。Vue 将通过内置的路由系统动态处理用户的视图更改请求。但是,如果您右键单击页面并选择查看页面源代码,您将只看到原始根index.html文件的代码,而不是当前的 UI 视图。这种行为可能会有问题,特别是在构建需要良好搜索引擎优化(SEO)的网站或应用程序时^(1)。

另外,加载和执行 JavaScript 代码以在向用户显示任何内容之前可能导致用户等待时间较长,原因包括需要下载的重量级 JavaScript 文件、慢速网络、浏览器绘制内容所需的时间(首次绘制)等因素。因此,整个过程可能导致较长的交互时间(2)和较慢的首次内容绘制(3)。所有这些因素影响了整体应用程序的性能和用户体验,并且通常难以解决。

在这种情况下,可能有比客户端渲染应用程序更好的选择,例如我们将在下一节中探讨的服务器端渲染。

服务器端渲染(SSR)

正如其名称所示,服务器端渲染(SSR)是一种在服务器端将所有内容编译为完全工作的 HTML 页面,然后根据需要(而不是在浏览器上执行)将其提供给客户端(浏览器)的方法。

要开发本地 SSR Vue 应用程序,我们需要一个本地服务器来与浏览器通信并处理所有数据请求。我们可以通过以下命令安装 Express.js^(4)作为我们项目的依赖项:

yarn add express

安装完成后,我们可以在项目的根目录中创建一个名为server.js的文件,并使用示例 13-1 中的代码来设置我们的本地服务器。

示例 13-1. 本地服务器的server.js文件
import express from 'express'

const server = express() ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

server.get('/', (req, res) => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    res.send(`  ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png) <!DOCTYPE html>
        <html>
        <head>
            <title>Vue SSR Example</title>
        </head>
        <body>
            <main id="app">Vue SSR Demo</main>
        </body>
        </html> `)
})

server.listen(3000, () => { console.log('We are ready to go') }) ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/4.png)

1

创建一个server实例

2

为入口 URL“/”定义处理程序

3

处理程序将返回一个字符串,作为在浏览器上显示Vue SSR Demo的 HTML 页面。

4

我们设置本地服务器以在端口 3000 上运行和侦听。

在项目的根目录中,我们可以使用node server.js命令启动本地服务器。一旦服务器准备好,我们必须使用vue包中的createSSRApp方法在服务器上创建我们的应用程序。例如,让我们编写一个 Vue 应用程序,在app.js文件中显示当前日期和时间的数字时钟,代码在示例 13-2 中。

示例 13-2. 数字时钟应用程序的app.js文件
import { createSSRApp, ref } from 'vue'

const App = { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
    template: ` <h1>Digital Clock</h1>
        <p class="date">{{ date }}</p>
        <p class="time">{{ time }}</p> `,
    setup() {
        const date = ref('');
        const time = ref('');

        setInterval(() => {
            const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
            const MONTHS = [
                'Jan', 'Feb', 'Mar',
                'Apr', 'May', 'Jun',
                'Jul', 'Aug', 'Sep',
                'Oct', 'Nov', 'Dev'
            ];

            const currDate = new Date();
            const minutes = currDate.getMinutes();
            const seconds = currDate.getSeconds();
            const day = WEEKDAYS[currDate.getDay()];
            const month = MONTHS[currDate.getMonth()].toUpperCase();

            const formatTime = (time) => {
                return time < 10 ? `0${time}` : time;
            }

            date.value =
              `${day}, ${currDate.getDate()} ${month} ${currDate.getFullYear()}`
            time.value =
              `${currDate.getHours()}:${formatTime(minutes)}:${formatTime(seconds)}`
        }, 1000)

        return {
            date,
            time
        }
    }
}

export function createApp() {
  return createSSRApp(App) ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
}

1

我们为主应用程序组件 App 定义选项。

2

我们使用createSSRApp()App选项在服务器端构建应用程序。

此文件暴露了一个方法createApp(),可用于服务器端和客户端,返回一个准备好用于挂载的 Vue 实例。

在我们的server.js文件中,我们将使用app.js中的createApp()创建服务器端应用程序实例,并使用vue/server-renderer包中的renderToString()方法将其渲染为 HTML 格式的字符串。一旦renderToString()返回带有内容字符串的响应,我们将用它替换返回的响应中的内容,如示例 13-3 所示。

示例 13-3. 更新server.js以将应用实例渲染为 HTML 字符串
import { createApp } from './app.js'
import express from 'express'
import { renderToString } from 'vue/server-renderer';

const server = express()

server.get('/', (req, res) => {
  const app = createApp(); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

  renderToString(app).then((html) => {
    res.send(` <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Demo - Digital Clock</title>
      </head>
      <body>
        <div id="app">${html}</div> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png) </body>
    </html> `);
  });
});

server.listen(3000, () => { console.log('We are ready to go') })

1

使用createApp()创建应用实例

2

renderToString()生成的 HTML 字符串放置在具有#app作为其 id 的div中。

当我们访问 http://locahost:3000/ 时,我们将只看到浏览器仅显示标题 数字时钟(图 13-2),字段 datetime 为空。

数字时钟

图 13-2. 空数字时钟

这种行为发生是因为我们只生成了返回客户端的 HTML 静态代码,但在浏览器中没有 Vue 可用。同样的情况也适用于任何交互行为,例如 onClick 事件处理程序。为了解决这个交互问题,我们需要在水合模式下挂载我们的应用程序,允许 Vue 接管静态 HTML,并在 HTML 在浏览器端可用时使其变得交互和动态。我们可以通过定义一个 entry-client.js 来做到这一点,该文件将使用 app.js 中的 createApp() 获取应用程序实例。浏览器将执行此文件并将 Vue 实例挂载到 DOM 中的正确元素上(示例 13-4)。

示例 13-4. entry-client.js 文件,用于在水合模式下挂载应用实例
import { createApp } from './app.js';

createApp().mount('#app');

我们还将更新 server.js 文件,以使用 <script> 标签在浏览器中加载 entry-client.js 文件,并启用在浏览器中提供客户端文件(示例 13-5)。

示例 13-5. 更新 server.js 以在浏览器中加载 entry-client.js
//... 
server.get('/', (req, res) => {
  const app = createApp();

  renderToString(app).then((html) => {
    res.send(` <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Demo - Digital Clock</title>
        <script type="importmap"> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png) {
            "imports": {
              "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
            }
          }
        </script>
        <script type="module" src="/entry-client.js"></script> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png) </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html> `);
  });
});

server.use(express.static('.')); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/3.png)

1

使用 importmap 加载 vue 包的源代码

2

使用 <script> 标签在浏览器中加载 entry-client.js 文件

3

在浏览器中启用提供客户端文件

当我们重新启动服务器并刷新浏览器时,将会看到以更新的日期和时间显示的时钟。

数字时钟

图 13-3. 数字时钟

在 SSR 中使用 DOM API 和 Node API

你不能在 SSR 中使用 DOM API 和 Web API,因为这些只是浏览器的 API。你也不能仅使用 Node API,例如文件读取中的 fs,用于客户端组件。

我们已经学习了如何创建一个简单的 SSR Vue 应用程序。然而,当我们需要处理更复杂的应用程序时,例如使用 Vue 单文件组件、代码分割、Vue Router,可能需要使用 window API 等时,我们可能需要构建一个引擎来处理应用程序代码捆绑、使用正确捆绑代码进行渲染、包装 Vue Router 等等,这可能是一个繁琐的任务。

相反,我们可以使用已经提供此引擎的框架,例如下一节讨论的 Nuxt.js。

使用 Nuxt.Js 进行服务器端渲染

Nuxt.js(Nuxt)是一个基于 Vue 构建的开源模块化 SSR 框架。它提供了许多开箱即用的功能,如基于文件的路由系统、性能优化、不同的构建模式等,同时专注于开发者的体验(图 13-4)。

Nuxt.js 官方网站

图 13-4. Nuxt.js 官方网站

作为基于模块的框架,Nuxt 包充当核心,我们可以向应用程序中插入其他 Nuxt 支持的模块以扩展核心功能。您可以在Nuxt 模块官方文档中找到可用的 Nuxt 模块列表,包括 SEO、PWA、i18n 等模块。

注意

访问Nuxt 官方文档以了解其 API 文档、安装方法以及主要用途的参考资料。截至撰写时,Nuxt 3.4.2 是最新版本。

在本节中,我们将使用 Nuxt 创建我们在第八章中介绍的 Pizza House 应用程序。我们将从以下命令开始创建新的 Nuxt 应用程序:

npx nuxi init pizza-house

pizza-house是我们的项目名称,而nuxi是 Nuxt CLI,将使用以下主要文件搭建 Nuxt 应用程序:

app.vue

应用程序的根组件。

nuxt.config.ts

Nuxt 的配置文件,包括设置插件、CSS 路径、应用程序元数据等。

注意

Nuxt 默认支持 TypeScript 的应用程序创建。

Nuxt 还将在package.json中创建用于构建和本地运行应用程序的脚本命令,如示例 13-6 所示。

示例 13-6. Nuxt 应用程序的package.json文件
"scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare"
},

在运行yarn命令安装依赖后,可以运行yarn dev命令在本地启动应用程序,并访问http://localhost:3000查看默认的 Nuxt 登陆页面。

由于 Nuxt 支持使用pages文件夹进行基于文件的路由,现在我们将在此文件夹下定义我们的路由系统:

index.vue

应用程序的主页。Nuxt 将自动将此页面映射到根路径(/)。

pizzas/index.vue

显示披萨列表的页面,路径为/pizzas

pizzas/[id].vue

这是一个动态嵌套页面,其中[id]是显示披萨详细信息的占位符。Nuxt 将自动将此页面映射到路径/pizzas/:id,如/pizza/1/pizza/2等。

接下来,我们需要用示例 13-7 中的代码替换app.vue的内容,以使路由系统正常工作。

示例 13-7. 更新app.vue以使用 Nuxt 的布局和页面组件
<template>
  <div>
    <NuxtLayout>
      <NuxtPage/>
    </NuxtLayout>
  </div>
</template>

NuxtLayout是应用程序的布局组件,NuxtPage是页面组件。Nuxt 将自动用定义的页面和布局组件替换这些组件。

让我们将示例 13-8 中的代码添加到pages/index.vue中,以显示首页。

示例 13-8. Pizza House 应用程序的首页
<template>
    <h1>This is the home view of the Pizza stores</h1>
</template>

以及从示例 13-9 到pages/pizzas/index.vue的代码,用于显示披萨列表。

示例 13-9. Pizza House 应用程序的 Pizzas 页面
<template>
  <div class="pizzas-view--container">
    <h1>Pizzas</h1>
    <ul>
      <li v-for="pizza in pizzas" :key="pizza.id">
        <PizzaCard :pizza="pizza" />
      </li>
    </ul>
  </div>
</template>
<script lang="ts" setup>
import { usePizzas } from "@/composables/usePizzas";
import PizzaCard from "@/components/PizzaCard.vue";

const { pizzas } = usePizzas();
</script>

此页面使用来自 示例 11-1 的 PizzaCard 组件和来自 composables/usePizzas.tsusePizzas 组合式,以获取要显示的披萨列表,使用了 示例 13-10 中的代码。

示例 13-10. Pizza House 应用程序的组合式
import type { Pizza } from "@/types/Pizza";
import { ref, type Ref } from "vue";

export function usePizzas(): { pizzas: Ref<Pizza[]> } {
  return {
    pizzas: ref([
      {
        id: "1",
        title: "Pina Colada Pizza",
        price: "10.00",
        description:
          "A delicious combination of pineapple, coconut, and coconut milk.",
        image:
      "https://res.cloudinary.com/mayashavin/image/upload/Demo/pina_colada_pizza.jpg",
        quantity: 1,
      },
      {
        id: "2",
        title: "Pepperoni Pizza",
        price: "12.00",
        description:
          "A delicious combination of pepperoni, cheese, and pineapple.",
        image:
      "https://res.cloudinary.com/mayashavin/image/upload/Demo/pepperoni_pizza.jpg",
        quantity: 2,
      },
      {
        id: "3",
        title: "Veggie Pizza",
        price: "9.00",
        description:
          "A delicious combination of mushrooms, onions, and peppers.",
        image:
      "https://res.cloudinary.com/mayashavin/image/upload/Demo/veggie_pizza.jpg",
        quantity: 1,
        },
    ]),
  };
}

当我们使用 yarn dev 运行应用程序时,将分别在浏览器中看到首页(图 13-5)和披萨页面(图 13-6)。

Pizza House 应用程序的首页

图 13-5. Pizza House 应用程序的首页

Pizza House 应用程序的披萨页面

图 13-6. Pizza House 应用程序的披萨页面

现在,我们将通过将代码从 示例 13-11 添加到 pages/pizzas/[id].vue 来实现披萨详情页面。

示例 13-11. 披萨详情组件
<template>
  <section v-if="pizza" class="pizza--container">
    <img :src="pizza.image" :alt="pizza.title" width="500" />
    <div class="pizza--details">
      <h1>{{ pizza.title }}</h1>
      <div>
        <p>{{ pizza.description }}</p>
        <div class="pizza-stock--section">
          <span>Stock: {{ pizza.quantity || 0 }}</span>
          <span>Price: ${{ pizza.price }}</span>
        </div>
      </div>
    </div>
  </section>
  <p v-else>No pizza found</p>
</template>
<script setup lang="ts">
import { usePizzas } from "@/composables/usePizzas";

const route = useRoute(); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)

const { pizzas } = usePizzas();

const pizza = pizzas.value.find(
    (pizza) => pizza.id === route.params.id ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
);
</script>

1

使用 useRoute,Vue Router 的全局组合式,获取当前路由的信息。

2

route.params.id 是 URL 中披萨的 id。

当我们访问 /pizzas/1 时,将在浏览器中看到披萨详情页面(图 13-7)。

id 为 1 的披萨详情页面

图 13-7. id 为 1 的披萨详情页面

与常规的 Vue 应用程序不同,我们不能将路由参数 id 映射到 PizzaDetails 组件的 id 属性。相反,我们需要使用 useRoute 组合式获取当前路由的信息,包括其参数。

接下来,我们将通过将代码从 示例 13-12 添加到 layouts/default.vue 文件来实现应用程序的默认布局。

示例 13-12. Pizza House 应用程序的默认布局
<template>
    <nav>
        <NuxtLink to="/">Home</NuxtLink> ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/1.png)
        <NuxtLink to="/pizzas">Pizzas</NuxtLink>
    </nav>
    <main>
        <slot /> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/lrn-vue/img/2.png)
    </main>
</template>
<style scoped>
nav {
    display: flex;
    gap: 20px;
    justify-content: center;
}
  </style>

1

NuxtLink 是 Nuxt 组件,用于渲染链接元素,类似于 Vue Router 中的 RouterLink

2

<slot /> 是用于渲染页面内容的插槽元素。

Nuxt 将使用默认布局替换 NuxtLayout,我们将在浏览器中看到导航栏显示(图 13-8)。

Pizza House 应用程序的默认布局

图 13-8. Pizza House 应用程序的默认布局

我们还可以在 layouts 中创建不同的布局文件,并将所需的布局文件名传递给 NuxtLayoutname 属性。Nuxt 将根据其值选择适当的布局组件进行渲染。例如,我们可以创建一个名为 layouts/pizzas.vue 的新布局文件,并使用 示例 13-13 中的代码。

示例 13-13. Pizza House 应用程序的披萨布局
<template>
    <h1>Pizzas Layout</h1>
    <main>
        <slot />
    </main>
</template>

app.vue中,我们将布局名称有条件地传递给NuxtLayoutname props(示例 13-14)。

示例 13-14. 使用 pizzas 布局的 PizzaDetails 组件
<template>
    <NuxtLayout :name="customLayout">
        <NuxtPage />
    </NuxtLayout>
</template>
<script setup lang="ts">
import { computed } from "vue";

const customLayout = computed(
    () => {
      const isPizzaLayout = useRoute().path.startsWith("/pizzas/");
      return isPizzaLayout ? 'pizzas' : 'default';
    }
);
</script>

当我们转到/pizzas/1时,我们将看到使用layouts/pizzas布局呈现的披萨详情页面(图 13-9)。

注意

除了pages结构外,其余应用结构与常规 Vue 应用相同。因此,将 Vue 应用转换为 Nuxt 应用非常简单。

通过 SSR,我们可以实现更快的初始页面加载和更好的 SEO,因为浏览器接收到我们应用的完全填充的 HTML 文件。然而,SSR 的缺点是,每次浏览器刷新时,与单页面应用程序相比,应用需要完全重新加载。^(5)

使用披萨布局呈现的披萨详情页面

图 13-9. 使用自定义布局呈现的披萨详情页面

此外,由于 SSR 需要在服务器上动态填充页面内容后再将页面内容文件返回给浏览器,这可能导致页面渲染延迟,并且任何需要页面内容更改的交互都可能导致多个服务器请求,从而影响整体应用性能。我们可以使用静态页面生成器(SSG)方法来解决这个问题。

静态页面生成器(SSG)

静态页面生成器(SSG)是一种服务器端渲染的类型。与常规服务器端渲染不同,SSG 将在构建时生成并索引应用程序中的所有页面,并根据需要将这些页面提供给浏览器。通过这种方式,它确保了客户端的初始加载和性能。

注意

这种方法适用于不需要动态内容的应用,例如博客、文档等。但是,如果您的应用包含用户生成的内容(认证等),请考虑使用 SSR 或者混合方法

在 Nuxt 中使用 SSG 非常简单。我们可以在同一个代码库中使用yarn generate命令。该命令将生成应用的静态文件,存储在dist目录中,准备部署。

generate命令将生成应用的静态文件,存储在.output/public目录中,准备部署(图 13-10)。

运行 yarn generate 后的构建结果截图

图 13-10. 运行yarn generate后的.output目录

就是这样了。最后一步是将dist目录部署到静态托管服务,例如 Netlify、Vercel 等。这些托管平台将使用带缓存的内容交付网络(CDN)将静态文件按需提供给浏览器。

最后一点

在本章中,我们学习了如何使用 Nuxt 构建 SSR 和 SSG 应用程序。通过这一点,我们在本书中的旅程也告一段落。

我们已经涵盖了 Vue 的所有基础知识,包括核心概念、Options API、Vue 组件的生命周期,以及如何有效地使用 Composition API 在 Vue 应用中创建强大和可重用的组件系统。我们还学习了如何集成 Vue Router 和 Pinia,以创建具有路由和数据状态管理的完全可工作的 Vue 应用程序。我们探索了开发 Vue 应用程序流程的不同方面,从使用 Vitest 进行单元测试和使用 Playwright 进行端到端测试,到使用 GitHub workflows 创建部署流水线并使用 Netlify 进行托管。

现在你已经准备好探索更高级的 Vue 主题,并且具备了构建自己的 Vue 项目所需的技能。那么,接下来应该怎么做呢?有许多可能性等待着你。开始构建你的 Vue 应用程序并进一步探索 Vue 生态系统。如果你想开发基于内容的网站,请考虑深入了解 Nuxt。如果你对为 Vue 制作 UI 库感兴趣,请查看 Vite 和像原子设计这样的设计系统概念。

无论你的选择是什么,你在 Vue 中学到的技能将始终在你成为优秀的前端工程师和 Vue 开发者的旅程中派上用场。希望这本书能成为你在这条路上的参考和基础。

开发 Web 应用程序,特别是使用 Vue,是一件有趣而令人兴奋的事情。开始创造并分享你所实现的成就吧!

^(1) SEO 是使你的应用程序更适合搜索引擎在搜索结果中索引的过程。

^(2) 用户可以与页面进行交互的时间点。

^(3) 用户第一次看到内容的时间点。

^(4) Express.js 是一个 Node.js Web 应用程序框架。

^(5) 单页面应用程序是一种方法,可以动态地用新数据替换当前视图,而无需重新加载整个页面。

posted @ 2025-11-20 09:29  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报