Vue3-示例-全-

Vue3 示例(全)

原文:zh.annas-archive.org/md5/84EBE0BE98F4DE483EBA9EF82A25ED12

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Vue 是主要框架之一,拥有庞大的生态系统,并因其在开发应用时的易用性以及能够帮助你快速实现令人印象深刻的开发结果而不断增加采用率。本书探讨了最新的 Vue 版本——Vue 3.0——以及如何有效地利用它。

你将通过基于示例的方法学习,从探索 Vue 3 的基础开始,创建一个简单的应用,并学习组件、指令等功能的使用。为了增强你的知识并让你对应用构建技能有信心,本书将向你展示如何使用 Jest 和 Vue Test Utils 测试应用。之后,你将学习如何使用 Vue 3 编写非 Web 应用,并使用 Electron 插件创建跨平台桌面应用。你还将学习如何使用 Vue 和 Ionic 创建多用途移动应用。随着学习的深入,你将学习如何使用 Vue 3 开发与 GraphQL API 良好交互的 Web 应用。最后,你将构建一个实时聊天应用,使用 Vue 3 和 Laravel 进行实时通信。

通过本书,你将通过完成一系列使用 Vue 3 构建应用的项目,掌握实际技能。

这本书适合谁

这本书适合对使用 Vue 3 进行前端 Web 开发和创建专业应用感兴趣的 Web 开发人员。如果你想学习如何使用 Vue.js 3.0 作为前端创建全栈 Web 应用,你也会发现本书很有用。要充分利用本书,需要具备基本的 JavaScript 编程知识。

本书涵盖内容

第一章《在 Vue 3 中创建你的第一个应用》将介绍如何使用 Vue 3 创建简单的应用。你将从构建最基本的应用开始,然后逐渐转向构建更复杂的解决方案。

第二章《构建 Vue 3 渐进式 Web 应用》将教你如何使用 Vue 3 创建一个 GitHub 渐进式 Web 应用(PWA)。在构建项目的过程中,你将深入了解 Vue 应用的内部工作原理,探讨基本构建模块,并创建包含组件的 Vue 应用,以及组件的组成部分和工作原理。

第三章,使用测试构建滑块拼图游戏,将通过让你使用 Vue 3 创建一个简单的游戏来介绍 Vue。你将学习如何使用不同的方法、混合、指令和计算属性添加到项目中。

第四章,构建照片管理桌面应用,将帮助你使用 Vue Electron 插件构建照片管理桌面应用。你将学习如何使用 Electron 和 Vue 轻松构建跨平台桌面应用程序。

第五章,使用 Ionic 构建多功能计算器移动应用,将帮助你创建一个多功能计算器移动应用。你将使用 Vuex 来管理状态并保存结果数据,以便以后在本地存储中使用。最后,你将掌握货币转换、单位转换和小费计算。

第六章,使用 PrimeVue UI 框架构建度假预订应用,将教你如何创建一个具有管理员功能的旅行预订应用。管理员界面将是用户管理预订的仪表板。它将涉及使用状态管理和路由来创建一个功能齐全的应用程序。后端将很简单,这样你就可以更多地专注于 Vue。还需要使用 Vuex 进行状态管理和 Vue Router 进行路由。

第七章,使用 GraphQL 创建购物车系统,将帮助你创建一个 Vue 3 应用并将其与 GraphQL API 一起使用。你将学习如何在 Vue 3 应用程序中使用 GraphQL 客户端。API 将具有查询、变异和数据库交互,你将学习如何使用 Express 创建 GraphQL API。

第八章,使用 Vue 3、Laravel 和 Socket.IO 构建聊天应用,将教你如何使用 Vue 3、socket.io 和 Laravel 创建聊天应用。这个应用将进行 HTTP 请求并进行实时通信。它可以被多个用户使用。

为了充分利用本书

为了更好地利用本书,您应该了解现代 JavaScript 的基础知识。了解从 2015 年以后引入的 JavaScript 特性将使您更容易理解本书。基本的 TypeScript 概念,如定义接口和高级数据类型,将在第五章使用 Ionic 构建多用途计算器移动应用中使用。

此外,第八章使用 Vue 3、Laravel 和 Socket.IO 构建聊天应用,涵盖了需要对 PHP 有基本了解的 Laravel。更高级项目的后端部分还需要了解非常基本的 SQL 语法。SelectInsertCreate table等命令将会有所帮助。

其他所需的是最新版本的 Node.js 和 Visual Studio Code。Visual Studio Code 支持 JavaScript 和 TypeScript 开发。需要 Node.js 来运行 Vue CLI 和 Ionic CLI。

阅读本书后,您应该尝试通过创建自己的项目来进行更多练习。这样,您将能够运用从本书中获得的知识。仅仅从教程中学习只是一个开始;独立创建项目将使您熟练掌握技能。

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的潜在错误。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书名,并按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/-Vue.js-3-By-Example。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都以以下方式编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以这种方式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”

提示或重要说明

以这种方式出现。

第一章:在 Vue 3 中创建您的第一个应用程序

Vue 3是流行的 Vue.js 框架的最新版本。它专注于改善开发人员的体验和速度。它是一个基于组件的框架,让我们可以轻松创建模块化、可测试的应用程序。它包括其他框架常见的概念,如 props、过渡、事件处理、模板、指令、数据绑定等。本章的主要目标是让您开始开发您的第一个 Vue 应用程序。本章侧重于如何创建组件。

在本章中,我们将学习如何使用 Vue 3 从头开始创建简单的应用程序。我们将从构建最基本的应用程序开始,然后在接下来的几章中构建更复杂的解决方案。

我们将涵盖的主要主题如下:

  • 了解 Vue 作为一个框架

  • 设置 Vue 项目

  • Vue 3 核心功能-组件和内置指令

  • 使用 Vue.js Devtools 进行调试

技术要求

本章的代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter01

了解 Vue 作为一个框架

正如我们在介绍中提到的,Vue 中有一些概念可以从其他框架中获得。指令可以像在 Angular.js 和 Angular 中一样操作文档对象模型DOM)。模板可以像我们在 Angular 中一样渲染数据。它还有自己特殊的语法用于数据绑定和添加指令。

Angular 和 React 都有 props,用于在组件之间传递数据。我们还可以循环遍历数组和对象条目,以显示列表中的项目。与 Angular 一样,我们可以向 Vue 项目添加插件以扩展其功能。

Vue.js 独有的概念包括计算属性,这些属性是从其他属性派生出来的组件属性。此外,Vue 组件具有 watchers,让我们可以监视响应式数据的变化。响应式数据是由 Vue.js 监视的数据,当响应式数据发生变化时,操作会自动执行。

随着响应式数据的变化,组件的其他部分和引用这些值的其他组件都会自动更新。这就是 Vue 的魔力。这也是我们可以用如此少的代码做如此多事情的原因之一。它替我们负责监视数据变化的任务,这样我们就不必自己做了。

Vue 3 的另一个独特功能是,我们可以使用脚本标签添加框架及其库。这意味着,如果我们有一个旧的前端,我们仍然可以使用 Vue 3 及其库来增强旧的前端。此外,我们不需要添加构建工具来构建我们的应用程序。这是大多数其他流行框架所没有的一个很棒的功能。

还有流行的 Vue Router 库用于路由,以及 Vuex 库用于状态管理。它们都已更新为与 Vue 3 兼容,因此我们可以安全地使用它们。这样,我们就不必像在使用其他框架(如 React)时担心要使用哪个路由器和状态管理库。Angular 自带其自己的路由,但没有指定标准状态管理库。

使用 Vue CLI 和脚本标签设置 Vue 项目

有几种方法可以创建 Vue 项目或向现有前端添加脚本标签。对于原型设计或学习目的,我们可以通过添加以下代码来添加 Vue 3 的最新版本:

<script src="https://unpkg.com/vue@next"></script>

这将始终在我们的应用程序中包含最新版本的 Vue。如果我们在生产中使用它,我们应该包含版本号,以避免新版本的意外更改破坏我们的应用程序。如果我们想指定版本,版本号可以替换next这个词。

我们还可以通过安装包来安装 Vue。为此,我们可以运行以下命令:

npm install vue@next

这将在我们的 JavaScript 项目中安装最新版本的 Vue。

如果我们使用旧版本的 Vue CLI 从头创建了一个 Vue 项目,那么我们可以使用 CLI 为我们生成所有文件并安装所有包。这是开始使用 Vue 项目的最简单的方法。对于 Vue 3,我们应该使用 Vue CLI v4.5,通过运行以下命令:

yarn global add @vue/cli@next

我们还可以通过运行以下命令安装 Vue 调色板:

npm install -g @vue/cli@next

然后,要将我们的 Vue 项目升级到 Vue 3,我们可以运行以下命令:

vue upgrade –-next

Vite 构建工具将让我们从头开始创建一个 Vue 3 项目。它可以比 Vue CLI 更快地为我们提供项目服务,因为它可以原生地处理模块。我们可以通过使用 NPM 运行以下命令来从头开始设置 Vue 项目:

$ npm init vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

使用 Yarn,我们必须运行以下命令:

$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev

在任何情况下,我们都可以用我们选择的项目名称替换<project-name>

我们可以使用各种版本的 Vue。其中一组是 CDN 版本,不带捆绑器。我们可以通过文件名中的vue(.runtime).global(.prod).js模式来识别它们。这些可以直接通过脚本标签包含。

我们使用它们与直接添加到 HTML 中的模板一起。vue.global.js文件是完整的构建,包括编译器和运行时,因此它可以从 HTML 动态编译模板。vue.runtime.global.js文件只包含运行时,并且需要在构建步骤中预编译模板。

开发和生产分支是硬编码的,我们可以通过检查文件是否以.prod.js结尾来区分它们。这些文件已经准备好用于生产,因为它们已经被压缩。这些不是通用模块定义UMD)构建。它们包含用于常规脚本标签的 IIFE。

如果我们使用诸如 Webpack、Rollup 或 Parcel 之类的捆绑器,那么我们可以使用vue(.runtime).esm-bundler.js文件。开发和生产分支由process.env.NODE_ENV属性确定。它还有完整版本,它可以在运行时动态编译模板和运行时版本。

在本章中,我们将通过 Vue 的脚本标签版本介绍 Vue 的基本功能。在随后的章节中,我们将继续使用 Vue CLI 来创建我们的 Vue 3 项目。这样,我们可以专注于探索 Vue 3 的基本功能,这在我们转向创建更复杂的项目时会很有用。让我们开始创建一个 Vue 实例。

创建您的 Vue 实例

现在我们已经设置好了我们的 Vue 项目,我们可以更仔细地看一下 Vue 实例。所有 Vue 3 应用程序都有一个 Vue 实例。Vue 实例充当应用程序的入口点。这意味着这是首先加载的内容。它是应用程序的根组件,它有一个模板和一个组件选项对象,用于控制模板在浏览器中的呈现方式。

要创建我们的第一个 Vue 3 应用程序,我们必须将以下代码添加到index.html文件中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      count: {{ count }}
    </div>
    <script>
      const Counter = {
        data() {
          return {
            count: 0
          };
        }
      };
      Vue.createApp(Counter).mount("#app");
    </script>
  </body>
</html>

在我们的第一个 Vue 3 应用程序中,我们首先添加了script标签以添加 Vue 框架脚本。它还不是最终版本,所以我们添加了 Vue 脚本的下一个版本。

在 body 中,我们有一个 ID 为 app 的div,我们用它来容纳模板。模板中唯一的内容将由 Vue 3 附带的模板编译器编译。在下面,我们有一个script标签来创建我们的应用程序。它提供了Counter对象,其中包含我们可以用来创建应用程序的属性。

Vue 组件作为对象提供,Vue 将使用它们来创建任何必要的组件。data属性是一个特殊属性,返回我们状态的初始值。这些状态是自动响应的。count状态是一个我们可以更新的响应式状态。它与模板中的相同。花括号中的任何内容必须是包含响应式属性或其他 JavaScript 表达式的表达式。

如果我们在花括号之间添加响应式状态,它们将被更新。由于count响应式属性被初始化为0,模板中的count属性也是0Counter对象被传递到Vue.createApp方法中,以编译模板并连接响应式属性,将花括号内的表达式渲染为最终结果。因此,我们应该在渲染的输出中看到count: 0

mount()方法接受一个 CSS 选择器字符串作为参数。选择器是要在其中渲染应用程序的模板。其中的任何内容都将被视为 Vue 表达式,并相应地进行渲染。花括号中的表达式将被渲染,属性将被 Vue 解释为 props 或指令,具体取决于它们的编写方式。

在下一节中,我们将看一下 Vue.js 3 的核心特性。

Vue 3 核心特性 - 组件和内置指令

现在我们已经创建了一个带有 Vue 实例的基本 Vue 应用程序,我们可以更仔细地看一下如何使它做更多的事情。Vue 3 是一个基于组件的框架。因此,组件是用于构建完整的生产质量 Vue 3 应用程序的核心构建块。组件是可以组合成完整应用程序并且可重用的部分。Vue 3 组件有几个部分,包括模板、组件选项对象和样式。样式是我们应用于渲染元素的 CSS 样式。模板是在浏览器屏幕上呈现的内容。它包含 HTML 代码和 JavaScript 表达式,形成在浏览器中呈现的内容。

模板从相应的组件选项对象获取数据。此外,组件模板可以具有指令,控制内容的呈现方式以及将数据从模板绑定到响应式属性。

组件

我们用一个 Vue 实例创建了一个基本的 Vue 应用。现在,我们必须找到一种管理我们应用的方法。Vue 3 是一个基于组件的前端框架。这意味着使用 Vue 3 创建的应用是由多个组件组合而成的。这样,我们可以保持应用的每个部分都很小,这有助于使测试变得容易,也容易调试。这对我们来说很重要,因为我们正在创建一个为用户提供功能的非平凡应用。

在 Vue 3 中,组件是具有一些预定义选项的 Vue 实例。要在另一个组件中使用组件,我们必须注册它们。要创建一个 Vue 组件,我们可以调用app.component()方法。第一个参数是组件,称为string,而第二个参数是一个包含组件选项的对象。

一个最小的组件应该至少包含添加到对象中的模板属性。这样,它将在我们的组件中显示一些内容,使其有用。我们将首先创建一个用于显示待办事项的组件。为了显示我们的待办事项,我们可以创建一个todo-item组件。此外,组件很可能需要接受 props 来显示来自其父组件的数据。prop是一个特殊的属性,让 Vue 组件将一些数据传递给子组件。子组件具有props属性来定义它将接受的值的类型。为此,我们可以编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <div>
        <ol>
           ...
            ]
          };
        }
      };
      const app = Vue.createApp(App);
      app.component("todo-item", {
        props: ["todo"],
        template: `<li>{{todo.description}}</li>`
      });
      app.mount("#app");
    </script>
  </body>
</html>

我们调用了app.component()方法来创建todo-item组件。它包含了props属性,其中包含一个接受todoprop 的 prop 名称数组。我们定义 prop 的方式意味着我们可以接受任何值作为todoprop 的值。我们还可以指定它们的值类型,设置它是否是必需的,或为其提供默认值。template属性让我们在需要时呈现它。我们只需将其设置为一个字符串,它将像任何其他模板一样呈现项目。

li元素在模板中呈现。花括号的工作方式与任何其他模板相同。它用于插值值。要访问 prop 的值,我们只需在组件中将其作为 this 的属性访问,或者在模板中直接使用 prop 名称本身。

要将todo属性从根 Vue 实例传递给todo-item组件,我们需要使用冒号前缀属性名称,以指示它是一个 prop。冒号是v-bind的简写。v-bind指令是 Vue 的内置指令,它让我们将数据作为 prop 传递给子组件。如果 prop 名称是驼峰式命名,则它将被映射到 HTML 中的 kebab-case 名称,以保持其有效。这是因为有效的属性应该具有 kebab-case 名称。Vue 3 附带的模板编译器将自动执行映射。因此,我们只需遵循惯例,然后我们就可以正确传递我们的 props。

如果我们使用v-for指令,我们应该添加 key 属性,以便 Vue 3 可以正确跟踪项目。使用v-for指令,我们可以循环遍历数组或对象,并显示它们的条目。值应该是一个唯一的 ID,以便 Vue 可以正确渲染项目,即使我们交换项目的位置并添加或删除项目并在列表中执行其他操作。为此,我们可以编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
  ...
    </div>
    <script>
      const App = {
        data() {
          return {
            todos: [
              { id: 1, description: "eat" },
              { id: 2, description: "drink" },
              { id: 3, description: "sleep" }
            ]
          };
          ...
      app.mount("#app");
    </script>
  </body>
</html>

每个id属性值对于 Vue 的列表跟踪是唯一的。

Vue 组件看起来像 Web 组件规范中的自定义元素,但 Vue 组件不是自定义元素。它们不能互换使用。这只是一种使用熟悉的语法来创建组件的方式,这是标准的。Vue 组件中有一些特性在自定义元素中是不可用的。在自定义元素中没有跨组件数据流、自定义事件通信和构建工具集成。然而,所有这些特性都在 Vue 组件中可用。我们将在接下来的部分中介绍 Vue 组件的这些特性。

组件生命周期

每个 Vue 3 组件都有自己的生命周期,每个生命周期阶段都有自己的方法。如果达到了生命周期的给定阶段,并且在组件中定义了该方法,那么该方法将被运行。

在使用app.mount()挂载应用程序后,事件和生命周期被初始化。当组件加载时将运行的第一个方法是beforeCreate()方法。然后,组件被初始化为响应式属性。然后运行created()方法。由于在这个阶段初始化了响应式属性,我们可以在这个方法和之后加载的方法中访问这些响应式属性。

然后,运行组件的模板或渲染函数来呈现项目。内容加载完成后,将运行beforeMount。一旦运行了beforeMount,应用程序将被挂载到我们在app.mount()方法中传递给选择器指定的元素中。

一旦应用程序被挂载到元素中,就会运行挂载钩子。现在,当任何响应属性发生变化时,将运行beforeUpdate钩子。然后,重新渲染虚拟 DOM,并从响应属性的最新值呈现最新的项目。这是运行任何外部库的初始化代码的好地方。完成后,将运行updated钩子。

beforeDestroy在组件卸载之前运行。这是在销毁组件之前运行任何清理代码的好地方。当组件被销毁时,将运行destroyed钩子。在这里,响应属性将不可用。

响应属性

响应属性是组件选项对象的属性,它们让我们同步模板中显示的内容,并根据我们对它们进行的操作而改变。对响应属性所做的任何更改都会在引用它们的任何地方传播到整个应用程序中。

在前面的示例中,我们向我们的应用程序添加了count响应属性。要更新它,我们只需更新响应属性的值本身:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <button @click="count++">increment</button>
      count: {{ count }}
    </div>
    <script>
      const Counter = {
        data() {
          return {
            count: 0
          };
        }
      };
      Vue.createApp(Counter).mount("#app");
    </script>
  </body>
</html>

在这里,我们有@click="count++"表达式,它监听按钮的点击,并在点击增加按钮时将计数增加1。最新的值将在任何地方都得到反映,因为它是一个响应属性。Vue 可以自动捕捉到响应属性的变化。@clickv-on:click的简写。

此外,我们可以将表达式重写为方法。为此,我们可以编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <button @click="increment">increment</button>
      count: {{ count }}
    </div>
    <script>
      const Counter = {
        data() {
          return {
            count: 0
          };
        },
        methods: {
          increment() {
            this.count++;
          }
        }
      };
      Vue.createApp(Counter).mount("#app");
    </script>
  </body>
</html>

引用 Vue 实例对象中的count响应属性,我们必须将其作为this的属性引用。因此,在 Vue 实例对象中的this.count与模板中的count是相同的。this关键字指的是组件实例。我们应该记住这一点,这样我们就不会在以后遇到问题。

此外,我们将方法的属性添加到组件对象中。这是一个特殊的属性,用于在我们的代码中保存我们可以在 Vue 实例的其他部分或模板中引用的方法。与响应属性一样,方法在 Vue 实例对象中被引用为this的属性,并且在模板中省略了this

因此,当我们点击按钮时,我们运行methods属性中的增量方法。当我们点击按钮时,计数值将增加1,我们应该在浏览器的输出中看到它显示。

处理用户输入

大多数应用程序需要用户向表单输入内容。我们可以使用 Vue 3 的v-model指令轻松实现这一点。它将输入的值与我们在 Vue 实例中定义的响应属性进行同步。

要使用它,我们只需将v-model属性添加到输入框中。为此,我们可以编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <p>{{ message }}</p>
      <input v-model="message" />
    </div>
    <script>
      const App = {
        data() {
          return {
            message: "hello world."
          };
        }
      };
      Vue.createApp(App).mount("#app");
    </script>
  </body>
</html>

在这里,我们有message响应属性,它已初始化为'hello world.'字符串。我们可以通过将其设置为v-model指令的值,在模板中使用相同的值。它将在输入的值和message响应属性之间进行同步,以便我们输入的任何内容都会传播到 Vue 实例的其余部分。

因此,'hello world.'字符串既显示在输入框中,也显示在段落元素中。当我们在输入框中输入内容时,它也会显示在段落元素中。它将更新message响应属性的值。这是 Vue 3 带来的一个很棒的功能,我们将在许多地方使用它。

条件和循环

Vue 3 的另一个非常有用的功能是我们可以在模板中有条件地渲染内容。为此,我们可以使用v-if指令,它让我们有条件地显示某些内容。v-if指令只有在我们分配给它的条件为真时,才将元素放入 DOM 中。v-show指令使用 CSS 显示和隐藏它绑定的元素,并且该元素始终在 DOM 中。如果它的值为真,我们将在模板中看到它显示。否则,我们不会看到该项显示。

它通过有条件地将项目附加到 DOM 来工作。只有当v-if值为真时,具有v-if指令的元素或组件内的元素和组件才会被附加到 DOM 中。否则,它们不会附加到 DOM 中。

例如,假设我们有以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <span v-if="show">hello world</span>
    </div>
    <script>
      const App = {
        data() {
          return {
            show: true
          };
        }
      };
      Vue.createApp(App).mount("#app");
    </script>
  </body>
</html>

在这里,'hello world'将被显示,因为showtrue。如果我们有以下代码,我们将看不到任何显示,因为 span 没有附加到 DOM 上:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <span v-if="show">hello world</span>
    </div>
    <script>
      const App = {
        data() {
          return {
            show: false
          };
        }
      };
      Vue.createApp(App).mount("#app");
    </script>
  </body>
</html>

要在模板中呈现项目数组和最终输出,我们可以使用v-for指令。我们放置一个特殊的 JavaScript 表达式,让我们循环遍历数组。我们可以通过编写以下代码来使用v-for指令:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <ol>
        <li v-for="todo in todos" :keu="todo.id">
          {{ todo.description }}
        </li>
      </ol>
    </div>
    <script>
      const App = {
        data() {
          return {
            todos: [
              { description: "eat", id: 1 },
              { description: "drink", id: 2 },
              { description: "sleep", id: 3 }
            ]
          };
        }
      };
      Vue.createApp(App).mount("#app");
    </script>
  </body>
</html>

我们在li元素中使用了v-for指令。'todo in todos'循环遍历todo数组并呈现标签之间的项目。todo变量是正在迭代的单个todos条目。我们访问描述属性,以便我们可以在列表中显示描述的值。

完成后,我们将看到一个带有todo文本的编号列表。

模板语法

我们已经广泛使用了模板。我们主要使用插值来显示数据和一些指令来呈现数据。此外,我们可以使用@v-on指令来监听发出的事件,例如点击和输入值更改。

还有其他有用的语法,我们可以用来创建模板。其中之一是使用插值表达式显示原始 HTML。默认情况下,Vue 3 会转义所有 HTML 实体,以便它们按原样显示。v-html指令让我们将 HTML 代码显示为真正的 HTML,而不是纯文本。

例如,让我们编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <span v-html="rawHtml"></span>
    </div>
    <script>
      const App = {
        data() {
          return {
            rawHtml: `<b>hello world</b>`
          };
        }
      };
      const app = Vue.createApp(App);
      app.mount("#app");
    </script>
  </body>
</html>

在这里,我们将rawHtml响应式属性设置为v-html的值,这样我们就可以看到b标签被呈现为粗体文本,而不是以原始形式呈现的字符。

JavaScript 表达式和模板

我们可以在大括号之间放置任何 JavaScript 表达式。它只能是单个表达式。

例如,以下代码片段显示了大括号之间的有效内容:

{{ number + 1 }} 
{{ areYouSure ? 'YES' : 'NO' }}
{{ message.split('').reverse().join('') }}

但是,我们不能在大括号之间放置任何 JavaScript 语句。例如,我们不能写{{ var foo = 1 }}{{ if (yes) { return message } }}

计算属性

计算属性是从其他响应式属性派生出来的特殊响应式属性。计算属性被添加到计算属性对象中作为函数。它们总是返回从其他响应式属性派生出来的东西。因此,它们必须是同步函数。

要创建计算属性,我们可以编写以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <p>{{message}}</p>
      <p>{{reversedMessage}}</p>
    </div>
    <script>
      const App = {
        data() {
          return {
            message: "hello world"
          };
        },
        computed: {
          reversedMessage() {
            return
             this.message.split("").reverse().join("");
          }
        }
      };
      const app = Vue.createApp(App);
      app.mount("#app");
    </script>
  </body>
</html>

在这里,我们创建了reversedMessage计算属性,它是message响应式属性的反转。我们返回了字符顺序反转后的消息。每当message响应式属性更新时,reversedMessage()方法将再次运行并返回最新的值。因此,我们可以在同一个模板中看到'hello world''dlrow olleh'。这些计算属性的返回值必须包含其他响应式属性,以便在其他响应式属性更新时它们也会更新。

指令

组件可能没有足够的能力做我们想要的事情。最重要的缺失是操作 DOM 并将输入数据与响应式属性同步的能力。指令是以v-前缀开头的特殊属性。它们期望单个 JavaScript 表达式作为值。我们已经看到一些内置指令,比如v-ifv-forv-bindv-on被用于各种目的。指令除了值之外还可以带参数。

例如,我们可以写<a v-on:click="doSomething"> ... </a>来监听锚元素上的点击事件。v-on部分是指令的名称。冒号和等号之间的部分是指令的参数,所以click是指令的参数。doSomething是指令的值。它是我们想要调用的方法的名称。

指令参数可以是动态的。要添加动态参数,我们可以将它们放在方括号之间:

<a v-bind:[attributeName]="url"> ... </a>

attributeName是我们想要用来设置参数值的响应式属性。它应该是一个字符串。我们也可以用v-on指令做同样的事情:

<a v-on:[eventName]="doSomething"> ... </a>

我们使用给定的eventName来监听事件。eventName也应该是一个字符串。

指令修饰符

指令可以带有修饰符,让我们改变指令的行为。修饰符是以点表示的特殊后缀。它们可以链接在一起以提供更多的改变。它们表示指令应该以某种特殊的方式绑定。例如,如果我们需要监听submit事件,我们可以添加prevent修饰符来调用event.preventDefault(),这将阻止默认的提交行为。我们可以通过编写以下代码来实现:

<form v-on:submit.prevent="onSubmit">...</form>

接下来,我们将看看如何使用 Vue.js Devtools 浏览器扩展轻松调试 Vue 3 项目。

使用 Vue.js Devtools 进行调试

现在,我们没有简单的方法来调试我们的应用程序。我们只能在代码中添加console.log语句来查看值。使用 Vue.js Devtools,我们可以更清晰地看到我们的应用程序。Vue.js Devtools 是一个 Chrome 或 Firefox 扩展,我们可以用它来调试我们的 Vue.js 应用程序。它可以用于使用 Vite 创建的项目,也可以通过包含 Vue 3 的script标签从头开始创建的项目。我们可以通过在各自浏览器的应用商店中搜索 Vue.js Devtools 扩展来安装该扩展。

重要提示:

安装 Chrome 版本的 Vue.js Devtools 的 URL 在chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd

Firefox 版本的插件在addons.mozilla.org/en-CA/firefox/addon/vue-js-devtools/?utm_source=addons.mozilla.org&utm_medium=referral&utm_content=search

安装完成后,我们应该在浏览器的开发控制台中看到 Vue 选项卡。通过它,我们可以检查 Vue 加载的响应式属性。如果我们的组件有一个name属性,那么它将显示在应用程序的组件树中。例如,假设我们有以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vue App</title>
    <script src="https://unpkg.com/vue@next"></script>
  </head>
  <body>
    <div id="app">
      <foo></foo>
    </div>
    <script>
      const App = {
        data() {
          return {};
        }
      };
      const app = Vue.createApp(App);
      app.component("foo", {
        data() {
          return {
            message: "I am foo."
          };
        },
        name: "foo",
        template: `<p>{{message}}</p>`
      });
      app.mount("#app");
    </script>
  </body>
</html>

在这里,由于我们将foo组件的name属性设置为'foo',我们将在组件树中看到它的列表。此外,foo组件具有message响应式属性,因此我们还将看到message属性及其值的显示。在组件树上方,有一个搜索框,让我们可以找到具有给定名称的响应式属性。我们还可以在查找组件输入框中搜索组件。

以下截图显示了我们的 Vue 3 应用程序中 Vue Devtools 扩展中的响应式属性的值:

图 1.1-使用 Vue Devtools 检查响应式属性

图 1.1-使用 Vue Devtools 检查响应式属性

还有“时间轴”菜单项,我们可以用它来检查被触发的事件。例如,假设我们有以下代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Vue App</title>
  <script src="https://unpkg.com/vue@next"></script>
</head>
<body>
  <div id="app">
    <button @click="count++">increment</button>
    count: {{ count }}
  </div>
  <script>
    const Counter = {
      data() {
        return {
          count: 0
        };
      }
    };
    Vue.createApp(Counter).mount("#app");
  </script>
</body>
</html>

当我们点击“增加”按钮时,我们将在“时间轴”部分看到鼠标事件的记录。事件触发的时间也将被记录。

全局设置部分,我们可以看到规范化组件名称设置,它让我们可以改变组件名称的显示方式。我们可以以帕斯卡命名法或短横线命名法显示原始名称。主题选项让我们可以改变 Vue 标签的主题颜色。

总结

在本章中,我们了解到 Vue 3 是一个基于组件的框架,并且我们看了组件的不同部分。我们涵盖的一个重要部分是响应式属性。它们是组件的属性,我们可以更改以更新引用响应式属性的应用程序的所有部分。这些属性可以手动监视,任何值的更改也会被 Vue 3 自动捕捉到,以便自动更新引用响应式属性的应用程序的任何部分。组件被组合在一起,以便在可能的情况下可以重复使用。

然后,我们继续了解了每个组件中模板的作用。模板也是每个组件的一部分。它们必须在屏幕上渲染出一些东西。模板可以包含 HTML 元素、其他组件和修改模板中元素和组件渲染方式的指令。模板中可以包含 JavaScript 表达式,这样我们就可以对事件做出反应。然后,我们看到了组件中计算属性的重要性。计算属性是依赖于其他响应式属性的特殊响应式属性。它们是同步的函数,并根据其他响应式属性的组合返回一个值。

我们看到的另一个重要点是内置在 Vue 3 中的v-model指令。Vue 3 提供了v-model指令,这样我们就可以将响应式属性绑定到表单控件的值上。指令是特殊的 Vue 代码,让我们可以改变 DOM 元素的渲染方式。Vue 3 提供了许多内置指令,可以做诸如从数组中渲染元素、将表单控件的值绑定到响应式属性等事情。

在最后一节中,我们学习了如何使用 Vue.js Devtools 来简化调试。这是一个适用于 Chromium 浏览器和 Firefox 的浏览器扩展,让我们可以观察组件的响应式属性值,并查看渲染的组件。它还会记录组件中元素触发的任何事件。

在下一章中,我们将学习如何构建一个简单的 GitHub 应用程序,进行 HTTP 请求。

第二章:构建 Vue 3 渐进式 Web 应用

在本章中,我们将学习如何使用 Vue 3 创建 GitHub 渐进式 Web 应用PWA)。在构建项目的过程中,我们将深入了解 Vue 应用的内部工作原理,查看基本构建块。我们将使用组件创建 Vue 应用,并在创建过程中,我们将查看组件的组成部分以及它们的工作原理。

在需要时,我们还将使用更高级的功能,比如指令。指令让我们能够操纵文档对象模型DOM)而不会使组件的代码混乱。它们为我们提供了一种干净的方式来访问 DOM 元素并以可重用的方式处理它们。这有助于简化测试,并帮助我们模块化我们的代码。

Vue 3 带有许多内置指令,我们将使用它们。在上一章中,我们简要概述了这些指令。在本章中,我们将更详细地了解它们的工作原理。这些指令提供了易于使用的抽象,使许多事情对我们来说更容易,并且是 Vue 3 的基本特性,我们无法离开它们。

我们将使用组件来显示我们想要的数据,它们将通过 props 接收输入,以便我们可以获取适当的数据并显示它。在每个组件中,我们将添加自己的方法,并利用一些组件生命周期方法。为了减少代码的重复,我们使用混入来抽象出组件中常用的功能,并将它们合并到我们的组件中。

在本章中,我们将学习以下主题:

  • 组件和 PWA 的基本理论

  • 介绍 GitHub 作品集应用程序

  • 创建 PWA

  • 提供 PWA 服务

技术要求

本章的代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter02找到。

组件和 PWA 的基本理论

在开始构建 Vue 应用之前,让我们先熟悉一下组件和 PWA。Vue 3 允许我们使用组件构建前端 Web 应用。通过组件,我们可以将应用程序分成小的、可重用的部分,然后将它们组合在一起形成一个大应用程序。这种组合是通过嵌套实现的。为了使应用程序的不同部分组合在一起,我们可以在它们之间传递数据。组件可以来自库,也可以由我们自己创建。

组件由几个部分组成;它包括模板、脚本和样式。模板是在屏幕上呈现的内容。它包含超文本标记语言HTML)元素、指令和组件。组件和 HTML 元素可以添加属性和事件监听器。属性用于从父组件传递数据到子组件。

事件监听器让我们可以监听从子组件到父组件发出的事件。事件可以携带有效负载,其中包含数据。这使我们能够实现子组件到父组件的通信。将这两者结合起来,我们就有了一个完整的系统,可以在父子组件之间进行通信。

任何非平凡的应用程序都会有多个需要相互通信的组件。

PWAs 是特殊的网络应用程序,可以安装在用户的计算机上,浏览器管理这些安装的应用程序。它们与常规网络应用程序不同,因为它们让我们可以原生地访问一些计算机硬件。当我们在浏览器中访问 PWA 时,我们可以选择安装 PWA,然后可以从应用商店中打开我们的应用程序。

PWAs 不需要特殊的捆绑或分发程序。这意味着它们可以像任何其他网络应用程序一样部署到服务器上。许多现代浏览器——如 Mozilla Firefox、Google Chrome、Apple Safari 和 Microsoft Edge——都支持 PWAs。这意味着我们可以使用它们安装应用程序。

PWAs 的特殊特性包括能够为每个用户工作,无论浏览器选择如何。它们还具有响应式,这意味着它们可以在任何设备上工作,例如台式机、笔记本电脑、平板电脑或移动设备。初始加载也很快,因为它们应该在第一次加载时被缓存。

它们也应该能够在没有互联网连接的情况下工作。服务工作者在后台运行,让我们可以在离线或低质量网络上使用 PWAs。这也是 PWAs 可用的缓存的另一个好处。

尽管 PWAs 是从浏览器中运行的,但它们的行为就像应用程序一样。它们具有类似应用程序的交互和导航样式。显示的内容也始终是最新的,因为服务工作者在后台运行以更新数据。

PWAs 的另一个重要好处是安全性。它们只能通过HTTP 安全HTTPS)提供,因此外部人员无法窥视连接。这样,我们就知道连接没有被篡改。

PWA 还支持推送通知,以便与用户互动并通知他们更新。

它们也可以从统一资源定位符URL)链接,并且 PWA 在我们可以使用它之前不需要安装过程——安装是完全可选的。安装后,它会在我们的浏览器上提供一个主屏幕图标,这样我们就可以点击它并开始使用它。

Vue 3 有一个@vue/cli-plugin-pwa插件,让我们可以在 Vue 3 项目中添加 PWA 功能,而无需进行任何手动配置。我们只需运行一个命令,所有文件和配置就会自动添加给我们。有了这个插件,我们可以使用 Vue 3 开发我们的 PWA,并且包含的服务工作者将在生产中运行。既然我们已经解决了这个问题,我们将看看如何创建可重用的组件。

介绍 GitHub 作品集应用程序

本章的主要项目是一个 GitHub 作品集应用程序。它是一个 PWA,这意味着它具有本章组件和 PWA 的基本理论部分列出的所有功能。这些功能是由@vue/cli-plugin-pwa插件自动提供的。我们可以通过一个命令添加我们需要的代码,以添加服务工作者和任何其他所需的配置。这样,当我们创建 Vue 项目时,我们就不必从头开始自己配置所有东西了。

为了开始我们的应用项目,我们将使用 Vite 来创建它。我们进入我们想要项目的文件夹,然后运行 Vite 来创建 Vue 3 应用项目。为此,我们使用Node Package Managernpm)运行以下命令:

  1. 第一个命令,在下面的代码片段中显示,运行 npm 全局安装 Vue 命令行界面CLI):
npm install -g @vue/cli@next
  1. 我们运行 Vue CLI 来创建 Vue 3 项目。我们的项目文件夹名称是vue-example-ch2-github-app。需要运行以下命令来创建项目文件夹,并添加所有文件和设置,以便我们不必自己添加它们。这个命令进入我们刚创建的项目文件夹,并在询问时选择 Vue 3 项目:
npm vue create vue-example-ch2-github-app 
  1. 然后,我们运行以下命令来运行开发服务器,这样我们就可以在浏览器中看到项目,并在编写代码时刷新应用程序预览:
npm run serve

或者,我们可以使用另一种资源协商器YARN)运行以下命令:

  1. 我们运行yarn global add来全局安装 Vue CLI,如下所示:
yarn global add @vue/cli@next
  1. 要创建 Vue 3 项目,我们运行以下命令,并在被询问时选择 Vue 3 项目:
yarn create vue-example-ch2-github-app
  1. 然后,我们运行以下命令来启动开发服务器,这样我们就可以在浏览器中看到项目,并在编写代码时刷新应用程序预览:
yarn serve

所有前述命令都是相同的,它们都以相同的方式创建项目;只是我们想要使用哪个包管理器来创建我们的 Vue 3 项目的问题。此时,项目文件夹将包含我们的 Vue 3 项目所需的文件。

我们的 GitHub 作品集应用是一个渐进式 Web 应用程序,我们可以使用现有的 Vue CLI 插件轻松创建这个应用程序。创建项目后,我们可以开始创建我们的 Vue 3 PWA。

创建 PWA

首先,我们需要一种简单的方式通过其表述状态转移REST应用程序编程接口API)访问 GitHub 数据。幸运的是,一位名为Octokit的开发人员制作了一个 JavaScript 客户端,让我们可以使用我们创建的访问令牌访问 GitHub REST API。我们只需要从内容分发网络CDN)导入该包,就可以从浏览器中访问 GitHub REST API。它还有一个 Node 包,我们可以安装和导入。然而,Node 包只支持 Node.js 应用程序,因此无法在我们的 Vue 3 应用程序中使用。

Vue 3 是一个客户端 Web 框架,这意味着它主要在浏览器上运行。我们不应该混淆只在 Node 上运行的包和支持浏览器的包,否则当我们在浏览器中使用不受支持的包时,就会出现错误。

