Vue3-现实世界的-Web-应用构建指南-全-

Vue3 现实世界的 Web 应用构建指南(全)

原文:zh.annas-archive.org/md5/434c5c4bb36768d96095d78b60003efe

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 Vue.js 社区,这是最友好的前端社区之一。Vue.js 是一个前端框架,它允许您轻松地构建高性能的交互式 Web 应用程序。Vue.js 的学习曲线很浅——入门很容易!本书将指导您开始创建 Vue.js 应用程序,随着复杂性和规模的增加,您将逐步深入。

除了向您展示技术和用 Vue.js 教授最佳实践外,本书的章节还旨在教授您关于通用开发实践的知识。本书让您在处理新技术、实施第三方解决方案或编排更复杂的应用程序结构时,体验不同的方法。

有许多资源专注于非常具体的用例,甚至单个组件。本书的目的是提供一个现实且广泛的 Vue.js 开发者职责和期望的视角。每一章都将产生一个功能性的应用程序。每个应用程序都会引入一个新概念,以便您熟悉。

我已经设计了章节,使其以自然的方式逐步推进,每章都会增加复杂性。我将在多个章节中迭代地强化概念。我在复杂的企业级环境中使用 Vue.js 的经验,以及我在采用有用实践和与第三方解决方案合作的经验,塑造了结构和章节的重点。

作为一名导师和教练,我试图向您展示并指导您通过常规的开发流程——不是一开始就编写完美的代码,而是接受重构步骤,并在应用程序增长的过程中改进软件。

本书的主要目标是不仅让您学习和理解 Vue.js 及其生态系统,而且通过建立一个展示您作为专业 Web 开发者能力的作品集,帮助您获得 Vue.js 开发者的工作。

使用 Vue.js、Nuxt、Pinia 和 Vite 等技术使我们能够构建最野心的应用程序。这仅仅是因为核心维护者和众多贡献者不懈的努力,他们构建并发布了开源软件。请考虑捐赠或参与,以表达您的支持。对这些框架或库的任何贡献都受到欢迎,并且非常必要,以保持维护和开发对我们所有人都有益的软件。

本书面向对象

本书旨在面向对基于 Web 的技术有偏好的软件工程师。任何有软件工程背景的人都应该能够快速掌握本书中的概念。

主要目标是为初学者或初级开发者提供指导,使他们熟悉 Vue.js 和前端技术及实践。本书帮助他们建立广泛的主题经验,这有助于他们更成功地申请 Vue.js 开发者的职位。

虽然不是主要焦点,但任何对 Vue.js 生态系统感兴趣的软件工程师都可以很好地通过这些章节,建立起使用 Vue.js 作为构建应用程序框架的可能性的广泛认识。

如果你喜欢目前流行的前端框架或库之一,如 React、Angular、Svelte 或 Qwik,你将在掌握诸如响应性、测试和从 API 获取数据等概念方面有先发优势。如果你希望过渡到以 Vue.js 为导向的职位,这本书将帮助你快速掌握 Vue.js 的常见实践方法。

本书涵盖的内容

第一章Vue.js 简介,解释了我们构建 Vue.js 应用程序所需的核心概念。它将帮助你设置一个具有 Vue.js 开发推荐设置的开发环境。

第二章创建待办事项列表应用,建立在核心概念之上,并解释了创建交互式 Web 应用程序的关键概念——响应性。它还介绍了开发和调试工具,作为维护和检查应用程序的重要工具。

第三章构建本地天气应用,探讨了外部数据作为 Web 应用程序资源的概念。它将向应用程序添加测试框架,我们可以使用它通过识别用户流程的满意和不满意情况以及如何处理这些情况来提高应用程序的健壮性。

第四章构建漫威探索者应用,大量依赖于与外部数据的交互和连接到公共 API 以获取用户所需的数据。它使用可组合的(一种用于处理有状态逻辑的概念)来使用和重用函数,并组合更复杂的行为。通过利用默认的路由器,我们将向应用程序引入多个视图和路由。

第五章使用 Vuetify 构建食谱应用,教你如何使用第三方库,如 Vuetify,快速构建界面。它将通过迭代加强使用 API 的工作概念,并介绍 Pinia,这是 Vue.js 的默认状态管理库。使用 Pinia,你将学习如何在浏览器中持久化状态。你将学习重构技术,并体验处理不断变化的功能和需求。

第六章使用数据可视化创建健身追踪器,继续探讨持久化状态的话题,并教你如何在外部数据库中存储数据,以及如何向 Web 应用程序添加入门级认证。它展示了在构建功能时抽象和更实用方法之间的权衡,并回顾了重构策略。

第七章使用 Quasar 构建跨平台支出跟踪器,转向了不同的方向,在这里你将学习如何使用 Web 技术通过框架为单个甚至多个非 Web 平台构建应用程序。它通过回顾类似的技术堆栈,但考虑到不同的目标和功能,继续巩固之前学到的主题。

第八章构建交互式测验应用,深入探讨了从后端到前端开发应用程序的过程,并包括了一些架构概念和决策。它介绍了 Nuxt,这是 Vue.js 生态系统中最受欢迎且对开发者友好的元框架之一。你将交互使用 WebSockets,并了解如何同时创建多个客户端之间的实时交互性。

第九章使用 TensorFlow 进行实验性物体识别,教你原型设计实践,以及如何在隔离环境中通过实验熟悉新技术。它还涉及到早期针对特定目标进行开发和测试。最重要的是,它教你在新项目构建过程中保持乐趣,让你在个人持续发展中保持兴趣和动力。

第十章使用 Nuxt.js 和 Storyblok 创建个人作品集,回到了 Nuxt 作为生成代码的框架,而不是作为实时 Web 服务器。这一章允许你创建一个个人项目,在阅读本书的过程中展示你的开发才能。它将应用程序连接到一个无头内容管理系统CMS),并教你如何自动化部署等任务。

为了充分利用这本书

为了理解我们将要介绍的概念,具备 HTML、CSS 和 JavaScript 的经验以及基本的网络工程知识是非常推荐的。

本书涵盖的软件/硬件 操作系统要求
Vue.js 3 Windows、macOS 或 Linux
Vue Test Utils
Nuxt 3
Pinia
Vuetify
Tailwind
Chart.js
Quasar
Express.js
Socket.io
TensorFlow.js

提到的库将在它们被介绍和使用的章节中提供安装说明。为了跟随代码,你可以在文本中找到指向较长代码块的链接,这些链接指向的是本书的一部分的仓库。

每一章的完整代码也可以从仓库中下载,或者仅作为参考进行查看。在某些情况下,仓库本身可能需要通过第三方提供商进行额外设置才能正常工作。这些说明都列在相关章节中。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

如果您在构建项目时遇到问题,您始终可以参考 GitHub 仓库中的源代码。每个章节都包含一个 TROUBLESHOOTING.md 文件,以帮助您解决问题。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!

Code in Action

本书配套的 Code in Action 视频可以在 bit.ly/2OQfDum 上观看。

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“v-for 指令使用 <ListItem /> 组件重复 <li> 项目。”

代码块应如下设置:

<style scoped>ul {
  list-style: none;
}
li {
  margin: 0.4rem 0;
}
</style>

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

<ListItem :is-checked='item.checked' v-on:click.prevent="updateItem(item)">{{ item.title }}</ListItem>

任何命令行输入或输出都应如下编写:

npm init vue@latest

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击 保存 以关闭外键属性。”

小贴士或重要提示

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com 并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

copyright@packt.com 并附有材料链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com

分享您的想法

读完《使用 Vue.js 3 构建真实世界 Web 应用程序》后,我们非常乐意听到您的想法!请访问 packt.link/r/1837630399 并分享您的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?

不要担心,现在,随着每本 Packt 书籍的购买,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用中。

好处不止于此,你还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容。

按照以下简单步骤获取这些好处:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781837630394

  1. 提交你的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他好处发送到你的邮箱。

第一部分:Vue.js 开发入门

在这部分,你将熟悉 Vue.js 框架的核心基础,并学习如何将这些基础应用到创建交互式 Web 应用中。你将学习如何设置项目和确保通过自动化测试的稳定性。最后,我们将学习如何处理外部数据和构建多页面应用。

本部分包含以下章节:

  • 第一章, Vue.js 简介

  • 第二章, 创建待办事项应用

  • 第三章, 构建本地天气应用

  • 第四章, 构建漫威探索应用

第一章:Vue.js 简介

这本书将使你熟悉目前最受欢迎的现代前端框架之一:Vue.js。现代框架使得向静态网页添加交互性变得容易,并可以帮助你构建完整的网络应用程序!

后者正是你在阅读本书时会学习到的内容。你将学习适合特定用例的不同技术,这将导致一系列可供展示的项目集合。

在你开始构建项目之前,我们将看看我们将构建这些项目的背景。在本章中,我们将使用最佳实践来设置开始任何 Vue.js 项目的环境。

在本章中,我们将涵盖以下主题:

  • Vue.js 的需求

  • 需求和工具

  • 设置开发环境

  • 我的第一个应用程序

技术要求

在我们开始之前,我们需要确保我们已经满足了安装和运行 Vue.js 以及开发应用程序的所有要求。熟悉 HTML、CSS、JavaScript 以及 TypeScript 是理解这些技术之上的概念所必需的。

为了我们能够在本地运行 Vue.js,我们需要安装 Node.js (nodejs.org/en/download)。这是在开发机器上运行 JavaScript 的运行器。

Vue.js 的需求

目前有大量的前端框架可供选择,这是一个不断发展和变化的领域。在这个时候,Vue.js 生态系统是一个非常成熟的领域,它提供了许多基于它的插件和工具。

Vue.js 是一个由世界各地的人们维护和开发的开源项目,它是一个社区驱动的项目。它最初是由 Evan You 的个人副项目发展而来,并已经成长为一个被各种类型的组织广泛采用的框架,例如 NASA、苹果、谷歌和微软。由于得到了公司和个人的赞助,Vue.js 是一个独立的框架。

Vue.js 目前处于第 3 版,与之前的版本相比有重大变化,尽管大多数模式仍然适用。Vue.js 3 支持所有 ES2015 兼容的浏览器。

让我们看看选择 Vue.js 构建网络应用程序的一些原因:

  • 它的性能出色,因为它是从底层开始优化的。

  • 它轻量级,可摇树(tree-shakeable),只包含运行应用程序所需的代码。经过构建步骤优化后的最小代码(大小约为 16 KB)。

  • 它具有高度的可扩展性,使用如单文件组件和组合式 API 等首选模式,这使得它适合企业级应用。

    单文件组件 是 Vue.js 哲学的一部分,其中组件的模板、脚本和样式封装在一个单独的文件中,目的是提高代码的组织、可读性和可维护性。

    组合式 API 允许在整个应用程序中对代码进行更好的组织和重用,这使得代码更加模块化且易于维护。

在所有这些好处之上,对于入门级开发者来说,学习曲线非常容易上手。由于其语法与经典的 HTML、JavaScript 和 CSS 表示法相似,因此很容易开始并找到自己的路径。

在本章中,我们将引导你完成初始设置,并介绍你可以用作所有未来 Vue.js 项目样板的所有步骤和设置。我们将采用推荐的设置,以确保你从一开始就能学习和应用最佳实践。

我们首先确保你设置了开发者环境,这样你就可以开始创建交互式网络应用程序!

需求和工具

为了高效地开始 Vue.js 开发,我们需要确保你能够合理地运行和编辑代码。虽然技术上可以使用来自内容分发网络CDN)的库来运行代码,但这对于实际应用来说并不推荐。正如官方文档(vuejs.org/guide/introduction.html)也指出,这里不涉及构建设置,其缺点是这种设置不支持单文件组件语法,并且让你在应用程序的优化(如编译、压缩和懒加载)方面几乎没有控制权。

在这本书中,我们将使用 Vue.js 的 npm 包,然后使用它来搭建起始项目以构建。我们将使用命令行启动所有项目。要使用 npm 包,你需要安装 Node.js(一个 JavaScript 运行时环境)。请确保安装至少 Node.js 版本 18。npm 是一个公共仓库(www.npmjs.com/),开发者在这里发布、共享和使用 JavaScript 包。

你可以从 nodejs.org/en 下载 Node.js。你可以通过打开命令行界面CLI)并输入以下内容来确认正确安装了版本:

node -v

应该返回类似以下内容:

v18.0.0

总是可以在没有任何本地安装的情况下开发和实验。我们可以转向基于网络的平台来实现这一点。这些环境提供了开箱即用的沙箱环境,并具有合理的预设。这意味着它们通常配置为使用官方文档中推荐的设置。它们提供的控制较少,并且在更具体的用例中有些受限。然而,如果你想要尝试一些概念,它们是非常有价值的。

在线资源

Vue.js 提供了一个在线开发沙盒,sfc.vuejs.org/,但我想要指出StackBlitzstackblitz.com/),在那里你可以实例化在浏览器中运行的完整开发环境。虽然它对于部署应用程序没有用,但这是一种测试概念证明或将其用作小型沙盒的极好方式。

你只需注册,开始一个新项目,并选择一个 Vue.js 3 模板即可开始。代码示例将在 GitHub 上提供,你可以克隆或分叉存储库以验证你的代码与工作示例的一致性。

为了方便查阅,Vue.js 文档(vuejs.org/guide/introduction.html)非常易于访问,并提供了所有可能上下文的逐步解释。我肯定会推荐你查看它们,以更深入地了解我们将要讨论的主题。

一旦你参与到 Vue.js 社区中,你会发现它是一个非常有帮助、支持性强且友好的社区。再次强调,官方 Vue.js 网站提供了一些指导(vuejs.org/about/community-guide.html)。

欢迎加入社区,现在让我们开始吧!

设置开发环境

编写和编辑代码有许多方法,随着时间的推移,你将找到最适合你的流程。对于这本书,我们将使用通常推荐的设置开始。请随意进行对你有用的更改。

Vue.js 开发在一个环境中进行,这个环境允许你通过突出显示正确代码并帮助你捕捉在保存更改之前出现的错误来高效地编写代码。调试和验证代码可以在多个阶段进行,但在这个书籍的范围内,我们将使用开发界面以及浏览器环境。

让我们从安装一个广泛使用的开发界面开始。

集成开发环境

集成开发环境IDE)通过支持语法高亮、格式化和与所选框架集成的插件等辅助工具,帮助你编写和编辑代码。任何现代编辑器都可以,但在这本书中,我们将使用微软的 Visual Studio CodeVSCode),它是免费使用的,并提供良好的开发者体验;可以从 code.visualstudio.com/ 下载。

在安装 IDE 之后,我推荐以下插件,这些插件可以让开发者的体验变得更加愉快:

  • Vue 语言特性Volar):支持 Vue.js 3 片段的标记和突出显示

  • Vue Volar 扩展包:添加一些推荐的插件,帮助在编码时自动化一些琐事

  • 更好的注释:用于在代码中更好地标记注释

  • 缩进彩虹:为缩进的代码块应用颜色,以快速识别缩进级别

Vue.js 可以使用许多其他 IDE 进行开发,其他 IDE,如 WebStorm、Sublime Text、Vim/NeoVim 和 Emacs。选择适合你的,记住,截图将使用之前描述的推荐 VSCode 设置进行展示。

Vue.js DevTools

今天的浏览器都内置了工具,允许网络开发者检查和操作网页的 HTML、CSS 和 JavaScript 代码,测试和调试他们的代码,测量页面性能,并模拟各种设备和网络条件。

macOS 用户可以使用 Cmd + Option + I 打开 DevTools。Windows 或 Linux 用户可以使用 Ctrl + Shift + I

值得注意的是,当你检查浏览器中的元素时,你将看到的元素是 Vue.js 渲染的元素!如果你检查浏览器的源代码,你将只看到应用的挂载点。这是虚拟的文档对象模型DOM)在起作用,我们将在稍后对此进行澄清。

由于 Vue.js 通常在浏览器环境中运行,使用 DevTools 是一项与编写干净代码一样有价值的技能。对于基于 Chromium 的浏览器和 Firefox,Vue.js 提供了一个标准插件。

Vue.js DevTools 插件可以帮助你在浏览器中运行时检查和操作 Vue.js 组件。这将有助于定位错误并更好地理解应用状态是如何转换为用户界面UI)的。

注意

你可以在此处找到更多信息并安装插件:vuejs.org/guide/scaling-up/tooling.html#browser-devtools

我们将在稍后的阶段深入探讨 Vue.js DevTools。到目前为止,我们已经满足了启动任何规模(无论是小型还是大型)的 Vue.js 应用的所有要求。它们都满足相同的基本要求。

到目前为止,你可能已经迫不及待地想要开始第一个项目了,所以让我们创建一个小型应用来熟悉开发过程。

我的第一款应用

让我们通过创建我们的第一个 Vue.js 应用来检验我们所获得的工具和知识,怎么样?

你通常会先打开 CLI 并导航到你想要开始项目的文件夹。输入以下命令将使用官方的 create-vue 工具创建一个新的空项目:

npm init vue@latest

y 继续操作,将 my-first-vue 作为项目名称,并选择以下图中显示的选项:

图 1.1 – 使用 Vue CLI 使用预设搭建应用

图 1.1 – 使用 Vue CLI 使用预设搭建应用

我们选择了 TypeScript 作为 JavaScript 的超集,它增加了静态类型。我们还启用了 ESLint 和 Prettier。ESLint 检查语法错误、格式问题和代码风格不一致,甚至可以与你的 IDE 集成,以视觉方式标记有问题的代码。Prettier 用于强制执行一致的代码风格。这三个选项通过在运行代码之前突出显示潜在问题来增强开发者的体验。

然后,按照说明,你可以进入创建的文件夹,并输入 npm install 来安装所需的依赖项。这将从 npm 注册表中下载所需的包文件,并将它们安装到项目的 node_modules 子文件夹中。

如果你运行 npm run dev,项目将启动一个开发服务器,你可以通过浏览器访问它。通常,本地地址将类似于 http://127.0.0.1:5173/

如果你在这个浏览器中打开那个 URL,你应该能看到你的第一个 Vue.js 应用程序!默认情况下是一个空的起始项目,其中包含了许多我们到目前为止已经覆盖的指针和链接,但对于任何开始使用 Vue.js 的开发者来说,这是一个很好的起点。

图 1.2 – 你的第一个 Vue.js 应用程序!

图 1.2 – 你的第一个 Vue.js 应用程序!

在成功安装后,我们可以更仔细地看看实际上安装了什么。让我们深入了解安装文件!

在 IDE 中的项目

现在,如果你在你选择的 IDE 中打开项目,你会注意到一个预定的结构。这适用于所有以这种方式构建的项目。让我们快速看一下结构:

图 1.3 – 起始应用的展开文件夹结构

图 1.3 – 起始应用的展开文件夹结构

在项目的根目录下,你会找到一些特定于配置项目的文件类型。这里的主要文件是 index.htmlpackage.jsonindex.html 文件是应用的入口点。它是一个轻量级的 HTML 模板,包含一个 div 元素,该元素的 id 将成为应用的挂载点。

package.json 文件是一个描述项目作为包的文件,定义了可以执行的节点脚本,并包含了项目所依赖的所有包的引用。node_modules 文件夹是包含从 package.json 文件中安装的所有包的文件夹。从这个目的来看,它可以被认为是一个只读文件夹。

然后我们有 publicsrc 文件夹。public 文件夹包含静态资源,如字体、图像和图标,这些资源不需要由构建系统处理。在起始项目中,你会找到一个默认的 favicon.ico

最后,src(代表源文件)文件夹是我们将进行最多更改的文件夹。目前它包含两个根文件。main.ts 文件注册 Vue 应用程序并应用样式,并将其挂载到 HTML 模板上。

App.vue 文件是 Vue.js 应用的入口点。如果你打开它,你可能会在单个文件中找到一些熟悉的语法混合,例如脚本标签、HTML 和 CSS。我们稍后会详细介绍。

它还包含一个与public文件夹类似的assets文件夹,不同之处在于这些文件夹可以被并且将被构建系统处理。最后,还有components文件夹,你可以将构成应用程序的组件放在这里。如果你采用单文件组件,每个组件都将承担特定的角色,并封装模板、脚本和样式。你已经开始看到一些组件,它们构成了默认的起始页面。

您的第一步编码

让我们创建第一个组件并将其添加到应用程序中:

  1. components文件夹中创建一个名为MyFirst.vue的新文件。

    Vue.js 组件最好使用至少两个驼峰式命名的单词来命名,通常由scripttemplatestyle块组成。这些都不是强制性的(尽管没有上下文的话,style块几乎没有价值)。

  2. 让我们创建一个小 HTML 片段:

    <template>    <div>My first <span>Vue.js</span> component!</div></template>
    
  3. App.vue中,你可以将其作为 Vue.js 组件使用!如果你打开它,你会看到一个带有import语句的script标签。你可以移除TheWelcome导入行,并用以下内容替换它:

    import MyFirst from './components/MyFirst.vue'
    
  4. 接下来,在template标签中,你可以移除类似 HTML 的<TheWelcome />标签,并用<MyFirst />HTML 标记替换它。

    如果你仍在运行代码,你会注意到浏览器已经更新了自己以反映这些更改。这被称为热重载,使得开发流程更加顺畅。如果你已经停止了进程,你可以重新启动它,并在浏览器中重新访问页面。

    你应该能看到你创建的组件!

  5. 让我们在组件中添加一个样式块,添加一些 CSS,并查看热重载的效果。在MyFirst.vue文件中,在template块下方添加以下代码:

    <style scoped>div {  color: #35495f;  font-size: 1.6rem;}span {  color: #41b883;  font-weight: 700;}</style>
    

    样式块的内容将被像正常 CSS 文件一样处理。scoped属性意味着divspan样式定义仅限于这个组件。Vue 会给虚拟 DOM 添加一个唯一的数据属性,并将 CSS 规则附加到该属性。在App.vue中,你可以看到也支持全局样式。

在浏览器中,你会看到组件自己更新以应用新的样式!现在我们已经熟悉了开发环境,我们将在下一章开始创建一个更具交互性的组件。

摘要

到目前为止,你已经准备好开始使用 Vue.js 进行开发了。我们已经完成了本地环境的设置,并使用了推荐的脚手架方式来创建一个新的起始项目。

在下一章中,我们将更详细地探讨一些 Vue.js 概念(例如响应性),并学习使用官方工具检查我们的应用程序。每一课都将介绍在开发中应用的新概念。

第二章:创建待办事项应用

现在我们已经设置了开发环境,我们将开始编写一个小型的第一个应用。在本章中,我们将创建一个待办事项应用,这将教会我们 Vue.js 的响应性和虚拟 DOM 是如何工作的。您可以将待办事项应用作为本书记录进度的指南!

让我们将其变成一个具有一些实际要求的作业:

  • 我们将确保您能看到一个项目列表

  • 每个项目都将有一个复选框

  • 列表将首先按未勾选的项目排序,然后是勾选的项目

  • 项目状态应该在未来的访问中由浏览器保留

有多种方式可以编写有效的 Vue.js 组件。目前,Composition API 比 Options API 更受欢迎。Options API 使用面向对象的方法,而 Composition API 允许以更可重用的方式编写和组织代码。

在本书中,除非另有说明,我们将使用带有简写的 Composition API 来表示 setup 函数。这种方式编写代码从组件中移除了许多噪音和重复的操作,是一种非常高效的工作方式。我们还将使用 TypeScript 变体,因为它默认支持,并通过支持严格的类型提供更好的开发者体验DX)。

注意

您可以在此处了解更多关于语法的详细信息:vuejs.org/api/sfc-script-setup.html#script-setup。更多关于使用 TypeScript 定义组件的信息请在此处查看:vuejs.org/guide/typescript/composition-api.html#using-script-setup

本章我们将涵盖以下主题:

  • 使用 CLI 工具为应用创建自定义环境

  • Vue.js 响应式概念

  • 使用 CSS 进行样式化

  • 初探 Vue.js DevTools

我们没有比上一章中提到的更多的技术要求,因此我们可以立即开始!

一个新项目

让我们首先通过使用上一章中的 CLI 命令来搭建一个新的项目。在您的projects文件夹中打开一个终端窗口,并使用以下说明:

npm init vue@latest

y继续,使用vue-todo-list作为项目名称,并选择以下截图所示的选项:

图 2.1 – 待办事项应用的设置配置

图 2.1 – 待办事项应用的设置配置

按照给定的说明安装依赖项,并打开您最喜欢的 IDE 开始。

小贴士

npm提供了简写来安装,只需输入npm i而不是npm install。更多关于npm命令的信息请在此处查看:docs.npmjs.com/cli/v6/commands

清理默认安装

让我们首先清理components文件夹,移除HelloWorld.vueTheWelcome.vueWelcomeItem.vueicons文件夹。然后我们从App.vue中移除引用并清理模板。

你将在components文件夹中看到一个__tests__文件夹,这是通过安装 Vitest 添加的。现在你可以忽略它。否则,components文件夹应该是空的。

App.vue文件应该看起来像这样:

<script setup lang="ts"></script>
<template>
</template>
<style scoped>
… (truncated, unchanged)
</style>

由于我们移除了所有默认元素,这将导致出现空白页面!现在我们可以从头开始构建自己的应用。

构建应用

在本章中,我们将添加一些组件并将 Todo 应用组合起来以满足本章开头列出的要求(参见技术要求)。我们将逐步添加功能。

让我们从简单开始,创建一个AppHeader组件。在components文件夹中创建一个AppHeader.vue文件(记住:Vue.js 建议文件名由至少两个驼峰式单词组成)。这只是一个静态组件,包含一个template和一个css块:

<template>  <header>
    <h1><span class="icon" aria-hidden="true">✅</span> To do</h1>
    <p>Building Real-world Web Applications with Vue.js 3</p>
  </header>
</template>
<style scoped>
header {
  border-bottom: #333 1px solid;
  background-color: #fff;
}
header::after {
  content: "";
  display: block;
  height: 1px;
  box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.75);
}
h1 {
  font-size: 2rem;
}
h1 .icon {
  font-size: 1rem;
  vertical-align: middle;
}
</style>

Vue 组件通常由模板、脚本和样式块组成。并非所有都是必需的(我们不会向这个组件添加任何脚本),但在这三个块之间,我们可以定义组件的各个方面。在这本书中,我们将看到许多这种模式在实际中的应用示例。

模板只是标题的表示,我们将使用scoped样式块来应用 CSS 规则到标记。注意scoped属性,它确保我们的 CSS 不会影响应用中的其他组件。

使用scoped CSS,我们可以编写干净、可读的规则。对于单文件组件,这种方法应该是默认的。

让我们继续构建我们的应用,通过创建一个列表组件。

创建ListItem组件

我们将在同一个文件夹中创建ListItem.vue组件。它将是列表中单个项目的表示,看起来像这样:

<script lang="ts" setup>defineProps<{
  isChecked?: boolean | false
}>()
</script>
<template>
  <label :class="{ 'checked': isChecked }">
    <input type="checkbox" :checked="isChecked" />
    <slot></slot>
  </label>
</template>
<style scoped>
label {
  cursor: pointer;
}
.checked {
  text-decoration: line-through;
}
</style>

我们定义将传递给组件的属性。属性是可以从组件外部控制的属性。这些通常是组件正在处理的值,并决定了该状态下组件的独特特征。在罕见的情况下,你也可以传递一个函数。

通过使用defineProps方法,我们正在使用 Vue.js API 正确地声明我们的属性。

第二部分是组件应该如何将 HTML 渲染到虚拟 DOM 中的方式。Vue.js 使用基于 HTML 的语法。你可以在这里了解更多信息:vuejs.org/guide/essentials/template-syntax.html#template-syntax

我们使用动态类名标记一个 HTML <label> 标签:当 isChecked 属性评估为 true 时,它将渲染为 class="checked"。在标签中,我们将添加一个具有动态 checked 属性的复选框:它也连接到 isChecked 属性。<slot></slot> 标签是 Vue.js 特有的,它允许我们在该位置放置任何内容,从父组件中。

最后,我们为这个组件定义 CSS 规则,类似于我们在 AppHeader.vue 中所做的那样。

创建列表

由于我们有 ListItem 组件可用,我们可以开始生成列表。我们将为此创建一个新的组件,它将包含列表信息,并使用它来渲染列表上的所有单个项目以及提供交互功能:

  1. 让我们创建一个简单的名为 TodoList.vue 的文件,其内容如下:

    <script lang="ts" setup>import ListItem from './ListItem.vue'</script><template><ul>  <ListItem :is-checked="false">This is the slotted content</ListItem></ul></template>
    
  2. 在我们继续之前,我们希望在开发过程中能够显示我们的应用程序。因此,在 App.vue 文件中,遵循类似的导入 AppHeader.vueTodoList.vue 文件并将组件添加到模板中的方法:

    <script setup lang="ts">import AppHeader from './components/AppHeader.vue';import TodoList from './components/TodoList.vue';</script><template>  <AppHeader />  <TodoList /></template><style scoped>… (truncated)</style>
    

现在我们已经可以看到我们在做什么了,这是一个开始启动开发服务器的良好时机。如果你使用 Visual Studio CodeVSCode),实际上 IDE 中有一个内置的终端:

  • macOS 用户 + `

  • Windows 用户Ctrl + `

  • Linux 用户Ctrl + Shift + `

如果你运行 npm run dev 命令,它将启动开发服务器并提供你一个本地预览 URL。

图 2.2 – npm run dev 命令的输出

图 2.2 – npm run dev 命令的输出

由于我们目前还没有一个功能性的应用程序,我们需要专注于其核心功能:列表。你可以让开发服务器继续运行,因为它将自动更新(这被称为热重载)新编写的代码。

制作列表

实际的功能位于 TodoList.vue 组件中,我们现在将创建它。我们将从小处着手,逐步添加更复杂的功能。让我们从一个具有多个列表状态的静态列表开始。

让我们首先看看 script 块。除了导入 ListItem 组件外,我们还在 Item 上定义了 type,它由一个字符串类型的 title 属性和一个可选的 checked 属性(布尔类型)组成。TypeScript 允许我们定义 Type 别名,我们的 IDE 可以在交互 Type 时将其插入。

在本例中,当在 ListItem 模板中访问 item 的属性时,IDE 已经识别出 title 和可选的 checked 属性:

<script lang='ts' setup>import ListItem from './ListItem.vue'
type Item = {
  title: string,
  checked?: boolean
}
const listItems: Item[] = [
  { title: 'Make a todo list app', checked: true },
  { title: 'Predict the weather', checked: false },
  { title: 'Play some tunes', checked: false },
  { title: 'Let\'s get cooking', checked: false },
  { title: 'Pump some iron', checked: false },
  { title: 'Track my expenses', checked: false },
  { title: 'Organize a game night', checked: false },
  { title: 'Learn a new language', checked: false },
  { title: 'Publish my work' }
]
</script>

当构造 ListItems 数组时,我们使用 [] 符号将 Type 作为该类型的数组赋值。我们立即用项目列表填充 ListItems 数组。这意味着 TypeScript 也可以推断类型,但最好在可能的情况下显式设置类型。

在模板中,我们创建一个无序列表元素,并使用 v-for 指令遍历数组中的项目:

<template>  <ul>
    <li
      :key='key'
      v-for='(item, key) in listItems'
    >
      <ListItem :is-checked='item.checked'>{{ item.title }}</ListItem>
    </li>
  </ul>
</template>

v-for指令用于遍历集合并重复标记集合的模板。对于每个项目,当前值被分配给第一个参数(item),并且可选地提供集合的索引作为第二个参数(key)。

v-for指令重复使用<li>项,并用包含<ListItem />组件的<li>项。对于每个项目,我们用该项目的is-checkedtitle属性填充<ListItem />组件。

key属性帮助 Vue.js 跟踪正在进行的更改,以便它可以更有效地更新虚拟 DOM。

最后,我们添加了一个scoped样式块来美化浏览器中的元素。这里并没有太多的事情发生:

<style scoped>ul {
  list-style: none;
}
li {
  margin: 0.4rem 0;
}
</style>

现在我们有一个非交互式的待办事项列表应用,并且已经满足了前两个要求。让我们看看我们如何添加一些交互性。

反应性解释

如果你已经打开了应用并点击了一个项目,你可以切换复选框,但在刷新页面时,什么都没有发生。此外,如果你仔细查看<ListItem />组件的 CSS,你可能已经注意到应该对已勾选的项目应用删除线样式。这只适用于第一个项目。

复选框的切换实际上是浏览器的原生行为,在待办事项列表的状态上下文中并不表示任何内容!

我们需要将 UI 中的更改连接到应用程序的状态。为了开始,我们需要从 Vue.js 包中导入一些实用工具。将这两行代码添加到<script>块的顶部:

import { ref } from 'vue'import type { Ref } from 'vue'

ref函数用于添加反应性并跟踪代码中某些部分的变化。ref的值由 TypeScript 自动推断,但对于复杂类型,我们可以指定类型。

注意

Vue.js 还提供了一个reactive实用工具来标记反应性。这两个工具之间有一些细微的差别,其中ref可以用来跟踪原始值和对象,而reactive只能用对象初始化。一般来说,你可以通过选择ref而不是reactive来保持代码的一致性。唯一的缺点是,你必须通过脚本块中的.value属性来访问反应性项的值。当在template块中使用变量时,Vue.js 会自动展开它。因此,为了能够一致地使用ref,这是一个小的妥协。

现在我们已经导入了实用工具,我们可以通过将内容包装在ref函数中来标记listItems以进行跟踪:

const listItems: Ref<Item[]> = ref([  { title: 'Make a todo list app', checked: true },
  { title: 'Predict the weather', checked: false },
  { title: 'Play some tunes', checked: false },
  { title: 'Let\'s get cooking', checked: false },
  { title: 'Pump some iron', checked: false },
  { title: 'Track my expenses', checked: false },
  { title: 'Organise a game night', checked: false },
  { title: 'Learn a new language', checked: false },
  { title: 'Publish my work' }
])

注意,大写的Ref用于类型化值,而小写的ref用作项目数组的外包装。如果我们现在想在脚本块中访问这些值,我们需要通过listItems.value来访问它们。

现在由于listItems是反应性的,虚拟 DOM 将自动对变量的变化做出响应。我们可以添加一个更改项的方法,以便它在用户界面中得到反映。

让我们在script块中添加以下函数:

const updateItem = (item: Item): void => {  const updatedItem = findItemInList(item)
  toggleItemChecked(updatedItem)
}
const findItemInList = (item: Item): Item | undefined => {
  return listItems.value.find(
    (itemInList: Item) => itemInList.title === item.title
  )
}
const toggleItemChecked = (item: Item): void => {
  item.checked = !item.checked
}

采用罗伯特·C·马丁的《代码整洁之道》哲学,我将指令拆分为具有自己明确意图的单独函数。当用item作为参数调用updateItem时,它会尝试在itemList中找到它,并切换对象的checked属性。

我们可以看到 TypeScript 引导我们到一个稍微更好的解决方案:因为findItemInList可能返回一个undefined值,而toggleItemChecked期望一个参数,所以调用toggleItemChecked函数的参数得到了一条波浪线。

图 2.3 – TypeScript 提示我们代码中可能存在的问题

图 2.3 – TypeScript 提示我们代码中可能存在的问题

我们可以通过添加一个围绕toggleItemChecked函数调用的语句来修复这个问题:

const updateItem = (item: Item): void => {  const updatedItem = findItemInList(item)
  if (updatedItem) {
    toggleItemChecked(updatedItem)
  }
}

完成脚本块的变化后,我们可以在模板块中附加用户界面的交互。我们希望访客能够点击ListItem来标记它为完成。Vue.js 有一个内置指令可以做到这一点:v-on。这充当事件处理器,并支持一些修饰符。更多信息,请参阅vuejs.org/api/built-in-directives.html#v-on

我们可以这样将其添加到模板中:

<ListItem :is-checked='item.checked' v-on:click.prevent="updateItem(item)">{{ item.title }}</ListItem>

我们还添加了.prevent修饰符来防止复选框机制的默认行为。这就是调用方法所需的全部代码!

对于v-on:click,甚至有一个简写,即使用@click。你将在资源中看到这两个指令的示例,所以了解它们是相同的很好。

在底层,Vue.js 使用ref函数在值上注册一系列观察者。模板引擎用于生成虚拟 DOM(组件组成元素的节点树表示)。一旦响应式值发生变化,使用该值的虚拟 DOM 节点也会发生变化。

Vue.js 比较 DOM 的变化,并只更新实际 DOM 中必要的元素以反映状态。能够非常精确地逐个更新 DOM 是 Vue.js 3 成为一个高性能框架的原因,因为它不需要遍历整个虚拟 DOM 节点!

让我们使用我们已有的列表作为下一步的输入,我们将查看排序。

排序列表

我们现在完全能够展示模板中的变量。然而,在某些情况下,你可能需要更高级的表达式,例如,在我们的例子中,需要排序列表。对于没有副作用且包含响应式数据的变量,你可以使用 Vue.js 的computed函数。

通常,你会使用computed进行数据过滤、格式表达式、显示计算或布尔条件。让我们将其应用于将完成项排序到底部的列表。

首先,我们将computed导入到TodoList组件中。我们可以在导入ref函数的地方添加它:

import { ref, computed function is very similar to ref, in the sense that it follows the same reactivity in updating the DOM when the value changes and you can even access the value using the .value property in the script block! The main difference is that a computed value is cached and only updates when one of the inputs changes.
For sorting the list, we can use the `listItems` as input and apply a simple JavaScript sorting function on the array. We can just add this line to define the `computed` value:

const sortedList = computed(() =>    [...listItems.value].sort((a, b) => (a.checked ? 1 : 0) - (b.checked ? 1 : 0))

)


As you can see, `computed` is a function that gets called on a change of the reactive value. In this case, `listItems.value`. We’ll simply apply a `sort` function to the collection.
In the template, we can now swap out `listItems` for the `sortedList` variable and you will see that checked items will be placed below the unchecked items.
Preserving changes to the list
We have a final requirement to achieve now, and that is to preserve the state of the list on reloading and revisiting the app. We’ll keep it as simple as we can for now and use the `localStorage` API of the web browser to store and retrieve the state of the list.
We’ll first add the functions that we can use to write to `localStorage` and retrieve from `localStorage`:

const setToStorage = (items: Item[]): void => {  localStorage.setItem('list-items', JSON.stringify(items))

}

const getFromStorage = (): Item[] | [] => {

const stored = localStorage.getItem('list-items')

if (stored) {

return JSON.parse(stored)

}

return []

}


These two functions interface with browsers’ abilities to store a string of data, so we need to stringify and parse that object. We’re storing the data on the `list-items` key.
Now we need to make sure we try and retrieve the data when the component gets loaded. There’s a function for it, called `onMounted` and it is part of the Vue.js core, so we can import it in a similar fashion to the `ref` and `computed` functions.
The `onMounted` function is what we call a life cycle hook. They are functions that get called at certain points in the *life cycle* of a component. The main life cycle events are triggered when a component gets mounted (or before), gets updated (or before), gets unmounted (or before), and gives an error. More information can be found here: [`vuejs.org/api/composition-api-lifecycle.html#composition-api-lifecycle-hooks`](https://vuejs.org/api/composition-api-lifecycle.html#composition-api-lifecycle-hooks).
In our case, we want the list to be retrieved in the browser when the component gets rendered (it would otherwise have no access to `localStorage`). So, we’ll import the function:

import { ref, onMounted, computed } from 'vue'


 And we need to create a reactive variable to hold the items:

const storageItems: Ref<Item[]> = ref([])


 We’ll also create a function (`initListItems`) that will run once when mounted, and move the initialization of the `listItems` there. We’ll also make a change to the declaration of the `listItems` by wrapping it with a check on the existence of `storageItems`. If they do not exist, we will use the `listItems` as default and write the contents to `localStorage`:

const initListItems = (): void => {  if (storageItems.value?.length === 0) {

const listItems = [

{ title: '制作待办事项应用', checked: true },

{ title: '预测天气', checked: false },

{ title: '阅读一些漫画', checked: false },

{ title: '让我们开始烹饪', checked: false },

{ title: '举铁锻炼', checked: false },

{ title: '跟踪我的开支', checked: false },

{ title: '组织游戏之夜', checked: false },

{ title: '学习一门新语言', checked: false },

{ title: '发布我的作品' }

]

setToStorage(listItems)

storageItems.value = listItems

}

}


Now, we add the following functions to retrieve any locally stored list items:

onMounted(() => {  initListItems()

storageItems.value = getFromStorage()

})


In order to keep the changes in sync, we can now modify the `findItemInList` function to look in the `storageItems` collection rather than `listItems` and also write the change to the storage after the item has been updated. We’ll modify the `updateItem` and `findItemInList` functions as follows:

const updateItem = (item: Item): void => {  const updatedItem = findItemInList(item)

if (updatedItem) {

toggleItemChecked(updatedItem)

setToStorage(storageItems.value)

}

}

const findItemInList = (item: Item): Item | undefined => {

return storageItems.value.find(

(itemInList: Item) => itemInList.title === item.title

)

}


Now, in the template, we’re using a computed value, so we should update the computed function too in order to see `localStorage` as the input for our data:

const sortedList = computed(() =>    [...storageItems.value].sort((a, b) => (a.checked ? 1 : 0) - (b.checked ? 1 : 0))

)


We’ve seen how we can use different components with specific uses to build a simple reactive app and how we can organize our code with readability and maintainability in mind. Vue.js encourages using Single File Components to structure your code.
Single File Components
The way that we have organized the app, with individual components having a single feature to fulfill is referred to as the **Single File Components** (**SFC**) philosophy.
This approach is designed to enhance code readability, maintenance, and reusability. With SFC, you can create reusable and modular components that can be easily shared and reused across different projects.
To be fair, we did cut some corners with the `TodoList.vue` component, since we could have abstracted the getting and setting of the `listItems` to a different component. For the sake of this example, however, it illustrates the capabilities in an acceptable way. There are no strict rules or guidelines for how you structure your components.
Note that you can structure or restructure the contents of the script block in a way that makes sense to you. You have the freedom to group related sets together, which makes for very readable code that’s easy to refactor.
The Vue.js DevTools
If you’ve not yet installed the Vue.js DevTools, please refer back to the *Vue.js DevTools* section in *Chapter 1*, *Introduction to Vue.js*, to follow the instructions. We will take a close look at the DevTools using our Todo list application for reference.
If you have the browser plugin installed and you visit a website where Vue.js is detected, the icon in the toolbar will indicate that Vue.js is detected on that particular URL:
![Figure 2.4 – Screenshot of Vue DevTools in the browser’s toolbar](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_04.jpg)

Figure 2.4 – Screenshot of Vue DevTools in the browser’s toolbar
If you click it, it will refer you to opening the browser’s DevTools, where a tab dedicated to Vue is added.
The **Vue.js** tab offers a lot of ways of drilling down into a certain aspect of the rendered code and some time travel inspection methods. It offers an accessible representation of the inputs and outputs of a component, which can help you visualize how a component is rendered.
So, let’s zoom in on a particular element, by using the inspect mode.
Inspecting a component
Let’s see if we can inspect a `ListItem` component. We have several ways of doing this: we can drill down into the DOM tree in the Vue.js panel, we can filter for the component name, and we can use the crosshair button to point out the component on the page.
![Figure 2.5 – Drilling down into the DOM tree in the Vue.js panel](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_05.jpg)

Figure 2.5 – Drilling down into the DOM tree in the Vue.js panel
In *Figure 2**.6*, we’ll use the filtering option to type the name of the component we want filtered. This works well when you’re not exactly sure what the structure of the application looks like.
![Figure 2.6 – Filtering for the component name](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_06.jpg)

Figure 2.6 – Filtering for the component name
In *Figure 2**.7*, we use the crosshair icon to select the element from the browser’s viewport. This works very well when you have a strong visual reference to a component!
![Figure 2.7 – Using the crosshair to point out a component on the page](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_07.jpg)

Figure 2.7 – Using the crosshair to point out a component on the page
Depending on your use case, you may prefer one method over the other. For this example, feel free to try all of them out to see their effect.
When you click in the component, you will see additional details, such as the props, extract of the setup function, event listeners, and the `onClick` event we registered with the `v-on` (or `@`) directive.
Apart from inspecting the props that the component was given, we can use the control buttons to scroll to and highlight the component on the page, inspect the render function for that component, highlight the generated DOM code, and even open the source file in the code editor!
![Figure 2.8 – The various controls of Vue DevTools in the browser extension](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_08.jpg)

Figure 2.8 – The various controls of Vue DevTools in the browser extension
Those several ways of inspecting components are useful tools when debugging the state of a component. What makes them especially powerful is that you’re looking at the component from within the browser’s environment, which is also how users of your app experience and interact with your application!
Manipulating a component
Apart from inspecting, we can also manipulate the state of a component. We can’t modify the properties of a `ListItem`, since it’s read-only. Let’s take a look at the `TodoList` component.
If you inspect it, you’ll see two collections that power the list: the `sortedList` and `storageList` variables.
![Figure 2.9 – The collections that power the contents of the list](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_09.jpg)

Figure 2.9 – The collections that power the contents of the list
Again, `sortedList` is a computed property and cannot be manipulated.
![Figure 2.10 – The values of the computed sortedList items cannot be modified](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_10.jpg)

Figure 2.10 – The values of the computed sortedList items cannot be modified
When we look at `storageList` and expand the collection, we see some modifiers. We can toggle the `checked` property and update the `title` property. Those changes even propagate to the values of the corresponding `sortedList`!
![Figure 2.11 – The values of the storageItems items can be modified and propagate to the corresponding sortedList values](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/bd-rlwd-webapp-vue3/img/B19563_02_11.jpg)

Figure 2.11 – The values of the storageItems items can be modified and propagate to the corresponding sortedList values
With the browser being dependent on the computed value, it means you see the effect in the browser as well. This is very useful for debugging different variants of the state of the user interface. You can also see the methods that are used in the component, available for inspection.
In other scenarios, we will touch upon different uses of Vue DevTools so you will slowly get more familiar with using them to more accurately debug or inspect the applications you build. When debugging any application state that affects the browser, Vue DevTools offers a very good set of features to help you analyze what is happening with the rendering of the application.
Taking a look at the application, you’ll notice that the first item on our Todo list is checked, which now accurately represents the progress we’ve made. Let’s work on checking off the next items on the list!
Summary
At this point, we’ve used the Vue CLI tool to create and customize our app boilerplate settings. We’ve been using two-way data binding, which translates to the reactivity in our applications. Using and applying the Single File Components philosophy, we can now apply this to build applications that are maintainable at any scale.
With Vue DevTools, we have learned a means of inspecting components and can apply this to debug our applications.
In the next chapter, we’ll connect our application with external APIs, giving it real-time data to work with.

第三章:构建本地天气应用程序

现在我们能够构建一个小型应用程序,我们可以添加更多复杂性。在这种情况下,我们将探讨包括另一个浏览器 API,并将其与外部数据源结合用于我们的应用程序。我们将构建一个小型天气应用程序,返回当前天气。

我们将开始应用不同的样式方法,选择 Tailwind 作为我们的 CSS 框架,并且为了提供额外的稳健性,我们还将考虑在我们的应用程序中包含一些测试。

在本章中,我们将涵盖以下主题:

  • 与来自不同类型 API 的外部数据合作

  • 如何处理异步数据

  • 将 Tailwind 应用于快速样式化任何应用程序

  • 通过为功能添加单元测试来确保稳定性

让我们看看我们需要满足哪些要求才能使我们的应用程序运行起来。

技术要求

对于本章,我们将使用第三方 API 为我们提供实际数据。我们需要在www.weatherapi.com/注册一个账户,并检索用于我们应用程序的 API 密钥。

我们将添加 Tailwind CSS 来为我们的应用程序应用样式。tailwindcss.com/网站提供了广泛的文档以及安装指南。

对于我们的单元测试,我们将使用 Vitest 框架:vitest.dev/

您可以在以下位置找到源代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/03.weather

初始化应用程序

让我们从为 Vue.js CLI 启动器设置一个稍微不同的配置开始:

npm init vue@latest

y继续,选择vue-local-weather作为项目名称,并选择以下截图所示的选项:

图 3.1 – 本地天气应用程序的设置配置

图 3.1 – 本地天气应用程序的设置配置

在遵循安装依赖项和清理默认文件的说明后,我们可以开始工作!

与不同类型的 API 合作

为了检索本地天气,我们需要一种获取位置的方法。我们将使用的天气服务接受不同类型的位置数据,但在这个例子中我们将使用纬度和经度。

浏览器的地理位置 API 能够提供所需的信息真是太方便了!让我们先构建一个组件,请求这些信息并将其显示在用户界面上。

让我们在components文件夹中创建一个名为GetLocation.vue的文件。我们将在script标签中导入 Vue.js 的实用工具,并定义预期可用的数据:

<script lang="ts" setup>import { ref } from "vue";
import type { Ref } from "vue";
type Geolocation = {
  latitude: number;
  longitude: number;
};
const coords: Ref<Geolocation | undefined>= ref();
</script>

现在,我们说我们期望响应式属性 coords 包含纬度和经度。没有什么特别的。让我们编写一个函数来从地理位置 API(developer.mozilla.org/en-US/docs/Web/API/Navigator/geolocation)获取数据。现在,请注意,用户可以拒绝访问此 API,因此我们还需要一个后备方案。

我们将添加一个响应式布尔属性 geolocationBlockedByUser 来跟踪调用 API 的成功情况,以及一个执行实际调用的函数:

<script lang="ts" setup>import { ref } from "vue";
import type { Ref } from "vue";
type Geolocation = {
  latitude: number;
  longitude: number;
};
const coords: Ref<Geolocation | undefined> = ref();
const geolocationBlockedByUser: Ref<boolean> = ref(false);
const getGeolocation = async (): Promise<void> => {
  await navigator.geolocation.getCurrentPosition(
    () => {},
    (error: { message: string }) => {
      geolocationBlockedByUser.value = true;
      console.error(error.message);
    }
  );
};
</script>

这里有几个问题。getGeolocation 函数正在被定义,因为它依赖于用户输入,所以它默认是一个异步函数。它返回的承诺是空的,因为我们使用 successCallback 来更新我们的响应式属性。

目前这部分是空的,但我们在下一步中会添加它。errorCallback 函数仅在无法获取地理位置时被调用,目前我们假设用户拒绝了使用。因此,我们将 geolocationBlockedByUser 的值设置为 true 并将错误记录到控制台。

查看文档(developer.mozilla.org/en-US/docs/Web/API/Geolocation_API/Using_the_Geolocation_API#getting_the_current_position),我们看到 getCurrentPosition 返回一个对象(位置),该对象在 coords 属性上持有纬度和经度。由于我们的 coords 响应式属性期望纬度和经度,我们按照以下方式处理来自 API 的数据:

const getGeolocation = async (): Promise<void> => {  await navigator.geolocation.getCurrentPosition(
    async (position: { coords: Geolocation }) => {
      coords.value = position.coords;
    },
    (error: { message: string }) => {
      geolocationBlockedByUser.value = true;
      console.error(error.message);
    }
  );
};

目前一切正常,但函数尚未执行。就像在上一章中一样,我们将使用 onMounted 钩子来在组件被挂载到 DOM 上时执行该函数。整个 script 标签现在应该看起来像这样:

<script lang="ts" setup>import { ref, onMounted } from "vue";
import type { Ref } from "vue";
type Geolocation = {
  latitude: number;
  longitude: number;
};
const coords: Ref<Geolocation | undefined> = ref();
const geolocationBlockedByUser: Ref<boolean> = ref(false);
const getGeolocation = async (): Promise<void> => {
  await navigator.geolocation.getCurrentPosition(
    async (position: { coords: Geolocation }) => {
      coords.value = position.coords;
    },
    (error: { message: string }) => {
      geolocationBlockedByUser.value = true;
      console.error(error.message);
    }
  );
};
onMounted(async () => {
  await getGeolocation();
});
</script>

让我们快速添加一个模板块,以渲染如下输出:

<template>  <div v-if="coords && !geolocationBlockedByUser">{{ coords.latitude }} {{ coords.longitude }}</div>
  <div v-if="geolocationBlockedByUser">User denied access</div>
</template>

将组件添加到 App.vue 中只需导入组件并在模板上渲染它:

<script setup lang="ts">import GetLocation from "./components/GetLocation.vue";
</script>
<template>
  <GetLocation />
</template>

使用 npm run dev 启动我们的开发服务器。现在,如果你在浏览器中打开应用,你应该会看到一个浏览器弹出窗口请求分享你的位置权限。如果你允许访问,你应该会看到浏览器确定的你的坐标(结果可能不同)。如果你拒绝了访问,你应该会看到一个消息说明你已拒绝。

注意

如果你仔细检查控制台,可能会注意到一个警告:仅对用户手势做出响应请求地理位置信息。立即尝试收集地理位置信息通常被认为是一种反模式或是不礼貌的行为。并非所有浏览器都会始终显示确认对话框,这可能导致用户在不了解的情况下泄露信息!

正确的做法是在模板中添加一个按钮,使用 onClick 指令执行 getGeolocation 函数。这样,用户会主动发起地理位置请求。

处理第三方 API 的数据

现在我们有了坐标,我们可以开始请求本地化的天气数据。在这个例子中,我们将使用一个公共天气 API (www.weatherapi.com/)。为了使用这项服务,我们需要请求一个 API 密钥。如果你注册一个账户,免费层每月允许你进行 1,000,000 次请求,这应该绰绰有余!

将这类访问密钥或秘密存储在本地环境变量文件中是一种常见做法。这种做法允许我们的构建过程将本地开发操作与我们的生产环境分离。它将这些变量集中在一个地方,而不是散布在你的应用程序中。

目前,我们将在项目根目录下创建一个名为 .env 的文件来存储 API 密钥,内容如下:

VITE_APP_WEATHER_API_KEY=Replace this with the key

VITE_APP_ 前缀确保 Vite 自动将变量暴露给应用程序。

注意

对于基于客户端的 Web 应用程序,密钥默认会暴露,因为它将附加到你可以通过浏览器网络请求检查的 API 调用。对我们来说,这没问题。在一个类似生产的环境中,你可能会通过自己的后端代理请求来隐藏任何秘密。

构造 API 调用

拥有我们的令牌后,我们可以开始进行调用。让我们了解我们需要如何构造端点地址来检索我们的相关数据。

使用 API 探索器 (www.weatherapi.com/api-explorer.aspx),我们看到我们可以使用位置从服务中获取数据。虽然探索器显示了一个地点名称,但如果我们深入研究请求参数 (www.weatherapi.com/docs/#intro-request),我们会看到 q 参数也接受十进制度数的纬度和经度,例如 q=48.8567,2.3508。这正是我们需要的东西!

查看文档后,我们需要类似以下内容:

https://api.weatherapi.com/v1/current.json?key=OUR_SECRET_KEY&q=OUR_LATITUDE_AND_LONGITUDE

我们可以通过将此端点作为 URL 粘贴到浏览器中手动调用它,用我们的实际数据替换变量。你应该会看到一个格式化的 JSON 对象,包含你位置的天气数据!现在我们确认一切正常工作后,我们可以将逻辑移动到 Vue 组件中,以便将其包含在我们的应用程序中。

让我们在 component 文件夹中创建一个名为 WeatherReport.vue 的组件。我们将从 script 块开始,首先描述我们将要使用两种类型,并定义这个组件需要的属性(coords):

<script lang="ts" setup>type WeatherData = {
  location: {
    localtime: Date;
    name: string;
    region: string;
  };
  current: {
    temp_c: number;
    temp_f: number;
    precip_mm: number;
    condition: {
      text: string;
      icon: string;
    };
    wind_degree: number;
    wind_kph: number;
    wind_mph: number;
  };
};
type Coords = { latitude: number; longitude: number }
interface Props {
  coords: Coords;
}
const props = defineProps<Props>();
</script>

对于 WeatherData 类型,我查看了一下 API 返回给我们的内容,只描述了我们感兴趣的属性。在实现时,请随意选择公制或英制单位!Coords 类型非常简单,只需持有纬度和经度的数值,我们可以在 script 块内部重用该类型,例如,用来描述 coords 组件属性。

如果我们想使用端点的响应,我们需要使其变得响应式。我们可以使用 ref 来做这件事,并将其映射到一个数据常量:

<script lang="ts" setup>import { ref } from "vue";
import type { Ref } from 'vue'
type WeatherData = {
  …
};
type Coords = { latitude: number; longitude: number }
interface Props {
  coords: Coords;
}
const props = defineProps<Props>();
const data: Ref<WeatherData | undefined> = ref();
</script>

在此基础上,我们准备好定义调用函数,使用 fetch API。在函数中,我们将接受一个参数,代表我们请求的坐标。我们将返回数据,以便我们稍后将其映射到我们刚刚创建的 data 属性:

<script lang="ts" setup>import { ref, onMounted } from "vue";
import type { Ref } from 'vue'
type WeatherData = {
  …
};
type Coords = { latitude: number; longitude: number }
interface Props {
  coords: Coords;
}
const props = defineProps<Props>();
const data: Ref<WeatherData | undefined> = ref();
const fetchWeather = async (coords: Coords): Promise<WeatherData> => {
  const { latitude, longitude } = coords;
  const q = ${latitude},${longitude};
  const res = await fetch(
 `https://api.weatherapi.com/v1/current.json?key=${
 import.meta.env.VITE_APP_WEATHER_API_KEY
    }&q=${q}`
  );
  const data = await res && res.json();
  return data;
};
onMounted(async () => {
  const { latitude, longitude } = props.coords;
  const weatherResponse = await fetchWeather({latitude, longitude});
  data.value = weatherResponse;
});
</script>

如您所见,我们描述了一个返回 WeatherData 类型形状的 promise 的 fetchWeather 函数。我们使用 coords 参数来构造 URL,将其与密钥结合。在请求得到响应后,我们将其转换为 JSON 并返回值。

与我们的 GetLocation 组件类似,我们希望立即获取数据,所以我们以类似的方式使用了 onMount 钩子。我们将组件属性传递给 fetchWeather 函数,并将响应映射到响应式数据变量。

现在我们有了数据,我们可以标记模板来显示信息!我们处理的是异步数据,因此有一个数据仍在加载的 UI 状态。

让我们从向 WeatherReport.vue 文件中添加两个状态开始:

<template>  <div>
    <article
      v-if="data && data.current">
      {{ data.current }}
    </article>
    <div v-else>Loading...</div>
  </div>
</template>

如果你从服务器得到快速响应,数据应该几乎瞬间显示出来。

现在,让我们看看我们如何以风格构建我们的界面!

使用 Tailwind 进行样式设计

Tailwind CSS 是一个流行的基于实用工具的 CSS 框架,我们可以通过使用和组合预定义的类来构建和样式化用户界面。由于抽象了 CSS 规则的编写,Tailwind 非常可扩展,这提供了在使用时的一致性和可维护性。让我们看看我们如何将 Tailwind CSS 应用到我们的小型应用中。

安装指南 (tailwindcss.com/docs/guides/vite) 覆盖了我们需要执行的步骤:

  1. 首先,我们必须向项目中添加依赖项:

    npm install -D tailwindcss postcss autoprefixer
    

    我们正在安装 Tailwind,同时也安装了允许 Vite 使用 PostCSS 处理样式的工具。PostCSS 是一个强大的 JavaScript 工具,用于使用 JavaScript 转换 CSS (postcss.org/)。

  2. 接下来,我们将初始化 Tailwind 的默认配置:

    content property tells the plugin where Tailwind should be applied. The next step is exposing the utility classes of Tailwind to the application.
    
  3. ./src 文件夹中创建一个 style.css 文件,并在你的开发和构建步骤中添加以下行以导入 Tailwind CSS 实用工具类:

    @tailwind base;@tailwind components;@tailwind utilities;
    
  4. 最后,打开 ./src/main.ts 文件,将 CSS 文件导入到应用中:

    import { createApp } from 'vue'import './style.css'import App from './App.vue'createApp(App).mount('#app')
    

请记住,导入的 CSS 文件与您传统上将 CSS 样式表链接到 HTML 文件的方式不同。通过导入它,CSS 文件将成为开发和构建管道的一部分,这允许我们在样式输出之前对样式执行高级操作。

在我们的案例中,我们正在导入 Tailwind CSS 的引用,添加浏览器特定的前缀,并从样式表中删除未使用的类。拥有如此多的控制和力量对于构建高级应用程序来说非常方便!

在我们的设置就绪后,我们可以开始将 Tailwind CSS 应用到我们的应用程序中。Tailwind 使用实用类来定义元素的样式。

实用类

基于实用类 CSS 框架的方法是围绕将 CSS 紧密耦合到用户界面,同时抽象底层规则和定义的概念构建的。您现在不是向元素添加一个类名并在该类中应用 CSS 样式,而是向元素添加多个类名来描述 CSS 行为。

元素与其样式之间的紧密耦合关系的好处是它非常易于维护,几乎没有隐藏的规则或副作用影响元素的渲染方式。基础 CSS 文件的大小保持不变;它只是所有实用类的列表。

我们甚至可以移除未使用的样式,因为我们知道在标记中使用哪些类。这比更传统的 CSS 具有更高的可预测性,在传统 CSS 中,样式定义与依赖于该样式的元素之间的关系远没有那么清晰。特别是对于快速原型设计,基于实用性的方法确实非常出色,所以让我们将其付诸实践。

让我们对index.html文件进行一些小的修改,以看到 Tailwind CSS 的实际应用。我们将向我们的应用程序挂载的<div/>元素添加一个类列表:

bg-gradient-to-b from-indigo-500 via-purple-500 to-pink-500 w-full h-screen flex items-center justify-center

Tailwind CSS 的一个优点是其可读性。从标记中,我们可以可视化组件在浏览器中的渲染方式。在这种情况下,背景是一个多色渐变,内容位于页面的水平和垂直中心。要了解更多关于可用的 Tailwind 实用类,官方文档提供了一个所有可用类的综合列表:tailwindcss.com/docs

文件看起来是这样的:

<!DOCTYPE html><html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app" class="bg-gradient-to-b from-indigo-500 via-purple-500 to-pink-500 w-full h-screen flex items-center justify-center"></div>
    <script type="module" src="img/main.ts"></script>
  </body>
</html>

如果我们现在在浏览器中查看应用程序,我们可以看到这个更改的结果。结合更多的实用类,我们将对从天气 API 获取的数据进行样式化。

让我们转到WeatherReport.vue并添加一些样式和 HTML 元素:

<template>  <div>
    <article
      v-if="data && data.current"
      class="max-w-md w-96 rounded-lg shadow-lg p-4 flex bg-white text-black"
    >
      <div class="basis-1/4 text-left">
        <img :src="img/data.current.condition.icon" class="h-16 w-16" />
      </div>
      <div class="basis-3/4 text-left">
        <h1 class="text-3xl font-bold">
          {{ data.current.condition.text }}
          <span class="text-2xl block">{{ data.current.temp_c }}&#8451;</span>
        </h1>
        <p>{{ data.location.name }} {{ data.location.region }}</p>
        <p>Precipitation: {{ data.current.precip_mm }}mm</p>
      </div>
    </article>
    <div v-else>Loading...</div>
  </div>
</template>

这看起来已经很不错了!看看我们是如何结合 Tailwind 类来确定元素的样式?你可以尝试各种不同的方式来展示数据。

我们有一些预定义的属性尚未映射到模板中。让我们看看它们,因为它们需要一些额外的关注。

数据格式化

让我们看看来自服务的时间戳。我们以 datetime 字符串的形式接收它。我们可以将其格式化以显示更易读的信息。我们可以简单地使用一个格式化函数将任何日期字符串格式化为我们喜欢的格式。我们将在组件的 <script> 块中创建这个函数:

const formatDate = (dateString: Date): string => {  const date = new Date(dateString);
  return new Intl.DateTimeFormat("default", {
    dateStyle: "long",
    timeStyle: "short",
  }).format(date);
};

我们使用浏览器的内置 Intl 命名空间(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)来解释日期信息,并根据浏览器的设置将其返回给用户。现在我们可以在模板中简单地调用该方法:

<template>  <div>
    <article
      v-if="data && data.current"
      class="max-w-md w-96 rounded-lg shadow-lg p-4 flex bg-white text-black"
    >
      <div class="basis-1/4 text-left">
        <img :src="img/data.current.condition.icon" class="h-16 w-16" />
      </div>
      <div class="basis-3/4 text-left">
        <h1 class="text-3xl font-bold">
          {{ data.current.condition.text }}
          <span class="text-2xl block">{{ data.current.temp_c }}&#8451;</span>
        </h1>
        <p>{{ data.location.name }} {{ data.location.region }}</p>
        <p>Precipitation: {{ data.current.precip_mm }}mm</p>
        <p>{{ formatDate(data.location.localtime) }}</p>
      </div>
    </article>
    <div v-else>Loading...</div>
  </div>
</template>

Vue.js 将括号之间的信息解释为表达式,所以它将只评估 formatDate 方法的输出作为渲染信息。

自定义样式用例

现在我们剩下关于风的信息:速度和方向。表示风速是直接的:要么选择公制值,要么选择英制值,并在模板中渲染它。表示风向,我们可以通过使用指向特定方向的箭头来使其更易于用户理解。

对于这样一项专业操作,最好是创建一个新的组件。我们先创建一个名为 WindDirection.vue 的组件。它将接收一个名为 degrees 的数值属性。我们将使用这个属性来创建一个动态(计算)样式,根据输入来决定:

<script lang="ts" setup>import { computed } from "vue";
interface Props {
  degrees: number;
}
const props = defineProps<Props>();
const windStyle = computed(() => ({
  transform: "rotate(" + props.degrees + "deg)",
}));
</script>
<template>
  <span
    ><span aria-hidden="true" class="inline-block" :style="windStyle">⬇</span
    ><span class="sr-only">Wind direction: {{ degrees }} degrees</span></span
  >
</template>

在文件中,有两件有趣的事情在进行。首先,degrees 属性被用来创建一个名为 windStyle 的计算值。这个值包含一个动态的 CSS 属性,因此将对其接收到的任何属性做出响应。我们通过将其绑定到 <span> 元素的 :style 属性来应用样式,该属性简单地包含一个箭头。

第二点要指出的是具有 sr-only 类的 <span> 元素。这是一种使内容更易于访问的技术,这是你应该始终考虑的事情。箭头及其旋转只有在你可以看到渲染的组件时才有意义。并不是每个人在使用网络时都能依赖视觉。有些人使用屏幕阅读器等工具来处理给定页面上的信息。

Tailwind 提供了一个特殊的类工具,用于标记仅对屏幕阅读器可见的内容,这意味着其内容默认情况下会被浏览器行为隐藏。屏幕阅读器可能会读取该元素的内容。在这种情况下,我们通过其旋转描述箭头的含义。

请注意提供工具,使任何网站或应用默认对所有用户都易于访问。

现在我们将把这个组件添加到我们的应用中,以完成整个应用:

<script lang="ts" setup>import { ref, onMounted } from "vue";
import type { Ref } from 'vue'
import WindDirection from "./WindDirection.vue";
// abbreviated…
</script>
<template>
  <div>
    <article
      v-if="data && data.current"
      class="max-w-md w-96 rounded-lg shadow-lg p-4 flex bg-white text-black"
    >
      <!-- abbreviated -->
      </div>
      <div class="basis-3/4 text-left">
        <h1 class="text-3xl font-bold">
          <!-- abbreviated -->
        </h1>
        <p>{{ data.location.name }} {{ data.location.region }}</p>
        <p>Precipitation: {{ data.current.precip_mm }}mm</p>
        <p>{{ formatDate(data.location.localtime) }}</p>
        <p>
          Wind: {{ data.current.wind_kph }} kph
          <wind-direction :degrees="data.current.wind_degree" />
        </p>
      </div>
    </article>
    <div v-else>Loading...</div>
  </div>
</template>

就这样!这就是我们的天气应用!你可以按任何你想要的方式对其进行样式设计。如果你想添加或删除属性,请先完成最后一部分,因为它将帮助你保持更改过程中的稳定性。

在下一节中,我们将为应用程序的特定功能添加单元测试。单元测试接受代码的小部分(单元),并验证在给定某些条件下这些部分具有相同的输出。让我们看看它是如何工作的。

使用 Vitest 确保稳定性

现在我们有一个工作的应用程序,添加或删除属性可以轻松完成。只需更新文件即可。然而,这并不总是理想的情况。能够轻松删除属性可能会导致应用程序中出现不希望的 bug!

我们可以通过使用测试来描述代码的行为,从而增加对代码的控制。在本部分中,我们将查看如何使用 Vitest 和 Vue Test Utils 添加单元测试,以展示单元测试如何帮助你(以及它们不能帮助你之处)。

Vue Test Utils

Vue.js 项目的官方测试库是Vue Test Utils(test-utils.vuejs.org/)。测试框架是一系列工具和函数,你可以使用它们来创建组件的独立实例,并操纵它以断言某些行为。

单元测试的目的是验证软件的每个单元(或组件)是否按预期工作并满足指定的要求。在我们的情况下,我们可以为我们的 Vue 组件编写测试,也可以为仅导出函数的 JavaScript 文件编写测试。

使用我们选择的预设,我们已经将测试工具包含在我们的应用程序中。它甚至将测试脚本作为package.json文件的一部分添加,以便我们可以运行测试。有几种组织和结构化代码的方式。我更喜欢将测试放在它们所测试的组件旁边。测试文件通过文件名中的.spec.ts后缀来识别,因此你可以轻松地找到它们。

当涉及到编写好的单元测试时,有几个方面需要考虑:

  • 你应该能够独立测试每个功能

  • 每个单元测试都应该独立于所有其他测试,并且不依赖于其他测试或函数的状态

  • 测试支持你的文档,因此请使用逻辑和描述性的名称来帮助理解测试覆盖的内容

当我们编写不同的测试集或将 Vue 组件作为纯 JavaScript 文件时,我们将发现我的意思。

让我们从简单的事情开始。应用程序的入口点是./src/app.vue组件。让我们快速打开文件:

<script setup lang="ts">import GetLocation from "./components/GetLocation.vue";
</script>
<template>
  <GetLocation />
</template>

现在,从功能的角度来看,很少会出现错误。它导入了GetLocation组件并将其挂载到模板上。如果我们从文档的角度来考虑,你可以将组件的功能描述为它应该渲染 GetLocation 组件。我们可以断言这一点,这正是我们将要编写的第一个测试的方式。

创建一个名为App.spec.ts的文件,内容如下:

import { describe, it, expect } from 'vitest'import { shallowMount } from "@vue/test-utils";
import GetLocation from "./components/GetLocation.vue";
import App from "./App.vue";
describe("App", ():void  => {
  it("renders the GetLocation component", ():void => {
    const wrapper = shallowMount<App>(App);
    expect(wrapper.findComponent(GetLocation).exists()).toBe(true);
  });
});

在本例中,我们导入了一些来自 Vitest 的工具来编写我们的测试和断言,还导入了一个用于在测试中挂载组件的方法,以及导入 GetLocationApp 组件。你可以使用以下命令执行单元测试:

npm run test:unit

此命令将执行所有已识别的测试文件,并保持一个监视器运行以重新运行任何更改的测试。脚本会自动在控制台生成测试报告。

(浅度)挂载的组件将表现得就像它被浏览器渲染时一样,但我们是在隔离状态下运行的。使用 Vue Test Utils,你可以选择 mountshallowMount 一个组件,区别在于挂载会尝试渲染任何子组件。shallowMount 函数为任何子组件创建存根,这减少了副作用并直接关注组件本身。作为一个经验法则,我倾向于总是使用 shallowMount,除非我需要断言特定的父子行为。

我们使用 describe 块来限定测试范围,并确保我们描述了测试的主题。

然后我们编写我们的测试:它渲染了 GetLocation 组件。这仅仅是挂载了 App 组件,然后断言 GetLocation 组件的存根是否在渲染树中被找到。

全局测试函数

在所有测试中,我们将使用三个函数来创建我们的测试断言。这些函数用于创建清晰和有组织的测试代码,其中 describe 函数用于将测试分组到逻辑测试套件中,it 函数用于定义单个测试用例,而 expect 函数用于定义正在测试的代码的预期行为。

我们可以在项目文件中进行一些小的修改,以消除手动导入这些常用函数的需求。

打开 vite.config.ts 文件并查找 test 属性。如果我们向 test 属性添加一个名为 globals 的属性,其值为 true,那么最终的结果应该类似于以下内容:

export default defineConfig({  plugins: [vue()],
  test: {
    globals: true,
    environment: "jsdom",
  }
})

现在,我们可以使用这些函数,但我们的 IDE 并不知道这些函数,因此我们需要配置一些额外的设置。首先,我们需要通过在 CLI 中运行以下命令来安装一些类型:

npm i --save-dev @types/jest

我们将使用以下添加到 types 的内容来更新 tsconfig.vitest.json 文件:

{  "extends": "./tsconfig.app.json",
  "exclude": [],
  "compilerOptions": {
    "composite": true,
    "lib": [],
    "types": ["node", "jsdom", "jest"]
  }
}

现在我们的 IDE 完美地支持直接使用 describeitexpect 函数。这就是你需要做的全部,以便将它们注册为全局可用函数。如果你喜欢,你可以从我们的第一个测试文件中删除那行代码。

简单的组件测试

让我们开始处理更复杂的组件。让我们从 WindDirection 组件开始。我们可以断言组件渲染的内容,以及它是否添加了正确的样式到方向指示器,以及屏幕阅读器文本是否反映了相同的值。

首先,我们将创建 WindDirection.spec.ts 文件,内容如下:

import { shallowMount } from "@vue/test-utils";import WindDirection from "./WindDirection.vue";
describe("WindDirection", () => {
  it("renders without crashing", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 90,
      },
    });
    expect(wrapper).toBeTruthy();
  });
});

这只断言组件在提供最小属性要求时不应出错。toBeTruthy 是一个宽松的断言,它断言值是任何表达式或值,该表达式或值评估为真。

当我们考虑到我们尽可能想要隔离测试时,我们可以添加一个测试来渲染带有适当样式的风向箭头。为此,我们需要检索一个元素——应用计算样式的 span 元素。在这些情况下,给元素添加一个特定的属性,称为 data-testid 是常见的做法。修改 span 元素以添加 data-testid 属性:

<span aria-hidden="true" class="inline-block" data-testid="direction" :style="windStyle">⬇</span>

现在我们有了一些测试可以指向的东西。添加特定属性进行测试而不是通过类名或层次结构进行定位的好处是,这不太可能随着时间的推移而发生变化,这使得你的测试更加健壮:

import { shallowMount } from "@vue/test-utils";import WindDirection from "./WindDirection.vue";
describe("WindDirection", () => {
  it("renders without crashing", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 90,
      },
    });
    expect(wrapper).toBeTruthy();
  });
  it("renders the indicator with the correct wind direction", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 90,
      },
    });
    const direction = wrapper.find("[data-testid=direction]");
    expect(direction.attributes("style")).toContain("rotate(90deg)");
    expect(direction.html()).toContain("⬇");
  });
});

正如你所见,我们使用 [data-testid=direction] 查询来本地化元素,然后断言其样式。通过将其映射到内容(一个向下指的箭头),旋转与箭头的组合提供了有意义的上下文。如果我们用任何其他内容替换向下指向的箭头,组件就会失去其意义,测试也会相应地失败。

我们可以添加一个针对屏幕阅读器使用的最终断言。首先,我们将给组件添加另一个 data-testid 属性。在这种情况下,添加到与屏幕阅读器相关的元素:

<span class="sr-only" data-testid="direction-sr">Wind direction: {{ degrees }} degrees</span>

这个值必须唯一,这样我们才能从测试文件中定位它,现在的测试文件如下所示:

import { shallowMount } from "@vue/test-utils";import WindDirection from "./WindDirection.vue";
describe("WindDirection", () => {
  it("renders without crashing", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 90,
      },
    });
    expect(wrapper).toBeTruthy();
  });
  it("renders with the correct wind direction", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 90,
      },
    });
    const direction = wrapper.find("[data-testid=direction]");
    expect(direction.attributes("style")).toContain("rotate(90deg)");
    expect(direction.html()).toContain("⬇");
  });
  it("renders the correct wind direction for screen readers", (): void => {
    const wrapper = shallowMount(WindDirection, {
      props: {
        degrees: 270,
      },
    });
    const srOnly = wrapper.find("[data-testid=direction-sr]");
    expect(srOnly.classes()).toContain('sr-only')
    expect(srOnly.html()).toContain("Wind direction: 270 degrees");
  });
});

在隔离方面,我们在这个挂载组件的新实例上执行这个测试,并且我们只关注 direction-sr 属性组件的内容。我们这样做是因为,如果测试失败,我们应该能够立即看到原因和影响。

我们可以将 expect 行添加到之前的测试块中,但如果任何断言失败,我们就无法看到直接的失败原因。在小型代码库中,这不会是一个大问题,但你可以想象当你处理由数百个组件及其单元测试组成的代码库时的复杂性。这就是为什么隔离和简单是关键。

模拟外部源

之前的组件只处理其自身的状态。但我们在我们的应用程序中也集成了外部源。我们无法测试我们无法控制的内容,因此我们不需要测试那些外部源。我们必须测试我们的组件与外部源交互的方式。为了使其更可预测,我们可以使用模拟来控制输出。

一个很好的例子是我们用来检索用户地理位置的浏览器 API。让我们创建我们的 GetLocation.spec.ts 文件来测试这个组件!

import { shallowMount } from "@vue/test-utils";import GetLocation from "./GetLocation.vue";
describe("GetLocation", () => {
  it("should render the component without crashing", (): void => {
    const wrapper = shallowMount(GetLocation);
    expect(wrapper).toBeTruthy();
  });
});

如果我们现在运行测试,它将会失败。坦白说,我们需要修复两件事。首先,这个测试是一个异步测试,因为地理位置的检索是一个承诺:

import { shallowMount } from "@vue/test-utils";import GetLocation from "./GetLocation.vue";
describe("GetLocation", () => {
  it("should render the component without crashing", async (): Promise<void> => {
    const wrapper = await shallowMount(GetLocation);
    expect(wrapper).toBeTruthy();
  });
});

不幸的是,这仍然不起作用。这是因为测试不是在实际浏览器中执行的,而是在 jsdom 中执行的,jsdom 是一个基于 JavaScript 的浏览器环境。它不支持(所有)原生浏览器 API。

组件尝试访问 navigator.geolocation.getCurrentPosition API,但它不存在!我们需要模拟它以允许我们的组件渲染。模拟可能有点抽象,但它实际上是关于控制影响我们组件的环境。在我们的情况下,我们可以使用一个非常直接的实施方法:

it("should render the component without crashing", async (): Promise<void> => {  global.navigator.geolocation = {
    getCurrentPosition: () => {},
  };
  const wrapper = await shallowMount(GetLocation);
  expect(wrapper).toBeTruthy();
});

在这个情况下,我们只是提供了一个名为 getCurrentPosition 的方法,以便在执行测试时存在于 浏览器 中。这个服务不返回任何有效或有用的信息,但这不是我们这里感兴趣的地方。我们只是希望我们的组件能够渲染。

同时请注意,这个测试突显了我们应用程序的一个缺陷:它需要 navigator.geolocation.getCurrentPosition 存在;否则,它将失败!

成功模拟

为了扩展我们的测试场景,我们需要断言我们的组件能够返回一个成功解析的地理位置。我们将创建一个新的测试用例,因为隔离,并改进我们的模拟导航器 API。我们将使用 Vitest 的 vi.fn() 函数来完成这项工作。

vi.fn() 函数 (vitest.dev/api/vi.html#vi-fn) 是一个 Vitest 函数,它会在函数上创建一个间谍。这意味着它存储了所有调用参数、返回值和实例。通过将其存储在 mockGeoLocation 中,我们可以更容易地断言其属性。该函数接受一个参数,即一个可调用的模拟实例。

在测试文件顶部,我们将像这样导入 vi 函数:

import { vi } from 'vitest'

让我们看看测试:

it("displays when geolocation resolved successfully", async (): Promise<void> => {  const mockGeoLocation = vi.fn((successCallback: Function) => {
    const position = {
      coords: {
        latitude: 51.5074,
        longitude: -0.1278,
      },
    };
    successCallback(position);
  });
  global.navigator.geolocation = {
    getCurrentPosition: mockGeoLocation,
  };
  const wrapper = await shallowMount<GetLocation>(GetLocation);
  expect(wrapper.vm.coords).toEqual({
    latitude: 51.5074,
    longitude: -0.1278,
  });
});

在这种情况下,我们不是在 navigator.geolocation.getCurrentPostition 方法上只有一个空函数,而是创建了一个模拟,模拟了一个成功的解决方案。我们可以找到 getCurrentPosition API 的规范 (w3c.github.io/geolocation-api/#dom-geolocation-getcurrentposition),以便我们的模拟匹配预期的行为。

我们提供了一个 successCallback 函数,它返回坐标,就像浏览器 API 一样,并且我们立即调用它来模拟用户授予对地理位置数据的访问权限。

在有一个成功的解决方案的情况下,我们可以断言组件接收到了来自浏览器的相同的 coords 对象。

不愉快路径

在测试了一个成功的解决方案之后,最后要测试的是如果用户拒绝访问位置数据会发生什么。我们将使用一个非常类似的方法,但我们将提供一个失败的次要回调而不是成功的回调。再次强调,这是根据规范:

it("displays a message when user denied access", async (): Promise<void> => {  const mockGeoLocation = vi.fn((successCallback: Function, errorCallback: Function) => {
    const error = new Error("User denied geolocation access");
    errorCallback(error);
  });
  global.navigator.geolocation = {
    getCurrentPosition: mockGeoLocation,
  };
  const wrapper = await shallowMount<GetLocation>(GetLocation);
  expect(wrapper.vm.geolocationBlockedByUser).toEqual(true);
  expect(wrapper.html()).toContain("User denied access");
});

如您所见,在这种情况下,我们完全忽略了 successCallback,而是定义和调用 errorCallback。正如组件所指示的,反应性的 geolocationBlockedByUser 属性将被设置为 true,我们将显示错误消息。

完整的测试文件现在看起来是这样的,其中我们断言组件可以渲染,并且可以解析成功的查询并处理拒绝的请求:

github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/03.weather/.notes/2.1-GetLocation.spec.ts

我们确保我们已经测试了快乐和不幸的路径。让我们看看我们如何将测试应用到处理来自端点的外部数据的组件上。

测试 API

我们最终的组件也有外部依赖。这并不比模拟浏览器 API 差很多,正如我们将在本节中发现的那样。如果我们看看我们的组件,它有以下特点:在没有检索到数据时显示加载状态,并以良好的格式显示服务的响应。

让我们从评估组件是否可以在名为 WeatherRepost.spec.ts 的文件中渲染开始:

import { shallowMount } from '@vue/test-utils'import WeatherReport from './WeatherReport.vue'
describe('WeatherReport', () => {
  it("should render the component without crashing", (): void => {
    global.fetch = vi.fn() as any
    const wrapper = shallowMount<WeatherReport>(WeatherReport, {
        props: {
            coords: {
                latitude: 0,
                longitude: 0
            }
        }
    });
    expect(wrapper).toBeTruthy();
  });
});

在这种情况下,你可以看到,我们不是模拟 navigator 属性,而是在模拟全局的 fetch 属性。我们不需要返回任何东西,所以我们保持这个测试尽可能简单。

接下来是测试加载状态。实际上,我们在这里有点作弊。这里只有一个没有数据或有数据的状态。我们为了简单起见,将没有数据视为加载状态:

it('displays loading message when data is undefined', (): void => {  global.fetch = vi.fn(() => Promise.resolve({
    json: () => Promise.resolve()
  })) as any
  const wrapper = shallowMount(WeatherReport, {
    props: {
      coords: {
        latitude: 0,
        longitude: 0
      }
    }
  });
  expect(wrapper.text()).toContain('Loading...')
});

对于这个测试,我们只是将数据解析为无。这意味着没有可渲染的数据,这将保持组件在加载状态。我们可以断言,在没有数据的情况下,Loading… 文本仍然可见。

我们还可以断言当我们从服务接收到数据的情况。这涉及到类似的方法,区别在于我们不是解析无,而是解析一个模拟的天气报告:

it('displays weather data when data is defined', async () => {  const mockData = {
    // ...abbreviated
  }
   global.fetch = vi.fn(() => Promise.resolve({
    json: () => Promise.resolve(mockData)
  })) as any
  const wrapper = shallowMount(WeatherReport, {
    props: {
      coords: {
        latitude: 0,
        longitude: 0
      }
    }
  })
  expect(wrapper.text()).toContain(mockData.current.condition.text)
  expect(wrapper.text()).toContain(mockData.current.temp_c)
  expect(wrapper.text()).toContain(mockData.location.name)
  expect(wrapper.text()).toContain(mockData.location.region)
  expect(wrapper.text()).toContain(mockData.current.wind_kph)
  expect(wrapper.text()).toContain(mockData.current.wind_degree)
});

我们断言 mockData 属性正在映射到包装器。然而,有一个问题:断言失败了!实际上,我们有两个问题。shallowMount 函数稍微简化了 HTML 结构,我们必须等待承诺得到解决。

幸运的是,Vue Test Utils 有一个处理承诺的有用工具:flushPromises 是一个实用函数,确保所有挂起的承诺都得到解决。我们可以在文件顶部导入它,与我们的 mount 函数一起:

import { mount, shallowMount, flushPromises } from '@vue/test-utils'

如果我们重新运行我们的测试,它将成功:

it('displays weather data when data is defined', async () => {  const mockData = {
// abbreviated
  }
  global.fetch = vi.fn(() => Promise.resolve({
    json: () => Promise.resolve(mockData)
  })) as any
  const wrapper = mount(WeatherReport, {
    props: {
      coords: {
        latitude: 0,
        longitude: 0
      }
    }
  })
  await flushPromises();
  expect(wrapper.text()).toContain(mockData.current.condition.text)
  expect(wrapper.text()).toContain(mockData.current.temp_c)
  expect(wrapper.text()).toContain(mockData.location.name)
  expect(wrapper.text()).toContain(mockData.location.region)
  expect(wrapper.text()).toContain(mockData.current.wind_kph)
  expect(wrapper.text()).toContain(mockData.current.wind_degree)
});

由于我们有时间戳的格式化器,所以有一个最后的检查。让我们给元素添加一个 data-testid 属性:

<p mockData object because we are only interested in one property (and not crashing the component):

it('displays formats the datetime to a locale format', async () => {  const mockData = {

location: {

localtime: new Date(),

},

current: {

condition: {},

}

}

global.fetch = vi.fn(() => Promise.resolve({

});

})) as any

const wrapper = mount(WeatherReport, {

props: {

坐标:{

纬度:0,

经度:0

}

}

})

});

const localtime = wrapper.find("[data-testid=localtime]");

预期(localtime.text()).toEqual('2001 年 1 月 31 日 上午 11:45')

});


Now this will only succeed once because the new `Date()` function will constantly be refreshed with the date and time of executing the test! Again, we have an external factor that we need to mock in order to isolate our test.
Vitest offers tooling to manipulate dates, times, and even the passing of timers ([`vitest.dev/api/vi.html#vi-setsystemtime`](https://vitest.dev/api/vi.html#vi-setsystemtime)). We can modify our test so that the test always assumes the exact same date and time. This way, we can assert the outcome based on a fixed value.
The updated version will look like this:

it('显示格式化为本地格式的日期时间', async () => {  const mockDateTime = new Date(2000, 12, 31, 11, 45, 0, 0)

vi.setSystemTime(mockDateTime)

const mockData = {

位置:{

localtime: new Date(),

},

当前:{

condition: {},

}

}

global.fetch = vi.fn(() => Promise.resolve({

json:() => Promise.resolve(mockData)

})) as any

const wrapper = mount(WeatherReport, {

props: {

坐标:{

纬度:0,

经度:0

}

}

})

等待(flushPromises());

const localtime = wrapper.find("[data-testid=localtime]");

预期(localtime.text()).toEqual('2001 年 1 月 31 日 上午 11:45')

vi.useRealTimers()

等待(flushPromises());


This makes it safe to assert the date based on a static value. I tend to pick my date and time values in such a way that months, days, hours, and minutes are easily identifiable regardless of the notation.
Summary
At this point, we’ve added a bit more complexity to our app, any external resource calls for additional error handling, and we’ve learned how to deal with asynchronous data using a loading state. We’ve been able to quickly style our app using the utility style CSS framework Tailwind. With the unit test, we’ve made sure that we can assert that our application’s core features will continue to work as expected or alarm us if the output changes in any way.
In the next chapter, we’ll focus on connecting more extensively with a third-party API, by combining multiple endpoints from an API into a single app.

第四章:创建 Marvel Explorer 应用

让我们构建一个稍微复杂一些的应用,使用第三方 API 为其提供数据。我希望你喜欢漫画,因为我们将构建一个基于 Marvel Comics API 的探索器,我还会尝试加入一些英雄式的双关语。我们将探索添加不同的路由,并添加一些抽象来更好地利用编写我们的代码。

在本章中,我们将涵盖以下主题:

  • 开始使用

  • 从 API 获取数据

  • 单页应用程序中的路由

  • 编写和使用 composables

  • 搜索和处理数据

  • 用户友好的错误处理

技术要求

在本章中,我们将用性能 npm(pnpm)替换node 包管理器(npm):pnpm.io

我们需要在developer.marvel.com/注册以获取 API 密钥。我们将添加Tailwind CSS(tailwindcss.com/)来为此应用应用样式。

在本章中,我们介绍使用 Vue.js 应用程序的官方路由来使用路由:router.vuejs.org/

本章的完整代码可在github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/04.marvel找到。

开始我们的新项目

为了开始,我们需要一个 API 密钥。如果您访问developer.marvel.com/并从菜单中选择获取密钥,您需要注册一个免费账户。之后,您将被重定向到开发者门户,在那里您创建一个密钥以与 API 交互。请确保记下公钥和私钥。

在我们的示例中,我们将从 localhost 访问 API,因此您需要将localhost127.0.0.1添加到授权引用者列表中。

注意

如果您想将此应用部署到网络上,您还需要确保将应用的相应 URL 添加到那里,但本章不涵盖部署步骤。

我想指出文档,您可以在交互式文档下找到它。我建议您尝试操作一下,以了解我们的数据提供者。

让我们开始一个新项目!

npm init vue@latest

y键继续,将项目名称选择为vue-marvel-explorer,并选择以下图片中显示的选项:

图 4.1 – Marvel Explorer 应用的设置配置

图 4.1 – Marvel Explorer 应用的设置配置

按照安装依赖项的说明操作后,我们就可以开始工作了!

这次,让我们使用 pnpm 安装我们项目的依赖项。pnpm 是一个针对 node 的包管理器,它相对于 npm 有一些优势,例如更好的包存储管理,这导致安装速度提高和网络请求减少。如果您互联网连接不佳,pnpm 会帮您解决问题!您可以在此处阅读安装指南(pnpm.io/installation)。命令与 npm 类似,因此应该很容易上手。

导航到您的项目文件夹,并输入 pnpm install(而不是 npm install)。酷的地方在于,将来安装相同包时,都会引用已安装的本地缓存,这样可以节省大量带宽和时间。

我们还将使用 pnpm 在项目中安装 tailwind,回顾来自天气应用程序的步骤,使用 npm 的替代品,旨在优化 node_modules 管理:

pnpm install -D tailwindcss postcss autoprefixerpnpm dlx tailwindcss init -p

让我们更新 tailwind.config.js 文件:

/** @type {import('tailwindcss').Config} */export default {
  purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  content: ['./src/**/*.{vue,js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}

./src 文件夹中创建一个 style.css 文件:

@tailwind base;@tailwind components;
@tailwind utilities;

最后,打开 ./src/main.ts 文件以将 CSS 文件导入到应用程序中(注意该文件包含路由初始化):

import { createApp } from 'vue'import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(router)
app.mount('#app')

如果您使用 pnpm run dev 运行开发服务器,您会看到演示应用程序添加了一个示例路由,允许您在主页和关于视图之间导航。关闭开发服务器,让我们在代码编辑器中打开项目。

您可以移除默认 Vue 安装添加的组件,以清理您的项目。如果您不确定,可以始终参考 GitHub 仓库。链接可以在 技术要求 部分找到。技术 要求

.env.example 文件重命名为 .env,并确保从 Marvel 开发者门户插入秘密:

VITE_APP_MARVEL_API_PUBLIC=YOUR_PUBLIC_KEY_HEREVITE_APP_MARVEL_API_SECRET=YOUR_SECRET_HERE

VITE_APP_ 为前缀的来自 .env 文件的变量会自动传递到您的应用程序中,并在预定义的 import.meta.env 对象中可用。

注意

我再次强调,在类似生产环境的生产环境中共享秘密不是最佳实践。您通常会使用类似授权代理的东西来确保 API 只接收受信任的请求。从某种意义上说,我们已经通过在 Marvel API 配置中定义请求域做到了这一点。通常,localhost 或其等效的 127.0.0.1 也不会出现在生产环境中!

这就完成了我们的环境设置。接下来,我们将继续将这些设置连接到我们的应用程序。

超级英雄连接

我们希望从应用程序的不同组件中检索 Marvel 漫画 API 的数据。一个很好的模式是通过创建 Vue 可组合式来实现。Vue 可组合式是在整个应用程序中使用和重用逻辑的一个经过验证的模式。我们将在 src 文件夹中创建一个名为 composables 的文件夹,并创建一个名为 marvelApi.ts 的文件。

您可以从示例仓库中导入类型(github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/src/types/marvel.ts)。

这些类型主要是与 API 的合约。请随意查看它们。我是通过摄取 API 的结果并定义类型来创建它们的。

我们将从一个异步函数开始,该函数从 API 的漫画端点获取数据,并返回响应的 promise。我们将逐步扩展其功能。向文件中添加一个新的组合函数useComics,并不要忘记导入类型:

import type { Comics } from '@/types/marvel'export const useComics = async (): Promise<Comics> => {
  const apiKey = import.meta.env.VITE_APP_MARVEL_API_PUBLIC;
  const MARVEL_API = `//gateway.marvel.com/v1/public/
  const API_SIGN = apikey=${apiKey}`
  const requestURI = `${MARVEL_API}/comics?${API_SIGN}`
  const res = await fetch(requestURI);
  const jsonRes = await res.json();
  return jsonRes.data;
}

现在我们可以将 API 调用连接到用户界面。我们将创建一个组件来显示端点数据。在src/components文件夹中创建一个新的 Vue 组件,名为ComicsOverview.vue。我们将从script标签的内容开始:

<script lang="ts" setup>import { ref, onMounted } from "vue";
import type { Ref } from "vue";
import { useComics } from "@/composables/marvelApi";
import type { Comic } from "@/types/marvel";
const isLoading: Ref<boolean> = ref(false);
const data: Ref<Comic[] | undefined> = ref();
const getComics = async () => {
  isLoading.value = true;
  const comics = await useComics();
  data.value = comics.results;
  isLoading.value = false;
};
onMounted(async () => {
  await getComics();
});
</script>

script块与第四章中的天气应用第四章非常相似。我们在组件挂载时请求数据,并使用isLoading变量跟踪状态。

在同一文件的模板中,我们将添加以下内容:

<template>  <div>
    <div v-if="isLoading"><p>Loading comics…</p></div>
    <div v-if="data && !isLoading">
      <div
        class="grid grid-flow-row grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
      >
        <div :key="comic.id" v-for="comic in data">{{ comic.title }}</div>
      </div>
    </div>
  </div>
</template>

您可以通过临时将组件导入到App.vue并在模板中加载它来快速查看结果。这里有一点细微的差别是我们将实际的获取操作抽象到了组合中,这使得组件的代码更加简洁,并且使获取操作更加可重用。

现在我们有了数据,我们将稍微润色一下组件。让我们创建一个LoadingIndicator.vue组件:

<script setup lang="ts">const props = defineProps<{
  text?: string;
}>();
</script>
<template>
  <div
    class="flex flex-col items-center justify-center p-4 pt-16 min-h-min min-w-screen"
  >
    <div v-if="text" class="mb-4">
      {{ text }}
    </div>
    <div class="flex space-x-2 animate-pulse">
      <div class="w-3 h-3 bg-gray-500 rounded-full"></div>
      <div class="w-3 h-3 bg-gray-500 rounded-full"></div>
      <div class="w-3 h-3 bg-gray-500 rounded-full"></div>
    </div>
  </div>
</template>

我们可以将它导入到组件中,然后使用它来替换<div v-if="isLoading"><p>Loading comics…</p></div>元素,如下所示:

<script lang="ts" setup>import { ref, onMounted } from "vue";
import type { Ref } from "vue";
import { useComics } from "@/composables/marvelApi";
import type { Comic } from "@/types/marvel";
import LoadingIndicator from "./LoadingIndicator.vue";
//… abbreviated
</script>
<template>
  <div>
    <LoadingIndicator v-if="isLoading" text="Loading comics..."/>
    // … abbreviated
  </div>
</template>

我们这样做是为了在使用重复的用户界面模式时创建更多的一致性。再次强调,这是基于组件的架构的一个优势。

我们还可以创建漫画的视觉表示。我们将立即应用抽象。在实践中,代码重构往往发生在代码库的开发过程中。预先预测哪些代码将被重用是很困难的,所以当需要时,不要犹豫,尽早和经常重构。然而,在我们的案例中,我们有一个不同的目标要教授,所以我们将不会专注于重构部分。

创建一个名为CardView.vue的组件,其中包含以下代码:

<template>    <article class="p-4 bg-white rounded-lg shadow-xl place-content-center text-slate-800">
    <header>
        <h1 class="pb-5 text-lg font-semibold "><slot name="header"></slot></h1>
    </header>
    <slot></slot>
    </article>
</template>

在这个组件中,我们正在使用插槽。插槽是来自父组件的特定动态内容(组件或文本)的占位符。插槽是重用模板和提供大量灵活性的绝佳方式。考虑一下LoadingIndicator组件,它只接受一个文本属性。限制可能是有益的,但有时您可能更愿意选择灵活性而不是约束。让我们开始应用这个通用组件。

我们将创建一个包含以下内容的ComicCard.vue组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.1-ComicCard.vue

让我们分解这个组件,好吗?你应该熟悉大多数概念,但我已经介绍了一些更多内容。一个特别的添加是以下这一行:

const lf = new Intl.ListFormat('en');

Intl是一个标准化的命名空间,用于处理与语言相关的函数。在我们的案例中,我们正在设置一个特定于英语(en)语言的列表格式化器,并在模板中使用它来连接创作者列表。列表作为数组提供(即[“Evan You”, “Sebastien Chopin”, “Anthony Fu”])。使用Intl格式化器,结果是特定于该语言的易读文本:Evan You, Sebastien Chopin, and Anthony Fu!

我们使用计算值来为每个漫画创建角色列表(charactersList)和每个漫画的创作者列表(creatorsList)。

在模板中,我们看到我们是如何在CardView组件中使用插槽并填充我们自己的模板的:

<template>  <CardView :data-testid="comic.id">
    <template v-slot:header>
     {{ comic.title }}
    </template>
    <template v-slot:default>
      <img
        class="aspect-[150/228] shadow-xl float-left mr-4"
        :src="img/`${comic.thumbnail.path}.${comic.thumbnail.extension}`"
        width="150"
      />
      //… abbreviated
    </template>
  </CardView>
</template>

我们已经定义了模板的内容。我们指定comic.title作为组件中的标题。对于默认插槽,我们提供了卡片内容的标记。在两种情况下,我们都让<CardView>组件处理格式化和样式,这也确保了用户界面的统一性。

在示例代码中,你会看到命名插槽的简写表示法:

<template #header>  {{ comic.title }}
</template>
<template #default>
  //… abbreviated
</template>

目前,我们将移除对App.vue的临时更改,因为我们打算将其添加到特定的路由视图中!

使用我们准备好的组件,我们将继续将这些组件移动到特定的视图和路由中。

单页应用程序中的精彩路由

现在,让我们看看应用程序的默认设置,因为我们已经预安装了应用程序以使用vue-router。这配置了应用程序的一些功能:

  • router文件夹中,我们有一个index.ts文件

  • views文件夹中,我们有两个名为HomeView.vueAboutView.vue的组件

  • App.vue中,我们有几个名为RouterLinkRouterView的组件

这就是路由是如何相互关联的。让我们看看每一个。

router文件夹的内容定义和配置了应用程序的路由。路由定义了应用程序中的不同路径以及当访问这些路径时应渲染的组件。每个路由都表示为一个具有pathnamecomponent等属性的对象。

path属性指定了 URL 路径,component属性指定了要渲染的 Vue 组件。name不是必需的,更多的是作为一个人类可读的标识符用于路由。

使用默认配置,它为我们定义了主页视图,并设置了其他视图以支持代码拆分,以限制每个路由的包大小。所以,这是一个开箱即用的最佳实践!

我们将把对about的引用更改为search,为我们的最终结果做准备:

{  path: '/search,
  name: search,
  // route level code-splitting
  // this generates a separate chunk (Search.[hash].js) for this route
  // which is lazy-loaded when the route is visited.
  component: () => import('../views/SearchView.vue')
}

我们将把AboutView.vue重命名为SearchView.vue。对于文件的内容,你可以简单地删除大部分,我们稍后会构建一些新的内容。目前像这样就可以了:

<template>  <div class="search">
    <h1>This is a search page</h1>
  </div>
</template>

为了将这些内容重新组合在一起,我们可以更新App.vue文件,使RouterLink组件指向/search而不是/about

为了明确:你必须使用我们在路由文件中配置的路径。RouterView组件用于根据当前路由渲染匹配的组件。它充当占位符,在相应组件渲染的地方。每当路由发生变化时,RouterView组件将自动更新以渲染新组件。还记得插槽吗?将RouterView视为一种动态插槽,可以容纳整个视图。

RouterLink用于在应用程序中创建导航链接,并在点击时触发到指定路由的导航。RouterLinkto属性指定目标路由的路径或名称。

让我们清理掉我们不再需要的默认配置部分:

<script setup lang="ts">import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
  <header>
    <div class="my-4 text-center">
      <h1 class="mb-4 text-6xl font-extrabold uppercase">Marvel Explorer 🔭</h1>
      <nav>
        <RouterLink to="/" class="px-4 py-2 border-2 rounded-s-md hover:text-slate-600">📒 Comics</RouterLink>
        <RouterLink to="/search" class="px-4 py-2 border-2 border-s-0 hover:text-slate-600 rounded-e-md">🦹 Heroes</RouterLink>
      </nav>
    </div>
  </header>
  <RouterView />
</template>

由于我们的ComicsOverview.vue组件已经准备好了,我们可以将其添加到HomeView.vue中,替换掉TheWelcome.vue部分:

<script setup lang="ts">import ComicsOverview from '@/components/ComicsOverview.vue';
</script>
<template>
  <main>
    <ComicsOverview />
  </main>
</template>

如果你现在运行应用程序,你可以在主页(加载漫画概览)和几乎为空的搜索页面之间导航。

我们将继续努力将更多信息添加到我们的应用程序中,因为我们的应用程序目前仅限于显示 API 结果的首页。

可选参数

如果你分析了来自漫威 API 的网络请求,你可能已经注意到我们显示的漫画只是冰山一角。有大量的漫画,由于数量庞大,它们不会在一个响应中发送。API 提供了分页结果。我们可以修改我们的应用程序以反映 API 的功能!

如果我们打开路由文件,我们可以在路由中添加一个可选参数。它解析并公开要用于应用程序的值。参数(或简称param)的表示法是在名称前加冒号。我们将向home路由添加一个名为page的参数:

{  path: '/:page',
  name: 'home',
  component: HomeView
},

我们在应用程序中引入了一个轻微的 bug。现在应用程序总是期望一个参数。对于主页来说,这并不总是如此!参数应该是可选的。为了标记参数为可选,我们在其后添加一个问号作为后缀:

{  path: '/:page?',
  name: 'home',
  component: HomeView
},

欢呼!我们已经成功添加了一个可选参数。现在我们可以将分页引入到漫画概览中。

呼叫斯特兰奇博士

vue路由器将自己暴露为一个可组合式组件。可组合式组件是 Vue 的一个超级功能,用于封装可复用的状态逻辑。这意味着在这个例子中,路由器可组合式组件持有路由的状态,我们可以在任何组件中使用它!

这意味着我们可以直接打开我们的ComicsOverview.vue文件来实现分页。让我们看看组件的script标签并添加几行:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.2-ComicsOverview.vue

首先,我们导入可组合式组件(第 4 行)并将其注册到路由常量(第 12 行)。然后我们添加两个响应式变量(第 16 行、第 17 行)来跟踪页面信息。我们通过route.params对象访问参数。由于我们命名了变量,我们可以访问方法上的相应属性。我们使用+route.params.page第 19-21 行)作为简写来将值转换为数值类型,并将其存储在响应式的currentPage中。

然后,在getComics中,我们使用来自端点数据来实际化值(第 27 行、第 28 行)。

现在我们知道了有多少页以及我们目前在哪一页,我们可以使用这些属性来提供一个简单的Pagination组件。

一个简单的分页组件

那么,让我们创建一个新的组件,命名为Pagination.vue,并添加以下内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.3-Pagination.vue

到现在为止,应该已经很直观了:我们添加了相关的属性(为了更大的灵活性,我们还提供了一个path属性),并且根据我们的当前页,我们可以渲染到第一页、上一页、下一页或最后一页的链接,并显示分页状态。

我想指出的是aria-hidden属性,我们用它来标记对屏幕阅读器无价值的装饰性元素。现在,我们将通过导入并将其粘贴在漫画卡片概述下添加到ComicsOverview.vue组件中:

<template>  <div>
    <LoadingIndicator v-if="isLoading" text="Loading comics..." />
    <div v-if="data && !isLoading">
      <div
        class="grid grid-flow-row grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
      >
        <ComicCard
          :comic="comic"
          :key="comic.id"
          v-for="comic in data"
        ></ComicCard>
      </div>
      <Pagination
        :total-pages="totalPages"
        path="/"
        :current-page="+currentPage"
      ></Pagination>
    </div>
  </div>
</template>

那是有效的!我们可以点击到下一页,但什么也没有发生。这是因为我们的 API 还没有支持分页功能。让我们看看如何添加这个功能,所以我们将打开marvelApi.ts文件。首先,我们将为useComics添加分页选项:

export const useComics = async (page: number = 0): Promise<Comics> => {    const apiKey = import.meta.env.VITE_APP_MARVEL_API_PUBLIC;  const MARVEL_API = //gateway.marvel.com/v1/public/
  const API_SIGN = apikey=${apiKey}
  const ITEMS_PER_PAGE = 20;
  const pagination = page ? &offset=${page * ITEMS_PER_PAGE} :
  const requestURI = ${MARVEL_API}/comics?${API_SIGN}${pagination}
  const res = await fetch(requestURI);
  const jsonRes = await res.json();
  return jsonRes.data;
}

我们只是接受一个页码,然后我们将使用预定义的ITEMS_PER_PAGE来确定偏移量(这是漫威 API 处理分页的方式)。然后,我们存储查询参数并将其添加到requestURI

现在,我们可以再次切换到ComicsOverview组件,以实现分页并将路由参数连接到 API 请求。为了完成这个任务,我们在脚本块中添加以下内容:

<script lang="ts" setup>import { ref, onMounted, watch } from "vue";
// abbreviated…
const getComics = async (page: number = 0) => {
  isLoading.value = true;
  const comics = await useComics(page);
  currentPage.value = comics?.offset / comics?.limit || 0;
  totalPages.value = Math.ceil(comics.total / comics.limit);
  data.value = comics.results;
  isLoading.value = false;
};
watch(
  () => route.params.page,
  async (newPage) => {
    await getComics(+newPage);
  }
);
onMounted(async () => {
  await getComics(+currentPage.value);
});
</script>

我们现在可以简单地将页面添加到getComics请求中,并将其传递给useComics可组合组件。我们在onMounted时这样做,即当你直接从 URL 进入应用程序时。我们还添加了一个监视函数,它跟踪route.params.page的变化,并在值改变时请求新页面。正如你所看到的加号,我们在这里也使用了快速转换为数字。

在所有这些准备就绪之后,我们现在可以浏览所有 2,746 页!作为一个额外的练习,为什么不尝试扩展分页组件以显示多个页面。

一旦你准备好向前推进,我们将重构我们的应用程序以使用可组合组件。它们是封装(有状态的)逻辑的函数。

可组合组件,集合!

让我们看看我们如何利用我们的可组合组件并重构应用程序以扩展一些功能。可组合组件都是关于可重用性:这是它们在 Vue 空间中的超级力量,所以让我们将我们之前创建的可组合组件投入实际应用。

首先,我们将对useComics可组合组件进行重构,我们将轻柔地应用清洁代码原则。在我们的上下文中,这相当于应用单一责任原则,并编写小型且结构紧凑的函数,具有有意义的名称。

重构 useComics

我们将以非破坏性的方式进行重构,直到我们准备好更新现有的useComic可组合组件。

我们首先将静态常量从函数中移出,放到更高的作用域。我们还将导入将在函数中引用的附加类型。这样,我们仍然可以访问它们,但它们在整个文件中都是可用的。我习惯于将这些类型的值放在文件顶部,以便于未来的参考:

import { Path } from '@/types/marvel'import type { Comics, Characters, Character } from '@/types/marvel'
const apiKey = import.meta.env.VITE_APP_MARVEL_API_PUBLIC;
const MARVEL_API = //gateway.marvel.com/v1/public/
const API_SIGN = apikey=${apiKey}
const ITEMS_PER_PAGE = 20;
export const useComics = async (page: number = 0): Promise<Comics> => {
  const pagination = page ? &offset=${page * ITEMS_PER_PAGE} :
  const requestURI = ${MARVEL_API}/comics?${API_SIGN}${pagination}
  const res = await fetch(requestURI);
  const jsonRes = await res.json();
  return jsonRes.data;
}

如果我们思考我们的新可组合组件应该做什么以及它们有什么共同点,我们可以确定以下活动:确定分页、确定搜索查询、构建漫威开发者 API URL、获取并返回数据。我们将为每个活动创建简短、独立的函数。这些不是可组合组件,我们不会在文件外部暴露它们。

让我们添加getPagination函数,它接受页码并将其转换为字符串,将页码转换为偏移量(符合漫威 API 的预期):

const getPagination = (page?: number): string => {    return page ? &offset=${page * ITEMS_PER_PAGE} : ''
};

为了构建一个包含搜索查询的额外字符串,我们添加以下内容:

const getQuery = (query?: string): string => {    return query ? &${query} : ''
};

下一个添加的是构建请求 URI 的函数,它将静态常量与getPaginationgetQuery函数的输出相结合:

const getRequestURI = (path: Path,query: string, pagination: string): string => {    const apiPath = ${MARVEL_API}/${path};
    return ${apiPath}?${API_SIGN}${query}${pagination};
};

我们还将添加一个执行请求并返回结果的函数。在这种情况下,我们可能可以重用这个函数,所以我们可以将其编写为可组合组件,使用use前缀:

export const useFetch = async (requestURI: string): Promise<Comics | Characters> => {  const res = await fetch(requestURI);
  const jsonRes = await res.json();
  return jsonRes.data as Comics | Characters;
};

最后,我们可以在一个组合式中将所有函数串联起来,这将使我们能够与漫威开发者 API 进行交互:

interface ApiOptions {  query?: string;
  page?: number;
}
export const useMarvelAPI = async (path: Path, options: ApiOptions): Promise<Comics | Characters> => {
  const pagination = getPagination(options.page);
  const query = getQuery(options.query);
  const requestURI = getRequestURI(path, id, query, pagination);
  return useFetch(requestURI);
}

正如你所见,我们创建了一个函数,它可以返回漫画或角色,这取决于我们提供的路径变量。由于漫威开发者 API 对每个端点都有类似的机制,我们能够对所需的选项做出有用的抽象。

我们添加到MavelAPI.ts文件中的代码如下:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.4-MarvelAPI.ts

我们通过添加抽象来扩展文件,以从端点检索数据,这样我们就可以重用通用的函数,同时能够请求特定内容。

让我们调查将这些功能整合到我们的应用中。

重新组装功能

现在,我们可以更新现有的useComics组合式,在此基础上构建。正如你所见,我们现在能够将组合式的代码内容缩减到一行,只提供路径和当前页:

export const useComics = async (page: number = 0): Promise<Comics> => {  return await useMarvelAPI(Path.COMICS, { page }) as Comics
}

通过运行我们的代码,带有分页的现有漫画概览应该保持完全功能。这是编写干净代码而不是组合式力量的证明。它使我们能够相对容易地实现新的、有用的代码。在我们的下一个组合式中,我们将请求不同类型的信息,正如我们将在函数中描述的那样。

我们与搜索 API 交互的方式是提供正确的 API 路径,在我们的案例中,我们将使用nameStartsWith方式进行搜索。我们将将其与动态搜索值一起作为查询的一部分提供:

export const useCharacterSearch = async (query: string, page: number = 0): Promise<Characters> => {  return await useMarvelAPI(Path.CHARACTERS, { query: nameStartsWith=${query}, page }) as Characters
}

这次,正如你所见,我们将预期的响应类型更改为Characters而不是Comics。当我们与这些组合式交互时,我们的 IDE 将能够区分这两种类型。

现在我们有两个组合式准备在应用中使用。我们重构文件的方式在编码中是一种自然的过程。随着时间的推移,需求会发生变化,因此代码随之变化是合乎逻辑的。将我们的代码拆分成小型、专注的函数将使我们在未来更容易理解和修改。

当使用组合式时,我们通常遵循提供简单易用函数的相同做法,而不是将它们组合成一个。

如果你感兴趣于应用组合式,我建议查看vueuse.org/。它托管了一个用于 Vue 应用中日常问题的现成组合式集合。

到目前为止,我们已经看到,将清晰的代码思维与特定的可组合组件相结合,如何帮助我们重构应用程序代码,将其分解成更易于阅读和维护的各个部分。我们也亲身体验了代码重构的过程。有时我们重构代码是因为需求的变化,有时我们只是想让现有的代码更易于阅读。

让我们现在看看如何向我们的应用程序添加更多类型的数据!

管理名单

通过我们全新的可组合组件,我们可以轻松访问来自漫威开发者 API 的更多数据!我们将继续创建 Vue 组件,这将使用户界面能够处理搜索操作。

我们首先将创建一个名为CharacterCard.vueComicCard.vue的变体。该组件将稍微简单一些,因此您可以将以下内容粘贴到文件中,或者创建ComicCard.vue的一个副本并更新它以匹配以下内容:

<script setup lang="ts">import type { Character } from "@/types/marvel";
import CardView from "./CardView.vue";
interface Props {
  character: Character;
}
const props = defineProps<Props>();
</script>
<template>
  <CardView :id="character.id">
    <img :src="img/`${character.thumbnail.path}.${character.thumbnail.extension}`" class="float-left w-12 h-12 mb-2 mr-4 rounded-full shadow-md aspect-square" />
    <template #header>{{ character.name }}</template>
    <div class="text-base max-w-prose">{{ character.description }}</div>
  </CardView>
</template>

这里没有发生什么特别的事情。我们期望一个名为 character 的单个属性,并且它应该匹配类型。因此,我们可以轻松地将底层属性映射到一个简单的 HTML 模板中。

接下来,我们将创建一个主要组件来托管所有用户界面元素。我们将创建一个名为SearchCharacter.vue的文件,并且我们将从模板开始。在创建了ComicsOverview.vue之后,这也应该看起来很熟悉。我已经高亮了关键差异:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.5-SearchCharacter.vue

当数据正在加载时(第 3 行),我们提供了一个有意义的消息,并且以不同的方式显示结果,即CharacterCard第 8-12 行)。在Pagination中,我们提供了当前路径(第 16 行),并且在没有返回数据时添加了一个更具体的消息(第 25-28 行)。

现在,我们将实现一种输入搜索查询的方法,以便将其传递到SearchCharacter展示。

搜索英雄

搜索是一个隔离的、特定的动作,因此按照单文件组件的哲学,我们将为这个动作创建一个特定的组件!

让我们通过创建一个名为SearchForm.vue的新文件来创建一个表单组件。从script标签开始,我会在过程中解释一些新内容:

<script lang="ts" setup>import { ref } from "vue";
import type { Ref } from "vue";
const emit = defineEmits(['searchSubmit'])
interface Props {
  isSearching: boolean;
}
const props = defineProps<Props>();
const query: Ref<string> = ref("");
const search = (): void => {
  emit("searchSubmit", query.value);
};
</script>

正在进行着两件有趣的事情。第一行高亮定义了一个emit。当我们要在作用域内向上传递某些内容(一个事件)时,就会发生 emit。Props 向下传递,emits 向上传递。通过使用defineEmits,我们将其包装起来,这样 Vue 可以在运行时跟踪事件,并将其命名为searchSubmit

接下来,我们有一个名为search的函数,它除了通过引用其名称并传递query.value作为参数来触发事件外,什么都不做。在我们的父组件中,我们将能够捕获该事件及其值。

是时候添加模板了。让我们尽可能简单开始:

<template>  <form class="flex justify-center my-8" v-on:submit.stop="search">
    <input
      class="px-3 py-2 border rounded-md rounded-r-none disabled:opacity-40 border-slate-300 text-slate-800 focus:outline-none focus:border-slate-500"
      type="text"
      v-model="query"
      placeholder="Search..."
      :disabled="isSearching"
    />
    <button
      class="px-4 py-2 text-sm font-bold text-white transition-colors duration-300 rounded-md rounded-l-none disabled:opacity-40 bg-slate-500 hover:bg-slate-600"
      :disabled="isSearching"
      type="submit"
    >
      🔍 Search
    </button>
  </form>
</template>

这里也有两点需要注意。v-on:submit.stop 语句是一个内置方法,它阻止实际的表单作为 HTML 表单提交(这会导致页面刷新)。相反,在提交时,它调用 search 函数。

为了有任何值引用,我们可以使用 v-model 将查询值绑定到输入字段。这为你提供了双向数据绑定。

添加搜索

虽然表单可以工作,但它感觉不像一个应用:我们仍然需要手动提交表单。在展示结果之前,让我们升级 SearchForm。我们将使用观察者来监视查询值的变化,并在它发生变化时触发 search 函数。

我们将更新 script 标签中的代码以匹配以下内容:

<script lang="ts" setup>import { ref, watch } from "vue";
import type { Ref } from "vue";
const emit = defineEmits(["searchSubmit"]);
interface Props {
  isSearching: boolean;
}
const props = defineProps<Props>();
const query: Ref<string> = ref("");
let timeout: number;
const search = (): void => {
  emit("searchSubmit", query.value);
};
const debouncedSearch = (): void => {
  clearTimeout(timeout);
  timeout = setTimeout(async () => {
    search();
  }, 500);
};
watch(query, (): void => {
  debouncedSearch();
});
</script>

理解导入 watch 函数和定义观察者并不难。而且,而不是直接调用搜索函数,观察者调用 debouncedSearch

向 API 发送请求在资源方面是昂贵的。通过防抖函数,我们运行一个计时器,在本例中为 500 毫秒(ms)。当计时器结束时,我们然后调用 search 函数,该函数反过来发出 searchSubmit 事件。然而,如果 debouncedSearch 函数在计时器清除之前被调用,我们只需重置计时器并等待另一个 500 毫秒。

超级能力的概述

我们终于可以组装 SearchCharacter 组件了。让我们从 script 标签开始,因为我们第一次开始时遗漏了它:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.6-SearchCharacter.vue

除了导入我们的实用工具和组件之外,组件的核心是在 Marvel 开发者 API 上触发搜索操作。getCharacterSearch第 20-34 行)负责更新响应式值以转换为 UI 并计算分页。其核心是使用我们的可组合结构检索结果,这些结果传递给响应式数据属性。

searchReset 函数(第 36-41 行)确保我们总能回到初始状态,例如,当你想要清除 UI 或有人搜索空值时。

在模板本身中,我们只需要添加 SearchForm,这样我们的用户就可以从漫威宇宙中找到他们最喜欢的英雄:

<template>  <div>
    <SearchForm
      :is-searching="isSearching"
      @search-submit="search"
    />
    <LoadingIndicator v-if="isSearching" :text="Searching by '${searchQuery}'..." />
    // ...abbreviated
  </div>
</template>

我们现在已经通过非常有用的搜索功能扩展了应用。这意味着我们可以测试我们的新可组合结构。它允许我们专注于搜索表单的实现,包括防抖,而不是获取数据。

我们已经看到我们的抽象如何以最小的努力扩展功能。到目前为止,我们所构建的几乎都是关于“愉快流程”,其中各个部分按预期工作。由于我们依赖于第三方 API,我们无法控制其稳定性,必须为数据未返回的情况做好准备。在下一节中,我们将重点关注错误处理。

不同的视角

到目前为止,我们的应用程序运行良好。我们可以通过确保我们可以处理 API 返回错误的情况来改善我们的应用程序体验。让我们看看我们如何使我们的应用程序在这方面更加健壮。

我们将添加一个页面,当发生错误时能够向用户显示错误。让我们从views文件夹中的新文件ErrorView.vue开始。只需创建以下内容的模板:

<template>  <main>
    Oops!
  </main>
</template>

我们稍后会回到这个文件。现在我们至少可以在router/index.ts文件中创建一个新的路由,它只是从search路由复制类似的逻辑:

import { createRouter, createWebHistory } from 'vue-router'import HomeView from '../views/HomeView.vue'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/:page?',
      name: 'home',
      component: HomeView
    },
    {
      path: '/search',
      name: 'search',
      component: () => import('../views/SearchView.vue')
    },
    {
      path: '/error',
      name: 'error',
      component: () => import('../views/ErrorView.vue')
    }
  ]
})
export default router

如果我们导航到应用程序中的/error路由,我们应该看到我们的errorpage。由于数据来自外部 API,我们无法控制它。这使得它成为应用程序中的一个明显弱点。

此外,编写防御性代码是常见的做法。如果我们查看marvelApi组合式文件,我们可以在应用程序中使用的组合式周围添加一些安全措施:

export const useComics = async (page: number = 0): Promise<Comics> => {  try {
    return await useMarvelAPI(Path.COMICS, { page }) as Comics
  } catch {
    throw new Error('An error occurred while trying to read comics');
  }
}
export const useCharacterSearch = async (query: string, page: number = 0): Promise<Characters> => {
  try {
    return await useMarvelAPI(Path.CHARACTERS, { query: nameStartsWith=${query}, page }) as Characters
  } catch {
    throw new Error('An error occurred while trying to search comics');
  }
}

注意

您还可以考虑在useFetchuseMarvelAPI上添加这些try/catch块。在发生错误时,错误将通过调用堆栈向上传播,这意味着它将在最高级别被捕获。

我们将模拟错误行为,以便为这些不可预见的情况进行开发。一种简单的方法是进入您的.env文件,并将VITE_APP_MARVEL_API_PUBLIC变量的值临时重命名为VITE_APP_MARVEL_API_PUBLIC_ERROR。完成后我们将将其改回!如果您运行应用程序,它将无法请求任何内容,您将在控制台看到我们设置的错误消息。

处理错误

现在,我们将专注于以用户友好的方式处理错误。让我们从ComicsOverview.vue文件开始。我们将用另一个try/catch块包裹getComics函数的内容。

在这种情况下,用户无法从这种错误中恢复状态,因此留在该页面上没有意义,因为它已经完全损坏。我们将用户重定向到我们的错误页面。这意味着我们将从vue-router导入useRouter组合式并在组件上实例化它。我们将相应地修改文件,特别是getComics函数:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/04.marvel/.notes/4.7-ComicsOverview.vue

我们在(第 4 行)导入并注册路由(第 14 行),并在发生错误时使用router重定向到新路由(第 27-29 行)。

接下来,我们将创建一个在ErrorView.vue中显示的组件。让我们创建一个名为ErrorMessage.vue的新组件,其中包含一些静态内容:

<template>  <article
    class="p-4 mx-4 my-24 bg-white rounded-lg shadow-xl place-content-center text-slate-800"
  >
    <header>
      <h1 class="pb-5 text-lg font-semibold">Oops! 🤭</h1>
    </header>
    Something went wrong!
  </article>
</template>

我们还将更新ErrorView.vue以加载该组件:

<script setup lang="ts">import ErrorMessage from '@/components/ErrorMessage.vue'
</script>
<template>
  <main>
    <ErrorMessage />
  </main>
</template>

如果我们运行我们的代码,只要 API 将我们视为未授权,我们应该会被重定向到/error路径。我们也将以类似的方式在SearchCharacter.vue中添加errorhandling

<script lang="ts" setup>import { ref } from "vue";
import type { Ref } from "vue";
import { useRouter } from "vue-router";
// … abbreviated
const router = useRouter();
// … abbreviated
const getCharacterSearch = async (query: string, page: number = 0) => {
  try {
    if (query !== "") {
      isSearching.value = true;
      searchQuery.value = query;
      const search = await useCharacterSearch(query, page);
      currentPage.value = search?.offset / search?.limit || 0;
      totalPages.value = Math.ceil(search.total / search.limit);
      data.value = search.results;
      isSearching.value = false;
    } else {
      searchReset();
    }
  } catch (e) {
    router.push("/error");
  }
};
// … abbreviated
</script>

在这种情况下,在你尝试搜索之后,应用将重定向到同一页面。如果能为我们用户提供一些上下文,让他们更好地理解出了什么问题,那就太好了。幸运的是,我们可以访问我们在catch块中抛出的错误消息。

添加查询参数

我们将修改router.push动作,使其向目的地传递一些额外的信息。

这是一个简单的更改,我们将将其应用到ComicsOverview.vueSearchCharacter.vue的行上。让我们更改这个:

} catch (e) {  router.push("/error");
}

我们还将修改它,使其提供有关query参数的信息:

} catch (e) {  router.push({ path: 'error', query: { info: e as string }})
}

在这种情况下,我们将把错误消息中的信息作为query参数传递给路由。请注意,这并不是为了发送大量文本,但它是一个很好的使用query参数的例子。

最后,我们可以修改ErrorMessage.vue文件,以便读取query参数并将其显示在组件上。我们可以通过在组件挂载后利用useRoute可组合式来读取路由来实现这一点。文件将看起来像这样:

<script setup lang="ts">import { ref, onMounted } from "vue";
import type { Ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const errorMessage: Ref<string> = ref("");
onMounted((): void => {
  errorMessage.value = route.query.info as string;
});
</script>
<template>
  <article
    class="p-4 mx-4 my-24 bg-white rounded-lg shadow-xl place-content-center text-slate-800"
  >
    <header>
      <h1 class="pb-5 text-lg font-semibold">Oops! 🤭</h1>
    </header>
    {{ errorMessage }}
  </article>
</template>

如果你现在处于错误状态,你应该看到一个更准确地说明出了什么问题的消息。完成操作后,别忘了重命名你的VITE_APP_MARVEL_API_PUBLIC变量!

到目前为止,我们在一些相当常见的技巧和原则上已经取得了良好的进展。在本章中,我们向应用中引入了可组合式,这带来了可重用的功能。我们还添加了客户端路由,并能够在我们的应用程序用户界面中创建链接,以及应用动态路由和传递额外的参数。

作为额外的奖励,我们介绍了基本的错误处理,并对喜欢的漫威漫画书籍有了一些了解。

摘要

在本章中,我们学习了如何添加多个页面,并通过多种方式导航:使用 router-link 组件或通过编程方式操作路由。我们创建了可组合式,以便在应用程序中使用和重用逻辑。为了提供更好的用户体验,我们学习了如何以用户友好的方式处理错误。

在下一章中,我们将使用 Vuetify 构建一个应用程序,这是一个第三方组件库。组件库允许我们通过使用现成的组件来加速开发。此外,我们还将介绍使用 Pinia 的应用程序状态,我们可以将数据(或状态)模块化存储,以便在整个应用程序中共享组件之间的数据。

第二部分:中级项目

在本部分,你将学习如何迭代使用外部 API 来构建数据丰富的应用程序。你还将了解如何处理更复杂的应用程序状态,应用数据存储和检索的基本知识,以及何时以及如何使用短期存储或长期持久化存储解决方案。你将了解到如何使用网络技术构建超越网络的应用程序并将它们部署到任何地方。

本部分包含以下章节:

  • 第五章使用 Vuetify 构建食谱应用

  • 第六章使用数据可视化创建健身追踪器

  • 第七章使用 Quasar 构建多平台支出追踪器

第五章:使用 Vuetify 构建食谱应用

在本章中,我们将利用第三方组件库的强大功能,快速构建用户界面,并探讨在应用上下文中存储的权力和使用方法。我们将构建一个餐单规划器,用户可以浏览食谱并将其添加到每周日历中。周规划器的状态将存储在用户的机器上,以确保在返回访问时可用。

本章将涵盖以下主题:

  • 应用和自定义 Vuetify 以构建视图

  • 使用组件库加速开发

  • 重构策略

  • 理解状态

  • 使用 Pinia 结构化存储的有用性

技术要求

在本章中,我们将使用Vuetify (vuetifyjs.com/en/),这是一个 Vue.js 3 应用的流行组件库。我们还需要在spoonacular.com/注册一个 API 密钥以检索食谱数据。

为了管理我们应用程序的状态,我们将在我们的应用中使用localStorage

本章的代码可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/05.mealplanner

一个新的 Vue 项目

我们准备好初始化一个新的项目,但这次我们将使用 Vuetify 安装程序。Vuetify 是 Vue 安装程序的包装器,为常见的 Vuetify 项目配置提供了预设。在 CLI 中,输入以下命令以进入安装向导的下一步:

npm create vuetify

现在,执行以下操作:

  1. 将项目名称选择为vue-meal-planner

  2. 使用基础(Vuetify,VueRouter)安装。

  3. 使用箭头键选择TypeScript

  4. 选择npm选项来安装依赖项。

如果你导航到新的项目文件夹,你可以使用 npm run dev 来运行本地开发服务器。结果应该看起来非常类似于图 5.11 所示:

图 5.1 – 初始化的 Vuetify 应用

图 5.1 – 初始化的 Vuetify 应用

在我们继续之前,我们还需要一个 API 密钥,以便使示例更接近现实。这将使我们能够搜索实际的食谱。要在 Spoonacular 注册,请按照以下步骤操作:

  1. 访问spoonacular.com/

  2. 导航到食品API。

  3. 通过电子邮件注册并选择一个密码。

  4. 确认你的电子邮件地址以完成注册过程。

  5. 登录后,前往个人资料以显示 API 密钥。

在你的项目根目录中创建一个.env文件,并添加以下行:

VITE_APP_SPOONACULAR_API=Replace this with the key

我们现在准备好创建一个餐单规划应用。

让我们开始烹饪

首先,我们要确保我们有一个不错的样板项目开始,并开始用以下内容替换App.vue的内容:

<script setup lang="ts"></script>
<template>
  <v-layout>
    <v-container class="main">
      <main>
        <router-view />
      </main>
    </v-container>
    <v-footer app><span class="text-light-green">My Meal Planner</span>&nbsp;- &copy; {{ new Date().getFullYear() }}</v-footer>
  </v-layout>
</template>

我们稍后会在此基础上进行扩展。请注意,在生成的 Vue 组件中,<template><script> 标签的顺序与我们的示例不同。我更喜欢从 <script> 标签开始,因为它包含与 <template> 标签相关的逻辑,但两者都是有效的:

图 5.2 – 模板和  标签的顺序与我们的示例不同

图 5.2 – 模板和 script 标签的顺序与我们的示例不同

在主页视图中,我们将构建我们的餐计划器作为未来 7 天的表示。首先,我们将从一个可以基于给定日期渲染多个天数的组件开始。

我们将在 components 文件夹中创建一个名为 CalendarDays.vue 的组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/05.mealplanner/.notes/5.1-CalendarDays.vue

首先,让我们看看 script 标签。它接受属性,以便执行 generateCards 函数,该函数为每个卡片生成一个带有 date 属性的 cards 数组。我们添加以下内容只是为了在模板中显示一些内容:

<template>  <v-table>
    <thead>
      <tr>
        <th class="text-left">Upcoming days</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="card in cards" :key="card.date.toString()">
        <td class="py-4">
          {{ card.content }}
        </td>
      </tr>
    </tbody>
  </v-table>
</template>

在模板中,我们使用 Vuetify 的 table 组件来渲染表格。Vuetify 组件以 v- 标识符为前缀。

我们使用从卡片数组生成的条目来渲染表格行。我们将通过创建一个 MealPlanner.vue 组件并将我们的组件导入其中来将组件暴露给视图:

<script setup  lang="ts">import CalendarDays from './CalendarDays.vue';
</script>
<template>
    <calendar-days :date="new Date()" :days="7" />
</template>

我们在这里没有做任何特别的事情,只是用我们想要的计划天数实例化了 CalendarDays 组件。当我们在这个文件夹中时,我们可以删除 HelloWorld.vue,并在 views\Home.vue 组件中替换引用,以便我们的应用程序在主页上显示 MealPlanner

让我们通过显示格式化的日期来改进它。我们将为此在即将创建的 composables 文件夹中创建一个小型可组合组件。让我们称这个文件为 formatters.ts

const getOrdinalSuffix = (day: number): string => {    const suffixes = ["th", "st", "nd", "rd"];
    const remainder = day % 100;
    return suffixes[(remainder - 20) % 10] || suffixes[remainder] || suffixes[0];
};
export const useFormatDate = (date: Date): string => {
    const day = date.getDate();
    const month = date.toLocaleString("default", { month: "long" });
    const ordinal = getOrdinalSuffix(day);
    return ${day}${ordinal} of ${month};
};

在这里,我们只是添加了一些聪明的代码,根据给定的日期来拼写第一、第二或第三后缀。我们利用了浏览器内置的 API 的一部分,但增加了一些额外的格式化。现在我们可以以可读的格式打印出生成的卡片上的日期,但我们会稍后再讨论这一点。

让我们再创建一个可组合组件来帮助我们与 Spoonacular API 交互。我们将在 composables 文件夹中创建一个名为 recipeApi.ts 的文件。内容应该熟悉,类似于我们在上一章中使用的函数:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/05.mealplanner/.notes/5.2-recipeApi.ts

使用 Vuetify 的默认安装,我们在 src/layouts/default 文件夹中得到了一个默认的 AppBar.vue 组件。让我们修改它,使其符合应用程序的目的:

<template>  <v-app-bar flat>
    <v-app-bar-title>
      <v-icon icon="mdi-silverware-fork-knife" />
      Meal planner
    </v-app-bar-title>
  </v-app-bar>
</template>

Vuetify 组件使得构建具有合理默认值的 app 相对容易。在下一节中,我们将学习如何快速扩展我们的应用程序,使用可用的组件。

使用 Vuetify 快速开发

我们到目前为止构建的应用程序对我们来说还没有什么用处。让我们将其变成一个工作餐计划器!由于我们希望进行抽象和模块化,我们将从拆分 CalendarDays.vue 组件的一些代码开始。

首先,我们将创建一个新的组件,命名为 CalendarCard.vue。我们将用它来表示日历项,并使用我们创建的日期格式化器:

<script setup lang="ts">import { useFormatDate } from "@/composables/formatters";
interface Card {
  date: Date;
}
const props = defineProps<{
  card: Card;
}>();
</script>
<template>
  <v-sheet class="d-flex justify-space-between">
    <v-sheet class="ma-2 pa-2">
      <h2 class="text-h2">{{ useFormatDate(card.date) }}</h2>
    </v-sheet>
  </v-sheet>
</template>

CalendarDays.vue 中,我们可以通过导入我们新创建的 CalendarCard 组件来替换内联表示:

<script setup lang="ts">import { ref } from "vue";
import CalendarCard from "@/components/CalendarCard.vue";
// …abbreviated

然后,我们将组件添加到模板中:

<template>  <v-table>
    // … abbreviated
    <tbody>
      <tr v-for="card in cards" :key="card.date.toString()">
        <td class="py-4">
          <calendar-card :card="card" />
        </td>
      </tr>
    </tbody>
  </v-table>
</template>

我们还需要添加一个新的路由,以便能够显示所有计划中的食谱。我们将在稍后添加食谱。首先,我们将创建一个表格来显示食谱。我们将通过另一个组件的 prop 来提供食谱。

让我们创建 RecipeTable.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/05.mealplanner/.notes/5.3-RecipeTable.vue.

我们使用 Vuetify 组件来创建一个表格表示将要提供的食谱列表。openPreview 函数(第 2、14-16 和 34 行)是表格未来将支持的功能之一。当我们实现这一点时,我们将确保发出的事件将被父组件捕获。让我们快速构建父组件。

让我们创建一个 RecipesList.vue 组件。它将使用 Vuetify 组件来展示过去和未来的食谱表格:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/05.mealplanner/.notes/5.4-RecipesList.vue.

我添加了一些代码来生成一些模拟数据(第 12-24 行)。有时专注于模板可能会有所帮助,在这些情况下,能够对数据进行细粒度控制并支持多种场景会很有帮助。在编写匹配此文件的单元测试时,甚至可以重用此代码!

另一方面是如何监听从 RecipeTable 组件发出的事件(第 69 和 76 行)。我们在点击事件上触发 openPreview 函数(第 26-28 行)。我们还需要一个视图和一个路由来能够导航到这些组件。

让我们在 src/views 文件夹中创建一个名为 RecipeView.vue 的组件,它简单地加载我们的组件:

<script setup lang="ts">import RecipesList from "@/components/RecipesList.vue";
</script>
<template>
  <RecipesList />
</template>

接下来,我们将扩展 Vuetify 为我们生成的路由配置,位于 src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'const routes = [
  {
    path: '/',
    component: () => import('@/layouts/default/Default.vue'),
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue'),
      },
      {
        path: 'recipes',
        name: 'Recipes',
        component: () => import('@/views/RecipesView.vue')
      },
    ],
  },
]
// …abbreviated

最后,我们可以更新 src/layouts/default/AppBar.vue

<template>  <v-app-bar flat>
    <v-btn id="hamburger-activator" icon="mdi-menu"> </v-btn>
    <v-menu activator="#hamburger-activator">
      <v-list>
        <v-list-item>
          <v-btn flat block><router-link to="/">Home</router-link></v-btn>
          <v-btn flat block
            ><router-link to="/recipes">Recipes</router-link></v-btn
          >
        </v-list-item>
      </v-list>
    </v-menu>
    <v-app-bar-title>
      <v-icon icon="mdi-silverware-fork-knife" />
      Meal planner
    </v-app-bar-title>
  </v-app-bar>
</template>

通过这些代码行,我们添加了一个带有切换功能的汉堡菜单。它只需按一下即可工作!这是使用组件库的一个非常强大的功能:它不仅提供了样式化的组件,还提供了常用的交互模式。

我强烈建议查看 Vuetify 文档,因为它提供了可用的组件的详尽列表以及如何使用它们的示例。在我们正在构建的应用中,我们只是轻触组件库的使用,但你也有优化组件以适应更具体目标或目的的选项。

我们现在可以导航到两个不同的视图并构建最终应用的有限版本。在下一节中,我们将连接 Spoonacular 食谱到我们的应用!

将食谱连接到我们的应用

在本节中,我们将 API 连接到我们的应用,这使用户能够开始为即将到来的日子规划餐点。我们将探讨使用 Vuetify 组件与应用交互的模式。

一些额外的设置

由于我们将处理异步数据,我们将添加一些辅助组件。首先,我们在 src/components 文件夹中创建一个名为 AppLoader.vue 的组件,它充当加载指示器:

<template>  <v-container class="fill-height" fluid>
    <v-row align="center" justify="center">
      <v-col cols="12" sm="8" md="4">
        <div class="text-center my-8">
          <v-progress-circular
            indeterminate
            color="light-blue"
            :size="80"
            :width="10"
          ></v-progress-circular>
        </div>
      </v-col>
    </v-row>
  </v-container>
</template>

在我们继续进行的同时,我们还可以添加一个专门处理链接的组件。我们将命名为 AppLink.vue

<script setup lang="ts">const props = defineProps({
  to: {
    type: String,
    required: true,
  },
});
</script>
<template>
    <router-link :to="to" class="text-light-blue">
        <slot></slot>
    </router-link>
</template>

我们现在可以直接将 AppLink 插入到 AppBar.vue 中,通过将 router-link 替换为我们的新组件。此组件为我们的应用程序中的链接添加样式。请注意,我们故意将标记保持接近原始的 router-link

<script setup lang="ts">import AppLink from '@/components/AppLink.vue';
</script>
<template>
  <v-app-bar flat>
    <v-btn id="hamburger-activator" icon="mdi-menu"> </v-btn>
    <v-menu activator="#hamburger-activator">
      <v-list>
        <v-list-item>
          <v-btn flat block><app-link to="/">Home</app-link></v-btn>
          <v-btn flat block
            ><app-link to="/recipes">Recipes</app-link></v-btn
          >
        </v-list-item>
      </v-list>
    </v-menu>
<!-- ... abbreviated -–>
</template>

现在我们能够导航了,让我们继续通过在应用中公开 API 数据来继续。

我们的 API 连接

我们现在将专注于 CalendarCard.vueCalendarDays.vue 组件。我们将添加搜索食谱并将其添加到一天、查看它以及删除它的功能。

我们将从 CalendarCard.vue 开始,添加一个事件来表示用户选择了一个特定的日期:

<script setup lang="ts">import { useFormatDate } from "@/composables/formatters";
const emits = defineEmits(["daySelected"]);
const addRecipeToDay = (card: Card): void => {
  emits("daySelected", card);
}
interface Card {
  date: Date;
}
const props = defineProps<{
  card: Card;
}>();
</script>
<template>
  <v-sheet class="d-flex justify-space-between">
    <v-sheet class="ma-2 pa-2">
      <h2 class="text-h2">{{ useFormatDate(card.date) }}</h2>
    </v-sheet>
    <v-sheet class="ma-2 pa-2">
      <v-btn text @click="addRecipeToDay(card)" icon="mdi-plus"></v-btn>
    </v-sheet>
  </v-sheet>
</template>

在用户界面中,我们添加了一个按钮,当点击时它会发出当前卡片。现在,让我们修改 CalendarDays.vue 以使其能够捕捉这个事件并显示一个对话框来搜索食谱:

<script setup lang="ts">import { ref } from "vue";
import type { Ref } from "vue";
import CalendarCard from "@/components/CalendarCard.vue";
const props = defineProps({
  date: {
    type: Date,
    required: true,
  },
  days: {
    type: Number,
    required: false,
    default: 7,
  },
});
interface Card {
  date: Date;
  content: string;
}
const generateCards = (startDate: Date, numberOfDays: number): Card[] => {
  const cards: Card[] = [];
  const currentDate = new Date(startDate);
  for (let i = 0; i < numberOfDays; i++) {
    const date = new Date(currentDate.getTime());
    const content = Card ${i + 1};
    cards.push({ date, content });
    currentDate.setDate(currentDate.getDate() + 1);
  }
  return cards;
};
const cards = ref<Card[]>(generateCards(props.date, props.days));
const dialogVisible: Ref<boolean> = ref(false);
const dateSelected: Ref<Date | null> = ref(null);
const recipeDialogOpen = (card: Card): void => {
  dateSelected.value = card.date;
  dialogVisible.value = true;
};
const recipeDialogClose = (): void => {
  dateSelected.value = null;
  dialogVisible.value = false;
};
</script>
<template>
  <v-table>
    <thead>
      <tr>
        <th class="text-left">Upcoming days</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="card in cards" :key="card.date.toString()">
        <td class="py-4">
          <calendar-card :card="card" @daySelected="recipeDialogOpen" />
        </td>
      </tr>
    </tbody>
  </v-table>
  <v-dialog v-model="dialogVisible" scrollable>
    <v-card>
      <v-card-title> Search for a recipe to add to this day </v-card-title>
      <v-card-actions>
        <v-btn color="primary" block @click="recipeDialogClose"
          >Close Dialog</v-btn
        >
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

在更新的代码中,我们将对话框的状态存储在一个新创建的变量中,并使用 Vuetify 提供的组件打开一个对话框,我们还可以关闭并恢复变量到它们的初始值。

选择食谱

接下来,我们将构建一个小型搜索组件以在对话框中显示。这将允许用户搜索食谱。当选择后,我们将食谱的详细信息传递给 CalendarDays.vue 组件。

创建一个名为 RecipeSearch.vue 的文件:

<script setup lang="ts">import { ref, watch } from "vue";
import type { Ref } from "vue";
import { useRecipeSearch } from "@/composables/recipeApi";
import type { RecipeResults } from "@/types/spoonacular";
const emits = defineEmits(["recipeSelected"]);
const searchQuery: Ref<string> = ref("");
const searchResults: Ref<RecipeResults[] | []> = ref([]);
const getSearchResults = async () => {
  const result = await useRecipeSearch(searchQuery.value);
  searchResults.value = result.results;
};
let timeout: ReturnType<typeof setTimeout>;
const debouncedSearch = (): void => {
  clearTimeout(timeout);
  timeout = setTimeout(async () => {
    getSearchResults();
  }, 500);
};
watch(searchQuery, (): void => {
  debouncedSearch();
});
const recipeSelected = (result: RecipeResults): void => {
  emits("recipeSelected", result);
};
</script>
<template>
  <v-car flat>
    <v-card-text>
      <v-text-field v-model="searchQuery" label="Search"></v-text-field>
    </v-card-text>
    <v-divider></v-divider>
    <v-list v-if="searchResults">
      <v-list-item v-for="(result, index) in searchResults" :key="index">
        <v-list-item-title @click="recipeSelected(result)" class="list-item">{{
          result.title
        }}</v-list-item-title>
      </v-list-item>
    </v-list>
  </v-car>
</template>
<style scoped>
.list-item {
  cursor: pointer;
}
.list-item:hover,
.list-item:active {
  text-decoration: underline;
}
</style>

我们正在使用一个防抖观察者,与 Marvel 搜索组件相同。我们可以使用组合式函数根据简单的基于文本的搜索查询检索结果,并在列表中显示结果。

当用户点击列表项时,我们将发出事件并将相应的食谱作为上下文发送给父组件。

让我们实现一天中食谱的添加和删除功能!

添加和删除餐点

实现搜索功能现在非常简单!我们可以在 setup 标签中导入组件,然后在模板中放置组件的标签。我们确实需要添加一个监听器,因为我们正在在搜索组件上发出事件。

让我们看看:

<script setup lang="ts">import { ref } from "vue";
import type { Ref } from "vue";
import type { RecipeResults } from "@/types/spoonacular";
import CalendarCard from "@/components/CalendarCard.vue";
import RecipeSearch from "@/components/RecipeSearch.vue";
interface Today {
  id: number;
  title: string;
  readyInMinutes: number;
}
interface Card {
  date: Date;
  content: string;
  today: Today[];
}
// …abbreviated
const insertRecipeOnDay = (recipe: RecipeResults): void => {
  if (dateSelected.value) {
    cards.value = cards.value.map((card) => {
      if (card.date.getTime() === dateSelected.value?.getTime()) {
        return { ...card, today: [...card.today, recipe] };
      }
      return card;
    });
    recipeDialogClose();
  }
};
</script>
<template>
  <v-table>
    <!-- abbreviated -->
  </v-table>
  <v-dialog v-model="dialogVisible" scrollable>
    <v-card>
      <v-card-title> Search for a recipe to add to this day </v-card-title>
      <recipe-search @recipeSelected="insertRecipeOnDay" />
      <v-card-actions>
        <v-btn color="primary" block @click="recipeDialogClose"
          >Close Dialog</v-btn
        >
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

通过这样,我们已经将 recipe-search 组件添加到页面中。通过 insertRecipeOnDay 函数,我们可以通过添加通过搜索组件发出的所选食谱并将其添加到卡片的新属性 today 上来修改我们的卡片集合。

在我们显示一天的食谱之前,让我们也添加一个基于食谱 ID 和日期删除一天中食谱的方法(这样我们就可以支持一天中的多个食谱以及跨多天的相似食谱)。我们可以在 script 标签中添加以下函数:

const removeRecipeFromDay = (recipe: { id: number }, date: Date): void => {  cards.value = cards.value.map((card) => {
    if (card.date.getTime() === date.getTime()) {
      return {
        ...card,
        today: card.today.filter((today) => today.id !== recipe.id),
      };
    }
    return card;
  });
};

此函数简单地通过过滤掉与给定 ID 和日期匹配的任何食谱来修改 today 集合。在模板中,我们将添加对 CalenderCard.vue 组件事件的监听器,如下所示:

<calendar-card :card="card" @daySelected="recipeDialogOpen" CalendarCard.vue component so that it shows the recipes for a day and then add the option to remove them:


In the template, we’ve just added an emitter to emit the `recipeRemoved` event that provides the recipe and date as context. Here, we use Vuetify components to create a repeating card layout to show any recipes that are added for that day.
As you can see, we also provide a link to the details page of the recipe, so we need to build one! But before we do that, let’s take a look at our app at this point. You should be able to use the app interface to show several upcoming days. When selecting a date, we can search for a recipe using the Spoonacular API and add one or more recipes to our meal planner. We can also remove recipes from our meal planner.
We are not saving anything yet, which means that refreshing the browser or navigating to a different page empties the meal planner! That’s something we will work on in the next chapter, but before we do that, we must add the cooking instructions to a separate route.
Let’s create a `CookingInstructions.vue` component in the `components` folder with the following contents:


Note that we are using our `AppLoader` component because the contents will come directly from the API. Other than that, most of the layout uses Vuetify components to display the details coming from the endpoint.
Next, we’ll create the view to load this component. In the `views` folder, we’ll create a `RecipeView.vue` file:


Let’s add it to the router (`src/router/index.ts`) with a new entry, using `id` as a parameter:

import { createRouter, createWebHistory } from 'vue-router'const routes = [

{

path: '/',

component: () => import('@/layouts/default/Default.vue'),

children: [

{

path: '',

name: 'Home',

// 路由级别的代码拆分

// 这为该路由生成一个单独的块(about.[hash].js)

// 当访问路由时按需加载。

component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue'),

},

{

path: 'recipes',

name: 'recipes',

component: () => import('@/views/RecipesView.vue')

},

{

path: '/recipe/:id',

name: 'recipe',

component: () => import('../views/RecipeView.vue')

}

],

},

]

const router = createRouter({

history: createWebHistory(process.env.BASE_URL),

routes,

})

export default router


That’s it for this section. While we have some functionality, it isn’t usable because our data is not persistent. We need to build a stateful application. And since managing state can be tedious to do by hand, we’ll make use of Pinia to help us with that!
You may have noticed that, by using Vuetify components, we have less logic in our application to deal with the state of the user interface. Our `script` tags now contain mostly functions that are tied to the features of the application.
This is one of the benefits of using a component library in your application: you can focus on the specific features rather than on building interactive user interface elements.
Using Pinia for state management
In this section, we’ll focus on making our application stateful using Pinia. This means we will have to refactor existing code, optimize certain flows, and add new features to our application.
Stateful applications
We use the term *stateful applications* to describe applications that can use, save, and persist data for a certain amount of time. A state can be temporary (while a session lasts) or of a more permanent nature when stored in a database.
The state is contextual to the current user and is typically not shared between users. In short, it is representative of the current user's state of interacting with an application.
Adding Pinia
**Pinia** is a framework for managing states of applications built using Vue.js 3\. It aims to facilitate sharing and interacting with a state or store by leveraging composables and simple syntax.
Let’s add Pinia to our project by installing it using the command line:

npm install pinia


 Next, we need to create our Pinia instance and pass it to the app as a plugin. Open the `main.ts` file to make the following changes:

/** * main.ts

  • 引入 Vuetify 和其他插件,然后挂载 App

*/

// 组件

import App from './App.vue'

// Pinia

import { createPinia } from 'pinia'

// 可组合函数

import { createApp } from 'vue'

// 插件

import { registerPlugins } from '@/plugins'

const pinia = createPinia()

const app = createApp(App)

registerPlugins(app)

app.use(pinia)

app.mount('#app')


That’s it! We can now create our stores and make our app even better.
The first store
We’ll start with something small. If you’ve opened the cooking instructions for a recipe, you may have noticed the loading indicator. That’s because the data is coming from an external API. If you refresh the page, you will notice that you have to wait for data to load again. You can see it in the **Network** tab of your developer tools as well. We can use a store to at least cache requests once they’ve been resolved, to prevent additional requests to the same resource and improve the performance of the app.
For stores, we’ll create a folder called `stores` in the `src` folder. Let’s add a `cache` folder and create an `index.ts` file. In this file, we’ll use the `defineStore` method to create a store called `cache` (the names need to be unique):

import { defineStore } from "pinia";export const useCacheStore = defineStore('cache', () => {

return { }

});


This is how you define any store – we use composable use notation. The composable consists of a (now empty) object that we will complement with the functions we want to expose. We need a function to cache data and one to return cached data, which will look like this:

import { defineStore } from "pinia";export const useCacheStore = defineStore('cache', () => {

const cachedData = (): void => {}

const cacheData = (): void => {}

return { cachedData, cacheData }

});


We can define functions and choose which functions we want to return. In this case, we’ll return both of the functions.
In our store, we can use native functions from Vue, such as `reactivity`! We’ll define a constant named `cache` and, using both of our functions, read from the cache or add data to the cache:

import { ref } from "vue";import { defineStore } from "pinia";

export const useCacheStore = defineStore('cache', () => {

const cache = ref([]);

const cachedData = (key: string): any => {

try {

return cache.value[key]

} catch (e) {

return undefined;

}

}

const cacheData = (key: string, data: any): void => {

cache.value[key] = data

}

return { cachedData, cacheData }

});


Now, this is a very simplistic design, but for our purposes, it works. Let’s see how we can implement this in our `CookingInstructions.vue` component.
In our `script` tag, we need to import the cache store and initialize the store on a `store` constant:

import { useCacheStore } from "@/stores/cache";const store = useCacheStore();


That’s all we need to do to use the methods in our store. Now, we’ll modify the `getRecipeDetails` function so that it only uses the external API if no data is found on the cache by the unique key per response:


The cache is not stored on the user’s machine but stored in the session of the Vue application. On refresh, the data will be lost, but when navigating backward or forward in the app, the cache will be available. When the data is stored on a unique identifier, and using the store, we can reach the contents of the store from anywhere within the Vue.js application.
Do you remember the incomplete features of the meal planner? Let’s build a store to centralize the way we manage those recipes.
The meal planner store
In this section, we’ll create a dedicated store for our meal planner capabilities. So, create a folder called `planner` in the `stores` folder and create an `index.ts` file where we’ll define our store:

import { defineStore } from "pinia";export const usePlannerStore = defineStore('planner', () => {

})


We’re going to add some functions to interact with recipes. In this case, we would like persistence so that the information is stored for longer periods. We’ll use the `useLocalStorage` composable from `VueUse` by installing the dependency in our project:

npm install @vueuse/core


 We want our planner to be persistent (and reactive), so let’s add that functionality first:

import { ref } from "vue";import { defineStore } from "pinia";

import { useLocalStorage } from "@vueuse/core"

interface Recipe {

id: number;

date: Date;

}

export const usePlannerStore = defineStore('planner', () => {

const recipes = ref<Recipe[] | any>(useLocalStorage('planner', []));

return { recipes }

})


When using the store, we can access the recipes from anywhere by accessing the store and requesting the recipes. We’ll add two more methods, for adding and removing recipes:

import { ref } from "vue";import { defineStore } from "pinia";

import { useLocalStorage } from "@vueuse/core"

interface Recipe {

id: number;

date: Date;

}

export const usePlannerStore = defineStore('planner', () => {

const recipes = ref<Recipe[] | any>(useLocalStorage('planner', []));

const addRecipe = (recipe: Recipe) => {

recipes.value.push(recipe)

}

const removeRecipeByIdDate = (options: { id: number, date: Date }) => {

const { id, date } = options;

const recipeIndex: number = recipes.value.findIndex((recipe: Recipe) => recipe.id === id && new Date(recipe.date).setHours(0, 0, 0, 0) === new Date(date).setHours(0, 0, 0, 0))

recipes.value.splice(recipeIndex, 1)

}

return { recipes, addRecipe, removeRecipeByIdDate }

})


With this in place, we can retrieve the recipes in the `MealPlanner.vue` file:


Using `storeToRefs` from Pinia ensures that the values from our store are automatically converted into reactive properties! We’re passing the recipes down to `CalendarDays.vue`, so let’s continue with the implementation.
Now, in the `CalendarDays.vue` component, we’re receiving the recipes as a property, but we’ll also make sure we can add and remove recipes from the planner. First, we’ll focus on processing the recipes by adding the property definition and updating the `generateCards` function, where we’ll map the recipes to each generated day:

const props = defineProps({  date: {

type: Date,

required: true,

},

days: {

type: Number,

required: false,

default: 7,

},

recipes: {

type: Array,

required: false,

value: [],

},

});

const generateCards = (startDate: Date, numberOfDays: number): Card[] => {

const cards: Card[] = [];

const currentDate = new Date(startDate);

for (let i = 0; i < numberOfDays; i++) {

const date = new Date(currentDate.getTime());

const content = Card ${i + 1};

const recipesThisDay = props.recipes?.filter((recipe: any) => {

const recipeDate = new Date(recipe.date).setHours(0, 0, 0, 0);

return recipeDate === date.setHours(0, 0, 0, 0);

}) as Today[];

cards.push({ date, content, today: recipesThisDay });

currentDate.setDate(currentDate.getDate() + 1);

}

return cards;

};


We can’t see it in action yet because we have no means of adding recipes to our store. Let’s add the store to the component and modify the `insertRecipeOnDay` function so that we can save the recipes in our planner store:

const insertRecipeOnDay = (recipe: RecipeResults): void => {  if (dateSelected.value) {

store.addRecipe({ ...recipe, date: dateSelected.value });

recipeDialogClose();

}

};


If you open the app and add a recipe to a date, it gets added just as before, with the difference that on refresh, the item is preserved!
Now, we can do something nifty using our cache store: we can preload the data to the cache to speed up the user experience when opening the cooking instructions after adding them. With our stores combined, it’s just a couple of lines.
We’ll import the cache store and, using the predetermined key, load the information and write it to the cache store. I tend to move the store references to the top of the file so that I can quickly glance over the capabilities that a component has at its disposal:


Now, if you’ve selected a recipe, if you visit the cooking instructions, you’ll notice that the content is there instantly! The added value of this approach depends on a couple of factors. If we expect that, on average, users want to navigate to a detailed view after adding a recipe, it makes perfect sense.
In other cases, it only adds an additional API call. So, use this pattern only when needed. In our case, it serves as a demonstration of using the cache store from multiple entry points in our application.
We have to make sure that we can remove the recipe as well. We’re going to apply that logic to the card that holds the recipe. We don’t have to centralize these functions since we have the store to take care of this for us! Therefore, we can remove the `removeRecipeFromDay` method (highlighted in the following code) from the `CalendarDays` component, as well as the event listener in the template:

<calendar-card  :card="card"

@daySelected="recipeDialogOpen"

@recipeRemoved="removeRecipeFromDay"

/>


Now, we can zoom in on the `CalendarCard.vue` component to add the ability to remove recipes to/from this component. We’ll start by removing the `recipeRemoved` event (highlighted in the following code) and function:

const emits = defineEmits(["daySelected", "recipeRemoved"]);// …abbreviated

const recipeRemoved = (recipe: Today, date: Date): void => {

emits("recipeRemoved", recipe, date);

};


We’ll create a new function after importing the planner store in this component. In the function, we’ll call `removeRecipeByIdDate` from the store and pass the current context:


In the template, we’ll modify the removal button by calling the new function with the correct parameters:

  

<v-btn

text

icon="mdi-trash-can-outline"

@click="removeFromDay({ id: today.id, date: card.date })"


With this, we can document and use actions in places that make sense within the context of the app. With the store, we’ve created a central state where we can access and manipulate different components without needing to pass properties from one component to the next. Having a library such as Pinia integrated with the Vue environment makes it a straightforward choice since it can fully leverage the reactive capabilities of Vue out of the box!
Computed store values
To stress the reusability aspect, we will finally take a look at `RecipesList.vue`, which we’ve filled with static content. Since the meal planner only shows the upcoming few days, we may want to show the full extent of past and future planned recipes.
We have two tabs in `RecipesList` – one for showing upcoming recipes and one for past recipes. While we could ingest all the recipes from the store and apply some sorting with a centralized store, it makes more sense to handle it close to the source.
We can use computed values in stores, just like Vue components! To display them, we’ll internally sort the recipes and provide two values:

import { ref, computed } from "vue";import { defineStore } from "pinia";

import { useLocalStorage } from "@vueuse/core"

import type { Recipe } from "@/types/spoonacular";

interface RecipeList extends Recipe {

date: Date;

}

export const usePlannerStore = defineStore('planner', () => {

const recipes = ref<Recipe[] | any>(useLocalStorage('planner', []));

const recipesSortedByDate = () =>

recipes.value.sort((a: { date : Date }, b: { date: Date }) => new Date(a.date).getTime() < new Date(b.date).getTime() ? -1 : 1)

const pastRecipes = computed(() => {

const sorted = recipesSortedByDate();

return sorted.filter((recipe: RecipeList) => {

const date = new Date(recipe.date);

return date < new Date();

}) as RecipeList[]

})

const futureRecipes = computed(() => {

const sorted = recipesSortedByDate();

return sorted.filter((recipe: RecipeList) => {

const date = new Date(recipe.date);

return date >= new Date();

}) as RecipeList[];

})

const addRecipe = (recipe: Recipe) => {

console.log('addRecipe', recipe)

recipes.value.push(recipe)

}

const removeRecipeByIdDate = (options: { id: number, date: Date }) => {

const { id, date } = options;

const recipeIndex: number = recipes.value.findIndex((recipe: Recipe) => recipe.id === id && new Date(recipe.date).setHours(0, 0, 0, 0) === new Date(date).setHours(0, 0, 0, 0))

recipes.value.splice(recipeIndex, 1)

}

return { recipes, pastRecipes, futureRecipes, addRecipe, removeRecipeByIdDate }

});


As I mentioned earlier, it is perfectly fine to have non-exposed methods in our store. With the final `return` statement, we can decide what methods to expose on the module. The functions are nothing extraordinary. After sorting by date, they filter and return dates from the past or future.
Let’s connect the dates to the `RecipesList.vue` component. We will focus on the `script` tag since we built the interface previously. We’ll remove the highlighted parts from the following code:


Without the mockup code, it looks a lot more readable already! We’ll also connect the `openPreview` event to another dialog. We’ll just reuse the existing `CookingInstructions.vue` component, but you could also consider creating a specific preview of your own.
Let’s change the `script` tag so that it matches the following:


Then, we’ll add a dialog from Vuetify to the template code to show `CookingInstructions`:


With this in place, navigating from the preview to the Cooking instructions page is almost instant again since we’re caching the contents and only have to load them on the first request. Using the store, we can interact with the date in a centralized and reusable way. Let’s try and solidify this by adding a final component to our app.
Rating the recipes
As a final addition, we’ll demonstrate the reusability of composables by adding a rating feature to every recipe. It will store the recipe ID and a rating of 1 through 5 stars. We should be able to read and update the rating from anywhere in the application.
First, we’ll create the store. For that, we’ll add a folder called `rating` with the following contents in the `index.ts` file:

import { ref } from "vue";import { defineStore } from "pinia";

import { useLocalStorage } from "@vueuse/core"

interface Rating {

id: number;

rating: number;

}

export const useRatingStore = defineStore('rating', () => {

const ratings = ref<Rating[] | any>(useLocalStorage('rating', []));

const getRatingById = (id: number) => {

const rating = ratings.value.find((rating: Rating) => rating.id === id)

return rating?.rating;

}

const saveRating = (rating: Rating) => {

const ratingIndex = ratings.value.findIndex((r: Rating) => r.id === rating.id)

if (ratingIndex === -1) {

ratings.value.push(rating)

} else {

ratings.value[ratingIndex] = rating

}

}

return { getRatingById, saveRating }

})


Now, we’ll create a component called `RecipeRating.vue`. It will use the rating store and, based on a provided ID, retrieve any ratings for that ID. It will also allow you to add a new rating for that particular ID, overwriting the old value.
Vuetify has a premade component for this, so when we’re combining stores and a component library, we can quickly build interactive components:


The important parts are highlighted, although you should be familiar with them by now. We can add this component anywhere in our application where we have access to a recipe ID.
Let’s start with `CookingInstructions.vue`. We need to import the component and then add it to our template:


Feel free to add the `rating` component on other mentions as well. It’s just two lines of code!
With that, we’ve iterated over our Marvel app by utilizing a component library to speed up development time. It allows us to build interfaces with relative ease. We’ve also refactored and updated our code since our needs have changed. This situation reflects how real-world code bases evolve.
Summary
In this chapter, we combined two concepts to build an application that scales well in development. As we’ve seen, by adopting more and more of the principles of both the component library as well as a centralized store, the more readable and simplified our code becomes.
Using a component library such as Vuetify provides us with a quick way of adding interactive elements to a user interface that are well tested and easy to use out of the box. Coming with means of customization and theming, we can make sure that our implementation follows any style guide.
This would be a good time to try your hand at customizing the user interface, either by setting up themes and styles or just by using the classes and properties on existing components.
By adding state to our app, we’ve made it usable and reusable for our users. In our example, we’ve stored our data in the browser, which isn’t as portable. However, it does give us a practical look at dealing with data and caching resources. With the methods in a central place, it becomes easier to refactor the way we would store data.
We’ve deliberately not built a perfect app from the beginning and instead demonstrated how a refactoring process evolves with the needs and features of app development. Admittedly, it’s still not perfect at this point. As an extra exercise, you could try and see whether you can apply some of the lessons we’ve learned so far.
In the next chapter, we’ll solidify our Vuetify knowledge by building another application using the component library. We’ll make things more interesting by building a data resource. We’ll learn about not just reading from an endpoint but also writing and storing data by building a simple fitness tracker!

第六章:使用数据可视化创建健身追踪器

到目前为止,我们一直依赖于无状态应用程序或将状态存储在用户的浏览器上。在本章中,我们将介绍使用数据库在集中位置存储数据,并学习如何修改和从数据源读取数据。我们将利用这个机会,使用第三方库来引入一些数据可视化。

当我们使用数据库并需要设置表时,这绝对不是一份面向生产环境的数据库配置和管理指南。我建议以不同的方式提高这些技能。它确实是一个有价值的原型,可以帮助您熟悉数据库处理的相关模式。

再次,我们将基于我们迄今为止所获得的知识,并引入可组合组件、存储库和组件库来构建我们的产品。

在本章中,我们将介绍以下主题:

  • 创建仪表板和报告

  • 使用 Supabase 检索数据

  • 使用 Supabase 存储数据

  • 使用vue-chartjs添加各种可视化

技术要求

从上一章的要求中有些重叠。我们将使用Vuetify(vuetifyjs.com/en/)和Pinia(pinia.vuejs.org/)。对于数据存储,我们将使用Supabase(supabase.com/),这是一个具有内置身份验证的开源数据库提供商。对于数据库,我准备了一个创建数据库的脚本,另一个是添加示例数据的脚本。

这是 GitHub 链接:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/06.fitness

我们将在本章中介绍这些步骤。最后,对于数据可视化,我们将安装并使用vue-chartjs(vue-chartjs.org/),这是一个针对chart.js(www.chartjs.org/)库的 Vue 兼容包装器。

创建客户端

为了开始我们的项目,我们将使用 Vuetify 安装程序,就像我们在上一章中所做的那样。这是相应的命令:

npm create vuetify

选择vue-fitness-tracker作为项目名称,并选择以下选项,如图所示:

图 6.1 – 设置 Vuetify 项目

图 6.1 – 设置 Vuetify 项目

在我们的项目初始化后,我们将创建和配置一个数据库来存储我们的数据。

设置数据库

fitness-tracker上注册免费账户作为名称,并选择一个强大的数据库密码。对于区域,选择一个地理位置靠近您的选项以获得更好的延迟。我们将坚持使用免费计划!

在下一页(图 6.2)中,您将看到项目的 API 密钥:

图 6.2 – 项目 API 密钥概览

图 6.2 – 项目 API 密钥概览

我们将把它们存储在我们的项目根目录下的 .env 文件中:

VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

注意,通过客户端应用以这种方式共享密钥总是会将它们暴露给公众。幸运的是,Supabase 有自己的方法来确保在交互数据库时进行身份验证。

我创建了一个脚本,用于设置数据库并包含我们应用的表结构。通过仪表板和 SQL 编辑器,你可以从示例仓库中的 example-structure.sql 文件添加和执行查询,如图 图 6.3 所示:

图 6.3 – 查询成功执行后,你应该在表编辑器概览中看到四个表格

图 6.3 – 查询成功执行后,你应该在表编辑器概览中看到四个表格

执行完毕后,你可以使用 example-exercises.sql 脚本设置一些数据,如图 图 6.4 所示:

图 6.4 – 将示例练习插入到练习表中

图 6.4 – 将示例练习插入到练习表中

为了简化与数据库的交互,我们将使用 Supabase JavaScript 客户端,通过安装依赖项:

npm install @supabase/supabase-js

让我们将这个包转换成一个可组合的组件,在应用中处理数据库连接。在 src/composables 中,我们将创建一个 supabase.ts 文件并添加以下内容:

import { createClient } from '@supabase/supabase-js'const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const useSupabaseClient = createClient(supabaseUrl, supabaseAnonKey);

我做的最后一个改动是清理 layouts 文件夹中的样板组件。我删除了所有文件,除了 Default.vue,并更新了其内容以匹配以下内容:

<template>  <v-app>
    <v-main>
      <router-view />
    </v-main>
  </v-app>
</template>

这就是我们的起点,开始构建我们的应用。接下来,我们将确保应用显示个人结果,使用 Supabase 提供的内置工具。

处理用户

应用程序的目标是允许跟踪和查看个人指标,因此为此目的,我们需要确保我们可以识别我们的用户。Supabase 默认支持身份验证,我们将使用一个非常基本的方法:一个魔法链接

魔法链接允许你仅使用有效的电子邮件地址进行注册和登录。登录时,服务会发送包含唯一标识符的电子邮件,点击后,用户将被验证到该电子邮件地址。在我们的情况下,后端处理验证它是一个新用户还是一个现有用户,这对于我们的用例来说非常完美。

由于我们可以识别用户,我们需要将我们的应用连接到 Supabase 以获取它提供的信息。我们还可以引入身份验证,以确保用户可以访问他们想要使用或访问的部分。

用户存储

我们将希望始终能够访问和更新用户的状态,因此我们将在 Pinia 中设置一个用户存储,以跟踪当前状态并提供更新状态、登录和登出的操作。

src/store文件夹中创建一个包含以下内容的user.ts文件后,我们将查看存储的内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.1-user.ts

在会话(第 11 行)中,我们将存储用户的认证状态。它可以是null表示未登录,或者状态可以包含一个对象(如UserSession接口中定义的),该对象由 Supabase 授权服务填充。

使用login第 13-20 行)和logout第 22-30 行)方法,我们调用 Supabase 认证服务并执行提供的回调函数。我们很快就会看到这些功能在实际中的应用!

为了存储用户,我们有insertProfile函数(第 32-44 行),它将任何认证用户插入到我们的数据库中供将来参考。

注意

存储个人数据可能受当地法律和治理的约束。在存储、存储原因以及如何删除个人数据方面,请务必谨慎和透明。

setUserSession第 47-49 行)简单地将数据传递到状态中供进一步参考。最后,userIsLoggedIn第 51-58 行)检查当前会话数据是否仍然有效,如果不是,则返回false。我们可以使用这个来快速评估显示用户界面元素。

在存储就绪后,我们可以在应用中包含配置文件,并采取一些合理的安全措施。

用户认证

让我们创建一个表单,用户可以提供电子邮件地址,这将导致在components文件夹中生成FormLogin.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.2-FormLogin.vue

如您所见,我们正在使用我们的用户存储来分发一个带有用户提供的电子邮件地址的login动作(第 11-14 行第 25 行),模板是用 Vuetify 组件构建的。它包含一个form和一个dialog组件,在提交时触发,以通知用户查看他们的电子邮件。

OTP 登录会将登录链接发送到提供的电子邮件地址,这意味着用户将从外部链接进入应用。我们需要确保在应用加载时尝试验证用户的会话。为此,我们将更新根目录下的App.vue文件:

<script setup lang="ts">import { onMounted } from "vue";
import { useUserStore } from "@/store/user";
import { useSupabaseClient } from "@/composables/supabase";
const userStore = useUserStore();
onMounted(async () => {
  const { data } = await useSupabaseClient.auth.getSession();
  if (data && data.session && data.session.user) {
    await userStore.insertProfile(data.session);
    userStore.setUserSession(data.session);
  }
  useSupabaseClient.auth.onAuthStateChange((_, _session) => {
    userStore.setUserSession(_session);
  });
});
</script>
<template>
  <router-view />
</template>

在这个脚本中,我们将通过 Supabase 验证用户会话。在接收到数据后,我们将其存储在用户存储中,并使用存储来在我们的数据库中更新一个配置文件。我们还跟踪状态变化,以便处理更新的令牌或失效。

受保护的路由

在获取到用户状态后,我们可以使用beforeEnter生命周期钩子来验证用户是否有权访问某个路由。beforeEnter方法充当中间件,并执行一个函数,您可以通过该函数决定如何处理路由更改。

我们首先在views文件夹中创建一个用于登录状态的视图,命名为Login.vue

<script lang="ts" setup>import FormLogin from '@/components/FormLogin.vue';
</script>
<template>
  <form-login />
</template>

在路由文件中,我们将添加一个名为loginGuard第 7-14 行)的函数来检查用户是否已登录,并且我们将在受保护路由的beforeEnter方法中调用该函数(第 37 行)。如果用户会话存在,您将被允许跟随该路由。否则,您将被重定向到新添加的loginRoute函数(第 12 行,第 39-44 行):github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.3-router-index.ts

如果您运行开发应用程序,在第一次访问时,由于您尚未认证,您将看到一个登录表单,如图图 6**.5所示:

图 6.5 – 用户未登录,被重定向到/login 路由

图 6.5 – 用户未登录,被重定向到/login 路由

在提供您的电子邮件地址后,会出现一个全屏弹出窗口,引导用户进行下一步操作。如果您打开电子邮件客户端,您应该会很快收到一封包含魔法链接的电子邮件:

图 6.6 – 包含魔法链接的默认电子邮件

图 6.6 – 包含魔法链接的默认电子邮件

点击魔法链接将打开一个新的浏览器窗口,并应将您重定向到主页,该主页仅对已登录用户可访问:

图 6.7 – 只有登录用户才能看到主页

图 6.7 – 只有登录用户才能看到主页

这意味着如果您看到的是 Vuetify 默认的主页,那么您是一个已登录的用户!目前您没有登出的方式,所以让我们来完成这个认证流程。

登出

为了完成用户流程,我们将添加一个用户登出的功能。为此,我们将在商店中添加一个与用户登出方法关联的菜单按钮。

让我们在src文件夹中创建一个AppMenu.vue文件,并添加注销登录按钮。我们稍后会进一步扩展菜单:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.4-AppMenu.vue

在菜单中,根据用户状态(第 5-6 行第 10 行第 18 行),我们将显示一个按钮用于登录或登出。通过对我们App.vue文件的一些小修改,我们可以快速包含AppMenu.vue文件到我们的应用中(第 6 行第 21-26 行): github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.5-AppMenu.vue

我们现在已经完成了应用中的用户流程。我们可以让新用户登录和现有用户登录,并且登录用户可以退出应用。如您所见,我们将让 Supabase 处理逻辑,而我们只是从 Supabase 获取数据。

这是在前端开发中的一种常见模式,将认证留给服务器而不是客户端。对于我们的即将推出的功能,我们不必担心谁可以访问什么数据,因为我们已经配置了 Supabase(带有行级安全RLS)策略 (supabase.com/docs/guides/auth#row-level-security)) 和认证方法来为我们处理数据)。

现在我们为用户提供了注册和登录的方式,我们可以开始为应用添加添加个人数据的特性。

应用状态

为了让我们更容易控制应用的状态,我们将添加一个新的存储来跟踪用户界面的当前状态。

Vuetify 在store/app.ts文件中为我们创建了一个占位符应用存储,因此我们将添加一些特性来处理页面转换(第 28 行第 30-35 行),切换菜单(第 18 行第 20-26 行)和控制对话框(第 37-55 行)以进行应用级通知:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.6-app.ts

在一个集中的位置提供这些类型的用户界面工具,消除了在我们应用中重复某些模式的需要,例如显示或隐藏对话框。这意味着这些工具是应用的一部分,因此在整个应用中都是可用的。

集中式对话框

让我们更新FormLogin.vue文件,以便利用应用级别的存储选项。我们可以清理现有的对话框选项,并用调用存储方法来替换它们:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.7-FormLogin.vue

正如你所见,通过使用通用的应用存储(第 5 行第 8 行),我们现在也可以轻松地添加额外的对话框;例如,当缺少电子邮件地址(第 13-17 行)或 OTP 已发送(第 18-24 行)时。我们唯一需要做的是添加一个中央位置来显示对话框,我们将为此打开并修改App.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.8-App.vue

这样,我们有一个对话框(第 13 行第 31-39 行),它是应用的一部分,我们可以通过我们的存储(第 4 行第 11 行)从任何地方控制它!对于应用菜单也是如此,所以让我们修改我们的应用以实现对应用菜单的集中控制。

集中化应用菜单

我们可以将类似的模式应用到菜单上。我们将将其转换为滑动进出视图的抽屉链接功能。让我们从通过添加必要的应用存储引用来修改AppMenu.vue开始:

<script setup lang="ts">import { storeToRefs } from "pinia";
import { useUserStore } from "@/store/user";
import { useAppStore } from "@/store/app";
const userStore = useUserStore();
const appStore = useAppStore();
const { userIsLoggedIn } = storeToRefs(userStore);
const { drawer } = storeToRefs(appStore);
const goToPage = (page: string): void => {
  appStore.navigateToPage(page);
};
</script>
<template>
  <v-navigation-drawer v-model="drawer" app>
    <v-list dense v-if="userIsLoggedIn">
      <v-list-item @click="userStore.logout()">
        <template v-slot:prepend>
          <v-icon icon="mdi-account-arrow-right"></v-icon>
        </template>
        <v-list-item-title>Log out</v-list-item-title>
      </v-list-item>
    </v-list>
    <v-list dense v-else>
      <v-list-item @click="goToPage('/login')">
        <template v-slot:prepend>
          <v-icon icon="mdi-account"></v-icon>
        </template>
        <v-list-item-title>Login</v-list-item-title>
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
</template>

在模板中,我们用 Vuetify 的navigation-drawer组件包裹了菜单,该组件使用drawer状态变量来显示为打开或关闭。我们还用导航到新页面的方法替换了router-link组件。

为了完成设置,我们需要修改App.vue文件以适应新的界面并最终确定应用布局:

<script setup lang="ts">// ...abbreviated
const userStore = useUserStore();
const appStore = useAppStore();
const { pageTitle, dialog } = storeToRefs(appStore);
const currentYear = new Date().getFullYear();
onMounted(async () => {
  // ...abbreviated
});
</script>
<template>
  <v-app>
    <app-menu />
    <v-app-bar app style="position: relative">
      <v-app-bar-nav-icon @click="appStore.toggleDrawer()"></v-app-bar-nav-icon>
      <v-toolbar-title>💪 Fittest Pal - {{ pageTitle }}</v-toolbar-title>
    </v-app-bar>
    <v-main>
      <!-- abbreviated -->
    </v-main>
    <v-footer app>
<span>&copy; {{ currentYear }} 💪 Fittest Pal Fitness Tracker</span>
    </v-footer>
  </v-app>
</template>

到目前为止,我们有一个支持个性化体验的应用基础,这取决于第三方认证,并且能够拥有公共和受保护的路由。我们有一些集中化的功能来控制应用用户界面的状态。下一步将是添加用户可以插入他们自己的数据的功能!

我们已经准备并设置了我们的数据库,包括表和一些预填充的练习。请随意查看表和exercises表的内容,因为它将帮助您理解我们的下一步。

我们有一个表格练习,其中存储了不同类型的健身房练习。用户数据(仅限于电子邮件地址)存储在表中。如果你通过登录表单注册,你应该已经看到了你的电子邮件地址!我们还有记录每个用户的训练的锻炼,以及将执行练习与锻炼组合的集合表。最后,我们添加了一个仪表板视图,我们将在稍后构建它。

现在,让我们确保用户可以通过构建一个锻炼跟踪器来向数据库添加数据。

锻炼跟踪

让我们添加一个新的路由,让我们的用户将常规添加到数据库中。让我们首先在路由上添加一个新的路由条目:

      {        path: 'track',
        name: 'Track',
        component: () => import(/* webpackChunkName: "track" */ '@/views/Track.vue'),
        beforeEnter: loginGuard
      },

正如你所见,这是一个只有经过认证的用户才能访问的页面。我们的入口也意味着我们需要创建一个名为Track.vue的视图,让我们继续:

<script lang="ts" setup>import TrackExercise from "@/components/TrackExercise.vue";
</script>
<template>
  <track-exercise />
</template>

我们将通过在 components 文件夹中创建一个空的 TrackExercise.vue 文件来完成初始化,并且我们将专注于创建一个符合我们跟踪活动的界面。

要导航到我们的路由,我们可以修改我们的 AppMenu.vue 文件。由于我们可以预期有更多的菜单项,我们可以在 script 块中定义一个模式,并让模板遍历这些项。我们将从集合中的一个单独项开始,如下所示:

<script setup lang="ts">// ...abbreviated
const menuItems = [
  {
    icon: "mdi-dumbbell",
    title: "Track",
    page: "/track",
  }
];
</script>

在我们的模板中,我们将扩展 v-list 以遍历登录用户的 menuItems 集合:

<v-list dense v-if="userIsLoggedIn">  <v-list-item
    v-for="item in menuItems"
    :key="item.title"
    @click="goToPage(item.page)"
  >
    <template v-slot:prepend>
      <v-icon :icon="item.icon"></v-icon>
    </template>
    <v-list-item-title>{{ item.title }}</v-list-item-title>
  </v-list-item>
  <v-list-item @click="userStore.logout()">
    <template v-slot:prepend>
      <v-icon icon="mdi-account-arrow-right"></v-icon>
    </template>
    <v-list-item-title>Log out</v-list-item-title>
  </v-list-item>
</v-list>

现在,我们可以从菜单中导航到应用中的新页面。

接下来,我们可以定义输入字段。我们将从为我们的用户创建一个日期选择器开始。

选择日期

我们想在锻炼中添加一个日期。目前 Vuetify 支持作为实验性功能的 datepicker。我们需要显式地将它导入到我们新创建的 TrackExercise.vue 文件中,并且此外,我们还将配置一些变量来跟踪用户界面状态以及所选日期:

<script setup lang="ts">import { ref } from "vue";
import type { Ref } from "vue";
import { VDatePicker } from "vuetify/labs/VDatePicker";
const showDialogDate: Ref<boolean> = ref(false);
const selectedDate: Ref<any[] | undefined> = ref(undefined);
</script>

在我们的模板中,我们将构建添加锻炼的控制,从日期选择开始:

<template>  <v-container>
    <v-row class="align-center justify-space-between mb-6">
      <v-btn @click="showDialogDate = true">
        <span v-if="selectedDate">Change date</span>
        <span v-else>Select date</span>
      </v-btn>
      {{ selectedDate }}
      <v-dialog v-model="showDialogDate" center>
        <v-date-picker
          v-model="selectedDate"
          show-adjacent-months
          @click:cancel="showDialogDate = false"
          @click:save="showDialogDate = false"
          style="margin: 0 auto"
        ></v-date-picker>
      </v-dialog>
    </v-row>
  </v-container>
</template>

这应该看起来很熟悉。我们添加了一个按钮来控制对话框,并对对话框进行了一些配置。我们不使用我们的全局对话框的原因是它包含更高级的内容和钩子。应用对话框旨在向我们的用户显示简短的消息。

如果你尝试一下,你会注意到选择日期会导致在界面中显示一个未格式化的日期。在我们继续之前,我们将使用 computed 方法以及浏览器的内置 Intl API 来修复这个问题:

<script setup lang="ts">import { ref, computed } from "vue";
// ... abbreviated
const formattedDate: Ref<string> = computed(() => {
  if (selectedDate?.value?.length) {
    return new Intl.DateTimeFormat("en-US", {
      weekday: "long",
      year: "numeric",
      month: "long",
      day: "numeric",
    }).format(selectedDate.value[0]);
  }
  return "";
});
</script>

在我们的模板中,我们将用格式良好的日期表示替换 {{ selectedDate }}

<h4 class="text-h5">{{ formattedDate }}</h4>

添加常规锻炼

对于添加一个常规锻炼,我们希望用户能够从我们的数据库中选择一个常规锻炼。我们希望提供一个常规选择器,所以让我们构建一个!组件应该从数据库中读取锻炼,并让用户选择一个以添加一套(重量和重复次数)的属性。

由于需要集中式数据,我们可以为所有与健身相关的数据和函数创建一个存储库。让我们在存储库中创建一个 fitness.ts 文件,并且我们将从从数据库中检索锻炼开始:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.9-fitness.ts

存储库公开了锻炼和检索它们的方法。由于我们希望立即填充 exercises 列表,我们可以在初始化存储库时调用 getExercises 方法(第 13-28 行第 29 行)!

我们将在未来添加更多存储库,但到目前为止,我们可以开始在一个新组件中使用数据:一个名为 SelectExercise.vuecomponents 文件夹,我们将导入存储库并使用它来填充一个 Vuetify select 组件:

<script setup lang="ts">import { storeToRefs } from "pinia";
import { useFitnessStore } from '@/store/fitness'
const fitnessStore = useFitnessStore()
const { exercises } = storeToRefs(fitnessStore)
</script>
<template>
  <v-select
    v-if="exercises"
    label="Select exercise"
    :items="exercises"
    item-title="name"
    item-value="id"
  ></v-select>
</template>

非常简单!我们访问存储并映射值到v-select组件。除了exercise,我们希望用户能够将weightrepetitions作为常规的一部分添加。因此,让我们将创建的组件包裹在一个名为AddRoutine.vue的父组件中:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.10-AddRoutine.vue

在我们转向模板之前,让我们先分解一下。我们有exercise第 10 行)和routine第 9 行)变量,其中一项常规包括一项练习以及一组重量和重复次数的组合。

routines的一个例子可能如下所示:

{ "exercise": "7fa3219e-9127-4189-ae30-d340aaf0f9e6",  "routines": [
    { "weight": "10", "repetitions": "10" },
    { "weight": "10", "repetitions": "10" }
  ]
}

流程从选择一项锻炼开始,然后监视器(第 38-40 行)准备填充新值的常规(清除routines数组)。模板中的界面被建模为重量和重复次数,您将在下面看到。

如往常一样,可以使用addRow函数(第 16-21 行)向routines属性添加多行。如果用户出错,可以从属性中移除一行(第 23-25 行)。

add函数(第 33-36 行)将routine对象发送到父组件并重置表单上的任何值。

在模板部分,我们首先使用select-exercise组件(第 48 行)来触发流程,并使用 Vuetify 扩展面板显示添加一套的表单(第 52-86 行)以及显示您已添加的套数摘要(第 87-131 行)。

第二个扩展面板也使用 Vuetify 徽章来指示未保存更改的数量(第 92-100 行)。底部有一个按钮(第 135-144 行),它调用emit函数以将routine对象发送到父组件。

我们现在可以回到TrackExercise.vue文件,以获取发出的事件,并将选定的日期与建模的常规结合起来,最终将其存储在数据库中。

script块中,我们将添加以下代码以跟踪常规和子组件:

<script setup lang="ts">// ...abbreviated
import type { Routine } from "@/types/fitness";
import AddRoutine from "./AddRoutine.vue";
const routines: Ref<Routine[]> = ref([]);
const showDialogRoutine: Ref<boolean> = ref(false);
const addRoutineToExercise = (newRoutine: any) => {
  showDialogRoutine.value = false;
  routines.value.push(newRoutine);
};
</script>

在我们的模板中,在日期选择器的表示下方,我们可以添加一个用于创建常规的对话框:

<template>  <v-container>
    <v-row class="align-center justify-space-between mb-6">
      // …abbreviated
    </v-row>
    {{ routines }}
    <v-row class="mb-6">
      <v-btn
        block
        size="x-large"
        @click="showDialogRoutine = true"
        v-if="selectedDate"
      >
        Add routine
      </v-btn>
      <v-dialog v-model="showDialogRoutine" fullscreen>
        <v-card>
          <v-card-text>
            <add-routine @add="addRoutineToExercise" />
          </v-card-text>
          <v-card-actions>
            <v-btn color="primary" @click="showDialogRoutine = false"
              >Close</v-btn
            >
          </v-card-actions>
        </v-card>
      </v-dialog>
    </v-row>
  </v-container>
</template>

在此代码更改之后,一旦选择了日期,我们将显示一个按钮以开始添加常规。常规选择组件在一个专用对话框中打开,并在add事件上调用addRoutineToExercise函数,该函数向此组件中的对象添加换行符。您现在可以亲自尝试,因为我们目前正在内联显示{{ routines }}。它应该看起来类似于图 6**.8

图 6.8 – 我们用于编译锻炼的界面

图 6.8 – 我们用于编译锻炼的界面

我们下一步将是对我们想要添加到数据库中的锻炼格式化显示,当然,也将锻炼本身存储在数据库中。

将数据保存到数据库

我们为用户创建了一个功能,以便他们可以模拟锻炼,这样我们就可以开始保存辛勤的工作。我们将使用我们的健身存储文件来完成这项工作,所以让我们添加一些新方法并将它们导出以供使用:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.11-fitness.ts.

我们需要访问用户 ID,所以我们将导入userStore函数(第 2 行),并且我们将导入我们将使用的一些数据类型。

我们添加了主要的saveWorkout函数(第 40-70 行),它执行两个辅助函数:首先,它使用insertWorkout函数(第 11-25 行)将锻炼保存到锻炼表中。在从锻炼中检索id属性后,我们可以开始保存集合。为此,我们遍历常规以编译集合列表,然后我们可以使用insertSets方法(第 27-38 行)一次性保存它们。

在添加了这些功能后,让我们回到我们的TrackExercise.vue文件,添加一个saveWorkout动作。我们将导入健身和应用存储:

import { useFitnessStore } from "@/store/fitness";const fitnessStore = useFitnessStore();
import { useAppStore } from "@/store/app";
const appStore = useAppStore();

我们还将添加函数来验证锻炼是否可以实际保存——一个用于重置表单状态,另一个将信息传递到我们的存储动作:

const canSaveWorkout = computed(() => {  return routines.value.length > 0;
});
const reset = () => {
  routines.value = [];
  selectedDate.value = undefined;
};
const saveWorkout = () => {
  if (selectedDate.value && routines.value?.length > 0) {
    fitnessStore.saveWorkout({
      date: selectedDate.value,
      routines: routines.value,
    });
    appStore.showDialog({
      title: "Success",
      contents: "Workout saved successfully",
    });
    reset();
  } else {
    appStore.showDialog({
      title: "Error",
      contents: "Please select a date and add at least one routine",
    });
  }
};

再次,我们使用应用对话框来显示我们系统的消息;既方便又可重用。我们将在模板底部添加一个条件性的保存按钮来结束我们的表单:

<template>// …abbreviated
    <v-row class="mb-6">
      <v-btn
        block
        size="x-large"
        :disabled="!canSaveWorkout"
        @click="saveWorkout"
        v-if="selectedDate"
        >Save workout</v-btn
      >
    </v-row>
  </v-container>
</template>

你可以试一试。数据应该会显示在你的 Supabase 实例的表格中。在*第七章**中,我们将开始以各种方式检索这些数据。正如你所看到的,在开发过程中,有时在构成链的各个组件之间来回切换是有意义的。

我试图演示这个过程,因为它接近实际开发。一次性提出理想的解决方案(或规范!)是很少见的。

我们辛勤工作的可视化

存储数据是一回事。对于用户来说,如果我们可以将其呈现于一定的上下文中,数据才有价值。我们在保存之前的常规编译显示中做了一些小练习。在这一部分,我们将看到几个不同的数据展示示例。

我们将确保容纳空状态(所以请随意删除任何项目或尝试新的登录),在我们添加了一些锻炼之后,我们将找到显示数据的方法。

让我们先从替换主页开始。在这种情况下,我们已经有了一个路由和Home.vue视图,但我们将删除对HelloWorld.vue组件的引用,并创建一个空的History.vue <template>组件。然后,在Home.vue中,我们将引用History.vue文件而不是HelloWorld.vue

基于视图的仪表板

我们可以从一个快速组件开始,展示用户的最新统计数据。当在数据库上执行脚本时,它包含了一个视图,称为workout_dashboard_view。这就像是一个只读查询的聚合,我们可以将其作为单独的表来查询。

我们将通过添加和暴露dashboard变量(第 15 行第 42 行)来为健身商店添加获取数据的方法,这与我们对锻炼所做的方法类似,这个变量反过来从getDashboard方法(第 17-40 行第 42 行)获取数据:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.12-fitness.ts

在有了这些之后,我们可以创建一个组件来从商店访问仪表板并显示内容。让我们称它为WorkoutStats.vue,并将对健身商店的引用和加载仪表板:

<script setup lang="ts">import { computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useFitnessStore } from "@/store/fitness";
const fitnessStore = useFitnessStore();
const { dashboard } = storeToRefs(fitnessStore);
const daysSinceLastWorkout = computed(() => {
  if (!dashboard.value) return 0;
  const lastWorkout = new Date(dashboard.value.last_workout_date);
  const today = new Date();
  const diffTime = Math.abs(today.getTime() - lastWorkout.getTime());
  const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
  return diffDays;
});
onMounted(() => {
  fitnessStore.getDashboard();
});
</script>

我们增加了一个函数来计算今天和上次锻炼日期之间的差异,但这是一个简洁的设置。在模板中,我们可以期待四个用于仪表板的价值,每个我们都会单独展示在卡片上:

<template>  <v-row>
    <v-col cols="12" sm="3" m="2" class="d-flex justify-space-between">
      <v-card class="align-self-stretch flex-grow-1">
        <v-card-title>{{ daysSinceLastWorkout }}</v-card-title>
        <v-card-text>Days since last workout</v-card-text>
      </v-card>
    </v-col>
    <v-col cols="12" sm="3" m="2" class="d-flex justify-space-between">
      <v-card class="align-self-stretch flex-grow-1">
        <v-card-title>{{ dashboard?.total_workouts || 0 }}</v-card-title>
        <v-card-text>Total workouts</v-card-text>
      </v-card>
    </v-col>
    <v-col cols="12" sm="3" m="2" class="d-flex justify-space-between">
      <v-card class="align-self-stretch flex-grow-1">
        <v-card-title>{{ dashboard?.cumulative_weight || 0 }}</v-card-title>
        <v-card-text>Cumulative weight moved</v-card-text>
      </v-card>
    </v-col>
    <v-col cols="12" sm="3" m="2" class="d-flex justify-space-between">
      <v-card class="align-self-stretch flex-grow-1">
        <v-card-title>{{
          dashboard?.most_weight_single_workout || 0
        }}</v-card-title>
        <v-card-text>Most weight in a single workout</v-card-text>
      </v-card>
    </v-col>
  </v-row>
</template>

我们使用一个表达式来默认为0值,如果我们从仪表板没有结果。然而,一旦它被填充了值,我们就用这些值填充模板。由于模板中存在一些重复,所以有一些优化的空间。这将是一个很好的练习,可以自己改进!

现在,我们有一些方法来激励我们的用户开始填写更多的锻炼!现在让我们看看我们是否也能显示个人的锻炼。

历史和概述

为了检索我们的锻炼,我们将在健身商店中添加一个新的方法。为此目的,我们还有一个视图准备就绪!让我们看看更新的健身商店文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.13-fitness.ts

如您所见,通过锻炼(第 15 行)和检索锻炼的方法(第 20-46 行),我们向应用中暴露了一个新的数据集。

通过这些更改,我们可以在我们的History.vue文件中添加一些额外的内容。我们将首先从商店导入数据,并在模板中简单地输出它:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.14-History.vue

这个添加为我们提供了从数据库视图的概述(第 6-7 行,第 9 行,第 28 行),这些数据以 Vuetify 的展开面板形式呈现(第 36-45 行)。但正如你所看到的,我们需要对数据进行一些调整,因为它现在显示的是每个锻炼和练习组合的单独一行。我们希望按锻炼分组数据,因此我们将创建一个函数来转换我们的数据并添加更多的结构。请查看 History.vue 文件的下一个迭代版本:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.15-History.vue

这样我们就能得到唯一的 ID,以便我们能够识别每一个锻炼,并且我们已经重新设计了数据,使得每个唯一的锻炼都有一个对应的练习集合作为子项(第 23-33 行)。我们在练习上也做了类似的事情:根据 exercise_name 属性对它们进行分组。

在这种情况下,我们选择这种特定的方法,因为我们只需要查询数据库一次。有多种优化数据库查询的方法,我们现在选择直接从数据库中获取数据,并在我们的应用程序中对其进行建模以满足我们的需求。

我们还更新了面板,使其遍历 workoutIds 并根据 id 属性显示重构的(部分)锻炼(第 53-63 行)。

作为最后一步,我们可以创建一个小的组件来显示锻炼。我们将创建一个 GroupedExerciseView.vue 组件并添加以下内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.16-GroupedExerciseView.vue

我们可以通过将练习集作为属性传递来导入并使用我们的面板中的 Vue 组件:

<script setup lang="ts">// ...abbreviated
import GroupedExerciseView from "./GroupedExerciseView.vue";
// ...abbreviated
</script>

然后,我们可以将其传递到模板中:

<template>  <v-container>
    <workout-stats class="mb-4" />
    <h1>Past workouts</h1>
    <v-expansion-panels v-model="panel" multiple v-if="workouts">
      <v-expansion-panel v-for="id in workoutIds" :key="id">
        <v-expansion-panel-title
          >{{
            formattedDate(new Date(workoutsGroupedById[id].workout_created_at))
          }}
        </v-expansion-panel-title>
        <v-expansion-panel-text>
          <grouped-exercise-view
            :exercise="set"
            v-for="(set, index) in setsByExerciseName(workoutsGroupedById[id].sets)"
            :key="index"
          />
        </v-expansion-panel-text>
      </v-expansion-panel>
    </v-expansion-panels>
  </v-container>
</template>

我们还可以使用这个最后修改过的组件,在我们的添加锻炼的概述中,以优雅的格式显示用户将要保存的内容。

数据将略有不同,因此我们将创建一个 wrapper 组件来修改数据,然后再将其作为练习发送到 GroupedExerciseView.vue 组件。我们将新文件命名为 ExerciseGrouping.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.17-ExerciseGrouping.vue

在这里,我们也在使用这个组件,但我们确保以不同的格式修改数据以适应组件。为了使其可见,我们将在 TrackExercise.vue 文件中导入 ExerciseGrouping.vue 组件并显示其值:

<script setup lang="ts">// ...abbreviated
import ExerciseGrouping from "./ExerciseGrouping.vue";
// ...abbreviated
</script>
<template>
  <v-container>
    // ...abbreviated
    <exercise-grouping
        :key="index"
        v-for="(row, index) in routines"
        :exercise-id="row.exercise || 'Unknown'"
        :routines="row?.routines"
        class="mb-6"
      />
    // ...abbreviated
  </v-container>
</template>

这有助于我们的用户更好地跟踪未来的锻炼。这些是相对简单的数据表示。让我们看看我们是否可以添加更复杂的数据可视化,如图表。

图表

当处理大量数据时,在某个时候进行可视化是非常常见的。我们将通过采用第三方库(chart.js)来实现不同的图表,并使其渲染我们的跟踪数据!在这个时候,跟踪一定时间范围内的多个锻炼将有助于创建更直观的数据可视化体验。

面对具体和复杂的挑战,通常比自行构建解决方案更有效的是求助于第三方库。在这种情况下,我们将查看一个将chart.js连接到 Vue.js 的库,并将其应用于我们的应用程序。

根据vue-chartjs

我们使用一个库来显示图表。在这种情况下,vue-chartjs包帮助我们集成框架无关的chart.js与我们的 Vue.js 应用程序。使用第三方包装器更好地将底层库嵌入到框架中是非常常见的。

这通常有助于从我们的框架中抽象出已知的概念和行为,并将其翻译到库中,而库对此生态系统一无所知。这样,我们就不必处理集成层,可以专注于添加对我们最终用户有意义的特性。

让我们构建一些图表!我们将安装vue-chartjs和核心的chart.js库:

npm i vue-chartjs chart.js

我们将创建一个新的路由graph,其中包含一个加载空组件和菜单添加的视图。

在路由文件中,我们添加以下条目:

      {        path: 'graph',
        name: 'Graph',
        component: () => import(/* webpackChunkName: "graph" */ '@/views/Graph.vue'),
        beforeEnter: loginGuard
      },

这表明我们需要在views文件夹中创建一个Graph.vue文件,所以让我们这么做:

<script lang="ts" setup>import Graph from "@/components/Graph.vue";
</script>
<template>
  <graph/>
</template>

我们将创建一个Graph.vue组件,在路由上开始构建不同类型的图表。让我们从一个panel扩展模板开始:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.18-Graph.vue

一旦我们在AppMenu.ts文件中的menuItems集合中添加条目,我们就可以导航到这个项目的最终页面:

const menuItems = [  // ...abbreviated
  {
    icon: "mdi-chart-line",
    title: "Graph",
    page: "/graph",
  },
];

如页面所示,我们将实现三种类型的图表,展示我们跟踪锻炼的各种内容。

一块饼图(图表)

正如我们将看到的,使用库生成图表非常简单!我们必须记住,chart组件期望数据以固定格式,就像这个例子一样:

chartData: {  labels: [ 'January', 'February', 'March' ],
  datasets: [ { data: [40, 20, 12] } ]
},

这不可避免地意味着我们需要进行一些数据重构,所以让我们先做这件事。实际上,我们需要从数据中获得更多详细的信息,因此我们将在我们的健身存储中添加以下功能:

    const workoutsWithSets: Ref<WorkoutFromDatasource | []> = ref([]);    const getWorkoutsWithSets = async (options: GetWorkoutsOptions = { order: 'ascending' }): Promise<void> => {
        try {
            // ...abbreviated
            const { data, error, status }: any = await useSupabaseClient
                .from('workouts')
                .select(
                  id, created_at,
                  sets (
                    workout_id, weight, repetitions,
                    exercises ( name, color )
                  )
                )
    // ...abbreviated
return { exercises, getExercises, saveWorkout, workouts, getWorkouts, dashboard, getDashboard, workoutsWithSets, getWorkoutsWithSets }

关键区别在于select查询,我们在这里还请求了带有其属性的集合。当然,我们还需要从存储库返回这些新方法。然而,数据库中的数据尚未准备好用于图表,因为它期望不同的格式。在现实生活中的场景中,这种情况经常发生,所以让我们构建一个解决方案。

我们创建了一个名为graph.ts的新存储库,并从返回饼图数据的函数开始:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.19-graph.ts.

在这里,我们正在获取锻炼数据,并以与饼图兼容的对象形式返回它。

现在,我们将在components文件夹中创建一个名为GraphPie.vue的组件,其中我们将使用存储库,并针对图表类型进行一些配置,以根据锻炼数据渲染饼图:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.20-GraphPie.vue.

在我们的PieChart组件完成之后,我们将将其导入到Graph.vue组件中,然后我们可以替换可折叠中的行:

<script setup lang="ts">import { ref } from "vue";
import type { Ref } from "vue";
import GraphPie from '@/components/GraphPie.vue'
const panel: Ref<Number[]> = ref([0]);
</script>
<template>
  <v-container>
    <h1>Graphs</h1>
    <v-expansion-panels v-model="panel" accordion>
      <v-expansion-panel>
        <v-expansion-panel-title
          >Workout distribution all time (sets & reps)</v-expansion-panel-title
        >
        <v-expansion-panel-text>
          <graph-pie />
        </v-expansion-panel-text>
      </v-expansion-panel>
      // ...abbreviated
    </v-expansion-panels>
  </v-container>
</template>

根据数据的可用性和内容,你最终将得到一个类似于以下用户界面的界面:

图 6.9 – 以饼图形式表示的示例锻炼数据

图 6.9 – 以饼图形式表示的示例锻炼数据

使用vue-chartjs,我们相对容易地可视化了数据集。我们已经努力确保我们的数据格式与库期望的方式一致。考虑到这一点,我们可以继续扩展到不同类型的可视化。

更多图表!

看看我们的可折叠示例,我们将再构建两个图表,只是为了感受多种实现方式。我们将更新我们的图形存储库以添加更多功能。我们将创建一个可以在我们的图表中重复使用的内部方法,然后在存储库上创建一个公共方法来检索数据:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.21-graph.ts.

我们创建了一个辅助函数(createGraphData,第 11-46 行)来收集和格式化基于月平均的数据。对于我们的两种不同类型的图表,我们将创建一个名为getGraphMonthlyAverage(第 48-58 行)的函数。由于库期望数据以预定义的格式,我们可以以不同的方式呈现相同的数据。

为了展示这一点,我们将从相同的数据创建一个条形图以及一个折线图。首先,条形图;代码与饼图非常相似,我们的抽象已经到位。我们将把这个组件称为GraphBar.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.22-GraphBar.vue

这就是条形图在浏览器中的渲染方式:

图 6.10 – 以条形图表示的示例锻炼数据

图 6.10 – 以条形图表示的示例锻炼数据

与我们之前的图形组件没有太大区别!我们将为折线图做同样的事情,称为GraphLine.vue,正如你可能猜到的:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/06.fitness/.notes/6.23-GraphLine.vue

这就是折线图在浏览器中的渲染方式:

图 6.11 – 以折线图表示的示例锻炼数据

图 6.11 – 以折线图表示的示例锻炼数据

我们甚至可以在一个能够以不同类型图形渲染数据的 Vue 组件中抽象重复的代码。这将是一个很好的额外作业练习。尝试用getGraphPie数据替换getGraphMonthlyAverage数据,你会发现图表只是接受这些新值,因为它们遵循正确的形状。

为了完成这一章,我们将在Graph.vue文件中添加组件:

<script setup lang="ts">import { ref } from "vue";
import type { Ref } from "vue";
import GraphPie from '@/components/GraphPie.vue'
import GraphLine from '@/components/GraphLine.vue'
import GraphBar from '@/components/GraphBar.vue'
const panel: Ref<Number[]> = ref([0]);
</script>
<template>
  <v-container>
    <h1>Graphs</h1>
    <v-expansion-panels v-model="panel" accordion>
      <!-- abbreviated -->
      <v-expansion-panel>
        <v-expansion-panel-title
          >Monthly cumulative weight per exercise</v-expansion-panel-title
        >
        <v-expansion-panel-text>
          <graph-bar />
        </v-expansion-panel-text>
      </v-expansion-panel>
      <v-expansion-panel>
        <v-expansion-panel-title
          >Cumulative effort per exercise over time</v-expansion-panel-title
        >
        <v-expansion-panel-text>
          <graph-line />
        </v-expansion-panel-text>
      </v-expansion-panel>
    </v-expansion-panels>
  </v-container>
</template>

通过这样,我们构建了一个应用程序,用户可以登录并跟踪和检索他们的个人结果。

摘要

我们已经看到如何使用存储、组合组件和嵌套组件的概念来构建一个相对复杂的使用流程。为了安全起见,我们依赖于 Supabase 的授权模型,这有助于我们以高效的方式实现我们的目标。

查看 Supabase 结构和数据有助于理解某些端点如何存储和提供他们的数据。到目前为止,我们只是消费数据。在底层,每个 Supabase 实例都是一个专用的 PostgreSQL 数据库。如果你想了解更多关于 PostgreSQL 的信息,我强烈推荐查看 Dr. Quan Ha Le 和 Marcelo Diaz 合著的《使用 PostgreSQL 开发现代数据库应用程序》一书,可在www.packtpub.com/product/developing-modern-database-applications-with-postgresql/9781838648145找到。

面对更复杂的任务,采取逐步构建功能的方法是有意义的,这也是我展示过的做法。这有时意味着在了解更多关于功能的同时,需要重新访问某些文件进行小的添加。即使一开始需求非常明确,将功能分解成更小的部分并在构建过程中跟踪它们在应用程序文件结构中的路径,也是非常有益的。

我们在部分地方应用了一定程度的抽象,同时也采取了一种实用主义的方法。找到这种平衡可能很困难,但我确实倾向于通过一些重复来防止过度设计:这通常会导致更易于阅读的代码,并且更容易进行小的修改。

我确实倾向于将组件分解成具有特定角色的部分。存储组件的构成就是一个很好的例子,就像我们的 Supabase 客户端文件一样。通过关注点分离SoC),我们可以限制每个单独部分的复杂性,使其在未来更具可扩展性和可管理性。

我们专门为网络构建了应用程序。在下一章中,我们将看到如何创建针对不同环境的项目。

第七章:使用 Quasar 构建多平台支出跟踪器

在本章中,我们将迭代我们在第六章中涵盖的主题和技术。我们将使用 Vue 构建一个类似的应用程序,并依赖 Supabase 来存储我们的数据。然而,在本章中,我们将专注于构建一个可以部署在除 Web 以外的多个平台上的应用程序。

我们将选择 Quasar (quasar.dev/)作为我们的首选框架,因为它允许我们选择多种不同的平台。为了简化,我们将专注于创建基于 Electron (www.electronjs.org/)的桌面应用程序。Quasar 和 Electron 都是维护良好的开源项目,拥有优秀的文档和活跃的社区。

在本章中,我们将涵盖以下主题:

  • 巩固我们之前学到的知识

  • 熟悉不同的框架

  • 理解平台无关开发的价值

  • 使用 Web 技术构建原生应用

  • 学习 Web 和原生之间的关键差异

技术要求

在本章中,我们将重用第六章中的大部分需求,因为我们将会构建一个具有相似功能的应用程序。这将帮助您了解框架如何影响应用程序的架构。

我们将严重依赖 Quasar (quasar.dev/)作为我们的基础框架。由于该框架还提供 UI 模式(quasar.dev/components),我们在这个项目中不需要 Vuetify。我们将使用 Pinia (pinia.vuejs.org/)来处理我们的应用程序状态。为了存储数据,我们将在 Supabase (supabase.com/)中创建一个新的项目,Supabase 是一个具有内置身份验证的开源数据库提供商。对于数据库,我已经准备了一个创建数据库的脚本和另一个添加示例数据的脚本。我们将在设置数据库部分中介绍这些步骤。

最终产品位于本书的 GitHub 仓库中,位置为github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/07.expenses

设置数据库

我们将首先满足我们的数据库需求。我们已经有了一个免费账户(见第六章)。我们将创建一个名为expense-tracker的新项目,设置一个强大的数据库密码,并分配一个地理位置接近的区域。

您将被重定向到一个视图,该视图为您提供项目 URLAPI 密钥详情,如图图 7.1所示:

图 7.1 – 我们支出跟踪项目的 API 设置

图 7.1 – 我们支出跟踪项目的 API 设置

由于我们的应用程序尚未准备就绪,我们需要将 URL 和 API 密钥记在一个安全的地方,或者一旦我们到达应用程序,简单重访这个页面。

对于这个项目,我准备了一个脚本,用于创建数据库所需的表和设置,称为 example-structure.sql。在 Supabase 中打开 SQL 编辑器,然后粘贴并运行脚本的内容:

图 7.2 – 运行 example-structure.sql 脚本后的成功消息

图 7.2 – 运行 example-structure.sql 脚本后的成功消息

我们的支出跟踪器将能够将支出组织到不同的类别中,因此我还创建了一个脚本,将一组示例类别插入到 example-categories.sql 文件中。您可以将内容粘贴到 SQL 编辑器中并运行此文件:

图 7.3 – 运行 example-categories.sql 脚本后的成功消息

图 7.3 – 运行 example-categories.sql 脚本后的成功消息

现在我们已经设置了数据库,我们可以开始创建一个新的项目。

使用 Quasar 构建项目

我们将遵循默认的设置和安装指南,quasar.dev/start/quick-start。在 CLI 中,我们将运行 npm init quasar 并选择配置,如图 图 7.4 所示:

图 7.4 – 使用 Quasar CLI 创建新项目

图 7.4 – 使用 Quasar CLI 创建新项目

这将安装项目和其依赖项。一旦初始化完成,我们可以导航到项目文件夹,并通过 CLI 安装 Supabase JavaScript 客户端:

npm install @supabase/supabase-js

为了完成初始化,我们将创建一个包含 Supabase API 密钥的 .env 文件:

VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

我们可以通过在命令行中运行以下命令来验证我们的安装:

npx quasar dev

示例项目将被安装,如图 图 7.5 所示:

图 7.5 – 使用 Quasar 的默认项目

图 7.5 – 使用 Quasar 的默认项目

由于我们的目标是开发桌面应用程序,我们可以轻松运行该环境的开发命令:

npx quasar dev -m electron

第一次运行此命令时,需要安装一些依赖项以运行环境。这将产生类似于以下输出的结果:

图 7.6 – 在 Electron 开发模式下运行 Quasar

图 7.6 – 在 Electron 开发模式下运行 Quasar

看看如何轻松地针对不同的环境?当然,Electron 非常接近我们的浏览器环境,因此它的行为将非常相似。我们将使用浏览器开发和调试我们的应用程序。在几乎所有情况下,我们可以依赖这个框架将我们的代码交付和编译到特定的平台。

针对 Android 或 iOS 的开发稍微复杂一些。它将使用 Capacitor 来构建一个类似原生的壳,作为操作系统和应用程序之间的代理。如果你对移动部署感兴趣,我强烈建议参考 Quasar 指南:quasar.dev/quasar-cli-vite/developing-capacitor-apps/introduction

当我们的应用程序在网页和 Electron 上运行时,我们就有了构建支出跟踪器的起点!

注意

我们使用 Quasar 作为框架是因为它具有捆绑和构建功能,但 Quasar 还提供了一组丰富的可重用 Vue 组件(quasar.dev/components)。在我们的示例代码中,你将能够通过组件名称中的 q- 前缀来识别它们。我们不会深入探讨组件的工作原理,所以我将向您推荐官方(且非常好)的文档,您可以在 quasar.dev/docsquasar.dev/components 找到。

让我们看看如何将我们的 Supabase 实例与前端应用程序连接,怎么样?

使用 Supabase 和 Quasar 进行身份验证

拥有一个应用程序而不是一个网站意味着外部超链接,例如通过 Supabase 的 OTP 登录方法,将无法直接使用。处理这些问题对于本章来说有点过于高级,所以我们选择通过电子邮件和密码进行登录。为了使 Supabase 和我们的 Quasar 应用程序很好地集成,我将我们的实现大致基于以下在线资源:dev.to/tvogel/getting-started-with-supabase-and-quasar-v2-kdo

src/boot 文件夹用于在初始化 Vue.js 应用程序之前需要执行的脚本(quasar.dev/quasar-cli-vite/boot-files/)。在我们的情况下,我们需要利用启动文件,因为我们希望在更改路由之前执行逻辑,以查看用户是否有权限。这意味着我们需要在执行应用程序主脚本之前,在脚本中处理我们的身份验证和 Supabase 客户端。

首先,我们将创建一个 src/boot/supabase.ts 文件,其中包含以下文件的內容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.1-supabase.ts

我们还将使用 router-auth.ts 文件,并将其放置在同一个文件夹中:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.2-router-auth.ts

我们可以通过在boot属性中引用文件来将这些脚本添加到我们的quasar.config.js文件中,这样它看起来就会像这样:

// ...abbreviatedboot: ['supabase', 'router-auth'],
// …abbreviated

上述配置告诉我们的应用程序在初始化(或启动)应用程序之前运行给定的脚本。

在我们的基本启动脚本就绪后,我们可以查看我们的路由。

路由和应用程序结构

现在,我们将添加一些路由,以便我们可以构建我们的应用程序并将router-auth脚本应用于正确的路由。让我们从src/pages文件夹中删除所有文件,除了ErrorNotFound.vue页面。我们将添加以下具有相同结构的页面:

  • AccountPage.vue

  • Expenses Page.vue

  • CategoriesPage.vue

使用以下模板,将每个页面的<h1 class="text-h1">Home</h1>内容替换为相关标题:

<script setup lang="ts"></script><template>
  <q-page class="column items-center justify-center">
    <h1 class="text-h1">Home</h1>
  </q-page>
</template>

我们将在管理类别显示支出和概览部分构建每个功能的实现。但在开始之前,我们需要将身份验证集成到我们的路由中。

首先,让我们看看src/router/index.ts文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.3-router-index.ts

我们将进行两项更改:我们将导入 Supabase 启动脚本中的init函数(第 11 行)并在路由函数中执行该函数(第 23 行)。我们现在可以定义通向我们已创建的页面的路由。

让我们看看routes.ts文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.4-routes.ts

文件包含熟悉的代码,但略有不同——对于需要用户登录的每个路由,我们都添加了一个meta字段:

    meta: {      requiresAuth: true,
    },

此外,/auth路由(第 29-31 行)是不同的:它不依赖于布局,而是直接导入组件。这是因为该路由将是未认证用户的程序入口点。

现在,如果我们查看我们的src/boot/router-auth.ts文件,我们将看到在每次路由更改之前,我们都会检查该元字段是否存在,然后验证用户session是否存在。如果不存在,我们将用户重定向到fullPath属性,这相当于主页。

是时候通过构建应用程序的注册和登录功能来将这些功能付诸实践了。

注册和登录

让我们努力为用户提供一种注册和登录我们应用的方法。在我们的组件文件夹中,我们将创建一个名为FormLogin.vue的登录表单:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.5-FormLogin.vue.

我们还将创建一个(非常相似)的用于注册的文件,名为FormSignUp.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.6-FormSignUp.vue.

两种形式之间的关键区别是我们所使用的提交方法,即supabase.auth.signInWithPasswordsupabase.auth.signUp方法。我们在这里并不感兴趣于抽象化任何东西。试图过度优化可能重复的每一件事并不总是最佳方案。在这种情况下,我们更倾向于可读性和简洁性,而不是两个文件之间相似的代码。

让我们在一个新创建的AuthPage.vue页面上整合这两个表单:

<script setup lang="ts">import { ref } from 'vue';
import type { Ref } from 'vue';
import FormLogin from 'src/components/FormLogin.vue';
import FormSignUp from 'src/components/FormSignUp.vue';
const tab: Ref<'login' | 'sign-up'> = ref('login');
</script>
<template>
  <div class="column items-center justify-center">
    <h1 class="text-h2">Home</h1>
    <q-card class="column">
      <q-tabs
        v-model="tab"
        active-color="primary"
        indicator-color="primary"
        align="justify"
        narrow-indicator
      >
        <q-tab name="login" label="Log in" />
        <q-tab name="sign-up" label="Sign up" />
      </q-tabs>
      <q-separator />
      <q-tab-panels v-model="tab" animated>
        <q-tab-panel name="login">
          <FormLogin />
        </q-tab-panel>
        <q-tab-panel name="sign-up">
          <FormSignUp />
        </q-tab-panel>
      </q-tab-panels>
    </q-card>
  </div>
</template>

在前面的代码中,我们使用了 Quasar 的tab组件在页面上提供两个表单。现在我们应该能够注册新账户。然而,有一个小问题。

Supabase 的默认设置要求我们在注册时确认电子邮件地址。为了简化起见,我们需要禁用此功能。登录您的 Supabase 仪表板,导航到身份验证 | 提供者,然后展开电子邮件面板。在那里,我们需要禁用确认电子邮件选项,如图图 7.7所示:

图 7.7 – 在 Supabase 中禁用确认电子邮件选项

图 7.7 – 在 Supabase 中禁用确认电子邮件选项

保存此设置后,我们可以注册为新用户。您可以保持 Supabase 仪表板打开,并导航到个人资料以验证您新创建的账户!

图 7.8 – 网页视图中的初始登录状态

图 7.8 – 网页视图中的初始登录状态

在网络应用中,我们将被重定向到(大部分为空的)账户页面,如图图 7.8所示。现在我们正在取得进展。

首先,我们将稍微修改现有的src/components/EssentialLink.vue组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.7-EssentialLink.vue.

我们正在用路由链接集成替换链接。<q-item /> 组件提供了支持,因此我们可以直接将其用作路由链接实体!让我们打开 layouts/MainLayout.vue,以便我们可以更改适用于我们用例的默认布局。你可以按任何你喜欢的样子修改标题,但让我们专注于 essentialLinks 常量,以反映我们创建并希望在 菜单 中显示的页面。我们将用以下内容替换它:

const essentialLinks: EssentialLinkProps[] = [  {
    title: 'Account',
    caption: 'Manage my account settings',
    icon: 'face',
    link: '/',
  },
  {
    title: 'Expenses',
    caption: 'Track my expenses',
    icon: 'toll',
    link: '/expenses',
  },
  {
    title: 'Categories',
    caption: 'Manage my expense categories',
    icon: 'settings',
    link: '/categories',
  },
];

当然,你可以在保存更改后立即在应用中看到这些变化!

我们还没有允许用户在应用中注销会话。在下一节中,我们将确保用户也可以注销。

注销

让我们添加一个注销按钮。我们将在 components 文件夹中创建一个名为 ButtonSignOut.vue 的独立组件:

<script setup lang="ts">import { supabase } from 'src/boot/supabase';
import { useRouter } from 'vue-router';
const router = useRouter();
const signOut = async (): Promise<void> => {
  try {
    const { error } = await supabase.auth.signOut();
    if (error) {
      throw new Error('Logout failed');
    }
  } catch (error) {
    console.error(error.message);
  } finally {
    router.go(0);
  }
};
</script>
<template>
  <q-btn @click="signOut()">Sign out</q-btn>
</template>

我们再次调用 Supabase 的注销方法,并指示路由器转到其历史表中的第一个条目。

现在,我们可以切换回 MainLayout.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.8-MainLayout.vue

如果我们查看 <q-drawer> 组件的内容 (第 20-39 行),我们将看到添加了一个注销按钮 (第 29-32 行,第 48 行)。你可以自由地将注销按钮添加到,比如说,账户页面。

注意

由于我们正在创建新文件以及修改现有的启动文件,我们的应用中存在混合的编码风格。有时代码块在模板之前,有时则相反。对于我们的当前实现,这并不成问题,尽管在协作或处理大型项目时,强烈建议使用一致的编码风格并严格遵守。

到目前为止,我们已经为以用户为中心的应用创建了基本功能:注册、登录和注销。我们还使用路由将授权应用于我们应用的各个部分。现在,我们将专注于向应用添加特定功能,例如费用跟踪。

费用跟踪功能

跟踪我们未来费用的一个重要部分是能够将它们组织到不同的类别中。我们将首先在我们的应用中添加一个 Pinia 存储来处理类别数据。这与我们在 第六章 中完成的练习非常相似。让我们创建一个 src/store/categories.ts 文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.9-categories.ts

如你所见,我们正在使用 boot 脚本中的 sessionsupabase 脚本来与已登录用户会话和数据库连接交互。

为了显示类别,我们将在组件文件夹中创建一个名为 CategoryList.vue 的组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.10-CategoryList.vue.

除了显示类别外,removeOwnCategory 函数(第 29-39 行)允许用户删除他们自己添加的类别。所有操作都是通过我们创建的存储进行分发的。

在下一节中,我们将创建一些功能,让我们的用户能够为自己管理类别,从而使应用更加个性化。

管理类别

拥有删除类别的功能意味着我们必须构建一个组件来添加自定义类别。因此,我们将创建一个新的组件,名为 CategoryAdd.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.11-CategoryAdd.vue.

这暴露了一个与切换对话框中小表单的切换相关联的粘性浮动操作按钮FAB),该表单接受名称和颜色输入,并使用用户的配置文件 ID 在数据库中更新条目。这是另一个组件的例子,它具有明确的目的,并且包含在一个文件中。这就是我们构建复杂应用的方式!

具备添加个人类别的功能意味着我们可以将个人类别与默认类别一起显示。

为了完成类别功能,我们将导入我们刚刚创建的两个组件,并在 CategoriesPage.vue 上列出它们:

<script setup lang="ts">import CategoryList from 'src/components/CategoryList.vue';
import CategoryAdd from 'src/components/CategoryAdd.vue';
</script>
<template>
  <q-page class="column items-center justify-center">
    <h1 class="text-h1">Categories</h1>
    <category-list />
    <category-add />
  </q-page>
</template>

在我们的类别就绪后,我们可以通过公开添加费用的功能来最终确定应用!

添加费用

为了添加费用,我们将首先创建一个 Pinia 存储。该存储将包含我们用户的费用概览,并有一些用于检索和添加费用的方法。让我们创建一个 src/store/expenses.ts 文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.12-expenses.ts.

我们应用最重要的功能是其跟踪费用的能力!有了我们的存储,我们可以创建一个专门用于此的组件,名为 src/components/ExpenseAdd.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.13-ExpenseAdd.vue.

如你所见,这个组件与添加类别有很多相似之处,尽管这个表单依赖于 Vuetify Select 组件中存在类别。两者都是 FAB 组件,用于切换对话框以方便此功能。

我们可以将新的ExpenseAdd组件添加到ExpensesPage.vue中,如下所示:

<script setup lang="ts">import ExpenseAdd from 'src/components/ExpenseAdd.vue';
</script>
<template>
  <q-page class="column items-center justify-center">
    <h1 class="text-h1">Expenses</h1>
<expense-add />
  </q-page>
</template>

现在,你应该能够添加支出。你可以通过查看 Supabase 仪表板中的表格来验证这一点。对于我们的用户,我们将在应用中开始创建视图,所以那将是我们的下一步!

展示支出和概览

src/components文件夹中,我们将创建一个CategoryOverview.vue组件,该组件将把数据库中的类别和支出聚合到一个组合视图中:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/07.expenses/.notes/7.14-CategoryOverview.vue

与类别概览类似,我们还有一个类别的概览,但这个概览的布局略有不同。这将作为我们的新起点。让我们通过添加CategoryOverview组件来最终确定ExpensesPage.vue

<script setup lang="ts">import ExpenseAdd from 'src/components/ExpenseAdd.vue';
import CategoryOverview from 'src/components/CategoryOverview.vue';
</script>
<template>
  <q-page class="column items-center justify-center">
    <h1 class="text-h1">Expenses</h1>
    <expense-add />
    <category-overview />
  </q-page>
</template>

如果你运行你的应用并导航到支出页面,你应该能看到以下类似的内容:

图 7.9 – 支出页面的第一个概览

图 7.9 – 支出页面的第一个概览

为了给这个页面增加价值,我们需要开始在这里展示我们的支出。我们可以在组件文件夹中创建一个特定的组件来完成这个任务,我们可以将其命名为ExpensesCategoryTotal.vue

<script setup lang="ts">import { computed } from 'vue';
import { useExpensesStore } from 'src/stores/expenses';
import { storeToRefs } from 'pinia';
import { Expense } from 'src/types/expenses';
const expensesStore = useExpensesStore();
const { expenses } = storeToRefs(expensesStore);
interface Props {
  categoryId: string;
}
const props = defineProps<Props>();
const totalPerCategory = computed(() => {
  return expenses.value.reduce((total: number, expense: Expense): number => {
    if (expense.category_id === props.categoryId) {
      return total + expense.amount;
    }
    return total;
  }, 0);
});
</script>
<template>
  <q-card-section  align="right"> Total: {{ totalPerCategory }} </q-card-section>
</template>

这是一个我们可以提供类别 ID 的组件,它将抓取并汇总该类别的所有amount值作为该类别的总金额。一旦我们完成这个任务,我们就可以轻松地将ExpensesCategoryTotal组件添加到我们的类别概览中:

<script setup lang="ts">// …abbreviated
import ExpensesCategoryTotal from './ExpensesCategoryTotal.vue';
// …abbreviated
</script>
  <div class="masonry">
    <!-- abbreviated -->
        <q-card
          clickable
          class="q-ma-sm"
          :style="{ backgroundColor: category.color }"
        >
          <q-card-section>
            <div class="text-h6">{{ category.name }}</div>
          </q-card-section>
          <expenses-category-total :category-id="category.id" />
        </q-card>
    <!-- abbreviated -->
</template>

根据你插入的支出,你现在将看到我们创建的拼贴概览中每个类别的总支出概览。我们几乎完成了!

如果用户能够对他们的支出有更多的了解,那就太好了?所以,让我们在某个特定类别中添加一个更详细的支出概览。幸运的是,在我们的商店中,已经暴露了按类别查询数据库的功能。这是一个额外的对 Supabase 的调用,所以我们想要对额外的请求保持一定的谨慎,这意味着我们只有在请求时才会加载数据。

让我们从添加处理请求并将数据存储在CategoryOverview.vue文件中的脚本开始:

<script setup lang="ts">// ...abbreviated
import type { ExpenseWithCategory } from 'src/types/expenses';
// ...abbreviated
const loading: Ref<boolean> = ref(true);
const showDialog: Ref<boolean> = ref(false);
const selectedCategoryName: Ref<string | undefined> = ref(undefined);
const expensesByCategoryId: Ref<ExpenseWithCategory[] | undefined> = ref([]);
onMounted(async () => {
  // ...abbreviated
});
const getExpensesByCategoryId = async (
  categoryName: string,
  categoryId: string
) => {
  selectedCategoryName.value = categoryName;
  const expenses = await expensesStore.getExpensesByCategory(categoryId);
  if (expenses && expenses.length > 0) {
    expensesByCategoryId.value = expenses;
    showDialog.value = true;
  }
};
const getCategories = async () => {
// ...abbreviated
};
</script>

现在,在模板中,当点击一个拼贴时,我们将调用getExpensesByCategoryId。所以,让我们修改<q-card>并添加一个onClick事件:

        <q-card          class="q-ma-sm"
          :style="{ backgroundColor: category.color }"
@click="getExpensesByCategoryId(category.name, category.id)"
        >
          <q-card-section>
            <div class="text-h6">{{ category.name }}</div>
          </q-card-section>
          <expenses-category-total :category-id="category.id" />
        </q-card>

接下来,作为马赛克风格元素的兄弟元素,我们将添加对话框标记来显示所选类别的支出:

<template>  <div class="masonry">
    <!-- ...abbreviated –->
  </div>
  <q-dialog v-model=»showDialog»>
    <q-card v-if="expensesByCategoryId && expensesByCategoryId.length > 0">
      <q-card-section>
        <div class="text-h6">
          {{ `${selectedCategoryName} expenses overview` }}
        </div>
      </q-card-section>
      <q-separator />
      <q-card-section style="max-height: 50vh" class="scroll">
        {{ expensesByCategoryId }}
      </q-card-section>
    </q-card>
  </q-dialog>
</template>

我们需要将给定的费用集合输入到一个新的组件中。这将是我们完成应用程序前的最后一个组件!

components文件夹中,我们将创建一个ExpensesList.vue组件,并让它接收expenses作为属性:

<script setup lang="ts">interface Props {
  expenses: {
    id: string;
    description: string;
    amount: number;
    created_at: Date;
  }[];
}
const props = defineProps<Props>();
const formatDate = (date: Date) => {
  const dateObj = new Date(date);
  return dateObj.toLocaleDateString();
};
</script>
<template>
  <q-list dense class="expenses" v-if="expenses">
    <q-item v-for="expense in expenses" :key="expense.id">
      <q-item-section>
        <q-card-section class="flex row justify-between">
          <div>{{ expense.description }}</div>
          <div>{{ expense.amount }}</div>
          <div>{{ formatDate(expense.created_at) }}</div>
        </q-card-section>
      </q-item-section>
    </q-item>
  </q-list>
</template>
<style scoped>
.expenses {
  min-width: 400px;
  max-width: 80vw;
}
</style>

现在,我们可以通过导入ExpensesList组件并使用该列表组件而不是渲染原始数据,将费用的详细信息与CategoryOverview.vue一起包装起来:

<script setup lang="ts">// ...abbreviated
import ExpensesCategoryTotal from './ExpensesCategoryTotal.vue';
import ExpensesList from './ExpensesList.vue';
// ...abbreviated
</script>
<template>
  <div class="masonry">
    <!-- ...abbreviated -->
  </div>
  <q-dialog v-model="showDialog">
    <q-card v-if="expensesByCategoryId && expensesByCategoryId.length > 0">
      <q-card-section>
        <div class="text-h6">
          {{ `${selectedCategoryName} expenses overview` }}
        </div>
      </q-card-section>
      <q-separator />
      <q-card-section style="max-height: 50vh" class="scroll">
        <expenses-list :expenses="expensesByCategoryId" />
      </q-card-section>
    </q-card>
  </q-dialog>
</template>
<style lang="scss" scoped>
// ...abbreviated
</style>

到目前为止,我们有一个应用程序,用户可以管理类别并添加费用,这些费用在概览中显示。

以此为基础,如果你喜欢,尝试自己扩展应用程序的不同功能将是一个很好的练习。你将如何添加日期过滤器到费用中?你将在哪个级别引入它们,它们将影响什么?或者,关于删除费用呢?

在下一节中,我们将把我们的 Web 应用程序转换为桌面应用程序。代码中的每次改进都可以进行转换,所以请随意继续并稍后添加一些新功能!

使用 Quasar CLI 构建应用程序

Quasar CLI 提供了一些命令,可以快速构建和发布 Electron 应用程序。重要的是要意识到,在这个阶段,默认构建脚本的输出默认只支持你的当前操作系统和架构!这是一个测试应用程序的好方法,让我们看看会发生什么。我们可以在终端中运行以下命令来生成我们的应用程序代码:

quasar build -m electron

这将比启动开发服务器需要更长的时间来处理:首先,Quasar 为 Web 构建文件,然后使用这些生产就绪的代码与 Electron 编译一个原生应用程序。一旦处理完成,你可以在项目的/dist/electron文件夹中找到构建输出文件。现在你也应该能够执行你的应用程序了!

接下来,我们将通过创建和提供我们自定义的应用程序图标来改进应用程序的视觉识别。

自定义图标

在任何应用程序中,图标的重要性不亚于其名称。对于我们的 Web 应用程序,我们已经省略了这一部分,因为有很多资源可以帮助添加网站上的收藏图标。然而,对于我们的桌面应用程序,我们将重新创建添加图标的步骤。

我从src/assets文件夹下载了一个合适的图标。Quasar 提供了一个名为npx的小工具。我们可以使用以下命令生成图标集:

npx icongenie generate -m electron -i ./src/assets/icon.png

如果npx命令不起作用,你可以尝试使用以下终端命令全局安装 node 包:

npm i -g @quasar/icongenie

一旦全局安装完成,你可以在项目的文件夹中运行包,如下所示:

icongenie generate -m electron -i ./src/assets/icon.png

一旦脚本完成,你将在src-electron/icons文件夹中找到输出。这就是全部内容!

我们默认的输出仅适用于我们正在开发的平台。我们感兴趣的是为不同的平台构建。我们将在下一节中了解那些选项。

为不同目标打包

默认情况下,Quasar 在幕后使用 Electron Packager (electron.github.io/electron-packager/main/) 为你创建包。你也可以将其更改为 Electron Builder (www.electron.build/index.html),但在这个例子中,我们将使用默认设置。

如果你打开 quasar.config.js 文件,你可以滚动到 electron 属性:

// ...abbreviated  electron: {
      // extendElectronMainConf (esbuildConf)
      // extendElectronPreloadConf (esbuildConf)
      inspectPort: 5858,
      bundler: 'packager', // 'packager' or 'builder'
      packager: {
        // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
        // OS X / Mac App Store
        // appBundleId: '',
        // appCategoryType: '',
        // osxSign: '',
        // protocol: 'myapp://path',
        // Windows only
        // win32metadata: { ... }
      builder: {
        // https://www.electron.build/configuration/configuration
        appId: 'packt-expense-tracker',
      },
    },
// ...abbreviated

这是默认设置。由于我们处理的是 Electron 打包器,我们的配置放在 packager 属性中。为了构建额外的平台,我们可以添加我们想要构建的目标平台作为属性(如指南中所述,见electron.github.io/electron-packager/main/modules/electronpackager.html#officialplatform),并且对于每个属性,我们可以添加额外的配置。我们将使用配置属性和默认预设来针对每个平台的目标架构。以下配置将尝试为 macOS (Darwin)、Linux 和 Windows (Win32) 构建应用程序,并作为示例指定特定的架构:

electron: {  inspectPort: 5858,
  bundler: 'packager', // 'packager' or 'builder'
  packager: {
    platform: ['darwin', 'linux', 'win32'],
    darwin: {
      arch: ['x64'],
    },
    linux: {
      arch: ['x64', 'arm64'],
    },
    win32: {
       arch: ['x64'],
    },
  },
  // ...abbreviated
},

现在,有一些注意事项。如果我在 Mac 或 Linux 机器上运行前面的代码,我需要安装一个 Windows 模拟器来为该平台构建。好消息是,如果你运行 HomeBrew (brew.sh/),你可以使用以下命令轻松安装 Wine

brew install --cask wine-stable

Quasar CLI 为你提供这些指令。对于非 macOS 用户,你构建的应用程序将是未签名的。这意味着用户需要手动接受门卫安全警告。它也将无法发布到应用商店。

在原生应用程序开发方面,Quasar 和 Electron 提供的灵活性仍然是一种非常可行的交付需要在多个平台上运行的应用程序的方式。此外,请注意,这些应用程序可能不如专门为特定平台开发的应用程序性能出色。开发往往涉及权衡。然而,能够开发和将网络应用程序交付到原生平台是非常有用的。

记住,我们应用的基础是一个带有 -m spa-m electron 标志。它将打包并构建我们的应用程序,使其准备好在网络上运行。

摘要

在这一章中,我们解锁了一个强大的功能:我们从一个在 第六章 中学到的内容开始,构建了一个网络应用程序。使用 Quasar 的可用功能,我们处理了我们的代码,并将我们的网络应用程序作为适合多个平台的独立桌面应用程序部署。

我们还采用了另一个框架来构建应用。我们不是使用 Vuetify,而是依赖 Quasar 提供的默认组件。这样,我们在构建应用时看到了并体验到了代码风格上的细微差异,使用框架和构建工具。我们也体验到了相似之处,例如,在 Pinia 作为集中式存储的使用上。

这种应用构建方式并不总是最合适的。存在一些限制和权衡。优点在于,你只需构建一个应用,就可以部署到多个目标。这种开发方法的成本效益使其成为多平台策略的严肃候选人。

在下一章中,我们将做一些有趣的事情。我们将连接多个设备到单个服务器,并构建一个实时问答游戏!

第三部分:高级应用

这一部分的书籍涵盖了复杂的使用案例,你将学习如何将复杂性分解为单个部分并相应地分配责任。此外,你还将了解使用 Vue.Js 作为前端框架与采用如 Nuxt 这样的元框架来构建服务器端应用之间的区别及其用例。

此外,你还将通过原型设计、构建和迭代实验性框架和解决方案来体验调查的过程。

这一部分包含以下章节:

  • 第八章构建交互式问答应用

  • 第九章使用 TensorFlow 进行实验性物体识别

第八章:构建一个交互式问答应用

我们将通过创建一个具有管理面板和通过 WebSockets 在多个客户端之间实现实时连接的问答应用来提高本章的复杂性。WebSockets 与我们的常规端点不同,它保持连接打开,允许从中央 socket 服务器向一个或多个客户端发送连续更新。使用这些功能,我们将构建一个小型 Kahoot 克隆。

对于管理面板,我们将使用 Nuxt (nuxt.com/)。Nuxt 是一个作为 Vue 哲学扩展而构建的框架,但增加了服务器功能。除了第七章中的 Electron 应用第七章之外,我们所有的代码都可以在客户端的浏览器中运行。Nuxt 允许 Vue 代码在服务器上执行。除此之外,它还具有许多额外的功能,这些功能极大地改善了开发者体验DX)。随着本章的进展,我们将遇到这些功能。

在本章中,我们将涵盖以下主题:

  • 熟悉 Nuxt 和服务器端渲染

  • 理解 REST API 和 WebSockets 之间的区别

  • 复杂应用架构中的客户端体验和服务器角色

  • 应用逻辑的结构化

  • 在开发环境中使用 Node.js 脚本

我们将构建三个不同的应用,这些应用需要相互通信以形成一个交互式问答。

技术要求

在我们的设置中,核心是服务器问答应用SQA),它围绕 Nuxt (nuxt.com/)、Pinia (pinia.vuejs.org/)进行状态管理、Supabase (supabase.com/)管理问答数据以及 Vuetify (vuetifyjs.com/)渲染管理界面而构建。

我们将构建一个独立的socket.io服务器 (socket.io/),以在客户端之间保持实时连接。

最后,我们的客户端问答应用CQA)将使用 Vuetify 来渲染问答元素 (vuetifyjs.com/)。

您可以在此处找到本章的完整代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/08.quiz.

问答应用设置中的实体

为了让您了解我们的元素将如何协同工作,让我们快速看一下以下图示:

图 8.1 – 问答应用设置中的实体概览

图 8.1 – 问答应用设置中的实体概览

与我们之前的项目相比,一个重大变化是,我们的客户端(CQA)不再直接与 Supabase 数据库通信。相反,它通过 SQS 连接,从中检索问题和分数,并将其答案发送回 SQS。SQS 然后,与 SQA 通信以检索相关的测验信息,并在其客户端(CQAs)之间集中当前活动的测验会话。

SQA 用于管理测验内容和与数据库交互。

事先说一句

由于设置相当复杂,本章将不会关注,并且将省略安全措施。了解项目的局限性,不要将其视为生产就绪代码是很好的。在可能的情况下,我们将简要提及影响或可能的解决方案。

让我们通过设置数据库来深入了解。

设置数据库

与前几章一样,我们将从设置我们的数据库开始。我们将创建另一个名为 quiz 的项目,设置一个强大的数据库密码,并选择一个地理位置相近的区域。

记得记录项目 URLAPI 密钥值!

注意

本章的目的是不专注于数据库管理,以下设置不应被视为生产应用程序的最佳实践!

按照以下步骤设置数据库:

  1. 前往 quiz

  2. 取消选择启用行级安全(RLS),并在阅读其警告后确认对话框。

  3. 部分,将id字段的类型更改为uuid

  4. 这个表格就足够了,所以点击保存

我们将仅使用此表作为问题分组机制,因此我们将尽可能保持其简单。

  • 现在,返回到 questions。再次取消选择 quiz 表,并确认id列是自动输入的。点击保存以关闭外键属性。创建一个名为question的列,并将其类型设置为text。创建四个名为answer_1answer_4的列,并将它们的类型设置为text。创建一个名为correct的列,并将其类型设置为int2。点击保存以创建表。

我们不会导入任何预设数据,因为我们将使用我们的 SQA 来处理插入操作!让我们从这个项目中构建我们的第一个应用程序开始。

The SQA

为了组织我们所有的应用程序,我们将在章节根文件夹中为每个项目创建子文件夹。由于此应用程序将在 Nuxt 上运行,我们可以使用 Nuxi CLI 来为我们安装项目。从我们项目的根目录,我们将在命令行中运行以下命令:

npx nuxi@latest init server

我们将简单地选择 npm 作为我们的包管理器。一旦安装完成,导航到 server 文件夹并运行 npm run dev 以启动应用程序。默认情况下,它将在端口 3000 上运行。在浏览器中打开 URL 后,你应该会看到如下内容:

图 8.2 – 新鲜 Nuxt 安装的欢迎屏幕

图 8.2 – 新鲜 Nuxt 安装的欢迎屏幕

虽然这看起来可能不多,但请检查这个页面的源代码。Nuxt 不是将虚拟 DOM 渲染到<div id="app" />元素中,而是作为一个 Node.js 进程运行,这意味着它支持 Vue 组件的服务端渲染!这可以非常有益,因为你不必依赖于 JavaScript 在浏览器中执行,这有利于搜索引擎优化、爬虫支持、渲染性能和浏览器内存使用。更多信息,请访问 Nuxt 官方文档(vuejs.org/guide/scaling-up/ssr.html#server-side-rendering-ssr)。

在我们的基础搭建好之后,让我们使用 Nuxt 模块系统添加一些额外的功能。

模块和自动导入

我们将首先将 Vuetify 添加到我们的项目中。Nuxt 有一个稳固的社区,为每个人贡献了一些模块。在我们的案例中,我们将使用Nuxt Vuetify 模块(nuxt.com/modules/nuxt-vuetify)。在我们的server文件夹中,运行以下命令:

npm install --save-dev @invictus.codes/nuxt-vuetify

Nuxt 模块可以通过修改nuxt.config.ts文件来注册和配置,如下所示:

// https://nuxt.com/docs/api/configuration/nuxt-configexport default defineNuxtConfig({
  devtools: { enabled: true },
  modules: [
    '@invictus.codes/nuxt-vuetify'
  ],
  vuetify: {
    moduleOptions: {
      treeshaking: true,
      useIconCDN: true,
      styles: true,
      autoImport: true,
      useVuetifyLabs: true,
    }
  }
})

modules属性中,我们注册我们想要使用的模块,并且可选地(在这种情况下)在vuetify属性中配置模块应该如何行为。

就这些了!我们现在可以在我们的应用程序中使用 Vuetify 模板。Nuxt 支持一个称为自动导入的概念,这意味着对于常用脚本,我们不需要在脚本块中显式编写导入语句。Nuxt 可以在运行时确定所需的文件!当您开始编写代码时,这将使我们的文件非常干净和易于阅读。

基于文件的路由

与自动导入类似,Nuxt 默认使用我们熟悉的vue-router,并且配置为根据一定的文件结构为你创建路由。nuxt.com/docs/getting-started/routing#routing

我们将从一个默认布局开始,通过在layouts文件夹中创建一个default.vue文件来实现:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.1-default.vue.

如您所见,我们依赖于 Vuetify 组件为我们创建一个简单的布局。您已经可以识别出几个路由。我们将在下一节中创建它们。现在,我们可以通过更新 SQA 根文件夹中的app.vue文件来使用默认布局,内容如下:

<template>  <NuxtLayout>
    <NuxtWelcome />
  </NuxtLayout>
</template>

如菜单所示,我们需要几个路由:一个主页路由,一个管理测验的路由,以及一个可以分享测验的路由。

我们将从主页路由开始,在 SQA 文件夹的根目录下创建一个 pages 文件夹,并添加一个包含以下内容的 index.vue 文件:

<template>  <v-card class="mx-auto" width="600">
    <template v-slot:title>Welcome to the Admin Panel</template>
    <v-card-text>
      <p>
        Via this interface, you can create and edit quizzes. This panel is not perfect, but it works. As an extra challenge consider implementing the following features:
      </p>
      <ul class="ma-4">
        <li>
          Adding meta data to a quiz, such as a title and make it more identifiable in Admin Panel;
        </li>
        <li>Managing the order of Questions in a Quiz;</li>
        <li>Securing the Admin Panel via the Supabase OTP authentication;</li>
        <li>Adding validation on the Question dialog inputs</li>
      </ul>
      <p>Good luck!</p>
    </v-card-text>
  </v-card>
</template>

现在,如果我们切换回 app.vue 文件,并在开发服务器激活时用 <NuxtPage /> 组件替换 <NuxtWelcome /> 组件(注意,我们不需要在我们的脚本块中导入这些组件就可以使用它们?),那么我们的主页现在会在我们的应用中打开 ./pages/index.vue 的内容!

那么,这里发生了什么?NuxtPage 组件内置了一些逻辑,可以读取 pages 文件夹,并在初始化为 Nuxt 默认部分的 vue 路由实例上动态创建路由。真不错!

现在,如果我们想要路由到某个路径,我们只需在 ./pages 文件夹中创建一个与路由名称匹配的新文件夹。在我们的例子中,我们将创建一个 quiz 子文件夹,并在该文件夹中添加另一个 index.vue 文件。

注意

从技术上讲,你还可以选择在 ./pages 文件夹中创建一个名为 quiz.vue 的文件。然而,由于我们将添加多个属于 quiz 域的路由,将它们分组在专门的文件夹中是一个更好的做法。

我们将从 ./pages/quiz/index.vue 中的一个基本文件开始:

<template>  <div class="my-8">
    <h1 class="text-h3 mb-8">Choose quiz to edit</h1>
    <v-card class="mx-auto" max-width="600">
      <v-divider />
      <v-card-actions>
        <v-btn primary class="my-4 mx-4">✨ Create new Quiz</v-btn>
      </v-card-actions>
    </v-card>
  </div>
</template>

现在,当你在浏览器中导航到 http://localhost:3000/quiz(或使用导航抽屉中的 管理测验 按钮)时,你应该看到以下页面:

图 8.3 – 基于文件的路由在实际中的应用

图 8.3 – 基于文件的路由在实际中的应用

我们的静态页面对我们帮助不大,所以我们将专注于在下一节中建立与数据库的连接和数据。

重新介绍两位熟悉的朋友

正如我们在第六章和第七章中所做的那样,我们将依赖于 Supabase JS 客户端和 Pinia。让我们看看它是如何工作的。

首先,我们将使用 npm 命令安装 Supabase JS 客户端(www.npmjs.com/package/@supabase/supabase-js):

npm install @supabase/supabase-js

我们还将创建一个包含 supabase.ts 文件的 ./composables 文件夹,其内容如下:

import { createClient } from '@supabase/supabase-js'const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const useSupabaseClient = createClient(supabaseUrl as string, supabaseAnonKey as string, { auth: { persistSession: false } });

Nuxt 已经设置为扫描 ./composables 文件夹并提取导出,以便它支持对可组合组件的自动导入!有一个注意事项:它只扫描一个层级,并排除嵌套文件夹。

如您所见,我们需要在我们的 SQA 文件夹的根目录中设置一个 .env 文件,其中包含我们在创建数据库时收到的 URL 和 API 密钥。.env 文件与第六章和第七章中的设置相同:

VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

在我们的客户端就绪后,我们可以添加 Pinia – 这次,作为一个 Nuxt 模块。在终端中运行以下命令来安装包:

npm install pinia @pinia/nuxt

如果你安装过程中遇到 ERESOLVE 错误,请查看安装指南中提供的提示(pinia.vuejs.org/ssr/nuxt.html#Installation)。

我们将把模块添加到 nuxt.config.ts 属性的 modules 数组中:

  modules: [    '@invictus.codes/nuxt-vuetify',
    '@pinia/nuxt'
  ],

为了获得更好的编码体验,我们还可以通过在 Nuxt 配置中添加以下属性来定义 Pinia 函数的自动导入:

  pinia: {    autoImports: [
      'defineStore',
      ['defineStore', 'definePiniaStore'],
    ],
  },

nuxt.config.ts 文件将看起来像这样:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.2-nuxt.config.ts。随着 Supabase 和 Pinia 的部署就绪,我们可以创建我们的测验存储库了!

对于我们的存储库,我们将在 ./pages 文件夹中创建一个 ./stores 文件夹,并包含一个 quiz.ts 文件,其内容如下:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.3-quiz.ts

如果你将这个存储库与,例如,第六章中的 用户存储库 进行比较,你会注意到缺少了很多导入。这是因为它们由 Nuxt 处理!既然我们不会深入探讨,让我们快速总结一下 测验存储库 的功能。

我们的存储库公开了一个所有测验的列表(第 7 行)以及从 Supabase 获取该数据的方法(第 11-30 行)。我们的存储库还公开了单个测验的属性(第 8 行)以及从数据库获取测验数据的相关方法(第 62-82 行)。对于测验及其答案,我们公开了更新和删除数据的方法。这就是我们需要继续的基本管理。

我们将重新访问 ./pages/quiz/index.vue 文件,以在测验级别添加管理:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.4-index.vue

当你运行开发服务器时,你应该能够添加几个新的测验,并且它们将显示在这个概览中,如下面的截图所示:

图 8.4 – 我们的应用程序通过 Pinia 存储连接到数据库

图 8.4 – 我们的应用程序通过 Pinia 存储连接到数据库

通过在 ./pages 文件夹中创建一个 ./share 子文件夹,创建一个 index.vue 文件,并将以下内容粘贴到该文件中,我们可以轻松地构建一个用于 /share 路由的类似测验概览:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.5-index.vue

我们正在接近目标!如果你注意到了我们创建的两个页面中的 <nuxt-link /> 组件,你可能已经注意到了它与 <router-link /> 的相似之处。<nuxt-link /> 组件是它的包装器,同时也帮助 Nuxt 确定所有可能的路由映射。与基于文件的路由一起,这些 <nuxt-link /> 组件的出现和配置有助于确定 vue-router 实现的配置。你可能也注意到了,这两个组件分别链接到 /quiz/share 的动态路由。让我们来修复这些问题!

动态基于文件的路由

我们将从 ./share 路由开始。正如你通过链接组件的标记所看到的,我们正在针对一个带有参数的路由:

<nuxt-link :to="`/share/${quiz.id}`">  <v-btn flat>{{ quiz.id }}</v-btn>
</nuxt-link>

通常,我们会用以下示例配置我们的 Vue 路由配置:

  routes: [    {
      path: '/share/:id',
      name: share,
      component: () => import('../pages/ShareDetail.vue')
    },
  ]

使用 Nuxt,传递参数就像在文件名中标记它一样简单。在 index.vue 文件旁边,我们将创建一个名为 [quiz_id].vue 的文件。这是在路由文件中定义参数的等效结果。它告诉 Nuxt 指示路由器创建一个带有 quiz_id 参数的路由。在文件中,我们可以通过使用 useRoute 可组合函数并访问 params.quiz_id 属性来读取参数!

[quiz_id].vue 文件中,我们将添加以下内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.6-%5Bquiz_id%5D.vue.

页面本身并没有什么特别之处:它只是生成一个指向 URL 的锚点链接元素,这个 URL 我们将在本章的最后部分构建一个应用!

为了完成管理界面的构建,我们将为某个测验中的问题编辑创建一个页面。首先,作为一个要求,我们将创建一个 ./components 文件夹,并包含一个 FormQuestion.vue 文件,其内容如下:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.7-FormQuestion.vue.

在文件中,我们直接将表单映射到我们数据库中的列。设置起来相当直接,尽管我想指出重复设置答案(第 33-48 行)的地方,我们使用 v-for 指令生成四个答案字段,并使用动态对象键映射每个字段的 v-model第 36 行)。

现在,我们将通过在./pages/quiz文件夹中创建一个[quiz_id].vue文件,作为quiz路由的后代来创建一个动态路由:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.8-%5Bquiz_id%5D.vue.

在完成这些页面后,你应该能够创建一个或两个包含多项选择题的小测验。在我们继续之前,我们需要至少有一个,但最好是更多的测验在数据库中,附带几个多项选择题。

你为什么不尝试创建一个关于 Vue 生态系统的测验呢?

设置 SQS

我们接下来的任务是设置处理一个或多个客户端请求的服务器。这将是一个小型独立应用程序,并且它将从我们的 Nuxt 服务器获取数据,因为那已经与数据库实例建立了连接。在 Nuxt 中创建一个端点是我们在还没有构建的,因为我们的 Nuxt 应用程序只能展示管理应用程序!

Nuxt API 路由

如我之前提到的,Nuxt 应用程序作为 Node 进程运行。当我们请求页面时,它充当一个解析 Vue 组件和路由以返回 HTML 响应的 Web 服务器。除此之外,它还可以同时充当服务器!Nuxt 使用 Nitro 服务器引擎(nuxt.com/docs/guide/directory-structure/server)来处理./server文件夹中的脚本请求。它还支持基于文件的路由和参数,类似于./pages文件夹。

为了将测验作为 RESTful API 的一部分来提供,我们将在我们的 Nuxt 项目中创建./server/api/quiz结构。在quiz文件夹中,我们将创建一个index.ts文件。这将通过 Nuxt URL 的/api/quiz请求可用:

import type { QuizHead } from '../../types/quiz';import { useSupabaseClient } from '../../composables/supabase';
export default defineEventHandler(async (): Promise<QuizHead[] | null> => {
  console.log("📦 Requesting quizzes from endpoint")
  const { data, error, status } = await useSupabaseClient
    .from(`quiz`)
    .select(`id, created_at`);
  if (error && status !== 406) console.error(error);
  return data
})

这里没有发生什么特别的事情。我们添加了带有📦表情符号的console.log,这有助于我们分析创建后的应用程序流程。如果你的开发服务器正在运行(添加新文件时可能需要重启),你应该能够通过此 URL 请求测验信息:localhost:3000/api/quiz

如果我们正确设置了端点,我们应该在浏览器中看到类似这样的内容。这是我们的测验表内容的 JSON 格式:

图 8.5 – Nuxt 的测验 API 服务器示例

图 8.5 – Nuxt 的测验 API 服务器示例

我们将使用参数化文件表示法添加另一个入口点。我们将在index.ts文件旁边创建一个[id].ts文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.9-%5Bid%5D.ts.

我们使用这个端点来快速检索所有问题和答案以及用于我们设置的正确答案。现在我们能够检索到测验及其详细信息,我们终于可以构建设置的下一步:SQS。

设置基本的 Node 项目

在本节中,我们不会使用任何与 Vue 相关的软件。这部分主要依赖于 Express (expressjs.com/) 和 Socket.io (socket.io/)。这意味着我们不能依赖有用的 CLI 工具来为我们创建项目。幸运的是,这并不难。返回到我们项目的根目录,我们将创建一个名为./sockets的新文件夹。通过 CLI,我们将运行npm init命令来定义我们的项目,我们将简单地接受所有默认设置。一旦完成,我们将通过 CLI 安装 Express、Socket.io 和一些 TypeScript 工具包:

npm install express socket.io ts-node @types/node

我们还将在./sockets文件夹中创建一个tsconfig.json文件,其中包含以下配置:

{  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["dom"],
    "allowJs": true,
    "outDir": "build",
    "rootDir": "./",
    "strict": true,
    "noImplicitAny": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}

对于游戏机制,我提供了一个Quiz类,我们可以实现它而不必深入了解细节。创建一个名为quiz.ts的文件,其内容如下:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.10-quiz.ts

接下来,我们可以处理套接字连接。我们将在./sockets文件夹中创建一个名为index.ts的文件,其内容如下:

const express = require("express");const { createServer } = require("node:http");
const { Server } = require("socket.io");
import QuizGame from "./quiz";
const app = express();
const server = createServer(app);
// This needs to match the client url of the running app instance
const clientAppUrl = "http://localhost:5173";
let serverOptions = {};
if (process.env.NODE_ENV !== "production") {
  serverOptions = {
    ...serverOptions,
      cors: {
        origin: clientAppUrl, // cors is enabled for socketed connections on localhost:5173
      },
    }
}
const io = new Server(server, serverOptions);
const game = new QuizGame();
// ******************************
// Listen on the port for events
// ******************************
server.listen(4000, () => {
  console.log("🔌 Server is running on port 4000");
});

这段代码构建了一个空白服务器。正如你所见,我们已经导入了Quiz类,并使用它来在服务器上实例化一个游戏。clientAppUrl很重要:当使用套接字时,跨源资源共享CORSdeveloper.mozilla.org/en-US/docs/Web/HTTP/CORS)策略将阻止来自 localhost 的传入流量,除非其来源被提供给服务器。

尽管如此,我们现在又遇到了另一个问题:Quiz类期望从我们的 Nuxt 服务器获取数据,记得吗?我们需要让 Nuxt 服务器与 SQS 同时运行。我们可以手动在单独的终端窗口中这样做,但也可以编写脚本来自动化启动过程,以便更方便。毕竟,一旦我们完成对 SQS 的工作,我们需要能够运行另一个应用程序来充当客户端!

并行执行脚本

在本节中,我们将关注我们项目的根目录,从这里我们将触发子目录中的脚本。我们将使用终端导航到根目录,并使用npm init设置另一个 Node 项目,接受默认设置。一旦完成,我们将安装一些辅助包:

npm install nodemon npm-run-all ts-node @types/node open chalk

我们还将创建一个名为open.mjs的文件,其内容如下:

import chalk from 'chalk';import open from 'open';
const urls = [{
    name: '📦 Quiz Admin Panel',
    url: 'http://localhost:3000',
}]
urls.forEach(url => {
    setTimeout(async (): void => {
        console.log(`✨ Opening ${chalk.black.bgCyan(url.name)} at ${chalk.magenta(url.url)}`);
        await open(url.url);
    }, url.wait || 0);
});

在这个文件中,我们使用 open 包来自动打开浏览器窗口。为了使控制台中的状态更易于识别,我们使用 chalk 为日志的某些部分添加颜色。现在,当我们打开 package.json 时,我们可以更改 scripts 属性,使其与以下示例匹配:

  "scripts": {    "dev": "npm-run-all --parallel dev:*",
    "dev:sockets": "nodemon ./sockets/index.ts",
    "dev:server": "cd server && npm run dev",
    "dev:open": "node ./open.mjs"
  },

那么,这里发生了什么?npm-run-all 命令以并行模式触发多个命令——在我们的情况下,所有以 dev 为前缀的脚本:

  • dev:sockets 脚本使用 nodemon 在我们的套接字项目文件夹中运行并监视文件更改

  • dev:server 脚本从其文件夹中打开 Nuxt 服务器

  • dev:open 脚本执行 open.mjs 脚本,该脚本反过来会在预定义的 SQA URL 上打开浏览器窗口

如果你不想每次启动脚本时都打开 SQA,你可以删除 dev:open 脚本,或者从 open.mjs 文件中的 urls 常量中删除条目。

至少我们现在可以用一个命令控制多个脚本的执行。如果我们从项目的根目录运行 npm run dev,它将自动启动 Nuxt 服务器以及 SQS!

让我们退一步,解释为什么常规端点不足以满足需求,以及为什么我们正在构建一个完整的套接字服务器作为服务器和客户端之间的层。

为什么使用套接字?

我们使用套接字服务器作为我们的测验应用程序的主机。与 RESTful 连接相比,套接字的优势在于数据传输默认是双向的,套接字连接是有状态的和持久的。这使得套接字非常适合依赖于即时更新的应用程序,例如聊天应用程序,或者在我们的情况下,在线游戏(尤其是那些支持协作或竞争的游戏)。

我们正在启动一个客户端可以连接的套接字服务器。在连接时,服务器和客户端之间进行握手,这建立了两者之间的协议和约定。这个握手允许服务器识别客户端,这在处理多个客户端但想要针对个体时非常有用。

一旦建立了连接,客户端和服务器都可以发送带有上下文的事件。服务器可以向所有客户端或单个客户端广播。客户端只向服务器发送事件。根据事件和上下文,我们可以处理事件。

使用 Sockets.io,我们可以使用 io.emit('event', context) 广播事件,并使用 io.on('event') 监听事件。

在我们的应用程序中,我们将看到服务器和客户端上两种方法的实际应用:服务器处理发送给客户端的信息,并接收客户端给出的答案——我们甚至会用它来控制客户端应用程序的导航状态!从客户端方面来看,我们将监听发送的事件,并使用套接字向我们的测验答案发送回复。

完成 SQS

我们可以通过将./sockets文件夹中的./index.ts文件的内容替换为以下内容来最终确定 SQS:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.11-index.ts.

我们已经添加了通信的机制。套接字服务器在第 27-76 行初始化。在套接字连接事件中,我们定义了服务器需要监听的所有事件:

  • 当玩家加入时(第 17 行),服务器通过当前状态通知其他玩家(第 32 行,第 130-133 行

  • 当玩家准备好开始游戏时(第 37-41 行),玩家被添加到当前游戏中(第 38 行),并且通知其他玩家

  • 如果玩家在游戏过程中回答问题(第 43-50 行),则处理答案,并在需要时更新玩家的分数

  • 当玩家选择测验时(第 55-60 行),服务器查询 Nuxt 服务器以获取问题,并将其更新到当前游戏中(第 59 行

  • 当测验开始时(第 62-66 行),当前问题被设置为集合中的第一个,并将问题发送给玩家(第 65 行,第 82-88 行

我们有一些选项可以将玩家发送到特定的视图:

  • 有一种方法可以向玩家发送问题(第 82-88 行

  • 在每第三个问题之间,我们可以显示当前得分(第 114-118 行

  • 一旦测验结束,我们将玩家引导到最终得分(第 120-125 行

作为游戏机制的一部分,回答问题是基于时间的。计时器作为游戏的一部分内部运行,但我们会在计时器开始时(第 138-145 行)或结束时(第 147-150 行)发出事件。

通过所有这些,我们有了交互式测验的基本需求。请注意,它并不非常健壮,但足以有效地传达我们的观点。

在最后一节中,我们将创建一个应用程序来完成整个测验应用平台!

创建 CQA

为了总结所有内容,我们将在项目的根目录下创建客户端应用程序作为 Vuetify 项目。让我们通过终端导航到根目录,并输入npm create vuetify以开始安装。我们将使用以下设置:

  • app作为项目的名称

  • 对于预设,我们将选择默认的Base (Vuetify, VueRouter)

  • 我们将选择 TypeScript

  • 安装依赖项时,我们将选择npm

安装完成后,我们可以打开文件夹并在我们的 IDE 中开始编辑。

首先,我们将对vite.config.ts文件进行一些修改,这有助于我们的应用程序在多应用环境中工作。我们将添加一个名为clearScreen的新属性,其值为false。这将防止进程清除日志(其中也将包含我们的服务器日志)。我们只需在文件底部简单添加即可。

接下来,我们将定位serverport属性,并将port更改为新值——即5173(这对应于我们的 socket 服务器的 CORS 设置)。这防止了 Vuetify 应用程序尝试占用与我们的 Nuxt 应用程序相同的端口!vite.config.ts文件的底部应该类似于以下代码:

// ...abbreviatedexport default defineConfig({
  plugins: [
    // ...abbreviated
  define: { 'process.env': {} },
  resolve: {
    // ...abbreviated
  },
  server: {
    port: 5173,
  },
  clearScreen: false,
})

在我们继续对应用程序进行工作之前,我们将把我们的客户端应用程序添加到我们的主要开发脚本中。让我们从项目根目录打开package.json文件并添加一个运行 Vuetify 开发脚本的脚本:

  "scripts": {    "dev": "npm-run-all --parallel dev:*",
    "dev:client": "cd app && npm run dev",
    "dev:sockets": "nodemon ./sockets/index.ts",
    "dev:server": "cd server && npm run dev",
    "dev:open": "node ./open.mjs"
  },

关于open.mjs文件,我们将添加一个 URL,该 URL 将自动打开浏览器。我们将给它一个5000毫秒的超时时间,因为它需要等待 Nuxt 和 socket 服务器初始化完成:

const urls = [  {
    name: "📦 Quiz Admin Panel",
    url: "http://localhost:3000",
  },
  {
    name: "📱 Quiz App",
    url: "http://localhost:5173",
    wait: 5000,
  },
];

现在,我们可以通过使用npm run dev:client命令从项目的根目录启动应用程序。我们也可以通过在项目根目录使用npm run dev命令一次性启动所有脚本。当我们在应用程序的功能上工作时,这非常方便,因为应用程序本身非常依赖于其他正在运行的实体。

设置应用程序

我们将首先设置应用程序的路由和视图。我们将回到应用程序文件夹,对./src/router/index.ts文件进行一些修改:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.12-index.ts

现在,对于所有这些路由,我们将在./src/views文件夹中添加一个单独的视图 Vue 组件。我们将使用以下模板作为基础(将模板中的标题替换为相应视图的标题):

<template>  <div class="my-8">
    <h1 class="text-h3 mb-8">Home</h1>
  </div>
</template>

最后,你将得到以下视图:

图 8.6 – 与我们的路由匹配的新创建的视图

图 8.6 – 与我们的路由匹配的新创建的视图

我们还将通过用以下内容替换./src/layouts/default.vue中的内容来简化布局:

<template>  <v-layout>
    <v-app-bar class="bg-primary pa-4"
      ><h1 class="text-h5">Quiz time!</h1>
    </v-app-bar>
    <v-main>
      <RouterView />
    </v-main>
  </v-layout>
</template>

我们还可以从文件夹中删除AppBar.vueView.vue文件。

在下一步中,我们将着手添加到我们的服务器的连接。

添加 socket 客户端

到目前为止,是时候将 socket 客户端添加到我们的应用程序项目中了。在./app根文件夹中,通过输入以下命令安装包:

npm install socket.io-client

要在我们的应用程序中使用 Socket.io,我们将在./src文件夹中创建一个名为sockets.ts的文件,并包含以下内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.13-sockets.ts

此文件将处理来自套接字服务器的连接并将连接作为常量导出(第 6 行)。在此文件中,我们还将存储并公开我们接收到的数据,作为一个单一的对象(第 18-29 行)。由于对象具有多个层级,我们将使用reactive而不是ref,因为它的深度响应模型(vuejs.org/api/reactivity-core.html#reactive)。此state将传播它在我们的应用程序中使用的任何更改。

监听套接字事件

现在,让我们创建一个功能,使我们能够开始打开一个测验。我们期望用户通过包含测验 ID 的链接进入应用程序。为此,我们将更新./src/views/Start.vue文件,并完全替换其内容如下:

<script setup lang="ts">import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { socket } from "@/socket";
const route = useRoute();
const router = useRouter();
const init = (id: string): void => {
  socket.emit('quiz:select', id);
}
onMounted(() => {
  const id : string | string[] = route.params.id;
  if (id) {
    init(id.toString());
    router.push('/lobby');
  }
});
</script>

如您所见,我们甚至不需要任何模板!组件从路由参数中获取id值。代码使用套接字来发出quiz:select事件,然后将用户重定向到/lobby路由。

自动路由更改

在我们继续进入大厅之前,我们还将修改./src/App.vue文件,使其能够监听测验中的特定状态变化并重定向到某些路由:

<template>  <router-view />
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { state } from '@/socket'
const router = useRouter()
const quizStatus = computed((): string | null => state.quizStatus)
// when quizStatus changes, check if it's "ready" and if so, redirect to /quiz
watch(quizStatus, (newStatus) => {
  if (newStatus === 'question') {
    router.push('/question')
  }
  if (newStatus === 'answer') {
    router.push('/answer')
  }
  if (newStatus === 'end') {
    router.push('/final')
  }
  if (newStatus === 'scoreboard') {
    router.push('/scoreboard')
  }
})
</script>

在这里,我们正在导入quizStatus并监视其变化。一旦更新的值匹配我们的其中一个路由,我们将程序化地更新路由器以显示相应的视图。现在,让我们进入大厅!

大厅中的玩家管理

在大厅中,我们希望能够将自己添加为玩家,同时也想查看所有当前连接的玩家的概览。为了添加玩家,我们将在./src/components文件夹中创建一个新的组件,名为PlayerAdd.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.14-PlayerAdd.vue

在此情况下,我们将使用套接字连接来发出事件(第 9-11 行,第 25-27 行),但我们也会从状态中读取,例如,只允许玩家连接一次。

在文件夹中的下一个组件,名为PlayersOverview.vue,将补充大厅:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.15-PlayersOverview.vue

此组件仅从状态中读取,并以良好的格式展示概览,使用户能够在大厅视图中识别自己。

我们现在可以将这两个组件添加到./src/views/Lobby.vue文件中,以完成此功能:

<script setup lang="ts">import PlayerAdd from '@/components/PlayerAdd.vue'
import PlayersOverview from '@/components/PlayersOverview.vue'
</script>
<template>
  <div class="my-8">
    <h1 class="text-h3 mb-8">Welcome to the lobby</h1>
    <player-add />
    <players-overview class="my-8" />
  </div>
</template>

这是我们第一次看到我们的 socket 实际运作的机会!所以,让我们看看。一旦我们运行了开发脚本,我们的三个应用程序将并行运行。要进入大厅,我们需要通过本地开发 URL 访问 CQA,并将 ID 作为参数提供给/start路由。还记得我们的管理员面板吗?它有一个名为/start/{QUIZ_ID}的路由,用于大厅:

图 8.7 – 已将玩家添加到大厅

图 8.7 – 已将玩家添加到大厅

现在,在不关闭现有大厅浏览器窗口的情况下,切换回管理员面板,并在新浏览器窗口中打开相同的链接。你将被再次重定向到大厅。但在这个情况下,你将以新玩家的身份进入。你应该在概览中看到我们之前添加的玩家,准备开始游戏!如果你添加一个新的玩家名称,新玩家将通过 socket 服务器实时添加到两个浏览器窗口中!

看看我们的终端控制台。你会看到显示玩家状态已更改的消息:

图 8.8 – 控制台显示来自 socket 服务器的玩家状态

图 8.8 – 控制台显示来自 socket 服务器的玩家状态

由于我们已经连接了视图和路由,我们可以触发测验的开始。如果玩家之一点击开始,测验将自动遍历所有问题,并最终结束在最终得分视图。请随意尝试。你会注意到测验同时由两个浏览器窗口处理!

我们需要一些答案

好的——现在是时候构建我们应用程序的核心:回答机制了。对于这个和接下来的部分,我建议准备一个包含四个问题的测验。这样,我们可以在合理的时间内测试所有机制。

我们将在./components文件夹中创建一个新的组件,名为QuestionForm.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.16-QuestionForm.vue

与我们添加和显示玩家的方式类似,我们使用 socket 连接来发射用户的输入(第 12 行),同时读取实际状态并在 UI 中展示(第 15-17 行)。

./src/views/Question.vue中,我们将导入并显示此组件,作为当前问题的标题显示在视图旁边:

<script setup lang="ts">import { computed } from 'vue'
import { state } from '@/socket'
import type { QuizQuestion } from '@/types/quiz'
import QuestionForm from '@/components/QuestionForm.vue';
const question = computed((): QuizQuestion => {
  return state.quizCurrentQuestion
})
</script>
<template>
  <div class="my-8">
    <h1 class="text-h3 mb-8">{{ question.question }}</h1>
    <QuestionForm />
  </div>
</template>

通过将此组件添加到视图中,我们的用户可以尝试并回答问题。我们是否也向用户展示结果呢?让我们创建一个新的组件,名为 AnswerResult.vuegithub.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.17-AnswerResult.vue.

在前面的文件中,我们正在使用来自 socket 服务器的测验状态来显示单个用户是否回答正确。如您所见,我们可以完美地追踪哪个用户是哪个,并向他们展示定制的界面。在这种情况下,我们是在客户端应用程序中进行过滤。

再次,我们可以将此 AnswerResult 组件添加到正确的视图中——在这种情况下,是 Answer.vue 文件:

<script setup lang="ts">import AnswerResult from '@/components/AnswerResult.vue'
</script>
<template>
  <div class="my-8">
    <h1 class="text-h3 mb-8">Answers</h1>
    <answer-result />
  </div>
</template>

到目前为止,客户端应用程序已经开始非常接近我们想要的结果:

图 8.9 – 测验回答正确时的状态示例!

图 8.9 – 测验回答正确时的状态示例!

到目前为止,我们的工作开始变得有些重复,所以让我们通过显示中间结果和最终得分来完善我们的应用!

保留和显示得分

对于我们的得分板,我们将在组件文件夹中创建另一个名为 ScoreBoard.vue 的组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.18-ScoreBoard.vue.

我们将组件添加到 ScoreBoard 视图中:

<script setup lang="ts">import ScoreBoard from '@/components/ScoreBoard.vue';
</script>
<template>
  <div class="my-8">
    <h1 class="text-h3 mb-8">Scoreboard</h1>
    <score-board />
  </div>
</template>

对于最终得分,我们希望为赢家做一些额外有趣的事情。我们将在 ./app 文件夹的终端中运行以下命令来安装 Vue Confetti Explosion 组件(github.com/valgeirb/vue-confetti-explosion):

npm install vue-confetti-explosion

对于我们的盛大结局,我们将确保在名为 FinalScoreBoard.vue 的组件中为测验赢家添加彩带,创建以下内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/08.quiz/.notes/8.19-FinalScoreBoard.vue.

这里重要的是我们要识别哪个用户是赢家,并向该用户展示彩带(第 6 行、 第 22-24 行、第 53-55 行)!

FinalScoreBoard 组件也需要添加到其相应的视图文件(./src/views/FinalScore.vue)中:

<script setup lang="ts">import FinalScoreBoard from '@/components/FinalScoreBoard.vue';
</script>
<template>
  <div class="my-8">
    <h1 class="text-h3 mb-8">Scoreboard</h1>
    <final-score-board />
  </div>
</template>

就这样,我们的应用就完成了!你现在应该能够同时让一个或多个玩家完成测验:

图 8.10 – 这场比赛只能有一个赢家

图 8.10 – 这场比赛只能有一个赢家

我认为我们的客户端应用程序几乎没有值得提及的状态。所有这些都由中央网络套接字服务器处理!

摘要

这一章节相当长,涵盖了三个不同的应用程序。我希望各个应用程序的独立角色是清晰的。我也希望你能欣赏套接字服务器设置和 RESTful 服务器之间的差异。在这一章节中,我们使用了两者,各自都有其优势。

此外,考虑到我们在这一章节中编写的代码量,应该很清楚,这些代码在稳健性和安全性方面并不如你期望的生产就绪版本那样。我想强调,这并不是本章的重点!

在这一章节中引入的新概念之一是 Nuxt。正如你可能已经注意到的,它具有非常强大的功能,可以提升最终产品和开发者的体验。你可以将 Nuxt 视为任何 Vue 应用的默认扩展。我支持使其易于正确做事,同时使错误做事变得困难的理念。Nuxt 鼓励的具有偏见的设置使其容易上手。

网络套接字服务器在我们的所有项目中算是一个有点特别的存在。但正如我们从客户端的实现中可以看到的那样,它的实时更新与 Vue 应用的响应式模型非常契合。

作为结束语,我们还通过创建自动化重复性任务的脚本,在我们的开发工作流程中做了一些小的质量提升。你可以将其视为一种迈向工作空间或单仓库设置的垫脚石,这进一步扩展了相互依赖的项目管理。

在下一章中,我们将缩小应用程序的数量,并通过在 Vue 应用中使用人工智能和对象识别来构建一些有趣的东西!

第九章:使用 TensorFlow 进行实验性物体识别

是时候做一些更实验性的东西了。正如我们所见,人工智能AI)在编写由 AI 辅助的代码以及构建由 AI 驱动的解决方案时提供了许多新的探索机会。在本章中,我们将探讨TensorFlow。Google 在开源许可下开发和发布了 TensorFlow。它使开发者能够使用和训练适用于不同应用的机器学习模型。你可以在 TensorFlow 网站上找到精选的演示列表:www.tensorflow.org/js/demos

我们将利用 Google 发布的默认物体识别模型,应用库中的一个小部分。

首先,我们将构建一个小型的示例原型来发现一些功能。然后,我们将应用我们新获得的知识来构建一些实验性和有趣的东西。这是一个游戏,你需要使用浏览器中的摄像头追踪现实生活中的物体!

在本章中,我们将涵盖以下主题:

  • 通过原型设计来识别功能和限制

  • 利用多个外部 API 构建多媒体应用

  • 使用浏览器的原生摄像头语音合成媒体流API

我们将要构建的示例与前面的章节有交集,为你提供了定制应用程序以适应个人用例的潜在机会。我挑战你在这里创建一些独特的东西,基于最终的代码解决方案——也许甚至是一个使用你在第七章中学到的 Quasar 知识构建的原生应用!

技术要求

我们将在Vuetify框架(vuetifyjs.com/en/)和Piniapinia.vuejs.org/)上构建主要应用来管理状态。如前所述,我们将利用各种TensorFlow库(www.tensorflow.org/js/)将一些智能融入我们的应用。

你可以在这里找到本章的完整代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/09.tensorflow.

让我们从原型应用开始吧!

TensorFlow 简介

当我需要研究一个新的框架或技术时,我发现创建一个小型应用来测试它非常有帮助,这样我就可以在完全隔离的环境中测试它。我们将以 TensorFlow 应用同样的方法。原始的想法是,我们使用物体识别库(github.com/tensorflow/tfjs-models/tree/master/coco-ssd)并应用模型到我们设备上的摄像头图像。

设置项目

让我们使用一个熟悉的框架来快速构建我们新项目的样板代码。我们将使用 Vuetify CLI 为我们创建一个新项目:

  1. 在命令行界面中运行 npm create vuetify@3.0.0

  2. 选择 vue-tensorflow 作为项目的名称。

  3. 使用 Essentials (Vuetify, VueRouter, Pinia) 安装。

  4. 使用箭头键选择 TypeScript

  5. 选择 npm 来安装依赖项。

如果你导航到新的项目文件夹,你可以使用 npm run dev 运行本地开发服务器。结果应该对我们来说非常熟悉,因为我们已经做过几次了(见 第五章图 5**.1)。

接下来,我们将安装使用 TensorFlow 的依赖项。我们将安装的前两个依赖项将帮助我们获取 CPU 和 WebGL 以帮助算法中的计算。从终端运行以下命令:

npm install @tensorflow/tfjs-backend-cpu @tensorflow/tfjs-backend-webgl

我们将使用预训练的模型来帮助我们进行对象识别。Coco SSD (github.com/tensorflow/tfjs-models/tree/master/coco-ssd) 可以用于识别单张图像中的多个对象。我们可以通过运行以下命令将模型作为我们项目的依赖项安装:

npm install @tensorflow-models/coco-ssd

目前我们需要的就这些了!

注意

我们将遇到的一个限制是,预训练的模型是训练来识别有限类别的(类别指的是一个类别中对象的分类)。我们只能访问大约 80 个不同的类别。我们将不得不在这个限制下工作。

为了为未来的发展准备对象识别,我们将创建一个存储库来封装特征。由于我们在安装过程中选择了 Pinia,项目上已经初始化了一个空的存储库。我们将在 ./store 文件夹中创建一个名为 objects.ts 的新文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.1-object.ts.

我们设置了一些属性来跟踪模型的状态。请注意,模型加载可能需要一些时间,因此我们必须确保通知用户,以便他们有一个良好的用户体验。在存储初始化时,我们必须立即调用 loadModel() 函数,该函数在存储中加载模型(第 14 行,32-39 行),以便在整个应用程序中方便访问。

我们还添加并公开了一个 detect 函数(第 22-30 行)。该函数接收一个图像并通过模型运行该图像。结果是包含每个项目确定性的检测到的项目数组。

目前,这已经足够我们开始实施工作了。现在,让我们为我们的原型构建一个界面。

执行和显示状态检查

看到应用正在做什么是非常有价值的,尤其是第一次加载模型可能需要一些时间。我们将在./components文件夹中创建一个名为StatusCheck.vue的组件:

<template>  <v-list>
    <v-list-subheader>Status</v-list-subheader>
    <v-list-item>
      <v-list-item-title
        >AI Model
        <span v-if="isModelLoading">Loading...
          <v-progress-circular indeterminate :size="16" color="primary" />
        </span>
      </v-list-item-title>
      <v-list-item-subtitle v-if="isModelLoaded">Loaded!</v-list-item-subtitle>
      <template v-slot:append v-if="isModelLoaded">
        <v-icon icon="mdi-check" color="success"></v-icon>
      </template>
    </v-list-item>
  </v-list>
</template>
<script setup lang="ts">
import { watch } from "vue";
import { useObjectStore } from "@/store/object";
import { storeToRefs } from "pinia";
const objectStore = useObjectStore();
const { isModelLoading, isModelLoaded } = storeToRefs(objectStore);
const emit = defineEmits(["model-loaded"]);
watch(isModelLoaded, () => {
  if (isModelLoaded.value) emit("model-loaded");
});
</script>

此组件只是以良好的格式列出存储中的状态。当模型加载时,它还会发出model-loaded事件,这样我们就可以捕捉到该事件。让我们让模型加载状态显示在我们的应用中。我们可以从./components文件夹中删除HelloWorld.vue文件,并用以下内容替换./view/Home.vue的内容:

<template>  <v-container>
    <StatusCheck />
  </v-container>
</template>
<script lang="ts" setup>
import StatusCheck from "@/components/StatusCheck.vue";
</script>

现在,我们可以第一次运行我们的应用。你将注意到它最初加载需要一段时间,但过了一段时间后,你应该看到以下类似的内容:

图 9.1 – 可视化模型的状态

图 9.1 – 可视化模型的状态

现在我们已经加载了模型,我们可以使用它了!我们将构建一个图像上传字段,让模型分析图像的内容。

选择图像

我们将首先在components文件夹中创建一个新的组件。我们将称之为ImageDetect.vue,并从以下内容开始:

<template>  <v-container>
    <StatusCheckSimple @model-loaded="modelLoaded = true" />
    <v-file-input @change="inputFromFile" v-model="image" accept="image/png, image/jpeg" :disabled="!modelLoaded" />
    <v-img :src="img/url" height="100"></v-img>
  </v-container>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { Ref } from "vue";
import StatusCheckSimple from "./StatusCheck.vue";
const image: Ref<File | any | undefined> = ref(undefined);
const imageToDetect: Ref<HTMLImageElement | undefined> = ref(undefined);
const url: Ref<string | undefined> = ref(undefined);
import { useObjectStore } from "@/store/object";
import { storeToRefs } from "pinia";
const objectStore = useObjectStore();
const { detected } = storeToRefs(objectStore);
const modelLoaded: Ref<boolean> = ref(false);
const inputFromFile = (event: any): void => {
  const file = event.target.files[0];
  image.value = [file];
  imageToDetect.value = dataToImageData(file);
};
const dataToImageData = (dataBlob: Blob | MediaSource): HTMLImageElement => {
  const objUrl = URL.createObjectURL(dataBlob);
  const img = new Image();
  img.onload = () => {
    URL.revokeObjectURL(img.src);
  };
  img.src = objUrl;
  url.value = objUrl;
  return img;
};
</script>

如模板所示,我们将一些模板逻辑移动到这个文件中。我们使用<StatusCheck />组件和@model-loaded事件来确定图像检测控件是否可见或激活。

在脚本中,我们首先设置一些变量,以便跟踪在浏览器中选择的图像。一旦用户更改文件内容,我们就可以在浏览器内存中加载图像,以便在占位符中显示它。

我们将前往./views/Home.vue并替换其内容以加载这个新组件:

<template>  <v-container>
    <ImageDetect />
  </v-container>
</template>
<script lang="ts" setup>
import ImageDetect from "@/components/ImageDetect.vue";
</script>

现在,我们有一个提供图像的功能,并且我们有一个能够检测图像中对象的存储。让我们通过将存储引用添加到script标签并添加一个按钮来触发检测来开始连接它们:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.2-ImageDetect.vue

如第 7 行所示,我们已准备好显示检测到的对象。

注意

在局限性方面,我提到该模型能够识别多个对象。列表可以在以下位置找到:github.com/tensorflow/tfjs-models/blob/master/coco-ssd/src/classes.ts

我建议尝试使用人物图像或列出的类别之一来测试这个功能。

应用检测后,你应该得到类似以下内容:

图 9.2 – 基于上传图像的对象识别

图 9.2 – 基于上传图像的对象识别

这已经很有趣了,但让我们看看我们是否可以应用更多功能。首先,我们将查看如何优雅地格式化结果:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.3-ImageDetect.vue

第 12-22 行所示,我们添加了一个格式良好的检测项目列表。我们使用roundNumber函数(第 18 行,第 65-67 行)来四舍五入百分比。

让我们探索添加一个附加功能,看看我们是否可以通过探索语音合成 API 来给我们的应用添加语音。

为应用添加语音

由于我们正在查看我们应用的非传统输入(使用图像而不是鼠标和键盘),探索不同的信息呈现方式也很有趣。现代浏览器内置了一个将文本转换为语音TTS)的功能,称为SpeechSynthesisUtterance (developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance)。让我们为我们的原型添加一个功能,以便我们可以探索这一点。

这个 API 设置起来相当简单。我们将首先在./components文件夹中创建一个新的组件,名为TextToSpeech.vue,它将接受文本作为属性:

<template>  <v-btn @click="tts" prepend-icon="mdi-microphone" :disabled="isSpeaking">Speak</v-btn>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { Ref } from "vue";
const props = defineProps<{
  message: string;
}>();
const isSpeaking: Ref<boolean> = ref(false);
const tts = async () => {
  const { message } = props;
  const msg = new SpeechSynthesisUtterance();
  msg.text = message;
  msg.rate = 0.8;
  msg.pitch = 0.2;
  await window.speechSynthesis.speak(msg);
  msg.onstart = () => isSpeaking.value = true;
  msg.onend = () => isSpeaking.value = false;
};
</script>

tts函数中,我们可以看到如何访问 API 并发送语音消息。由于我们希望在语音活动时禁用按钮,我们正在跟踪onstartonend回调函数,并相应地更新isSpeaking变量。我们还在ratepitch设置上做了一些实验。

在配置SpeechSynthesisUtterance时,我们有更多选项,正如我们可以在文档中看到的那样。然而,不幸的是,我发现了一些限制。浏览器之间有一些不匹配,某些语言的支持并不稳定或可用。然而,TextToSpeech.vue组件在我们的应用中应该可以工作,所以让我们给我们的应用添加语音功能!

将组件存储后,我们将将其添加到ImageDetect.vue的模板中(别忘了导入组件!):

<template>  <v-container>
    <!-- abbreviated –->
    <div v-if="detected">
      <v-list>
        <v-list-item v-for="(item, index) in detected" :key="index">
          <!-- abbreviated –->
        </v-list-item>
      </v-list>
      <TextToSpeech :message="speech" v-if="speech"></TextToSpeech>
    </div>
  </v-container>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { Ref } from "vue";
import StatusCheckSimple from "./StatusCheckSimple.vue";
import TextToSpeech from "./TextToSpeech.vue";
// ...abbreviated
</script>

如您从模板中看到的那样,我们需要向组件提供speech。让我们看看代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.4-ImageDetect.vue

我们在这里添加了一些辅助工具。我们希望语音只命名唯一的类别,因此我们添加了一个名为uniqueObjects的计算变量,它过滤所有重复条目(第 71-75 行)。计算出的speech值(第 77-85 行)接受该列表并使用 Intl API 将其连接起来,我们也在第四章中使用过这个 API!输出是我们可以安全发送到<TextToSpeech />组件的内容。

如果你想的话,试试看!我们的原型是功能性的,这正是我们能够从中学习到的东西。

从原型学习

因此,有了这个微应用程序,我们可以进行一些实验。我遇到了两个主要问题:

  • 对象识别是可行的,但它非常有限,仅限于预训练模型中的类别。提供自训练模型应该是可能的,但在本主题的范围内处理它有点过于复杂。

  • 浏览器之间的 TTS 功能并不非常稳定或可靠,尤其是在不同语言之间。

我的最初想法是创建一个应用程序,它会使用摄像头流来指出我们可以学习翻译的对象。有了这两个限制,构建它将不可行。幸运的是,我们仍然可以借助可靠的功能来玩得开心,而不需要修改模型。

让我们构建一个小游戏,我们需要收集物品。我们可以使用现有的类列表,并对其进行一些修剪,使其适合我们的用例。让我们进行一次寻宝活动!

寻宝猎人

在本节中,我们将构建一个可以在网络浏览器上运行的小应用程序,最好是在手机上。使用Scavenge Hunter的目标是从列表中收集某些物品。我们可以使用类列表的一部分来控制用户需要收集的物品,在这种情况下,我们肯定能够检测到那些对象!

一旦检测到对象,我们将根据模型的发现和确定性为其添加分数。由于我们无法保证对象被正确识别,我们还应该能够跳过分配。我们不是上传图片,而是使用摄像头流!

设置项目

我们可以继续使用我们构建的原型,或者如果我们想的话,创建一个新的项目。在后一种情况下,需要依赖项和存储,因此我们需要重复提供在设置项目执行和显示状态检查部分的相关步骤。

让我们看看如何将我们原型的基石转变为一个小游戏,好吗?

通用更改

我们将从配置文件开始。我们需要在项目的根目录中创建此文件,命名为config.ts

export default Object.freeze({    MOTIVATIONAL_QUOTES: [
        "Believe in yourself and keep coding!",
        "Every Vue project you complete gets you closer to victory!",
        "You're on the right track, keep it up!",
        "Stay focused and never give up!"
    ],
    DETECTION_ACCURACY_THRESHOLD: 0.70,
    SCORE_ACCURACY_MULTIPLIER: 1.10, // input scores are between DETECTION_ACCURACY_THRESHOLD and 1
    MAX_ROUNDS: 10,
    SCORE_FOUND: 100,
    SCORE_SKIP: -150,
})

在一个中心位置拥有这类配置文件非常有帮助,这样我们就不必花费时间在单个文件中寻找设置。请随意修改config.ts文件中的游戏配置值!

让我们打开./index.html模板,这样我们就可以更新标题标签为新项目的名称——即Scavenge Hunter

我们还将在./views文件夹中创建两个新的视图文件。在这里粘贴一些占位符内容是可以的,如下所示:

<template>  <div>NAME OF THE VIEW</div>
</template>

我们需要一个用于查找状态的视图,称为Find.vue,以及一个用于游戏结束的视图,称为End.vue。我们将在构建完成屏幕跳到结束部分中稍后添加内容。有了视图,我们可以更新./router/index.ts文件,内容如下:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.5-index.ts

我们还将进一步简化界面。在./layouts/default文件夹中,删除AppBar.vueView.vue文件。在Default.vue文件中,将其内容替换为以下内容:

<template>  <v-app>
    <v-main>
      <router-view />
    </v-main>
  </v-app>
</template>

现在,我们应该能够运行应用程序,但此刻没有太多新的事情要做。让我们通过 Pinia 存储添加一些核心功能。

额外的存储

我通常先设计和设置存储,因为它们通常充当信息和方法的核心来源。首先,我们将用与第六章中非常相似的内容替换./store/app.ts文件的内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.6-app.ts

这是构建我们的健身追踪器时使用的应用存储的精简版,但我们已经移除了所有不必要的功能。

由于我们处理的是一个预定义的类别列表,我们将把这些类别添加到object.ts存储中作为额外的值:

// ...abbreviatedexport const useObjectStore = defineStore('object', () => {
    // ...abbreviated
    const loadModel = async () => {
        // ...abbreviated
    }
    loadModel();
    // Full list of available classes listed as displayName on the following link:
    // https://raw.githubusercontent.com/tensorflow/tfjs-models/master/coco-ssd/src/classes.ts
    const objects: string[] = ["person", "backpack", "umbrella", "handbag", "tie", "suitcase", "sports ball", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "orange", "broccoli", "carrot", "chair", "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "remote", "cell phone", "microwave", "oven", "sink", "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush"];
    return { loadModel, isModelLoading, isModelLoaded, detected, detect, objects }
})

我并没有添加所有的类别,而是选择了我们能在某人家里找到的类别。你可以将其更改为你认为合理的库存(特别是为了测试目的)。

让我们通过添加一个./store/game.ts存储文件来引入一些游戏机制:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.7-game.ts

此存储包含对正在进行的回合和跳过的回合的引用(第 19-23 行),跟踪得分(第 23 行),并帮助我们从一个定义在object存储中的对象列表中选择一个类别。特别是getNewCategory第 28-45 行)很有趣,因为它从objects集合中抽取一个随机类别,同时确保它始终是一个独特的新类别。

在本节的最后一步,我们将替换./App.vue文件的内容:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.8-App.vue

这将应用商店的功能与界面连接起来。现在,我们可以继续构建我们的小游戏了!

开始新游戏

我们将首先创建一个按钮,该按钮会触发新游戏的条件。在components文件夹中,我们将创建一个StartGame.vue组件,它不过是一个带有一些动作的按钮:

<template>  <v-btn
    :disabled="!canStart"
    @click="newGame"
    prepend-icon="mdi-trophy"
    append-icon="mdi-trophy"
    size="x-large"
    color="primary"
    ><slot>Start game!</slot></v-btn
  >
</template>
<script lang="ts" setup>
import { useAppStore } from "@/store/app";
import { useGameStore } from "@/store/game";
import { storeToRefs } from "pinia";
const gameStore = useGameStore();
const appStore = useAppStore();
const { canStart } = storeToRefs(gameStore);
const { reset } = gameStore;
const newGame = () => {
  reset();
  appStore.navigateToPage("/find");
};
</script>

如您所见,我们依赖于存储来告诉按钮是否应该禁用。我们通过调用gameStorereset()函数和在appStore上调用navigateToPage函数来触发新游戏。现在,我们应该能够在Home.vue视图中放置这个按钮组件。让我们用以下内容完全更新该视图:

<template>  <v-card class="pa-4">
    <v-card-title>
      <h1 class="text-h3 text-md-h2 text-wrap">z Scavenge Hunter</h1>
    </v-card-title>
    <v-card-text>
      <p>Welcome to "Scavenge Hunter"! The game where you find things!</p>
    </v-card-text>
    <StatusCheck />
    <v-card-actions class="justify-center">
      <StartGame />
    </v-card-actions>
  </v-card>
</template>
<script lang="ts" setup>
import StartGame from "@/components/StartGame.vue";
import StatusCheck from "@/components/StatusCheck.vue";
</script>

如果你现在运行应用程序,你会注意到无法开始游戏。由于我们想使用用户的摄像头视频流,我们需要请求访问权限。我们将扩展StatusCheck.vue文件,以确保我们有权访问摄像头。我们可以使用VueUse库中的组合式来完成这项工作。因此,从终端,让我们使用以下命令安装VueUse包:

npm i @vueuse/core

使用这个依赖项,我们可以更新StatusCheck.vue文件。该组件的更改相当广泛,因此请使用以下来源:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.9-StatusCheck.vue

除了对模型加载状态的一些额外格式化和显示实际状态的模板更改外,大多数更改都在脚本中。usePermission组合式返回一个响应式属性,告诉我们用户是否已授予使用摄像头的访问权限。如果模型已加载且用户已授予摄像头访问权限,则游戏可以开始(第 61-65 行)。如您所见,我们通过将它们作为数组(第 61 行)提供给watch函数,在多个值上使用watch函数。

onMounted钩子(第 67-81 行)中,我们手动尝试请求视频流。一旦流开始,我们就立即关闭它,因为我们不需要流,只需要权限。权限在我们访问期间是持久的。

构建完成屏幕

在我们深入到图像流和对象搜索方面之前,我们将构建最终的屏幕。我们将在./components文件夹中创建一个组件来显示一个名为ScoreCard.vue的游戏的结果:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.10-ScoreCard.vue.

在组件中,我们只是显示了一些在游戏过程中收集的指标。它们都是gameStore的一部分,因此我们可以轻松访问它们。

End.vue中,我们将导入ScoreCard.vue文件并对模板进行一些修改:

<template>  <v-card class="pa-4">
    <v-card-title>
      <h1 class="text-h3 text-md-h2 text-wrap">It's over!</h1>
    </v-card-title>
    <v-card-text>
      <p>Let's see how you did!</p>
    </v-card-text>
    <ScoreCard />
    <v-card-actions class="justify-center">
      <StartGame>Play Again?</StartGame>
    </v-card-actions>
  </v-card>
</template>
<script lang="ts" setup>
import ScoreCard from "@/components/ScoreCard.vue";
import StartGame from "@/components/StartGame.vue";
</script>

这里除了<StartGame />组件之外没有太多的事情,我们重用了这个组件来简单地触发一个新游戏。这就是使用插槽的方式!现在,我们可以开始处理中间部分了!

跳到结尾

首先,让我们确保我们可以通过跳过所有任务来完成一个(非常有限)的流程。我们将在./views/Find.vue文件中实现基本的游戏流程。让我们看看这个文件中的script标签,因为我们在这个文件中有很多事情要做:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.11-Find(script).vue.

script标签的顶部,我们正在加载存储库中的属性和方法(第 3-15 行)。我们使用appStore导航到不同的页面,使用gameStore因为它包含有关当前游戏进度的信息。

我们有一些计算值有助于以良好的方式呈现和格式化数据。currentRound第 17-19 行)显示游戏的进度。我们使用isPlaying第 21-23 行)来确定回合与设定的最大回合数的界限。最后,我们还有一些有趣的随机励志名言(第 25-29 行),这些名言是从我们的配置文件中加载的。

这个组件中有两个方法。一个是skip第 31-39 行)一个回合。skip函数跟踪跳过的回合数(第 32 行)并修改玩家的score第 33-37 行)。我们必须确保分数不低于0。跳过后,我们调用newRound方法。

newRound函数(第 41-47 行)跟踪应该发生的事情:要么回合数已达到最大值,我们应该导航到End状态,要么我们应该使用存储库中的getCategory函数加载一个新的类别。为了确保我们进入这个Find状态时开始,我们将在onMounted钩子中调用那个newRound函数。

接下来,让我们看看Find.vue文件的模板,在那里我们将计算值和方法连接到基本界面:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.12-Find(template).vue.

再次强调,这里并没有什么特别之处。我们使用<SkipRound />组件和@skipped事件来确保无论我们是否能够使用对象识别,我们都可以在回合中前进。

在这个阶段运行应用程序应该会给出以下类似的结果:

图 9.3 – 基本游戏流程

图 9.3 – 基本游戏流程

你应该能够通过跳过所有回合来完成整个流程。这种游戏在移动设备上比在笔记本电脑或个人电脑上更有意义,所以现在是确保我们可以正确测试应用程序的好时机。

在移动设备上进行测试

如果你正在为特定的用例构建应用程序,尽早测试这些用例是非常有意义的!虽然我们可以在浏览器中的移动视图中打开应用程序,但将其在移动设备上运行同样有意义。我们可以做的第一件事是自动通过更新package.json文件中的dev脚本来暴露开发服务器的主机:

{    "scripts": {
    "dev": "vite --host",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "lint": "eslint . --fix --ignore-path .gitignore"
  },
  "dependencies": {
    // ...abbreviated
  },
  "devDependencies": {
    // ...abbreviated
  }
}

此更改会自动通过你的本地网络提供服务,只要你的移动设备和开发服务器在同一网络中,你就可以通过网络地址访问应用程序:

图 9.4 – 将开发服务器暴露给网络

图 9.4 – 将开发服务器暴露给网络

尽管如此,我们还没有完成。媒体流仅可通过安全连接访问。根据官方文档中Vite的建议(vitejs.dev/config/server-options.html#server-https),我们将使用终端安装一个插件:

npm install --save-dev @vitejs/plugin-basic-ssl

安装完成后,我们将更新vite.confis.ts文件,使其能够使用插件:

// Pluginsimport vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import basicSsl from '@vitejs/plugin-basic-ssl'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    basicSsl(),
    vue({
      template: { transformAssetUrls }
    }),
    // ...abbreviated
  ],
  // ...abbreviated
})

保存后,我们可以重启开发服务器。现在内容是通过 HTTPS 协议提供的。它没有使用已签名的证书,因此你可能会在首次进入时收到浏览器的警告。现在你也可以使用你的移动设备验证每个步骤!

这样,我们就从开始到结束构建了一个基本流程,我们可以在移动设备上对其进行测试。然而,游戏本身目前并不那么有趣,对吧?是时候给游戏添加一些对象识别了!

从摄像头进行对象识别

这将是一个涉及几个步骤的变化。首先,我们将引入一个可以捕获浏览器视频的组件。我们将在./components文件夹中创建一个CameraDetect.vue组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.13-CameraDetect.vue

CameraDetect.vue组件中的代码使用@vueuse包中的 composables 与浏览器的DevicesuserMediaAPI 交互。我们使用useDevicesList列出可用的摄像头(第 33-40 行)并填充一个<v-select />组件(第 4-14 行)。这允许用户在可用的摄像头之间切换。

由于安全原因,用户需要手动激活摄像头(在切换摄像头时也是如此)。组件中的按钮切换摄像头流(第 44-46 行)。为了显示流,我们使用watchEffect将流导入video引用(第 48-50 行)。通过在<video />HTML 组件中引用流,我们可以将摄像头视频显示给用户(第 20 行)。

我们的流是我们原型文件上传的替代品。我们已经准备好存储以检测对象,因此现在我们将流连接到detect函数。

在流上检测和识别对象

我们原型的一个变化是我们向对象识别方法提供图像的方式。使用流意味着我们需要连续处理输入,就像浏览器能够做到的那样快。

识别对象

我们从objectStore中的detect方法需要能够确定识别出的对象是我们寻找的对象。我们将在object.ts文件中的函数中添加一些功能:

    // ...abbreviated    const detect = async (img: any, className?: string) => {
        try {
            detected.value = []
            const result = await cocoSsdModel.detect(img)
            const filter = className ? (item: DetectedObject) => (item.score >= config.DETECTION_ACCURACY_THRESHOLD && item.class === className) : () => true
            detected.value = result.map((item: DetectedObject) => item).filter(filter).sort((a: DetectedObject, b: DetectedObject) => b.score - a.score)
        } catch (e) {
            // handle error if model is not loaded
        }
    };
    // ...abbreviated

在这里,我们添加了一个名为className的可选参数。如果提供了它,我们定义一个filter函数。该过滤器应用于识别出的对象集合。如果没有提供className,该过滤器函数默认返回true,这意味着它不会过滤掉任何对象。我们只这样做是为了为<ImageDetect />组件提供向后兼容。

注意

当处理现有的代码库时,在开发过程中必须考虑到这些兼容性问题。在我们的案例中,原型函数需要向后兼容,因此对我们应用来说不是至关重要。我强调这一点是因为,在测试覆盖率低的大型应用中,你可能会遇到这些解决方案。

通过我们对object.ts文件的修改,我们可以将流传递给objectStore

从流中检测对象

我们首先将视频流的内 容传递给从 objectStore 更新的 detect 函数。我们还将包括 gameStore,以便我们可以将当前类别作为 className 属性传递。让我们将这些行添加到 CameraDetect.vue 文件中,以便我们设置好:

import { ref, watchEffect, watch } from "vue";// ...abbreviated
import { storeToRefs } from "pinia";
import { useObjectStore } from "@/store/object";
const objectStore = useObjectStore();
const { detected } = storeToRefs(objectStore);
const { detect } = objectStore;
import { useGameStore } from "@/store/game";
const gameStore = useGameStore();
const { currentCategory } = storeToRefs(gameStore);
// ...abbreviated

不要忘记我们从 Vue 导入的 watch 钩子;我们需要它来监控摄像头活动!接下来,我们将在脚本中添加一个名为 detectObject 的函数:

const detectObject = async (): Promise<void> => {  if (!props.disabled) {
    await detect(video.value, currentCategory.value);
  }
  window.requestAnimationFrame(detectObject);
};

这里发生了什么?我们创建了一个递归函数,它通过传递 videocurrentCategory 值不断调用 detect 方法。为了节流调用,我们使用了 window.requestAnimationFrame (developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)。通常,这个 API 是在动画时查询浏览器:浏览器将在准备好处理它时接受回调函数。这对于我们的用例来说也非常合适!

我们可以在视频启用时立即触发初始调用。我们导入的 watch 钩子可以监控 enabled 变量,并在视频启用后调用 detectObject 函数:

watch(enabled, () => {  if (enabled.value && video.value) {
    video.value.addEventListener("loadeddata", detectObject);
  }
});

最后,一旦我们找到匹配项,我们需要向我们的应用程序发出信号。我们将添加一个名为 foundemit 事件,一旦 detected 属性被填充了项,就会触发:

const emit = defineEmits(["found"]);watch(detected, () => {
  if (detected.value?.length > 0) {
    emit("found", detected.value[0]);
  }
});

我们正在将 detected 项集合中的顶级匹配返回给父组件。

注意

你可以通过临时修改 objectsStore 中的 objects 属性来简化测试,使其包含你手头的一些对象的值,例如 person。稍后,你可以将列表恢复到其之前的状态。

使用 Vue 的 DevTools,你可以再次测试应用程序。如果你打开 DevTools 并导航到 时间轴组件事件 面板,一旦摄像头做出正匹配,你将看到连续的事件被发出(嗯,每个动画帧一次):

图 9.5 – 由  组件发出的正匹配

图 9.5 – 由 组件发出的正匹配

现在,我们可以将发出的事件连接到 Find 状态。所以,让我们转到 ./views/Find.vue 文件,这样我们就可以捕捉到 found 事件并将其拉入我们的小游戏!

连接检测

如果我们打开 Find.vue 文件,我们现在可以在组件的模板上添加事件处理程序。我们还将提供一个 disable 属性来通过更改组件行来控制摄像头,如下所示:

<CameraDetect @found="found" :disabled="detectionDisabled" />

在脚本块中,我们必须对found事件进行一些更改,并为detectionDisabled属性提供值。让我们看看新的组件代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/09.tensorflow/.notes/9.14-Find.vue

我们添加了detectionDisabled响应式变量(第 51 行)并将其传递给<CameraDetect />组件。在现有的skip函数中,我们将detectionDisabled的值设置为false第 68 行)。我们还添加了found函数(第 78-86 行),其中更新detectionDisabled的值并处理新的分数,通过计算识别对象的确定性(第 81-83 行)并更新gameStore第 84 行)。与skip函数类似,我们调用newRound函数来推进游戏。

一旦调用newRound函数,我们更新detectionDisabled变量并将其设置为true以继续检测。

这将是测试应用的一个很好的时机。在这种情况下,一旦检测到,你将迅速通过轮次,向终点前进。如果识别似乎不可靠,你可以在./config.ts文件中将DETECTION_ACCURACY_THRESHOLD降低。

总结游戏流程

虽然游戏现在功能正常,但由于我们没有给用户提供足够的反馈,所以游戏不可玩。有了appStore在手,最简单的解决方案之一就是使用对话框!一旦我们整合了它,我们的迷你游戏就完成了!

首先,我们将通过添加对dialogVisible响应式值的引用来更新CameraDetect.vue文件。为此,请将以下内容添加到script标签中:

// ...abbreviatedimport { useAppStore } from "@/store/app";
const appStore = useAppStore();
const { dialogVisible } = storeToRefs(appStore);
// ...abbreviated

接下来,我们将在detectObject函数中使用dialogVisible来评估是否应该从objectStore调用detect函数:

// ...abbreviatedconst detectObject = async (): Promise<void> => {
  if (!props.disabled && !dialogVisible.value) {
    await detect(video.value, currentCategory.value);
  }
  window.requestAnimationFrame(detectObject);
};
// ...abbreviated

由于对话框之前从未可见,这不会影响我们的代码。我们将通过修改Find.vue文件来解决这个问题。为了定义对话框的内容,我们将在script标签中添加以下计算值:

// ...abbreviatedconst dialogEndLine = computed(() =>
  objectsFound.value + skips.value >= objectsLimit.value
    ? "You're done!"
    : "Get ready for the next round!"
);
// ...abbreviated

这将返回一条激励用户的话语。请随意修改!我们将更改的两个函数是foundskipped。让我们首先看看更新的found函数:

const found = (e: { class: string; score: number }) => {  detectionDisabled.value = true;
  objectsFound.value++;
  const newScore = Math.round(
    config.SCORE_FOUND * (e.score + 1) * config.SCORE_ACCURACY_MULTIPLIER
  );
  score.value += newScore;
  newRound();
  appStore.showDialog(
"Congratulations! 🥳",
    `<p>You've scored ${newScore} points by finding <strong>${e.class}</strong>!</p><p>${dialogEndLine.value}</p>`
  );
};

如你所见,我们只是在使用appStoreshowDialog方法向用户展示一个对话框。《CameraDetect />》组件现在能够检测到对话框的可见性,并将停止在后台检测。对于skipped函数,我们将添加以下内容:

const skipped = () => {  detectionDisabled.value = true;
  skips.value++;
  if ((score.value + config.SCORE_SKIP) <= 0) {
    score.value = 0;
  } else {
    score.value += config.SCORE_SKIP;
  }
  newRound();
  appStore.showDialog(
"Oh no! 🙀",
    `<p>Skipping cost you ${-config.SCORE_SKIP} points!</p><p>${
      dialogEndLine.value
    }</p>`
  );
};

如你所见,这些更改非常相似!再次提醒,你可以根据自己的喜好修改这些内容。

我们的游戏现在完成了!太棒了!我们现在几乎完成了我们的应用程序收集。我认为这个游戏非常适合增加更多功能和定制,以便你可以将其变成你自己的迷你游戏。从前几章中,我们讨论了许多你可以应用或只是发挥创造力的额外技术和概念。

摘要

我们以一个小型原型开始本章,以实验一种新技术。在一个隔离的环境中构建东西可以帮助你快速了解某种技术如何在现有环境中被采用。正如你所经历的,我们遇到了无法解决的限制。在这种情况下,这并不重要,因为我们处理的企业需求很少。

我们还学会了如何利用浏览器本身现有的和可用的 API 来构建一些非常规的东西。当组合一个作品集时,通过独特项目脱颖而出可以使你作为一个开发者脱颖而出。在结合多种技术的同时构建小型项目可以帮助你了解如何将它们组合成应用程序。这是一个更密集的方法,但结果是对技术的理解更好。

随意花些时间自定义前几章的项目。在最后一章,我们将创建一个在线托管的作品集。这将是你迄今为止所取得成就的完美展示!

第四部分:总结

最后一部分将所有前面的主题结合起来。你将学习如何优化 Nuxt 以用于静态站点目的,以及如何部署到网络主机。然后,我们将探讨自动化工作流程,如部署过程。本节为你提供了自定义输出的自由,并将所有前面的章节连接成一个可展示的资料库。

本部分包含以下章节:

  • 第十章使用 Nuxt.js 和 Storyblok 构建作品集

第十章:使用 Nuxt.js 和 Storyblok 创建作品集

如果你已经从 第一章 的待办事项列表中检查了应用程序的主题,你可能已经注意到我们已经到达了最后一章。为了庆祝我们的成就,我们将创建一个作品集,在这里我们可以展示我们过去完成的项目,同时也有灵活性添加未来的项目。我们还将探讨通过自动化流程将作品集部署到在线空间。

我们将使用 Nuxt (nuxt.com/) 来构建作品集,这将大大加快我们的开发过程。内容将存储在 Storyblok (www.storyblok.com/) 空间中。对于发布,我们将利用 Netlify (www.netlify.com/),这是一个非常友好的开发者平台,用于托管现代网络应用。

在本章中,我们将涵盖以下主题:

  • Nuxt 的使用复习以及将 Nuxt 作为静态网站渲染器使用

  • 学习将无头 CMS 应用于组织和内容管理

  • 使用现有集成连接 Nuxt 和 Storyblok

  • 应用经过验证的模式来优化网站

  • 自动化部署到公共主机

虽然我们将专注于构建作品集网站的基本要素,但你应该能够进行修改以进一步个性化最终产品,使其能够继续作为你的个人作品集网站来展示你的才能。

注意

本章项目的一部分基于 Storyblok 发布的指南:www.storyblok.com/tp/add-a-headless-CMS-to-nuxt-3-in-5-minutes

技术要求

像在 第八章 中一样,我们将使用 Nuxt (nuxt.com/) 作为构建作品集网站的框架。对于我们的样式和交互,我们将使用 Nuxt 生态系统的一部分 UI 库:Nuxt UI (ui.nuxt.com/)。我们需要在 SSL 模式下运行本地开发,为此我们将使用 mkcert (github.com/FiloSottile/mkcert) 生成一个本地可信的开发证书。

我们的内容将通过 Storyblok (www.storyblok.com/) 进行管理和存储,它提供了一个优秀的无头 内容管理系统 (CMS) 解决方案,我们可以使用其免费层。无头 CMS 是一个主要关注内容的系统,旨在将内容与展示分离。在我们的案例中,我们的展示由 Nuxt 处理,但它可以是任何我们授予访问内容的任何应用程序。这种关注点的分离有助于构建可扩展的应用程序。一旦我们构建了我们的作品集,我们将使用 Netlify (www.netlify.com/) 在公共 URL 上发布我们的作品集。

在开发这个项目时,如果你事先准备了一些数据,这将有助于防止在开发过程中切换上下文。让我们回顾一下之前完成的项目。对于每个我们想要展示的项目,我们更喜欢准备一些截图(Screenshot.rocks是一个从你的窗口创建样式截图的出色浏览器插件:screenshot.rocks/),并准备描述。

你可以在这里找到本章的完整代码:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/tree/main/10.portfolio

准备好几个项目后,我们可以开始初始内容设置!

设置 Storyblok

在 Storyblok 网站上注册相当直观。在初始注册后,你将进入一个演示空间。我们将跳过演示(你总是可以将其作为空间的一部分重新访问)并选择创建一个新的空间,在那里我们将选择适当的名字和服务器位置:

图 10.1 – 为我们的作品集创建一个新的 Storyblok 空间

图 10.1 – 为我们的作品集创建一个新的 Storyblok 空间

创建后,你可能会遇到一个关于试用期的模态窗口。你可以直接选择免费社区计划,或者在 14 天试用期后选择。

欢迎屏幕看起来像这样:

图 10.2 – Storyblok 仪表板

图 10.2 – Storyblok 仪表板

为了我们的作品集,我们可以关闭并忽略入门部分。我们将主要关注设置内容块库资产部分。

如果我们打开内容部分,我们会看到一个标题为主页的条目。让我们打开它看看:

图 10.3 – 主页条目上的初始向导

图 10.3 – 主页条目上的初始向导

这个屏幕告诉我们,我们可以在 Storyblok 所说的上下文预览中预览我们的内容。这意味着我们可以在 Storyblok 环境中加载我们的作品集,以提供非常逼真的作品集预览!

让我们设置一下,看看这实际上意味着什么。我们需要(你可能已经猜到了)访问令牌值来连接 CMS 到我们的应用程序。

初始化 Nuxt 作品集

我们将使用nuxi Nuxt CLI 工具创建一个新的项目,类似于我们在第八章中做的:

npx nuxi@3.8.0 init portfolio

再次,我们将选择npm作为我们的包管理器。如果你还没有安装用于生成 SSL 证书的mkcert工具(github.com/FiloSottile/mkcert),你需要遵循适当的安装说明来继续:github.com/FiloSottile/mkcert#installation

安装完成后,我们可以在命令行界面使用以下命令生成 localhost 证书:

mkcert localhost

命令应该产生类似于以下输出的结果:

图 10.4 – 成功生成 SSL 证书

图 10.4 – 成功生成 SSL 证书

下一步,我们需要更新 package.json 文件中的开发脚本,以便我们可以以 SSL 模式运行 Nuxt 开发服务器。找到以下命令:

"dev": "nuxt dev",

用以下行替换它,该行添加了证书并将进程指向一个 .env 文件:

"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 nuxt dev --dotenv .env --https --ssl-cert localhost.pem --ssl-key localhost-key.pem",

我们还将创建一个包含以下信息的 .env 文件(访问令牌值可以在 Storyblok 的 欢迎 页面上找到):

NUXT_STORYBLOK_ACCESS_TOKEN=replace_with_your_access_token

如果您跳过了 Storyblok 欢迎 页面,您可以通过 Storyblok 菜单查看令牌。导航到 设置,然后 访问令牌 – 它应该列在那里。

现在,我们必须从终端运行 npm run dev 命令以启动开发服务器。请注意,您现在可以通过 HTTPS 协议访问服务器!

在 Storyblok 欢迎 页面上,我们现在可以填写 设置预览 URL 字段,使其包含本地 URL(这应该与预先填充的占位符相似)。不要忘记结尾的斜杠!

如果在 Storyblok 中遇到错误,您可能需要首先在浏览器中打开该 URL,因为浏览器可能会将本地证书标记为不安全。如果您在浏览器中接受证书,您可以重新加载 Storyblok 界面;它应该显示 Nuxt 欢迎页面:

图 10.5 – 在 Storyblok 界面中加载的 Nuxt 欢迎屏幕!

图 10.5 – 在 Storyblok 界面中加载的 Nuxt 欢迎屏幕!

这非常方便!您现在可以从在线 CMS 界面看到本地运行的预览!目前还没有可编辑的内容,但我们将在下一阶段进行改进。

安装 Nuxt 模块

目前,我们可以停止 Nuxt 服务器以安装我们将要使用的 Nuxt 模块。在终端中,使用以下命令安装 Storyblok 模块 (github.com/storyblok/storyblok-nuxt):

npm i @storyblok/nuxt@5.7.4

Storyblok Nuxt 模块是一个具有意见化且因此低代码集成的 SDK,与 Nuxt 集成。它支持自动导入,并且可以非常容易地将 Vue 组件直接映射到 Storyblok 实体。

Storyblok Nuxt 模块能够正常工作之前,我们需要添加一些配置,因此让我们打开 nuxt.config.ts 文件并修改它,使其包含以下内容:

// https://nuxt.com/docs/api/configuration/nuxt-configexport default defineNuxtConfig({
  devtools: { enabled: true },
  modules: [
    ['@storyblok/nuxt', {
      accessToken: process.env.NUXT_STORYBLOK_ACCESS_TOKEN,
      apiOptions: {
          region: "eu"
        }
      }
    ]
  ],
})

如果您在另一个区域操作,请确保选择相应的区域。

当我们在设置模块时,我们也可以在这个时候添加 UI 库 (ui.nuxt.com/)。我们将使用以下命令从终端安装库:

npm i @nuxt/ui@2.9.0

安装完成后,我们需要通过在 nuxt.config.ts 文件中的 modules 属性中添加模块的名称来注册该模块:

// https://nuxt.com/docs/api/configuration/nuxt-configexport default defineNuxtConfig({
  devtools: { enabled: true },
  modules: [
    ['@storyblok/nuxt', {
      accessToken: process.env.NUXT_STORYBLOK_ACCESS_TOKEN,
      apiOptions: {
        region: "eu"
      }
    }
    ],
    '@nuxt/ui'],
})

在我们开始向 CMS 添加自己的内容之前,让我们测试一下我们是否可以在我们的应用程序中显示 Storyblok 的内容。

首先,我们将在 内容 部分的 Home 条目中打开。使用 条目配置,我们将告诉 Storyblok,这个页面是通过 实路径 属性在域根处发布的:

图 10.6 – 更新主页的实路径值

图 10.6 – 更新主页的实路径值

这个更改使得 主页 的内容可以在我们应用程序的主 URL 上可用。如前所述,Storyblok 提供了一个低代码 SDK,用于将 CMS 与我们的应用程序集成。“低代码”通常意味着我们需要采用某些模式。

SDK 为我们做的事情之一,例如,是将块库的内容映射到 Vue 组件。

为了实现这一点,我们需要将这些组件放置在我们项目的根目录下的 ./storyblok 文件夹中。为了我们的测试,我们需要创建两个文件。首先,我们将在 ./storyblok 文件夹中开始创建 Page.vue 文件:

<template>    <div v-editable="blok">
      <StoryblokComponent v-for="blok in blok.body" :key="blok._uid" :blok="blok" />
    </div>
  </template>
  <script setup lang="ts">
  defineProps({ blok: Object })
  </script>

接下来,我们将在同一文件夹中创建一个 Teaser.vue 文件:

<template>  <div v-editable="blok" v-if="blok">
    {{ blok.headline }}
  </div>
</template>
<script setup lang="ts">
defineProps({ blok: Object });
</script>

在这些文件中需要注意 v-editable 指令,因为它们向 Storyblok 信号,这些块的内容确实是可编辑的。我们现在可以在我们的应用程序中创建一个 ./pages 文件夹,并临时创建一个包含以下内容的 index.vue 文件:

<script setup>const story = await useAsyncStoryblok('home')
</script>
<template>
  <StoryblokComponent v-if="story" :blok="story.content" />
</template>

让我们为我们的网站创建一个简单的布局。首先,我们将在 ./components 文件夹中放置一个 Header.vue 文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/10.portfolio/.notes/10.1-Header.vue

我们还必须在同一文件夹中创建另一个名为 Footer.vue 的文件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/10.portfolio/.notes/10.2-Footer.vue

在这两个模板中,我们都在使用一些 Nuxt UI 组件,但除此之外,没有其他特别之处。我们将通过在 ./layouts 文件夹中创建一个 default.vue 文件来完成基本布局:

<template>  <div class="flex flex-col min-h-screen">
    <Header />
    <div class="flex-grow">
      <main class="container mx-auto">
        <slot />
      </main>
    </div>
    <Footer />
  </div>
</template>

在页面结构(尽管有限)就绪后,我们可以从项目的根目录中移除 app.vue 文件。在 Storyblok 界面中,你会看到一个 Hello world! 消息,取代了 Nuxt 欢迎视图。在浏览器窗口中打开开发服务器将得到相同的结果。现在,如果你在 Storyblok 中更改文本并保存更改,你将看到它在 Storyblok 界面以及直接在浏览器中都有所反映!

图 10.7 – 更新并保存主页内容

图 10.7 – 更新并保存主页内容

在这里,你可以看到我们手动配置和设置以建立连接所需是多么少!如果我们为我们的 Storyblok 组件使用正确的命名约定,SDK 可以自动使用 slug 将 Storyblok 中的内容填充到组件中。除此之外,这些组件在 Storyblok 编辑器视图中也是可编辑的。

除了自动将内容映射到组件之外,SDK 还有助于识别 URL 并提供相应的内容。在下一节中,我们将向 CMS 添加更多内容,以便我们可以更好地探索这种连接!

我们到目前为止所做的是设置页面。使用 Storyblok,你可以轻松地创建和修改页面,使用预定义的组件。这些是bloks。拥有可重复使用的 bloks 集合允许编辑者通过添加 bloks 并配置它们来创建页面和组合内容。预览选项的可用性允许编辑者在部署前看到更改,这是 Storyblok 的一个宝贵功能。

与多种内容类型协同工作

要定义我们页面上要使用的类型和内容,我们可以导航到 Storyblok 中的Block library。默认情况下,你会看到四个现有的 bloks:featuregridpageteaser。在 bloks 之间,有两种不同的类型。pageContent类型,而其余的是Nestable类型。关键区别在于Content type是内容的一个顶级类型。你可以把它看作是一种具有独特 URL 的页面。你使用它来保存不同层次的内容类型的组合——即Nestable。这些是页面的构建块:你可以将它们添加到页面中,以构建你喜欢的页面内容。

此外,还有Universal type,它可以充当上述任何一种类型。尽管如此,我们不会在我们的示例中使用它。

让我们通过点击portfolio(在这种情况下,小写是首选)来创建一个新的 bloks 以用于我们的投资组合。这将是一个Content type bloks,我们将通过点击Add Block按钮来配置类型。接下来,我们将添加我们展示项目所需的最小字段。让我们使用以下设置:

  1. 添加一个Text类型的title字段名称。

  2. 添加一个Asset类型的image字段名称:

    1. 创建后,配置字段以仅允许Images
  3. 添加一个150字符的description字段名称。

  • 添加一个Richtext类型的body字段名称。

现在,我们将通过导航到 Storyblok 界面的Content部分,开始向我们的投资组合添加一些条目。

配置投资组合

我们希望我们的投资组合在/portfolio路径上发布,其中 slug 会添加到 URL 中。在/portfolio路径上,最终我们希望展示项目的概览。在Portfolio中。slug 将自动填充。注意内容类型字段,我们可以限制可以显示为文件夹一部分的内容类型。我们现在将保持默认设置,但稍后我们会回到这个选项。点击创建将文件夹添加到我们的内容中。

因为我们要在投资组合的根部分展示项目概览,我们将导航到文件夹并创建一个新的条目,但这次我们将选择Home,并在创建时必须勾选将文件夹定义为根复选框。对于内容类型,我们将使用默认设置,即页面。Storyblok 将尝试打开页面,这可能会导致服务器不可用(如果你还没有启动它)或出现404 未找到错误,因为我们还没有为这个路由定义目标。没关系 – 我们将在将内容映射到代码部分解决这个问题。我们需要使用条目配置来配置这个页面,并勾选将文件夹定义为根复选框。这个选项会移除 slug 值,这正是我们所需要的。

虽然我们在这里添加了一个类型页面,但我们希望限制其余内容,使其仅包含Portfolio条目。为此,我们将移动到Portfolio文件夹条目的根目录,这样我们就可以再次编辑文件夹属性:

图 10.8 – 更新投资组合文件夹的设置

图 10.8 – 更新投资组合文件夹的设置

这将打开与创建文件夹时相同的模态框。我们将使用这个来通过勾选限制内容类型选项并选择投资组合类型来进一步限制对Portfolio文件夹内容的添加:

图 10.9 – 限制投资组合文件夹的内容类型

图 10.9 – 限制投资组合文件夹的内容类型

这不会影响我们之前创建的主页,但它将影响未来的条目。所以,让我们创建一些投资组合条目。你可以选择你想要展示的内容 – 我建议为了这个部分至少添加两项。我们稍后会添加更多来展示预览的功能!

将内容映射到代码

首先,我们将关注常规页面,因为我们现在要处理的不止一个。我们可以完全删除./pages/index.vue文件,并用一个名为./pages/[...slug].vue的文件来替换它:

<script setup lang="ts">const { slug } = useRoute().params as { slug: string[] };
const story = await useAsyncStoryblok(
  slug && slug.length > 0 ? slug.join("/") : "home", { version: "draft" }
);
</script>
<template>
  <div>
    <StoryblokComponent v-if="story" :blok="story.content" />
  </div>
</template>

在这里放置了这段代码后,我们应该能够渲染一个简单的页面。让我们看看投资组合部分的首页。如果我们打开它,在 Storyblok 中,我们会看到一个空白页面。在右侧的内容面板中,我们可以添加一个新的块 – 例如,预告块。

让我们拖入 预告 块并为其提供一个适合投资组合概览页的标题:

图 10.10 – 在预览模式下在页面上渲染预告块

图 10.10 – 在预览模式下在页面上渲染预告块

再次,我们在浏览器中访问开发 URL 时,我们看到的预览立即可见!

那么,这里发生了什么?我们使用了基于文件的 Nuxt 路由功能,使我们的应用程序落在 [...slug].vue 页面上。在这个页面上,我们通过 useAsyncStoryblok 可组合函数读取 slug 路由参数来查询 Storyblok 内容。在模板中,我们依赖于 Storyblok SDK 提供的 <StoryBlokComponent /> 组件,从 ./storyblok 文件夹动态加载相应的组件,以反映 CMS 中的区块库中的项目。由于 Nuxt 框架的自动导入,我们为页面提供了一个非常干净的设置!

您可能已经注意到,./storyblok 文件夹的内容与库中的区块数量不匹配。让我们快速修复这个问题:您需要确保这些始终对齐以支持不同的场景。

我们将使用组件的最小设置来修复这个问题。首先,我们在 ./storyblok 文件夹中创建一个 Feature.vue 组件,内容如下:

<script setup lang="ts">defineProps({ blok: Object });
</script>
<template>
  <div v-editable="blok" v-if="blok">
    <h3>
      {{ blok.name }}
    </h3>
  </div>
</template>

然后,在同一个文件夹中,我们将创建一个 Grid.vue 文件:

<script setup lang="ts">defineProps({
  blok: {
    type: Object as () => { columns: any },
    required: true,
  },
});
</script>
<template>
  <div v-editable="blok" v-if="blok" class="flex mx-auto">
    <StoryblokComponent
      v-for="blok in blok.columns"
      :key="blok._uid"
      :blok="blok"
/>
  </div>
</template>

如您所见,这个文件与 Feature.vue 文件略有不同,因为它可以包含嵌套的 Storyblok 组件。我们可以简单地将这些组件传递给 <StoryblokComponent /> 以渲染每个可能递归级别的正确组件。

展示投资组合部分

我们的 投资组合 部分大致由两种情况组成:我们希望在主页上展示所有投资组合项目的概览,并且我们希望在单独的页面上展示单个案例。

首先,让我们通过在 portfolio-all 中创建一个新的区块来创建概览,并将其设置为可嵌套的区块。在字段编辑器中,添加以下字段:

  1. 添加一个 文本 类型的 title 字段。

  2. 添加一个名为 description150 字符字段。

  • 添加一个 富文本 类型的 body 字段。

保存后,我们可以更新 投资组合 主页。我们可以移除不再需要的 标题 块。拖入新的 投资组合所有 组件并添加一些合理的文本:

图 10.11 – 配置投资组合所有区块

图 10.11 – 配置投资组合所有区块

正如我们所学的,我们需要确保在我们的 ./storyblok 文件夹中有一个对应的组件。命名约定遵循驼峰式,因此我们需要在文件夹中创建一个 PortfolioAll.vue 组件(github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/10.portfolio/.notes/10.3-PortfolioAll.vue):

<script setup lang="ts">import type { Ref } from "vue";
import type { StoryblokProject, StoryBlok } from "@/types/storyblok";
const props = defineProps({
  blok: { type: Object as () => StoryBlok, required: true, },
});
const projects: Ref<StoryblokProject[] | null> = ref(null);
const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get("cdn/stories", {
  version: "draft",
  starts_with: "portfolio",
  is_startpage: false,
});
const richTextBody = computed(() => renderRichText(props.blok. body));

projects.value = data.stories; 
</script>
<template> 
  <div>
    <h1 class="text-2xl mb-4 text-primary">
      {{ blok.headline }}
    </h1>
    <div v-html="richTextBody" class="py-4" />
    <div class="grid grid-cols-2 gap-4">
      <UCard
        v-for="project in projects"

<PortfolioAll /> 组件稍微复杂一些。虽然它在获取 props 的意义上与 Page 组件有很多共同之处,但我们还手动使用 useStoryblokApi 可组合函数(第 11-16 行)请求额外的数据。我们获取了所有在投资组合路径上的故事。is_startpage 信号表示我们想要排除投资组合部分的首页。在接收到数据后,我们将其存储在 projects 引用(第 9 和 20 行)中,以便我们可以在模板中遍历它,并使用 Nuxt UI 的 <ULink /> 组件来渲染指向项目页面的链接。

另外还有一个新增功能:由于我们为首页的 body 定义了一个富文本字段,我们需要确保它被正确渲染。我们可以使用 Storyblok 的 renderRichText 函数(第 18 行)作为一个计算值。

让我们再构建一个投资组合详情页面,以暂时结束这一部分。同样,我们需要在 ./storyblok 文件夹中创建相应的组件,以便它自动映射到 URL。由于我们正在显示 Portfolio.vue 组件:github.com/PacktPublishing/Building-Real-world-Web-Applications-with-Vue.js-3/blob/main/10.portfolio/.notes/10.4-Portfolio.vue

Portfolio.vue / 是一个相当简单的组件。因为我们已经预定义了 ./pages/[…slug.vue] 组件或更具体地说,是 useAsyncStoryblok 可组合函数中的一部分字段,它为 <StoryblokComponent /> 提供数据。

为了更直观地展示,以下模型有助于解释组件是如何渲染的:

图 10.12 – 从 Storyblok 中的数据到浏览器中的组件

图 10.12 – 从 Storyblok 中的数据到浏览器中的组件

当数据来自 Storyblok 时,它带有组件类型的描述。使用 slug 页面,我们总是到达一个 Storyblok 组件,然后尝试将类型匹配到 ./storyblok 文件夹中相应的 Vue 组件。该组件反过来可能是一个嵌套类型,这意味着它包含 Storyblok 组件的另一个实例,依此类推。一旦你掌握了这种思维模型,它就相当直接。Nuxt 和 Storyblok 模块处理的自动化程度和假设可能会使机制变得有些模糊。

在我们的产品组合就绪后,让我们更仔细地看看内容模型以及我们如何更好地调整它以满足我们的需求。

修改内容模型

我们的无头 CMS 现在能够向我们的产品组合展示基本的布局和内容。这种方式仍然非常有限,所以让我们找出如何与内容模型合作,以便它支持新功能。

更新现有类型

假设我们的预告区块是一个标题区块。我建议尽可能保持区块的静态性,但让我们看看我们需要做什么来更改名称。如果我们导航到 CMS 中的区块库并悬停在预告区块上,我们可以通过上下文菜单进入编辑模式。在配置选项卡中,我们有更新名称的选项:

图 10.13 – 更新现有区块的名称

图 10.13 – 更新现有区块的名称

下一步仅仅是将./storyblok文件夹中的Teaser.vue组件重命名为Heading.vue。为了保持稳定性,我强烈建议在内容建模方面提前规划,而不是依赖于现有内容类型的重写。

一个经验法则

随着应用的不断增长,引入需要现有组件重命名或删除的模型更新变得越来越有风险。这些被认为是破坏性变更,可能会暂时导致您的应用程序中断。Storyblok 会警告您,更改可能需要时间才能在整个内容中传播,这可能会影响或阻止应用程序生成所有页面。这类警告或错误应谨慎处理。

改变或删除功能总会在代码中引入一些风险。添加新功能要安全得多,正如我们接下来将要看到的。

扩展区块属性

添加功能的更改更容易实现,让我们看看我们如何扩展功能区块。同样,我们将打开编辑视图。我们将添加几个字段:

  1. 添加一个资产类型的image字段名称:

    1. 创建后,配置该字段仅允许图片
  2. 添加一个链接类型的link字段名称。

保存这些更改后,我们将更新组件,以便在预览编辑器中体验这些更改。让我们打开./storyblok/Feature.vue文件并替换其内容:

<script setup lang="ts">defineProps({ blok: {
  type: Object as () => { name: string, image: any, link: any },
} });
</script>
<template>
  <div v-editable="blok" v-if="blok" class="text-center">
    <ULink :to="blok.link.cached_url">
      <img
        v-if="blok.image"
        :src="img/200x0'"
        :alt="blok.image.alt"
      />
    </ULink>
    <header class="text-xl">
      {{ blok.name }}
    </header>
  </div>
</template>

我们可以将网站主页上的默认功能区块更新为,例如,链接到一些产品组合项。如果您不想使用它们,也可以删除现有的功能。更新组件后,Storyblok 会为您提供良好的可视化,展示结果将如何呈现。

页面现在仍然有点空,这是因为我们没有添加文本的手段。让我们这次先创建组件,然后再在 CMS 中实现它。我们将在./storyblok文件夹中创建一个RichText.vue组件,其内容如下:

<script setup lang="ts">const props = defineProps({
  blok: { type: Object as () => { body: any }, required: true },
});
const richTextBody = computed(() => renderRichText(props.blok.body));
</script>
<template>
  <div v-editable="blok" class="prose">
    <div v-html="richTextBody" />
  </div>
</template>
<style scoped>
.prose {
  line-height: 1.8em;
}
</style>

然后,在 Storyblok 中,我们将添加一个名为rich-text的新块,作为一个可嵌套的块。作为字段的一部分,我们只添加一个名为body富文本类型的字段。现在,你可以立即添加内容块并开始向网站添加更多内容!

我们将对我们的块进行一些重构,因为单个元素不允许我们创建一个看起来好的视觉表示,尤其是在处理文本页面时。首先,我们将在./storyblok文件夹中创建一个名为Article.vue的 Vue 组件:

<script setup lang="ts">defineProps({
  blok: { type: Object as () => { title: string, content: any}, required: true },
});
</script>
<template>
  <div v-editable="blok" class="article">
    <h2 v-if="blok.title" class="text-2xl mb-4">{{ blok.title }}</h2>
    <StoryblokComponent
      v-for="blok in blok.content"
      :key="blok._uid"
      :blok="blok"
    />
  </div>
</template>
<style scoped>
.article {
  max-width: 720px;
  margin: 2em auto;
}
</style>

接下来,我们将转到 CMS 创建一个名为article的新块,作为一个具有以下字段的嵌套块:

  1. 添加一个文本类型的title字段名。

  2. 添加一个类型的content字段名。

第二个字段允许我们使用许多不同的可用块来组合文章类型。尝试为自己设置一个像联系卡块这样的东西将是一个好的实践!

从我们设置的布局中汲取灵感,我们仍然缺少一个页面。使用 Storyblok,你应该能够在项目的根目录下创建一个Me故事。你可以使用这个空间来介绍自己并分享你的旅程。

映射元字段

你还记得我们在展示投资组合部分添加到投资组合所有投资组合块的描述字段吗?我们还没有映射这些字段!这些字段旨在为页面提供一些用于搜索引擎优化SEO)的元数据。使用语义 HTML 标签,我们可以专门描述页面内容以便搜索引擎索引。Nuxt 提供了一个现成的解决方案来控制这些标签,并提供了一个可组合的(nuxt.com/docs/getting-started/seo-meta#usehead)。

在这两种情况下,由于字段名相同,我们可以通过在脚本标签中插入以下代码来添加meta字段:

useHead({  meta: [
    { name: 'description', content: props.blok.description }
  ]
});

现在,如果你查看网站的源代码,你会看到标签和内容被渲染:

图 10.14 – 元字段已在 HTML 源代码中渲染

图 10.14 – 元字段已在 HTML 源代码中渲染

记住这一点是很好的,我们可以使用 CMS 中的内容不仅限于网站的可见部分 – 我们可以将任何内容作为我们应用程序中的变量进行处理。我们可以提供开关或主题 – 真的什么都可以 – 这样我们就可以在不部署新代码的情况下更改网站。这就是无头 CMS 在开发应用程序时成为一个强大工具的原因。

添加新功能

完成这些步骤后,你可以尝试向你的投资组合添加更多功能。比如一个格式良好的联系表?或者一个可管理的简历部分?凭借我们所构建的,你应该能够自己解决这个问题!

生成一个独立的网站

我们现在有一个功能齐全的资料库。这种类型的网站内容非常静态:它不会根据访客而改变,而且代码方面可能不会每天更新!这意味着我们可以优化我们将部署到公共领域的输出。

我们创建的资料库是静态网站生成的完美用例。通常,Nuxt 会在服务器上作为一个活跃的进程运行,它可以直接响应请求并实时获取数据。一个很好的例子是我们第八章中构建的问答服务器。但由于发布时我们不需要实时数据,我们可以利用 Nuxt 的另一个功能。在生成静态网站时,Nuxt 最初作为一个应用程序运行,并索引所有内部链接。对于每个内部链接,它将从服务器获取一次数据,然后将输出写入静态文件集合。

让我们看看这个动作!该命令已经是我们package.json脚本的一部分,但我们需要稍作修改,以便我们可以传递访问数据的 API 密钥:

…  "scripts": {
    "build": "nuxt build",
    "dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 nuxt dev --dotenv .env --https --ssl-cert localhost.pem --ssl-key localhost-key.pem",
    "generate": "nuxt generate  --dotenv .env",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare"
  },
…

我们可以在终端中运行以下命令:

npm run generate

你将看到正在生成静态网站所需的文件。完成后,Nuxt生成过程将返回一个你可以用来测试网站作为静态网站的命令:

npx serve .output/public

运行此命令将启动一个简单的 HTTP 网络服务器。没有 Nuxt 进程在运行!这仅仅是浏览器在运行 HTML、CSS 和 JavaScript 文件。所有来自 CMS 的内容现在都作为网站的一部分捆绑在一起。好吧,除了图片——它们由 Storyblok CDN 提供,该 CDN 专门为此类操作进行了优化。

我们将在下一节中看到如何使我们的静态网站公开。

发布静态网站

任何网络托管商现在都能提供这些静态文件。这就是静态生成网站的一个好处:在托管它们时拥有很大的自由度。然而,我们还有一些后续要求,因此我们选择了 Netlify (app.netlify.com/)来满足我们的使用案例。如果你注册,你应该能够免费进入免费层。在注册时,选择最适合你的类别,并为你的团队选择一个名称。

在下一屏,我们将采用 Netlify Drop 的方式进行部署:

图 10.15 – 选择你的第一个部署方法

图 10.15 – 选择你的第一个部署方法

选择./output/public文件夹。确保你放下public文件夹,而不是单独的内容!

一旦你完成上传,你将第一次进入仪表板。点击打开生产部署按钮将带你到你的已发布资料库!子域名由 Netlify 生成,包含随机词汇。你可以选择通过域名****管理部分来更改它:

图 10.16 – 首次部署后的 Netlify 仪表板

图 10.16 – 首次部署后的 Netlify 仪表板

Netlify 的免费层提供了处理域名的能力,可以将您自己的域名指向这个空间。

静态生成网站的缺点是,我们部署的网站完全脱离了内容和我们的 Nuxt 服务器。这意味着如果我们更新内容或更改代码中的功能,我们需要重新生成网站并手动将输出上传到 Netlify。这可行但不是最佳方案!

幸运的是,通过一些额外的努力和配置,我们可以在内容更改或代码更改时自动化部署!

自动化代码更改时的构建和部署

我们的 Netlify 环境不了解我们的应用程序。Netlify 是一个用于部署 Web 应用的工具。我们无法将 Netlify 连接到我们的本地开发环境。我们需要将源代码也托管在网上。

GitHub (github.com/) 与 Netlify 具有出色的集成,非常适合托管和版本控制代码。不深入细节,GitHub 可以检测到仓库中的代码何时被更新,并在这种情况下触发某些操作。

首先,我们将设置一个仓库,我们可以在这里发布代码。让我们创建一个新的空仓库:

图 10.17 – 在 GitHub 中创建新的仓库

图 10.17 – 在 GitHub 中创建新的仓库

仓库创建后,您可以使用终端按照以下步骤将我们的代码附加到仓库:

git initgit branch -m master main
git remote add origin https://github.com/{YOUR_USERNAME}/{REPOSITORY_NAME}.git
git add .
git commit -am "published source to git"
git push -u origin main

这会将我们的代码推送到 GitHub,这意味着我们可以将其连接到 Netlify!在仪表板上,导航到 网站配置 | 构建和部署,然后点击 链接仓库 按钮。然后,您需要在您的 GitHub 账户上授权 Netlify。完成此操作后,您将看到您仓库的概览。选择您刚刚创建的资料库以继续。

它可能会自动检测这是一个 Nuxt 仓库,Netlify 将尝试提出一些建议。我们需要更改三个设置:

  1. 修改 npm run generate

  2. 修改 ./output/public

  3. 我们需要添加环境变量来添加 .env 文件的内容,其中 NUXT_STORYBLOK_ACCESS_TOKEN 应与您的访问令牌匹配。请确保已保存!

您所做的更改应与以下设置匹配:

图 10.18 – 我们新的 Netlify 部署设置

图 10.18 – 我们新的 Netlify 部署设置

部署此操作将允许 Netlify 从仓库中读取并拉取更改时的最新版本。它将自动执行此操作。然后,它将在虚拟环境中运行 npm run generate 脚本来进行静态站点渲染。最后一步是将输出文件夹的内容部署到 Web 域名。

就这么简单!如果您在本地代码上工作并提交代码到仓库,更改将自动部署!值得注意的是,我们没有验证我们的代码是否功能正常,因为我们没有在我们的应用程序上运行测试。确保您的代码的关键功能正常工作是一个最佳实践。

自动化内容变更时的构建

如果我们能在内容变更而不是代码变更时做类似的事情,那就太完美了,对吧?幸运的是,有一个非常方便的方法来做这件事:我们可以使用 webhook 来触发操作。Storyblok 会跟踪内容何时准备好发布,并且可以在 Netlify 平台上触发一个操作,执行构建,就像代码变更一样。然而,这次,Nuxt 将在生成过程中获取内容的最新版本。

让我们看看我们如何设置它。首先,我们需要在 Netlify 中创建一个 webhook。这个 webhook 仅仅是一个在调用时触发内部操作的唯一 URL。

导航到 内容变更时部署。设置默认为构建 main 分支,这是完美的。保存片刻后,您将收到一个具有类似 api.netlify.com/build_hooks/UNIQUE_IDENTIFIER 模式的唯一 URL。

现在,我们可以转到 Storyblok 来配置调用此 webhook。如果我们导航到 设置Webhook 部分,我们可以管理 webhook!我们将创建一个新的,再次使用一个有意义的名称。在 端点 URL 区域,我们可以粘贴 Netlify 的端点。

触发器 决定了我们希望在哪些事件上调用 webhook。您可以尝试调整一下设置,但我建议从以下这个集合开始:

图 10.19 – 选择事件触发器以调用 webhook

图 10.19 – 选择事件触发器以调用 webhook

理想情况下,您希望选择尽可能少的触发器,因为每次构建过程都需要时间。连续调用 webhook 只是将请求放入队列,这可能导致您的更改可见的等待时间更长。

让我们保存这些设置并更改我们作品集中的某些内容。

小贴士

如果您没有运行 Nuxt 开发服务器,您仍然可以使用右侧的面板编辑内容。您甚至可以使用 显示表单视图 按钮将其扩展以填充屏幕:

图 10.20 – 使用表单视图扩展编辑功能

图 10.20 – 使用表单视图扩展编辑功能

没有开发服务器仅仅意味着您没有实时预览。访问内容不是强制性的。

保存后,您可以转到 Netlify 控制面板上的 部署 部分,查看它触发了新的构建。

您做到了!

在本章中,我们一直在使用不同的平台和系统,将它们连接成一个可以渲染静态网站的单个应用程序。一个关键收获是我们可以将许多专业解决方案的优势结合起来,构建一个我们可以部署的稳健产品。我强烈推荐您查看更多关于 Storyblok 和 Netlify 的资源,因为它们与这种类型的 Web 堆栈配合得很好。

Storyblok 的实时预览功能与使用 Nuxt 作为框架的开发体验相结合,使得这类项目非常容易上手并构建出我们可以使用的成果!我们注意到,Nuxt 不一定要作为服务器运行——它可以用来自动从服务器生成数据并存储输出。这种方法比实时数据获取更可持续,并且性能更优。

通过我们设置的 Storyblok 与 Nuxt 的集成方式,任何人都可以非常容易地构建一个网站。我们构建了一个相对简单的示例,但随着块的增加,您可以创建的可能性会大幅增加,同时对于非技术用户来说仍然非常易于理解。

在本章中,我们设定的每一个配置都是为了尽可能容易地管理网站。甚至发布机制也通过自动化代码部署以及在内容更改时自动生成新版本来助力这一目标。

在所有这些之上,我们还创建了一个平台,您可以在阅读本书章节的过程中记录您的学习历程。我建议您用您自豪的项目或成就来完善您的作品集。

本书的一个目标就是展示现实世界的代码。现实世界的代码通常是实用的,可能并不总是优化到完美。同样,本书中的示例也不是完美的。然而,它们有效地实现了预期的目标。拥抱实用主义使您能够与用户验证功能,并缩短反馈循环,这是一个非常有价值的方面。我说明了重构和优化是持续过程中的重要部分。我想强调,随着您对项目的深入了解,您自然会优先考虑哪些方面需要优化。

回顾过去,我希望您为在这本书中涉及的不同主题、技术和应用程序所付出的努力感到自豪。我试图描绘出可能的现实世界场景,同时不断增进您的 Vue.js 知识和经验。我想感谢您对这些主题的兴趣,以及在我们旅程中的陪伴。调查、构建和记录的过程对我来说也非常宝贵,我感激有机会在编写代码时教授和分享我的知识和方法。

posted @ 2025-09-08 13:04  绝不原创的飞龙  阅读(53)  评论(0)    收藏  举报