要开始,我们对现有文件进行一些更改。首先,我们从index.css中删除样式代码。在这个项目中,我们专注于应用程序的功能,而不是样式。此外,我们将index.html文件中的标题标签内文本重命名为GitHub App

然后,为了使我们构建的应用成为 PWA,我们必须运行另一个命令来添加服务工作者,以整合诸如硬件访问支持、安装和离线使用支持等功能。为此,我们使用@vue/cli-plugin-pwa插件。我们可以通过运行以下命令来添加这个插件:

vue add pwa

这将添加我们需要整合的所有文件和配置,使我们的 Vue 3 项目成为 PWA 项目。

Vue CLI 创建了一个使用单文件组件并对大部分应用程序使用ECMAScript 6ES6)模块的 Vue 项目。当我们构建项目时,这些文件被捆绑在一起,然后在 Web 服务器上提供并在浏览器上运行。使用 Vue CLI 创建的项目以main.js作为入口点,它运行创建 Vue 应用所需的所有代码。

我们的main.js文件应包含以下代码:

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

该文件位于src文件夹的根目录,Vue 3 将在应用程序首次加载或刷新时自动运行此文件。createApp函数将通过传入入口点组件来创建 Vue 3 应用程序。入口点组件是我们首次加载应用程序时首先运行的组件。在我们的项目中,我们导入了App并将其传递给createApp

此外,index.css文件是从同一文件夹导入的。这是我们应用程序的全局样式,这是可选的,所以如果我们不想要任何全局样式,我们可以省略它。然后导入registerServiceWorker.js文件。仅使用文件名导入意味着文件中的代码直接运行,而不是我们从模块中导入任何内容。

registerServiceWorker.js文件应包含以下代码:

/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
...
    offline () {
      console.log('No internet connection found. App is running          in offline mode.')
    },
    error (error) {
      console.error('Error during service worker          registration:', error)
    }
  })
}

这是我们运行vue add pwa时创建的。如果应用程序处于production模式,我们调用register函数来注册服务工作者。当我们运行npm run build命令时,服务工作者将被创建,我们可以使用创建的服务工作者让用户从我们提供的构建代码中访问功能,例如缓存和硬件访问。服务工作者仅在production模式下创建,因为我们不希望在开发环境中缓存任何内容。我们始终希望看到显示最新数据,以便我们可以创建代码并调试它,而不会被缓存所困扰。

我们需要做的另一件事是从src/components文件夹中删除HelloWorld.vue组件,因为我们的应用程序不需要这个。我们稍后还将删除App.vue中对HelloWorld组件的任何引用。

现在我们已经对现有代码文件进行了编辑,我们可以创建新文件。为此,我们执行以下步骤:

  1. components文件夹中,我们添加了一个repo文件夹;在repo文件夹中,我们添加了一个issue文件夹。在repo文件夹中,我们添加了Issues.vue组件文件。

  2. components/repo/issue文件夹中,我们添加Comments.vue文件。Issues.vue用于显示 GitHub 代码存储库的问题。Comments.vue用于显示添加到代码存储库问题的评论。

  3. components文件夹本身,我们添加GitHubTokenForm.vue文件以便我们输入和存储 GitHub 令牌。

  4. 我们还将Repos.vue文件添加到相同的文件夹中,以显示 GitHub 访问令牌所指向的用户的代码存储库。最后,我们将User.vue文件添加到components文件夹中,以便显示用户信息。

  5. src文件夹中创建一个mixins文件夹以添加一个 mixin,让我们使用 GitHub 访问令牌创建 Octokit GitHub 客户端。

我们将octokitMixin.js文件添加到mixins文件夹中以添加空的 mixin。现在,我们将它们全部留空,因为我们准备添加文件。

为我们的应用程序创建 GitHub 客户端

我们通过创建 GitHub Client对象来启动项目,该对象将在整个应用程序中使用。

首先,在src/mixins/octokitMixin.js文件中,我们添加以下代码:

import { Octokit } from "https://cdn.skypack.dev/@octokit/rest";
export const octokitMixin = {
  methods: {
    createOctokitClient() {
      return new Octokit({
        auth: localStorage.getItem("github-token"),
      });
    }
  }
}

上述文件是一个 mixin,它是一个我们合并到组件中以便我们可以在组件中正确使用它的对象。Mixin 具有与组件相同的结构。添加methods属性以便我们可以创建并合并到组件中的方法。为了避免命名冲突,我们应该避免在我们的组件中命名任何方法为createOctokitClient,否则我们可能会得到意外的错误或行为。createOctokitClient()方法使用 Octokit 客户端通过获取github-token本地存储项来创建客户端,然后将其设置为auth属性。auth属性是我们的 GitHub 访问令牌。

Octokit构造函数来自我们从github.com/octokit/rest.js/releases?after=v17.1.0添加的octokit-rest.min.js文件。我们找到v16.43.1标题,点击Assets,下载octokit-rest.min.js文件,并将其添加到public文件夹中。然后,在public/index.html中,我们添加一个script标签来引用该文件。我们应该在index.html文件中有以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-
      width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <script src="<%= BASE_URL %>octokit-rest.min.js">
      </script>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.
          options.title %> doesn't work properly without 
           JavaScript enabled. Please enable it to 
            continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

添加问题和评论的显示

然后,在src/components/repo/issue/Comments.vue文件中,我们添加以下代码:

<template>
  <div>
    <div v-if="comments.length > 0">
      <h4>Comments</h4>
      <div v-for="c of comments" :key="c.id">
        {{c.user && c.user.login}} - {{c.body}}
      </div>
    </div>
  </div>
...
        repo,
        issue_number: issueNumber,
      });
      this.comments = comments;
    }
  },
  watch: {
    owner() {
      this.getIssueComments();
    },
    repo() {
      this.getIssueComments();
    },
    issueNumber() {
      this.getIssueComments();
    }
  }
};
</script>

在这个组件中,我们有一个template部分和一个script部分。script部分包含了从问题中获取评论的逻辑。name属性包含了我们组件的名称。我们可以在其他组件中使用这个名称来引用我们的组件。props属性包含了组件接受的 props,如下面的代码片段所示:

  props: {
    owner: {
      type: String,
      required: true,
    },
    repo: {
      type: String,
      required: true,
    },
    issueNumber: {
      type: Number,
      required: true,
    },
  },

该组件接受ownerrepoissueNumber三个 props。我们使用一个对象来定义 props,这样我们可以通过type属性轻松验证类型。ownerrepo的类型值为String,因此它们必须是字符串。issueNumber属性的类型值设置为Number,因此它必须是一个数字。

required属性被设置为true,这意味着当我们在另一个组件中使用Comments组件时,必须设置这个prop

data()方法用于返回一个具有响应式属性初始值的对象。comments响应式属性的初始值设置为空数组。

mixins属性让我们设置要合并到我们应用程序中的 mixin。由于octokitMixin有一个methods属性,其中的内容将被添加到我们组件的methods属性中,以便我们可以直接调用组件,就像我们将在这个组件的methods属性中所做的那样。

我们将 mixin 合并到我们的组件对象中,如下所示:

mixins: [octokitMixin],

methods属性中,我们的Comments组件有一个方法。我们使用getIssueComments()方法来获取问题的评论。其代码如下所示:

  ...
  methods: {  
    ...
    async getIssueComments(owner, repo, issueNumber) {
      if (
        typeof owner !== "string" ||
        typeof repo !== "string" ||
        typeof issueNumber !== "number"
      ) {
        return;
      }
      const octokit = this.createOctokitClient();
      const { data: comments } = await 
        octokit.issues.listComments({
        owner,
        repo,
        issue_number: issueNumber,
      });
      this.comments = comments;
    },
    ...
  }
  ...
}

我们需要ownerrepoissueNumber属性。owner参数是拥有存储库的用户的用户名,repo参数是存储库名称,issueNumber参数是问题的问题编号。

我们检查每个类型,以确保它们是我们期望的类型,然后才使用octokit.issue.listComments()方法发出获取问题的请求。Octokit 客户端是通过我们的 mixin 的createOctokitClient()方法创建的。listComments()方法返回一个解析带有评论数据的问题的 promise。

之后,我们有 watch 属性来添加我们的监视器。属性的键是我们正在监视的 props 的名称。每个对象都有一个 immediate 属性,它使监视器在组件加载时立即开始监视。handler 方法具有在 prop 值更改或组件加载时运行的处理程序,因为我们将 immediate 属性设置为 true

我们从此处的属性中传入所需的值,以及 val 来调用 getIssueComments() 方法。val 参数具有我们正在监视的任何 prop 的最新值。这样,如果我们设置了所有 prop 的值,我们总是可以获得最新的评论。

在模板中,我们通过引用 comments 响应式属性来加载评论。值是由在监视器中运行的 getIssueComments() 方法设置的。使用 v-for 指令,我们循环遍历每个项目并呈现值。c.user.login 属性具有发布评论的用户的用户名,c.body 具有评论的正文。

接下来,我们将以下代码添加到 src/components/Issues.vue 文件中:

...
<script>
import { octokitMixin } from "../../mixins/octokitMixin";
import IssueComments from "./issue/Comments.vue";
export default {
  name: "RepoIssues",
  components: {
    IssueComments,
  },
  props: {
    owner: {
      type: String,
      required: true,
    },
    repo: {
      type: String,
      required: true,
    },
  },
  mixins: [octokitMixin],
  ...
};
</script>

上述代码为显示问题添加了一个组件。在 Comments.vue 组件中我们有类似的代码。我们使用相同的 octokitMixin 混合来整合来自混合的 createOctokitClient() 方法。

不同之处在于我们有 getRepoIssues() 方法来获取给定 GitHub 存储库的问题,而不是给定问题的评论,并且我们有两个 props 而不是三个。ownerrepo props 都是字符串,并且我们以相同的方式将它们设置为必需的并验证它们的类型。

data() 方法中,我们有 issues 数组,当我们调用 getRepoIssues 时设置。这在以下代码片段中显示:

src/components/Issues.vue

  data() {
    return {
      issues: [],
      showIssues: false,
    };
  },

octokit.issues.listForRepo() 方法返回一个解析给定存储库的问题的 promise。showIssue 响应式属性让我们切换是否显示问题。

我们还有获取 GitHub 问题的方法,如下面的代码片段所示:

src/components/Issues.vue

  methods: {
    async getRepoIssues(owner, repo) {
      const octokit = this.createOctokitClient();
      const { data: issues } = await 
        octokit.issues.listForRepo({
        owner,
        repo,
      });
      this.issues = issues;
    },
  },

showIssues 响应式属性由 显示问题 按钮控制。我们使用 v-if 指令在 showIssues 响应式属性为 true 时显示问题。外部的 div 标签用于检查问题的长度属性,这样当长度大于 0 时,我们只显示 显示问题 按钮和问题列表。

该方法由观察者触发,如下所示:

src/components/Issues.vue

  watch: {
    owner: {
      handler(val) {
        this.getRepoIssues(val, this.repo);
      },
    },
    repo: {
      handler(val) {
        this.getRepoIssues(this.owner, val);
      },
    },
  },
  created () {
    this.getRepoIssues(this.owner, this.repo);
  }

components属性中,我们将导入的IssueComments组件(之前创建的组件)放入我们的组件对象中。如果我们将组件放入components属性中,那么它将在组件中注册,我们可以在模板中使用它。

接下来,我们按如下方式将模板添加到文件中:

src/components/Issues.vue

<template>
  <div v-if="issues.length > 0">
    <button @click="showIssues = !showIssues">{{showIssues 
       ? 'Hide' : 'Show'}} issues</button>
    <div v-if="showIssues">
      <div v-for="i of issues" :key="i.id">
        <h3>{{i.title}}</h3>
        <a :href="i.url">Go to issue</a>
        <IssueComments :owner="owner" :repo="repo" 
          :issueNumber="i.number" />
      </div>
    </div>
  </div>
</template>

当我们使用v-for指令时,需要包括key属性,以便正确显示条目,以便 Vue 3 跟踪它们。key的值必须是唯一的 ID。我们在模板中引用了我们注册的IssueComments组件,并向其传递了props:符号是v-bind指令的简写,表示我们正在向组件传递 props,而不是设置属性。

让用户使用 GitHub 令牌访问 GitHub 数据

接下来,我们将在src/components/GitHubTokenForm.vue文件上进行工作,如下所示:

<template>
  <form @submit.prevent="saveToken">
    <div>
      <label for="githubToken">Github Token</label>
      <br />
      <input id="githubToken" v-model="githubToken" />
    </div>
    <div>
      <input type="submit" value="Save token" />
      <button type="button" @click="clearToken">Clear token
         </button>
...
    clearToken() {
      localStorage.clear();
    },
  },
};
</script>

我们有一个表单,其中有一个输入框,让我们输入 GitHub 访问令牌。这样,我们可以在提交表单时保存它。此外,我们还有一个类型为submit的输入框。它的value属性显示为提交按钮的文本。我们还有一个按钮,可以让我们清除令牌。@submit.prevent指令让我们运行saveToken提交处理程序,并同时调用event.preventDefault()@符号是v-on指令的简写,它监听表单发出的提交事件。

文本输入框具有v-model指令,将输入值绑定到githubToken响应式属性。为了使我们的输入对屏幕阅读器可访问,我们有一个带有for属性的标签,引用输入框的 ID。标签之间的文本显示在标签中。

表单提交后,saveToken()方法将运行,将输入的值保存到本地存储中,键为github-token字符串。created()方法是一个生命周期钩子,让我们可以从本地存储中获取值。通过访问具有github-token键的项目,可以获取保存的令牌。

clearToken()方法用于清除令牌,并在单击清除令牌按钮时运行。

接下来,我们将以下代码添加到src/components/Repos.vue组件中:

<template>
  <div>
    <h1>Repos</h1>
    <div v-for="r of repos" :key="r.id">
      <h2>{{r.owner.login}}/{{r.name}}</h2>
      <Issues :owner="r.owner.login" :repo="r.name" />
    </div>
  </div>
</template>
<script>
import Issues from "./repo/Issues.vue";
import { octokitMixin } from "../mixins/octokitMixin";
export default {
  name: "Repos",
  components: {
    Issues,
  },
  data() {
    return {
      repos: [],
    };
  },
  mixins: [octokitMixin],
  async mounted() {
    const octokit = this.createOctokitClient();
    const { data: repos } = await 
       octokit.request("/user/repos");
    this.repos = repos;
  },
};
</script>

我们使用octokit.request()方法向 GitHub REST API 的/user/repos端点发出请求。再次,octokit对象是使用之前使用的相同的 mixin 创建的。我们注册Issues组件,以便我们可以使用它来显示代码存储库的问题。我们循环遍历repos响应式属性,该属性被分配了octokit.request()方法的值。

数据在模板中呈现。r.owner.login属性具有 GitHub 存储库所有者的用户名,r.name属性具有存储库名称。我们将这两个值作为 props 传递给Issues组件,以便Issues组件加载给定存储库的问题。

同样,在src/components/User.vue文件中,我们编写以下代码:

<template>
  <div>
    <h1>User Info</h1>
    <ul>
      <li>
        <img :src="userData.avatar_url" id="avatar" />
      </li>
      <li>username: {{userData.login}}</li>
      <li>followers: {{userData.followers}}</li>
      <li>plan: {{userData.pla && userData.plan.name}}</li>
    </ul>
  </div>
...
    const { data: userData } = await 
      octokit.request("/user");
    this.userData = userData;
  },
};
</script>
<style scoped>
#avatar {
  width: 50px;
  height: 50px;
}
</style>

scoped关键字意味着样式仅应用于当前组件。

该组件用于显示我们可以从 GitHub 访问令牌访问的用户信息。我们使用相同的 mixin 为 Octokit 客户端创建octokit对象。通过调用request()方法,向用户端点发出请求以获取用户数据。

然后,在模板中,我们使用avatar_url属性显示用户数据。username.login属性具有令牌所有者的用户名,userData.followers属性具有用户的关注者数量,userData.plan.name属性具有计划名称。

最后,为了将整个应用程序放在一起,我们在App.vue组件中使用GitHubTokenFormUserRepo组件。App.vue组件是加载应用程序时加载的root组件。

src/App.vue文件中,我们编写以下代码:

<template>
  <div>
    <h1>Github App</h1>
    <GitHubTokenForm />
    <User />
    <Repos />
  </div>
</template>
<script>
import GitHubTokenForm from "./components/GitHubTokenForm.vue";
import Repos from "./components/Repos.vue";
import User from "./components/User.vue";
export default {
  name: "App",
  components: {
    GitHubTokenForm,
    Repos,
    User,
  },
};
</script>

我们通过将它们放在components属性中注册所有三个组件来注册它们。然后,我们在模板中使用它们。

现在,我们应该看到以下屏幕:

图 2.1 - 仓库列表

图 2.1 - 仓库列表

我们看到显示的存储库列表,如果有为它们记录的问题,我们会看到显示问题按钮,让我们看到给定存储库的任何问题。这可以在以下截图中看到:

图 2.2 - 显示问题按钮

图 2.2 - 显示问题按钮

我们可以点击隐藏问题来隐藏它们。如果有任何评论,那么我们应该在问题下面看到它们。

提供 PWA

现在我们已经构建了应用程序,我们可以提供它,以便我们可以在浏览器中安装它。让我们开始,如下所示:

  1. 要构建该应用程序,我们运行以下命令:
npm run build
  1. 我们可以使用browser-sync包,通过运行以下命令来安装它:
npm install –g browser-sync

上述命令将安装一个 Web 服务器。

  1. 我们可以进入dist文件夹,其中包含构建的文件,并运行browser-sync来提供 PWA。

  2. 现在,要运行应用程序,我们需要从我们的 GitHub 帐户获取 GitHub 身份验证令牌。如果您没有 GitHub 帐户,那么您将不得不注册一个。

  3. 一旦我们创建了一个帐户,那么我们就可以获得令牌。要获取令牌,请登录到您的 GitHub 帐户。

  4. 前往github.com/settings/tokens

  5. 页面加载完成后,点击个人访问令牌链接。

  6. 点击生成新令牌以生成令牌。一旦创建,将令牌复制到某个地方,以便我们可以通过在应用程序中输入它来使用它。

我们应该看到类似这样的东西:

图 2.3 – 获取令牌的屏幕

图 2.3 – 获取令牌的屏幕

  1. 一旦您获得了令牌,返回到我们在浏览器中加载的应用程序。

  2. 将令牌输入到GitHub Token输入中,点击保存令牌,然后刷新页面。如果有任何存储库以及相关问题和评论,它们应该显示在页面中。

  3. 一旦我们在浏览器中,我们应该在 URL 栏的右侧看到一个加号(+)标志。这个按钮让我们安装 PWA。

  4. 一旦我们安装它,我们应该在主屏幕上看到它。我们可以转到chrome://apps URL,以查看我们刚刚安装的应用程序,如下截图所示:图 2.4 – 我们 PWA 中的 GitHub 存储库列表

图 2.4 – 我们 PWA 中的 GitHub 存储库列表

  1. 如果您使用的是 Chrome 或任何其他 Chromium 浏览器,如 Edge,您可以按下F12打开开发者控制台。

  2. 点击应用程序选项卡,然后点击左侧的服务工作者链接,让我们测试服务工作者,如下截图所示:图 2.5 – 应用程序选项卡中的服务工作者部分

图 2.5 – 应用程序选项卡中的服务工作者部分

  1. 我们可以选中离线复选框,模拟它在离线时的行为。选中重新加载时更新将在刷新页面时重新加载应用程序,并获取最新的数据。URL 应该与您的应用程序运行的 URL 相同。这是我们的 GitHub PWA 注册的服务工作者。

  2. 注销链接将注销服务工作者。当我们再次运行应用程序时,应该重新注册它。

我们现在已经完成了使用 Vue 3 创建我们的渐进式 Web 应用程序。我们可以在浏览器中安装它,然后像设备上的任何其他应用程序一样使用它。

总结

通过构建 GitHub PWA,我们学会了如何创建可重用的组件。我们还研究了如何添加 props,以便从父组件向子组件传递数据。在子组件中,我们通过检查数据类型并指定 prop 是否必需来验证 props。这样,我们可以轻松地看到 prop 是否具有意外的值。

我们还研究了如何使用观察者来监视响应式属性值的变化。观察者可以添加以监视任何响应式属性的变化。我们可以监视本地被改变的数据,也可以监视 props 的值。它们都是响应式的,因此它们都会触发观察者方法。我们可以在观察者中运行异步代码,这是计算属性无法做到的。

此外,我们还研究了组件的生命周期钩子。每个组件也有自己的生命周期钩子。我们可以向生命周期方法中添加我们自己的代码,以便在需要时运行代码。组件生命周期的各个阶段都有生命周期钩子,包括加载时的开始阶段,更新和销毁。

最后,我们学会了如何使用命令行插件将我们的 Vue 3 web 应用程序转换为 PWA。我们可以向我们的 Vue 项目添加插件来创建 PWA。通过它,服务工作者将在我们的应用程序中注册,以处理不同的连接类型和缓存。

在下一章中,我们将使用 Vue 3 创建一个滑块拼图,并编写自动化测试来测试应用程序的每个部分。

第三章:使用测试构建滑块拼图游戏

在上一章中,我们使用 Vue 创建了一个简单的 GitHub 应用,并添加了一些组件。在本章中,我们将构建一个简单的滑块拼图游戏。游戏的目标是重新排列图片的部分,直到它看起来像我们期望的样子。它将有一个计时器来计算经过的时间,并在屏幕上显示出来。一旦我们正确地重新排列了图像的部分,我们将看到一个“你赢了”的消息,并且如果它是前 10 名最快的时间,经过的时间将被记录在本地存储中。我们有多个拼图可以选择,这样我们的游戏就会更加有趣。这比只有一个拼图更有趣。

为了构建应用程序,我们将构建具有计算属性和计时器的组件来计算经过的时间。此外,一些组件将从本地存储中获取和设置数据。每当我们从本地存储中获取数据时,结果将被显示出来。我们将使用本地存储来存储最快的时间。本地存储只能存储字符串,因此我们将把结果转换为字符串并存储起来。

我们将使用计时器来计时玩家赢得游戏的时间,并使用计算属性来确定玩家赢得游戏的时间。此外,为了确保我们的游戏能够正常运行,我们将为每个部分添加单元测试,以自动测试每个组件。

在本章中,我们将深入研究组件,并涵盖以下主题:

  • 理解组件和混合的基础知识

  • 设置我们的 Vue 项目

  • 创建用于洗牌图片的组件

  • 让用户重新排列幻灯片

  • 根据时间计算得分

  • 使用 Jest 进行单元测试

技术要求

本章的源代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter03

理解组件和混合的基础知识

组件还有比我们在第二章中所做的更多,构建一个 Vue 3 渐进式 Web 应用,来创建 GitHub 渐进式 Web 应用。这些组件是最基本的部分。我们将在我们的组件中使用定时器,而不仅仅是获取数据并显示它的组件。此外,我们将看看何时以及如何使用计算属性,以便我们可以创建从其他响应式属性派生值的响应式属性。这样可以避免创建我们不需要的额外方法或不必要地使用指令。

此外,我们将看看如何使用计算属性来返回从其他响应式属性派生的值。计算属性是返回值的方法,这些值是从一个或多个其他响应式属性派生而来的。它们本身也是响应式属性。它们最常见的用法是作为 getter。然而,计算属性既可以有 getter 也可以有 setter。它们的返回值被缓存,以便在一个或多个响应式属性的值更新之前不会运行。它们可用于以高效的方式替换复杂的模板表达式和方法。

组件还可以发出自定义事件。一个事件可以包含一个或多个与事件一起发出的有效负载。它们有自己的事件名称,我们可以通过使用v-on指令来监听事件。我们可以使用$event变量或事件处理程序方法的参数来获取发出的有效负载。

Vue 3 应用的另一个重要部分是测试。当我们提到测试时,通常是指自动化测试。测试有许多形式,对捕捉各种类型的错误都很有用。它们经常用于捕捉回归,即在我们更改已经成为应用一部分的代码后创建的错误。我们可以通过几种测试来检查回归。我们可以创建的最小测试是单元测试,它测试一个组件及其部分的隔离。它通过在测试环境中挂载我们的组件来工作。任何阻止我们的测试隔离运行的依赖项都被模拟,以便我们可以在隔离环境中运行我们的测试。这样,我们可以在任何环境和任何顺序下运行我们的测试。

每个测试都是独立的,所以我们不应该在任何地方运行它们时出现任何问题,即使没有互联网连接。这很重要,因为它们应该是可移植的。此外,诸如 API 数据和定时器之类的外部资源非常不稳定。它们也是异步的,这使它们难以测试。因此,我们必须确保我们的测试不需要它们,因为我们希望结果是一致的。

Vue 支持 JavaScript 测试框架,如JestMocha。这是使用 Vue CLI 创建 Vue 项目的一个巨大好处。我们不必自己创建所有测试代码的脚手架。

另一种测试是端到端测试。这些测试模拟用户如何使用我们的应用程序。我们通常会创建一个从头开始然后关闭的环境来运行这些测试。这是因为我们希望我们的测试中始终有新鲜的数据。测试必须能够以一致的方式运行。如果我们要像用户一样使用应用程序,我们需要一致的数据来完成这项工作。

在本章中,我们将主要关注前端应用程序的单元测试。它们可以提供类似于端到端测试的 DOM 交互,但它们更快速,体积更小。它们也运行得更快,因为我们不必每次运行测试时都创建一个干净的环境。环境的创建和用户交互测试总是比单元测试慢。因此,我们应该有许多单元测试和少量端到端测试,用于测试应用程序最关键的部分。

设置 Vue 项目

现在我们已经学会了计算属性和 getter 和 setter 的基础知识,我们准备深入研究我们将需要的组件部分并创建项目。

要创建项目,我们再次使用 Vue CLI。这次,我们必须选择一些选项,而不是选择默认选项。但在这之前,我们将创建一个名为vue-example-ch3-slider-puzzle的项目文件夹。然后,我们必须进入文件夹并使用npm运行以下命令:

  1. 首先,我们必须全局安装 Vue CLI,以便我们可以使用它创建和运行我们的项目:
npm install -g @vue/cli@next
  1. 现在,我们可以进入我们的项目文件夹并运行以下命令来创建我们的项目:
vue create .

同样地,我们可以使用 Yarn 运行以下命令:

  1. 首先,我们必须全局安装 Vue CLI,以便我们可以使用它创建和运行我们的项目:
yarn global add @vue/cli@next
  1. 然后,我们可以进入我们的项目文件夹并运行以下命令来创建我们的项目:
yarn create .

无论哪种情况,我们都应该看到 Vue CLI 命令行程序并获得如何选择项目的说明。如果我们被问及是否要在当前文件夹中创建项目,我们可以输入Y并按Enter来执行。然后,我们应该看到我们可以使用的项目类型,我们应该选择Manually select features,然后选择Vue 3来创建一个 Vue 3 项目:

图 3.1 - 在 Vue CLI 向导中创建项目类型的选择

图 3.1 - 在 Vue CLI 向导中创建项目类型的选择

在下一个屏幕上,我们应该看到我们可以添加到项目中的内容。选择Unit Testing,然后您需要选择Testing with Jest,这样我们就可以为我们的应用程序添加测试。

一旦我们完成了编写代码,这个项目将为许多组件提供测试:

图 3.2 - 我们应该为这个项目选择的选项

图 3.2 - 我们应该为这个项目选择的选项

一旦我们让 Vue CLI 完成项目的创建,我们应该在src文件夹中看到代码文件。测试应该在tests/unit文件夹中。Vue CLI 为我们节省了大量精力,因为我们不需要自己编写测试代码。它带有一个我们可以扩展的示例测试。

一旦我们选择了这些选项,我们就可以开始创建我们的应用程序。在这个项目中,我们将从 Unsplash 获取一些图片,该网站为我们提供了免版税的图片。然后,我们将获取这些图片并将它们分成九个部分,以便我们可以在slider puzzle组件中显示它们。我们需要整张图片和切割后的部分。在这个例子中,我们将从以下链接获取图片:

当我们进入每个页面时,我们必须点击下载按钮来下载图片。一旦我们下载了图片,我们必须转到www.imgonline.com.ua/eng/cut-photo-into-pieces.php自动将图片切成九块。

section 1中,我们选择我们的图片文件。在section 2中,我们将宽度分成的部分高度分成的部分都设置为3。这样,我们可以将我们的图片分成九个部分。一旦我们做到了这一点,我们就可以下载生成的 ZIP 文件,然后将所有的图片提取到一个文件夹中。这应该对每个图片都重复进行。

一旦我们有了所有的整个和切割的图像片段,我们应该把它们都放到我们刚刚创建的 Vue 3 项目文件夹的src/assets文件夹中。这样,我们就可以从我们的应用程序访问并显示这些图像。第一张图片显示了一朵粉色的花,所以整个图片被命名为pink.jpg,切割后的图片在cut-pink文件夹中。生成的切割图片的文件名保持不变。第二张图片是一朵紫色的花,所以整个图片被命名为purple.jpg,切割后的图片文件夹被命名为cut-purple。第三张图片是一朵红色的花。因此,它被命名为red.jpg,包含图像切割片段的文件夹被命名为cut-red

现在我们已经处理了图片,我们可以创建我们的组件。

首先,我们必须从src/components文件夹中删除HelloWorld.vue,因为我们不再需要它了。我们还必须从App.vue文件中删除对它的任何引用。

接下来,在components文件夹中,我们必须创建Puzzles.vue文件,以便让我们选择拼图。它有一个模板,这样我们就可以显示我们选择的拼图。在component options对象中,我们有一个包含要显示的拼图数据的数组。此外,我们有一个方法,让我们向我们的父组件发出事件,即App.vue组件。这样,我们就可以在我们将创建的滑块拼图组件中显示正确的拼图。为此,在src/components/Puzzles.vue中,我们必须添加以下模板代码:

<template>
  <div>
    <h1>Select a Puzzle</h1>
    <div v-for="p of puzzles" :key="p.id" class="row">
      <div>
        <img :src="require(`../assets/${p.image}`)" />
      </div>
      <div>
        <h2>{{p.title}}</h2>
      </div>
      <div class="play-button">
        <button @click="selectPuzzle(p)">Play</button>
      </div>
    </div>
  </div>
</template>

然后,我们必须添加以下脚本和样式标签:

<script>
export default {
  data() {
    return {
      puzzles: [
        { id: 'cut-pink', image: "pink.jpg", title: "Pink 
          Flower" },
        { id: 'cut-purple', image: "purple.jpg", title: 
          "Purple Flower" },
        { id: 'cut-red', image: "red.jpg", title: "Red 
          Flower" },
      ],
    };
  },
...
<style scoped>
.row {
  display: flex;
  max-width: 90vw;
  flex-wrap: wrap;
  justify-content: space-between;
}
.row img {
  width: 100px;
}
.row .play-button {
  padding-top: 25px;
}
</style>

在脚本标签之间,我们有component options对象,其中包含data()方法,以及在脚本标签之间的拼图的响应属性。它有一个包含idimagetitle属性的对象数组。id属性是一个唯一的 ID,我们在使用v-for指令渲染条目时使用它。我们还向App.vue发出 ID,这样我们就可以从那里将其作为属性传递给我们的滑块拼图组件。title是我们以人类可读的方式在模板中显示的标题。

methods属性中,我们有一个selectPuzzle()方法,它接受谜题对象作为参数。它调用this.$emit来触发 puzzle-changed 事件。第一个参数是name。第二个参数是我们想要在事件中触发的payload属性。我们可以通过在父组件中为元素添加v-on指令来监听事件,无论这个组件在哪里被引用。

在模板中,我们使用h1组件显示titlev-for指令循环遍历谜题的array响应属性中的项目并显示它们。像往常一样,我们需要为每个条目设置key属性,以便为 Vue 3 正确跟踪值设置唯一 ID。我们还必须添加一个class属性,以便我们可以样式化行。要显示图像,我们可以调用require,这样 Vue 3 可以直接解析路径。Vue CLI 使用 Webpack,因此它可以将图像作为模块加载。我们可以将其设置为src属性的值,它将显示图像。我们加载整个图像并显示它们。

此外,在行中,我们有一个按钮,当我们点击它时调用selectPuzzle()方法。这将设置谜题的选择并将其传播到我们将创建的滑块谜题组件,以便我们可以看到正确的谜题显示。

.row img select的宽度设置为100px,以显示整个图像的缩略图。此外,我们可以以一种与其他子元素对齐的方式显示按钮。

接下来,我们必须创建src/components/Records.vue文件,以添加一个包含速度记录的组件。这提供了一个最快完成游戏的时间列表。最快的时间记录存储在本地存储中,以便轻松访问。在这个组件中,我们只是显示组件。

要创建这个组件,我们必须在src/components/Records.vue中编写以下代码:

<template>
  <div>
    <h1>Records</h1>
    <button @click="getRecords">Refresh</button>
    <div v-for="(r, index) of records" :key="index">{{
      index + 1}} - {{r.elapsedTime}}</div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      records: [],
    };
  },
  created() {
    this.getRecords();
  },
  methods: {
    getRecords() {
      const records = JSON.parse(localStorage.getItem(
        "records")) || [];
      this.records = records;
    },
  },
};
</script>

component对象中,我们有getRecords()方法,它从本地存储中获取最快的时间记录。localStorage.getItem()方法通过其键获取数据。参数是映射到我们想要获取的数据的键。它返回一个包含数据的字符串。因此,为了将字符串转换为对象,我们必须调用JSON.parse将 JSON 字符串解析为对象。它应该是一个数组,因为我们将创建一个数组并将其字符串化为 JSON 字符串,然后记录它。本地存储只能保存字符串;因此,这是一个必需的步骤。

一旦我们从本地存储中检索到记录,我们可以将其设置为this.records响应式属性的值。如果本地存储中没有带有records键的项目,我们必须将默认值设置为空数组。这样,我们总是将一个数组分配给this.records

此外,我们还有beforeMount钩子,它让我们在组件挂载之前获取记录。这样,当组件挂载时,我们将看到记录。

在模板中,我们使用v-for指令显示速度记录,以循环遍历项目并显示它们。数组条目中的v-for指令在括号中有第一个项目。括号中的第二个项目是索引。我们可以将key属性设置为索引,因为它们是唯一的,而且我们不会移动条目。我们在列表中显示两者。

此外,我们有一个按钮,当我们点击它时调用getRecords方法以获取最新条目。

现在我们已经创建了最简单的组件,我们可以继续创建滑块拼图组件。

创建洗牌图片的组件

滑块拼图游戏提供了滑块拼图,玩家将拼图洗牌成图片以赢得比赛,显示经过的时间,重新排列拼图的逻辑,检查我们是否赢得比赛的逻辑,以及计算自游戏开始以来经过的时间的计时器。

为了轻松计算经过的时间,我们可以使用moment库。要安装该库,我们可以运行npm install moment。一旦我们安装了包,我们就可以开始编写必要的代码。

让我们创建src/components/SliderPuzzle.vue文件。该文件的完整代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter03/src/components/SliderPuzzle.vue找到。

我们将首先通过script标签创建组件:

<script>
import moment from "moment";
const correctPuzzleArray = [
  "image_part_001.jpg",
  "image_part_002.jpg",
  "image_part_003.jpg",
  "image_part_004.jpg",
  "image_part_005.jpg",
  "image_part_006.jpg",
  "image_part_007.jpg",
  "image_part_008.jpg",
  "image_part_009.jpg",
];
...
</script>

首先,我们导入moment库来计算经过的时间。接下来,我们定义correctPuzzleArray变量,并将其分配给一个具有文件正确顺序的数组。我们根据这个数组来确定玩家是否赢得了比赛。

然后,我们开始创建组件选项的对象。props属性包含我们自己的属性。puzzleId是一个包含玩家正在玩的谜题的 ID 的字符串。我们必须确保它是一个字符串。我们将其默认值设置为'cut-pink',这样我们就始终有一个谜题集。

data()方法包含我们的初始状态。我们返回一个包含它们的对象。这样,我们可以确保响应属性的值始终与我们应用程序中的其他组件隔离。correctPuzzleArray响应属性就是我们之前定义的。我们只是将其设置为一个属性,使其成为一个响应属性。这使它可以与我们的isWinning计算属性一起使用,因为我们希望在此数组更新时更新值:

<script>
...
export default {
  name: "SliderPuzzle",
  props: {
    puzzleId: {
      type: String,
      default: "cut-pink",
    },
  },
  data() {
    return {
      correctPuzzleArray,
      shuffledPuzzleArray: [...correctPuzzleArray].sort(
        () => Math.random() - 0.5
      ),
      indexesToSwap: [],
      timer: undefined,
      startDateTime: new Date(),
      currentDateTime: new Date(),
    };
  },
  ...
};
</script>

shuffledPuzzleArraycorrectPuzzleArray响应属性的副本,但项目被洗牌,以便玩家必须重新排列项目才能赢得游戏。为了创建属性的值,首先我们必须用扩展运算符复制correctPuzzleArray数组。然后,我们必须使用callback调用sortcallback是一个使用Math.random()生成介于-0.50.5之间的数字的函数。我们需要一个在这个范围内的随机数,以便值随机排序。callback是一个比较函数。它可以接受两个参数;也就是说,前一个和当前数组条目,这样我们就可以比较它们:

<script>
...
export default {
  ...
  computed: {
    isWinning() {
      for (let i = 0; i < correctPuzzleArray.length; i++) {
        if (correctPuzzleArray[i] !== 
          this.shuffledPuzzleArray[i]) {
          return false;
        }
      }
      return true;
    },
    elapsedDiff() {
      const currentDateTime = moment(this.currentDateTime);
      const startDateTime = moment(this.startDateTime);
      return currentDateTime.diff(startDateTime);
    },
    elapsedTime() {
      return moment.utc(this.elapsedDiff).format(
        "HH:mm:ss");
    },
  },
};
</script>

由于我们是随机排序物品,所以不需要进行任何比较。如果比较器回调返回负数或0,则项目的顺序不变。否则,我们要排序的数组中的项目顺序会改变。sort()方法返回一个按顺序排列的新数组。

indexesToSwap响应属性用于添加我们想要交换的图像文件名的索引。当我们点击swap()方法时,我们向indexesToSwap响应属性推送一个新值,这样当indexesToSwap数组中有两个项目时,我们就可以交换这两个项目。

timer响应属性可能包含由setInterval函数返回的计时器对象。setInterval函数让我们周期性地运行代码。它接受一个包含我们想要运行的代码的回调作为第一个参数。第二个参数是回调之间的时间间隔,以毫秒为单位。

startDateTime响应属性包含游戏开始时的日期和时间。它是一个包含当前时间的Date实例。currentDateTime响应属性具有当前日期和时间的Date实例。随着游戏在我们传递给setInterval函数的callback属性中进行处理,它会被更新。

data()方法包含了所有响应式属性的初始值。

computed属性包含了计算属性。计算属性是同步函数,返回一些基于其他响应式属性的值。计算属性本身也是响应式属性。当计算属性函数中引用的响应式属性更新时,它们的值也会更新。在这个组件中,我们定义了三个计算属性:isWinningelapsedDiffelapsedTime

isWinning计算属性是包含游戏状态的属性。如果它返回true,那么玩家赢得了游戏。否则,玩家还没有赢得游戏。为了检查玩家是否赢得了游戏,我们循环遍历correctPuzzleArray响应式属性,并检查它的每个条目是否与shuffledPuzzleArray响应式属性数组中的条目相同。

correctPuzzleArray包含了正确的项目列表。因此,如果shuffledPuzzleArray数组的每个项目与correctPuzzleArray中的条目匹配,那么我们知道玩家已经赢了。否则,玩家还没有赢。因此,如果correctPuzzleArrayshuffledPuzzleArray之间有任何差异,那么它返回 false。否则,返回 true。

elapsedDiff计算属性计算了经过的时间(毫秒)。这是我们使用moment库从startDateTimecurrentDateTime计算经过时间的地方。我们使用moment库来进行这个计算,因为它让我们的工作变得更容易。它有一个diff()方法,我们可以用它来计算这个和另一个moment对象之间的差异。以毫秒为单位返回差异。

一旦我们计算出elapsedDiff计算属性,我们就可以使用它来使用moment格式化经过的时间为人类可读的时间格式,即 HH:mm:ss。elapsedTime计算属性返回一个字符串,其中包含格式化后的经过时间。moment.utc()方法是一个函数,它接受一个 UTC 时间段,然后返回一个moment对象,我们可以调用format()方法来计算时间。

现在我们已经定义了所有的响应式和计算属性,我们可以定义我们的方法,这样我们就可以将幻灯片重新排列成正确的图片。

重新排列幻灯片

我们可以通过编写以下代码为SliderPuzzle.vue组件添加所需的methods

<script>
...
export default {
  ...
  methods: {
    swap(index) {
      if (!this.timer) {
        return;
      }
      if (this.indexesToSwap.length < 2) {
        this.indexesToSwap.push(index);
      }
      if (this.indexesToSwap.length === 2) {
...
      this.resetTime();
      clearInterval(this.timer);
    },
    resetTime() {
      this.startDateTime = new Date();
      this.currentDateTime = new Date();
    },
    recordSpeedRecords() {
      let records = JSON.parse(localStorage.getItem(
        "records")) || [];
...
      localStorage.setItem("records", stringifiedRecords);
    },
  },
};
</script>

逻辑定义在methods属性中。我们有swap()方法让我们交换切割图像幻灯片。start()方法让我们将响应式属性重置为初始状态,洗牌切割照片幻灯片,然后启动计时器计算经过的时间。我们还在每次运行计时器代码时检查玩家是否获胜。stop()方法让我们停止计时器。resetTime()方法让我们将startDateTimecurrentDateTime重置为它们的当前日期时间。recordSpeedRecords()方法让我们记录玩家赢得游戏所花费的时间,如果他们进入前 10 名。

我们从逻辑上交换幻灯片开始,定义swap()方法。它接受一个参数,即我们想要交换的幻灯片之一的索引。当玩家点击幻灯片时,将调用此方法。这样,我们将要与另一个幻灯片交换的项目之一的索引添加到indexesToSwap计算属性中。因此,如果玩家点击两张幻灯片,它们的位置将彼此交换。

swap()方法体检查indexesToSwap响应式属性是否包含少于两个幻灯片索引。如果少于两个,则调用push将幻灯片追加到indexesToSwap数组中。接下来,如果indexesToSwap响应式属性数组中有索引,则进行交换。

为了进行交换,我们从indexToSwap响应式属性中解构索引。然后,我们再次使用解构赋值进行交换:

[this.shuffledPuzzleArray[index1], this.shuffledPuzzleArray[index2]] = [this.shuffledPuzzleArray[index2], this.shuffledPuzzleArray[index1]];

要交换数组中的项目,我们只需将shuffledPuzzleArrayindex2分配给index1的项目。然后,原本在shuffledPuzzleArrayindex1处的项目以相同的方式放入shuffledPuzzleArrayindex2槽中。最后,我们确保清空indexesToSwap数组,以便让玩家交换另一对幻灯片。由于shuffledPuzzleArray是一个响应式属性,它会随着模板中的v-for指令更新而自动呈现在模板中。

start()方法让我们可以启动计时器,计算从点击开始按钮开始游戏到游戏结束或用户点击退出按钮时的经过时间。首先,该方法通过将这些值设置为当前日期时间来重置startDateTimecurrentDateTime响应式属性,我们通过实例化Date构造函数来获取当前日期时间。然后,我们通过复制correctPuzzleArray,然后调用 sort 来对correctPuzzle数组的副本进行排序来洗牌幻灯片。此外,我们将indexesToSwap属性设置为空数组,以清除任何已存在的项目,使我们可以重新开始。

一旦我们完成了所有重置,我们就可以调用setInterval来启动计时器。这将使用当前日期和时间更新currentDateTime响应式属性,以便我们可以计算elapsedDiffelapsedTime计算属性。接下来,我们检查isWinning响应式属性是否为 true。如果是,那么我们就调用this.recordSpeedRecords方法来记录玩家获胜时的最快时间。

如果玩家获胜,即isWinningtrue,我们还可以调用stop()方法来停止计时器。stop()方法只是调用resetTime()方法来重置所有时间。然后,它调用clearInterval来清除计时器。

要显示滑块拼图,我们可以添加template标签:

<template>
  <div>
    <h1>Swap the Images to Win</h1>
    <button @click="start" id="start-button">Start 
      Game</button>
    <button @click="stop" id="quit-button">Quit</button>
    <p>Elapsed Time: {{ elapsedTime }}</p>
    <p v-if="isWinning">You win</p>
    <div class="row">
      <div
        class="column"
        v-for="(s, index) of shuffledPuzzleArray"
        :key="s"
        @click="swap(index)"
      >
        <img :src="require(`../assets/${puzzleId}/${s}`)" 
          />
      </div>
    </div>
  </div>
</template>

然后,我们可以通过编写以下代码来添加所需的样式:

<style scoped>
.row {
  display: flex;
  max-width: 90vw;
  flex-wrap: wrap;
}
.column {
  flex-grow: 1;
  width: 33%;
}
.column img {
  max-width: 100%;
}
</style>

styles标签中,我们有用于样式化滑块拼图的样式。我们需要滑块拼图,这样我们就可以在一行中显示三个幻灯片,总共三行。这样,我们可以在 3x3 的网格中显示所有幻灯片。row类的属性设置为flex,这样我们就可以使用 flexbox 来布局幻灯片。我们还将flex-wrap属性设置为wrap,这样我们就可以将任何溢出的项目包装到下一行。max-width设置为90vw,这样滑块拼图网格就会保持在屏幕上。

column类的flex-grow属性设置为1,这样它就是在一行中显示的三个项目之一。

在模板中,我们使用h1元素显示游戏的title。我们有一个开始游戏按钮,当我们点击按钮开始游戏计时器时,它调用start()方法。此外,我们有一个退出按钮,当我们点击它停止计时器时,它调用stop()方法。elapsedTime计算属性显示方式与其他响应属性相同。如果用户获胜,即isWinning响应属性返回 true,我们将看到'You Win'消息。

要显示幻灯片,我们只需使用v-for指令循环遍历所有shuffledPuzzleArray响应属性,并呈现所有幻灯片。当我们点击每个幻灯片时,将调用swap()方法并传入索引。一旦我们在indexesToSwap响应属性中有两个索引,我们就交换幻灯片。由于它们是唯一的,所以将key属性设置为文件名。要显示幻灯片图像,我们使用图像路径调用require,以便显示图像。

由于我们有 flexbox 样式来以三行三列的方式显示项目,所有九个图像将自动显示在 3x3 的网格中。现在我们已经完成了滑块拼图游戏逻辑,我们只需要添加记录时间得分的逻辑到本地存储中。

根据时间计算得分

这是在recordSpeedRecords()方法中完成的。它通过从本地存储中获取具有记录的本地存储项来获取记录。然后,我们获取elapsedTimeelapsedDiff响应属性的值,并将它们推入records数组中。

接下来,我们使用sort()方法对记录进行排序。这一次,我们不是随机排序项目,而是按照elapsedDiff响应属性的时间跨度进行排序,该时间以毫秒为单位。我们传入一个带有ab参数的回调函数,它们分别是先前和当前的数组条目,然后返回它们之间的差异。这样,如果它返回一个负数或 0,那么它们之间的顺序不变。否则,我们交换顺序。然后,我们调用slice方法,使用第一个和最后一个索引来包含它在分配给sortedRecords常量的返回数组中。slice()方法返回一个包含第一个索引的项目一直到最后一个索引减去 1 的数组。

最后,我们使用JSON.stringify()方法将数组stringify为字符串,将sortedRecords数组转换为字符串。然后,我们调用localStorage.setItem将该项放入具有'records'键的项中。

最后,我们必须将App.vue文件的内容更改为以下内容:

<template>
  <div>
    <Puzzles @puzzle-changed="selectedPuzzleId = $event" />
    <Records />
    <SliderPuzzle :puzzleId="selectedPuzzleId" />
  </div>
</template>
<script>
import SliderPuzzle from "./components/SliderPuzzle.vue";
import Puzzles from "./components/Puzzles.vue";
import Records from "./components/Records.vue";
export default {
  name: "App",
  components: {
    SliderPuzzle,
    Puzzles,
    Records,
  },
  data() {
    return {
      selectedPuzzleId: "cut-pink",
    };
  },
};
</script>

我们将之前创建的组件添加到屏幕上进行渲染。selectedPuzzleId默认情况下具有我们选择的拼图的 ID。

现在我们已经有了所有的代码,如果我们还没有运行过项目,我们可以在项目文件夹中运行npm run serve来运行项目。然后,当我们访问 Vue CLI 指示的 URL 时,我们将看到以下内容:

图 3.3 - 滑块拼图游戏的屏幕截图

图 3.3 - 滑块拼图游戏的屏幕截图

现在我们已经完成了 Web 应用的代码,我们必须找到一种简单的方法来测试它的所有部分。

使用 Jest 进行单元测试

测试是任何应用程序的重要部分。当我们提到测试时,通常指的是自动化测试。这些是我们可以快速重复运行的测试,以确保我们的代码没有出错。当任何测试失败时,我们知道我们的代码没有像以前那样工作。要么我们创建了一个 bug,要么测试已经过时。因为我们可以快速运行它们,所以我们可以编写许多测试并在构建代码时运行它们。

这比手动测试要好得多,手动测试必须由一个人一遍又一遍地执行相同的操作。手动测试对测试人员来说很无聊,容易出错,而且非常慢。这对任何人来说都不是一种愉快的体验。因此,最好尽可能多地编写自动化测试,以最小化手动测试。

如果按照 Vue CLI 中显示的说明进行操作,很容易在不进行任何额外工作的情况下添加骨架测试代码。单元测试文件应该会自动生成给我们。我们的代码中应该有一个tests/unit文件夹,用于将我们的测试代码与我们的生产代码分开。

Jest是一个 JavaScript 测试框架,我们可以用它来运行单元测试。它为我们提供了一个有用的 API,让我们描述我们的测试组并定义我们的测试。此外,我们还可以轻松地模拟通常使用的任何外部依赖项,如定时器、本地存储和状态。要模拟localStorage依赖项,我们可以使用jest-localstorage-mock包。我们可以通过运行npm install jest-localstorage-mock –save-dev来安装它。–save-dev标志让我们将包保存为开发依赖项,因此它只会安装在开发环境中,而不会安装在其他地方。此外,在package.json文件中,我们将添加一个jest属性作为root属性。为此,我们可以编写以下代码:

"jest": {
"setupFiles": [
"jest-localstorage-mock"
  ]
}

我们在package.json中有这些属性,这样当我们运行我们的测试时,localStorage依赖项将被模拟,以便我们可以检查它的方法是否已被调用。连同其他属性一起,我们的package.json文件应该看起来像以下内容:

{
  "name": "vue-example-ch3-slider-puzzle",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "³.6.5",
    "lodash": "⁴.17.20",
    "moment": "².28.0",
    "vue": "³.0.0-0"
  },
  "devDependencies": {
...
    "eslint-plugin-vue": "⁷.0.0-0",
    "jest-localstorage-mock": "².4.3",
    "typescript": "~3.9.3",
    "vue-jest": "⁵.0.0-0"
  },
  "jest": {
    "setupFiles": [
      "jest-localstorage-mock"
    ]
  }
}

完成后,我们可以添加我们的测试。

为 Puzzles.vue 组件添加测试

首先,我们必须从tests/unit文件夹中删除现有文件。然后,我们可以开始编写我们的测试。我们可以先为Puzzles.vue组件编写测试。为此,我们必须创建tests/unit/puzzles.spec.js文件并编写以下代码:

import { mount } from '@vue/test-utils'
import Puzzles from '@/components/Puzzles.vue'
describe('Puzzles.vue', () => {
  it('emit puzzled-changed event when Play button is 
    clicked', () => {
    const wrapper = mount(Puzzles)
    wrapper.find('.play-button button').trigger('click');
    expect(wrapper.emitted()).toHaveProperty('puzzle-
      changed');
  })
  it('emit puzzled-changed event with the puzzle ID when 
    Play button is clicked', () => {
    const wrapper = mount(Puzzles)
    wrapper.find('.play-button button').trigger('click');
    const puzzleChanged = wrapper.emitted('puzzle-
      changed');
    expect(puzzleChanged[0]).toEqual([wrapper.vm.puzzles[0].id]
 );
  })
})

describe函数接受一个字符串,其中包含测试组的描述。第二个参数是一个包含测试的回调函数。describe函数创建一个块,将几个相关的测试组合在一起。它的主要目的是使测试结果在屏幕上更容易阅读。

it()函数让我们描述我们的测试。它也被称为test()方法。它的第一个参数是测试的name属性,以字符串形式表示。第二个参数是带有测试代码的回调函数。它还接受一个可选的第三个参数,其中包含毫秒为单位的timeout,以便我们的测试不会永远运行下去。默认超时时间为 5 秒。

如果从ittest函数返回一个promise,Jest 将等待promise解析完成后再完成测试。如果我们在ittest函数中提供一个参数,通常称为done,Jest 也会等待。如果在ittest回调中添加了done参数,则调用done函数表示测试已完成。

ittest函数不一定要在我们传递给describe的回调函数内部。它也可以被独立调用。然而,最好将相关的测试与describe一起分组,这样我们可以更容易地阅读结果。

第一个测试测试了当点击播放按钮时,会发出puzzle-changed事件。正如我们从Puzzles.vue组件中所看到的,puzzle-changed事件是通过this.$emit()方法发出的。为了创建我们的测试,我们调用mount来挂载我们的组件。它以我们要测试的组件作为参数。它还接受第二个参数,其中包含我们想要覆盖的组件选项的对象。在这个测试中,因为我们没有覆盖任何内容,所以我们没有传入任何东西作为第二个参数。

mount()方法返回wrapper对象,这是我们正在测试的组件的wrapper对象。它有一些方便的方法,我们可以用来进行测试。在这个测试中,我们调用find()方法来获取具有给定选择器的 HTML 元素。它返回 HTML DOM 对象,我们将调用trigger()方法来触发我们在测试中想要的事件。

这样,我们可以触发键盘和鼠标事件,以模拟用户交互。因此,以下代码用于获取具有.play-button button选择器的元素,然后触发其上的点击事件:

wrapper.find('.play-button button').trigger('click');

测试的最后一行用于检查是否发出了puzzle-changed事件。emitted()方法返回一个具有名称的属性的对象。这些是发出的事件的事件名称。toHaveProperty()方法让我们检查作为参数传入的属性名称是否在返回的对象中。这是由expect()方法返回的对象的属性。

在第二个测试中,我们再次挂载组件并在同一元素上触发click事件。然后,我们使用事件名称调用emitted()方法,以便通过返回的对象获取与事件一起发出的有效负载。puzzleChanged数组包含作为第一个元素发出的有效负载。然后,为了检查是否发出了puzzles[0].id属性,我们在最后一行进行检查。wrapper.vm属性包含挂载的组件对象。因此,wrapper.vm.puzzlesPuzzles组件的拼图的响应属性。因此,这意味着我们正在检查Puzzles组件中拼图的响应属性的id属性是否已发出。

为 Records 组件添加测试

接下来,我们必须为Records组件编写测试。为此,我们必须创建tests/unit/records.spec.js文件,并编写以下代码:

import { shallowMount } from '@vue/test-utils'
import 'jest-localstorage-mock';
import Records from '@/components/Records.vue'
describe('Records.vue', () => {
  it('gets records from local storage', () => {
    shallowMount(Records, {})
    expect(localStorage.getItem).       toHaveBeenCalledWith('records');
  })
})

这是我们使用jest-localstorage-mock包的地方。我们只需导入包文件;然后,文件中的代码将运行并为我们模拟localStorage依赖项。在测试中,我们调用shallowMount来挂载我们的Records组件,然后我们可以检查localStorage.getItem是否使用'records'参数调用。使用jest-localstorage-mocks包,我们可以直接传递localStorage.getItem以期望它进行检查。toHaveBeenCalledWith()方法让我们检查它所调用的参数。

由于我们在beforeMount()方法中调用了localStorage.getItem()方法,因此这个测试应该通过,因为我们在加载组件时调用了它。

为 SliderPuzzle 组件添加测试

最后,我们必须为SliderPuzzle组件编写一些测试。我们将添加tests/unit/sliderPuzzle.spec.js文件,并编写以下代码:

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  it('inserts the index of the image to swap when we click 
    on an image', () => {
    const wrapper = mount(SliderPuzzle)
    wrapper.find('#start-button').trigger('click')
...
    expect(firstImage).toBe(newSecondImage);
    expect(secondImage).toBe(newFirstImage);
  })
  ...
  })
  afterEach(() => {
    jest.clearAllMocks();
  });
})

在“在单击图像时插入要交换的图像的索引”测试中,我们挂载SliderPuzzle组件,然后在img元素上触发click事件。img元素是滑块拼图的第一张幻灯片。应调用swap()方法,以便indexesToSwap响应属性具有添加的第一张图像的索引。toBeGreaterThan()方法让我们检查我们期望的返回值是否大于某个数字。

在“当点击 2 个图像时交换图像顺序”测试中,我们再次挂载SliderPuzzle组件。然后,我们获取wrapper.vm.shuffledPuzzleArray以获取早期数组中的索引并解构它们的值。我们将在稍后使用它来比较来自同一数组的值,以查看在我们点击了两个图像后它们是否已经被交换。

接下来,我们使用wrapper.get()方法触发幻灯片上的点击,以获取图像元素。然后,我们调用trigger()方法来触发点击事件。接着,我们检查在交换完成后indexesToSwap响应属性的长度是否为0。然后,在最后三行中,我们再次从wrapper.vm.shuffledPuzzleArray中获取项目并比较它们的值。由于条目在两个幻灯片之后应该被交换,我们有以下代码来检查交换是否真的发生了:

expect(firstImage).toBe(newSecondImage);
expect(secondImage).toBe(newFirstImage);

在“启动方法调用时启动计时器”测试中,我们再次挂载SliderPuzzle组件。这次,我们调用start()方法来确保计时器实际上是通过setInterval创建的。我们还检查setInterval函数是否以函数和 1,000 毫秒的方式调用。为了让我们轻松测试任何与计时器有关的内容,包括测试任何调用setTimeoutsetInterval的内容,我们调用jest.useFakeTimers()来模拟这些函数,以便我们的测试不会干扰其他测试的操作:

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  ...
  it('starts timer when start method is called', () => {
    const wrapper = mount(SliderPuzzle);
    wrapper.vm.start();
    expect(setInterval).toHaveBeenCalledTimes(1);
    expect(setInterval).toHaveBeenLastCalledWith(expect.any(
      Function), 1000);
  })
  ...
  afterEach(() => {
    jest.clearAllMocks();
  });
})

toHaveBeenCalledTimes()方法检查我们传递给expect()方法的函数是否被调用了指定次数。由于我们调用了jest.useFakeTimers()setInterval实际上是真正setInterval函数的一个间谍,而不是真正的版本。我们只能在函数与expecttoHaveBeenCalledTimestoHaveBeenCalledWith一起使用间谍。所以,我们的代码将起作用。toHaveBeenLastCalledWith()方法用于检查我们的函数间谍被调用的参数类型。我们确保第一个参数是一个函数,第二个参数是 1,000 毫秒。

在“停止方法调用时停止计时器”测试中,我们通过挂载组件然后调用stop()方法来做类似的事情。我们确保在调用stop()方法时实际上调用了clearInterval

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  ...
  it('stops timer when stop method is called', () => {
    const wrapper = mount(SliderPuzzle);
    wrapper.vm.stop();
    expect(clearInterval).toHaveBeenCalledTimes(1);
  })
  it('shows the elapsed time', () => {
    const wrapper = mount(SliderPuzzle, {
      data() {
        return {
          currentDateTime: new Date(2020, 0, 1, 0, 0, 1),
          startDateTime: new Date(2020, 0, 1, 0, 0, 0),
        }
      }
    });
    expect(wrapper.html()).toContain('00:00:01')
  })
  ...
  afterEach(() => {
    jest.clearAllMocks();
  });
})

接下来,我们添加'将记录保存到本地存储'的测试。我们再次利用jest-localstorage-mock库来模拟localStorage依赖项。在这个测试中,我们以不同的方式挂载SliderPuzzle组件。第二个参数是一个包含data()方法的对象。这是我们在组件的options对象中拥有的data()方法。我们用传入的值覆盖了组件原始的响应式属性值,以便设置日期,以便我们可以对其进行测试。

然后,我们调用wrapper.vm.recordSpeedRecords()方法来测试是否调用了localStorage.setItem()方法。我们调用了挂载组件中的方法。然后,我们创建了stringifiedRecords JSON 字符串,以便我们可以将其与localStrorage.setItem调用进行比较。toHaveBeenCalledWith只适用于localStorage.setItem,因为我们导入了jest-localstorage-mock库来从实际的localStorage.setItem()方法创建一个间谍。这让 Jest 可以检查方法是否被调用以及给定的参数。

为了测试当点击开始按钮时计时器是否启动,我们有'点击开始按钮启动计时器'的测试。我们只需使用get()方法按其 ID 获取开始按钮,并在其上触发click事件。然后,我们检查setInterval函数是否被调用。与localStorage一样,我们使用jest.useFakeTimers()方法模拟setInterval函数,以从实际的setInterval函数创建一个间谍。这让我们可以检查它是否被调用。

类似地,我们有'点击退出按钮停止计时器'的测试,以检查是否在点击退出按钮时调用了clearInterval函数。

最后,我们有'显示经过的时间'的测试,以使用不同的值挂载组件的currentDateTimestartDateTime响应式属性。它们被设置为我们想要的值,并且它们在测试中保持不变。然后,为了检查elapsedTime计算属性是否正确显示,我们调用wrapper.html()方法来返回包装组件中呈现的 HTML,并检查其中是否包含我们正在寻找的经过的时间字符串。

为了在每个测试后清理模拟,以便在每个测试后重新开始,我们调用 jest.clearAllMocks() 方法来清除每个测试后的所有模拟。afterEach 函数接受一个在每个测试完成后运行的回调函数。

运行所有测试

运行测试,我们运行 npm run test:unit。通过这样做,我们会看到类似以下的内容:

图 3.4 - 我们的单元测试结果

图 3.4 - 我们的单元测试结果

由于所有测试都通过了,我们项目中的代码正在按照我们的预期运行。运行所有测试只需要大约 4 秒,比手动测试我们的代码要快得多。

总结

在本章中,我们通过在组件中定义计算属性来更深入地了解组件。此外,我们为组件添加了测试,以便可以单独测试组件的各个部分。通过 Vue CLI,在我们的应用程序中轻松添加了测试文件和依赖项。

在我们的组件内部,我们可以使用 this.$emit() 方法发出传播到父组件的事件。它接受一个事件名称的字符串。其他参数是我们希望从父组件传递到子组件的有效负载。

为了向我们的 Vue 3 应用程序添加单元测试并运行测试,我们使用了 Jest 测试框架。Vue 3 为 Jest 添加了自己特定的 API,以便我们可以使用它来测试 Vue 3 组件。为了测试组件,我们使用 mountshallowMount 函数来挂载组件。mount 函数让我们挂载组件本身,包括嵌套组件。shallowMount 函数只挂载组件本身,而不包括子组件。它们都返回我们组件的 wrapper,以便我们可以使用它与组件交互进行测试。

我们应该确保我们的测试是独立运行的。这就是为什么我们要模拟外部依赖关系。我们不希望运行任何需要外部测试和项目代码可用的代码。此外,如果需要,我们必须确保在测试中清理任何依赖关系。如果有任何模拟,我们必须清理它们,以便它们不会传递到另一个测试中。否则,我们可能会有依赖于其他测试的测试,这会使故障排除测试变得非常困难。

在下一章中,我们将学习如何创建一个照片库应用程序,通过将要保存的数据发送到后端 API 来保存数据。我们将介绍使用 Vue Router,以便我们可以导航到不同的页面。

第四章:构建照片管理桌面应用程序

到目前为止,我们只构建了使用 Vue 3 的 Web 应用程序。在本章中,我们将使用 Vue Electron 插件构建照片管理桌面应用程序。我们将学习如何使用 Electron 和 Vue 轻松构建跨平台桌面应用程序。这很有用,因为我们可以在不费太多额外努力的情况下构建跨平台桌面应用程序。这将节省我们时间并获得良好的结果。

在本章中,我们将重点关注以下主题:

  • 理解组件

  • 使用 Vue CLI 插件 Electron Builder 创建项目

  • 添加照片提交 UI

  • 添加照片显示

  • 为照片管理器应用程序添加路由

  • 使用我们的应用程序与照片管理 API

技术要求

要理解本章,您应该已经知道如何做以下事情:

  • 创建基本的 Vue 组件

  • 使用 Vue CLI 创建项目

您可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter04找到本章的所有代码。

理解组件

组件内部只能有这么多东西。它们从父组件中获取 props,因此我们可以定制它们的行为。此外,它们可以具有计算属性和观察者来监视响应式属性并返回数据或执行我们希望它们执行的操作。它们还可以具有允许我们对其进行特定操作的方法。组件应该简单;也就是说,它们内部不应该有太多东西。模板中应该只有少量元素和组件,以保持其简单。组件还具有一些内置指令,供我们操纵文档对象模型DOM)并进行数据绑定。

除此之外,组件无法做太多事情。如果我们只有几个组件而且没有 URL 导航的方式,那么构建任何非平凡复杂度的东西将是不可能的。

如果我们的应用程序只有组件,那么在它变得太复杂之前,我们只能嵌套其中的一些组件。此外,如果我们有大量嵌套的组件,那么导航将变得困难。大多数应用程序有不同的页面,如果没有某种路由机制,就无法进行导航。

使用 Vue Router,我们可以在转到特定 URL 时呈现我们想要的组件。我们还可以使用 Vue Router 提供的router-link组件导航到路由。Vue Router 具有许多功能。它可以将 URL 与路由匹配。URL 可以具有查询字符串和 URL 参数。此外,我们可以使用它添加嵌套路由,以便我们可以将路由嵌套在不同的组件内。映射到 URL 的组件显示在 Vue Router 的router-view组件中。如果我们有嵌套路由,那么我们需要在父路由中使用router-view组件。这样,子路由将被显示。

为了导航到不同的路由,Vue Router 提供了 JavaScript History API 的包装器,该 API 内置在几乎所有现代浏览器中。使用此 API,我们可以轻松返回到不同的页面,转到历史记录,或者转到我们想要的 URL。Vue Router 还支持 HTML5 模式,以便我们可以拥有不带井号的 URL,以将其与服务器端呈现的路由区分开来。

此外,支持过渡效果,当我们在不同路由之间导航时,我们可以看到这些效果。当链接是已导航到并且处于活动状态时,样式也可以应用于链接。

理解 Vue CLI 插件 Electron Builder

我们可以使用 Electron 将 JavaScript 客户端 Web 应用程序转换为桌面应用程序。Vue CLI 插件 Electron Builder 使我们能够在桌面应用程序中构建 Vue 3 应用程序的文件和设置,而无需进行太多手动更改。基本上,Electron 应用程序是在 Chromium 浏览器包装器内运行的 Web 应用程序,它显示我们的 Web 应用程序。因此,它可以做任何我们需要它做的事情,使用浏览器。这包括一些有限的硬件交互,例如使用麦克风和摄像头。此外,它提供一些本机功能,例如在流行操作系统的通知区域中显示项目和显示本机通知。它旨在成为从不需要低级硬件访问的 Web 应用程序构建桌面应用程序的简单方法。

Vue CLI 插件 Electron Builder 是从 Vue 应用程序创建 Electron 应用程序的最快方式,因为它支持一些本地代码。我们还可以在我们的代码中包含本地模块;我们只需包含我们想要包含的具有本地代码的 Node.js 模块的位置。此外,我们可以包含环境变量,以便根据不同的环境构建我们的代码。由于这是由 Chromium 支持的,因此还支持 Web workers。我们可以使用它来运行后台任务,这样当我们想要运行长时间运行或 CPU 密集型任务时,就不必阻塞主浏览器线程。所有这些都可以通过常规 Vue 应用程序代码或通过配置更改来完成。Electron 支持的其他功能也不受 Vue CLI 插件 Electron Builder 支持。这意味着它在功能上更有限。使用此插件时,诸如本地菜单之类的功能是不可用的。但是,我们可以在多个平台上构建桌面应用程序。

在本章中,我们将构建一个在 Windows 上运行的照片管理器桌面应用程序。该应用程序将包括一个页面,用于显示您添加的所有照片。此外,它将允许用户添加照片并存储它们;它将具有一个表单,使用户能够添加照片。我们将使用 Vue Router 让我们手动或自动地浏览页面。

使用 Vue CLI 插件 Electron Builder 创建项目

使用 Vue 3 和 Vue CLI 插件 Builder 创建项目是一项简单的任务。执行以下步骤:

  1. 要创建项目,请创建一个名为vue-example-ch4-photo-manager的项目文件夹。然后,进入文件夹并使用npm运行以下命令:
npm install -g @vue/cli@next
npm vue create

或者,您可以使用yarn运行以下命令:

yarn global add @vue/cli@next
yarn create

运行完命令后,您应该会看到一个菜单,其中列出了您可以创建的项目类型。

  1. 选择默认的 Vue 3 项目选项来创建一个 Vue 3 项目。然后,将 Vue CLI 插件 Electron Builder 添加到我们的 Vue 应用程序中。

  2. 要添加 Vue CLI Electron Builder 插件,请在项目文件夹中运行vue add electron-builder。运行cd <folder path>以导航到文件夹。所有文件和设置都将被添加到项目中。

运行命令后,我们应该在项目中看到一些新的东西。现在我们有了 background.js 文件,其中包含显示 Electron 窗口的代码。我们还在 package.json 文件中添加了一些新的脚本命令。electron:build 脚本命令允许我们为生产环境构建应用程序。electron:server 命令允许我们使用开发服务器为 Electron 应用程序提供服务,以便我们可以使用它开发我们的应用程序。我们自动获得了热重载功能,以便我们可以在浏览器和 Electron 桌面应用程序窗口中实时看到所有更改。由于它只是浏览器的包装器,我们可以在应用程序的桌面窗口中看到最新的更改。

在桌面窗口中,我们还应该看到与浏览器中相同的 Chromium 开发控制台。为了使调试更容易,我们建议在浏览器窗口中进行调试,因为我们可以检查元素并在开发控制台中进行任何想要的操作。

background.js 文件的内容可以在 github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter04/src/background.js 找到。

BrowserWindow 构造函数默认创建一个宽度为 800 像素,高度为 600 像素的浏览器窗口。我们可以通过拖动窗口来改变窗口大小,就像我们在其他桌面应用程序中所做的那样。win.loadURL() 方法加载我们应用的主页,也就是 index.html 文件。index.html 文件位于 dist-electron 文件夹中。否则,我们调用 win.loadURL() 方法来加载 webpack-dev-server 的 URL,以便在开发环境中在窗口中查看应用程序。这是因为 webpack-dev-server 只在开发环境中运行时才将应用程序加载到内存中。

win.webContents.openDevTools() 方法在开发环境中打开 Chromium 开发控制台。app.quit() 方法退出桌面应用程序。当程序在 Windows 下运行时,我们监听消息事件,如 process.platform === 'win32' 表达式所示。否则,Electron 监听 SIGTERM 事件,并在发出该事件时关闭窗口。当我们结束程序时,将发出 SIGTERM 事件。

要更改窗口的标题,我们只需更改 public/index.html 文件中的 title 标签。例如,我们可以写如下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,       initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title>Photo App</title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options
        .title %> doesn't work properly without JavaScript 
          enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

我们只需将title标签的内容更改为我们想要的内容,文本就会显示为窗口的标题。

然后,一旦我们运行vue add electron-builder命令,我们就会添加文件和设置。要启动开发服务器并在屏幕上显示 Electron 应用程序,我们运行yarn electron:servenpm run electron:serve命令,这些命令由 Vue CLI 插件 Electron Builder 提供。您应该在屏幕上看到一个窗口显示(请参阅图 4.1)。当我们对现有文件进行任何更改或添加或删除文件时,它将自动刷新。现在我们几乎准备好开始构建我们的应用程序了:

图 4.1 – Electron 应用程序窗口

图 4.1 – Electron 应用程序窗口

我们将安装一些我们在 Vue 应用程序中需要使用的包,以及 Vue Router 库以向我们的应用程序添加路由。我们将用它来进行路由链接,也可以通过编程方式进行导航。我们还将使用 Axios HTTP 客户端轻松地向我们的 API 发送 HTTP 请求。要安装这些包,我们可以运行npm install axios vue-router@4.0.0-beta.9yarn add axios vue-router@4.0.0-beta.9命令。

我们安装 Axios,这样我们就可以方便地进行 HTTP 请求。现在我们准备构建我们的应用程序。

添加照片提交界面

要构建应用程序,我们首先将添加我们的组件和文件以存储我们在整个应用程序中需要的常量。首先,我们删除components/HelloWorld.vue文件。然后,我们删除App.vue文件中对它的任何引用,包括importcomponents属性以注销该组件。现在我们将向我们的应用程序添加一些新组件。

首先,我们将PhotoFormPage.vue组件文件添加到src/components文件夹中。在文件中,我们添加以下模板:

<template>
  <div class="form">
    <h1>{{ $route.params.id ? "Edit" : "Add" }} Photo</h1>
    <form @submit.prevent="submit">
      <div>
        <label for="name">Name</label>
        <br />
        <input
          type="text"
          v-model="form.name"
          name="name"
          id="name"
          class="form-field"
        />
...
        <label for="dateTaken">Date Taken</label>
        <br />
        <input
          type="datetime-local"
          name="dateTaken"
          id="dateTaken"
          v-model="form.dateTaken"
        />
      </div>
      ...
    </form>
  </div>
</template>

此模板具有用于添加和编辑照片的输入。namedescription是文本输入。Date Taken字段是日期输入。

然后,我们将照片字段添加为文件输入:

<template>
  <div class="form">
    <h1>{{ $route.params.id ? "Edit" : "Add" }} Photo</h1>
    <form @submit.prevent="submit">
      ...
      <div>
        <label for="photoFile">Photo</label>
        <br />
        <input type="file" name="photoFile" id="photoFile" 
          @change="onChange" />
        <br />
        <img :src="form.photoFile" id="photo-preview" />
      </div>
    </form>
  </div>
</template>

我们将选定的文件读入 base64 字符串,以便我们可以使用 HTTP 请求轻松保存数据。我们还使用 base64 字符串在img元素中预览图像。

然后,我们将script标签添加到PhotoFormPage.vue中:

<script>
import axios from "axios";
import { APIURL } from "../constant";
export default {
  name: "PhotoForm",
  data() {
    return {
      form: {
        name: "",
        description: "",
        dateTaken: "",
        photoFile: undefined,
      },
    };
  },
...
      reader.onload = () => {
        this.form.photoFile = reader.result;
      };
    },
  },
  async beforeMount() {
    const { id } = this.$route.params;
    if (id) {
      const { data } = await 
        axios.get(`${APIURL}/photos/${id}`);
      this.form = data;
    }
  },
};
</script>

通过检查id参数是否设置,确定我们是编辑现有照片还是创建新照片。如果设置了,那么我们正在编辑。否则,我们正在创建新照片。这个逻辑在submit方法和beforeMount钩子中使用。

submit()方法的id用于向 API 发出 HTTP 请求以保存条目。我们通过编写以下内容从路由中获取id参数:

const { id } = this.$route.params;

然后,我们在下面立即添加一个if语句来检查它是否设置。如果设置了,我们将发出PUT请求来更新现有条目。否则,我们将发出POST请求来创建新条目。

在这个组件中,我们有一个表单,允许我们在应用程序中添加和编辑照片。根据edit属性的值,我们显示添加编辑文本。然后,我们有一个包含许多字段的表单。form元素具有提交事件侦听器,当我们点击提交input按钮时运行submit()方法。prevent修饰符在submit处理程序内部自动运行event.preventDefault()方法,我们不需要自己添加。我们需要这样做,因为我们不希望浏览器执行默认的submit行为,这会直接将我们的表单数据提交到服务器。我们希望使用我们自己的客户端代码在客户端处理数据。

在这个项目中,我们不会创建自己的 API,也不会在服务器端进行任何数据验证。

此外,修饰符使我们不必自己输入它,也使我们的代码更短。指令语法足够常见,有一个符号代表它。@符号也可以替换为v-on指令,因为@v-on指令的简写。

form标签内部,我们有带有v-model指令的input元素,它绑定到form响应属性的各种属性。label HTML 元素是每个输入的标签。label具有for属性,允许它将标签映射到input元素的id参数。这对于辅助功能很有用,因为屏幕阅读器会捕捉并向用户朗读它。这对我们应用程序的视障用户非常有帮助。我们将在textarea标签中使用非常相似的代码。

日期和时间选择器是通过将type属性设置为datetime-local创建的本机日期和时间选择器。这使我们能够添加一个日期和时间选择器,该选择器设置为设备的时区。然后,我们将v-model指令设置为将日期和时间选择器的值绑定到用户在浏览器或桌面应用程序窗口中选择的值。大多数现代浏览器都支持这种类型的输入,因此我们可以使用它来使用户选择日期和时间。type属性也可以设置为date以添加仅日期选择器。此外,我们可以将类型设置为datetime以添加设置为 UTC 的日期和时间选择器。

file输入更复杂。输入的type属性设置为file,以便我们可以看到文件输入。此外,它具有一个 change 事件监听器,运行onChange()方法将二进制图像文件转换为 base64 字符串。这将图像保存到我们的 API 中作为文本字符串。对于像这样的小型应用程序,我们可以直接将图像保存为字符串。

然而,如果我们正在构建一个具有大量用户使用应用程序并进行大量文件上传的生产质量应用程序,那么将文件保存在第三方存储服务中,如 Dropbox 或 Amazon S3,将是一个好主意。然后,我们可以从 URL 中获取文件,而不是作为 base64 字符串。HTTP URL 和 base64 URL 是等效的。我们可以将两者都设置为img标签的src属性的值以显示图像。在这种情况下,我们将src属性设置为img标签中的 base64 URL。

在表单底部,我们有一个type属性设置为submit的输入。这允许我们通过在表单输入元素上按Enter或单击提交输入按钮来提交输入。

接下来,我们添加data()方法。这将返回form响应属性的初始值。form响应属性包括namedescriptiondateTakenphotoFile属性。name属性是我们照片的名称。description属性是我们照片的描述。dateTaken属性包含拍摄照片的日期和时间的字符串。photoFile属性是照片文件的 base64 字符串表示。

接下来,我们在这个组件中有一些方法。首先,我们有submit()方法,它可以进行PUT请求,更新现有的照片条目,或者进行POST请求,创建新的照片条目。在进行任何 HTTP 请求之前,我们检查this.form响应式属性的所有属性是否都填充了非假值。我们希望所有字段都填写完整。如果有任何属性的值被设置为假值,那么我们会显示一个警报,告诉用户填写所有字段。

为了使获取属性的过程更简短,我们解构了this.form响应式属性的属性,然后进行检查。之后,我们检查edit属性是否为true。如果是,则使用PUT请求来更新现有条目。id属性设置为$route.params.id的值,以便从 URL 中获取 ID URL 参数的值。

如果edit响应式属性为true,那么我们会向我们的 API 发出PUT请求,以更新现有的照片条目。为了进行PUT请求,我们调用axios.put()方法。这将 URL 作为第一个参数,并将请求体内容作为第二个参数的对象。否则,我们调用axios.post(),使用相同的参数进行POST请求,以创建新的照片条目。PUT请求的 URL 末尾附加了照片条目的 ID,以便 API 可以识别要更新的条目:

{ 
  methods: {    
    async submit() {
      const { name, description, dateTaken, photoFile } = 
        this.form;
      if (!name || !description || !dateTaken || 
        !photoFile) {
        alert("All fields are required");
        return;
      }
      if (this.edit) {
        await axios.put(`${APIURL}/photos/${this.id}`, 
          this.form);
      } else {
        await axios.post(`${APIURL}/photos`, this.form);
      }
      this.$router.push("/");
    },
    onChange(ev) {
      const reader = new FileReader();
      reader.readAsDataURL(ev.target.files[0]);
      reader.onload = () => {
        this.form.photoFile = reader.result;
      };
    },
  }
}

我们还定义了onChange()方法,用作文件输入的更改事件监听器。当我们选择一个文件时,将运行此方法。在方法体中,我们创建一个新的FileReader实例,将所选的图像文件读入 base64 字符串。参数中有一个事件对象,其中包含我们选择的文件。ev.target.files属性是一个类似数组的对象,其中包含所选的文件。由于我们只允许用户选择一个文件,因此我们可以使用0属性来获取第一个文件。0是一个属性名,而不是索引,因为files属性是一个类似数组的对象;也就是说,它看起来像一个数组,但不像一个数组。但是,它是一个可迭代的对象,因此我们可以使用for-of循环或spread运算符来循环遍历项目或将它们转换为数组。

为了将所选文件读入 base64 字符串,我们使用文件对象调用reader.readAsDataURL方法来将文件读入 base64 字符串。然后,我们通过监听reader对象发出的 load 事件来获取结果。我们通过将事件处理程序设置为onload属性的值来实现这一点。读取的结果在result属性中。它设置为this.form.photoFile属性,以便我们可以在文件输入下面的img标签中显示图像,并在提交后将其存储在我们的数据库中。

然后,我们在beforeMount钩子中添加一些代码。我们检查this.edit属性的值,如果this.edit属性的值为true,则从我们的 API 中获取照片条目。我们只需要在挂载此组件时进行检查,因为我们在route组件中使用此组件。另外,当我们转到映射到该组件的 URL 时,route组件会被挂载。当我们转到另一个 URL 时,该组件将被卸载。因此,我们不需要监视器来监视editid属性的值。我们将检索到的数据设置为form响应属性,以便用户可以在表单字段中看到数据并根据需要进行编辑。

axios.post()axios.put()axios.get()方法都返回一个解析为响应数据的 promise。data属性具有响应数据。因此,我们可以使用asyncawait语法来使我们的 promise 代码更简洁,就像我们在整个组件中所做的那样。

style标签中,我们有几种样式可以用来设置表单的样式。我们通过在form类中添加margin属性并将其设置为0 auto来将表单显示在屏幕中心附近。宽度设置为70vw,这样它只占视口宽度的 70%,而不是整个宽度。form-field类的width属性设置为 100%,以便表单字段填满整个表单的宽度。否则,它们将显示为默认宽度,非常短。photo-preview ID 分配给我们用于预览的img标签。我们将其width属性设置为200px,这样我们只显示图像的缩略图预览。

<style scoped>
.form {
  margin: 0 auto;
  width: 70vw;
}
.form-field {
  width: 100%;
}
#photo-preview {
  width: 200px;
}
</style>

在这个文件中,我们发出请求,允许我们编辑或删除照片条目。

接下来,我们为我们的主页创建一个组件。我们将在src/components文件夹中创建一个HomePage.vue文件,并在其中编写以下代码:

<template>
  <div>
    <h1>Photos</h1>
    <button @click="load">Refresh</button>
    <div class="row">
      <div>Name</div>
      <div>Photo</div>
      <div>Description</div>
      <div>Actions</div>
    </div>
...
<script>
import axios from "axios";
import { APIURL } from "../constant";
export default {
  data() {
    return {
      photos: [],
    };
  },
  methods: {
    ...
  }
</script>
<style scoped>
  ...
</style>

这个文件比我们之前创建的组件更复杂。在component options对象中,我们有一个data()方法,返回我们响应属性的初始值。在这个组件中,我们只有一个用于保存照片的响应属性。photos响应属性将具有文件。在methods属性中,我们有一些方法,用于填充响应属性。load方法使用axios.get()方法从photos端点获取数据。APIURL来自我们稍后将创建的constants.js文件。它只是一个包含端点基本 URL 的字符串,我们可以向其发出 HTTP 请求。

axios.get()方法返回一个解析为对象的 promise。该对象具有 HTTP 请求。data属性具有响应主体。我们将主体数据分配给this.photos响应属性,以在模板中显示照片条目。

以下代码用于检索照片:

{  
  ...
  methods: {
    async load() {
      const { data } = await axios.get(`${APIURL}/photos`);
      this.photos = data;
    },
    ...
  },
  beforeMount() {
    this.load();
  },
};

edit()方法调用this.$router.push()方法,并带有我们要转到的 URL 路径的对象。此外,path属性具有我们要转到的路由的基本路径以及我们要添加到路径末尾的 URL 参数。id参数是我们附加到路径的 URL 参数。它具有照片条目的 ID:

{  
  ...
  methods: {
    ...
    edit(id) {
      this.$router.push({ path: `/edit-photo-form/${id}` });
    },
    ...
  },
  ...
};

deletePhoto()方法也接受id参数。它与edit()方法的参数相同。在此方法中,我们调用axios.delete()方法,向photos端点发出DELETE请求,并使用id参数作为 URL 参数,以标识要删除的条目。一旦项目被删除,我们调用this.load()方法从 API 重新加载最新条目:

{
  ...
  methods: {
    ...
    async deletePhoto(id) {
      await axios.delete(`${APIURL}/photos/${id}`);
      this.photos = [];
      this.load();
    },
  },
  ...
}

template部分,我们使用v-for指令将photos响应属性数组的条目呈现为表格。key属性是必需的,用于识别具有唯一 ID 的唯一项目。key属性非常重要,因为当用户单击删除按钮时,我们将从列表中删除项目。这意味着每个条目必须具有唯一的 ID,以便 Vue 3 在我们正确删除一个项目后能够正确识别所有项目。这样最新的项目才能正确呈现。

我们使用v-for循环渲染照片:

   ... 
   <div v-for="p of photos" class="row" :key="p.id">
      <div>
        <img :src="p.photoFile" />
      </div>
      <div>{{p.name}}</div>
      <div>{{p.description}}</div>
      <div>
        <button @click="edit(p.id)">Edit</button>
        <button @click="deletePhoto(p.id)">Delete</button>
      </div>
    </div>
    ...

要渲染图像,我们使用具有src属性的img标签。photoFile属性是图像的文本形式的 base64 URL。其他属性是我们直接在表格中呈现的字符串。在最右侧的列中,我们有两个按钮 - 编辑删除编辑按钮在点击时调用edit(),并传递照片条目的id属性。这将导航我们到稍后将创建的照片编辑表单。删除按钮将调用deletePhoto()方法,并传递照片条目的id属性以进行删除。项目将在删除项目后重新加载:

图 4.2 - 编辑和删除按钮

图 4.2 - 编辑和删除按钮

style标签中,我们有一些 CSS 来将div标签显示为表格。row类具有display属性设置为flex,以便我们可以将其用作flexbox容器。flex-wrap属性设置为wrap,以便我们可以包裹任何溢出的内容。通常,在这个模板中我们不会有任何溢出的内容。justify-content设置为space-between,以便在flexbox容器中均匀分布子元素。

row类中的div标签具有width属性设置为25%,以便我们可以均匀分布行中的子元素。这允许我们将行内的四个div标签并排显示。row类中的div标签内的img元素具有width属性设置为100px,以便我们可以在照片列表中查看小缩略图预览。

style标签具有scoped属性,这意味着样式不会影响我们项目中的其他组件:

<style scoped>
.row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
}
.row div {
  width: 25%;
}
.row img {
  width: 100px;
}
</style>

接下来,在我们的应用程序中创建一个导航栏。为此,我们进入src/components文件夹,并添加NavBar.vue组件文件。创建文件后,我们可以添加以下代码:

<template>
  <nav>
    <ul>
      <li>
        <router-link to="/">Home</router-link>
      </li>
      <li>
        <router-link to="/add-photo-form">Add Photo
          </router-link>
      </li>
      <li>
        <router-link to="/search">Search Photos
          </router-link>
      </li>
    </ul>
  </nav>
</template>

在这里,我们添加了一个ul元素来添加一个无序列表。这样,我们就不会在每个li元素的左边看到任何数字显示。在li元素内部,我们有来自 Vue Router 的router-link组件,用于显示允许我们导航应用程序的链接。我们使用router-link而不是常规的a标签。这是因为 Vue Router 将解析router-link组件的to属性,以正确的路径显示我们期望的组件,如果在 URL 模式中找到匹配的话。

由于我们尚未注册 Vue Router 插件或任何路由,也没有将NavBar添加到任何组件中,因此在导航栏中看不到任何内容。style标签具有一些样式,可以使链接水平显示而不是垂直显示。

此外,我们有一个登出链接,用于退出应用。logout()方法使用localStorage.clear()方法清除本地存储。然后,通过调用this.$router.push()方法并使用/login路径重定向回登录页面:

<script>
export default {
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/login");
    },
  },
};
</script>

ul li选择器将list-style属性设置为none,这样我们就看不到NavBar项目左侧显示的项目符号。我们使用display属性将它们水平显示为inline。然后,我们添加margin-right属性并将其设置为10px,这样我们在链接之间有一些空间。

ul选择器将margin属性设置为0 auto,这样我们可以水平居中链接。width70vw,这样它们就更靠近屏幕中心,而不是将项目放在左边。

<nav>
    <ul>
      <li>
        <router-link to="/">Home</router-link>
      </li>
      <li>
        <router-link to="/add-photo-form">Add Photo
          </router-link>
      </li>
      <li>
        <router-link to="/search">Search Photos
          </router-link>
      </li>
    </ul>
  </nav>
<script>
export default {
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/login");
    },
  },
};
</script>
<style scoped>
ul li {
  list-style: none;
  display: inline;
  margin-right: 10px;
}
ul {
  margin: 0 auto;
  width: 70vw;
}
</style>...

现在我们已经完成了允许我们保存照片的表单,让我们看看如何在页面上显示添加的照片。

添加照片显示

在这里,我们添加了一个搜索页面,以便可以通过名称搜索照片条目。为此,我们将SearchPage.vue组件文件添加到项目的src/components文件夹中。

SearchPage.vue组件比PhotoForm组件简单。它有一个表单元素和一个表单字段。表单字段用于接受用户的关键字来搜索我们的照片集。输入的type属性设置为text,因此我们的代码中有一个常规文本输入。与其他输入一样,我们使用v-model指令将输入值绑定到一个响应式属性。设置id参数,以便我们可以在标签中使用for属性。表单还有一个搜索按钮,其input类型设置为submit

<template>
  <div>
    <h1>Search</h1>
    <form @submit.prevent="submit">
      <div>
        <label for="name">Keyword</label>
        <br />
        <input type="text" v-model="keyword" name="keyword"
          id="keyword" class="form-field" />
      </div>
      <div>
        <input type="submit" value="Search" />
      </div>
    </form>
    <div v-for="p of photos" class="row" :key="p.id">
      <div>
        <img :src="p.photoFile" />
      </div>
      <div>{{p.name}}</div>
      <div>{{p.description}}</div>
    </div>
  </div>
</template>

然后,搜索结果以row类显示,使项目按行排列。这类似于我们在HomePage组件中显示照片的方式。img的 base64 URL 设置为src属性的值。此外,我们在其右侧有namedescription属性。v-for指令循环遍历photos响应式属性数组,使我们能够显示数据。再次,我们将key属性设置为唯一 ID,以便按 ID 显示项目。

component options对象中,我们使用data()方法来初始化我们的响应式属性。它们包括keywordphotoskeyword响应式属性用于搜索关键字。photos响应式属性用于存储照片集搜索结果:

<script>
import axios from "axios";
import { APIURL } from "../constant";
export default {
  name: "SearchPage",
  data() {
    return {
      keyword: "",
      photos: [],
    };
  },
...
  watch: {
    $route: {
      immediate: true,
      handler() {
        this.keyword = this.$route.query.q;
        this.search();
      },
    },
  },
};
</script>

methods属性中,我们有一些可以使用的方法。search()方法允许我们使用axios.get()方法获取数据。这个方法使用查询字符串发出GET请求,因此我们可以获取我们正在寻找的条目。this.$route.query.q属性用于从 URL 中获取q查询参数。这个属性是可用的,因为我们将注册 Vue Router 插件,以便我们可以从这个属性获取query参数。一旦我们获取了响应数据,我们就将其分配给this.photos响应式属性。

submit()方法在表单提交时运行,无论是点击搜索按钮还是按下Enter键。由于我们监听表单中的submit事件,这个方法将被运行。和其他表单一样,我们在@submit指令中添加了prevent修饰符。这样我们就可以调用event.preventDefault()方法来阻止数据被提交到服务器端。在这个方法中,我们调用this.$router.push()方法将页面重定向到带有查询字符串的/search路径。/search路径将映射到当前组件,因此我们只需使用 URL 中的新查询字符串重新挂载这个组件。这样,我们就可以设置this.$router.query.q属性来获取带有键的查询字符串参数,以获取查询字符串的值并使用它。

name_like URL 查询参数将被 API 捕获,这样我们就可以搜索我们在name字段中设置为值的文本。

最后,我们为$route响应式属性设置了一个监视器。我们需要将immediate属性设置为true,这样我们才能获取query参数的最新值,然后运行search()方法来从GET请求中获取数据,当这个组件挂载时。handler()方法有一个在$route对象改变时运行的方法。query属性的更改将被捕获。因此,在方法内部,我们将keyword响应式属性设置为this.$route.query.q的值,以显示输入框中q查询字符串的最新值。另外,我们调用this.search()方法,根据查询字符串获取最新的搜索结果。

styles标签中有一些样式,我们可以用它们来为我们的表单和行设置样式。它们与我们以前使用的样式类似。我们使表单字段变宽,并将表单显示在靠近中心的位置。行以 flexbox 容器显示,行内的所有单元格宽度均匀:

<style scoped>
.form-field {
  width: 100%;
}
.row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
}
.row div {
  width: 25%;
}
.row img {
  width: 100px;
}
</style>

最后,我们需要创建一个文件来导出一个APIURL变量,以便组件可以引用它们。到目前为止,我们在大多数已创建的组件中都使用了这些。在src文件夹中,我们创建constants.js文件,并编写以下代码来导出APIURL变量:

export const APIURL = 'http://localhost:3000'

现在,我们可以正确地将SearchPage.vue导入到所有组件中,并添加一个搜索页面。

将路由添加到照片管理器应用程序

没有 Vue Router 插件,我们无法在应用程序内显示页面组件。链接将无法工作,我们也无法重定向到任何地方。要添加 Vue Router 插件,我们需要注册它,然后添加路由。我们在src/main.js文件中添加以下代码:

import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHistory } from 'vue-router'
import PhotoFormPage from './components/PhotoFormPage';
import SearchPage from './components/SearchPage';
import HomePage from './components/HomePage';
const routes = [
  { path: '/add-photo-form', component: PhotoFormPage },
  { path: '/edit-photo-form/:id', component: PhotoFormPage },
  { path: '/search', component: SearchPage },
  { path: '/', component: HomePage },
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
const app = createApp(App)
app.use(router);
app.mount('#app') ...

在这个文件中,我们导入所有页面组件,然后将它们放入routes数组中。routes数组包含路由。数组中的每个对象都有pathcomponent属性。路径具有我们想要与组件匹配的 URL 模式,component属性具有我们想要在 URL 模式与路径中的内容匹配时加载的component对象。路径是一个带有 URL 模式的字符串。我们的字符串中有一个 URL 参数占位符。:id字符串具有id URL参数的 URL 占位符。在我们的EditPhotoFormPage组件中,我们通过使用this.$route.params.id属性来检索id URL参数。它将作为字符串返回。

createRouter() 函数使我们能够创建一个路由器对象,我们可以使用 app.use() 方法在我们的应用程序中注册它。这是 Vue Router 4 的新功能,与 Vue Router 3 不同。我们注册 Vue Router 插件和路由的方式与 Vue Router 3 不同。因此,Vue Router 4 是唯一可以与 Vue 3 一起使用的版本。createWebHistory() 函数允许我们使用 HTML5 模式。使用这个,我们可以删除基本 URL 段和 URL 其余部分之间的井号。这样做使 URL 看起来更好,更符合用户的习惯。routes 属性具有我们之前创建的路由数组。然后,为了注册路由和 Vue Router 插件,我们调用 app.use(router) 来注册两者。现在 router-link 组件和重定向应该可以工作了。

beforeEnter() 方法是一个每个路由的导航守卫。我们需要这个方法,这样我们只能在登录后访问可用的页面。在这个方法中,我们检查具有键值为 true 的本地存储项。然后,如果是 false,我们通过调用 next() 函数并将 path 属性设置为 login 来重定向到登录页面。在调用 next 之前需要使用 return 关键字,因为我们不希望运行函数的其余代码。否则,我们只需调用 next 来继续导航到目标路由,即 path 属性的值。我们还将 beforeEnter() 方法添加到我们想要应用的路由对象中:

const beforeEnter = (to, from, next) => {
  const loggedIn = localStorage.getItem('logged-in') ===     'true';
  if (!loggedIn) {
    return next({ path: 'login' });
  }
  next();
}

然后,在 src/App.vue 中,我们通过以下代码添加了 router-view 组件和 NavBar 组件:

<template>
  <div id="app">
    <nav-bar v-if="!$route.fullPath.includes
      ('login')"></nav-bar>
    <router-view></router-view>
  </div>
</template>
<script>
import NavBar from "./components/NavBar.vue";
export default {
  name: "App",
  components: {
    NavBar,
  },
};
</script>
<style scoped>
#app {
  margin: 0 auto;
  width: 70vw;
}
</style>

我们导入 NavBar.vue 组件,然后通过将其放入 components 属性中注册它。然后,我们添加了 nav-bar 组件来显示带有路由链接组件的导航栏。router-view 组件通过将 URL 模式与 routes 数组中的模式进行比较,显示与 Vue Router 匹配的组件。

现在,当我们单击链接或成功提交表单时,我们将看到加载的路由。

当我们在登录页面时,我们不必显示 nav-bar 组件。因此,我们添加了一个检查 $route.fullPath.includes() 方法,以检查我们是否在登录页面上。$route.fullPath 属性具有当前页面的完整路径,不包括基本 URL。

使用我们的应用程序与照片管理 API

在之前的部分中,我们看了照片显示的客户端部分。要从 API 返回照片,我们必须添加一个后端 API,这将使我们能够存储和检索我们应用程序的数据。由于本书主要关注于使用 Vue 3 进行客户端应用程序开发,而不是服务器端应用程序开发,我们将使用一个简单的 API 解决方案来使用 JSON 存储我们的数据,这样我们就不必创建自己的 API。所有数据都存储在一个平面文件数据库中,完全是 JSON。为此,我们使用 JSON Server 包。这是一个不需要配置的包,我们可以在一分钟内让它运行起来。我们所有的字段都存储为 JSON 对象属性,因此它们需要是文本,包括图像。这个包是为需要快速原型化我们应用程序的前端开发人员而设计的后端。

首先,我们运行 npm i –g json-server 来安装 JSON Server 包。这样,我们可以从任何文件夹访问 JSON Server 包。一旦我们这样做了,我们创建一个 photo-api 文件夹来存储我们的照片数据库。然后,在文件夹内,我们添加 db.json 文件。接下来,我们创建 photo-api 文件夹,转到我们刚创建的文件夹,并运行 json-server --watch db.json 来运行服务器。在文件夹内,我们添加以下代码:

{
  "photos": []
}

db.json 文件中,我们将拥有我们在 Vue 3 应用程序中指向的所有端点。服务器应该监听端口 3000,因此,API 的基本 URL 是 localhost:3000。现在,我们应该可以访问以下 API 端点:

  • GET /photos

  • GET /photos/1

  • POST /photos

  • PUT /photos/1

  • PATCH /photos/1

  • DELETE /photos/1

GET /photos 端点允许我们获取 photos JSON 数组中的所有项目。GET /photos/1 端点返回 ID 为 1 的单个照片条目。我们可以用任何 ID 替换它。POST /photos 端点使我们能够在 photos JSON 数组中添加新条目。PUT /photos/1PATCH /photos/1 允许我们更新 ID 为 1 的照片条目。DELETE /photos 路由允许我们删除 ID 为 1 的照片。

GET 请求也可以带有查询字符串。为了搜索具有特定文本的字段,我们可以向 URL 发出 GET 请求,例如 GET /photos?tname_like=foo。这使我们能够搜索每个条目的 Name 字段,并找到包含文本的 Name 字段值的 photos 条目。

现在,我们应该能够发出客户端 API 中的请求,以获取我们想要的内容。整个 JSON 数组以 JSON 格式返回,以便我们可以轻松呈现这些项目。JSON 服务器包将监视 JSON 的任何更新,因此我们将始终获得最新数据。此外,我们可以使用port标志更改端口。因此,我们可以编写诸如run json-server --watch db.json –port 3005这样的内容来在端口3005上运行 JSON 服务器。APIURL变量也必须相应更改。

当服务器运行时,我们应该在命令提示符中看到类似以下内容:

图 4.3 - JSON 服务器输出

图 4.3 - JSON 服务器输出

现在,我们的 Electron 桌面应用程序窗口显示了 Vue 3 照片管理应用程序,我们可以随心所欲地操作我们的照片收藏。我们现在应该能够查看我们的应用程序如下:

图 4.4 - 照片应用程序表单

图 4.4 - 照片应用程序表单

我们可以在以下截图中查看搜索页面:

图 4.5 - 搜索页面

图 4.5 - 搜索页面

照片应用程序项目现在已经完成。我们添加了表单,以便用户添加照片条目。我们使用 Vue Router 允许我们添加具有多个页面的应用程序,并在它们之间轻松导航。我们还添加了 Vue CLI Electron 插件,以便我们可以轻松地使用 Vue 3 构建跨平台桌面应用程序。

总结

因此,我们可以使用 Vue Router 构建超出简单应用的应用程序,这允许我们将不同的 URL 映射到不同的组件。这样,我们可以转到不同的 URL 并呈现不同的组件。URL 可以在其中包含查询字符串、哈希和 URL 参数,并且 Vue Router 会自动解析它们。它们将与路由定义数组中列出的路由模式匹配。

路由可以嵌套,并且它们可以具有名称,以便我们可以根据它们的名称而不是它们的路由模式来识别它们。此外,我们可以有一个 catchall 或 404 路由,以便在我们的路由定义中列出的路由模式都不匹配时显示一些内容。

Vue Router 还具有用于编程导航的方法,这些方法的命名方式与浏览器历史 API 中的方法相同,并且可以使用相同的参数进行调用。

此外,我们学会了如何限制某些路由在满足特定条件之前不显示。为了限制用户何时可以看到路由组件,我们可以使用导航守卫在进入路由之前进行检查。导航守卫也可以添加以在路由加载后运行。它们也可以全局应用或应用于单个路由。如果我们单独应用导航守卫,那么对导航的检查可以针对每个路由进行定制。此外,我们可以对一个路由应用多个路由导航守卫。这使我们比使用全局导航守卫时拥有更多的灵活性。

然后,我们看了如何将我们的 Vue 3 web 应用程序转换为桌面应用程序。使用 Electron,我们可以从浏览器应用程序构建桌面应用程序。这非常方便,因为我们可以构建基于 Web 的业务应用程序,并轻松将它们转换为桌面应用程序。这些应用程序是跨平台的,我们可以像使用常规桌面应用程序一样轻松地完成许多工作。例如,我们可以像在浏览器应用程序中一样,对硬件进行有限访问,如摄像头和麦克风。此外,我们可以向用户显示原生通知,就像在桌面应用程序中一样,因为这受到 Chromium 浏览器引擎的支持。Electron 只是在 Chromium 浏览器中运行我们的应用程序。Vue CLI Electron Builder 插件让我们可以用一条命令将 Vue 应用程序转换为 Electron 应用程序。

在下一章中,我们将使用 Ionic 构建一个计算器移动应用程序。

第五章:使用 Ionic 构建多功能计算器移动应用

在前四章中,我们使用 Vue 3 构建了各种类型的 Web 应用程序。我们也可以使用 Vue 3 创建移动应用程序,但不能仅使用 Vue 3 创建它们。我们可以使用将 Vue 3 作为基础框架的移动应用程序框架来创建移动应用程序。在第四章《构建照片管理桌面应用程序》中,我们使用 Vue Router 构建了一个 Web 应用程序,以便我们的应用程序可以拥有多个页面。Vue Router 让我们能够创建稍微复杂的应用程序。

在本章中,我们将进一步从构建 Web 应用程序的知识中迈出,以便开始构建移动应用程序。我们将构建一个计算器应用程序,让我们能够转换货币和计算小费。它还会记住我们所做的计算,因此我们可以轻松地返回重新进行。

此外,我们还将涵盖以下主题:

  • 介绍 Ionic Vue

  • 创建我们的 Ionic Vue 移动应用项目

  • 安装项目的包

  • 将计算器添加到我们的计算器应用程序中

技术要求

本章项目的源代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter05找到。

介绍 Ionic Vue

Ionic Vue是一个移动应用程序框架,让我们能够使用 TypeScript 和 Vue 构建应用程序。它还有基于 React 和 Angular 的版本。它配备了许多组件,我们可以像其他 UI 框架一样将其添加到我们的应用程序中。它们包括常见的输入、菜单、图标、列表等等。编译后的 Ionic 应用程序在 Web 视图中运行,因此我们可以在我们的应用程序中使用本地存储、地理位置和其他浏览器 API 等 Web 技术。

它还配备了内置工具,让我们能够自动构建移动应用程序,无需从头开始设置所有内容。Ionic Vue 默认创建使用组合 API 的组件,因此我们将使用它来构建更模块化并且与 TypeScript 更好地配合的 Vue 应用程序。

理解组合 API

Composition API 更适合与 TypeScript 一起使用,因为它不引用具有动态结构的this关键字。相反,Composition API 的所有部分,包括其库,都与具有清晰参数和返回类型的函数兼容。这使我们可以轻松为它们定义 TypeScript 类型定义。Vue Router 4 和 Vuex 4 与 Vue 3 的 Composition API 兼容,因此我们可以在 Ionic Vue 应用程序中一起使用它们。

使用 Composition API,我们仍然有组件对象,但其结构与选项 API 中的完全不同。选项和 Composition API 之间唯一相同的属性是components属性。它们都允许我们在两个 API 中注册组件。我们的组件逻辑主要在setup()方法中。这是我们定义响应式属性、计算属性、观察者和方法的地方。第三方库还可以为我们提供钩子,这些钩子是我们可以在 setup 函数中调用的函数,以便从库中获得我们想要的功能。例如,Vuex 4 为我们提供了useStore钩子,以便我们可以访问存储。Vue Router 4 带有useRouter钩子,让我们在应用程序中导航。

我们定义响应式和计算属性的方式与选项 API 不同。在选项 API 中,我们在data()方法中定义和初始化我们的响应式属性。在 Composition API 中,我们调用ref函数来定义持有原始值的响应式属性;然后我们调用reactive来定义具有对象值的响应式属性。要在 Composition API 中定义计算属性,我们调用带有回调的computed函数,该回调引用其他响应式属性以创建计算属性。

观察者是使用watch函数创建的。它接受一个回调来返回我们想要观察值的响应式属性。我们传递给watch函数的第二个参数是一个回调,让我们在观察的值发生变化时执行某些操作。我们可以使用回调的第一个参数获取被观察的响应式属性的最新值。第三个参数包含观察者的选项。我们可以在其中设置深度和立即属性,就像在选项 API 中观察者一样。

方法也被添加到setup函数中。我们可以使用箭头函数或常规函数来定义它们,因为 this 的值并不重要。响应式属性和方法必须在我们在setup()方法中返回的对象中返回,以便在模板中使用它们。这样,它们可以在我们的代码中使用。

理解 TypeScript

TypeScript是微软开发的一种语言,它是 JavaScript 的扩展。它为我们的代码提供了编译时的数据类型检查。然而,它并没有为我们提供额外的运行时数据类型检查,因为 TypeScript 在运行之前会编译成 JavaScript。在组合 API 中,我们的组件不引用this关键字,因此我们不必担心它具有错误的值。

使用 TypeScript 的好处是确保原始值、对象和变量的类型安全,这在 JavaScript 中是不存在的。在 JavaScript 中,我们可以将任何东西赋给任何变量。这当然会成为一个问题,因为我们可能会将东西赋给我们通常不会使用的数据类型。此外,函数可以接受任何参数,我们可以以任何顺序传递任何参数给函数,因此如果我们传递函数不期望的参数,可能会遇到问题。此外,任何地方都可能变成nullundefined,因此我们必须确保只有我们期望为 null 或 undefined 的地方才有这些值。JavaScript 函数也可以返回任何东西,因此 TypeScript 也可以限制这一点。

TypeScript 的另一个重要特性是我们可以创建接口来限制对象的结构。我们可以指定对象属性及其类型,以便限制对象具有给定的属性,并且属性具有我们指定的数据类型。这可以防止我们将对象分配给我们不期望的变量和参数,并且还为我们提供了在支持 TypeScript 的文本编辑器中无法获得的自动完成功能。这是因为对象的结构是固定的。接口可以具有可选和动态属性,以便让我们保持 JavaScript 对象的灵活性。

为了保持 JavaScript 的灵活性,TypeScript 带有联合和交集类型。联合类型是指我们使用逻辑 OR 运算符将多个类型组合在一起的情况。具有联合类型的变量意味着变量可以是联合类型列表中的一个类型。交集类型是使用逻辑 AND 运算符将多个类型组合在一起的情况。将类型设置为交集类型的变量必须具有交集中所有类型的成员。

为了保持类型规范的简洁,TypeScript 带有type关键字,它允许我们创建类型别名。类型别名可以像常规类型一样使用,因此我们可以将类型别名分配给变量、属性、参数、返回类型等等。

在我们的移动应用中,我们将为小费计算器、货币转换器和一个带有过去计算列表的主页添加页面。我们将我们所做的任何计算都保存在本地存储中,以便以后可以返回到它们。历史记录通过vuex-persistedstate插件保存到本地存储中。该插件与 Vuex 4 兼容,它允许我们直接将 Vuex 状态保存到本地存储中,而无需编写任何额外的代码来实现这一点。

现在我们已经了解了 Vue 的组合 API、TypeScript 和 Ionic 的基础知识,我们可以开始用它构建我们的应用程序。

创建我们的 Ionic Vue 移动应用项目

我们可以通过安装 Ionic CLI 来创建我们的 Ionic Vue 项目。首先,我们必须通过运行以下命令来安装 Ionic CLI:

npm install -g @ionic/cli

然后,我们必须通过转到我们想要运行项目文件夹的文件夹来创建我们的 Ionic Vue 项目。我们可以使用以下命令来做到这一点:

ionic start calculator-app sidemenu --type vue

sidemenu选项允许我们创建一个带有侧边菜单的 Ionic 项目。这将在创建菜单和页面时节省我们的时间。--type vue选项允许我们创建一个 Ionic Vue 项目。

我们可以使用以下命令获取所有选项的帮助,并查看每个选项的解释:

  • ionic –help

  • ionic <command> --help

  • ionic <command><subcommand> --help

我们应该在我们的项目目录中运行ionic <command> --help

使用 Capacitor 和 Genymotion

Ionic Vue 项目使用 Capacitor 进行服务和构建。Capacitor 将在 Android Studio 中打开项目;然后,我们可以从那里启动并在模拟器或设备中预览我们的应用程序。对于这个项目,我们将使用 Genymotion 模拟器预览我们的应用程序。它速度快,并且有一个插件,让我们可以从 Android Studio 中启动。我们可以从 www.genymotion.com/download/ 下载 Genymotion 模拟器,而 Android Studio 可以从 developer.android.com/studio 下载。

安装了 Genymotion 后,我们必须从 Genymotion UI 中创建一个虚拟机。为此,我们可以点击 加号 按钮,然后添加我们想要的设备。我们应该添加一个具有较新版本的 Android(如 Android 7 或更高版本)的设备。其他选项可以根据我们的喜好选择。要安装 Android Studio 的 Genymotion 插件,请按照 www.genymotion.com/plugins/ 上的说明进行操作。这将让我们在 Genymotion 中运行我们的 Android Studio 项目。

接下来,在我们项目的 package.json 文件中,如果在脚本部分看不到 ionic:serveionic:build 脚本,我们可以通过在 package.json 文件的脚本部分中写入以下代码来添加它们:

"ionic:serve": "vue-cli-service serve",
"ionic:build": "vue-cli-service build"

然后,我们可以运行 ionic build 来构建我们的代码,以便稍后使用 Capacitor 进行服务。

完成后,我们可以运行以下命令为 Android 项目添加依赖项:

npx cap add android
npx cap sync

这也是必需的,这样我们才能将我们的项目作为 Android 应用程序运行。

完成这些命令后,我们可以运行以下命令,以便我们可以使用实时重新加载运行我们的应用程序,并从 Genymotion 中进行网络访问。

ionic capacitor run android --livereload --external --address=0.0.0.0

这样,我们就可以像任何其他应用程序一样访问互联网。它还运行 ionic:serve 脚本,这样我们就可以在浏览器中预览我们的应用程序。在浏览器中预览我们的应用程序比在模拟器中更快,所以我们可能会想要这样做:

图 5.1 – Genymotion 模拟器

图 5.1 – Genymotion 模拟器

如果我们想在 Genymotion 中预览,我们可以转到 Android Studio,一旦我们运行 ionic capacitor run 命令,它应该会自动打开。然后,我们可以按 Alt+Shift+F10 打开运行应用程序对话框,然后选择要运行的应用程序。

现在我们已经设置好了 Vue Ionic 项目,我们必须安装一些额外的软件包,以便创建我们的移动应用程序。

为我们的项目安装包

我们必须安装一些项目中需要的依赖项,但它们尚未安装。我们可以使用 Axios 来进行 HTTP 请求以获取汇率。uuid模块让我们可以为我们的历史记录生成唯一的 ID。Vuex 不随 Ionic 项目一起提供,因此我们必须安装它。我们还必须安装vuex-persistedstate模块,以便我们可以将 Vuex 状态数据保存到本地存储中。

要安装这些包,请运行以下命令:

npm install axios uuid vuex@next vuex-persistedstate

下一个版本的 Vuex 是 4.x 版本,与 Vue 3 兼容。

将计算器添加到我们的计算器应用程序中

现在我们的项目准备好了,我们可以开始在我们的应用程序上工作。我们首先添加路由定义,将 URL 路径映射到我们将创建的页面组件。然后我们将为每个功能添加组件。然后我们将添加 Vuex 存储,其中包含将存储数据持久化到本地存储的代码,这样我们可以随时使用数据。

添加路由

首先,我们将致力于为我们的计算器应用程序添加路由。在src/router/index.ts文件中,编写以下代码:

import { createRouter, createWebHistory } from '@ionic/vue-
 router';
import { RouteRecordRaw } from 'vue-router';
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/currency-converter',
    component: () => 
      import('../views/CurrencyConverter.vue')
  },
  {
    path: '/tips-calculator',
    component: () => import('../views/TipsCalculator.vue')
  }
]
const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})
export default router

在这个文件中,我们有routes数组,我们可以用它来添加我们将要添加到计算器应用程序中的页面的路由。routes数组的类型是Array<RouteRecordRaw>。这意味着routes数组中的对象必须具有路径和组件属性。path属性必须是一个字符串,而组件可以是一个组件或返回一个解析为组件的 promise 的函数。如果对象不符合Array<RouteRecordRaw>指定的结构,TypeScript 编译器在构建代码时会给我们一个错误。

每当我们更改任何代码文件时,代码都会被构建,因为我们设置了livereload选项,所以我们几乎立即会得到编译器错误。这可以防止大多数与数据类型相关的错误在运行时发生。类型定义内置在vue-router模块中,因此我们不必担心缺少数据类型。

添加货币转换器页面

接下来,我们将添加货币转换器页面。要添加它,首先创建src/views/CurrencyConverter.vue文件。然后,我们必须通过编写以下代码将标题添加到模板中:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Currency Converter</ion-title>
      </ion-toolbar>
    </ion-header>
    ...
  </ion-page>
</template>

ion-page组件是页面容器,让我们可以在其中添加内容。ion-toolbar组件在页面顶部添加了一个工具栏。ion-buttons组件是按钮的容器,在其中,我们必须在开始插槽中添加ion-menu-button,这样我们就可以在屏幕左上角添加一个菜单按钮。当我们点击它时,ion-menu-button组件将打开左侧菜单。ion-title组件包含页面标题。它位于左上角。

接下来,我们必须添加ion-content组件,以在货币转换页面上添加内容。例如,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          <ion-item>
            <ion-label :color="!amountValid ? 'danger' : 
              undefined">
              Amount to Convert
            </ion-label>
            <ion-input
              class="ion-text-right"
              type="number"
              v-model="amount"
              required
              placeholder="Amount"
            ></ion-input>
          </ion-item>
          ...
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们添加了ion-list组件,以便在我们的页面上添加一个列表。它让我们可以向我们的应用程序添加一个项目列表。在ion-list中,我们添加ion-item以添加列表项组件。ion-label让我们将标签添加到列表项中。标签文本的color属性由color属性设置。amountValid属性是一个计算属性,用于检查amount响应式属性是否有效。ion-input组件将输入呈现到我们的应用程序中。我们将type设置为number,以使输入成为数字输入。

placeholder属性让我们可以向我们的应用程序添加占位符。ion-text-right类让我们把输入放在右侧。这是 Ionic 框架提供的一个类。v-model指令让我们将amount响应式属性绑定到输入值,这样我们就可以在组件代码中使用输入的值。

ion-contentfullscreen属性使页面全屏。

接下来,我们将向ion-list组件添加更多项目:

<template>
  <ion-page>
...
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          ...
          <ion-item>
            <ion-label> Currency to Convert From
              </ion-label>
            <ion-select
              v-model="fromCurrency"
              ok-text="Okay"
              cancel-text="Dismiss"
            >
              <ion-select-option
                :value="c.abbreviation"
                v-for="c of fromCurrencies"
                :key="c.name"
              >
                {{ c.name }}
              </ion-select-option>
            </ion-select>
          </ion-item>
          ...
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们向我们的ion-list添加了更多ion-itemsion-select组件让我们可以从下拉菜单中添加要转换的货币,这样我们就可以选择金额所在的货币。我们将fromCurrency绑定到我们在下拉菜单中选择的值,以在组件代码中获取所选项目。ok-text属性设置下拉菜单中的确定文本,而cancel-text属性包含取消按钮的文本。ion-select组件让我们显示一个带有单选按钮的对话框,让我们显示可供我们选择的项目。然后,当我们点击或轻触确定按钮时,我们可以选择该项目。

ion-select-option组件让我们向选择对话框框添加选项。我们使用v-for指令循环遍历fromCurrencies响应式属性,这是我们从要转换的货币对话框中筛选出的selected选项创建的计算属性,稍后我们将添加。这样,我们就不能在两个下拉菜单中选择相同的货币,因此货币转换是有意义的。

接下来,我们将添加另一个选择对话框,让我们选择要将金额转换为的货币。为此,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          ...
...
                {{ c.name }}
              </ion-select-option>
            </ion-select>
          </ion-item>
          <ion-item>
            <ion-button size="default" 
              @click.stop="calculate">
              Calculate
            </ion-button>
          </ion-item>
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

toCurrencies响应式属性是一个计算属性,其中包含一个条目,其值为从fromCurrency中筛选出的值。这意味着我们不能在两个下拉菜单中选择相同的货币。

我们还添加了Calculate按钮,这样我们就可以计算转换后的金额。我们将很快添加calculate()方法。

接下来,我们将添加另一个ion-list。这将添加一个列表,用于显示转换后的金额的标签。为此,我们可以编写以下代码:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Currency Converter</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content fullscreen>
      <div id="container">
        ...
        <ion-list v-if="result">
          <ion-item>
            <ion-label>Result</ion-label>
            <ion-label class="ion-text-right">
              {{ amount }} {{ fromCurrency }} is {{ 
                result.toFixed(2) }}
              {{ toCurrency }}
            </ion-label>
          </ion-item>
        </ion-list>
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们显示了输入的金额和fromCurrency。我们还显示了结果和我们选择的toCurrency选项。我们调用了带有参数2toFixed来将结果四舍五入到两位小数。

添加脚本标签

接下来,我们将添加一个带有lang属性设置为tsscript标签,以便我们可以添加 TypeScript 代码。首先,我们将添加import语句,以便添加我们在代码中将使用的组件和其他项目:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  ...
} from "@ionic/vue";
import { computed, reactive, ref, watch } from "vue";
import { currencies as currenciesArray } from "../constants";
import axios from "axios";
import { useStore } from "vuex";
import { CurrencyConversion } from "@/interfaces";
import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router";
...
</script>

请参阅本书的 GitHub 存储库,了解可以注册的完整组件列表。

computed函数让我们可以创建与 Composition API 一起使用的计算属性。reactive函数让我们可以创建具有对象值的响应式属性。ref属性让我们可以创建具有原始值的计算属性。watch函数让我们可以创建与 Composition API 一起使用的观察者。

currenciesArray变量是一个货币数组,我们将使用它来创建fromCurrenciestoCurrencies计算属性。axios对象让我们可以使用 Axios HTTP 客户端进行 HTTP 请求。useStore变量是一个函数,让我们可以访问我们的 Vuex 存储。CurrencyConversion接口提供了我们用来限制添加到历史记录列表的对象结构的接口。uuidv4变量是一个函数,让我们可以创建 UUID,这是我们分配给历史记录条目以标识它们的唯一 ID。useRoute函数让我们可以访问路由对象,以获取当前 URL 路径和 URL 的其他部分。

接下来,我们将通过将导入的组件添加到components属性中来注册组件。为此,我们可以编写以下代码:

<script lang="ts">
...
export default {
  name: "CurrencyConverter",
  components: {
    IonButtons,
    ...
  },
  ...
};
</script>

请查看此书的 GitHub 存储库,以获取可以注册的所有组件的完整列表。我们只需将所有import组件放入component属性中进行注册。

setup方法上工作

接下来,我们将开始在setup()方法上工作,并向其中添加响应式和计算属性。我们还将添加 watchers,它们让我们可以观察路由的变化。首先,我们将编写以下代码:

<script lang="ts">
...
export default {
  ...
  setup() {
    const amount = ref(0);
    const fromCurrency = ref("USD");
    const toCurrency = ref("CAD");
    const result = ref(0);
    const currencies = reactive(currenciesArray);
    const store = useStore();
    const route = useRoute();
    ...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };
  },
};
</script>

我们调用useStore()方法返回存储对象,其中包含 Vuex 存储。我们需要 Vuex 存储来提交 mutations,这让我们可以向历史记录中添加条目。因为我们将向 Vuex 存储添加vuex-persistsedstate插件,历史记录条目将自动添加到本地存储中。同样,我们调用useRoute函数返回路由对象,让我们可以访问路由对象。我们需要路由对象来让我们观察id query参数的查询字符串。如果我们通过 ID 找到项目,那么我们可以使用它们从本地存储中获取的值,从而设置fromCurrencytoCurrencyamount的值。

此外,我们调用ref函数来创建amount响应式属性,它们是数字值。fromCurrencytoCurrency响应式属性是字符串值,它们包含我们选择的货币的货币代码。currencies响应式属性是一个响应式数组,其初始值设置为currenciesArray。我们传递给refreactive的参数是每个响应式属性的初始值。

接下来,我们可以通过编写以下代码来添加计算属性:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const fromCurrencies = computed(() => {
      return currencies.filter(
        ({ abbreviation }) => abbreviation !== 
          toCurrency.value
      );
    });
...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };  },
};
</script>

我们调用computed函数并使用回调来创建计算属性。与选项 API 一样,我们返回我们想要的计算属性的值。唯一不同的是,我们使用value属性获取基本值响应式属性的值。fromCurrencies响应式属性是通过筛选具有与toCurrency相同值的缩写的货币条目创建的。toCurrencies是通过筛选具有与fromCurrency值相同的缩写值的货币条目创建的。

amountValid计算属性让我们确定在ion-input中输入的金额是否有效。我们希望它是至少为0的数字,因此我们返回该条件,以便我们可以检查这一点。

接下来,我们将通过向setup()方法添加更多项目来向CurrencyConverter组件添加这些方法:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const addToHistory = (entry: CurrencyConversion) =>
      store.commit("addToHistory", entry);
    const calculate = async () => {
      result.value = 0;
      if (!amountValid.value) {
        return;
      ...
      });
      result.value = amount.value * 
        rates[toCurrency.value];
    };    
    ...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };  },
};
</script>

addToHistory()方法让我们向 Vuex 存储和本地存储添加历史记录条目,以便我们可以在主页上显示活动。这样,我们以后可以选择它们并进行相同的计算。在签名中,我们使用CurrencyConversion接口注释条目参数的类型,以便我们知道我们向 Vuex 存储和本地存储添加了正确的内容。我们将addToHistory提交到存储,并将历史记录条目作为有效负载。

正在处理计算方法

calculate()方法中,我们将结果的值重置为0。然后,我们调用addToHistory将条目添加到历史记录中。id属性是从uuidv4函数生成的,用于为条目生成唯一 ID。我们从响应式属性值设置其他属性。value属性是访问基本值响应式属性所必需的。

然后,我们使用 Axios 从免费使用的汇率 API 获取汇率。我们只需将基本查询参数设置为我们要转换的货币代码。最后,我们通过将从 API 检索到的汇率乘以金额来计算转换值的结果。

然后,为了完成CurrencyConverter组件,我们添加了查询字符串的监视器。我们监视queryID参数,如果我们从主页打开历史记录条目,它将发生变化。要添加监视器,我们可以编写以下代码:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    watch(
      () => route.query,
      (query) => {
        const { id: queryId } = query;
        const { history } = store.state;
        const entry = history.find(
          ({ id }: CurrencyConversion) => id === queryId
        );
        if (!entry) {
          return;
        }
      ...
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };
  },
};
</script>

要创建观察者,我们传入一个返回route.query的函数来返回查询对象。route变量被分配给我们之前调用的useRoute函数的返回值。然后,我们从函数的第二个参数中获取查询对象的值。我们从查询对象中获取id属性。然后,我们从存储中获取历史状态,其中包含我们在本地存储中存储的所有条目。本地存储会自动由vuex-persistedstate同步到 Vuex 存储中。

我们调用history.find()方法来通过其id查找条目。然后,返回一个条目,并将retrieved属性值设置为响应式属性值。当我们从历史记录中选择条目时,它会自动填充它们。

在第三个参数中,我们有一个对象,其中的 immediate 属性设置为true,以便在组件挂载时立即运行观察者。

我们使用return语句返回要在模板中公开的所有内容。我们包括所有的响应式属性、计算属性和方法,以便它们可以在模板中使用。

项目完成后,货币转换器应如下所示:

图 5.2 - 货币转换器

图 5.2 - 货币转换器

添加小费计算器

接下来,我们将添加TipsCalculator页面组件。要添加它,我们必须添加src/views/TipCalculator.vue文件。在其中,我们将首先添加模板和标题:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Tips Calculator</ion-title>
      </ion-toolbar>
    </ion-header>
    ...
  </ion-page>
</template>

ion-headerCurrencyConverter几乎相同,只是ion-title内容不同。

接下来,我们添加ion-content组件以添加页面内容。为此,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          <ion-list>
            <ion-item>
              <ion-label :color="!amountValid ? 'danger' : 
                undefined">
                  ...
                  {{ c.name }}
                </ion-select-option>
              </ion-select>
            </ion-item>
      ...            
          </ion-list>
          ...
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

在上述代码中,我们添加了ion-list和表单控件的 ion 项。我们可以在页面上输入小费前的金额。第二个ion-item组件让我们添加country ion-select控件。它让我们选择一个国家,以便我们可以获得该国家的小费率。小费率是从tippingRate计算属性计算出来的。ion-select-option是从countries响应式数组属性创建的,它提供了我们可以选择的国家列表,以获取它们的小费率。

接下来,我们将添加小费率的显示和计算小费按钮。为此,我们将编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          <ion-list>
            ...
            <ion-item>
              <ion-label>Tipping Rate</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                tippingRate }}% </ion-label>
            </ion-item>
            <ion-item>
              <ion-button size="default" 
                @click="calculateTip">
                Calculate Tip
              </ion-button>
            </ion-item>
          </ion-list>
...
          ...
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

我们只显示tippingRate计算属性的值和计算小费按钮。我们通过添加@click指令并将其设置为calculateTip()方法来添加点击处理程序。

模板的最后部分是计算结果。我们向组件添加ion-list来添加结果。我们显示小费和合计。要添加它,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          ...
          <ion-list>
            <ion-item>
              <ion-label>Tip (Local Currency)</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                result.tip }} </ion-label>
            </ion-item>
            <ion-item>
              <ion-label>Total (Local Currency)</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                result.total }} </ion-label>
            </ion-item>
          </ion-list>
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

接下来,我们将为TipsCalculator组件添加 TypeScript 代码。它的结构类似于CurrencyConverter组件。首先,我们通过编写以下代码添加导入:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  IonMenuButton,
  IonPage,
  IonTitle,
  IonToolbar,
  IonSelect,
  IonSelectOption,
  IonInput,
  IonLabel,
  IonButton,
  IonList,
  IonItem,
} from "@ionic/vue";
import { computed, reactive, ref, watch } from "vue";
import { countries as countriesArray } from "../constants";
import { useStore } from "vuex";
import { TipCalculation } from "@/interfaces";
import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router";
...
</script>

我们像在CurrencyConverter.vue中一样导入所有组件和库。

然后,我们像在CurrencyConverter中一样注册组件:

<script lang="ts">
...
export default {
  name: "TipsCalculator",
  components: {
    IonButtons,
    IonContent,
    IonHeader,
    IonMenuButton,
    IonPage,
    IonTitle,
    IonToolbar,
    IonSelect,
    IonSelectOption,
    IonInput,
    IonLabel,
    IonButton,
    IonList,
    IonItem,
  },
  ...
};
</script>

然后,我们在setup()方法中定义响应式属性并获取存储和路由:

<script lang="ts">
...
export default {
...
  ...
  setup() {
    const store = useStore();
    const route = useRoute();
    const subtotal = ref(0);
    const countries = reactive(countriesArray);
    const country = ref("Afghanistan");
    ...
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

我们像在CurrencyConverter中一样调用useStoreuseRoute。然后,我们使用ref函数创建subtotal响应式属性。由于它的值是一个数字,我们使用ref函数来创建它。country数组的响应式属性是用reactive函数创建的。

接下来,我们必须通过编写以下代码来添加一些计算属性:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const tippingRate = computed(() => {
      if (["United States"].includes(country.value)) {
        return 20;
      } else if (
        ["Canada", "Jordan", "Morocco", "South 
          Africa"].includes(country.value)
      ) {
        return 15;
      } else if (["Germany", "Ireland", 
         "Portugal"].includes(country.value)) {
        return 5;
      }
      return 0;
    });
    const amountValid = computed(() => {
      return +subtotal.value >= 0;
    });
    ...
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

在这里,我们根据所选的国家计算小费率。

amountValid 计算属性让我们检查subtotal值是否有效。我们希望它为0或更大。

接下来,我们将把其余的项目添加到组件中:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const result = reactive({
      tip: 0,
      total: 0,
    });
    const addToHistory = (entry: TipCalculation) =>
      store.commit("addToHistory", entry);
      ...
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

result 响应式属性包含小费计算的结果。tip 属性包含小费金额。最后,total 属性包含subtotaltip的总和。

calculateTip() 方法让我们计算小费。result 属性的值首先被初始化为0。然后,我们检查amountValid是否为真。如果不是,我们停止运行该函数。否则,我们使用addToHistory函数将历史记录条目添加到存储和本地存储中。接下来,我们在calculateTip()方法的最后两行进行小费计算。

最后,我们通过编写以下代码将观察者添加到setup()方法中:

<script lang="ts">
...
export default {
...
  setup() {
    ...
    watch(
      () => route.query,
      (query) => {
        const { id: queryId } = query;
        const { history } = store.state;
        const entry = history.find(({ id }: TipCalculation)
         => id === queryId);
        if (!entry) {
          return;
        }
        const {
          subtotal: querySubtotal,
          country: queryCountry,
        }: TipCalculation = entry;
        subtotal.value = querySubtotal;
        country.value = queryCountry;
      },
      { immediate: true }
    );
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

就像在CurrencyConverter.vue中一样,我们监视解析的查询字符串对象,并在找到时从历史记录条目中填充值。

最后,我们使用return语句返回所有要暴露给模板的项目,包括任何响应式和计算属性以及方法。完成项目后,我们应该看到以下屏幕:

图 5.3 - 小费计算器

图 5.3 - 小费计算器

添加主页

接下来,我们将添加Home.vue页面组件,这将让我们查看到目前为止我们所做的计算。我们可以通过打开页面重新进行计算,使用历史记录中填充的数值。为了添加计算历史列表,我们将从其模板开始:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Home</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content fullscreen>
        ...
        </ion-list>
      </div>
    </ion-content>
  </ion-page>
</template>

我们使用了与其他页面相同的标题,但这个页面有不同的标题。

然后,我们渲染historyWithTypes计算属性来渲染历史记录中的项目。如果type属性设置为tip,我们渲染小费计算数据。否则,我们显示货币转换数据。在每一行中,我们有打开按钮,当我们点击它时调用go()方法。这将带我们到页面,该页面由CurrencyCoverterTipsCalculatorwatchers填充了历史记录中给定的数值。删除按钮调用deleteEntry()方法,通过其索引删除条目。我们必须记得为每个条目设置key属性为唯一 ID,以便 Vue 可以正确跟踪它们。

接下来,我们将通过编写以下代码来添加导入:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  IonMenuButton,
  IonPage,
  IonTitle,
  IonToolbar,
  IonLabel,
  IonButton,
  IonList,
  IonItem,
} from "@ionic/vue";
import { useStore } from "vuex";
import { computed } from "vue";
import { CurrencyConversion, TipCalculation } from 
 "@/interfaces";
import { useRouter } from "vue-router";
...
</script>

然后,我们将为我们的历史记录条目添加type别名,并通过编写以下代码注册组件代码:

<script lang="ts">
...
type HistoryEntry = CurrencyConversion | TipCalculation;
export default {
  name: "Home",
  components: {
    IonButtons,
    IonContent,
    IonHeader,
    IonMenuButton,
    IonPage,
    IonTitle,
    IonToolbar,
    IonLabel,
    IonButton,
    IonList,
    IonItem,
  },
  ...
};
</script>

我们创建HistoryEntry TypeScript 类型别名,它是CurrencyConversionTipCalculation接口的联合。HistoryEntry类型的对象必须具有CurrencyConversionTipCalculation接口的结构。然后,我们像注册其他组件一样注册组件。

接下来,我们将添加setup()方法来添加组件的逻辑:

<script lang="ts">
...
export default {
  ...
  setup() {
    const store = useStore();
    const router = useRouter();
    const history = computed(() => store.state.history);
    const historyWithTypes = computed(() => {
      return history.value.map((history: HistoryEntry): 
        HistoryEntry & {
        type: string;
      } => {
        if ("subtotal" in history) {
          return {
            ...history,
            type: "tip",
          };
        }
        return {
          ...history,
          type: "conversion",
        };
      });
    });
    const go = (history: HistoryEntry & { type: string }) 
     => {
      const { type, id } = history;
      if (type === "tip") {
        router.push({ path: "/tips-calculator", query: { id
         } });
      } else {
        router.push({ path: "/currency-converter", query: { 
         id } });
      }
    };
    const deleteEntry = (index: number) => {
      store.commit("removeHistoryEntry", index);
    };
    return {
      history,
      historyWithTypes,
      go,
      deleteEntry,
    };  
  },
};
</script>

我们像往常一样使用 useStoreuseRouter 获取存储和路由。然后,我们使用 Vuex 存储中的 history 计算属性获取历史状态。然后,我们使用 history 计算属性创建 historyWithTypes 计算属性。这使我们可以向对象添加 type 属性,以便我们可以轻松地在模板中区分项目的类型。在 map 回调中,我们将返回类型设置为 HistoryEntry & { type: string },以创建与 HistoryEntry 组成的接口和 { type: string } 类型的交集类型。HistoryEntry & { type: string } 与 CurrencyConversion & { type: string } | TipCalculation & { type: string } 相同,因为 & 运算符在与联合 (|) 运算符一起使用时会分发。

go() 方法允许我们使用 id 属性作为 id 查询参数的值导航到正确的页面,当我们调用 router.push 时。path 属性包含了我们在路由定义中指定的 URL 路径,而 query 属性包含了用于在路径之后形成查询字符串的对象。

deleteEntry() 方法允许我们通过提交 removeHistoryEntry 突变来删除条目。

我们返回所有方法和计算属性,以便它们可以在模板中使用。主页 应该如下截图所示:

图 5.4 – 主屏幕

图 5.4 – 主屏幕

创建 Vuex 存储

现在,我们需要创建 Vuex 存储。为此,我们将创建 src/vue/index.ts 文件并编写以下代码:

import { createStore } from 'vuex'
import createPersistedState from "vuex-persistedstate";
import {
  CurrencyConversion,
  TipCalculation
} from '../interfaces'
type HistoryEntry = CurrencyConversion | TipCalculation
const store = createStore({
  plugins: [createPersistedState()],
  state() {
    return {
      history: []
    }
  },
  mutations: {
    addToHistory(state: { history: HistoryEntry[] }, entry:
      HistoryEntry) {
      state.history.push(entry)
    },
    removeHistoryEntry(state: { history: HistoryEntry[] },
     index: number) {
      state.history.splice(index, 1)
    },
  }
})
export default store

在这里,我们有与 Home.vue 相同的接口和类型别名。我们使用 createStore 函数创建了 Vuex 存储。plugins 属性设置为 createPersistedState 函数返回的数组,以便我们将存储状态保存到本地存储中。我们在 state() 方法中有历史状态。mutations() 方法有 addToHistory 突变,它允许我们向历史数组状态添加条目。我们还有 removeHistoryEntry,它允许我们根据索引从历史状态中删除历史项目。我们必须记得在最后导出存储,以便我们以后可以导入它。

然后,我们需要添加国家和货币列表。为了添加它们,我们将创建 src/constants.ts 文件并添加以下代码:

import { Choice } from "./interfaces";
export const countries: Choice [] = [
  {
    "name": "Afghanistan",
    "abbreviation": "AF"
  },
  {
    "name ": "Åland Islands",
    "abbreviation": "AX"
  },
  ...
]
export const currencies: Choice[] = [
  {
    "name": "United States Dollar",
    "abbreviation": "USD"
  },
  {
    "name": "Canadian Dollar",
    "abbreviation": "CAD"
  },
  {
    "name": "Euro",
    "abbreviation": "EUR"
  },
]

完整的文件内容可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter05/src/constants.ts找到。

现在,我们将通过添加src/interfaces.ts文件并添加以下代码来添加我们导入的接口:

export interface Choice {
  name: string,
  abbreviation: string
}
export interface CurrencyConversion {
  id: string,
  amount: number,
  fromCurrency: string,
  toCurrency: string,
}
export interface TipCalculation {
  id: string,
  subtotal: number,
  country: string,
}

main.ts中,我们必须通过编写以下代码向我们的应用程序添加存储:

...
const app = createApp(App)
  .use(IonicVue)
  .use(router)
  .use(store);
...

我们添加了.use(store),这样我们就可以在我们的应用程序中使用存储。

最后,在App.vue中,我们必须更新左侧菜单的项目。在模板中,我们必须编写以下内容:

<template>
  <IonApp>
    <IonSplitPane content-id="main-content">
      <ion-menu content-id="main-content" type="overlay">
        <ion-content>
          <ion-list id="unit-list">
            <ion-list-header>Calculator</ion-list-header>
            <ion-menu-toggle
...
      <ion-router-outlet id="main-content"></ion-router-
        outlet>
    </IonSplitPane>
  </IonApp>
</template>

ion-menu-toggle组件包含我们可以点击或轻触以转到给定页面的菜单项,由router-link属性指定。ion-router-outlet组件是我们之前创建的页面所在的位置。ion-icon组件让我们显示每个条目的图标。

接下来,我们将通过编写以下代码为App.vue添加导入项:

<script lang="ts">
import {
  IonApp,
  IonContent,
  IonIcon,
  IonItem,
  IonLabel,
  IonList,
  IonListHeader,
  IonMenu,
  IonMenuToggle,
  IonRouterOutlet,
  IonSplitPane,
} from "@ionic/vue";
import { computed, defineComponent, ref, watch } from 
 "vue";
import { RouterLink, useLink, useRoute } from "vue-router";
import { cashOutline, homeOutline } from "ionicons/icons";
...
</script>

现在,我们将通过编写以下代码添加组件逻辑:

export default defineComponent({
  name: "App",
  components: {
    IonApp,
    IonContent,
    IonIcon,
    IonItem,
    IonLabel,
    IonList,
    IonListHeader,
    IonMenu,
    IonMenuToggle,
    IonRouterOutlet,
    IonSplitPane,
  },
  setup() {
    const selectedIndex = ref(0);
    const appPages = [
      ...
      {
        title: "Tips Calculator",
        url: "/tips-calculator",
        iosIcon: cashOutline,
        mdIcon: cashOutline,
      },
    ];
    const route = useRoute();
    return {
      selectedIndex,
      appPages,
      cashOutline,
      route,
    };
  },
});

在这里,我们注册了组件并添加了appPages属性来渲染项目。它不是一个响应式属性,因为我们没有使用 reactive 创建它,但是我们可以在模板中使用它,因为我们返回了它。现在,我们将通过编写以下代码添加一些样式:

<style scoped>
...
.selected {
  font-weight: bold;
}
</style>

接下来,我们将通过编写以下代码添加一些全局样式:

<style>
ion-menu-button {
  color: var(--ion-color-primary);
}
#container {
  text-align: center;
  position: absolute;
  left: 0;
  right: 0;
}
#container strong {
  font-size: 20px;
  line-height: 26px;
}
#container p {
  font-size: 16px;
  line-height: 22px;
  color: #8c8c8c;
  margin: 0;
}
#container a {
  text-decoration: none;
}
</style>

通过创建项目,我们学会了如何使用 Composition API,这是 Ionic 用来创建 Vue 项目的。我们还学会了如何在 JavaScript 代码中添加 TypeScript 类型注释,以防止代码中的数据类型错误。最后,我们学会了如何从 Web 应用程序创建移动应用程序。

总结

使用 Ionic Vue,我们可以轻松地使用 Vue 3 创建移动应用程序。它利用组合 API、TypeScript 和 Vue Router,以及 Ionic 提供的组件,创建出外观良好的应用程序,可以作为 Web 应用程序或移动应用程序运行。它还配备了所有必要的工具,可以在设备或模拟器中预览应用程序,并将其构建成应用程序包,我们可以部署到应用商店。

使用 Composition API,我们可以像使用 Vue Options API 一样添加逻辑,但我们可以使用函数而不是引用它们来添加所有逻辑。Ionic Vue 还将 TypeScript 作为组件的默认语言。这使我们能够在编译时防止类型错误,减少类型错误在运行时发生的几率。这是一个方便的功能,可以减少 JavaScript 开发中的挫败感。我们利用接口、联合和交集类型以及类型别名来定义对象的类型。

在下一章中,我们将看看如何使用 PrimeVue 和 Express 构建旅行预订应用程序。

第六章:使用 PrimeVue UI 框架构建度假预订应用

第五章中,使用 Ionic 构建多用途计算器移动应用,我们使用 Ionic 移动应用框架构建了一个移动应用,该框架构建在 Vue.js 之上。然而,到目前为止,在本书中,我们尚未使用基于 Vue.js 的 UI 库或框架构建任何 Web 应用程序。此外,我们还没有构建具有自己后端的任何内容。大多数系统肯定需要后端,因为我们需要一个地方来存储我们的数据、验证用户、运行后台任务等。在本章中,我们将使用 PrimeVue UI 框架构建度假预订应用。

我们将使用 Vue 3 前端进行管理,另一个前端供用户添加他们的预订。我们还将包括一个简单的后端,以在管理员执行只有他们才能完成的任务之前对其进行身份验证。为了尽可能简化项目,普通公众的前端不需要身份验证。

在本章中,我们将专注于以下主题:

  • 使用 PrimeVue UI 框架构建前端

  • 使用 Express 构建简单的后端进行身份验证

  • 使用 SQLite 在后端持久化数据

  • 在前端使用 Vue Router 进行身份验证

  • 使用 Vee-Validate 和 Yup 进行表单验证

技术要求

本章的代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter06

PrimeVue是基于 Vue 3 的 UI 框架。这意味着我们可以将其用于 Vue 3 应用程序。基于 Vue 2 的框架无法用于 Vue 3 应用程序,因为 API 已经经历了重大变化。Vue 3 的底层代码也与 Vue 2 不同。PrimeVue 包括许多我们在 Web 应用程序中使用的常见组件,如文本输入、按钮、菜单栏、表格等。它在包含的内容方面非常全面。此外,它还提供了主题样式。这意味着我们可以立即使用内置组件。由于 PrimeVue 是为 Vue 3 而制作的,我们可以简单地注册组件、导入 CSS,并在我们的代码中使用组件。我们还可以根据需要为给定组件在本地或全局注册它们。

理解 PrimeVue

PrimeVue 带有输入框和各种文本的样式,例如验证错误和按钮。它还带有 flexbox 助手,我们可以使用它们轻松设置组件的间距。这非常有用,因为我们可以简单地使用 PrimeVue 提供的 CSS 类来设置组件的位置和间距。

到目前为止,在本书中,我们还没有使用任何库来使表单验证更加方便。表单验证是大多数 Web 应用程序中我们必须经常做的事情。

理解 Vee-Validate 和 Yup

Vee-Validate 4是与 Vue 3 兼容的表单验证库。借助它,我们可以添加组件,以在前端对我们的表单进行验证。我们将与 Yup 数据验证库一起使用它,以创建表单验证模式,Vee-Validate 可以用于验证。通过Form组件,我们可以添加一个表单,使我们能够使用 Vee-Validate 4 进行表单验证。然后,Field组件可以用于验证 PrimeVue 提供的表单控件组件。它通过包装它们并将Field组件提供的插槽属性作为输入组件的属性传递来实现这一点。

Yup 库将与 Vee-Validate 一起使用,让我们可以轻松验证表单值,而无需从头开始编写所有代码。它允许我们创建表单验证模式对象,我们可以将其传递到使用 Vee-Validate 创建的表单中,以添加表单验证。

理解 Express

为了创建一个简单的后端来存储数据,我们使用Express 框架。这是一个非常简单的 Node.js 后端框架,可以让我们快速创建一个简单的后端。为了存储数据,我们将使用 SQLite 数据库来保持项目简单。我们将使用 Express 框架创建一个 API,以便前端可以向其发出 HTTP 请求。我们通过公开 API 端点来让他们发出请求,前端可以通过添加路由来使用它。每个路由都有一个处理程序函数,用于处理前端提交的数据。我们从前端发出的 HTTP 请求中获取请求数据,其中包括头部和主体,并在路由处理程序中使用它们来获取和存储我们想要的数据。

连接前端和后端

为了使前端应用与后端应用通信,我们需要在后端启用跨域通信,以便来自前端的流量可以通过到达后端。这可以通过我们将添加到 Express 应用程序的跨域资源共享CORS)中间件轻松完成。

为了使用 SQLite 数据库,我们使用sqlite3库,它允许我们在 Node.js 应用程序中操作 SQLite 数据库。我们可以进行查询并运行 SQL 命令来插入或删除数据库中的数据。

此外,我们将为管理员前端设置简单的身份验证。我们将检查管理员登录的用户名和密码,如果有效,我们可以发放一个令牌并将其发送到前端。然后,前端将使用存储在标头中的令牌来检查请求是否可以从前端发出。我们仅为仅限管理员路由添加身份验证,因此我们只需要在加载需要身份验证的路由之前检查令牌。

为了创建和检查令牌,我们使用jsonwebtoken库。这允许我们创建一个令牌并用秘密字符串签名。它还使我们能够使用秘密检查令牌是否有效。我们将jsonwebtoken库放在一个中间件中,在路由处理程序之前运行以进行检查。

如果令牌有效,则调用函数继续到路由处理程序。否则,我们向客户端发送401状态。

现在,我们将构建项目。

创建度假预订项目

创建度假预订应用程序,我们需要为前端、管理员前端和后端创建子项目。我们使用 Vue CLI 来创建frontendadmin frontend项目脚手架。我们使用Express Generator全局包来创建backend文件夹。

按照以下步骤设置项目:

  1. 首先,创建travel-booking-app文件夹来容纳所有项目。

  2. 接下来,在主文件夹内创建admin-frontendfrontendbackend文件夹。

  3. 进入admin-frontend文件夹并运行以下命令:

npx vue create

这将在admin-frontend文件夹内添加 Vue 项目的脚手架代码。

  1. 如果要在当前文件夹中创建项目,请选择Y。然后,在要求选择项目的 Vue 版本时,选择Vue 3

同样,以相同的方式在frontend文件夹中运行 Vue CLI。

  1. 要创建一个 Express 项目,请运行 Express 应用程序生成器应用程序。要做到这一点,请进入backend文件夹并运行以下命令:
npx express-generator

上述命令将在backend文件夹中添加我们项目所需的所有文件。如果出现错误,请尝试以“管理员”身份运行express-generator

现在我们已经创建了项目的脚手架文件和文件夹,我们准备开始在后端上工作。

创建后端

现在我们已经创建了带有脚手架代码的项目文件夹,我们可以开始编写项目代码了。我们将从后端开始,因为我们需要它用于两个前端。

要开始,让我们添加一些需要用来操作 SQLite 数据库并向我们的应用程序添加身份验证的库。此外,我们需要一个库来向我们的应用程序添加 CORS。

要安装它们,请运行以下命令:

npm i cors jsonwebtoken sqlite3

安装完这些包后,我们就可以开始编写代码了。

添加身份验证中间件

首先,我们添加我们将用来检查令牌的中间件。我们可以很容易地使用 jsonwebtoken 库来做到这一点。它具有 verify 方法来检查令牌。

要添加中间件,请在后端文件夹中创建middlewares文件夹,然后添加verify-token.js文件。接下来,添加以下中间件的代码:

const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
  const token = req.get('x-token')
  try {
    jwt.verify(token, 'secret');
    next()
  } catch (err) {
    res.status(401)
  }
}

在这里,我们使用req.get方法获取x-token请求头。然后,我们使用返回的tokensecret调用jwt.verify来验证令牌是否有效。然后,如果有效,我们调用next。如果无效,将抛出错误,并运行catch块。运行res.status401来将401响应返回给前端,因为在这种情况下令牌无效。

module.exports属性设置为middleware函数作为值,我们正在导出它。导出函数使其在我们后端应用程序的其他模块中可用。

添加处理请求的路由

接下来,我们将添加带有路由的router模块。首先,添加用于操作预订的路由。要做到这一点,请将bookings.js文件添加到routes文件夹中。在文件中,编写以下代码:

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const router = express.Router();
const verifyToken = require('../middlewares/verify-token')
router.get('/', (req, res, next) => {
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    db.all(`
      SELECT
        bookings.*,
        catalog_items.name AS catalog_item_name,
        catalog_items.description AS catalog_item_description
      FROM bookings
      INNER JOIN catalog_items ON catalog_items.id = 
        bookings.catalog_item_id
    `,
      [],
      (err, rows = []) => {
        res.json(rows)
      });
  })
  db.close();
});
...

在这里,我们导入所需的模块,包括我们刚刚创建的“verify-token 中间件”文件。

router.get方法允许我们创建一个 GET 请求 API 端点。路径在第一个参数中。它是路由的路径,相对于路由器的路径。因此,路由器的路由路径是第一个部分,而router.get的第一个参数中的路径形成 URL 的其余部分。

router.get方法的第二个参数是路由处理程序。req参数是一个包含请求数据的对象。res参数是一个对象,让我们可以向前端发送各种类型的响应。我们使用sqlite3.Database构造函数获取数据库,并指定数据库文件的路径。

接下来,我们调用db.serialize函数,以便可以按顺序运行回调中的代码。

db.all方法获取查询返回的所有结果。字符串是从 bookings 表中检索所有数据的 SQL 命令,我们将使用我们自己的 SQL 代码创建它。bookings表与catalog_items表连接,以便vacation套餐数据与预订相关联。

db.all的第二个参数是我们想要传递的额外参数,我们通过传入一个空数组来忽略它们。然后,在最后一个参数中,我们有一个带有err参数的函数,其中包含对象形式的错误。rows参数包含查询结果。在回调中,我们调用res.json返回带有rows参数数据的 JSON 响应。

然后,我们调用db.close来在所需操作完成后关闭数据库连接。

接下来,我们将创建一个 POST 路由。这将允许我们运行INSERT SQL 命令将条目插入到 bookings 表中。添加以下代码:

...
router.post('/', (req, res) => {
  const db = new sqlite3.Database('./db.sqlite');
  const { catalogItemId, name, address, startDate, endDate } = 
   req.body
  db.serialize(() => {
    const stmt = db.prepare(`
      INSERT INTO bookings (
        catalog_item_id,
        name,
        address,
        start_date,
        end_date
      ) VALUES (?, ?, ?, ?, ?)
    `);
    stmt.run(catalogItemId, name, address, startDate, endDate)
    stmt.finalize();
    res.json({ catalogItemId, name, address, startDate,
      endDate })
  })
  db.close();
});
...

在这里,我们使用req.body属性获取 body 属性。我们获取所有要插入条目的属性。接下来,我们使用INSERT语句创建一个预处理语句。最后的值是问号;这意味着它们是占位符,我们可以在运行stmt.run方法时放入我们自己的值。

准备好的语句很有用,因为它们使我们能够安全地传递值到我们的 SQL 命令中。这些值都经过了净化,以便恶意代码无法在代码中运行。我们运行stmt.run来运行带有我们想要替换占位符的值的准备好的语句。然后,我们调用stmt.finalize来通过写入数据来完成操作。接下来,我们调用res.json将 JSON 响应返回给前端作为响应。然后,我们调用db.close再次关闭数据库连接。

接下来,我们将使用router.delete方法创建一个DELETE端点。为此,请编写以下代码:

...
router.delete('/:id', verifyToken, (req, res) => {
  const db = new sqlite3.Database('./db.sqlite');
  const { id } = req.params
  db.serialize(() => {
    const stmt = db.prepare("DELETE FROM bookings WHERE id = (?)");
    stmt.run(id)
    stmt.finalize();
    res.json({ status: 'success' })
  })
  db.close();
});
...

在这里,我们有/:id路径。:id是路由的 URL 参数占位符。我们还有verifyToken中间件,我们在booking.js文件的顶部导入了它。我们可以使用它来在继续运行路由处理程序之前验证令牌。这意味着这个路由是一个需要在 API 端点调用的标头中需要令牌的经过身份验证的路由。

在路由处理程序中,我们从req.params属性中获取id URL 参数。然后,我们调用db.serialize,就像我们在之前的路由中所做的那样。在回调中,我们有准备好的语句,因此我们可以使用DELETE SQL 命令和我们在stmt.run方法中设置的id值。然后,我们调用stmt.finalizeres.jsondb.close,就像我们在其他路由中所做的那样。

最后,在booking.js文件的末尾,让我们添加以下内容:

module.exports= = router;

添加上述语句使我们能够将其导入到另一个文件中以注册路由。注册路由将使其可访问。

接下来,我们将在routes文件夹中创建catalog.js文件。这个文件是一个具有 API 端点的router模块,用于添加我们的度假套餐。首先,我们开始如下:

const express = require('express');
const router = express.Router();
const sqlite3 = require('sqlite3').verbose();
const verifyToken = require('../middlewares/verify-token')
router.get('/', (req, res,) => {
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    db.all("SELECT * FROM catalog_items", [], (err, rows = []) 
      => {
      res.json(rows)
    });
  })
  db.close();
});
...

这几乎与bookings.js中的GET路由相同;但是,在这里,我们从catalog_items表中检索所有项目。

接下来,让我们添加一个POST路由来将条目添加到catalog_items表中。编写以下代码:

...
router.post('/', verifyToken, (req, res,) => {
  const { name, description, imageUrl } = req.body
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    const stmt = db.prepare(`
    INSERT INTO catalog_items (
      name,
      description,
      image_url
    ) VALUES (?, ?, ?)
  `
    );
    stmt.run(name, description, imageUrl)
    stmt.finalize();
    res.json({ status: 'success' })
  })
  db.close();
});
...

在这里,我们在第二个参数中有verifyToken来检查此路由中的令牌,然后再运行第三个参数中的路由处理程序。

接下来,我们添加一个路由,使我们能够删除catalog_items条目。我们使用以下代码来实现这一点:

...
router.delete('/:id', verifyToken, (req, res,) => {
  const { id } = req.params
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    const stmt = db.prepare("DELETE FROM catalog_items WHERE 
      id = (?)");
stmt.run(id)
stmt.finalize();
res.json({status:'success'})
db.close();
});
...

最后,我们导出路由:

module.exports = router;

这个模块与booking.js并没有太大的不同。

接下来,我们删除routes/users.js文件的内容,或者如果不存在,则创建它。然后,我们添加以下代码:

const express = require('express');
const jwt = require('jsonwebtoken');
const router = express.Router();
router.post('/login', (req, res) => {
  const { username, password } = req.body
  if (username === 'admin' && password === 'password') {
    res.json({ token: jwt.sign({ username }, 'secret') })
  }
  res.status(401)
});
module.exports= = router;

这是我们检查管理员用户的usernamepassword是否有效的地方。我们只有一个用户需要检查,以保持项目简单。我们从req.body对象中获取usernamepassword,该对象具有 JSON 请求对象。然后,我们使用if语句检查usernamepassword,如果if中的表达式返回true,我们调用jwt.sign使用第一个参数中的令牌数据和第二个参数中的secret创建令牌。然后,我们使用res.json返回带有认证令牌的响应。

否则,我们调用res.status401返回401响应,因为usernamepassword无效。

接下来,在app.js中注册我们的router模块和全局中间件。为此,我们编写以下代码:

...
const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');
const catalogRouter = require('./routes/catalog');
const bookingsRouter = require('./routes/bookings');
const app = express();
const cors = require('cors')
...
app.use('/users', usersRouter);
app.use('/catalog', catalogRouter);
app.use('/bookings', bookingsRouter);

我们导入了之前使用require导出的router模块。然后,我们导入了cors模块。

const cors = require('cors')

我们调用app.use添加cors中间件,然后添加router模块。在最后三行中,我们将path作为第一个参数,router模块作为第二个参数。这使我们能够访问之前创建的端点。使用cors模块,我们可以在 Express 应用程序中启用跨域通信。

接下来,让我们创建我们的 SQL 脚本,以便我们可以轻松地删除和创建表。为此,在backend文件夹中创建db.sql文件,并编写以下代码:

DROP TABLE IF EXISTS bookings;
DROP TABLE IF EXISTS catalog_items;
CREATE TABLE catalog_items (
  id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  image_url TEXT NOT NULL
);
CREATE TABLE bookings (
  id INTEGER NOT NULL PRIMARY KEY,
  catalog_item_id INTEGER NOT NULL,
  name TEXT NOT NULL,
  address TEXT NOT NULL,
  start_date TEXT NOT NULL,
  end_date TEXT NOT NULL,
  FOREIGN KEY (catalog_item_id) REFERENCES catalog_items(id)
);

在这里,我们创建了bookingscatalog_items表。每个表都有各种字段。TEXT创建文本列。NOT NULL使列不可为空。PRIMARY KEY表示该列是主键列。FOREIGN KEY表示一个列是另一个列的外键。

我们可以通过安装 SQLite 程序的 DB 浏览器来运行 SQL 代码,可以在sqlitebrowser.org/下载,然后在backend文件夹中创建db.sqlite。然后,我们可以转到执行 SQL选项卡,并将代码粘贴到文本输入中。之后,我们可以选择所有文本,按下F5来运行代码。这将删除任何现有的bookingscatalog_items表,并重新创建它们。要将更改写入磁盘,您必须保存它们。要做到这一点,点击文件菜单,然后点击写入更改。我们也可以按下Ctrl + S键组合来保存更改。

最后,为了使我们的应用程序在更改代码时自动运行和重新启动,我们可以全局安装nodemon包。要做到这一点,请运行以下命令:

npm i -g nodemon

然后,在package.json文件中,将script.start属性的值更改为以下代码:

{
  ...
  "scripts": {
    "start": "nodemon ./bin/www"
  },
  ...
}

我们可以使用nodemon来运行npm start,而不是常规的 node 可执行文件,这意味着当我们更改任何代码文件并保存时,应用程序将自动重新启动。

现在我们已经为前端创建了一个基本的后端来消耗,我们可以继续使用 PrimeVue 和 Vue 3 创建我们的前端应用程序。

创建管理前端

现在后端应用程序已经完成,我们可以继续在管理前端上工作。我们之前已经在admin-frontend文件夹中为管理前端创建了 Vue 3 项目,因此我们只需要安装所需的包并处理代码。我们将需要 PrimeVue 包 - 即 Vee-Validate、Vue Router、Axios 和 Yup 包。

要安装它们,请在admin-frontend文件夹中运行以下命令:

npm i axios primeflex primeicons primevue@³.1.1 vee-validate@next vue-router@4 yup

Axios 允许我们向后端发出 HTTP 请求。Vue Router 允许我们将 URL 映射到page组件。Vee-Validate 和 Yup 允许我们轻松地向我们的表单添加表单验证,其余的包是 PrimeVue 包。

创建管理前端页面

安装完包后,我们可以开始处理代码。首先,我们将处理组件。在components文件夹中,添加CatalogForm.vue文件,并编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <Field v-slot="{ field, errors }" v-model="name" 
      name="name">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Name"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Name is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们有来自 Vee-Validate 包的Form组件,用于添加具有表单验证的表单。只有当所有表单值都有效时,才会触发submit事件。我们稍后将注册Form组件。validation-schema属性设置为 Yup 创建的验证模式对象。

Form组件内,我们有一个Field组件,这也是由 Vee-Validate 包提供的。稍后我们还将全局注册此组件,以便我们可以使用它。在Field组件内,我们有InputText组件,用于在我们的应用程序中添加输入字段。为了为InputText组件启用表单验证,我们将field对象传递给slot属性,并将整个内容作为v-bind指令的值。v-bind指令允许 Vee-Validate 处理表单值并向我们的form字段添加验证。errors数组提供了可能发生的任何验证错误。

p-col-12类由 PrimeVue 的 PrimeFlex 包提供。它允许我们将div标签的宽度设置为全宽,这意味着它占据页面上的 12 列中的 12 列。使用p-inputgroup类,我们可以创建一个输入组。p-error类将文本颜色样式设置为红色,以便我们可以以用户易于看到的方式显示表单验证消息。p-invalid类使输入的边缘变为红色。仅当错误的长度大于0时,我们才将其更改为红色,因为这意味着存在验证错误,而且仅在错误的长度大于0时才显示较小的元素。

Field组件具有v-model指令,将输入的值绑定到相应的响应式属性。我们还有一个name属性,也用作submit事件处理程序的value参数的属性名称,该处理程序具有输入的值。这些值始终有效,因为只有当所有表单值都有效时,才会运行提交处理程序。

使用name字段,我们可以输入度假套餐的名称。

接下来,我们需要添加一个文本区域,以允许用户为度假套餐输入描述。要做到这一点,请编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="description" 
      name="description">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <Textarea
            placeholder="Description"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Description is invalid
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这与name字段几乎相同;但是,在这种情况下,我们将InputText组件替换为Textarea组件。我们还更改了v-modelname的值。Textarea组件来自 PrimeVue 包,它以自己的样式呈现为textarea元素。

接下来,我们添加图像 URL 字段,以便为度假套餐添加图像 URL。我们只需让用户输入图像 URL,以使我们的项目更简单。要将字段添加到Form组件中,请编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="imageUrl" 
      name="imageUrl">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Image URL"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Image URL is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这只是另一个文本输入,名称和v-model值不同。最后,让我们使用以下代码向表单添加一个submit按钮:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <div class="p-col-12">
      <Button label="Add" type="submit" />
    </div>
  </Form>
</template>

Button组件来自 PrimeVue 包,我们稍后将全局注册它,以使其在任何地方都可用。

接下来,我们添加component选项对象。我们使用component选项 API 来创建我们的组件。首先,我们导入所有内容,并使用 Yup 库创建表单验证模式。要添加代码,请在components/CatalogForm.vue中编写以下内容:

<script>
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  name: yup.string().required(),
  description: yup.string().required(),
  imageUrl: yup.string().url().required(),
});
export default {
  name: "BookingForm",
  data() {
    return {
      name: "",
      description: "",
      imageUrl: "",
      schema,
    };
  },
  ...
};
</script>

在这里,我们使用yup.object方法创建了模式对象,它允许我们验证具有一些属性的对象。validation模式与v-model绑定是分开的。我们传递给shape方法的对象属性必须与Field组件的name属性值匹配。

为了验证具有name设置为name的字段的值,我们将name属性设置为yup.string().required(),以确保name字段是一个字符串并且有值。我们为description设置相同的值。imageUrl值设置为yup.string().url().required(),以确保输入的值是一个 URL 并且已填写。

data方法返回模式,以便我们可以使用Form组件的validation-schema属性。

为了完成组件,我们添加了onSubmit方法,当Form组件发出submit事件时被调用:

<script>
...
export default {
  ...
  methods: {
    async onSubmit(value) {
      const { name, description, imageUrl } = value;
      await axios.post(`${APIURL}/catalog`, {
        name,
        description,
        imageUrl,
      });
      this.$emit("catalog-form-close");
    },
  },
};
</script>

在这里,我们只是从value参数中获取property值,该参数具有有效的表单字段值。然后,我们使用 JSON 负载进行 POST 请求到目录端点。随后,我们调用this.$emit方法来发出catalog-form-close事件,以通知对话框组件关闭此表单。

添加一个顶部栏和菜单栏

接下来,我们将在我们的应用程序中添加一个top bar组件。要做到这一点,在src/components文件夹中创建TopBar.vue。然后,将以下模板代码添加到文件中:

<template>
  <Menubar :model="items">
    <template #start>
      <b>Admin Frontend</b>
    </template>
  </Menubar>
</template>

Menubar组件由 PrimeVue 组件提供。我们可以使用它来添加一个菜单栏,其中包含一些我们可以点击以导航到不同页面的项目。model属性设置为items响应式属性,它是一个我们即将添加的菜单项对象数组。start插槽让我们可以在菜单栏的左侧添加项目。我们可以将一些粗体文本放入插槽中,它将显示在左侧。

接下来,我们可以为组件添加一个component对象。要添加它,请编写以下代码:

<script>
export default {
  name: "TopBar",
  props: {
    title: String,
  },
  data() {
    return {
      items: [
        {
          label: "Manage Bookings",
          command: () => {
            this.$router.push("/bookings");
          },
        },
...
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/");
    },
  },
  beforeMount() {
    document.title= = this.title;
  },
};
</script>

在这里,我们注册title属性,用它来设置document.title的值。document.title属性设置了顶部栏的标题。在data方法中,我们返回一个具有项目的响应式属性的对象。这被设置为一个具有labelcommand属性的对象。label属性显示在用户的菜单栏项目中。该项目显示为一个链接。当我们点击该项目时,command方法被运行。

通过this.$router.push方法,我们可以导航到与给定 URL 映射的页面。logOut方法导航回到与/路径映射的页面,这是我们稍后将讨论的login页面。另外,我们清除本地存储,以便清除身份验证令牌。

beforeMount钩子中,我们将document.title属性设置为title属性的值。

添加共享代码以处理请求

接下来,让我们编写 Axios 请求拦截器的代码,以便让我们将身份验证令牌添加到除了我们对/login端点进行请求之外的所有请求的x-token请求头中。为此,创建src/plugins文件夹,并将axios.js添加到其中。然后,在此文件中,编写以下代码:

import axios from 'axios'
import { APIURL } from '@/constants'
axios.interceptors.request.use((config) => {
  if (config.url.includes(APIURL) 
   && !config.url.includes('login')) {
    config.headers['x-token'] = localStorage.getItem('token')
    return config
  }
  return config
}, (error) => {
  return Promise.reject(error)
})

在这里,我们通过检索具有config.url属性的 URL 来检查正在进行请求的 URL。然后,如果我们对除/login之外的端点进行任何请求,我们设置x-token请求头:

config.headers['x-token'] = localStorage.getItem('token')

请注意,我们从本地存储中获取令牌,并将其设置为config.headers['x-token']的值。config.headers属性是一个具有请求头的对象。第二个参数是请求错误处理程序。在这里,我们只是返回一个带有Promise.reject的拒绝承诺,以便我们可以处理错误。

接下来,我们向我们的路由添加 Vue Router 路由。我们留在src/plugins文件夹中,并创建一个vue-router.js文件。然后,我们向文件中添加以下代码:

import { createWebHashHistory, createRouter } from 'vue-
 router'
import Login from '../views/Login.vue'
import Bookings from '../views/Bookings.vue'
import Catalog from '../views/Catalog.vue'
const beforeEnter = (to, from, next) => {
  try {
    const token = localStorage.getItem('token')
    if (to.fullPath !== '/' && !token) {
      return next({ fullPath: '/' })
    }
    return next()
  } catch (error) {
    return next({ fullPath: '/' })
  }
}
const routes = [
  { path: '/', component: Login },
  { path: '/bookings', component: Bookings, beforeEnter },
  { path: '/catalog', component: Catalog, beforeEnter },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

我们添加beforeEnter函数来检查前端页面上除首页之外的任何页面的令牌。我们可以使用to.fullPath属性检查用户尝试访问的路径。如果不是'/',并且本地存储中没有令牌,那么我们调用带有对象的next,并将fullPath属性设置为'/'以转到登录页面。否则,我们调用没有参数的next以转到我们应该去的页面。如果出现错误,那么我们也转到登录页面,正如您从catch块中的代码所看到的。

接下来,我们有包含路由定义的routes数组。这个数组中path属性是路由的路径,component是路径映射到的组件。beforeEnter属性被添加到最后两个路由对象,这样我们只能在登录后才能访问那里。

然后,为了创建router对象,我们使用带有history属性设置为createWebHashHistory函数返回的对象的对象调用createRouter。这样我们就可以在主机名和 URL 的其余部分之间保持哈希。我们将routes属性设置为routes数组,以便注册路由。这样我们就可以在访问路由时看到正确的组件。

最后,我们将router对象作为默认导出,以便稍后可以使用app.use将路由对象添加到我们的应用中。

接下来,我们在src文件夹内创建views文件夹。这意味着我们可以添加用户可以访问的页面。现在,让我们通过将Bookings.vue文件添加到src/views文件夹来添加一个页面,以允许管理员管理任何预订。我们打开文件并向组件添加以下模板。这样我们就可以添加之前创建的TopBar组件:

<template>
  <TopBar title="Manage Bookings" />
  <div class="p-col-12">
    <h1>Manage Bookings</h1>
  </div>
  <div class="p-col-12">
    <Card v-for="b of bookings" :key="b.id">
      <template #title>{{ b.name }} </template>
      <template #content>
        <p>Address: {{ b.address }}</p>
        <p>Description: {{ b.description }}</p>
        <p>Start Date: {{ b.start_date }}</p>
        <p>End Date: {{ b.end_date }}</p>
      </template>
      <template #footer>
        <Button
          icon="pi pi-times"
          label="Cancel"
          class="p-button-secondary"
          @click="deleteBooking(b.id)"
        />
      </template>
    </Card>
  </div>
</template>

请注意,我们使用h1元素添加页面标题。然后,我们添加Card组件来向管理员显示预订情况。Card组件由 PrimeVue 提供,我们稍后将注册它。我们使用v-for指令将预订数组渲染为多个Card组件。key属性设置为唯一 ID,以便 Vue 3 可以正确区分每个项目。

我们用不同的内容填充titlecontentfooter插槽。footer插槽有一个Button组件,当我们点击按钮时运行deleteBooking函数。icon属性允许我们在按钮的左侧设置图标。label属性在图标的右侧有按钮文本。通过p-button-secondary类,我们可以设置按钮的颜色。

接下来,我们可以添加component选项对象,其中包含getBookingdeleteBooking方法,分别通过后端 API 检索预订和删除预订。要添加它们,请编写以下代码:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import TopBar from "@/components/TopBar";
export default {
  name: "Bookings",
  components: {
    TopBar,
  },
  data() {
    return {
      bookings: [],
    };
  },
...
  beforeMount() {
    this.getBookings();
  },
};
</script>

我们还在components属性中注册了TopBar组件。getBookings方法调用axios.get发出 GET 请求,并将this.bookings的值设置为响应对象的响应式属性。

bookings存储在返回的解析值的对象的data属性中。

同样,我们在deleteBooking方法中调用axios.delete发出DELETE请求以删除项目。然后,我们调用this.getBookings再次获取数据。我们还在beforeMount钩子中调用this.getBookings,以在页面加载时获取数据。

接下来,我们添加一个页面,允许管理员管理度假套餐项目。为此,让我们将Catalog.vue文件添加到src/views文件夹中。然后,在文件中,编写以下内容:

<template>
  <TopBar title="Manage Vacation Packages" />
  <div class="p-col-12">
    <h1>Manage Vacation Packages</h1>
  </div>
  <div class="p-col-12">
    <Button label="Add Vacation Package" 
      @click="displayCatalog= = true" />
    <Dialog header="Add Vacation Package" v-
      model:visible="displayCatalog">
      <CatalogForm
        @catalog-form-close="
          displayCatalog= = false;
          getCatalog();
        "
      />
    </Dialog>
  </div>
  ...
</template>

在这里,我们添加TopBar组件来显示顶部栏;h1显示一个标题。接下来,我们添加一个按钮,通过将displayCatalog设置为true来显示对话框。然后,我们通过将v-model指令与可见修饰符设置为displayCatalog值来显示Dialog组件。使用这个,我们可以控制Dialog组件何时显示。Dialog组件显示一个对话框,这个组件由 PrimeVue 提供。

header属性设置对话框框的标题文本。我们使用CatalogForm作为内容,并监听CatalogForm组件发出的catalog-form-close事件。当它被发出时,我们将displayCatalog设置为false,并调用getCatalog再次获取数据:

<template>
  ...
  <div class="p-col-12">
    <Card v-for="c of catalog" :key="c.id">
      <template #header>
        <img :alt="c.description" :src="c.image_url" />
      </template>
      <template #title> {{ c.name }} </template>
      <template #content>
        {{ c.description }}
      </template>
      <template #footer>
        <Button
          icon="pi pi-times"
          label="Delete"
          class="p-button-secondary"
          @click="deleteCatalogItem(c.id)"
        />
      </template>
    </Card>
  </div>
</template>

接下来,我们添加从目录响应属性渲染的Card组件,使用v-for指令来渲染目录条目。其余代码与我们在Bookings.vue文件中的代码类似,但现在渲染属性不同,当我们点击它时,Button调用不同的方法。

接着,我们通过向src/views/Catalog.vue添加以下代码来添加组件对象:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import TopBar from "@/components/TopBar";
import CatalogForm from "@/components/CatalogForm";
...
  methods: {
    async getCatalog() {
      const{ { data } = await axios.get(`${APIURL}/catalog`);
      this.catalog = data;
    async deleteCatalogItem(id) {
      await axios.delete(`${APIURL}/catalog/${id}`);
      this.getCatalog();
    },
  },
  beforeMount() {
    this.getCatalog();
  },
};
</script>

在这里,代码与我们在src/views/Bookings.vue中的代码类似,只是在这里,我们向目录端点发出请求以获取和删除数据。

然后,我们在管理前端应用程序中创建最后一个页面,即登录页面。要添加登录页面,我们将Login.vue文件添加到src/views文件夹中。然后,在文件内部,我们使用以下代码添加formusername字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div class="p-col-12">
      <h1>Admin Log In</h1>
    </div>
    <Field v-slot="{ field, errors }" v-model="username" 
      name="username">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Username"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Username is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

username字段与我们之前添加的所有其他字段类似。接下来,我们使用以下代码添加password输入和按钮:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="password" 
      name="password">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Password"
            type="password"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Password is invalid
        </small>
      </div>
    </Field>
    <div class="p-col-12">
      <Button label="Log In" type="submit" />
    </div>
  </Form>
</template>

我们将type属性设置为password,使字段成为密码输入。按钮的type属性设置为submit,以便在单击按钮时触发submit事件,并且所有表单值保持有效。

接下来,我们添加Login.vue文件的组件对象部分,其中包含onSubmit方法来发起登录请求:

<script>
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  username: yup.string().required(),
  password: yup.string().required(),
});
export default {
  name: "Login",
  data() {
    return {
      username: "",
      password: "",
      schema,
    };
  },
  methods: {
    async onSubmit(values) {
      const { username, password } = values;
      try {
        const {
          data: { token },
        } = await axios.post(`${APIURL}/users/login`, {
          username,
          password,
        });
        localStorage.setItem("token", token);
        this.$router.push("/bookings");
      } catch (error) {
        alert("Login failed");
      }
    },
  },
};
</script>

我们创建了验证模式的schema对象,它类似于我们之前使用的其他模式。然后,我们将其添加到data方法中返回的对象中。onSubmit方法从value参数中获取usernamepassword属性,以便我们可以将其用于向/users/login端点发起 POST 请求。

完成后,如果请求成功,我们从响应中获取一个令牌,以及localStorage.setItem方法。接下来,我们调用this.$router.push方法重定向到/bookings URL。如果有任何错误,我们将显示一个带有“登录失败”消息的警报。

接下来,我们将router-view组件添加到App.vue中,这样我们就可以显示在routes对象中创建的页面。要添加它,编写管理前端:共享代码,添加以处理请求的代码:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>
<style>
body {
  background-color: #ffffff;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
    Roboto, Helvetica,
    Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, 
      Segoe UI Symbol;
  font-weight: normal;
  color: #495057;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0px;
}
</style>

我们还有一个style标签来设置字体系列,并将页面的边距设置为0px,这样元素与页面边缘之间就没有空白。

接下来,我们将constants.js添加到src文件夹,然后将APIURL添加到其中:

export const APIURL = 'http://localhost:3000'

main.js中,我们注册了所有全局组件以及之前创建的路由对象。我们还导入了 PrimeVue 提供的全局样式,所以一切看起来都很好:

import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config';
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Card from 'primevue/card';
import Toolbar from 'primevue/toolbar';
import router from './plugins/vue-router'
import Textarea from 'primevue/textarea';
import Dialog from 'primevue/dialog';
import Menubar from 'primevue/menubar';
import { Form, Field } from "vee-validate";
import "primeflex/primeflex.css";
import 'primevue/resources/themes/bootstrap4-light-blue/theme.css'
import "primevue/resources/primevue.min.css";
import "primeicons/primeicons.css";
import './plugins/axios'
...
app.component("Form", Form);
app.component("Field", Field);
app.use(PrimeVue);
app.use(router)
app.mount('#app')

package.json中,通过将script.serve属性更改为以下内容,更改开发服务器运行的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8082",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  ...
}

现在,当我们运行npm run serve时,我们会得到以下截图:

图 6.1 - 管理前端

图 6.1 - 管理前端

现在我们已经创建了管理前端应用程序,我们唯一需要添加的是用户前端。

创建用户前端

现在我们已经完成了管理前端,我们将通过创建用户前端来完成本章的项目。用户前端与管理前端类似;但是,在这种情况下,不需要身份验证即可使用。

我们将从安装与管理前端相同的软件包开始。转到前端文件夹并运行以下命令:

npm i axios primeflex primeicons primevue@³.1.1 vee-validate@next vue-router@4 yup

接下来,如果不存在,创建src/components文件夹。然后,在src/components中创建BookingForm.vue文件,以便用户可以添加他们的预订。

添加formname字段,以允许用户输入他们的姓名:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <Field v-slot="{ field, errors }" v-model="name" 
      name="name">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Name"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">Name 
          is invalid </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这与我们之前添加的其他文本输入字段非常相似。然后,使用以下代码添加地址字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="address" 
      name="address">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Address"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0"
          >Address is invalid</small
        >
      </div>
    </Field>
    ...
  </Form>
</template>

现在,让我们添加 PrimeVue 提供的Calendar组件,这是我们在此项目中以前未使用过的。Calendar组件允许用户选择日期。我们可以添加开始日期字段,以允许用户选择他们假期的开始日期:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="startDate"         name="startDate">
      <div class="p-col-12">
        <label>Start Date</label>
        <div class="p-inputgroup">
          <Calendar
            inline
            placeholder="Start Date"
            :class="{ 'p-invalid': errors.length > 0 }"
            :minDate="new Date()"
            v-bind="field"
            v-model="startDate"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Start date is invalid
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们有minDate属性,它设置用户可以选择的最早日期。inline属性将使日期选择器显示在表单上,而不是在弹出窗口中。同样,我们可以使用以下代码添加结束日期字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="endDate"         name="endDate">
      <div class="p-col-12">
        <label>End Date</label>
        <div class="p-inputgroup">
          <Calendar
            inline
            placeholder="End Date"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
            v-model="endDate"
            :minDate="new Date(+startDate + 24 * 3600 * 1000)"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0"
          >End date is invalid</small
        >
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们将minDate属性设置为startDate后的一天。24 * 3600 * 1000毫秒等于一天。最后,我们添加submit按钮,就像我们在其他表单中所做的那样:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <div class="p-col-12">
      <Button label="Book" type="submit" />
    </div>
    ...
  </Form>
</template>

接下来,我们通过编写以下内容创建schema

<script>
import { Form, Field } from "vee-validate";
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  name: yup.string().required(),
  address: yup.string().required(),
  startDate: yup.date().required().min(new Date()),
  endDate: yup
    .date()
    .required()
    .when(
      "startDate",
      (startDate, schema) => startDate && 
        schema.min(startDate)
    ),
});
...
</script>

为了验证endDate,我们使用when方法调用要检查的field名称。然后,我们调用schema.min来确保endDate晚于startDate

接下来,我们添加component对象来注册selectedCatalogId属性并添加onSubmit方法。我们编写以下代码:

<script>
...
export default {
  name: "BookingForm",
  components: {
    Form,
    Field,
  },
  props: {
...
  methods: {
    async onSubmit(values) {
      const { name, address, startDate, endDate } = values;
      await axios.post(`${APIURL}/bookings`, {
        name,
        address,
        startDate,
        endDate,
        catalogItemId: this.selectedCatalogId,
      });
      this.$emit("booking-form-close");
    },
  },
};
</script>

onSubmit方法从values参数中获取表单字段值,并向 bookings 端点发出 POST 请求以添加预订。我们使用selectedCatalogId来添加预订。然后,我们发出booking-form-close事件,向父级发出事件,以信号表单关闭。

接下来,我们通过将vue-router.js添加到src/plugins文件夹中,向我们的应用程序添加 Vue Router:

import { createWebHashHistory, createRouter } from 'vue-router'
import Catalog from '../views/Catalog.vue'
const routes = [
  { path:'/', component: Catalog },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

这与我们在管理前端中所做的非常相似。

接下来,我们创建一个页面,通过添加src/views/Catalog.vue文件,然后添加以下模板代码来向用户展示所有的度假套餐:

<template>
  <Card v-for="c of catalog" :key="c.id">
    <template #header>
      <img :alt="c.description" :src="c.image_url" />
    </template>
    <template #title> {{ c.name }} </template>
    <template #content>
      {{ c.description }}
    </template>
    <template #footer>
      <Button
        icon="pi pi-check"
        label="Book"
        class="p-button-secondary"
        @click="book(c.id)"
      />
...
          :selectedCatalogId="selectedCatalogId"
        />
      </Dialog>
    </template>
  </Card>
</template>

在这里,我们只是从目录数组中呈现一个表单。我们有一个带有BookingForm组件的Dialog组件。我们监听由其发出的booking-form-close事件,通过将selectedCatalogId作为同名 prop 的值传递给其来关闭Dialog组件,将displayBookingForm设置为false并调用displayMessage来显示警报。

模板代码的其余部分几乎与我们之前的相同,除了显示的属性名称和标题插槽中图像的添加。

接下来,我们通过编写以下代码将component选项对象添加到同一文件中:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import BookingForm from "../components/BookingForm.vue";
export default {
  name: "Catalog",
  components: {
    BookingForm,
  },
  data() {
    return {
      selectedCatalogId: undefined,
      displayBookingForm: false,
...
    displayMessage() {
      alert("Booking successful");
    },
  },
  beforeMount() {
    this.getCatalog();
  },
};
</script>

我们在components属性中注册BookingForm组件。getCatalog函数从 API 获取度假套餐目录项。booking函数将displayBookingForm设置为true以打开Dialog组件,并在那里也设置了selectedCatalogIdbeforeMount钩子调用getCatalog以检索目录数据。

添加路由视图和入口点代码

App.vue中,我们编写以下代码来添加router-view并设置与我们在管理前端中相同的样式:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>
<style>
body {
  background-color: #ffffff;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
    Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, 
     Segoe UI Emoji, Segoe UI Symbol;
  font-weight: normal;
  color: #495057;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0px;
}
</style>

然后,我们在src文件夹中创建constants.js,并添加以下行来添加APIURL

export const APIURL = 'http://localhost:3000'

然后,在main.js中,我们用以下代码替换文件的内容,以全局注册组件和路由。我们还导入了 PrimeVue 提供的样式,让我们的应用程序看起来很好:

import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config';
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Card from 'primevue/card';
import Toolbar from 'primevue/toolbar';
import Calendar from 'primevue/calendar';
import Dialog from 'primevue/dialog';
import router from './plugins/vue-router'
import "primeflex/primeflex.css";
import 'primevue/resources/themes/bootstrap4-light-blue/theme.css'
import "primevue/resources/primevue.min.css";
import "primeicons/primeicons.css";
const app = createApp(App)
app.component("InputText", InputText);
app.component("Button", Button);
app.component("Card", Card);
app.component("Toolbar", Toolbar);
app.component("Calendar", Calendar);
app.component("Dialog", Dialog);
app.use(PrimeVue);
app.use(router)
app.mount('#app')

package.json中,我们通过将script.serve属性更改为以下内容来更改开发服务器运行的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8082",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  ...
}

现在,当我们运行npm run serve时,我们会得到以下截图:

图 6.2 - 用户前端

图 6.2 - 用户前端

用户前端创建完成后,度假预订系统现在已经完成。

总结

在本章中,我们学习了如何有效地使用 PrimeVue 来构建一个度假预订应用程序。通过 PrimeVue,我们可以轻松创建漂亮的 Vue 3 web 应用程序。PrimeVue 带有许多有用的组件,我们可以添加到我们的 web 应用程序中,比如输入框、文本区域、表格、对话框、日期选择器等等。它还内置了样式,因此我们不必自己从头开始添加任何样式。此外,我们还可以添加 PrimeVue 提供的 PrimeFlex 包;通过 flexbox,我们可以轻松地改变元素和组件的间距和位置。

Vee-Validate 和 Yup 允许我们在 Vue 3 应用程序中添加表单验证。这与 PrimeVue 提供的输入组件很容易集成。这两个库使得大部分表单验证工作变得简单,因为我们不必自己编写所有的表单验证代码。

为了创建一个简单的后端,我们使用 Express 创建了一个简单的 API 来与前端交互。我们还使用了sqlite3包来操作我们 API 中的 SQLite 数据库。Express 带有许多附加组件,我们可以使用它们来添加许多功能,比如跨域通信。我们还可以通过jsonwebtoken库很容易地向我们的 Express 应用程序添加 JSON Web Token 身份验证。

在下一章中,我们将学习如何使用 GraphQL 和 Vue 3 构建一个商店前端。

第七章:使用 GraphQL 创建购物车系统

在上一章中,我们使用 Vue 3 和 Express 构建了一个旅行预订系统。这是我们从头开始构建自己的后端的第一个项目,该后端被前端使用。拥有自己的后端让我们能够做更多的事情,否则我们无法做到,例如,我们可以将喜欢的数据保存在我们自己创建的数据库中。此外,我们为管理员用户添加了自己的身份验证系统。在管理员前端,我们使用beforeEnter路由守卫保护我们的路由,在管理员用户登录之前检查身份验证令牌。

在本章中,我们将研究以下主题:

  • 介绍 GraphQL 应用程序编程接口(API)

  • 使用 Express 创建 GraphQL API

  • 创建管理员前端

  • 创建客户前端

技术要求

本章项目的代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter07找到。

介绍 GraphQL API

在上一章中,我们使用 Express 创建了一个后端。该端点接受 JSON 数据作为输入,并返回 JSON 数据作为响应。然而,它可以接受任何 JSON 数据,后端可能不会预期。此外,没有一种简单的方法可以在没有前端的情况下测试我们的 API 端点。这是我们可以用 GraphQL API 解决的问题。GraphQL 是一种特殊的查询语言,使客户端和服务器之间的通信更加容易。GraphQL API 具有内置的数据结构验证。每个属性都有一个数据类型,可以是简单或复杂类型,由许多具有简单数据类型的属性组成。

我们还可以使用 GraphiQL 测试 GraphQL API,这是一个网页,让我们轻松地进行自己的 GraphQL API 请求。由于每个请求都有数据类型验证,它可以根据 GraphQL API 模式的定义提供自动完成功能。该模式为我们提供了与查询和变异一起使用的所有数据类型定义。查询是让我们使用 GraphQL API 查询数据的请求,而变异是让我们以某种方式更改数据的 GraphQL 请求。

我们使用模式字符串明确定义查询和变异。查询和变异将输入类型作为输入数据的数据类型,并使用指定的输出数据类型返回数据。因此,我们永远不会对我们必须发送的数据的结构产生任何疑问,也永远不必猜测请求将返回什么类型的数据。

GraphQL API 请求基本上只是常规的超文本传输协议HTTP)请求,只是它们具有特殊的结构。所有请求默认都发送到/graphql端点,并且我们将查询或变异作为 JSON 请求中query属性的字符串值发送。变量值与variable参数一起发送。

查询和变异是有名称的,并且所有查询和变异都以相同的名称发送到解析器函数中的代码,而不是路由处理程序。然后函数根据模式指定的参数获取请求数据,并在解析器函数代码中对其进行处理。

对于 Vue 3 应用程序,我们可以使用专门的 GraphQL API 客户端来更轻松地创建 GraphQL API 请求。我们只需传入一个字符串来进行查询和变异,以及与查询和变异相关的变量。

在本章中,我们将使用 Vue 3 创建一个带有管理前端和客户前端的购物车系统。然后,我们将使用 Express 和express-graphql库创建一个后端,该后端接收 GraphQL API 请求并将数据存储在 SQLite 数据库中。

设置购物车系统项目

为了创建度假预订项目,我们必须为前端、管理前端和后端创建子项目。为了创建前端和管理前端项目,我们将使用 Vue CLI。为了创建后端项目,我们将使用express-generator全局包。

为了设置本章的项目,我们执行以下步骤:

  1. 首先,我们创建一个文件夹来存放所有项目,并将其命名为shopping-cart

  2. 然后我们在主文件夹内创建admin-frontendfrontendbackend文件夹。

  3. 接下来,我们进入admin-frontend文件夹,并运行npx vue create来为admin-frontend文件夹添加 Vue 项目的脚手架代码。

  4. 如果我们被要求在当前文件夹中创建项目,我们选择Y,然后当被要求选择项目的 Vue 版本时,我们选择Vue 3。同样,我们以frontend文件夹的方式运行 Vue CLI。

  5. 要创建 Express 项目,我们运行 Express 应用程序生成器应用程序。要运行它,我们进入backend文件夹,然后运行npx express-generator

这个命令将把所有需要的文件添加到backend文件夹中。如果出现错误,请尝试以管理员身份运行express-generator包。

现在我们已经完成了项目的设置,我们可以开始编写代码了。接下来,我们将开始创建 GraphQL 后端。

使用 Express 创建 GraphQL API

要开始购物车系统项目,我们首先要使用 Express 创建一个 GraphQL API。我们从后端开始,因为我们需要它用于两个前端。要开始,我们必须添加一些需要用于操作 SQLite 数据库并向我们的应用程序添加身份验证的库。此外,我们需要启用应用程序中的跨域资源共享CORS)的库。

CORS 是一种让我们能够从浏览器向不同域中托管的端点发出请求的方法,与前端托管的域不同。

为了使我们的 Express 应用程序接受 GraphQL 请求,我们使用graphqlexpress-graphql库。要安装两者,我们运行以下命令:

npm i cors jsonwebtoken sqlite3 express-graphql graphql

安装完包后,我们就可以开始编写代码了。

使用解析器函数

首先,我们要处理解析器函数。为了添加它们,我们首先在backend文件夹中添加一个resolvers文件夹。然后,我们可以为身份验证编写解析器。在resolvers文件夹中,我们创建一个auth.js文件,并编写以下代码:

const jwt = require('jsonwebtoken');
module.exports = {
  login: ({ user: { username, password } }) => {
    if (username === 'admin' && password === 'password') {
      return { token: jwt.sign({ username }, 'secret') }
    }
    throw new Error('authentication failed');
  }
}

login方法是一个解析器函数。它接受具有usernamepassword属性的user object属性,我们使用这些属性来检查凭据。我们检查用户名是否为'admin',密码是否为'password'。如果凭据正确,我们就发出令牌。否则,我们抛出一个错误,这将作为/graphql端点的错误响应返回。

为订单逻辑添加解析器

接下来,我们为订单逻辑添加解析器。在resolvers文件夹中,我们添加orders.js文件。然后,我们开始编写解析器函数以获取订单数据。订单数据包含有关订单本身以及客户购买的商品的信息。为了添加解析器,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getOrders: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all(`
          SELECT *,
            orders.name AS purchaser_name,
            shop_items.name AS shop_item_name
          FROM orders
          INNER JOIN order_shop_items ON orders.order_id = 
            order_shop_items.order_id
          INNER JOIN shop_items ON 
           order_shop_items.shop_item_id = shop_items.
             shop_item_id
        `, [], (err, rows = []) => {
          ...
        });
      })
      db.close();
    })
  },
  ...
}

我们使用sqlite3.Database构造函数打开数据库,指定数据库路径。然后,我们返回一个查询所有订单及客户购买商品的承诺。订单存储在orders表中。商店库存商品存储在shop_items表中,我们有order_shop_items表来链接订单和购买的商品。

我们使用db.all方法进行select查询以获取所有数据,并使用inner join连接所有相关表以获取其他表中的相关数据。在回调中,我们编写以下代码来循环遍历行以创建order对象:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getOrders: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all(`
          ...
        `, [], (err, rows = []) => {
          if (err) {
            reject(err)
...
          const orderArr = Object.values(orders)
          for (const order of orderArr) {
            order.ordered_items = rows
              .filter(({ order_id }) => order_id === 
                order.order_id)
              .map(({ shop_item_id, shop_item_name: name, 
                price, description }) => ({
                shop_item_id, name, price, description
              }))
          }
          resolve(orderArr)
        });
      })
      db.close();
    })
  },
  ...
}

这样我们就可以删除行中的重复订单条目。键是order_id值,值是订单数据本身。然后,我们使用Object.values方法获取所有订单值。我们将返回的数组分配给orderArr变量。然后,我们循环遍历orderArr数组,使用filter方法从原始行的数组中获取所有已订购的商店商品,以通过order_id查找商品。我们调用map从行中提取订单的商店商品数据。

我们在数据上调用resolve,以便从/graphql端点返回它作为响应。在回调的前几行中,当err为真时,我们调用reject,以便如果有错误,我们可以将错误返回给用户。

最后,我们调用db.close()来关闭数据库。我们可以在最后这样做,因为我们使用db.serialize来运行serialize回调中的所有语句,以便结构化查询语言SQL)代码可以按顺序运行。

添加订单

我们添加一个解析器函数来添加订单。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  addOrder: ({ order: { name, address, phone, ordered_items:
    orderedItems } }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const orderStmt = db.prepare(`
          INSERT INTO orders (
            name,
            address,
            phone
...
                  shop_item_id: shopItemId
                } = orderItem
                orderShopItemStmt.run(orderId, shopItemId)
              }
              orderShopItemStmt.finalize()
            })
            resolve({ status: 'success' })
            db.close();
          });
      })
    })
  },
  ...
}

我们获取订单的请求有效负载,其中包括我们在参数中解构的变量。我们以相同的方式打开数据库,并且我们从相同的承诺代码和db.serialize调用开始,但在其中我们使用db.prepare方法创建一个准备好的语句。我们发出INSERT语句以将数据添加到订单条目中。

然后,我们使用要插入的变量值调用run来运行 SQL 语句。准备好的语句很好,因为我们传递给db.run的所有变量值都经过了清理,以防止 SQL 注入攻击。然后,我们调用finalize来提交事务。

接下来,我们使用db.all调用和SELECT语句获取刚刚插入到orders表中的行的 ID 值。在db.all方法的回调中,我们获取返回的数据并从返回的数据中解构orderId

然后,我们创建另一个准备好的语句,将购买的商店物品的数据插入到order_shop_items表中。我们只是插入order_idshop_item_id来将订单与购买的商店物品关联起来。

我们循环遍历orderedItems数组并调用run来添加条目,然后我们调用finalize来完成所有数据库事务。

最后,我们调用resolve向客户端返回成功响应。

为了完成这个文件,我们添加removeOrder解析器,让我们能够从数据库中删除订单。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  removeOrder: ({ orderId }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const delOrderShopItemsStmt = db.prepare("DELETE FROM 
          order_shop_items WHERE order_id = (?)");
        delOrderShopItemsStmt.run(orderId)
        delOrderShopItemsStmt.finalize();
        const delOrderStmt = db.prepare("DELETE FROM orders 
          WHERE order_id = (?)");
        delOrderStmt.run(orderId)
        delOrderStmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
}

我们以与之前相同的方式调用db.serializedb.prepare。唯一的区别是,我们正在发出DELETE语句来删除order_shop_itemsorders表中具有给定order_id的所有内容。我们需要先从order_shop_items表中删除项目,因为订单仍然在那里被引用。

一旦我们清除了orders表之外对订单的所有引用,我们就可以在orders表中删除订单本身。

获取商店物品

我们在resolvers文件夹中创建一个shopItems.js文件,用于保存获取和设置商店物品的解析器函数。首先,我们从一个解析器函数开始获取所有商店物品。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getShopItems: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all("SELECT * FROM shop_items", [], (err, rows = 
          []) => {
          if (err) {
            reject(err)
          }
          resolve(rows)
        });
      })
      db.close();
    })
  },
  ...
}

我们像之前一样调用db.serializedb.all。我们只是用查询获取所有的shop_items条目,然后调用resolve将选定的数据作为响应返回给客户端。

添加一个解析器函数来添加商店物品

现在,我们将添加一个解析器函数来添加商店物品。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  addShopItem: ({ shopItem: { name, description, image_url: 
    imageUrl, price } }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const stmt = db.prepare(`
          INSERT INTO shop_items (
            name,
            description,
            image_url,
            price
          ) VALUES (?, ?, ?, ?)
        `
        );
        stmt.run(name, description, imageUrl, price)
        stmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
  ...
}

我们发出INSERT语句来插入一个条目,其中的值是从参数中解构出来的。

最后,我们通过编写以下代码添加removeShopItem解析器,让我们能够根据其 ID 从shop_items表中删除条目:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  removeShopItem: ({ shopItemId }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const stmt = db.prepare("DELETE FROM shop_items WHERE 
          shop_item_id = (?)");
        stmt.run(shopItemId)
        stmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
}

将解析器映射到查询和变异

我们需要将解析器映射到查询和变异,以便在进行 GraphQL API 请求时调用它们。为此,我们转到app.js文件并添加一些内容。我们还将添加一些中间件,以便我们可以启用跨域通信和对某些请求进行令牌检查。为此,我们首先编写以下代码:

const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const { graphqlHTTP } = require('express-graphql');
const { buildSchema } = require('graphql');
const cors = require('cors')
const shopItemResolvers = require('./resolvers/shopItems')
const orderResolvers = require('./resolvers/orders')
const authResolvers = require('./resolvers/auth')
const jwt = require('jsonwebtoken');

我们使用require函数导入所需的所有内容。我们可以用前面的代码块替换文件顶部的所有内容。我们导入解析器、CORS 中间件、GraphQL 库项和jsonwebtoken模块。

接下来,我们通过调用buildSchema函数为我们的 GraphQL API 创建模式。为此,我们编写以下代码:

...
const schema = buildSchema(`
  type Response {
    status: String
  }
  ...
  input Order {
    order_id: Int
    name: String
    address: String
    phone: String
    ordered_items: [ShopItem]
  }
  ...
  type Query {
    getShopItems: [ShopItemOutput],
    getOrders: [OrderOutput]
  }
  type Mutation {
    addShopItem(shopItem: ShopItem): Response
    removeShopItem(shopItemId: Int): Response
    addOrder(order: Order): Response
    removeOrder(orderId: Int): Response
    login(user: User): Token
  }
`);
...

完整的模式定义可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter07/backend/app.js找到。

我们有type关键字来定义响应的数据类型,我们有ResponseToken类型用作响应。express-graphql库将检查响应的结构是否符合数据类型中指定的内容,因此任何返回具有Response类型的查询或变异都应该有一个status string属性。这是可选的,因为字符串后面没有感叹号。

input关键字让我们定义了一个input类型。一个input类型用于指定请求有效载荷的数据结构。它们与output类型的定义方式相同,具有一系列属性,冒号后面是它们的数据类型。

我们可以将一个数据类型嵌套在另一个数据类型中,就像我们在OrderOutput类型的ordered_items属性中所做的那样。我们指定它包含一个具有ShopItemOutput数据类型的对象数组。同样,我们为Order数据类型中的ordered_items属性指定了类似的数据类型。方括号表示数据类型是一个数组。

QueryMutation是特殊的数据类型,它们让我们在冒号前添加解析器名称,在冒号后添加输出的数据类型。Query类型指定查询,Mutation类型指定变异。

接下来,我们通过编写以下代码指定了带有所有解析器的root对象:

const root = {
  ...shopItemResolvers,
  ...orderResolvers,
  ...authResolvers
}

我们只需将导入的所有解析器放入root对象中,并将所有条目展开到root对象中,以将它们合并为一个对象。

然后,我们添加authMiddleware以对一些 GraphQL 请求进行身份验证检查。为此,我们编写以下代码:

const authMiddleware = (req, res, next) => {
  const { query } = req.body
  const token = req.get('authorization')
  const requiresAuth = query.includes('removeOrder') ||
    query.includes('removeShopItem') ||
    query.includes('addShopItem')
  if (requiresAuth) {
    try {
      jwt.verify(token, 'secret');
      next()
      return
    } catch (error) {
      res.status(401).json({})
      return
    }
  }
  next();
}

我们从 JSON 请求有效负载中获取query属性,以检查 GraphQL 请求调用的查询或变异。然后,我们使用req.get方法获取authorization标头。接下来,我们定义一个requiresAuth布尔变量,以检查客户端是否正在发出调用受限制的查询或变异的请求。

如果为true,我们调用jwt.verify以使用密钥验证令牌。如果有效,则调用next继续到/graphql端点。否则,我们返回401响应。如果querymutation属性不需要身份验证,则只需调用next继续到/graphql端点。

添加中间件

接下来,我们添加了所有需要的中间件以启用跨域通信,并添加了/graphql端点以接受 GraphQL 请求。为此,我们编写以下代码:

...
const app = express();
app.use(cors())
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(authMiddleware)
app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));
...

我们编写以下代码行以启用跨域通信:

app.use(cors())

以下代码行使我们能够接受 JSON 请求,这也是我们接受 GraphQL 请求所需的:

app.use(express.json());

以下代码行将身份验证检查添加到受限制的 GraphQL 查询中:

app.use(authMiddleware)

在以下代码块之前必须添加上述代码行:

app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));

这样,身份验证检查将在进行 GraphQL 请求之前完成。最后,以下代码块添加了一个/graphql端点,以便我们接受 GraphQL 请求:

app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));

grapgqlHTTP函数在传入一堆选项后返回一个中间件。我们为 GraphQL API 设置模式。rootValue属性具有包含所有解析器的对象。解析器名称应与QueryMutation类型中指定的名称匹配。将graphiql属性设置为true,以便在浏览器中转到/graphql页面时可以使用 GraphiQL Web 应用程序。

要测试经过身份验证的端点,我们可以使用 Chrome 和 Firefox 提供的ModHeader扩展程序,将身份验证标头与令牌添加到请求标头中。然后,我们可以轻松测试经过身份验证的 GraphQL 请求。

注意:

该扩展可以从chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj?hl=en下载到 Chromium 浏览器,addons.mozilla.org/en-CA/firefox/addon/modheader-firefox/?utm_source=addons.mozilla.org&utm_medium=referral&utm_content=search下载到 Firefox 浏览器。

以下屏幕截图显示了 GraphiQL 界面的外观。我们还有ModHeader扩展,让我们可以在屏幕右上角添加所需的标头以进行身份验证请求:

图 7.1 – 带有 ModHeader 扩展的 GraphiQL

图 7.1 – 带有 ModHeader 扩展的 GraphiQL

接下来,我们创建一个db.sql脚本,让我们可以通过编写以下代码创建我们需要使用的数据库:

DROP TABLE IF EXISTS order_shop_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS shop_items;
CREATE TABLE shop_items (
  shop_item_id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  price NUMBER NOT NULL,
  image_url TEXT NOT NULL
);
CREATE TABLE orders (
  order_id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  address TEXT NOT NULL,
  phone TEXT NOT NULL
);
CREATE TABLE order_shop_items (
  order_id INTEGER NOT NULL,
  shop_item_id INTEGER NOT NULL,
  FOREIGN KEY (order_id) REFERENCES orders(order_id)
  FOREIGN KEY (shop_item_id) REFERENCES 
   shop_items(shop_item_id)
);

我们创建了在解析器脚本中使用的表。TEXT让我们在列中存储文本;INTEGER让我们存储整数;FOREIGN KEY指定引用表和列后的外键;NOT NULL使列成为必需的;DROP TABLE IF EXISTS删除表(如果存在);CREATE TABLE创建表;PRIMARY KEY指定主键列。

创建 SQLite 数据库

使用DB Browser for SQLiteDB4S)程序来创建和操作 SQLite 数据库,我们可以从sqlitebrowser.org/下载。该程序适用于 Windows、Mac 和 Linux。然后,我们可以点击New Database,将db.sqlite数据库保存在backend文件夹中,以便后端可以访问数据库。然后,在Execute SQL选项卡中,我们粘贴脚本以向数据库添加表。要将数据库更改写入磁盘,必须保存它们。要做到这一点,点击File菜单,然后点击Write Changes。我们也可以按下Ctrl + S键组合来保存更改。

最后,在package.json中,我们通过编写以下代码来更改start脚本:

{
  ...
  "scripts": {
    "start": "nodemon ./bin/www"
  },
  ...
}

我们切换nodemon,这样当我们更改代码并保存时,应用程序将重新启动。我们运行npm I –g nodemon来全局安装nodemon

现在我们已经完成了后端,可以继续进行前端,以便拥有完整的购物车系统。

创建管理员前端

现在我们已经完成了后端应用程序,我们可以继续处理前端。由于我们之前已经在admin-frontend文件夹中为管理员前端创建了 Vue 3 项目,我们只需安装我们需要的包,然后开始编写代码。我们需要graphql-request GraphQL 包和 GraphQL 客户端库,以及 VeeValidate、Vue Router、Axios 和 Yup 包。

要安装它们,我们在admin-frontend文件夹中运行以下命令:

npm i vee-validate@next vue-router@4 yup graphql graphql-request

安装完包后,我们可以开始编写代码。

处理组件

首先,我们开始处理组件。在components文件夹中,我们通过编写以下代码将TopBar组件添加到components/TopBar.vue文件中,以容纳路由链接和退出按钮:

<template>
  <p>
    <router-link to="/orders">Orders</router-link>
    <router-link to="/shop-items">Shop Items</router-link>
    <button @click="logOut">Log Out</button>
  </p>
</template>
<script>
export default {
  name: "TopBar",
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/");
    },
  },
};
</script>
<style scoped>
a {
  margin-right: 5px;
}
</style>

我们添加了 Vue Router router-link组件,让管理员用户点击它们以转到不同的页面。

退出按钮在点击时运行logOut方法,以清除本地存储并使用this.$router.push重定向回登录页面。/路径将映射到登录页面,我们稍后会看到。

接下来,在src/plugins文件夹中,我们添加router.js文件。为此,我们编写以下代码:

import { createRouter, createWebHashHistory } from 'vue-router'
import Login from '@/views/Login'
import Orders from '@/views/Orders'
import ShopItems from '@/views/ShopItems'
const beforeEnter = (to, from, next) => {
  try {
    const token = localStorage.getItem('token')
    if (to.fullPath !== '/' && !token) {
      return next({ fullPath: '/' })
    }
    return next()
  } catch (error) {
    return next({ fullPath: '/' })
  }
}
const routes = [
  { path: '/', component: Login },
  { path: '/orders', component: Orders, beforeEnter },
  { path: '/shop-items', component: ShopItems, beforeEnter },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

我们添加了beforeEnter路由守卫来检查身份验证令牌是否存储在本地存储中。如果已经存储,并且我们要前往经过身份验证的路由,那么我们通过调用next而不带参数来继续到页面。否则,我们通过调用带有fullPath属性设置为'/'的对象的next来重定向回登录页面。如果有任何错误,我们也会返回到登录页面。

接下来,我们有路由映射的routes数组。我们将路径映射到组件,这样当我们在浏览器中输入统一资源定位符URL)或单击页面上的路由链接时,我们就会转到我们映射到的页面。我们在需要身份验证的路由上添加beforeEnter路由守卫。

然后,我们调用createRouter来创建router对象,并调用createWebHashHistory来使用哈希模式。使用哈希模式,主机名和 URL 的其余部分将由#符号分隔。我们还将routes数组添加到传递给createRouter的对象中,以添加路由映射。

然后,我们导出router对象,以便稍后将其添加到我们的应用程序中。

接下来,我们创建登录页面组件。为此,我们创建views文件夹,将Login.vue文件添加到其中,然后编写以下代码:

<template>
  <h1>Admin Login</h1>
  <Form :validationSchema="schema" @submit="submitForm">
    <div>
      <label for="name">Username</label>
      <br />
      <Field name="username" type="text" 
        placeholder="Username" />
      <ErrorMessage name="username" />
    </div>
    <br />
    <div>
      <label for="password">Password</label>
      <br />
      <Field name="password" placeholder="Password" 
        type="password" />
      <ErrorMessage name="password" />
    </div>
    <input type="submit" />
  </Form>
</template>

我们将Form组件添加到validationSchema属性设置为yup模式。我们监听submit事件,当所有字段都有效时会触发该事件,然后点击提交按钮。submitForm方法将包含我们输入的表单字段值,Field组件让我们创建一个表单字段。

ErrorMessage显示带有表单字段的错误消息。如果FieldErrorMessagename属性值匹配,那么任何给定名称的字段的表单验证都将自动显示。placeholder属性让我们添加表单占位符,type属性设置form输入类型。

接下来,我们添加组件的脚本部分。为此,我们编写以下代码:

<script>
import { GraphQLClient, gql } from "graphql-request";
import * as yup from "yup";
import { Form, Field, ErrorMessage } from "vee-validate";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: "",
  },
});
const schema = yup.object({
  name: yup.string().required(),
  password: yup.string().required(),
});
...
</script>

我们使用GraphQLClient构造函数创建 GraphQL 客户端对象。它接受 GraphQL 端点 URL 和各种我们可以传递的选项。我们将在需要身份验证的组件中传递所需的请求标头。

schema变量保存了yup验证模式,其中包含namepassword字段。两个字段都是字符串类型,都是必填的,如方法调用所示。属性名称必须与FieldErrorMessage组件的name属性值匹配,以便触发字段的验证。

添加登录逻辑并进行第一个 GraphQL 请求

接下来,我们通过编写以下代码添加登录逻辑:

<script>
...
export default {
  name: "Login",
  components: {
    Form,
    Field,
    ErrorMessage,
  },
  data() {
    return {
      schema,
    };
  },
...
        } = await graphQLClient.request(mutation, variables);
        localStorage.setItem("token", token);
        this.$router.push('/orders')
      } catch (error) {
        alert("Login failed");
      }
    },
  },
};
</script>

我们注册了从 VeeValidate 包导入的FormFieldErrorMessage组件。我们有data方法,它返回一个包含模式的对象,以便我们可以在模板中使用它。最后,我们有submitForm方法,用于从Field组件获取usernamepassword值,并进行登录 mutation GraphQL 请求。

我们将$username$password值传递到括号中,以便将它们传递到我们的 mutation 中。这些值将从我们传递给graphQLClient.request方法的variables对象中获取。如果请求成功,我们将从请求中获取令牌。一旦获取到令牌,我们将其放入localStorage.setItem中以将其放入本地存储。

gql标签是一个函数,它让我们将字符串转换为可以发送到服务器的查询 JSON 对象。

如果登录请求失败,我们会显示一个警报。以下截图显示了登录界面:

图 7.2 - 管理员登录界面

图 7.2 - 管理员登录界面

创建订单页面

接下来,我们通过创建views/Orders.vue文件来创建一个订单页面。为此,我们更新以下代码:

<template>
  <TopBar />
  <h1>Orders</h1>
  <div v-for="order of orders" :key="order.order_id">
    <h2>Order ID: {{ order.order_id }}</h2>
    <p>Name: {{ order.name }}</p>
    <p>Address: {{ order.address }}</p>
    <p>Phone: {{ order.phone }}</p>
    <div>
      <h3>Ordered Items</h3>
      <div
        v-for="orderedItems of order.ordered_items"
        :key="orderedItems.shop_item_id"
      >
        <h4>Name: {{ orderedItems.name }}</h4>
        <p>Description: {{ orderedItems.description }}</p>
        <p>Price: ${{ orderedItems.price }}</p>
      </div>
    </div>
    <p>
      <b>Total: ${{ calcTotal(order.ordered_items) }}</b>
    </p>
    <button type="button" @click="deleteOrder(order)">Delete 
      Order</button>
  </div>
</template>

我们添加了TopBar并使用v-for循环遍历订单以渲染条目。我们还循环遍历ordered_items。我们使用calcTotal方法显示了订单物品的总价格。我们还有删除订单按钮,当我们点击它时会调用deleteOrder方法。必须指定key属性,以便 Vue 3 可以识别这些条目。

接下来,我们通过编写以下代码使用 GraphQL 客户端创建脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import TopBar from '@/components/TopBar'
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: localStorage.getItem("token"),
  },
});
...
</script>

这与登录页面不同,因为我们将授权标头设置为我们从本地存储中获取的令牌。接下来,我们通过编写以下代码创建组件对象:

<script>
...
export default {
  name: "Orders",
  components: {
    TopBar
...
        {
          getOrders {
            order_id
            name
            address
            phone
            ordered_items {
              shop_item_id
              name
              description
              image_url
              price
            }
          }
        }
      `;
...
      await graphQLClient.request(mutation, variables);
      await this.getOrders();
    },
  },
};
</script>

我们使用components属性注册TopBar组件。我们有data方法返回一个带有orders响应式属性的对象。在beforeMount钩子中,当组件挂载时,我们调用getOrders方法获取订单。calcTotal方法通过使用map从所有orderedItems对象中获取价格,然后调用reduce将所有价格相加来计算所有订单物品的总价格。

getOrders方法发出 GraphQL 查询请求以获取所有订单。我们使用请求指定要获取的字段。我们还指定了要获取的嵌套对象的字段,因此我们也对ordered_items做同样的操作。只有指定的字段才会被返回。

然后,我们使用查询调用graphQlClient.request进行查询请求,并将返回的数据分配给orders响应式属性。

deleteOrder方法接受一个order对象,并向服务器发出removeOrder变更请求。orderId在变量中,因此将删除正确的订单。我们在删除订单后再次调用getOrders以获取最新的订单。

以下截图显示了管理员看到的订单页面:

图 7.3 - 订单页面:管理员视图

图 7.3 - 订单页面:管理员视图

现在我们已经添加了订单页面,接下来我们将添加一个页面,让管理员可以添加和删除他们想要在商店出售的物品。

添加和删除出售物品

接下来,我们添加一个商店物品页面,让我们可以添加和删除商店物品。为此,我们从模板开始。我们通过编写以下代码来渲染商店物品:

<template>
  <TopBar />
  <h1>Shop Items</h1>
  <button @click="showDialog = true">Add Item to Shop</button>
  <div v-for="shopItem of shopItems" 
    :key="shopItem.shop_item_id">
    <h2>{{ shopItem.name }}</h2>
    <p>Description: {{ shopItem.description }}</p>
    <p>Price: ${{ shopItem.price }}</p>
    <img :src="shopItem.image_url" :alt="shopItem.name" />
    <br />
    <button type="button" @click="deleteItem(shopItem)">
      Delete Item from Shop
    </button>
  </div>
  ...
</template>

我们像之前一样添加TopBar组件,并渲染shopItems,就像我们对订单所做的那样。

接下来,我们添加一个带有 HTML 对话框元素的对话框,以便我们可以添加商店物品。为此,我们编写以下代码:

<template>
  ...
  <dialog :open="showDialog" class="center">
    <h2>Add Item to Shop</h2>
    <Form :validationSchema="schema" @submit="submitForm">
      <div>
...
        <Field name="imageUrl" type="text" placeholder=" Image 
          URL" />
        <ErrorMessage name="imageUrl" />
      </div>
      <br />
      <div>
        <label for="price">Price</label>
        <br />
        <Field name="price" type="text" placeholder="Price" />
        <ErrorMessage name="price" />
      </div>
      <br />
      <input type="submit" />
      <button @click="showDialog = false" type="button">
        Cancel</button>
    </Form>
  </dialog>
</template>

我们设置open属性来控制对话框何时打开,并将类设置为center,以便我们可以应用样式将对话框居中并在页面的其余部分上方显示它。

在对话框中,我们以与登录页面相同的方式创建了表单。唯一的区别是表单中的字段。在表单底部,我们有一个取消按钮,将showDialog响应属性设置为false以关闭对话框,因为它被设置为open属性的值。

接下来,我们创建带有 GraphQL 客户端和表单验证模式的脚本(与之前一样),如下所示:

<script>
import { GraphQLClient, gql } from "graphql-request";
import * as yup from "yup";
import TopBar from "@/components/TopBar";
import { Form, Field, ErrorMessage } from "vee-validate";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: localStorage.getItem("token"),
  },
});
const schema = yup.object({
  name: yup.string().required(),
  description: yup.string().required(),
  imageUrl: yup.string().required(),
  price: yup.number().required().min(0),
});
...
</script>

然后,我们通过编写以下代码来添加组件选项对象:

<script>
... 
export default {
  name: "ShopItems",
  components: {
    Form,
    Field,
    ErrorMessage,
    TopBar,
  },
  data() {
    return {
      shopItems: [],
      showDialog: false,
      schema,
    };
  },
  beforeMount() {
    this.getShopItems();
  },
  ...
};
</script>

我们注册组件并创建一个data方法来返回我们使用的响应属性。beforeMount钩子调用getShopItems方法以从 API 获取商店物品。

接下来,我们通过编写以下代码来添加getShopItems方法:

<script>
... 
export default {
  ...
  methods: {
    async getShopItems() {
      const query = gql`
        {
          getShopItems {
            shop_item_id
            name
            description
            image_url
            price
          }
        }
      `;
      const { getShopItems: data } = await 
        graphQLClient.request(query);
      this.shopItems = data;
    },
    ...
  },
};
</script>

我们只需发出getShopItems查询请求,以获取带有大括号中字段的数据。

接下来,我们通过编写以下代码来添加submitForm方法,以发出变更请求以添加商店物品条目:

<script>
... 
export default {
  ...
  methods: {
    ...
    async submitForm({ name, description, imageUrl, price: 
      oldPrice }) {
      const mutation = gql`
        mutation addShopItem(
          $name: String
          $description: String
          $image_url: String
          $price: Float
        ) {
...
        description,
        image_url: imageUrl,
        price: +oldPrice,
      };
      await graphQLClient.request(mutation, variables);
      this.showDialog = false;
      await this.getShopItems();
    },
    ...
  },
};
</script>

我们通过解构参数中的对象来获取所有表单字段的值,然后调用graphQLClient.request以使用从参数的解构属性中设置的变量进行请求。我们将price转换为数字,因为根据我们在后端创建的模式,price应该是浮点数。

请求完成后,我们将showDialog设置为false以关闭对话框,并再次调用getShopItems以获取商店物品。

我们将添加的最后一个方法是deleteItem方法。该方法的代码可以在以下代码片段中看到:

<script>
... 
export default {
  ...
  methods: {
    ...
    async deleteItem({ shop_item_id: shopItemId }) {
      const mutation = gql`
        mutation removeShopItem($shopItemId: Int) {
          removeShopItem(shopItemId: $shopItemId) {
            status
          }
        }
      `;
      const variables = {
        shopItemId,
      };
      await graphQLClient.request(mutation, variables);
      await this.getShopItems();
    },
    ...
  },
};
</script>

我们发出removeShopItem变更请求,以删除商店物品条目。请求完成后,我们再次调用getShopItems以获取最新数据。

可以在以下截图中看到管理员视图的商店物品页面:

图 7.4 – 商店物品页面:管理员视图

图 7.4 – 商店物品页面:管理员视图

src/App.vue中,我们编写以下代码来添加router-view组件以显示路由组件内容:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>

src/main.js中,我们编写以下代码来将路由器添加到我们的应用程序中:

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

最后,在package.json中,我们将服务器脚本更改为从不同端口提供应用程序,以避免与前端发生冲突。为此,我们编写以下代码:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8090",
    ...
  },
  ...
}

我们现在已经完成了管理员前端,将继续进行这个项目的最后部分,即为客户创建一个前端,以便他们可以订购商品。

创建客户前端

现在我们已经完成了管理员前端,通过创建客户前端来完成本章的项目。这与管理员前端类似,只是不需要进行身份验证。

我们首先安装与管理员前端相同的包。因此,我们转到frontend文件夹并运行以下命令来安装所有包:

npm i vee-validate@next vue-router@4 yup vuex@4 vuex-persistedstate@ ⁴.0.0-beta.3 graphql graphql-request

我们需要使用Vuex-Persistedstate插件来存储购物车项目。其余的包与管理员前端相同。

创建插件文件夹

我们在src文件夹中创建一个plugins文件夹,并通过在该文件夹中创建router.js文件并编写以下代码来添加路由:

import { createRouter, createWebHashHistory } from 'vue-router'
import Shop from '@/views/Shop'
import OrderForm from '@/views/OrderForm'
import Success from '@/views/Success'
const routes = [
  { path: '/', component: Shop },
  { path: '/order-form', component: OrderForm },
  { path: '/success', component: Success },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

接下来,我们通过创建src/plugins/vuex.js文件来创建我们的 Vuex 存储,然后编写以下代码:

import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";
const store = createStore({
  state() {
    return {
      cartItems: []
    }
  },
  getters: {
    cartItemsAdded(state) {
      return state.cartItems
    }
  },
  mutations: {
    addCartItem(state, cartItem) {
      const cartItemIds = state.cartItems.map(c => 
        c.cartItemId).filter(id => typeof id === 'number')
      state.cartItems.push({
...
      state.cartItems = []
    }
  },
  plugins: [createPersistedState({
    key: 'cart'
  })],
});
export default store

我们调用createStore来创建 Vuex 存储。在传递给createStore的对象中,我们有state方法来返回初始化为数组的cartItems状态。getters属性有一个对象,其中包含cartItemsAdded方法来返回cartItems状态值。

mutations属性对象中,我们有addCartItem方法来调用state.cartItems.pushcartItem值添加到cartItems状态中。我们使用mapfilter方法获取现有的购物车项目 ID。我们只想要数字的 ID。新购物车项目的 ID 将是cartItemIds数组中最高的 ID 加上1

removeCartItem方法让我们调用splice通过索引删除购物车项目,clearCartcartItems状态重置为空数组。

最后,我们将plugins属性设置为一个具有createPersistedState函数的对象,以创建一个Vuex-Persistedstate插件来将cartItems状态存储到本地存储中。key值是存储cartItem值的键。然后,我们导出存储,以便稍后将其添加到我们的应用程序中。

创建订单表单页面

接下来,我们创建一个订单表单页面。这个页面有一个表单,让顾客输入个人信息并编辑购物车。为了创建它,如果还没有,我们创建一个src/views文件夹,然后创建一个OrderForm.vue组件文件。我们首先编写以下模板代码:

<template>
  <h1>Order Form</h1>
  <div v-for="(cartItem, index) of cartItemsAdded" 
    :key="cartItem.cartItemId">
    <h2>{{ cartItem.name }}</h2>
    <p>Description: {{ cartItem.description }}</p>
    <p>Price: ${{ cartItem.price }}</p>
    <br />
...
      <Field name="phone" type="text" placeholder="Phone" />
      <ErrorMessage name="phone" />
    </div>
    <br />
    <div>
      <label for="address">Address</label>
      <br />
      <Field name="address" type="text" placeholder="Address" 
         />
      <ErrorMessage name="address" />
    </div>
    <br />
    <input type="submit" />
  </Form>
</template>

我们有类似于管理员前端的表单。我们使用来自 VeeValidate 的相同FormFieldErrorMessage组件。

我们使用v-for循环遍历购物车项目,将它们渲染到屏幕上。它们通过Vuex-PersistedstatecartItemsAdded getter 从本地存储中检索出来。

接下来,我们通过编写以下代码以相同的方式创建脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import { mapMutations, mapGetters } from "vuex";
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
...
export default {
  name: "OrderForm",
  data() {
    return {
      schema,
    };
  },
  components: {
    Form,
    Field,
    ErrorMessage,
  },
  computed: {
    ...mapGetters(["cartItemsAdded"]),
  },
  ...
};
</script>

我们创建了 GraphQL 客户端和验证模式,并且我们以与管理员前端的商店项目页面相同的方式注册组件。唯一的新事物是调用mapGetters方法,将 Vuex getters 作为组件的计算属性添加进去。我们只需传入一个包含 getter 名称的字符串数组,将计算属性映射到这些 getter。接下来,我们通过编写以下代码添加方法:

<script>
...
export default {  
  ...
  methods: {
    async submitOrder({ name, phone, address }) {
      const mutation = gql`
        mutation addOrder(
          $name: String
          $phone: String
          $address: String
          $ordered_items: [ShopItem]
...
            shop_item_id,
            name,
            description,
            image_url,
            price,,
          })
        ),
      };
      await graphQLClient.request(mutation, variables);
      this.clearCart();
      this.$router.push("/success");
    },
    ...mapMutations(["addCartItem", "removeCartItem", 
        "clearCart"]),
  },
};
</script>

我们有一个submitOrder方法,从订单表单中获取输入的数据,并向服务器发出addOrder变异请求。在variables对象中,我们需要从每个ordered_items对象中删除cartItemId,以使其与我们在后端创建的ShopItem模式匹配。我们不能在发送到服务器的对象中有不包含在模式中的额外属性。

一旦请求成功,我们调用clearCart来清空购物车,然后调用thus.$router.push去到成功页面。mapMutation方法将变异映射到我们组件中的方法。clearCart方法与clearCart Vuex 存储变异相同。

以下截图显示了订单表单的管理员视图:

图 7.5 – 订单表单:管理员视图

图 7.5 – 订单表单:管理员视图

接下来,我们通过编写以下代码创建一个src/views/Shop.vue文件:

<template>
  <h1>Shop</h1>
  <div>
    <router-link to="/order-form">Check Out</router-link>
  </div>
  <button type="button" @click="clearCart()">Clear Shopping 
     Cart</button>
  <p>{{ cartItemsAdded.length }} item(s) added to cart.</p>
  <div v-for="shopItem of shopItems" :key="shopItem.
     shop_item_id">
    <h2>{{ shopItem.name }}</h2>
    <p>Description: {{ shopItem.description }}</p>
    <p>Price: ${{ shopItem.price }}</p>
    <img :src="shopItem.image_url" :alt="shopItem.name" />
    <br />
    <button type="button" @click="addCartItem(shopItem)">Add
       to Cart</button>
  </div>
</template>

我们使用v-for渲染商店项目,就像我们对其他组件所做的那样。我们还有一个router-link组件,在页面上渲染一个链接。

我们使用cartItemsAdded getter 显示添加的购物车项目数量。当我们点击清空购物车时,将调用clearCart Vuex 变异方法。接下来,我们通过编写以下代码为组件添加脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import { mapMutations, mapGetters } from "vuex";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL);
...
    async getShopItems() {
      const query = gql`
        {
          getShopItems {
            shop_item_id
            name
            description
            image_url
            price
          }
        }
      `;
      const { getShopItems: data } = await 
        graphQLClient.request(query);
      this.shopItems = data;
    },
    ...mapMutations(["addCartItem", "clearCart"]),
  },
};
</script>

我们以相同的方式创建 GraphQL 客户端。在组件中,我们在beforeMount钩子中调用getShopItems来获取购物车商品。我们还调用mapMutations将我们需要的 Vuex 变异映射到组件中的方法。

最后,我们通过编写以下代码将img元素缩小到100px宽度:

<style scoped>
img {
  width: 100px;
}
</style>

接下来,我们通过创建src/views/Success.vue文件并编写以下代码来创建一个订单成功页面:

<template>
  <div>
    <h1>Order Successful</h1>
    <router-link to="/">Go Back to Shop</router-link>
  </div>
</template>
<script>
export default {
  name: "Success",
};
</script>

订单成功页面只有一些文本和一个链接,可以返回到商店的主页。

接下来,在src/App.vue中,我们编写以下代码来添加router-view组件以显示路由页面:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>

src/main.js中,我们添加以下代码来将路由器和 Vuex 存储添加到我们的应用程序:

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/plugins/router'
import store from '@/plugins/vuex'
const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

最后,我们通过编写以下代码来更改应用项目的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8091",
    ...
  },
  ...
}

我们的项目现在已经完成。

我们可以用npm run serve来运行前端项目,用npm start来运行后端项目。

通过开发购物车项目,我们学会了如何创建 GraphQL API,这些 API 是可以通过查询和变异处理 GraphQL 指令的 JSON API。

总结

我们可以很容易地使用 Express 和express-graphql库创建 GraphQL API。为了更轻松地进行 GraphQL HTTP 请求,我们使用了在浏览器中工作的graphql-request JavaScript GraphQL 客户端。这使我们能够轻松设置请求选项,如标头、要进行的查询和与查询一起使用的变量。

我们使用graphql-request GraphQL 客户端来向后端发出请求,而不是使用常规的 HTTP 客户端。graphql-request库让我们比使用常规的 HTTP 客户端更轻松地进行 GraphQL HTTP 请求。借助它,我们可以轻松地传递带有变量的 GraphQL 查询和变异。

使用映射到解析器函数的模式创建了 GraphQL API。模式让我们定义输入和输出数据的所有数据类型,这样我们就不必猜测要发送哪些数据。如果发送任何无效数据,我们将收到一个错误,告诉我们请求出了什么问题。我们还必须指定我们想要在 GraphQL 查询中返回的数据字段,只有我们指定的字段才会返回。这使我们能够返回我们需要使用的数据,使其更加高效。

此外,我们可以在向/graphql端点发出请求之前进行常规令牌检查,为 GraphQL API 请求添加身份验证。

我们可以使用 GraphiQL 交互式沙盒轻松测试 GraphQL 请求,让我们可以发出想要的请求。要测试经过身份验证的请求,我们可以使用ModHeader扩展来设置标头,以便我们可以成功地进行经过身份验证的请求。

在下一章中,我们将看看如何使用 Laravel 和 Vue 3 创建实时聊天应用程序。

第八章:使用 Vue 3、Laravel 和 Socket.IO 构建聊天应用

在之前的章节中,我们创建了仅通过 HTTP 通信的前端项目或全栈项目。前端和后端之间没有实时通信。如果我们需要从服务器端实时向客户端通信数据,或者反过来,实时通信有时是必要的。没有一些实时通信机制,就没有办法在客户端不发起请求的情况下从服务器端向客户端通信。这是我们可以很容易通过 Laravel 框架和 Socket.io 添加的功能。

在本章中,我们将研究以下主题:

  • 使用 Laravel 创建 API 端点

  • 设置 JWT 身份验证

  • 创建前端以让用户聊天

Laravel 是用 PHP 编写的后端 Web 框架。它是一个包括处理 HTTP 请求、数据库操作和实时通信的全面后端框架。

在本章中,我们将看看如何让所有这些部分一起工作,以便我们可以使用 Vue 3、Laravel Echo Server 和 Redis 一起创建一个聊天应用。

技术要求

要完全理解本章,需要以下内容:

  • 对 PHP 的基本理解

  • 使用 Vue 组件创建基本应用的能力

  • 使用 Axios HTTP 客户端发送和接收 HTTP 请求的能力

本章项目的代码可在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter08找到。

使用 Laravel 创建 API 端点

创建我们的聊天应用的第一步是使用 Laravel 创建后端应用。使用 Laravel 创建 API 是我们必须学习的主要内容。这是我们以前没有做过的事情。这也意味着我们必须使用 PHP 编写代码,因为 Laravel 是基于 PHP 的 Web 框架。因此,在阅读此代码之前,您应该学习一些基本的 PHP 语法。与 JavaScript 和其他面向对象的语言一样,它们共享类似的概念,如使用对象、数组、字典、循环、类和其他基本的面向对象编程概念。因此,在学习难度方面,它与 JavaScript 应该没有太大的不同。

安装所需的库

要使用 Laravel 创建我们的 API,我们不必自己创建所有文件,只需运行几个命令,就会自动为我们创建所有文件和配置设置。在创建 API 之前,我们必须运行 PHP。在 Windows 中,将 PHP 添加到我们的 Windows 安装的最简单方法是使用 XAMPP。我们可以通过访问www.apachefriends.org/download.html来下载和安装它。它也适用于 macOS 和 Linux。

安装完成后,我们可以使用Composer创建我们的 Laravel API。Composer 是 PHP 的包管理器,我们将在以后使用它来安装更多的库。创建项目的最简单方法是创建我们的项目文件夹,然后在转到文件夹后运行创建 Laravel 项目的命令:

  1. 首先,我们创建一个名为vue-example-ch8-chat-app的项目文件夹,其中将分别放置前端和后端的文件夹。

  2. 然后,在这个文件夹中,我们创建后端文件夹来存放我们的 Laravel 项目代码文件。

  3. 现在我们转到命令行,然后进入vue-example-ch8-chat-app,然后运行composer global require laravel/installer

这将安装 Laravel 安装程序,让我们创建 Laravel 项目。全局库的位置如下:

  • macOS: $HOME/.composer/vendor/bin

  • Windows: %USERPROFILE%\AppData\Roaming\Composer\vendor\bin

  • GNU / Linux 发行版: $HOME/.config/composer/vendor/bin$HOME/.composer/vendor/bin

我们还可以运行composer global about来查找库文件的位置。

完成后,我们使用一个命令创建包含所有文件和配置文件的脚手架,并为我们安装所有所需的库。

我们通过命令行进入vue-example-ch8-chat-app文件夹,然后运行laravel new backend在后端文件夹中创建 Laravel 应用程序。Laravel 安装程序将运行并为我们的 Laravel 创建脚手架。此外,Composer 将安装我们运行 Laravel 所需的所有 PHP 库。完成所有这些后,我们应该拥有一个完整的 Laravel 安装,其中包含我们运行应用程序所需的所有文件和配置。

创建数据库和迁移文件

现在,随着 Laravel 应用程序的创建和所有相关库的安装,我们可以开始在 Laravel 应用程序上创建我们的 API。首先,我们通过创建一些迁移文件来创建我们的数据库。我们需要它们来创建chatsmessages表。chats表包含聊天室数据。而messages表包含与聊天室相关联的聊天消息。它还将引用发送消息的用户。

我们不需要创建users表,因为在创建 Laravel 应用程序时会自动创建。几乎每个应用程序都需要保存用户数据,因此这是自动包含的。使用 Laravel 脚手架,我们可以创建具有用户名、电子邮件和密码的用户,并使用刚刚创建的用户的用户名和密码登录。Laravel 还具有发送电子邮件进行用户验证的功能,而无需添加任何代码。

创建迁移,我们运行以下命令:

php artisan make:migration create_chats_table
php artisan make:migration create_messages_table

上述命令将为我们创建带有日期和时间前缀的迁移文件。所有迁移文件都在database/migrations文件夹中。因此,我们可以进入此文件夹并打开文件。在文件名为create_chats_table的文件中,我们添加以下代码:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateChatsTable extends Migration
{
    public function up()
    {
        Schema::create('chats', function (Blueprint $table)
        {
            $table->id();
            $table->string('name');
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent();
        });
    }
    public function down()
    {
        Schema::dropIfExists('chats');
    }
}

上述代码将创建chats表。up()方法包含我们在运行迁移时要运行的代码。down()方法包含我们在要撤消迁移时要运行的方法。

up()方法中,我们调用Schema::create来创建表。::符号表示该方法是静态方法。第一个参数是表名,第二个参数是一个回调函数,我们在其中添加创建表的代码。$table对象具有id()方法来创建id列。string()方法创建一个带有参数中列名的string列。timestamp()方法让我们创建一个带有给定列名的timestamp列。useCurrent()方法让我们将时间戳的默认值设置为当前日期和时间。

down()方法中,我们有Schema::dropIfExists()方法来删除具有参数中给定名称的表,如果存在的话。

迁移文件必须具有从Migration类继承的类,才能用作迁移。

同样,在文件名为create_message_table的迁移文件中,我们编写以下内容:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateMessagesTable extends Migration
{
    public function up()
    {
        Schema::create('messages', function (Blueprint 
         $table) {
            $table->id();
            $table->unsignedBigInteger('user_id');
            $table->unsignedBigInteger('chat_id');
            $table->string('message');
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent();
            $table->foreign('user_id')->references('id')-
              >on('users');
            $table->foreign('chat_id')->references('id')-
              >on('chats');
        });
    }
    public function down()
    {
        Schema::dropIfExists('messages');
    }
}

上述文件中有创建messages表的代码。这个表有更多的列。我们有与chats表中相同的idtimestamp列,但我们还有user_id无符号integer列来引用发布消息的用户的 ID,以及chat_id无符号integer列来引用chats表中的条目,将消息与创建它的聊天会话关联起来。

foreign()方法让我们指定user_idchat_id列分别引用用户和chats表中的内容。

配置我们的数据库

在我们运行迁移之前,我们必须配置我们将用于存储后端数据的数据库。为此,我们通过复制.env.example文件并将其重命名为.env来在项目的root文件夹中创建.env文件。

.env文件有许多设置,我们需要运行我们的 Laravel 应用程序。为了配置我们将使用的数据库,我们运行以下命令,以便连接到 SQLite 数据库:

DB_CONNECTION=sqlite
DB_DATABASE=C:\vue-example-ch8-chat-app\backend\db.sqlite

完整的配置文件在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/.env.example。我们只需将其内容复制到同一文件夹中的.env文件中以使用该配置。

本章中,我们使用 SQLite 是为了简化,这样我们就可以专注于使用 Vue 3 创建聊天应用程序。然而,如果我们要构建生产应用程序,我们应该使用具有更好安全性和管理能力的生产级数据库。DB_CONNECTION环境变量具有我们想要使用的数据库类型,即 SQLite。在DB_DATABASE设置中,我们指定了数据库文件的绝对路径。Laravel 不会自动为我们创建这个文件,所以我们必须自己创建。要创建 SQLite 文件,我们可以使用 DB Browser for SQLite 程序。它支持 Windows、macOS 和 Linux,因此我们可以在所有流行的平台上运行它。您可以从sqlitebrowser.org/dl/下载该程序。安装完成后,只需在左上角点击New Database,然后点击File菜单,再点击Save以保存数据库文件。

配置到 Redis 的连接

除了将 SQLite 用作我们应用程序的主要数据库之外,我们还需要配置与 Redis 的连接,以便我们可以使用 Laravel 的排队功能将数据广播到 Redis 服务器,然后由 Laravel Echo 服务器接收,以便事件将被发送到 Vue 3 前端。 Redis 配置的环境变量如下:

BROADCAST_DRIVER=redis
QUEUE_CONNECTION=redis
QUEUE_DRIVER=sync

然后我们添加以下 Redis 配置:

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

第一组环境变量配置了队列将数据发送到的位置。BROADCAST_DRIVER设置为redis,以便我们将事件发送到 Redis。QUEUE_CONNECTION也必须设置为redis,出于同样的原因。QUEUE_DRIVER设置为sync,以便事件在广播后立即发送到队列。

运行迁移文件

现在我们已经创建了迁移并配置了要使用的数据库,我们运行php artisan migrate来运行迁移。运行迁移将向我们的 SQLite 数据库添加表。添加表后,我们可以添加种子数据,这样当我们想要重置数据库或者数据库为空时,我们就不必重新创建数据。要创建种子数据,我们在database/seeders/DatabaseSeeder.php文件中添加一些代码。在文件中,我们编写以下代码来添加我们数据库的文件:

<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\User;
use App\Models\Chat;
class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->addUsers();
        $this->addChats();
        $this->addMessages();
    }
    private function addUsers()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('users')->insert([
                'name' => 'user'.$x,
                'email' => 'user'.$x.'@gmail.com',
                'password' => Hash::make('password'),
            ]);
        }
    }
  ...
}

我们有addUsers()方法将一些用户添加到users表中。我们创建一个循环,调用DB::table('users')->insertusers表中插入一些条目。->符号与 JavaScript 中的句号相同。它允许我们访问对象属性或方法。

insert()方法中,我们传入一个带有我们要插入的键和值的关联数组或字典:

    ...
    private function addChats()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('chats')->insert([
                'name' => 'chat '.$x,
            ]);
        }
    }
    ...

addChats()方法让我们添加聊天室条目。我们只需要插入名称。在addMessages()方法中,我们插入messages表的条目。我们获取要设置为id值的用户条目的值,该值来自users表中的现有条目。同样,我们对chat_id执行相同的操作,通过从chats表中获取条目并使用该条目的id值将其设置为chat_id的值。

...
    private function addMessages()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('messages')->insert([
                'message' => 'hello',
                'user_id' => User::all()->get(0)->id,
                'chat_id' => Chat::all()->get($x)->id
            ]);
            DB::table('messages')->insert([
                'message' => 'how are you',
                'user_id' => User::all()->get(1)->id,
                'chat_id' => Chat::all()->get($x)->id
            ]);
        }
    }
...

一旦我们编写了 seeder,我们可能希望重新生成 Composer 的自动加载程序,以便使用我们的依赖项更新自动加载程序。我们可以通过运行composer dump-autoload来做到这一点。这在引用任何依赖项过时并且我们想要刷新引用以使其不过时时非常方便的。然后我们运行php artisan db:seed来运行 seeder 以将所有数据填充到表中。

要将数据重置为原始状态,我们可以通过运行php artisan migrate:refresh –seed来同时运行迁移和 seeder。我们也可以只清空数据库并重新运行所有迁移,通过运行php artisan migrate:refresh

创建我们的应用逻辑

现在我们已经有了数据库结构和种子数据,我们可以继续创建我们的应用逻辑。我们创建一些控制器,这样我们就可以从前端接收请求。Laravel 控制器应该在app/Http/Controllers文件夹中。我们创建一个用于接收请求或操作chats表的控制器,另一个用于接收请求来操作messages表。Laravel 自带一个用于创建控制器的命令。首先,我们通过运行以下代码创建ChatController.php文件:

php artisan make:controller Chat

然后我们应该将app/Http/Controllers/ChatController.php文件添加到我们的项目中。完整的代码在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/ChatController.php

一个 Laravel 控制器有一个继承自Controller类的类。在这个类里,我们有一些方法,这些方法将被映射到 URL,这样我们就可以运行这些方法并做我们想做的事情。每个方法都接受一个请求对象,其中包括请求数据,包括头部、URL 参数和主体。

get()方法查找单个Chat条目。Chatchats表的模型类。在 Laravel 中,约定是类名对应于表名,通过去掉末尾的s然后将第一个字母转换为大写来转换表名。因此,Chat模型类用于操作chats表中的条目。Laravel 会自动进行映射,因此我们不必自己做任何事情。我们只需要记住这个约定,这样我们就不会被搞混。find()方法是一个我们用来通过 ID 获取单个条目的static方法。

在所有控制器函数中,我们可以返回一个字符串,一个关联数组,一个响应对象,或者从query()方法返回的结果作为响应返回。因此,当我们发出请求并调用get方法时,Chat::find方法的返回值将作为响应返回。

getAll()方法用于从chats表中获取所有条目。all()方法是一个静态方法,返回所有条目。

create()方法用于从请求数据创建条目。我们调用Validate::make静态方法为请求数据创建验证器。第一个参数是$request->all(),这是一个返回请求对象中所有项目的方法。第二个参数是一个关联数组,其中包含要验证的请求体的键。它的值是一个包含验证规则的字符串。required规则确保name被填写。string规则检查作为name键值的值是否为字符串。max:255规则是name值中我们可以拥有的最大字符数:

...
    public function create(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
        ]);
        if($validator->fails()){
            return response()->json($validator->errors()-
              >toJson(), 400);
        }
        $chat = Chat::create([
            'name' => $request->get('name'),
        ]);
        return response()->json($chat, 201);
    }
...

我们使用$validator->fails()方法来检查验证是否失败。$validator是由Validator::make方法返回的对象。在if块中,我们调用response()->json()将错误以 400 状态码返回给用户。

否则,我们调用Chat::create来创建chats表条目。我们使用$request->get方法从请求体中获取name字段的值,使用我们想要获取的键。然后我们将其设置为我们传递给create方法的关联数组中'name'键的值。

我们对update()方法做类似的操作,只是我们调用Chat::find来通过其id值找到项目。然后我们将请求体中的name字段的值分配给返回的聊天对象的name属性。然后我们调用$chat->save()来保存最新值。然后我们通过调用response()->json($chat)返回响应,以将最新的聊天条目转换为 JSON:

...
    public function update(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
        ]);
        if($validator->fails()){
            return response()->json($validator->errors()-
              >toJson(), 400);
        }
        $chat = Chat::find($request->id);
        $chat->name =  $request->get('name');
        $chat->save();
        return response()->json($chat);
    }
...

当我们向 API 发出DELETE请求以删除聊天室条目时,将调用delete()方法。我们再次调用Chat::find来查找具有给定 ID 的chats表中的条目。然后我们调用$chat->delete()来删除返回的条目。然后我们返回一个空响应:

...
    public function delete(Request $request)
    {
        $chat = Chat::find($request->id);
        $chat->delete();
        return response(null, 200);
    }
...

我们有类似的逻辑MessageController.php,让我们保存聊天消息。我们有UserController.php文件,其中包含注册用户帐户时保存用户数据的代码。

重要:

这些文件可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/MessageController.phpgithub.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/UserController.php找到。

公开控制器方法以供端点使用

接下来,我们必须将我们的控制器方法映射到我们将发出请求调用的 URL。我们通过向routes/api.php文件添加一些代码来实现这一点。为此,我们用以下代码替换文件中的内容:

<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\MessageController;
Route::post('register', [UserController::class, 'register']);
Route::group([
    'middleware' => 'api',
    'prefix' => 'auth'
], function () {
    Route::post('login', [AuthController::class, 'login']);
    Route::post('logout', [AuthController::class, 
     'logout']);
    Route::post('refresh', [AuthController::class, 
     'refresh']);
    Route::post('me', [AuthController::class, 'me']);
     });
...
    Route::get('{chat_id}', [MessageController::class, 
     'getAll']);
    Route::post('create', [MessageController::class, 
     'create']);
});

我们通过分别调用Route::postRoute::get方法,将控制器方法公开为客户端的 POST 和 GET 端点。

jwt.verify中间件是我们在运行路由的controller方法之前用来检查 JSON Web 令牌的方法。这样,只有在令牌有效时才会运行controller()方法。

然后我们必须创建AuthController来进行 JSON Web 令牌身份验证。

首先,我们运行以下命令:

php artisan make:controller AuthController

然后在app/Http/Controllers/AuthController.php文件中,我们添加了用于获取当前用户数据、登录和注销的端点方法。该文件的代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/AuthController.php

如果您没有app/Http/Middleware/JwtMiddleware.php文件,该文件的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Middleware/JwtMiddleware.php

它使我们能够在 Vue 客户端和此应用程序之间启用 JSON Web 令牌身份验证。

设置 JWT 身份验证

现在,我们必须设置 JSON Web 令牌身份验证与我们的 Laravel 应用程序,以便我们可以将我们的前端与后端分开托管。为此,我们使用了tymon/jwt-auth库。要安装它,我们运行以下命令:

composer require tymon/jwt-auth

接下来,我们运行以下命令来发布软件包配置文件:

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

上述命令将为我们添加所有必需的配置。现在我们应该在我们的应用程序中添加config/jwt.php。然后,我们通过运行以下命令生成秘密密钥以签署 JSON Web 令牌:

php artisan jwt:secret

secret密钥将被添加到.env文件中,密钥为JWT_SECRET

配置我们的身份验证

接下来,我们必须配置我们的身份验证,以便在成功发出需要身份验证的路由请求之前,我们可以验证我们的 JSON Web 令牌。在config/auth.php中,我们有以下代码:

<?php
return [
    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],
    'password_timeout' => 10800,
];

guards部分,我们有一个api密钥,其值为一个关联数组,其中驱动程序键设置为'jwt',提供者设置为'users',以便我们使用jwt-auth库发行的 JSON Web 令牌对用户进行身份验证。

接下来,我们添加代码以启用 CORS,以便我们的 Vue.js 3 应用程序可以与其通信。

启用跨域通信

为了使我们能够在前端和后端之间进行跨域通信,我们安装了fruitcake/laravel-cors软件包。为此,我们运行以下命令:

composer require fruitcake/laravel-cors

然后,在app/Http/Kernel.php中,我们应该有以下内容:

<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\
           PreventRequestsDuringMaintenance::class,
        \Illuminate\Foundation\Http\Middleware\
           ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\
           ConvertEmptyStringsToNull::class,
        \Fruitcake\Cors\HandleCors::class,
    ];
...
        'password.confirm' =>  \Illuminate\Auth\Middleware\
           RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\
           ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\
           ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\
           EnsureEmailIsVerified::class,
        'jwt.verify' => \App\Http\Middleware\
           JwtMiddleware::class,
    ];
}

我们在$routesMiddleware关联数组中写入以下代码,注册了laravel-cors包中提供的HandleCors中间件,并在$routeMiddleware关联数组中注册了jwt.verify中间件:

'jwt.verify' => \App\Http\Middleware\JwtMiddleware::class,

这样,我们可以使用jwt.verify中间件来验证令牌。

完整的代码在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Kernel.php

此外,我们安装predis包,这样我们就可以更轻松地与我们的 Redis 数据库进行通信。要安装predis包,我们运行以下命令:

composer require predis/predis

然后,在config/database.php中,我们编写以下代码:

<?php
use Illuminate\Support\Str;
return [
    ...    'redis' => [
        'client' => env('REDIS_CLIENT', 'predis'),
        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'redis'),
            'prefix' => env('REDIS_PREFIX', Str::slug(   
            env('APP_NAME', 'laravel'), '_').'_database_'),
        ],
        ...
        'cache' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_CACHE_DB', '1'),
        ],
    ],
];

我们在关联数组中配置了 Redis 数据库连接,以便我们可以连接到 Redis。

完整的文件在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/config/database.php

现在我们已经为存储用户数据和他们的聊天消息创建了 API,我们将继续为 Laravel 应用程序添加实时通信功能,以便用户可以实时保存和获取聊天消息。

添加实时通信

现在我们已经完成了添加路由、身份验证和数据库配置和操作代码,我们准备添加让我们在前端和后端实时通信的代码。首先,我们需要在 Laravel 后端创建一个event类,这样我们就可以调用event函数来广播事件,就像我们在MessageController中所做的那样。

为此,我们运行php artisan make:event MessageSent命令来创建MessageSent事件类。该类现在应该在backend/app/Events/MessageSent.php文件中。创建文件后,我们用以下代码替换文件中的内容:

<?php
namespace App\Events;
...
class MessageSent implements ShouldBroadcast
{
    use InteractsWithSockets, SerializesModels;
    public $user;
    public $message;
    public function __construct(User $user, Message 
      $message)
    {
        $this->user = $user;
        $this->message = $message;
    }
    public function broadcastOn()
    {
        return new Channel('chat');
    }
    public function broadcastAs()
    {
        return 'MessageSent';
    }
}

__constructor()方法是构造函数;我们获取$user$message对象,然后将它们分配给同名的类变量。broadcastOn()方法返回Channel对象,它创建一个我们可以在前端监听的频道。broadCastAs()方法返回我们在聊天频道中监听的事件名称。我们将在前端使用这个来监听广播事件。一个event类应该实现ShouldBroadcast接口,以便可以从中广播事件。

MessageSent.php的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Events/MessageSent.php

backend/routes/channels.php文件中,我们应该有以下代码,以便所有用户都可以监听聊天频道:

<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('chat', function () {
    return true;
});

第一个参数是我们要订阅的频道的名称。回调是一个函数,如果用户可以监听事件则返回true,否则返回false。一个可选参数是user对象,以便我们可以检查用户是否可以监听给定事件。

此文件的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/routes/channels.php

通信流程如下图所示:

图 8.1 - 聊天应用程序架构图

图 8.1 - 聊天应用程序架构图

Vue 3 前端通过 HTTP 请求将要发送的消息发送到 Laravel 应用程序。Laravel 应用程序将消息保存到messages表中,并带有聊天会话和用户的 ID。它还通过队列向 Redis 服务器广播事件。然后 Laravel Echo 服务器监视 Redis,看是否有任何内容保存到 Redis 数据库。如果有任何新保存的内容,那么 Laravel Echo 服务器将其推送到 Vue 3 前端。Vue 3 前端通过使用 Laravel Echo 客户端和 Socket.IO 客户端监听 Laravel Echo 服务器上的事件来接收它。

与 Socket.IO 通信

为了使我们的后端应用通过 Socket.IO 与前端通信,我们需要使用 Laravel Echo Server。为此,我们首先需要全局安装 Laravel Echo Server 的npm包。我们可以通过运行npm install –g laravel-echo-server来安装它。然后我们将运行这个包来创建配置文件以设置通信。

为此,我们创建一个新文件夹,然后运行laravel-echo-server init来运行命令行向导,在文件夹中创建 Laravel Echo Server 配置文件。在这一点上,我们可以用默认设置回答所有问题。这是因为一旦向导完成,我们将编辑它创建的配置文件。

向导完成后,我们应该在文件夹中看到laraval-echo-server.json文件。现在我们打开它,并用以下代码替换其中的内容:

{
 "authHost": "http://localhost:8000",
 "authEndpoint": "/broadcasting/auth",
 "clients": [
  {
   "appId": "APP_ID",
   "key": "c84077a4dabd8ab2a60e51b051c9d0ea"
  }
 ...
  },
  "sqlite": {
   "databasePath": "/database/laravel-echo-server.sqlite"
  },
  "publishPresence": true
 },
 "devMode": true,
 "host": "127.0.0.1",
 ...
 "http": true,
  "redis": true
 },
 "apiOriginAllow": {
  "allowCors": true,
  "allowOrigin": "*",
  "allowMethods": "GET, POST",
  "allowHeaders": "Origin, Content-Type, X-Auth-Token,     X-Requested-With, Accept, Authorization, X-CSRF-TOKEN,     X-Socket-Id"
 }
}

在上述代码中,我们有配置的 JSON 代码,以便 Laravel Echo Server 可以监听保存在 Redis 中的项目,然后通过 Socket.IO 客户端将 Redis 数据库中的内容发送到前端。devMode属性设置为true,以便我们可以看到所有发送的事件。主机具有 Laravel Echo Server 的主机 IP 地址。port属性设置为6001,因此此服务器将监听 6001 端口。此文件的另一个重要部分是apiOriginAllow属性。它设置为一个对象,其中allowCors设置为true,以便我们可以与前端进行跨域通信。

allowOrigin属性让我们设置允许监听发出的事件的域。allowMethods属性具有允许从前端接收的 HTTP 方法。allowHeaders属性具有允许从前端发送到 Laravel Echo Server 的 HTTP 请求标头列表。

authHost具有 Laravel 应用的基本 URL,以便它可以监听 Laravel 应用广播的事件。authEndpoint具有用于检查用户是否经过身份验证以便监听需要身份验证的事件的身份验证端点。

这个配置文件的另一个重要部分是数据库配置属性。数据库属性设置为"redis",以便它将监听 Redis 服务器以保存项目。databaseConfig属性具有设置,让我们连接到 Redis 服务器。"redis"属性设置为一个对象,其中port属性设置为 Redis 服务器监听的端口。Redis 的默认端口是6379host属性是 Redis 服务器的位置。publishPresence属性设置为true,以便 Redis 发布保存在其数据库中的项目。

完整的配置在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/laravel-echo-server/laravel-echo-server.json

Redis 的最新版本仅适用于 Linux 或 macOS。要在 Ubuntu Linux 上安装 Redis,请运行以下命令安装 Redis 服务器:

sudo apt update
sudo apt install redis-server

如果您使用的是 Windows 10,可以使用 Windows 子系统来安装 Ubuntu Linux 的副本,以便运行最新版本的 Redis。要在 Windows 10 上安装 Ubuntu,请执行以下操作:

  1. 开始菜单中键入打开或关闭 Windows 功能

然后我们滚动到底部,点击Windows 子系统用于 Linux进行安装。它会要求您重新启动,您应该在继续之前这样做。

  1. 计算机重新启动后,转到Windows 商店,搜索Ubuntu,然后您可以点击它并点击获取

  2. 安装完成后,您可以在开始菜单中键入Ubuntu并启动它。现在只需按照说明完成安装。

然后您可以运行上述两个命令来安装 Redis。

安装完 Redis 后,我们运行以下命令来运行 Redis 服务器:

redis-server

现在我们项目的后端部分已经完成。现在我们运行php artisan servephp artisan queue:listen来运行 Laravel 应用程序和队列工作程序。我们还必须运行 Laravel Echo Server,通过运行laravel-echo-server start来启动 Laravel Echo Server。

如果遇到任何问题,您可能需要清除缓存以确保最新的代码实际在运行。要做到这一点,您可以运行以下命令来清除所有缓存:

php artisan config:cache
php artisan config:clear
php artisan route:cache
php artisan route:clear

如果缓存已清除,代码仍然无法正常工作,那么您可以返回检查代码。

现在我们已经为我们的 Laravel 应用程序添加了实时通信,我们准备继续创建前端,让用户注册帐户,登录并在聊天室中开始聊天。

创建前端以让用户聊天

现在我们已经完成并运行了后端代码,我们可以开始工作在前端。前端与前几章的内容并没有太大的不同。我们使用 Vue CLI 在vue-example-ch8-chat-app文件夹的frontend文件夹中创建我们的项目,然后我们可以开始编写我们的代码。

在“vue-example-ch8-chat-app / frontend”文件夹中,我们运行vue create,然后选择选择版本,然后选择启用Vue Router选项的Vue 3选项。一旦 Vue CLI 向导完成运行,我们就可以开始构建我们的前端。

安装 Vue 依赖项

除了 Vue 依赖项之外,我们还需要安装 Axios HTTP 客户端、Socket.IO 客户端和 Laravel Echo 客户端包,以通过 Laravel Echo 服务器分别进行 HTTP 请求和监听从服务器端发出的事件。要安装这些,我们运行以下命令:

npm install axios socket.io-client laravel-echo

首先,在src文件夹中,我们创建constants.js文件并添加以下代码:

export const APIURL = 'http://localhost:8000';

我们添加了APIURL常量,当我们向 API 端点发出请求时将使用它。在src/main.js中,我们用以下代码替换我们已有的代码:

...
axios.interceptors.request.use((config) => {
  if (config.url.includes('login') || 
   config.url.includes('register')) {
    return config;
  }
  return {
    ...config, headers: {
      Authorization: `Bearer ${localStorage.getItem('token')}`,
    }
  }
}, (error) => {
  return Promise.reject(error);
});
axios.interceptors.response.use((response) => {
  const { data: { status } } = response;
  if (status === 'Token is Expired') {
    router.push('/login');
  }
  return response;
}, (error) => {
  return Promise.reject(error);
});
createApp(App).use(router).mount('#app')

在这个文件中有两件新事物。我们有 Axios 请求和响应拦截器,这样我们就可以在每次请求时应用相同的设置,而不必重复相同的代码。axios.interceptors.request.use()方法接受一个回调,让我们根据需要返回一个新的config对象。

如果请求的 URL 不包括“登录”或“注册”,那么我们需要将令牌添加到Authorization标头中。这就是我们在传递给“use()”方法的回调中所做的。我们将令牌添加到需要它们的端点的请求配置中。第二个回调是一个错误处理程序,我们只是返回一个拒绝的承诺,这样我们就可以在发出请求时处理它们。

类似地,我们有axios.interceptor.response.use()方法来检查每个响应,并使用第一个参数中的回调函数。我们检查响应体是否具有将status属性设置为"Token is expired"字符串,以便在收到此消息时重定向到登录页面并返回响应。否则,我们原样返回响应。第二个参数中的错误处理程序与请求拦截器相同。

创建我们的组件

接下来,我们创建我们的组件。我们从表单开始,让我们设置或编辑聊天室名称。为此,我们进入src/components文件夹并创建ChatroomForm.vue文件。然后,在文件中,我们编写以下代码:

<template>
  <div>
    <h1>{{ edit ? "Edit" : "Add" }} Chatroom</h1>
    <form @submit.prevent="submit">
      <div class="form-field">
        <label for="name">Name</label>
        <br />
        <input v-model="form.name" type="text" name="name" 
          />
      </div>
      <div>
        <input type="submit" />
      </div>
    </form>
  </div>
</template>
...

该组件接受edit属性,其类型为布尔值,以及id属性,其类型为字符串。它有一个响应式属性,即form属性。它用于将输入值绑定到响应式属性。我们有submit()方法来检查名称是否已填写。如果是,则我们继续提交。如果edit属性为 true,则我们进行 PUT 请求以更新chats表中具有给定 ID 的现有条目。否则,我们在相同的表中创建一个具有给定名称值的新条目。完成后,我们重定向到具有聊天室列表的主页。

created钩子中,我们检查edit响应式属性是否为 true。如果是,则我们获取具有给定 ID 的chats表中的条目,并将其设置为form响应式属性的值,以便我们可以在输入框中看到form.name属性的值:

<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "ChatroomForm",
  ...
  async created() {
    if (this.edit) {
      const { data } = await 
       axios.get(`${APIURL}/api/chat/${this.id}`);
      this.form = data;
    }
  },
 };
</script>

接下来,在src/components文件夹中,我们创建NavBar.vue来创建一个渲染导航栏的组件。在文件内,我们编写以下代码:

<template>
  <div>
    <ul>
      <li>
        <router-link to="/">Chatrooms</router-link>
      </li>
      <li><a href="#" @click="logOut">Logout</a></li>
    </ul>
  </div>
</template>
<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "NavBar",
  methods: {
    async logOut() {
      await axios.post(`${APIURL}/api/auth/logout`);
      localStorage.clear();
      this.$router.push("/login");
    },
  },
};
</script>
...

我们有一个router-link组件,可以转到聊天室页面。这是通过将to属性设置为/路由来实现的。我们还有一个链接,当我们点击它时调用logout()方法。logout()方法发出 POST 请求到/api/auth/logOut端点来使 JSON web token 失效。然后我们调用localStorage.clear()方法来清除本地存储。然后我们调用this.$router.push来重定向到登录页面。

在样式部分,我们为ulli元素设置了一些样式,使li水平显示,并在它们之间设置了一些边距。我们还将list-style-type属性设置为none,以便从列表中移除项目符号:

<style scoped>
ul {
  list-style-type: none;
}
ul li {
  display: inline;
  margin-right: 10px;
}
</style>

src/views文件夹中,我们有页面组件,我们使用 Vue Router 将其映射到 URL,以便我们可以从浏览器访问这些组件。首先,在src/views文件夹中创建AddChatroomForm.vue组件文件,并添加以下代码:

<template>
  <div>
    <NavBar></NavBar>
    <ChatroomForm></ChatroomForm>
  </div>
</template>
<script>
import ChatroomForm from "@/components/ChatroomForm";
import NavBar from "@/components/NavBar";
export default {
  components: {
    ChatroomForm,
    NavBar
  },
};
</script>

我们只需在components属性中注册NavBarChatroomForm组件,然后将它们添加到模板中。

接下来,我们创建ChatRoom.vue组件来显示我们的聊天消息,并在此文件中添加代码来监听从 Laravel 应用程序通过 Redis 数据库和 Laravel Echo 服务器发出的laravel_database_chat频道的MessageSent事件。在此文件中,我们编写以下代码:

...
<script>
import axios from "axios";
import { APIURL } from "../constants";
import NavBar from "@/components/NavBar";
export default {
  name: "Chatroom",
  components: {
    NavBar,
  },
  beforeMount() {
    this.getChatMessages();
    this.addChatListener();
  },
  data() {
    return {
      chatMessages: [],
      message: "",
    };
  },
  ...};
</script>

然后,我们在同一文件中添加方法来获取和发送聊天消息,编写以下代码:

...
<script>
...
export default {
  ...
  methods: {
    async getChatMessages() {
      const { id } = this.$route.params;
      const { data } = await 
        axios.get(`${APIURL}/api/message/${id}`);
      this.chatMessages = data;
      this.$nextTick(() => {
        const container = this.$refs.container;
        container.scrollTop = container.scrollHeight;
      });
    },
    async sendChatMessage() {
      const { message } = this;
      if (!message) {
        return;
      }
      const { id: chat_id } = this.$route.params;
        ...
        () => {
          this.getChatMessages();
        }
      );
    },
  },
</script>

getChatMessages方法从 API 获取聊天室的聊天消息,sendChatMessage方法通过向 API 发出 HTTP 请求提交聊天消息来向聊天室发送消息。然后,API 端点会通过 Laravel Echo 服务器将消息发送到队列,然后返回到此应用中使用的 Socket.IO 聊天客户端。我们调用addChatListener来监听服务器发送的laravel_database_chat事件,该事件调用getChatMessages以获取最新消息。

组件模板只是使用v-for指令来渲染chatMessages响应式属性的每个条目并将它们呈现出来。以下代码中的form元素用于让我们输入消息,然后通过发出 HTTP 请求将其提交到 Laravel。端点将消息保存到messages表,并且还会触发一个我们监听的事件,该事件通过 Redis 数据库和 Laravel Echo 服务器发送。前端只从实时通信的角度了解 Laravel Echo 服务器:

<template>
  <div>
    <NavBar></NavBar>
    <h1>Chatroom</h1>
    <div id="chat-messages" ref="container">
      <div class="row" v-for="m of chatMessages" 
        :key="m.id">
        <div>
          <b>{{ m.user.name }} - {{ m.created_at }}</b>
        </div>
        <div>{{ m.message }}</div>
      </div>
    </div>
    <form @submit.prevent="sendChatMessage">
      <div class="form-field">
        <label for="message">Message</label>
        <br />
        <input v-model="message" type="text" name="message"
           />
      </div>
      <div>
        <input type="submit" />
      </div>
    </form>
  </div>
</template>

component对象中,我们有beforeMount钩子来调用getChatMessage方法来获取聊天消息。addChatListener()方法使用 Socket.IO 客户端创建事件侦听器,让我们监听从 Laravel Echo 服务器发出的事件。在getChatMessage()方法中,我们调用this.$nextTick()方法并带有一个回调,以便我们在获取消息后始终滚动到包含消息的div标签的底部。我们在$nextTick回调中运行该代码,因为我们需要确保滚动代码在所有消息都呈现后运行。

this.$nextTick()方法让我们在响应式属性更新后等待组件重新渲染,然后再运行回调中的代码。

addChatListener()方法中,我们订阅了laravel_database_chat频道,这与我们在 Laravel 应用程序中定义的聊天频道相同。我们可以通过观察 Laravel Echo Server 的输出来确保我们订阅了正确的频道。.MessageSent事件与我们在后端应用程序中定义的事件相同。事件名称前的点是必需的,以便它在正确的命名空间中监听到正确的事件。在我们传递给监听器的回调函数中,我们调用this.getChatMessages()来获取最新的消息。

聊天消息的容器高度设置为 300px,这样当我们有太多消息时,它不会太高。它还让我们在有足够的消息溢出容器时滚动到底部:

<style scoped>
#chat-messages {
  height: 300px;
  overflow-y: scroll;
}
.row {
  display: flex;
  flex-wrap: wrap;
}
.row div:first-child {
  width: 30%;
}
</style>

接下来,在src/views文件夹中,我们通过编写以下代码创建Chatrooms.vue组件文件:

<template>
  <div>
    <NavBar></NavBar>
    <h1>Chatrooms</h1>
    <button @click="createChatRoom">Create Chatroom
      </button>
    <table id="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Go</th>
          <th>Edit</th>
          <th>Delete</th>
...
  beforeMount() {
    this.getChatRooms();
  },
};
</script>
<style scoped>
#table {
  width: 100%;
...
</style>

我们渲染了一个包含我们可以进入、编辑名称或删除的聊天室列表的表格。该方法只是获取聊天室数据,并转到编辑具有给定 ID 的聊天室的路由,添加聊天室的路由,重定向到具有给定 ID 的聊天室页面的路由,以及删除聊天室的路由。当我们删除聊天室时,我们再次使用getChatRooms()方法获取最新条目,以便获取最新数据。

我们在beforeMount钩子中获取聊天室列表,以便在页面加载时看到表格条目。接下来,在相同的文件夹中,我们创建EditChatroomForm.vue文件并添加以下代码:

<template>
  <div>
    <NavBar></NavBar>
    <ChatroomForm edit :id="$route.params.id">
      </ChatroomForm>
  </div>
</template>
<script>
import ChatroomForm from "@/components/ChatroomForm";
import NavBar from "@/components/NavBar";
export default {
  components: {
    ChatroomForm,
    NavBar,
  },
};
</script>

它与AddChatroomForm.vue文件具有相同的内容,但是ChatroomForm上的edit属性设置为trueid属性设置为从 Vue Router 获取的id URL 参数。

创建登录页面

接下来,我们通过创建src/views/Login.vue并添加以下代码来创建登录页面:

<template>
  <div>
    <h1>Login</h1>
    <form @submit.prevent="login">
      <div class="form-field">
        <label for="email">Email</label>
        <br />
        <input v-model="form.email" type="email" name="email" />
      </div>
      <div class="form-field">
        <label for="password">Password</label>
        <br />
        <input v-model="form.password" type="password"            name="password" />
      </div>
      <div>
        <input type="submit" value="Log in" />
        <button type="button" @click="goToRegister">Register</            button>
      </div>
    </form>
  </div>
</template>
<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "Login",
  data() {
    return {
      form: {
        email: "",
        password: "",
      },
    };
  },
  methods: {
    async login() {
      const { email, password } = this.form;
      if (!email || !password) {
        alert("Email and password are required");
        return;
      }
      try {
        const {
          data: { access_token },
        } = await axios.post(`${APIURL}/api/auth/login`, {
          email,
          password,
        });
        localStorage.setItem("token", access_token);
        this.$router.push("/");
      } catch (error) {
        alert("Invalid username or password");
      }
    },
    goToRegister() {
      this.$router.push("/register");
    },
  },
};
</script>

模板只有一个登录表单,用于输入电子邮件和密码以便我们登录。当我们提交表单时,将调用login()方法。它首先检查所有字段是否填写正确,然后使用凭据向/api/auth/login路由发出 HTTP 请求,以查看我们是否可以登录。

表单还有一个Register.vue文件,用于转到注册页面,以便我们可以注册账户并加入聊天室。

我们需要创建的最后一个页面是用于容纳注册表单的页面。为了创建它,我们编写以下代码:

<template>
  <div>
    <h1>Register</h1>
    <form @submit.prevent="register">
      <div class="form-field">
        <label for="email">Name</label>
        <br />
        <input v-model="form.name" type="text" name="name"
          />
      </div>
      <div class="form-field">
        <label for="email">Email</label>
        <br />
        <input v-model="form.email" type="email" 
          name="email" />
      </div>
      <div class="form-field">
        <label for="password">Password</label>
        <br />
          …
          name,
          email,
          password,
          password_confirmation: confirmPassword,
        });
        this.$router.push("/login");
      } catch (error) {
        alert("Invalid username or password");
      }
    },
  },
};
</script>

表格中有姓名电子邮件密码确认密码字段,所有这些字段都是注册账户所必需的。当我们提交表格时,我们调用register()方法。我们对字段进行检查,以查看它们是否填写正确。调用email正则表达式上的test()方法来检查有效的电子邮件地址。如果有效,则test()方法返回true。否则,它返回false。我们还检查密码是否与confirmPassword变量相同。如果一切正常,我们就会发出 POST 请求来注册用户账户。

src/App.vue中,我们用以下代码替换现有内容,以添加router-view组件,以便我们可以从src/views文件夹中看到路由组件:

<template>
  <div>
    <router-view />
  </div>
</template>
<style scoped>
div {
  width: 70vw;
  margin: 0 auto;
}
</style>
<style>
.form-field input {
  width: 100%;
}
</style>

然后,在src/router/index.js文件中,我们用以下代码替换现有内容,以注册所有路由,并使用 Laravel Echo 库创建Socket.io事件来监听:

...
window.io = require('socket.io-client');
const beforeEnter = (to, from, next) => {
  const hasToken = Boolean(localStorage.getItem('token'));
  if (!hasToken) {
    return next({ path: 'login' });
  }
  next();
}
const routes = [
  {
...
    path: '/edit-chatroom/:id',
    name: 'edit-chatroom/:id',
    component: EditChatroomForm,
    beforeEnter
  },
]
...
export default router

Laravel Echo 客户端与 Socket.IO 客户端一起使用,以便我们可以监听从 Laravel Echo 服务器广播的事件。broadcaster属性设置为'socket.io',以便我们可以监听来自 Laravel Echo 服务器的事件。host属性设置为 Laravel Echo 服务器的 URL。

此外,我们还有beforeEnter导航守卫,我们在之前的章节中已经看到了,当我们需要限制路由仅在身份验证成功后才可用时。我们只需检查令牌是否存在。如果存在,我们调用next来继续下一个路由。否则,我们重定向到登录页面。

现在我们可以通过运行npm run serve来运行前端,就像我们在所有其他项目中所做的那样。现在我们应该看到类似以下屏幕截图的东西。以下屏幕截图显示了聊天室用户界面:

图 8.2 – 聊天室的屏幕截图

图 8.2 – 聊天室的屏幕截图

以下屏幕截图是 Laravel Echo 服务器的工作情况。我们应该看到广播的事件名称以及发送的频道:

图 8.3 – 当聊天事件发送到前端时,Redis 的输出

图 8.3 – 当聊天事件发送到前端时,Redis 的输出

以下屏幕截图是队列事件的日志:

图 8.4 - Laravel 事件的输出

图 8.4 - Laravel 事件的输出

我们通过在backend文件夹中运行php artisan queue:listen来启动队列,该文件夹是 Laravel 项目所在的文件夹。

现在我们已经让聊天应用的前端工作了,我们使用 Laravel 和 Vue 创建了一个简单的聊天系统。

总结

在本章中,我们看了如何使用 Laravel 和 Vue 构建聊天应用。我们用 Laravel 构建了后端,并添加了控制器来接收请求。我们还使用了 Laravel 内置的队列系统将数据发送到前端。我们还在我们的 Laravel 应用中添加了 JSON Web Token 身份验证。

在前端,我们使用 Socket.IO 客户端来监听从 Laravel Echo 服务器发送的事件,该服务器通过 Redis 从 Laravel 获取数据。

现在我们已经通过 Vue 3 项目的各种难度,可以将我们在这里学到的东西应用到现实生活中。现实生活中的 Vue 应用几乎总是会向服务器发出 HTTP 请求。Axios 库使这变得容易。一些应用还会与服务器实时通信,就像本章中的聊天应用一样。

唯一的区别在于,在现实生活中的应用中,会有检查来查看用户是否经过身份验证并且被授权将数据发送到服务器。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报