Vue3-设计模式与最佳实践-全-

Vue3 设计模式与最佳实践(全)

原文:zh.annas-archive.org/md5/a8e765ec91697860c03ca479c1d16d74

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Vue 3 是“进阶框架”的最新和最强大的迭代版本,用于创建反应性和响应式的用户界面。该框架本身引入了新的概念和设计模式的方法,这在其他库和框架中可能不太常见。通过学习框架的基础知识,理解软件工程中的设计原则和模式,这本书帮助你识别每种方法的权衡,并构建稳固的应用程序。

它从基本概念开始,然后通过示例和编码场景逐步构建更复杂的架构。你将从简单的页面开始,最终构建一个多线程、离线和可安装的进阶网络应用程序PWA)。内容还探讨了如何使用 Vue 3 的新测试工具。

不仅仅展示“如何做事”,这本书还帮助你学习如何“思考”和“处理”设计模式已经找到解决方案的常见问题。避免在每个项目中重新发明轮子将节省你的时间,并使你的软件更适合未来的变化。

本书面向的对象

本书针对关注框架设计原则并利用在 Web 应用程序开发中常见的设计模式的 Vue 开发者。学习使用和配置新的打包器(Vite)、Pinia(状态管理)、Router 4、Web Workers 和其他技术来创建高性能和稳固的应用程序。具备 JavaScript 的先验知识和 Vue 的基本知识将有所帮助。

本书涵盖的内容

第一章**, Vue 3 框架

Vue 3 进阶框架是什么?本章介绍了框架最重要的方面和其他关键概念。

第二章**, 软件设计原则 和模式

软件原则和模式构成了良好软件架构的标志。本章介绍了这两者,并提供了在 JavaScript 和 Vue 3 中实现示例。

第三章**, 设置 工作项目

在建立必要的入门概念之后,本章设置了一个工作项目,该项目将作为未来项目的基准参考。它将逐步指导你如何使用正确的工具开始一个项目。

第四章**, 使用组件进行 用户界面组合

本章介绍了用户界面的概念,并引导你进入实现一个网络应用程序的过程,从概念视觉设计到开发与之匹配的组件。

第五章**, 单页应用程序*

这是一个关键章节,介绍了 Vue Router 以创建单页网络应用程序。

第六章**, 进阶 网络应用程序

本章在单页应用(SPAs)的基础上构建,以创建渐进式 Web 应用(PWAs),并介绍了使用工具来评估其准备状态和性能的方法。

第七章**,数据 流管理

本章介绍了设计和控制应用程序内以及组件间数据和信息流的关键概念。它介绍了 Pinia 作为 Vue 3 的官方状态管理框架。

第八章**,使用 Web Workers 进行多线程处理

本章重点介绍如何使用 Web Workers 进行多线程来提高大型应用性能,并介绍了更多易于实现和维护的架构模式。

第九章**,测试和 源代码控制

在本章中,我们介绍了 Vue 团队提供的官方测试工具以及最广泛使用的版本控制系统:Git。本章展示了如何为我们的独立 JavaScript 以及 Vue 3 组件创建测试用例。

第十章**,部署 您的应用

本章介绍了理解如何在实时生产服务器上发布 Vue 3 应用以及如何使用 Let’s Encrypt 证书对其进行安全保护所必需的概念。

第十一章**,附加章节 - UX 模式,本附加章节深入探讨了用户界面和用户体验模式的概念,为开发者和设计师之间提供了一种共同语言。它展示了 HTML 5 标准和其他常见元素提供的常见模式。

附录:从 Vue 2 迁移到 Vue 3

本附录为经验丰富的 Vue 2 开发者提供了关于更改和迁移选项的指南。

结语

在最后一章中,作者简要总结了每章学到的所有概念,并鼓励您继续个人发展。

为了充分利用本书

本书假设您熟悉并熟悉诸如 JavaScript、HTML 和 CSS 等网络技术。对扩展他们对设计模式和架构理解感兴趣的开发者将能从本书中获得最大收益。网络应用领域的学生和初学者也可以通过仔细阅读代码示例并使用 GitHub 仓库中提供的项目来跟随本书。

本书涵盖的软件/硬件 操作系统要求

| 官方 Vue 3 生态系统:

  • Vue 3 框架

  • Pinia

  • Vue Router

  • Vite

  • Vitest

  • Vue 测试工具

Windows、macOS 或 Linux
Node.js(任何版本 + v16 LTS)
Web 服务器:NGINX、Apache
Visual Studio Code
Chrome 浏览器

考虑到现代计算机,没有特定的硬件要求,但建议至少具备以下条件:

  • 至少 1 GHz 的 Intel 或 AMD CPU

  • 4 GB 的 RAM(越多越好)

  • 至少 10 GB 的可用存储空间(用于程序和代码)

作为一般规则,如果您的计算机可以运行现代网络浏览器(Chrome/Chromium、Mozilla Firefox 或 Microsoft Edge),那么它应该满足安装和运行本书中提到的所有开发工具的所有要求。

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices。如果代码有更新,它将在 GitHub 仓库中更新。

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

代码实战

本书“代码实战”视频可在packt.link/FtCMS查看

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/oronG

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“main.js文件将导入并启动 Vue 3 应用程序。”

代码块按以下方式设置:

<script setup>
    // Here we write our JavaScript
</scrip>
<template>
    <h1>Hello World! This is pure HTML</h1>
</template>
<style scoped>
    h1{color:purple}
</style>

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

<script>
export default{
    data(){return {_hello:"Hello World"}}
}
</script>

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

$ npm install

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在这种情况下,值得提及的是童子军原则,它与它相似但适用于团队。”

小贴士或重要注意事项

看起来是这样的。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至customercare@packtpub.com,并在邮件主题中提及本书标题。

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

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

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

分享您的想法

一旦您阅读了Vue.js 3 设计模式和最佳实践,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取优惠:

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

packt.link/free-ebook/9781803238074

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。

第一章:Vue 3 框架

与互联网早期只是学术和科学目的的链接页面集合相比,今天的全球互联网已经发生了巨大的变化。随着技术的进步和机器变得更加强大,早期协议中添加了越来越多的功能,新技术和技术竞争,直到最终采用标准。额外的功能以浏览器插件和嵌入式内容的形式出现。Java 小程序、Flash、Macromedia、Quicktime 以及其他插件都很常见。随着 HTML5 的到来,其中大多数,如果不是全部,都逐渐被标准所取代。

现在,结构、样式和行为之间存在明确的区分。超文本标记语言HTML)定义了构成网页的结构元素。层叠样式表CSS)提供规则来修改 HTML 元素的显示外观,包括动画和转换。最后,JavaScript 是一种编程语言,它提供行为并可以访问和修改 HTML 和 CSS。因此,引入了众多不同的功能,也带来了浏览器之间的高度复杂性和不兼容性。这就是库和框架诞生的原因,最初是为了解决不兼容性问题并标准化外观,但很快演变为包括其他编程范式,而不仅仅是简单的 HTML 和 CSS 操作。

当今最流行的库和框架中,一些使用 响应式范式。它们巧妙地在 JavaScript 中进行更改,以自动反映在 HTML/CSS 中。Vue 3 是进阶框架的最新版本,它大量使用了响应性的概念。它还实现了其他软件设计范式和模式,允许你从静态网页中的简单交互构建到可以本地安装并可与原生桌面应用程序竞争的复杂应用程序。

在本书中,我们将探索 Vue 3 框架,并研究不同的设计模式,以帮助我们构建一流的应用程序:从简单的网页到强大的 渐进式网络应用程序PWAs)。在这个过程中,我们将探讨软件工程中的最佳实践和经过验证的模式。

本章涵盖了以下主题:

  • 进阶框架

  • 单文件组件

  • 不同的语法选项来编写组件

到本章结束时,你将基本了解 Vue 3 在 JavaScript 生态系统中的位置以及它提供的功能。对于 Vue 2 用户,本书附录中包含迁移应用程序时需要了解的更改。随着本书的进展,我们将在此基础上构建知识。

进阶框架

在我们描述 Vue 是什么之前,我们需要区分术语框架。这些术语经常被互换使用,但它们之间有一个区别,一个好的开发者应该在选择其中一个来构建 Web 应用程序时意识到这一点。

让我们来看看这些术语的定义:

  • 图书馆是一个由他人开发的可重用代码集合,以函数、类等形式存在,可以轻松导入到你的程序中。它并不规定如何以及在哪里使用它,但通常,它们会提供如何使用它们的文档。程序员需要决定何时以及如何实现它们。这个概念存在于大多数开发语言中,以至于其中一些完全基于导入库来提供功能的概念。

  • 框架也提供了一系列供你使用的类和函数,但规定了定义程序运行和构建方式、架构以及你的代码可以在何种条件下或如何使用的规范。这里要考虑的关键属性是,框架在应用程序中反转了控制权,因此它定义了程序的流程和数据。通过这样做,它强调了程序员应该遵守的结构或标准。

在区分了这些概念之后,现在提出了一个问题:何时使用库,何时使用框架。在回答这个问题之前,让我们明确,在构建实际应用程序时,这两者之间有一个巨大的灰色区域。从理论上讲,你可以使用任何一个来构建相同的应用程序。像软件工程中的所有事情一样,这是一个决定每种方法权衡的问题。所以,带着一点盐来接受接下来的内容;这不是刻在石头上的法律:

  • 当构建小型到中型应用程序或需要向应用程序添加额外功能时(通常,你可以在框架内部使用额外的库),你可能想使用。也有一些“大小”指南的例外。例如,React是一个库,但基于它构建了巨大的应用程序,如 Facebook。需要考虑的一个权衡是,仅使用库而不使用框架将需要建立团队内的共同方法和更多协调,因此管理和方向的努力可以显著增加。另一方面,在纯 JavaScript 编程中使用库可以提供一些重要的性能改进,并给你带来相当大的灵活性。

  • 当你构建中到大型应用程序时,你可能需要使用一个框架,当你需要一个结构来帮助你协调开发,或者当你想要快速入门而跳过从头开始开发常见功能的基础。有些框架是建立在其他框架之上的,例如,Nuxt是建立在Vue之上的。需要考虑的权衡是,你被指定了一个架构模型来构建应用程序,这通常遵循特定的方法和思维方式。你和你的团队将不得不学习框架及其限制,并在这个范围内工作。总有可能你的应用程序在未来会超出框架的范围。同时,一些好处如下:更容易协调工作,从先发优势中获得相当大的收益,真正解决并经过测试的常见问题,专注于特定情况(例如,考虑购物应用与社交媒体之间的差异),等等。然而,根据框架的不同,你可能会因为额外的处理而面临一些小的性能损失或扩展困难。权衡每种情况的利弊取决于你。

那么,Vue 究竟是什么呢?根据定义,Vue 是一个渐进式框架,用于构建用户界面。渐进式意味着它具有框架的架构优势,同时也具有库的速度和模块化优势,因为特性和功能可以增量实现。在实践中,这意味着它规定了构建应用程序的某些模型,但同时也允许你从小规模开始,并根据需要扩展。你甚至可以在单个页面上使用多个 Vue 应用程序,或者接管整个应用程序。如果需要,你甚至可以导入和使用其他库和框架。相当复杂!

Vue 的另一个基本概念是响应性。它指的是自动在 HTML 中显示 JavaScript 中变量值或变化的能力,但也包括在你的代码中。这是 Vue 提供的魔法的一部分。

在传统编程中,一旦变量被分配了一个值,它就会保持这个值直到程序性地改变。然而,在响应式编程中,如果一个变量的值依赖于其他变量,那么当这些依赖项中的任何一个发生变化时,它将采用新的结果值。以下是一个简单的公式为例:

A = B + C

在响应式编程中,每当BC的值发生变化时,A也会发生变化。正如你将在本书后面看到的,这是一个构建用户界面的非常强大的模型。在这个例子中,并且为了符合术语,A是依赖项,而BC是依赖项。

在接下来的章节中,我们将随着构建示例应用程序来探索这个渐进式属性。但在那之前,我们需要看看 Vue 3 在其最基本形式下提供了什么。

在你的 Web 应用程序中使用 Vue

在你的 Web 应用中使用 Vue 有几种选择,这很大程度上取决于你的目标:

  • 要在页面上包含一个小型自包含的应用或代码片段,你可以直接在脚本标签中导入 Vue 和代码

  • 要构建一个更大的应用,你需要一个构建工具,它将你的代码打包以进行分发

注意,我使用的是“打包”这个词,而不是“编译”,因为 JavaScript 应用是在浏览器上运行时解释和执行的。这一点将在我们介绍单文件组件的概念时变得明显。

让我们简要地看看一个非常简单的 HTML 页面中的第一个案例示例:

<html>
<head>
    <script src="img/vue@3"></script>
</head>
<body>
    <div id="app">
    {{message}}
    </div>
<script>
    const {createApp} = Vue
    createApp({
        data(){
            return {message:'Hello World!'}
        }
    }).mount("#app")
</script>
</body>
</html>

head部分,我们定义一个script标签并从免费的Vue导入 Vue,它暴露了框架的所有方法和函数。在我们的body标签内部,我们声明一个具有id="app"div元素。这定义了我们的小型应用将被挂载的位置以及我们的 Vue 框架将控制的页面部分。请注意div: {{message}}的内容。双大括号定义了一个在运行时将被message变量的值替换的内容点,该变量我们在 JavaScript 中定义。这被称为插值,是值(字符串、数字等)在网页上显示的主要方式。

到了body的结尾,我们创建了一个包含我们应用的脚本元素。我们首先从 Vue 中提取createApp函数,并通过传递一个对象来使用它创建一个应用。这个对象有特定的字段,定义了一个data()方法,该方法反过来返回一个对象。这个对象中的字段名将被视为响应式变量,我们可以在 JavaScript 以及 HTML 中使用它们。最后,createApp()构造函数返回 Vue 3 应用实例,因此我们链式调用并调用mount()方法,将我们谦逊的应用挂载到具有app ID 的元素上。请注意,我们使用 CSS 选择器作为参数(井号表示id属性,因此id="app"通过#app被选中)。

由于这种使用 Vue 的方法并不常见(或流行),我们将关注更重要的事情,并使用打包器来组织我们的工作流程,并拥有显著更好的开发者体验……但首先,我们需要了解更多关于 Vue 以及是什么让它如此出色的信息。

打包器的方式,更好的方式...

如你所想,直接将 Vue 导入到网页中只会适用于非常小的应用程序。相反,Vue 以组件的概念构建,这些是可重用的、隔离的 JavaScript 代码、HTML 和 CSS 集合,它们作为一个单元运行。你可以把它们看作是构建网页的构建块。显然,浏览器对此一无所知,因此我们将使用打包器将我们的应用程序转换成浏览器可以解释的格式,同时在这个过程中还可以运行一系列优化。这就是“框架”部分开始发挥作用的地方,因为它规定了这些组件应该如何编写以及需要包含哪些方法。

使用打包器时,它将把我们的所有代码打包成一个或多个浏览器在运行时加载的 JavaScript 文件。Vue 应用程序在浏览器中的执行工作流程可以简化如下:

图 1.1:使用打包器时我们应用程序执行顺序的非常简化的视图

图 1.1:使用打包器时我们应用程序执行顺序的非常简化的视图

浏览器将像往常一样加载index.html页面,然后加载并执行bundle.js文件,就像任何其他 JavaScript 文件一样。打包器将打包所有我们的文件,并按规定的顺序执行它们:

  1. main.js文件将导入并启动 Vue 3 应用程序。

  2. 然后,它将从组件开始页面组合,这里封装在App.vue文件中。这个组件将生成其他组件,从而形成一个构成页面的组件树。

如果现在听起来有点奇怪,请不要担心。随着我们在书中构建示例应用程序的进展,我们将看到这些概念的实际应用。在第三章 设置工作项目中,我们将使用这个相同的图表开始一个简单的应用程序。

到目前为止,你已经对库和框架有了了解,并且对 Vue 能提供的内容有了一个快速的浏览。重要的是要记住,在现代 JavaScript 世界中,使用打包器来帮助我们组织应用程序并对浏览器代码进行优化是很常见的。稍后我们将使用官方的 Vue 3 打包器Vite。但首先,我们需要一些更多的基础概念。

理解单文件组件

如你所猜,之前提到的App.vue文件是一个从App.vue到最后的自定义按钮的组件,如果你愿意的话。我们将在第四章中深入讨论组件,即组件的用户界面组合,但就目前而言,请记住这是框架规定的做法。如果你有面向对象语言的经验,这可能会看起来很熟悉(而且你不会错)。

SFC 是一个带有.vue扩展名的纯文本文件,包含以下部分:

<script setup>
    // Here we write our JavaScript
</scrip>
<template>
    <h1>Hello World! This is pure HTML</h1>
</template>
<style scoped>
    h1{color:purple}
</style>

最初看起来可能有些奇怪,所有这些内容都集中在一个地方,但实际上这正是它的优点所在。以下是每个部分的描述:

  • 一个setup。这将定义我们将用于在 Vue 中编写代码的应用程序接口。我们还可以声明lang="ts"属性来使用 TypeScript 而不是纯 JavaScript。

  • 一个template标签包围着我们的组件的 HTML。在这里,我们可以使用 HTML 元素、其他组件、指令等等。Vue 的一个巨大优势是我们可以使用纯 HTML 来编写我们的 HTML。这听起来可能很显然,但其他库处理这个问题完全不同,并且有自己的语法。然而,Vue 3 也允许通过使用打包器插件来使用其他语法。我们在这里也有选择。

  • 一个scoped属性,它将封装规则并限制它们只应用于我们的组件,从而防止它们“溢出”到应用程序的其他部分。与前面的部分一样,我们也可以使用不同的语法来编写样式,只要它被打包器支持。

最佳实践

总是作用域你的样式,除非你在父组件上定义样式或你希望明确传递到整个应用程序的 CSS 变量。对于应用程序范围的样式,请使用单独的 CSS 文件。

需要记住的重要概念是,一个 SFC 包含这三个定义单个组件的元素。打包器应用程序将施展其魔法,将每个部分分离并放置在适当的位置,以便浏览器可以正确地解释它们。我们将在第三章“设置工作项目”和第四章“组件的用户界面组合”中使用快速且新的Vite,深入探讨组件以及如何在它们之间处理控制流和信息流。但首先,让我们看看我们如何编写我们的组件。

不同的笔触——选项、组合和脚本设置 API

在 Vue 2 中描述组件的经典方式已被标记为Options API。为了保持向后兼容性,Vue 3 也支持相同的语法。然而,还有一个名为Composition API的新语法,这是我们将在本书中使用的。

Options API是从 Vue 2 继承的,规定一个组件由一个具有定义字段的对象定义,其中没有任何字段是强制性的。此外,其中一些具有定义的参数和预期输出。例如,这些是最常用的字段(也是一个非排他性列表):

  • data应该是一个返回对象的函数,其字段将成为响应式变量。

  • methods是一个包含我们的函数的对象。这些函数可以通过使用this.variableName格式访问data中的响应式变量。

  • components 是一个对象,其中每个字段提供了一个模板的名称,值指向另一个组件(当前组件的子组件)的构造函数。

  • computed 是一个对象,其属性定义了“计算”属性。每个成员随后是一个函数或对象,可以用作模板和代码中的响应式变量。函数将是只读的,对象可以包含读取和写入它们值的逻辑。这个概念将在我们查看第三章设置工作项目中的代码示例时得到阐明。

  • propsemits 声明参数以从父组件接收数据,并声明发送到父组件的事件。这为在相关组件之间进行通信和传递数据提供了一种正式的方式,但并非唯一,正如我们将在第七章数据流管理中看到的。

  • 生命周期钩子方法是一系列在组件生命周期中触发的函数。

  • 混合是一种对象,它描述了可以在多个组件之间共享的通用功能。这并不是在 Vue 3 中重用代码的唯一方式。在 Options API 中使用混合引起了一些复杂性,从而产生了 Composition API。我们不会详细讨论混合,但会看到其他在组件之间共享功能的方法(例如“composables”)。

这种语法定义良好,但有一些限制。对于小型组件,它提供了过多的脚手架代码,而对于大型组件,代码组织受到严重影响,且非常冗长。此外,为了引用在 data 部分或其他方法中声明的响应式变量,内部代码必须使用 this 关键字(例如,this.data_variable_namethis.myMethod())。this 关键字指的是组件创建的实例。问题是当保留字 this 的含义根据使用范围和上下文而变化时。随着时间的推移,出现了其他缺点,导致了组合式 API 的创建。然而,这种语法在 Vue 3 中是相关且完全支持的。这个优点之一是你可以轻松地将代码从 Vue 2 迁移过来(在附录中稍后展示的某些考虑范围内,如附录 - 从 Vue 2 迁移)。

组合式 API 提供了一个名为 Setup() 的方法,它在组件挂载之前执行。在这个方法中,我们导入函数和组件,声明变量等,这些定义了我们的组件,而不是将它们声明为“选项”。这意味着你可以用更 JavaScript 的方式编写代码,这给了你更好的导入、重用和组织代码的自由。

让我们通过一个反应变量_hello="Hello World"来比较这两种方法:

选项式 API

<script>
 export default{
    data(){return {_hello:"Hello World"}}
 }
</script>

组合式 API

<script>
  import {ref} from "vue"
  export default{
    setup(){
      const _hello=ref("Hello World")
      return {_hello}
    }
  }
</script>

在选项 API 中,我们只是使用data字段来返回一个对象,其字段将变成响应式变量。Vue 将负责解释这个对象。然而,请注意,在组合式 API 中,我们首先需要从 Vue 导入ref构造函数,这将为我们创建一个响应式常量或变量。最终结果是一样的,但在这里,我们对自己的操作和位置有更多的精细控制。当使用新的 Vite 打包器时,这种对组件中导入内容的精细控制可能会导致代码构建和开发时间的加快。

初看之下,似乎组合式 API 比选项 API 更冗长,对于这样一个简单的例子确实如此。然而,随着我们的组件开始增长,这种状况就相反了。尽管如此,仍然很冗长...所以,组合式 API 有一个名为脚本设置的替代语法,这是我们将在本书中使用的语法。现在让我们比较一下使用这种新语法时组件的外观:

组合式 API – 脚本设置

<script setup>
    import {ref} from "vue"
    const _hello=ref("Hello World")
</script>

仅仅两行代码!这很难超越。因为我们添加了setup属性到script标签中,打包器就知道我们在这里所做的所有事情都属于组合式 API 的范畴,所有的函数、变量和常量都会自动暴露给模板。不需要定义导出。如果我们需要什么,我们可以直接导入并使用它。此外,我们现在还有一些额外的优势,如下所示:

  • 我们可以在模板中显示响应式和非响应式变量

  • 我们知道所有代码都是在组件挂载之前执行的

  • 语法更接近 vanilla JavaScript(一个很大的优点!!!),因此我们可以根据我们的方便和愉悦来组织代码

  • 更小的包大小(我之前提到过吗?是的,这很重要!)

但是等等,你可能注意到我把一个响应式变量定义为一个常量!是的,我是这样做的!而且,不,这并不是一个错误。在 JavaScript 中,常量指向一个特定的不可变值,在这个例子中,是一个对象,但这个规则只适用于对象,不适用于它的成员。ref()构造函数返回一个对象,因此常量适用于对象引用,我们可以改变其成员的值。如果你在 Java、C 或类似的语言中处理过指针,你可能认识这个概念,即使用对象的value属性。以下是一个例子:

_hello.value="Some other value";

但是,与此同时,访问这个变量在模板中的方式并没有改变:

<div>{{_hello}}</div>

简而言之,每次使用ref()构造函数将变量声明为响应式时,你需要使用constant_name.value格式来引用其值,就像模板(HTML)中的constant_name一样。当在模板中使用常量名称时,Vue 已经知道如何访问该值,你不需要像在 JavaScript 中那样显式地引用它。

小提示

采用代码约定,这样你就可以知道标识符是指变量、常量、函数、类等等

探索 Vue 3 的内置指令

Vue 还提供了称为 v- 的特殊 HTML 属性。对于本书的目的,让我们解释最常用的 Vue 指令:

v-bind: (缩写 "😊

v-bind: 指令将 HTML 属性的值绑定到 JavaScript 变量的值。如果变量是响应式的,每次它更新其值时,它都会反映在 HTML 中。如果变量不是响应式的,它将仅在 HTML 的初始渲染期间使用一次。我们通常只使用 : 缩写前缀(分号)。例如,my_profile_picture 响应式变量包含一个指向图片的网址:

<``img :src="img/my_profile_picture">

src 属性将接收 my_profile_picture 变量的值。

v-show

这个指令将显示或隐藏元素,而不会将其从文档中移除。它相当于修改 CSS 的 display 属性。它期望一个提供布尔值的变量(或可以解释为真或非空的东西)。例如,loading 变量有一个布尔值:

<``div v-show="loading">…</div>

loading 变量为真时,div 将会显示。

重要的是要记住,v-show 将使用对象的样式来显示或隐藏它,但元素仍然是 文档对象模型DOM)的一部分。

v-if, v-else, 和 v-else-if

这些指令的行为与 JavaScript 中的条件语句预期一致,根据传递的表达式解析的值显示或隐藏元素。在它们将元素显示或隐藏的意义上,与 v-show 类似,但不同之处在于它们会完全从 DOM 中移除元素。因此,如果在大规模上不正确地使用经常切换状态的元素,这可能会在计算上非常昂贵,因为框架必须执行更多操作来操作 DOM,而与 v-show 不同,当只需要更改显示样式时。

注意

使用 v-if 来显示或显示那些一旦显示或隐藏后不会切换的元素(并且在初始状态为隐藏时首选)。如果元素将经常切换状态,请使用 v-show。这将提高显示大量元素时的性能。

v-for 和 :key

这两个属性结合使用时,在 JavaScript 中表现得像 for 循环。它们将根据迭代器中指定的数量创建元素副本,每个副本都有相应的插值值。这对于显示数据项集合非常有用。:key 属性在内部用于更有效地跟踪变化,并且必须引用正在迭代的项的唯一属性——例如,对象的 id 字段,或者当索引不会改变时数组中的索引。以下是一个示例:

<span v-for="i in 5" :key="i"> {{``i}} </span>

这将在网页上显示五个 span 元素,i 的插值显示以下内容:

1 2 3 4 5

v-model

这个指令简直就是魔法。当它附加到输入元素(input、textarea、select 等)上时,它将把 HTML 元素返回的值赋给引用的变量,从而保持 DOM 和 JavaScript 状态的一致性——这被称为双向绑定。以下是一个示例:

<input type="text" v-model="name">

当用户在 HTML 中输入文本时,JavaScript 中的"name"变量将立即被赋予该值。在这些示例中,我们使用的是原始数据类型,如数字和字符串,但我们也可以使用更复杂的数据类型,如对象或数组。更多内容将在第四章中介绍,即使用组件进行用户界面组合,届时我们将深入探讨组件。

v-on:(以及缩写@)

这个指令的行为与之前看到的不同。它期望的不是变量,而是一个函数或表达式,并将一个 HTML 事件绑定到一个 JavaScript 函数上以执行它。事件需要在冒号后立即声明。例如,为了响应按钮上的click事件,我们会写出以下内容:

<button v-on:click="printPage()">Print</button>

当按钮触发click事件时,JavaScript 中的"printPage()"函数将被执行。此外,这个指令的缩写更常用,从现在起,我们将在这本书中使用它:只需将v-on:替换为@。然后,之前的示例变为以下内容:

<button @click="printPage()">打印</button>

您可以在官方文档中找到内置指令的完整列表:vuejs.org/api/built-in-directives.html。随着我们的前进,我们将看到其他指令。

到目前为止,我们已经看到 Vue 3 应用程序是通过组件构建的,这些组件我们可以用在我们的 HTML 中,并且我们使用 SFCs 创建它们。该框架还为我们提供了用于操作 HTML 元素的指令,但这并不是全部。在下一节中,我们将看到该框架还提供了一些方便的预构建组件供我们使用。

内置组件

该框架还为我们提供了几个内置组件,我们可以在不将它们显式导入每个 SFC 的情况下使用。我在这里为每个组件提供了一个简短的描述,因此您可以参考官方文档以获取语法和示例(见vuejs.org/api/built-in-components.html):

  • TransitionTransitionGroup是两个可以协同工作以提供元素和组件动画和过渡的组件。它们需要您创建 CSS 动画和过渡类,以便在将元素插入或从页面中删除时实现动画。它们主要(或经常)用于您使用v-for/:keyv-if/v-show指令显示元素列表时。

  • KeepAlive 是另一个包装组件(意味着它包围其他组件),用于在包裹的组件不再显示时保留状态(内部变量、元素等)。通常,组件实例在卸载时会被清除并“垃圾回收”。KeepAlive 将它们缓存起来,以便它们在重新显示时恢复状态。

  • Teleport 是 Vue 3 中的一个全新的组件,允许你将组件的 HTML 传输到页面上的任何位置,甚至可以传输到应用组件树之外。这在某些情况下很有帮助,当你需要在外部显示信息但必须由组件的内部逻辑处理时。

  • Suspense 是 Vue 3 中的一个新组件,但仍然处于实验阶段,因此在撰写本文时其未来尚不确定。其基本思想是在所有异步子组件/元素准备好渲染之前显示“后备”内容。它作为一个便利性提供,因为存在可以用来解决这个问题的一些模式。我们稍后会看到一些。

  • Component-is 是一个特殊元素,它将在运行时根据变量的内容加载组件——例如,如果我们需要根据变量的值显示一个组件,而使用其他指令可能很繁琐。它也可以用来渲染 HTML 元素。让我们看一个例子:

    <script setup>
        import EditItem from "EditItem.vue"
        import ViewItem from "ViewItem.vue"
        import {ref} from "vue"
        const action=ref("ViewItem")
    </script>
    <template>
        <component :is="action"></component>
        <button @click="action='EditItem'">Edit</button>
    </template>
    

在这个简单的例子中,当用户点击“编辑”按钮时,动作值将更改为EditItem,并且组件将在原地交换。你可以在这里找到文档:vuejs.org/api/built-in-special-elements.html.

基于框架和组件的理念,我们现在更好地准备向前迈进。

书籍代码约定

在这本书中,我们将使用一组适用于 Vue 3 的代码约定和指南,这些都是良好的实践。它们将帮助你不仅理解本书的示例,还能理解你可能会遇到的野外科普代码,因为越来越多的开发者正在使用它。让我们从开始讲起。

变量和属性

这些名称总是小写,并且空格被下划线替换,例如 total_countperson_id..

常量

对注入对象的引用以 $(美元符号)开头,例如 $router$modals$notifications

对响应式数据的引用以 _ 开头,并使用蛇形命名法,例如 _total_first_input

对常量值的引用全部使用大写字母,例如 OPTIONLANGUAGE_CODE.

注入依赖的构造函数将以 use 开头,例如 const $store=useStore().

类和组件名称

这些名称使用 PascalCase(每个单词以大写字母开头),例如 PersonTaskQueueBuilder

函数、方法、事件和文件名

例如,这些是用驼峰命名法编写的,例如,doSubscribe()processQueue()

实例

实例将具有抽象名称,对于提供函数的纯 JavaScript 对象,后面跟着单词Service,对于状态模型,则是Model,等等。我们将使用服务来封装功能。

这里有一个例子:const projectService=new ProjectService().

小贴士

与你的团队一起,始终使用大家都同意的代码约定。这将使代码更易于阅读和维护。还建议使用一个 linter(一个用于捕获你代码中约定的处理器)。

正如之前提到的,这些代码约定越来越受欢迎,所以你可能会在多个项目中看到它们。然而,这些并不是强制性的标准,绝对不是由框架规定的。如果你喜欢全部大写,那也可以,但真正重要的是你和你的团队能够以一致的方式定义并遵守自己的约定。最终,重要的是我们都有一种共同的代码编写语言。

摘要

本章已经从库和框架的基础知识过渡到 Vue 3 指令、组件,甚至代码约定。这些概念仍然有些抽象,所以随着我们继续阅读本书的其余部分并编写实际代码,我们将把它们具体化。然而,我们现在已经安全地站在了学习下一章设计原则和模式的基础上。

复习问题

为了帮助你巩固本章内容,你可以使用以下复习问题:

  • 库和框架之间的区别是什么?

  • 为什么 Vue 是一个“渐进式”框架?

  • 单文件组件是什么?

  • 在 Vue 开发中最常用的指令有哪些?

  • 代码约定为什么很重要?

如果你能在脑海中迅速回答这些问题,那么你已经准备好了!如果不能,你可能需要简要回顾本章,以确保你具备继续前进的基础。

第二章:软件设计原则和模式

软件开发本质上是一门人密集型学科。这意味着它需要了解技术和技术,同时也需要理解问题和在多个抽象层次上实施解决方案的决策能力。编程与开发者的思维方式有很大关系。多年来,在每个上下文和语言中,都出现了解决重复问题的指南和解决方案。了解这些模式将帮助您确定何时应用它们,并确保您的开发工作稳步推进。另一方面,原则是指导概念,应在过程的每个阶段应用,并且更多关乎您如何处理这个过程。

在本章中,我们将探讨在 Vue 3 应用程序开发中常见的非排他性和非详尽性的原则和模式列表。

原则 模式

|

  • 关注点分离

  • 组合优于继承

  • 单一职责

  • 封装

  • KIC – 保持清洁

  • DRY – 不要重复自己

  • KISS – 简单就是聪明

  • 为未来编写代码

|

  • 单例

  • 依赖注入

  • 观察者

  • 命令

  • 代理

  • 装饰者

  • 门面

  • 回调

  • 承诺

|

表 2.1 – 本章涵盖的原则和模式

理解这些原则和模式将帮助您更有效地使用框架,并且通常情况下,它将防止您“重新发明轮子”。与第一章一起,这将结束本书的基础部分,并为您跟随本书剩余部分的实际部分和应用示例的实现提供基础。

软件设计原则是什么?

在软件开发中,设计原则是适用于整个过程的宏观概念性指南。并非每个项目都会使用相同的原理,这些也不是必须强制执行的规则。它们可以从架构到用户界面(UI)和最后一段代码出现在项目中。在实践中,这些原则中的一些也可以影响软件属性,如可维护性和可重用性。

设计原则的非排他性列表

设计原则因上下文、领域,甚至一个人可能参与的团队而异。因此,本章包含的原则是非排他性的。

关注点分离

这可能是软件工程中最重要的原则。关注点的分离意味着一个系统必须被划分为由功能或服务(即关注点)组成的子系统。例如,我们可以将人体视为由许多子系统(呼吸、循环、消化等)组成的系统。这些子系统再由不同的器官整合,器官由组织构成,以此类推,直至最小的细胞。在软件中遵循同样的理念,一个应用程序可以被划分为按关注点分组的不同元素,从大型架构一直到最后的函数。如果没有将复杂性分解为可管理的部分,创建一个功能系统将会更加困难,甚至不可能。

通常,这一原则的应用从系统应该是什么的大图景开始,考虑它应该做什么来实现这一点,然后将它分解为可管理的可工作部分。

例如,这里是一个关于 Web 应用程序关注点分离的粗略图形表示。这个图中的每个框都标识了一个不同的关注点,这些关注点反过来又可以细分为更小的功能部分。更好的是,你可以看到这一原则如何帮助你识别系统的整合部分。

图 2.1 – 一个简单的 Web 应用程序架构视图,展示了关注点的分离

图 2.1 – 一个简单的 Web 应用程序架构视图,展示了关注点的分离

如果我们要深入到各自领域内的任何这些小框中,我们仍然可以找到更多需要进一步细分的关注点,直到达到不可分割的原子元素(例如组件或函数)。这一原则与许多其他原则有很大关系,并从中受益,如抽象和单一职责。我们将在本章的后面进一步讨论它们。

组合优于继承

组合优于继承的原则直接来源于面向对象编程OOP)。它指出,一个对象在需要时应该尝试使用其他对象的功能,通过引用或实例化它们,而不是创建一个庞大而复杂的继承家族树来添加这样的功能。现在,JavaScript 本质上是一种函数式语言,尽管它支持多种范式,包括来自 OOP 的特性,所以这一原则同样适用。对于那些从 OOP 迁移到 JavaScript 的人来说,有一个警告需要注意,那就是避免将 JavaScript 视为纯粹的 OOP 语言。这样做可能会创造不必要的复杂性,而不是从语言的优点中受益。

在 Vue 3 中,没有组件的扩展或继承。当我们需要共享或继承功能时,我们有一套很好的工具集来替代继承范式。我们将在第四章“用户界面组件组合”中看到如何通过使用组合组件来遵守这一原则。

单一职责原则

这一原则在面向对象编程以及函数式编程中都可以找到。简单来说,它指出一个类、方法、函数或组件应该只处理一个职责或功能。如果你在其他学科和语言中工作过,这会自然而然地发生。多功能函数难以维护,并且往往会失去控制,尤其是在像 JavaScript 这样松散类型和高度动态的语言中。同样的概念也直接适用于 Vue 3 组件。每个组件应该处理一个特定的操作,避免试图自己完成太多。在实践中,当一个组件超出一定范围时,最好将其拆分为多个组件或将行为提取到外部模块中。有时你可能会得到一个数千行长的组件,但根据我的经验,这很少是必要的,并且可以也应该避免。不过,有一个警告,即过多的具体性也可能导致不必要的复杂性。

例如,让我们想象一个同时显示注册选项的登录屏幕。这种做法在许多网站上都很常见。您可以将所有功能都包含在一个组件中,但这会违背这一原则。更好的选择是将组件拆分为至少三个组件来完成这项任务:

  • 处理 UI 逻辑的父组件。该组件决定何时显示/隐藏登录和注册组件。

  • 处理登录功能的子组件。

  • 处理注册功能的子组件。

这里是这个配置的图形表示:

图 2.2 – 使用多个组件组合的登录/注册界面

图 2.2 – 使用多个组件组合的登录/注册界面

我认为你可以很快地理解这一原则的好处。它使得代码易于管理、维护和适应,因为网络应用有快速变异和演化的趋势。

最佳实践技巧

给组件赋予单一职责和功能。尽可能避免庞大的单体组件。

封装

封装是指你应该将数据和函数包装成一个单一单元,同时暴露一个定义良好的应用程序编程接口API)。通常,这以类、模块或库的形式完成。JavaScript 也不例外,强烈建议遵循这个原则。在 Vue 3 中,这个概念不仅适用于组件,也适用于 CSS 样式和 HTML。单文件组件的引入是框架如何在实际中促进这个原则的一个明显例子,以及它对当今开发的重要性。在只有少数边缘情况的情况下,我们应该将(UI)组件视为接收传入参数并提供输出数据的黑盒,其他组件不应了解它们的内部工作方式,只有 API。随着我们在本书中构建示例应用程序,你将看到这个原则是如何发挥作用的。

KIC – 保持清洁

这个原则主要指的是你编写代码的方式。我应该在这一点上强调,KIC 直接应用于两个强烈影响 Web 和 Vue 3 应用程序的类别:

  • 你如何格式化你的代码

  • 你如何整理事件和变量

第一项包括使用代码约定、注释和缩进来组织代码以及函数的逻辑分组。例如,如果你有处理创建、读取、更新和删除CRUD)操作的方法,最好将它们放在代码的附近,而不是分散在源文件中。许多集成开发环境IDE)包含折叠或展开函数内部代码的功能。这有助于快速审查和定位具有相似逻辑的代码部分。

这个原则的第二部分与内存和引用处理有关。JavaScript 有一个非常好的垃圾回收器,其功能是丢弃未使用的数据以回收内存。然而,有时算法因为引用仍然挂起而无法释放资源。如果你使用过其他语言,如 C/C++,这个问题可能听起来很熟悉,因为你需要在不再使用时手动分配和释放内存。在 JavaScript 中,如果你注册一个函数来监听一个事件,当不再需要时,最好在你的组件适当的生命周期事件中手动注销它。这将防止内存泄漏和内存浪费,同时也防止一些安全风险(这些风险超出了本书的范围)。

我们将在第四章**,使用组件的用户界面组合中回顾组件的生命周期,但到目前为止,以下示例是这一原则的良好应用,并作为最佳实践保留。在这个例子中,我们将创建一个可组合组件来检测窗口大小的变化,因此,在script setup部分,我们会找到如下内容:

  1. 在挂载状态下,在窗口对象的 resize 事件上注册一个函数。

  2. 在组件卸载之前注销事件。

这里是代码片段:

<script setup>
   import {onMounted, onBeforeUnmount} from "vue"
   onMounted(()=>{
       window.addEventListener("resize", myFunction)
   })
   onBeforeUnmount(()=>{
       window.removeEventListener("resize", myFunction)
   })
   function myFunction(){
       // Do something with the event here
   }
</script>

onMountedonBeforeUnmount函数是 Vue 3 框架的一部分,并由适当的组件生命周期事件触发。在这里,当组件挂载到文档对象模型DOM)时,我们将函数附加到resize事件上,并在它被移除之前释放它。需要记住的重要概念是清理自己的工作并保持其整洁。

DRY – 不要重复自己

这个原则相当有名,几乎到了变成陈词滥调的地步。遗憾的是,它很容易被遗忘。它归功于安德鲁·亨特和大卫·托马斯,他们在《实用程序员》一书中使用了它。它主要被认为是“不要重复写同一件事”,虽然接近,但它的含义更广。它包括在过程以及应用程序的逻辑中避免冗余的概念。核心思想是,执行业务逻辑的每个过程应该只存在于整个应用程序的一个地方。

例如,大多数 Web 应用程序都通过 API 使用与服务器的一些异步连接。应用程序中可能还有多个元素将使用或需要使用这种远程计算机/服务器通信。如果你打算在每个组件中编写与服务器通信的整个代码/逻辑,我们最终会得到代码重复以及应用程序逻辑。维护这样的系统会打开通往大量负面副作用和安全问题的门,包括糟糕的用户体验等等。根据这个原则,更好的方法是将与服务器 API 相关的所有通信代码抽象成一个单独的模块或类。在实践中,在 JavaScript 中,这甚至可以委托给一个单独线程中的 Web Worker。我们将在第八章使用 Web Workers 进行多线程中探讨这种实现。

作为一条经验法则,如果你发现自己正在不同的组件或类中编写“有点相似”的代码,那么将功能抽象成其自己的模块或组件是一个明显的机遇。

KISS – 保持简单和简洁

这个原则并不仅限于软件开发领域。它是在 20 世纪 60 年代由美国海军提出的(根据维基百科,en.wikipedia.org/wiki/KISS_principle)。这个想法纯粹是常识:构建简单、小巧且能协同工作的功能部件,比一次性尝试创建一个庞大而复杂的程序要好。此外,算法应以最简单和最有效的方式进行实现。在 Web 开发中,这个原则至关重要。现代 Web 应用程序由数百个工作部件组成,这些部件分布在多个计算机、服务器和环境上。系统或代码实现越复杂,维护和适应的难度也越大。

虽然有一个警告。保持简单并不意味着过度简化或不必要的隔离。太多的部分可能会在系统中引入不必要的复杂性。应用 KISS 原则意味着保持在那个事物可管理和易于理解的美好中间点。

为未来编写代码

这个原则是指你应该让你的代码对除了你自己之外的其他人来说也是可读和易于理解的。命名约定、逻辑流程和行间注释都是这个原则的一部分。这不仅是为了在你可能需要将代码委托给其他人时,也是为了当你一年或两年后回到相同的代码时。你不想做的事情就是浪费时间思考过去那个缺乏经验的你用那行巧妙的意大利面代码做了什么。聪明的开发者编写代码就像他们要教给别人一样,简单而优雅。特别是如果你在使用或为开源代码做出贡献,这个原则对于团队协作至关重要。在这种情况下,值得提到的是童子军原则,它与前者类似,但适用于团队。它指出,当你发现难以阅读或“意大利面”代码时,你应该重构它以使其变得干净。

最佳实践技巧

使用源代码注释和文档来解释你的逻辑,保持你的代码干净,就像在教别人一样。大多数情况下,你实际上是在教自己。

设计原则适用于许多不同的场景,一些场景甚至超出了软件开发实践。考虑它们直到它们成为第二天性是很重要的。一般来说,这些原则以及其他原则的应用,以及设计模式的应用,对你的职业发展留下了重要的影响。

什么是软件设计模式?

在软件开发中,某些流程和任务以某种方式或某种程度的变化出现在多个项目中是很常见的。设计模式是解决此类类似问题的有效解决方案。它不规定代码,而更像是一个推理模板,一种独立于实现进行抽象、可重用和适应特定情况的方法。在实践中,有足够的空间发挥创意来应用模式。已经有许多书籍致力于这个主题,并提供了比本书范围更详细的信息。在接下来的几页中,我们将探讨我认为对于 Vue 3 应用程序来说最常见且需要记住的模式。尽管我们为了研究它们而单独看待它们,但现实情况是,通常实现会重叠、混合和封装多个模式在一个代码块中。例如,你可以使用单例来充当装饰器代理,以简化或改变应用程序中服务之间的通信(我们实际上会这样做很多次,完整的代码可以在第八章**,使用 Web Workers 进行多线程 中查看)。

设计模式也可以理解为软件工程和开发最佳实践。而与之相反的,不良实践通常被称为反模式。反模式是“解决方案”,尽管它们在短期内解决了问题,但沿着这条线会引发问题和不良后果。它们产生了绕过问题的需要,并使整个结构和实现不稳定。

现在让我们查看一个列表,这些模式应该是 Vue 3 项目工具箱的一部分。

模式快速参考列表

模式根据它们解决的问题或功能类型进行分类。根据系统的上下文、语言和架构,有许多模式。以下是我们将在本书中使用的模式列表,以及根据我的经验,这些模式更有可能在 Vue 应用程序中出现:

  • 创建型模式:这些处理创建类、对象和数据结构的方法:

    • 单例模式

    • 依赖注入模式

    • 工厂模式

  • 行为模式:这些处理应用程序中对象、组件和其他元素之间的通信:

    • 观察者模式

    • 命令模式

  • 结构模式:这些提供模板,影响应用程序的设计和组件之间的关系:

    • 代理模式

    • 装饰器模式

    • 门面模式

  • 异步模式:这些模式处理单线程应用程序(在 Web 应用程序中大量使用)中的异步请求和事件的数据和流程:

    • 回调模式

    • 承诺模式

无论如何,这个模式列表并不是唯一的。还有许多其他模式和分类,一个完整的库专门用于这个主题。值得一提的是,这些描述和应用可能因文献而异,并且根据上下文和实现可能存在一些重叠。

在介绍完设计模式之后,让我们通过示例来详细探讨它们。

单例模式

这是在 JavaScript 中非常常见的一种模式,也许是最重要的一种。基本概念定义了一个对象实例在整个应用程序中只能存在一次,所有的引用和函数调用都通过这个对象进行。单例可以作为资源、库和数据的网关。

何时使用

这里有一个简单的规则,可以帮助您了解何时应用此模式:

  • 当您需要确保资源只通过一个网关访问时,例如,全局应用程序状态

  • 当您需要封装或简化行为或通信(与其他模式结合使用时)。例如,API 访问对象。

  • 当多次实例化的 成本 有害时。例如,创建网络工作者。

实现

您可以在 JavaScript 中以多种方式应用此模式。在某些情况下,从其他语言迁移的实现会遵循 Java 示例,通常使用 getInstance() 方法来获取单例。然而,在 JavaScript 中实现此模式有更好的方法。让我们看看下面的例子。

方法 1

最简单的方法是通过导出一个普通的对象字面量或 JavaScript 对象表示法JSON),这是一个静态对象:

./chapter 2/singleton-json.js

const my_singleton={
    // Implementation code here...
}
export default my_singleton;

您可以将此模块导入其他模块,并且始终拥有相同的对象。这是因为打包器和浏览器足够智能,可以避免重复导入,所以一旦这个对象第一次被引入,它将忽略后续的请求。当不使用打包器时,JavaScript 的 ES6 实现也定义了模块是单例的。

方法 2

此方法创建一个类,然后在第一次实例化时保存对未来的引用。为了使此方法生效,我们使用类中的一个变量(传统上称为 _instance)并在构造函数中保存对实例的引用。在后续调用中,我们检查 _instance 值是否存在,如果存在,则返回它。以下是代码:

./chapter 2/singleton-class.js

class myClass{
    constructor(){
        if(myClass._instance){
            return myClass._instance;
        }else{
            myClass._instance=this;
        }
        return this;
    }
}
export default new myClass()

第二种方法可能对其他语言开发者来说更为熟悉。注意我们也是导出一个新的类实例,而不是直接导出类。这样,调用者就不必每次都记住实例化类,代码将与 方法 1 中的代码相同。这种情况需要与您的团队协调,以避免不同的实现。

调用者可以直接调用每个对象的方法(假设单例有一个名为 myFunction() 的函数/方法):

./chapter 2/singleton-invoker.js

import my_method1_singleton from "./singleton-json";
import my_method2_singleton from "./singleton-class";
console.log("Look mom, no instantiation in both cases!")
my_method1_singleton.myFunction()
my_method2_singleton.myFunction()

单例模式非常有用,尽管它很少独立存在。通常,我们使用单例来封装其他模式的实现,并确保我们有一个单一的访问点。在我们的示例中,我们将经常使用这个模式。

依赖注入模式

这个模式简单地声明,一个类或函数的依赖项作为输入提供,例如作为参数、属性或其他类型的实现。这个简单的声明打开了一个非常广泛的可能性。以一个与浏览器的 dbManager.js 文件一起工作的类为例,它公开了一个处理数据库操作的对象,而 projects 对象处理项目表的 CRUD 操作(或集合)。如果不使用依赖注入,你将得到类似这样的结果:

./chapter 2/dependency-injection-1.js

import dbManager from "dbManager"
const projects={
    getAllProjects(){
        return dbManager.getAll("projects")
    }
}
export default projects;

上述代码展示了“正常”的方法,即在文件开头导入依赖项,然后在我们的代码中使用它们。现在,让我们调整相同的代码以使用依赖注入:

./chapter 2/dependency-injection-2.js

const projects={
    getAllProjects(dbManager){
        return dbManager.getAll("projects")
    }
}
export default projects;

如您所见,主要区别在于现在将 dbManager 作为参数传递给函数。这就是所谓的注入。这为依赖项管理开辟了许多途径,同时将依赖项的硬编码推到实现树的更高层次。这使得这个类非常易于重用,至少在依赖项遵守预期 API 的情况下是这样。

上述示例并不是注入依赖的唯一方式。例如,我们可以将其分配给对象的内部属性。例如,如果 projects.js 文件使用属性方法实现,它将看起来像这样:

./chapter 2/dependency-injection-3.js

const projects={
    dbManager,
    getAllProjects(){
        return this.dbManager.getAll("projects")
    }
}
export default projects;

在这种情况下,对象的调用者(顺便提一下,是一个单例)需要知道这个属性,并在调用任何函数之前将其分配。以下是一个示例:

./chapter 2/dependency-injection-4.js

import projects from "projects.js"
import dbManager from "dbManager.js"
projects.dbManager=dbManager;
projects.getAllProjects();

但这种方法并不推荐。你可以清楚地看到,它打破了封装的原则,因为我们直接为对象分配了一个属性。尽管它是有效的代码,但它看起来并不像是整洁的代码。

逐个传递依赖项也不是推荐的做法。那么,更好的方法是什么呢?这取决于实现方式:

  • 在一个类中,在构造函数中要求依赖项(如果找不到,则抛出错误)是很方便的。

  • 在一个普通的 JSON 对象中,提供一个函数来显式设置依赖项,并让对象决定如何内部使用它是很方便的。

最后一种方法也推荐在对象实例化后传递依赖项,当依赖项在实现时未准备好时使用。

以下是对前面列表中提到的第一点的一个代码示例:

./chapter 2/dependency-injection-5.js

class Projects {
    constructor(dbManager=null){
        if(!dbManager){
            throw "Dependency missing"
        }else{
            this.dbManager=dbManager;
        }
    }
}

在构造函数中,我们声明一个具有默认值的预期参数。如果未提供依赖项,我们抛出错误。否则,我们将它分配给实例的一个内部私有属性以供使用。在这种情况下,调用者应该如下所示:

// Projects are a class
import Projects from "projects.js"
import dbManager from "dbManager.js"
try{
    const projects=new Projects(dbManager);
}catch{
    // Error handler here
}

在另一种实现中,我们可以有一个函数,它基本上通过接收依赖并将其分配给一个私有属性来完成相同的功能:

import projects from "projects.js"
import dbManager from "dbManager.js"
projects.setDBManager(dbManager);

这种方法比直接分配内部属性更好,但你仍然需要记住在使用对象中的任何方法之前进行分配。

最佳实践提示

无论你使用什么方法进行依赖注入,都要在整个代码库中保持一致。

你可能已经注意到,我们主要关注的是对象。正如你可能已经猜到的,将依赖项传递给函数与传递另一个参数是一样的,所以它不值得特别注意。

这个例子只是将依赖实现的责任移动到层次结构中的另一个类。但如果我们实现一个单例模式来处理我们应用程序中的所有或大部分依赖呢?这样,我们就可以在应用程序生命周期中的某个确定点将依赖的加载委托给一个类或对象。但我们应该如何实现这样的功能?我们需要以下内容:

  • 注册依赖的方法

  • 通过名称检索依赖项的方法

  • 一个结构来保持对每个依赖项的引用

让我们将其付诸实践,创建一个非常天真的单例实现。请记住,这是一个学术练习,所以我们不考虑错误检查、注销或其他考虑因素:

./chapter 2/dependency-injection-6.js

const dependencyService={                          //1
    dependencies:{},                               //2
    provide(name, dependency){                     //3
        this.dependencies[name]=dependency         //4
        return this;                               //5
    },
    inject(name){                                  //6
        return this.dependencies[name]??null;      //7
    }
}
export default dependencyService;

在这个最基本实现的基础上,让我们逐行通过注释来看:

  1. 我们创建一个简单的 JavaScript 对象字面量作为单例。

  2. 我们声明一个空对象,用作字典来按名称存储我们的依赖项。

  3. provide函数让我们可以通过名称注册依赖项。

  4. 在这里,我们只使用名称作为字段名,并分配通过参数传递的依赖项(注意我们没有检查预存在的名称等)。

  5. 在这里,我们返回源对象,主要是为了方便,这样我们就可以链式调用。

  6. inject函数将接受在provide函数中注册的名称。

  7. 我们返回依赖项或null(如果未找到)。

在有了这个单例之后,我们现在可以在整个应用程序中使用它,按需分配依赖项。为此,我们需要一个父对象来导入它们并填充服务。以下是一个示例,说明这可能看起来像什么:

./chapter 2/dependency-injection-7.js

import dependencyService from "./dependency-injection-6"
import myDependency1 from "myFile1"
import myDependency2 from "myFile2"
import dbManager from "dbManager"
dependencyService
    .provide("dependency1", myDependency1)
    .provide("dependency2", myDependency2)
    .provide("dbManager", dbManager)

正如你所见,这个模块有硬编码的依赖项,它的作用是将它们加载到 dependencyService 对象中。然后,依赖的函数或对象只需要导入服务,并通过注册名称检索所需的依赖项,如下所示:

import dependencyService from "./dependency-injection-6"
const dbManager=dependencyService.inject("dbManager")

这种方法确实在组件之间创建了一个紧密的耦合,但这里提供它作为参考。它的优点是我们可以在一个位置控制所有的依赖项,这样维护的益处可能是显著的。dependencyService 对象的方法名称的选择也不是随机的:这些名称与 Vue 3 在组件层次结构内部使用的名称相同。这对于实现一些用户界面设计模式非常有用。我们将在第四章使用组件进行用户界面组合第七章数据流管理中更详细地看到这一点。

正如你所见,这种模式非常重要,并且在 Vue 3 中通过 provide/inject 函数实现。这是对我们工具集的一个很好的补充,但还有更多。让我们继续下一个。

工厂模式

工厂模式为我们提供了一种创建对象而不直接创建依赖项的方法。它通过一个函数来实现,该函数根据输入将返回一个实例化的对象。这种实现的用法将通过一个公共或标准接口进行。例如,考虑两个类:CircleSquare。这两个类都实现了相同的 draw() 方法,该方法将图形绘制到画布上。然后,一个 factory 函数将类似于这样:

function createShape(type){
    switch(type){
        case "circle": return new Circle();
        case "square": return new Square();
}}
let
    shape1=createShape("circle"),
    shape2=createShape("square");
shape1.draw();
shape2.draw();

这种方法相当流行,尤其是在与其他模式结合使用时,正如我们将在本书中多次看到的。

观察者模式

观察者模式非常有用,是响应式框架的基础之一。它定义了对象之间的关系,其中一个对象(主题)被观察以检测变化或事件,而其他对象(观察者)则被通知这些变化。观察者也被称为监听器。以下是它的图形表示:

图 2.3 – 主题对象发出事件并通知观察者

图 2.3 – 主题对象发出事件并通知观察者

正如你所见,主题对象会发出事件来通知观察者。主题对象需要定义它将发布哪些事件和参数。同时,观察者通过向发布者注册一个函数来订阅每个事件。这种实现方式使得这种模式通常被称为发布/订阅模式,并且它可以有多种变体。

在考虑实现此模式时,重要的是要注意发布的基数:1 个事件对应 0..N 个观察者(函数)。这意味着主题必须在它的主要目的之上实现发布事件和跟踪订阅者的功能。由于这会打破设计中的几个原则(关注点分离、单一责任等),通常会将此功能提取到一个中间对象中。因此,先前的设计变为添加一个中间层:

图 2.4 – 带有调度器中间对象的观察者实现

图 2.4 – 带有调度器中间对象的观察者实现

这个中间对象,有时被称为“事件调度器”,封装了注册观察者、从主题接收事件并将它们分发给观察者的基本功能。当观察者不再观察时,它还会执行一些清理活动。让我们将这些概念应用到纯 JavaScript 中的简单且原始的事件调度器实现中:

./chapter 2/Observer-1.js

class ObserverPattern{
constructor(){
    this.events={}                                             //1
}
on(event_name, fn=()=>{}){                                     //2
    if(!this.events[event_name]){
       this.events[event_name]=[]
    }
    this.events[event_name].push(fn)                           //3
}
emit(event_name, data){                                        //4
    if(!this.events[event_name]){
       return
    }
for(let i=0, l=this.events[event_name].length; i<l; i++){
    this.events[event_name]i
}
}
off(event_name, fn){                                           //5
    let i=this.events[event_name].indexOf(fn);
    if(i>-1){
        this.events[event_name].splice(i, 1);
    }
}
}

上述实现再次是原始的。它不包含在生产环境中使用的必要错误和边缘情况处理,但它确实为事件调度器提供了基本的基本功能。让我们逐行查看它:

  1. 在构造函数中,我们声明一个对象,将其用作内部字典来存储我们的事件。

  2. on 方法允许观察者注册他们的函数。在这一行中,如果事件尚未初始化,我们创建一个空数组。

  3. 在这一行中,我们只是将函数推送到数组中(正如我所说的,这是一个原始的实现,因为我们没有检查重复,例如)。

  4. emit 方法允许主题通过其名称发布事件并向其传递一些数据。在这里,我们遍历数组并执行每个函数,传递我们接收到的作为参数的数据。

  5. off 方法是必要的,以便在不再使用函数时取消注册(参见本章早些时候提到的 保持清洁 原则)。

为了使此实现工作,每个观察者和主题都需要引用相同的 ObserverClass 实现。最简单的方法是通过 单例模式 来实现它。一旦导入,每个观察者都会使用以下行向调度器注册:

import dispatcher from "ObserverClass.js"    //a singleton
dispatcher.on("event_name", myFunction)

然后,主题通过以下行发出事件并传递数据:

import dispatcher from "ObserverClass.js"    //a singleton
dispatcher.emit("event_name", data)

最后,当观察者不再需要监视主题时,它需要使用 off 方法清理与主题的引用:

dispatcher.off("event_name", myFunction)

在这里,我们没有涵盖许多边缘情况和控制,而不是重新发明轮子,我建议使用现成的解决方案来处理这些情况。在我们的书中,我们将使用一个名为mitt的解决方案(www.npmjs.com/package/mitt)。它具有与我们示例中相同的方法。我们将在第三章设置工作项目中看到如何安装打包的依赖项。

命令模式

这个模式非常有用且易于理解和实现。而不是立即执行一个函数,基本概念是创建一个包含执行所需信息的对象或结构。这个数据包(命令)然后委托给另一个对象,该对象将根据某些逻辑执行执行。例如,命令可以被序列化并排队、调度、反转、分组和转换。以下是这个模式的图形表示,包括必要的部分:

图 2.5 – 命令模式的图形实现

图 2.5 – 命令模式的图形实现

该图显示了客户端如何向调用者提交他们的命令。调用者通常实现某种队列或任务数组来处理命令,然后将执行路由到适当的接收者。如果有任何数据要返回,它也会返回给适当的客户端。调用者通常还会将附加数据附加到命令中,以跟踪客户端和接收,特别是在异步执行的情况下。它还提供了一个“入口点”到接收者,并将“客户端”与它们解耦。

让我们再次尝试一个Invoker类的简单实现:

./chapter 2/Command-1.js

class CommandInvoker{
    addCommand(command_data){                          //1
        // .. queue implementation here
    }
    runCommand(command_data){                          //2
        switch(command_data.action){                   //3
            case "eat":
                // .. invoke the receiver here
                break;
            case "code":
                // .. invoke the receiver here
                break;
            case "repeat":
                // .. invoke the receiver here
                break;
        }
    }
}

在前面的代码中,我们逐行实现了Invoker应该具备的裸骨示例:

  1. Invoker提供了一个方法来向对象添加命令。这仅在命令需要以某种方式排队、序列化或根据某些逻辑处理时才是必要的。

  2. 这行代码根据command_data参数中包含的action字段执行命令。

  3. 根据action字段,调用者将执行路由到适当的接收者。

实现路由执行逻辑的方法有很多。重要的是要注意,这个模式可以根据上下文在更大范围内实现。例如,调用者可能甚至不在 Web 客户端应用程序中,而是在服务器或不同的机器上。我们将在第八章使用 Web Workers 进行多线程中看到这个模式的实现,在那里我们使用这个模式在不同线程之间处理任务并卸载主线程(Vue 3 运行的地方)。

代理模式

这种模式的定义直接来源于其名称,因为“代理”一词意味着代表他人行事的人或事物,仿佛它就是同一个。这听起来有点复杂,但它会帮助你记住它。让我们通过一个例子来了解它是如何工作的。我们需要至少三个实体(组件、对象等):

  • 一个需要访问目标实体 API 的客户端实体

  • 一个暴露了知名 API 的目标实体

  • 一个位于中间并暴露与目标相同 API 的同时拦截来自客户端的每条通信并将其转发给目标的代理对象

我们可以用这种方式图形化地表示这些实体之间的关系:

图 2.6 – 代理对象暴露与目标相同的 API

图 2.6 – 代理对象暴露与目标相同的 API

这种模式的关键因素是代理的行为和暴露的 API 与目标相同,这样客户端就不知道或不需要知道它正在处理的是代理而不是目标对象。那么,我们为什么要这样做呢?有很多很好的理由,例如以下:

  • 你需要保持原始未修改的 API,但与此同时:

    • 需要处理客户端的输入或输出

    • 需要拦截每个 API 调用以添加内部功能,例如维护操作、性能改进、错误检查和验证

    • 目标是一个昂贵的资源,因此代理可以实现逻辑来利用它们的操作(例如,缓存)

  • 你需要更改客户端或目标,但不能修改 API

  • 你需要保持向后兼容性

你可能会遇到更多理由,但希望到现在你能够看到这如何有用。作为一个模式,这个模板可以在多个级别上实现,从简单的对象代理到完整的应用程序或服务器。在执行系统的部分升级时,这相当常见。在较低级别上,JavaScript 甚至原生包含一个用于代理对象的构造函数,Vue 3 使用它来创建响应性。

第一章Vue 3 框架中,我们回顾了使用ref()进行响应式的选项,但这个 Vue 的新版本还包括另一个用于复杂结构的替代方案,称为reactive()。第一个使用 pub/sub 方法(观察者模式!),但后者使用原生代理处理程序(这个模式!)。让我们看看这个原生实现可能如何与一个简单的部分实现一起工作。

在这个简单的例子中,我们将创建一个具有反应性属性的自动将摄氏度转换为华氏度并反向转换的Proxy对象:

./chapter 2/proxy-1.js

let temperature={celsius:0,fahrenheit: 32},                    //1
    handler={                                                  //2
      set(target, key, value){                                 //3
         target[key]=value;                                    //4
    switch(key){
     case "celsius":
           target.fahrenheit=calculateFahrenheit(value);       //5
           break;
    case "fahrenheit":
           target.celsius=calculateCelsius(value);
         }
      },
      get(target, key){
         return target[key];                                   //6
      }
    },
    degrees=new Proxy(temperature, handler)                    //7
// Auxiliar functions
function calculateCelsius(fahrenheit){
    return (fahrenheit - 32) / 1.8
}
function calculateFahrenheit(celsius){
    return (celsius * 1.8) + 32
}
degrees.celsius=25                                             //8
console.log(degrees)
// Prints in the console:
// {celsius:25, fahrenheit:77}                                 //9

让我们逐行审查代码,看看它是如何工作的:

  1. 在这一行,我们声明了temperature对象,它将成为我们要代理的目标。我们用相等的转换值初始化其两个属性。

  2. 我们声明一个handler对象,它将成为我们的温度对象代理。

  3. 代理处理程序中的set函数接收三个参数:目标对象、引用的键以及尝试分配的值。请注意,我说“尝试”,因为操作已被代理拦截。

  4. 在这一行,我们按照预期将赋值操作应用于对象属性。在这里,我们可能执行其他转换或逻辑,例如验证或引发事件(再次是观察者模式!)。

  5. 注意我们如何使用 switch 来过滤我们感兴趣的属性名。当键是celsius时,我们计算并分配华氏值。当我们收到fahrenheit度数的赋值时,情况相反。这就是响应性发挥作用的地方。

  6. 对于get函数,至少在这个例子中,我们只是返回请求的值。按照这种方式实现,它将等同于跳过getter函数。然而,它在这里作为一个例子,我们可以操作和转换要返回的值,因为这个操作也被拦截了。

  7. 最后,在第 7 行,我们使用处理程序将degrees对象声明为temperature的代理。

  8. 在这一行,我们通过将摄氏度值赋给degrees对象的成员来测试响应性,就像我们通常对任何其他对象所做的那样。

  9. 当我们将degrees对象打印到控制台时,我们注意到fahrenheit属性已被自动更新。

这是一个相当有限且简单的例子,说明了原生的Proxy()构造函数是如何工作并应用该模式的。Vue 3 使用更复杂的方法来实现响应性和跟踪依赖,这涉及到代理和观察者模式。然而,这让我们对当我们亲眼看到 HTML 实时更新时幕后发生的方法有了很好的了解。

客户端和目标之间代理的概念也与下两个模式相关:装饰器模式和外观模式,因为它们也是一种代理实现。区分的关键因素是代理保留了与原始目标对象相同的 API。

装饰器模式

这种模式乍一看可能非常类似于代理模式,确实如此,但它增加了一些独特的特性,使其与众不同。它确实与代理模式有相同的移动部件,这意味着存在一个客户端、一个目标以及一个在目标之间实现相同接口的装饰器(是的,就像在代理模式中一样)。然而,在代理模式中,拦截的 API 调用主要处理数据和内部维护(“家务”),而装饰器则增强了原始对象的功能以执行更多操作。这是将它们区分开来的决定性因素。

在代理示例中,注意额外的功能是如何作为一个内部反应性来保持每个刻度中的度数同步的。当你改变一个时,它会内部自动更新另一个。在装饰器模式中,代理对象在执行目标对象的 API 调用之前、期间或之后执行额外的操作。就像在代理模式中一样,所有这些对客户端对象都是透明的。

例如,在之前的代码基础上,假设现在我们想要在保持相同功能的同时记录对某个目标 API 的每次调用。从图形上看,它将看起来像这样:

图 2.7 – 一个增强目标以添加日志功能的装饰器示例

图 2.7 – 一个增强目标以添加日志功能的装饰器示例

在这里,最初只是一个简单的代理,现在仅仅通过执行一个谦逊的日志调用,它已经变成了一个装饰器。在代码中,我们只需要在set()方法结束前添加这一行(假设还有一个名为getTimeStamp()的函数):

console.log(getTimeStamp());

当然,这只是一个简单的例子,只是为了说明问题。在现实世界中,装饰器非常有用,可以在不重写逻辑或代码的很大一部分的情况下为你的应用程序添加功能。在此基础上,装饰器可以是可堆叠的可链式的,这意味着如果需要,你可以创建“装饰器的装饰器”,这样每个装饰器将代表添加功能的一个步骤,同时保持目标对象的相同 API。就这样,我们开始步入中间件模式的边界,但在这本书中我们不会涉及它。无论如何,那个其他模式背后的想法是创建具有指定 API 的中间件函数层,每个函数执行一个动作,但不同之处在于任何步骤都可以决定终止操作,因此目标可能被调用也可能不被调用。但这又是另一个故事...让我们回到装饰器。

在这本书的之前部分,我们提到 Vue 3 组件没有像通过扩展彼此实现的纯 JavaScript 类那样具有继承。相反,我们可以使用装饰器模式在组件上添加功能或改变视觉外观。现在让我们看看一个简短的例子,因为我们将在第四章中详细讨论组件和 UI 设计,使用组件的用户界面组合

假设我们有一个最简单的组件,它显示一个谦逊的h1标签,该标签接收以下作为输入:

./chapter 2/decorator-1.vue

<script setup>
    const $props=defineProps(['label'])          //1
</script>
<template>
    <h1>{{$props.label}}</h1>                    //2
</template>
<style scoped></style>

在这个简单的组件中,我们在第//1行声明了一个名为label的单个输入。现在不用担心语法,因为我们将在第四章**,使用组件的用户界面组合*中详细看到它。在第//2行,我们像预期的那样在h1标签内直接插值值。

因此,为了为这个组件创建一个装饰器,我们需要应用以下简单的规则:

  • 它必须代表组件(对象)执行操作

  • 它必须遵守相同的 API(输入、输出、函数调用等)

  • 它必须在目标 API 的执行之前、之后或期间增强功能或视觉表示

考虑到这一点,我们可以创建一个装饰器组件,它拦截标签属性,稍作修改,并也修改目标组件的视觉外观:

./chapter 2/decorator-2.vue

<script setup>
    import HeaderH1 from "./decorator-1.vue"
    const $props=defineProps(['label'])                //1
</script>
<template>
    <div style="color: purple !important;">            //2
        <HeaderH1 :title="$props.label+'!!!'">         //3
        </HeaderH1>
    </div>
</template>

在此代码中,在行//1中,您可以看到我们保持了与目标组件(我们在上一行导入的)相同的接口,然后在行//2中,我们修改(增强)了color属性,在行//3中,我们通过添加三个感叹号来修改传递给目标组件的数据。通过这些简单的任务,我们保持了构建装饰器模式扩展到 Vue 3 组件的条件。这并不坏。

装饰器非常有用,但还有一个类似于代理的、也非常常见且实用的模式:界面(facade)模式。

界面模式

到目前为止,您可能已经看到了这些模式中的渐进模式。我们从代理开始,代表另一个对象或实体执行操作,通过使用装饰器增强了它,同时保持了相同的 API,现在轮到界面模式了。它的作用除了代理和装饰器的功能外,还要简化 API 并隐藏其背后的巨大复杂性。因此,界面(facade)位于客户端和目标之间,但现在目标是高度复杂的,可能是一个对象,甚至是系统或多个子系统。这种模式也用于更改对象的 API 或限制对客户端的暴露。我们可以将交互想象如下:

图 2.8 – 简化与复杂 API 或系统交互的界面对象

图 2.8 – 简化与复杂 API 或系统交互的界面对象

如您所见,界面(facade)的主要目的是提供一个更简单的方法来处理复杂的交互或 API。在我们的示例中,我们将多次使用这个模式,以使用更友好的方法简化浏览器中的原生实现。我们将使用库来封装 IndexedDB 的使用,并在第八章“使用 Web Workers 进行多线程”中创建与 Web Workers 的简化通信。

不言而喻,您之前一定见过这种模式的应用,因为它是现代技术的基础概念之一。在简单的界面(API)背后隐藏复杂性无处不在,并且是 Web 开发的重要组成部分。毕竟,整个互联网极其复杂,有成千上万的移动部件,构成网页的技术几乎像是魔法。没有这种模式,我们仍然会使用零和一进行编程。

在实践中,你会在自己的应用中添加简化层来分解复杂性。实现这一目标的一种方法就是使用提供简化界面的第三方库。在接下来的章节中,我们将使用其中的一些,例如以下这些:

  • Axios:用于处理与服务器所有异步 JavaScript 和 XML(AJAX)通信

  • DexieDB:用于处理到 IndexedDB(浏览器本地数据库)的 API

  • Mitt:用于创建事件管道(我们在观察者模式中提到过)

  • Vue 3:用于创建惊人的 UI

通常,大多数 Web 技术的本地实现都有门面库,这些库经过良好的实战测试。开发者非常擅长简化这些库,并通过开源运动与其他人共享代码。然而,当使用他人的模块时,请确保它们是“安全”的。不要重复造轮子,也不要重复自己。但现在,是时候继续到我们列表中的下一个模式了。

回调模式

回调模式易于理解。当需要在同步或异步操作完成后执行操作时适用。为此,函数调用包括一个参数,该参数是在操作完成后要执行的功能。话虽如此,我们需要区分以下两种代码流类型:

  • 同步操作按顺序依次执行。这是基本的代码流,从上到下。

  • 异步操作一旦被调用,就会在正常流程之外执行。它们的长度不确定,以及它们的成功或失败。

对于异步情况,回调模式特别有用。例如,考虑一个网络调用。一旦调用,我们不知道从服务器获取答案需要多长时间,也不知道它是否会成功、失败或抛出错误。如果没有异步操作,我们的应用将会冻结,等待直到有结果出现。这不会是一个好的用户体验,尽管从计算上是正确的。

JavaScript 中的一个重要特性是,由于它是单线程的,异步函数不会阻塞主线程,允许执行继续。这是很重要的,因为浏览器的渲染函数是在同一个线程上运行的。然而,这并不是免费的,因为它们确实消耗资源,但它们不会冻结 UI,至少在理论上是如此。在实践中,这将取决于许多因素,这些因素受到浏览器环境和硬件的严重影响。不过,我们还是坚持理论。

让我们来看一个同步回调函数的例子,并将其转换为异步。示例函数非常简单:我们将使用回调模式计算给定数字的斐波那契值。但首先,让我们回顾一下计算公式:

F(0)=0
F(1)=1
F(n)=F(n-1)+F(n-2), with n>=2

因此,这里有一个 JavaScript 函数,它应用公式并接收一个回调来返回值。注意,这个函数是同步的:

./chapter 2/callback-1.js - 同步斐波那契

function FibonacciSync(n, callback){
    if(n<2){
       callback(n)
    } else{
        let pre_1=0,pre_2=1,value;
        for(let i=1; i<n; i++){
           value=pre_1+pre_2;
           pre_1=pre_2;
           pre_2=value;
        }
        callback(value)
    }
}

注意看,我们不是用return返回值,而是将值作为参数传递给callback函数。什么时候使用这种做法是有用的呢?考虑以下简单的例子:

FibonacciSync(8, console.log);
// Will print 21 to the console
FibonacciSync(8, alert)
// Will show a modal with the number 21

只需替换回调函数,我们就可以显著改变结果的呈现方式。然而,这个示例函数有一个影响用户体验的基本缺陷。由于它是同步的,计算时间与传递的参数成正比:n越大,所需时间越长。使用足够大的数字,我们很容易挂起浏览器,但在那之前,我们就可以冻结界面。你可以通过以下片段测试执行是否是同步的:

console.log("Before")
FibonacciSync(9, console.log)
console.log("After")
// Will output
// Before
// 34
// After

要将这个简单的函数转换成异步函数,你只需将逻辑封装在setImmediate调用中即可。这将使执行脱离正常的工作流程。新的函数现在看起来是这样的:

function FibonacciAsync(n, callback){
    setImmediate(()=>{
        if (n<2){
            callback(n)
        } else{
            let pre_1=0,pre_2=1,value;
            for(let i=1; i<n; i++){
                value=pre_1+pre_2;
                pre_1=pre_2;
                pre_2=value;
            }
            callback(value);
        }
    })
}

如您所见,我们使用箭头函数来封装代码,没有任何修改。现在,看看我们用这个函数执行与之前相同的片段时的区别:

console.log("Before")
FibonacciAsync(9, console.log)
console.log("After")
// Will output
// Before
// After
// 34

如您所见,输出片段在34之前输出了After。这是因为我们的异步操作已经按照预期从正常流程中移除。在调用异步函数时,执行不会等待结果,而是继续执行下一个指令。有时这可能会让人困惑,但它非常强大且有用。然而,这种模式并没有规定如何处理错误或失败的操作,或者如何链式或顺序地运行多个调用。处理这些情况有不同的方法,但它们不是模式的一部分。还有另一种处理异步操作的方法,它提供了更多的灵活性和控制:承诺(promises)。我们将在下一节看到这一点,在大多数情况下,你可以互换使用这两种模式。我说“在大多数情况下”,并不是所有情况!

承诺模式

promises模式主要是为了处理异步操作。就像回调一样,承诺函数的调用会使执行脱离正常流程,但它返回一个特殊对象,称为Promise。这个对象提供了一个简单的 API,包括三个方法:thencatchfinally

  • then方法接收两个回调函数,传统上称为resolvereject。在异步代码中,它们用于返回成功值(resolve)或失败或负值(reject)。

  • catch方法接收一个error参数,当过程抛出error并中断执行时被触发。

  • finally方法在两种情况下都会执行,并接收一个回调函数。

当一个 Promise 正在运行时,它处于一个不确定状态,直到它被解决或拒绝。Promise 在这个状态下等待的时间没有时间限制,这使得它在处理长时间操作(如网络调用和进程间通信(IPC))时特别有用。

让我们看看如何使用 Promise 实现之前的斐波那契数列示例:

function FibonacciPromise(n) {
    return new Promise((resolve, reject) => {          //1
        if (n < 0) {
            reject()                                   //2
        } else {
             if (n < 2) {
                 resolve(n)                            //3
             } else {
                  let pre_1 = 1, pre_2 = 1, value;
                  for (let i = 2; i < n; i++) {
                      value = pre_1 + pre_2;
                      pre_1 = pre_2;
                      pre_2 = value;
                  }
                  resolve(value);
             }
        }
    })
}

初看之下,很容易看出实现方式略有变化。我们从第//1行开始,立即返回一个new Promise()对象。这个构造函数接收一个回调函数,该函数反过来会接收两个名为resolve()reject()的回调。我们需要在我们的逻辑中使用这些回调,以便在成功时返回值(resolve)或失败时返回值(reject)。注意,我们不需要将我们的代码包裹在setImmediate函数中,因为 Promise 本质上就是异步的。我们现在检查负数,并在这种情况下拒绝操作(第//2行)。我们做的另一个改变是在第//3行和第//4行用resolve()替换了callback()调用。

调用现在也发生了变化:

console.log("Before")
FibonacciPromise(9).then(
    value=>console.log(value),
    ()=>{console.log("Undefined for negative numbers!")}
);
console.log("After")
// Will output:
// Before
// After
// 34

如您所见,我们将then方法链接到调用,并传递给它成功和失败的两个函数(在我们的代码中是resolvereject)。就像之前一样,我们得到了相同的结果。现在,这可能会显得更冗长(确实是),但好处远远超过了额外的输入。Promise 是可链式的,这意味着对于成功的操作,你可以返回一个新的 Promise,从而实现顺序操作。以下是一个例子:

MyFunction()
    .then(()=>{ return new Promise(...)}, ()=>{...})
    .then(()=>{ return new Promise(...)}, ()=>{...})
    .then(()=>{ return new Promise(...)}, ()=>{...})
    .then(()=>{ return new Promise(...)}, ()=>{...})
    .catch(err=>{...})

Promise构造函数还公开了其他方法,例如.all,但我将向您推荐查阅文档以深入了解可能性和语法(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)。尽管如此,仍然相当冗长。幸运的是,JavaScript 为我们提供了一个简化的语法来处理 Promise,即async/await,我们可以将其视为一种更“传统”的编码方式。这仅适用于 Promise 函数的调用,并且只能在函数中使用。

为了举例说明,让我们假设我们有三个返回 Promise 的函数,分别命名为MyFuncAMyFuncBMyFuncC(是的,我知道,这些名字不是很好)。每个函数在成功的情况下返回一个单个值(这是一个条件)。然后,这些函数在MyProcessFunction中使用新的语法。以下是声明:

async function myProcessFunction() {                  //1
    try {                                             //2
         let     a = await MyFuncA(),                 //3
                 b = await MyFuncB(),
                 c = await MyFuncC()
         console.log(a + b + c)                       //4
    } catch {
             console.log("Error")
    }
}
// Invoke the function normally
MyProcessFunction()                                   //5

我们首先使用 async 关键字声明我们的函数(行 //1)。这向解释器表明我们将在函数内部使用 await 语法。一个条件是必须将代码包裹在 try...catch 块中。然后,我们可以在每个承诺函数调用的调用前使用 await 关键字,就像行 //3 一样。到行 //4 时,我们可以确信每个变量都已接收到值。当然,这种方法更容易遵循和阅读。

让我们调查以下行的等价性:

let a=await MyFuncA()

这将与thenable(使用 .then)语法相匹配:

let a;
MyFuncA()
    .then(result=>{ a=result; })

然而,这种最后一种语法的缺点是我们需要确保所有变量 abc 都有值,我们才能运行行 //4console.log(a+b+c),这意味着需要像这样链式调用:

let a,b,c;
MyFuncA()
    .then(result=>{ a=result; return MyFuncB()})
    .then(result=>{ b=result; return MyFuncC()})
    .then(result=>{ c=result; console.log(a+b+c)})

这种格式更难遵循,当然也更冗长。在这些情况下,async/await 语法更受欢迎。

使用承诺(promises)来封装长时间或不确定的操作,以及与其他我们已看到的模式(如外观模式、装饰器模式等)集成是非常有用的。这是一个重要的模式,我们需要牢记,我们将在我们的应用程序中广泛使用它。

摘要

在本章中,我们看到了软件开发的原则和重要的设计模式,以及使用纯 JavaScript 的示例,在适当的时候,也暗示了使用 Vue 3 的实现。这些模式在第一次看到时可能很难理解,但我们将使用它们,并在本书的其余部分回顾它们,以便本章可以作为参考。这将让您更好地了解根据您应用程序的需求何时以及如何应用不同的模式。

在下一章中,我们将从头开始实现一个项目,并为本书其余部分将要构建的应用程序奠定基础。随着我们的前进,我们将参考这些模式来帮助您巩固它们的应用。

复习问题

  • 原则和模式之间的区别是什么?

  • 单例模式为什么如此重要?

  • 你如何管理依赖关系?

  • 哪些模式使得反应性成为可能?

  • 模式是否交织在一起?为什么?你能给出一个例子吗?

  • 异步编程是什么,为什么它如此重要?

  • 你能想到哪些适用于承诺函数的使用案例吗?

第三章:设置工作项目

在前几章中,我们为使用 Vue 3 框架 设计 JavaScript Web 应用程序奠定了理论基础。然而,到目前为止,我们还没有真正进入一个实际项目。这正是本章的内容。我们将使用 Vue 3 伴随的新工具集从头开始创建一个项目,并准备一个我们将在其他项目中使用的模板。按照惯例,这个 Web 应用的初始项目是构建一个 待办事项列表(相当于 Hello World)。随着我们对每个新概念的介绍,我们将过度设计应用程序,使其变得更有用,或者至少更吸引人。

我们在这里将学习的一些实用技能如下:

  • 设置你的工作环境和 集成开发 环境IDE

  • 使用新的命令行工具和新的 Vite 打包器来构建我们的应用程序

  • 修改基本模板和文件夹结构以适应 最佳实践 和高级架构 设计模式

  • 将现成的 CSS 框架集成到我们的应用程序中

  • 配置 Vite 打包器以满足我们的需求

与前几章不同,这一章将主要侧重于实践,并且会对生态系统中每个元素的官方文档进行参考,因为这些内容会不时发生变化。你不需要记住这些步骤,因为从头开始启动项目对于大型项目来说并不常见,而且构建这些项目的工具也在不断进化。让我们开始吧。

技术要求

要遵循本章中的实际步骤,你需要以下内容:

  • 一台运行 WindowsLinuxmacOS 且具有 64 位架构的计算机。我将使用 Ubuntu 22.04,但这些工具是跨平台的,步骤可以在不同的操作系统之间转换(如果有不同之处,我会指出)。

  • Node.js 16.16.0 LTS 以及已安装的 npm节点包管理器)。你可以在官方文档中找到安装 Node.js 的步骤,网址为 nodejs.org/。构建工具在 Node.js 上运行,所以没有这个,你无法走得很远。Node.js 是一个适用于在服务器和浏览器“外部”运行的 JavaScript 版本,这使得它非常方便且强大。今天的大多数 Web 开发打包器都以某种方式使用 Node.js,如果不是至少为了它提供的极大便利性。

  • 一个 Volar 插件。官方网站是 code.visualstudio.com/,在这本书中,我们将使用这个编辑器作为推荐的开发环境(IDE)来与 Vue 和 Vite 一起工作。

  • Sublime Text(免费试用/付费):这是另一个流行的选择,尤其是在 macOS 用户中。官方网站是 www.sublimetext.com/

  • Jetbrains WebStorm(免费试用,付费):官方网站是www.jetbrains.com/webstorm/

  • Komodo IDE(免费):官方网站是www.activestate.com/products/komodo-ide/

  • NetBeans IDE(免费):官方网站是netbeans.apache.org/

  • 控制台终端模拟器。Linux 和 macOS 用户对此概念最为熟悉。Windows 用户可以使用命令提示符,某些 IDE 的集成终端,或者从 Microsoft Store 安装Windows Terminal.* 一个现代的网页浏览器,无论是基于 Chromium 引擎(Google Chrome、Microsoft Edge、Opera、Brave、Vivaldi 等)还是 Mozilla Firefox。

安装好这些工具后,我们就可以开始跟随示例和基本项目了。然而,我建议您也安装Git,用于代码版本控制。我们将在本书的第九章 测试和源代码控制中使用它。在现代开发中,很难想象在没有一些工具来跟踪代码更改和版本控制的情况下进行项目工作。Git 已成为行业标准。您可以通过访问官方网站上的文档进行安装:git-scm.com/

本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter03

查看以下视频,了解代码的实际应用:packt.link/CmuO9

现在,随着我们的工具准备就绪,我们就可以开始我们的第一个 Vue 3 项目了。

项目设置和工具

我们将使用Vite作为打包器,直接从命令行创建一个新的项目。在您将放置项目的目录中打开一个终端窗口,并按照以下步骤操作:

  1. 输入以下命令:

$ npm create vite@latest

  1. 如果出现提示安装附加包,请输入Y(是)。

  2. 接下来,您将被提示按照以下顺序输入项目信息:

    1. .) 作为名称。

    2. chapter-3(或您选择的任何名称)。如果已输入或接受了一个项目名称或接受默认名称,则此选项可能不会显示。如果您输入点(.)作为创建项目的名称,那么此选项将是强制性的。

    3. 使用箭头键选择vue并按Enter键。

    4. 选择版本:就像之前一样,使用箭头键选择 JavaScript(或 TypeScript,但本书我们将使用纯 JavaScript)。

接下来,您将看到助手如何根据您的选择下载额外内容并搭建项目。它将创建一个包含多个文件的目录结构。然而,如果我们打算运行项目,我们很快就会发现问题在于它根本无法工作。这是因为搭建过程并没有安装依赖项,只是提供了一个骨架。因此,我们还需要再进行一个步骤,那就是使用npm安装依赖项。在终端中,输入以下命令并按Enter(如果您是在当前目录下安装的;如果不是,首先进入刚刚创建的目录):

$ npm install

包管理器将下载并安装我们项目的依赖项,并将它们放置在一个名为node_modules的新目录中。正如您所猜测的,我们使用 Vite 的Vue开发环境是一个Node.js项目。

依赖项就绪后,现在就是运行项目并查看搭建工具为我们准备了什么的时候了。在终端中,输入以下命令:

$ npm run dev

接下来发生的事情可能相当快。Vite 将解析您的项目文件,并在您的机器上启动一个开发服务器,您可以在浏览器中使用这个网址。您在终端中会看到如下内容:

图 3.1 - 使用 Vite 运行开发服务器的结果

图 3.1 - 使用 Vite 运行开发服务器的结果

这里最重要的信息是localhost和您的项目网站正在提供服务的端口。显示的毫秒数只是为了让您知道 Vite 启动的速度有多快(如果您问我,这就是炫耀的权利)。接下来,为了查看我们到目前为止的工作结果,请在您的网络浏览器中打开本地地址,您应该会看到一个类似于以下屏幕的网站:

图 3.2:浏览器中的基本 Vite + Vue 项目

图 3.2:浏览器中的基本 Vite + Vue 项目

这个网站本身已经完全可用,尽管不是非常高效。为了测试 Vue 3 是否工作正常,点击屏幕中间的按钮,您会看到每次点击计数器都会增加。这就是响应性的体现!此外,Vite 为我们提供了一个带有实时更新和热模块替换HMR)的开发服务器,这意味着只要我们在代码中进行更改并保存文件,网站就会自动更新。在实践中,当开发用户界面时,通常会在浏览器中保持这个自更新网站打开以预览我们的工作,在某些情况下,甚至同时打开几个浏览器。非常方便!

我们已经在这段旅程中取得了进步,但距离终点还远。搭建的网站不过是一个起点。我们将对其进行修改,以更好地满足我们的需求,并在本章的剩余部分创建一个简单的待办事项应用。

在下一节中,我们将更详细地查看我们起始项目的结构和组织。

文件夹结构和修改

第一章《Vue 3 框架》中,我们提到框架为你的应用程序规定了一些结构。Vue 3 并不是例外,但与其他框架相比,目录结构中使用的约定是最小的。如果你在文件资源管理器中打开你安装项目的目录(无论是从你的操作系统还是在你的 IDE 中),你会找到一个类似这样的结构:

图 3.3:Visual Code 中的项目结构

图 3.3:Visual Code 中的项目结构

.vscode文件夹是由 IDE 创建的,node_modules是由npm创建的,用于分配依赖项。我们将忽略它们,因为我们不需要担心或处理它们。从顶部开始,让我们回顾每个目录的作用:

  • public

这个文件夹包含目录结构和文件,这些文件不会被打包器处理,将被直接复制到最终网站中。你可以在这里自由放置自己的静态内容。这就是你将放置你的图片、网络字体、第三方 CSS 库、图标等的地方。一般来说,这里的文件是那些永远不会被你的代码引用的文件,例如manifest.jsonfavicon.icorobots.txt等等。

  • src

我们将在这里放置我们的 JavaScript、动态 CSS、组件等等。如果我们展开这个文件夹,我们会发现脚手架工具已经创建了一个最小结构,如下所示:

  • 一个包含 SVG 文件的assets文件夹。在这个文件夹中,我们可以包含将被代码或打包器处理的文件。你可以直接将它们导入到你的代码中,打包器将负责在服务器上提供它们时正确映射它们。

  • 一个components文件夹,我们将在这里放置我们的.vue扩展。我们可以根据需要在这里创建目录结构。脚手架工具已经被放置在一个HelloWorld.vue组件中供我们使用。

  • 一个App.vue文件。这是我们应用程序的主组件,也是我们层次结构的根组件。按照惯例,我们这样称呼它。

  • 一个main.js文件,这是我们的应用程序的起点。它负责加载初始依赖项、主组件(App.vue)、创建带有所有额外功能(插件、全局指令和组件)的 Vue 3 应用程序,并将应用程序启动和挂载到网页上。

  • 一个styles.css文件,这是一个全局样式表,将应用于我们的整个应用程序。之前的脚手架工具通常将其放置在assets文件夹中,但现在它已经移动到src/根目录,给它一个更突出的位置。当这个文件被导入到main.js文件中时,它将被解析并与我们的 JavaScript 打包。

现在是时候调查项目根目录中的文件了,按照它们出现的顺序:

  • .gitignore 是一个控制从 Git 源代码控制中排除内容的文件。我们将在 第九章 中看到 Git,测试和 源代码控制

  • index.html 是主文件,也是我们 Web 应用的起点。打包器将按照文件出现的顺序开始访问和处理其他文件,首先是 index.html。你可以根据需要修改它,因为生成的文件相当基础。注意,在 body 标签的末尾,脚手架工具包含了一个 script 标签来加载我们的 main.js 文件。这个文件就是创建我们的 Vue 应用的文件。与其他自动生成此文件并将其注入 index.html 的打包器不同,Vite 要求你显式地导入它。除了其他优点之外,这让你可以控制 Vue 应用在网页中加载的时间。

  • package-lock.jsonnpm 用于管理 node_modules 中的依赖项。忽略它。

  • package.json 文件非常重要。该文件定义了项目,跟踪你的开发和生产依赖项,并提供了一些便捷的功能,例如通过简单的命令自动化一些任务。目前值得关注的是 scripts 部分,它定义了命令的简单别名。我们可以通过在命令行中输入 npm run <script name> 来运行这些命令。脚手架工具已经为我们准备好了三个 Vite 命令:

    • npm run dev:这将以开发者模式启动网站,带有本地服务器和实时刷新。

    • npm run build:这将打包我们的代码并将其优化,以创建一个生产就绪版本。

    • npm run preview:这是前两个选项之间的中间点。它将允许你在本地查看构建的生产就绪版本。这听起来可能有些令人困惑,直到你考虑到,在开发期间,你的应用程序访问的地址和资源,以及公共 URL,可能与生产环境中的不同。此选项允许你在本地运行应用程序,但仍然引用和使用那些生产端点和资源。在部署应用程序之前运行“预览”是一个好的做法。

  • vite.config.js 是一个配置文件,它决定了 Vite 在开发和打包生产版本时的行为。我们将在本章后面看到一些最重要的或常见的选项。

现在我们已经对 Vite 脚手架工具提供的内容有了更清晰的了解,是时候开始构建我们的示例应用了。在我们深入代码之前,还有几件事情需要处理:如何集成第三方样式表和 CSS 框架,以及一些会使我们的生活更轻松的 Vite 配置。

与 CSS 框架的集成

如果我们还记得在第二章“软件设计原则和模式”中讨论的最后三个原则(不要重复自己保持简洁为未来编写代码),那么在视觉外观和图形语言方面重新发明轮子通常是不受欢迎的。网络上有不断增长的 CSS 框架和库集合,我们可以轻松地将它们整合到我们的应用程序中。从旧的流行 Bootstrap 到原子设计,再到像 Tailwind 这样的实用类,以及经过图形语言如 Material Design 和拟物主义,选项范围非常广泛。Vue 已经有一些组件库实现了这些库中的一些,你可以在npm仓库中找到它们。使用这些库,你将局限于了解和应用设计师应用的约定,这在某些情况下可能会使你构建用户界面的方式变得固定。这些典型的例子包括使用Vue-material(以及其他)遵守 Google 的 Material Design 规范或整合网络字体和图标字体。不可能讨论每一个,但这里有一些指南和一些示例,说明如何将这些库整合到你的项目中:

  1. 按照框架或库提供的静态资产的要求结构,将它们放置在public文件夹中,并尊重所需的树结构。

  2. 在你的index.html文件中包含 CSS 框架或库的依赖项,按照它们的说明进行。通常,这意味着在head部分或body标签中导入样式表和 JavaScript 文件。在任何情况下,确保这些文件在我们应用程序加载之前放置(引用我们的main.js文件的script标签)。

  3. 如果框架或库需要实例化,请在挂载我们的应用程序之前进行。你可以在index.html中的script标签、main.js或另一个模块中直接这样做。

  4. 在组件的模板部分通常使用类(以及 JavaScript 函数),就像在纯 HTML 中使用这些库一样。一些框架会在window对象上创建 JavaScript 全局对象,因此你可以在组件的script部分直接访问它们。如果不是这种情况,考虑使用设计模式如单例代理装饰者模式来封装功能,以便在应用程序中使用。

现在我们将这些简单的说明付诸实践,应用到我们的示例项目中。我们将整合一个仅使用 CSS 的框架(这意味着它不使用额外的 JavaScript),以及字体图标来包含基本的图标。在生产构建中,我们应该删除未使用的 CSS 规则。一些 CSS 框架提供了这个功能,例如 Tailwind (tailwindcss.com/)。然而,这个主题超出了本书的范围,但值得在网上进行研究。

The w3.css 框架

网站 w3school.com 提供了一个基于 Google 流行的 Material Design 语言的部分 CSS 框架,用于许多移动应用程序。它提供了许多你可以免费实施的应用程序实用类。你可以在官方网站上了解更多信息:www.w3schools.com/w3css/

我们将遵循之前提到的指南,所以让我们按照以下步骤进行:

  1. www.w3schools.com/w3css/w3css_downloads.asp 下载w3.css文件,并将其放置在public目录中名为css的新文件夹中。完成后,它应该看起来像这样:

图 3.4 - w3.css 文件的位置

图 3.4 - w3.css 文件的位置

  1. 通过添加类似这样的link标签来修改我们项目的根目录下的index.html文件,引用w3.css文件:

    <link rel="stylesheet" href="/css/w3.css">
    

通过这次添加,CSS 文件中定义的类现在可以用于我们的组件模板中。为了避免项目脚手架中不欢迎的样式,请记住清除安装程序提供的styles.css文件。如果我们现在使用npm run dev运行开发服务器,我们会看到网站的外观略有变化,因为新的样式表已经成功应用。下一步现在是要添加一个 图标字体

FontAwesome 真是太棒了

开发者在处理大量图标时节省资源的一种方法是通过使用 字体图标。这些是字体文件,它们显示图标而不是字符。这个概念并不新鲜,但在网络开发中有许多应用。与 CSS 精灵表等其他技术相比,使用字体作为图标有许多好处。其中最相关的一点是,这些图标可以像常规字体一样进行操作,因此我们可以轻松地改变它们的大小、颜色等,并使它们与文本保持协调。这种方法并非全是快乐和幸福,因为现在,主要的权衡是这些图标最多只能显示一种或两种颜色,并且必须从必要性出发相对简单。

FontAwesome 是一个提供 图标字体 以供我们在应用程序中使用(无论是网页还是桌面)的网站。它已经这样做了很多年,并且拥有一些最好的图标集合。我们可以下载并使用其免费层来为我们项目使用。让我们再次遵循指南,在我们的项目中实现它们:

  1. fontawesome.com/download 下载 网页字体。这将下载一个包含所有不同替代方案的相当大的 ZIP 文件。

  2. 从 ZIP 文件中,将css/webfonts/目录原样复制到我们的public/文件夹中。我们在这里的项目中不会使用这个文件夹中的所有内容,所以你可以稍后删除我们不需要的部分。

  3. 编辑index.html文件以添加我们将使用的样式表。这些 CSS 文件将自动从/webfonts/文件夹加载图标字体:

    <link rel="stylesheet"href="/css/fontawesome.min.css"
    >
    <link rel="stylesheet" href="/css/solid.min.css">
    <link rel="stylesheet" href="/css/brands.min.css">
    

这就是我们包含 FontAwesome 到项目中的所有需要做的事情。还有其他一些替代方案已经将字体封装到 Vue 组件中,甚至网站还提供了 Vue 实现。然而,就本书的目的而言,我们将使用直接方法。如果我们打开网站的图标部分,我们可以浏览和搜索所有可用的图标。你可以将搜索限制为“solid”和“brands”,因为这是我们项目中包含的。例如,如果你想使用 FontAwesome 显示 Vue 图标,我们可以在模板中包含以下内容:

<i class="fa-brands fa-vuejs"></i>

这些类在任意空元素中实现所有魔法,但出于传统和方便的考虑,我们总是使用i标签。此外,你甚至不需要手动输入它们。一旦你找到了想要使用的图标,网站提供了一种“点击并复制”代码的便捷功能。上一行代码来自这里:

图 3.5 - FontAwesome 图标页面

图 3.5 - FontAwesome 图标页面

让我们记住,当只使用少量图标时,包含大量图标库会影响性能。对于生产构建,请确保你只包含你将在应用程序中使用的图标,通过仅使用必要的图标创建图标字体。就本书的目的和开发过程而言,我们可以跳过这一做法。

配备了漂亮的样式表和一些好的图标字体后,我们几乎可以开始编码了。还有一件事要做,那就是在我们的 Vite 配置中包含一些额外的选项。

Vite 配置选项

vite.config.js文件导出 Vite 将用于开发和生产的配置。Vite 旨在适用于许多不同的框架,而不仅仅是 Vue 3,尽管它是 Vue 3 的官方打包器。当我们打开文件时,我们注意到 Vue 是 Vite 的一个插件。内部,Vite 分别使用Rollup.js(www.rollupjs.org/)和esbuild(esbuild.github.io/)进行开发和生产构建。这意味着我们可以向 Vite 传递选项,还可以通过向这两个底层工具传递参数来对一些边缘情况有更精细的控制。此外,你可以为每种处理模式(开发和生产)传递不同的配置,所以我们在这里并不缺乏选项。

我们将在第十章“部署您的应用程序”中看到一些特定的部署配置,但到目前为止,我们将只关注开发部分,并添加一些内容以避免在代码中过多重复输入。

打开vite.config.js文件并添加以下导入:

import path from "path"

是的,路径导入不是 JavaScript,而是 Node.js,我们可以这样做,因为此文件是在 Node.js 上下文中读取和执行的。它永远不会到达浏览器或任何 JavaScript 上下文。

修改导出配置,使其看起来像这样:

export default defineConfig({
plugins: [vue()],
  resolve:{
    alias:{
      "@components":
          path.resolve(__dirname, "src", "components")
    }
  }
})

在这些行中,我们指定了一个名为@components的别名,与项目路径/src/components匹配。这样,当我们导入组件时,我们可以避免编写相对路径或完整路径,只需以这种方式引用组件内部的导入:

import MyComponent from "@components/MyComponent.vue"

为路径设置别名是一个很好的开发者体验特性。在大型项目中,组件的路径可能会相当长,而且代码重组有时会发生,这使得维护又是一个可能的中断点。定义别名可以让我们通过只在一个地方进行更改来获得更多的灵活性(原则:不要重复自己)。

您可以在vitejs.dev/config.找到 Vite 配置文件的完整参考。Vite 在vitejs.dev/plugins/提供了一个官方插件短列表(例如 Vue 插件),但社区也提供了一些插件来覆盖许多场景,请访问github.com/vitejs/awesome-vite#plugins。这些插件可以在需要时安装并导入到我们的配置文件中。

到目前为止,我们已经完成了足够的准备工作,可以继续前进,最终创建我们的简单待办事项应用程序。

待办事项应用程序

我们的示例应用程序将基于基本应用程序的框架文件构建。它将为我们提供一个输入元素来输入我们的待办事项,并将显示待办和已完成的任务列表。这个练习的目的如下:

  • 开发具有实时更新的应用程序

  • 创建一个组件,使用script setup语法中的响应式元素

  • 应用第三方库的样式和图标字体

当我们完成时,我们将拥有一个简单的网站,其外观应该如下(已添加待办事项作为示例):

图 3.6 - 应用样式后的最终待办事项应用程序结果

图 3.6 - 应用样式后的最终待办事项应用程序结果

为了这个练习的目的,我们将开发整个待办事项应用程序的一个单一组件,然后将其导入我们的main组件(App.vue)。当然,这是故意打破我们在第二章软件设计原则和设计模式中看到的一些原则。在第四章组件用户界面组合中,我们将使用这个产品并通过多个组件“使其正确”。

在应用程序中,用户将执行以下操作:

  1. 输入简短描述并按Enter键或点击加号将其作为任务输入。

  2. 系统将显示待办和已完成任务分别在不同的列表中,显示每个组中有多少。

  3. 用户可以点击任何任务来标记它是否已完成或未完成,应用程序将将其移动到相应的组。

了解应用程序的工作方式后,让我们继续编写代码。

App.vue

这是我们的主要组件。在启动应用程序中,我们需要从每个部分中删除内容,并更改为以下内容(我们将在下一节解释每个部分的作用):

<script setup>
    import ToDos from "@components/ToDos.vue"
</script>

script部分,我们只需要导入一个名为ToDos的组件(我们将在下一节创建此文件)。注意我们是如何使用已经定义的别名来指定路径(@components)。我们的主组件不会处理任何其他数据或功能,我们只使用它作为包装器来控制这个应用程序的布局。考虑到这一点,我们的模板现在将看起来像这样:

<template>
  <div class="app w3-blue-gray">
    <ToDos />
  </div>
</template>

我们声明了一个具有私有类(.app)的div元素,我们将在style部分中定义它。我们还应用了我们从W3.css导入的一种样式,为我们的应用程序添加了背景颜色。在我们的div元素内部,我们放置了ToDos组件。注意我们使用的是与script部分导入时相同的名称,并且使用的是 Pascal 大小写。我们可以使用这种表示法,或者 HTML 的 kebab-case 等价表示法(<to-dos />,单词之间用连字符分隔,且为小写)。然而,建议在我们的模板中始终使用 Pascal 大小写,以避免与现有或未来的 HTML 组件发生冲突。这个名称将在最终的 HTML 中转换为 kebab-case。

接下来,我们将定义样式,使用 CSS 的flex布局将组件居中显示在屏幕中央:

<style scoped>
.app {
  display: flex;
  justify-content: center;
  width: 100vw;
  min-height: 100vh;
  padding: 5rem;
}
</style>

在主组件就位后,现在让我们在/src/components目录中创建我们的ToDos组件,正确命名为ToDos.vue

ToDos.vue

在这个组件中,我们将放置这个简单应用程序的所有逻辑。我们需要以下响应式变量:

  • 一个变量用于捕获输入框中的文本,并创建我们的任务

  • 一个数组,我们将在这里存放具有以下字段的任务对象:一个唯一的 ID、一个描述和一个布尔值,用于指示它是否已完成

  • 一个过滤函数或计算属性(或属性),用于仅显示已完成的任务

根据前面的要求,让我们用以下代码填充我们的script部分:

import { ref, computed } from "vue"                        //1
const                                                      //2
_todo_text = ref(""),
_todo_list = ref([]),
    _pending = computed(() => {                            //3
         return _todo_list.value.filter(item =>
                                        !item.checked)
         }),
    _done = computed(() => {                               //4
         return _todo_list.value.filter(item =>
                                        item.checked)
     })
function clearToDo() {_todo_text.value = ""}               //5
function addToDo() {                                       //6
    if (_todo_text.value && _todo_text.value !== "") {
        _todo_list.value.push({id:  new Date().valueOf(),
        text: _todo_text.value, checked: false})
        clearToDo()
    }
}

我们从第//1行开始导入 Vue 的refcomputed构造函数,因为这是我们在这个应用程序中需要从框架中获取的所有内容。在第//2行,我们开始声明两个常量来指向响应式值:_todo_text,它将在输入元素中存储用户的任务描述,以及_todo_list,它将是我们任务(待办项)的数组。在第//3行和第//4行,我们声明了两个名为_pending_donecomputed属性。第一个将包含所有未完成的待办项的响应式数组,第二个将包含所有标记为完成的项。请注意,通过使用computed属性,我们只需要保留一个包含所有项目的数组。computed属性用于根据我们的需求获取列表的视图段。这与,例如,为每个组保留两个数组并将项目在它们之间移动的常见模式相比,是一种常用的模式。

最后,在第//5行,我们有一个辅助函数来重置项目文本的值,而在第//6行,我们有一个简单的函数,它检查描述的值并创建一个任务(待办项)添加到我们的列表中。重要的是要注意,当我们修改_task_list时,所有依赖于它的属性和变量都将自动重新评估。这种情况与computed属性相同。

在我们的组件逻辑中,要实现我们想要的结果,我们只需要这些。现在,是时候用 HTML 创建模板了。为了方便,我们将代码分成几个部分。代码中突出显示的部分标记了与框架和script部分中的代码有绑定或交互的部分:

<div class="todo-container w3-white w3-card-4">                    //1
    <!-- Simple header -->                                         //2
    <div class="w3-container w3-black w3-margin-0
        w3-bottombar w3-border-blue">
        <h1>
            <i class="fa-solid fa-clipboard-list"></i>
            To-Do List
        </h1>
</div>

我们的组件模板从第//1行开始,通过定义一个带有一些样式的包装元素来定义。然后,在第//2行,我们放置了一个带有样式和图标字体的简单标题。注意我们是如何同时使用W3 CSS 框架的 CSS 类和我们的作用域样式。接下来的代码行将专注于捕获用户输入:

<!-- User input -->                                                //3
<div class="w3-container flex-container w3-light-gray w3-padding">
    <input class="w3-input w3-border-0" type="text"
           autofocus
           v-model="_todo_text"
           @keyup.enter="addToDo()"
           placeholder="Type here your to-do item...">
    <button class="w3-button w3-gray" @click="clearToDo()">
        <i class="fa-solid fa-times"></i>
    </button>
    <button class="w3-button w3-blue" @click="addToDo()">
        <i class="fa-solid fa-plus"></i>
    </button>
</div>

与用户的交互从第//3行的部分开始,我们在这里定义了一个输入元素,并使用v-model指令附加我们的_todo_text响应式变量。从这时起,用户在我们输入框中输入的任何内容都将是我们代码中变量的值。为了方便起见,我们还通过以下属性捕获了Enter键:

@keyup.enter="addToDo()"

这将触发脚本中的addToDo函数。我们将在输入字段旁边的加号按钮上添加相同的操作,也是在click事件上:

@click="addToDo()"

这为我们提供了两种方式将我们的描述作为任务添加到待办事项列表中,即使用与同一功能相关联的多个事件。以下代码现在专注于显示输入数据:

<!-- List of pending items -->                                    //4
<div class="w3-padding w3-blue">Pending ({{ _pending.length }})
</div>
<div class="w3-padding" v-for="todo in _pending" :key="todo.id">
    <label>
        <input type="checkbox" v-model="todo.checked">
        <span class="w3-margin-left">
            {{ todo.text }}
        </span>
    </label>
</div>
<div class="w3-padding" v-show="_pending.length == 0">No tasks
</div>
<!-- List of completed tasks -->                                  //5
<div class="w3-padding w3-blue">Completed ({{ _done.length }})
</div>
<div class="w3-padding" v-for="todo in _done" :key="todo.id">
    <label>
        <input type="checkbox" v-model="todo.checked">
        <span class="w3-margin-left">
            {{ todo.text }}
        </span>
    </label>
</div>
<div class="w3-padding" v-show="_done.length == 0">               //6
  No tasks
</div>
</div>

要显示我们的任务列表,我们有两个几乎相同的代码块,从第//4行和第//5行开始——一个用于待办任务,另一个用于已完成任务。我们只关注第一个代码块(从第//4行开始),因为这两个代码块的行为几乎相同。在第一个div元素中,我们创建了一个小标题,显示_pending数组中的项目数量,通过插值其长度。我们用以下行来完成这个操作:

Pending ({{ _pending.length }})

注意我们如何在双大括号内直接访问数组属性,而不使用.value属性。虽然在我们的 JavaScript 代码中,我们应该写成_pending.value.length,但当我们使用 HTML 中的插值时,Vue 足够智能,能够识别template部分中的响应式变量并直接访问其值。这对于computed属性以及使用ref()创建的响应式变量同样适用。

在下一个div元素中,我们创建了一个带有v-for/:key指令的列表,该指令将遍历我们的_pending数组并为每个项目创建一个元素副本。在每一个元素中,我们现在可以使用在v-for指令中声明的名称todo来引用每个项目。接下来,我们在label元素内包裹一个input复选框和一个span,并将todo.checked属性(布尔值)绑定到输入框上,使用v-model。Vue 将负责根据复选框的状态分配truefalse值。当发生这种情况时,它还会触发computed属性的重新计算,我们会看到只需通过勾选/取消勾选一个项目,它就会在组(待办和已完成)之间移动,并更新每个块的总量。我们还有一个span元素来显示任务的文本。

最后,对于列表组为空的情况,我们还有一个div元素,当该列表在第//``6行(_pending.length==0)为空时才会可见。

如前所述,显示“已完成”列表的部分也是以相同的方式工作,应用相同的逻辑。

在这种情况下,我们的作用域样式将会相当小,因为我们只需要一些额外的设置,因为大部分繁重的工作都是使用w3.css库完成的。在我们的style部分中,添加以下内容:

.todo-container {max-width: 100%; min-width: 30rem;}
label {cursor: pointer; display: flex;}

todo-container类限制了我们的组件的最大和最小宽度,我们还修改了label元素,使用flex布局显示其子元素。

要查看应用程序的实际运行情况,保存所有更改,并在终端中使用以下命令启动 Vite 开发服务器:

$ npm run dev

一旦 Vite 准备就绪,就像我们之前做的那样,在网页浏览器中打开地址。如果一切顺利,你应该会看到我们的待办列表按预期工作。如果不这样,请检查存储库中的源代码,以确保你输入的代码与完整示例匹配。

对我们的待办应用进行快速评估

我们刚刚创建的应用程序正在运行,并且比简单的 Hello World 或计数按钮更高级一些。然而,我们还没有应用所有应该或可以应用的最佳实践和模式。这是故意的,作为一个学习练习。有时,为了知道如何正确构建某物,我们首先需要构建它以使其工作。一般来说,所有工程实践都理解,有一个迭代精炼的过程,每次交互都提供学习和成熟。一旦我们构建了第一个原型,就是时候退后一步,真诚地对其进行分析,思考我们如何改进它并做得更好。在这种情况下,以下是我们的分析:

  • 在我们的模板中,代码有重复,因为 _pending_done 计算属性基本上是相同的,只是基于变量值的微小差异。

  • 我们没有充分利用组件的力量,因为一切都是在单个组件中构建的。

  • 我们的组件也在创建我们的模型(待办事项),因此我们的业务逻辑与我们的组件绑定。

  • 在输入清理和控制方面,我们做得很少。可以预见,一些代码,甚至是相等的输入,都会破坏我们的应用程序。

  • 我们的待办事项列表是易变的。页面刷新将清除我们的列表。

  • 我们的任务只容纳两种状态(完成和待办)。如果我们想有第三种状态或更多状态怎么办?例如,进行中、等待或下一个?

  • 当前设计不提供编辑或删除已创建任务的方法。

  • 我们一次只能管理一个项目列表。

随着我们继续前进,我们将改进我们的应用程序,并应用原则和模式,使其成为一个更具弹性和有用的应用程序。在下一章中,我们将探讨如何以更易于接受的方式使用 Web 组件来组合 Web 应用程序。

摘要

在本章中,我们已经开始使用真实工具创建应用程序,从 IDE 到命令行工具,以构建、预览和构建我们的应用程序。我们还创建了一个简单的待办事项应用程序,并学习了如何将第三方 CSS 库和图标字体集成到我们的应用程序中,并定义了一些一般性指南以纳入其他库。我们还以批判性的态度对待我们的简单应用程序,作为提高其功能性和技能的步骤。在下一章中,我们将探讨如何更好地组织我们的代码并创建组件层次结构以创建我们的用户界面。

复习问题

  • 开发一个使用 Vite 的 Vue 3 应用程序有哪些要求?

  • 是否可以将第三方库和框架与 Vue 3 集成?

  • 将 CSS-only 库集成到 Vue 应用程序中的一些步骤是什么?

  • 在单个组件中创建应用程序是一个好主意吗?为什么是或不是?你能想到哪些场景,单组件应用程序是合适的?又或者,有哪些场景它是不合适的?

  • 为什么软件开发是一个迭代精炼的过程?

第四章:使用组件进行用户界面组合

在本章中,我们将更深入地探讨如何使用组件来组合用户界面。虽然我们可以像在第三章中我们的初始待办事项列表应用中那样,只用一个组件创建整个网页,但这并不是一个好的实践,除非是简单的应用、现有应用程序功能的部分迁移,或者在某些边缘情况下没有其他选择。组件是 Vue 构建界面的核心。

在本章中,我们将执行以下操作:

  • 学习如何使用组件层次结构来组合用户界面

  • 学习组件之间相互交互和通信的不同方式

  • 探索特殊和自定义组件

  • 创建一个应用设计模式的示例插件

  • 使用我们的插件和组件组合重新编写我们的待办事项应用

本章将介绍核心和高级概念,并为您提供构建具有可重用组件的稳健 Web 应用程序的工具。特别是,我们将应用从第二章中学习的设计模式,软件设计原则和模式,在代码实现中。

关于样式的说明

为了避免代码示例过长,我们将省略示例中的图标和样式。完整的代码,包括样式和图标,可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices

技术要求

遵循本章的要求与之前在第三章中提到的相同,设置一个 工作项目

查看以下视频以查看代码的实际应用:packt.link/eqm4l

本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter04

使用组件进行页面组合

要创建用户界面,我们必须有一个起点,无论是粗糙的草图还是复杂的设计。Web 应用程序的图形设计超出了本书的范围,因此我们将假设它已经创建好了。为了将设计转换为组件,我们可以将其视为一个回答以下问题的过程:

  1. 我们如何使用组件来表示布局和多个元素?

  2. 这些组件将如何相互通信和关联?

  3. 将有哪些动态元素进入或离开场景,以及它们将由哪些事件或应用程序状态触发?

  4. 考虑权衡,我们可以应用哪些设计模式来最好地满足用例?

Vue 3 非常适合创建动态、交互式的界面。这些问题引导我们到一个可重复的实现方法。所以,让我们定义一个具有明确阶段和步骤的通用过程。

步骤 1 – 识别布局和用户界面元素

此步骤回答的问题是:我们如何用组件来表示布局和多个元素?

我们将考虑整个页面,并根据设计考虑哪种布局最合适。我们应该使用列?部分?导航菜单?内容岛屿?是否有对话框或模态窗口?一种简单的方法是将设计图像取出来,并用矩形标记可能代表组件的部分,从最外层到单个交互单元。迭代这个页面的切割,直到你有一个舒适的组件数量。考虑到新的待办事项应用设计,这一步可能看起来是这样的:

图 4.1 – 将设计切割成带有虚线框的组件

图 4.1 – 将设计切割成带有虚线框的组件

一旦我们确定了组件,我们必须提取它们之间的关系,从最顶层的根组件(通常,这将是我们App.vue)创建一个层次结构。由于按上下文或功能分组组件,可能会出现新的组件。这是命名组件的好时机。这个初始架构将随着我们实现设计模式而演变。按照这个例子,层次结构可能看起来像这样:

图 4.2 – 组件层次结构的初步方法

图 4.2 – 组件层次结构的初步方法

注意到由于对其他组件的分组,出现了一个新的组件ToDoProject.vueApp组件通常处理应用程序的主要布局和层次结构中的起点。现在,随着我们的初始设计就位,是时候进行下一步了。

步骤 2 – 识别关系、数据流、交互和事件

此步骤回答的问题是:这些组件将如何相互沟通和关联?

在这个阶段,我们需要了解用户的交互(使用用例、用户故事或其他)。对于每个组件,我们决定它将保存什么信息(状态),将传递给其子组件的内容,它需要从其父组件获取的内容,以及它将触发哪些事件。在 Vue 中,组件只能垂直地相互关联。兄弟组件大部分情况下会忽略彼此的存在。如果一个兄弟组件需要与另一个组件共享数据,那么这些数据必须由一个可以与双方共享的第三方托管,通常是具有共同可见性的父组件。还有其他解决方案,例如响应式状态管理,我们将在第七章数据流管理中详细讨论。对于本章,我们将满足基本的关联功能。

记录这些信息有许多方法:在层次结构树中手写的笔记(见 图 4.2),描述性的正式文档,UML 图表(UML 代表 通用建模语言,是软件组件的图标表示),等等。为了简单起见,我们只将树的一个部分以表格格式写下:

组件 功能 状态,输入/输出,事件
ToDoProject.vue 托管待办事项列表并与用户协调交互。此组件将主动修改项目。 状态:待办事项列表
ToDoSummary.vue 显示按状态分类的待办事项汇总。 输入:待办事项列表
ToDoFilter.vue 收集一个字符串以过滤待办事项列表。 输出:一个过滤字符串
ToDoList.vue 显示待办事项列表,以及每个项目的信号操作。 输入:待办事项列表,一个过滤字符串

为了简洁,我省略了将构成用户对话框的组件和交互。我们将在本章后面看到它们,但可以简单地说,ToDoProject.vue 负责使用模态对话框管理交互。

第 3 步 – 识别用户交互元素(输入、对话框、通知等)

此步骤回答的问题是:哪些动态元素将进入或离开场景,以及它们将触发哪些事件或应用程序状态?

在我们的应用程序中,主要的 CRUD 操作(ToDoProject.vue 组件作为对某些事件的响应来控制此交互)由 ToDoProject.vue 组件执行。此过程在本序列图中表示:

图 4.3 – 通过模态框进行用户交互 – 编辑项目

图 4.3 – 通过模态框进行用户交互 – 编辑项目

在此图中,ToDoProject 组件与 ToDoList 组件共享待办事项列表。当用户触发 edit 事件时,子组件通过引发此类事件来通知父组件。然后,父组件复制项目并打开一个模态对话框,传递该副本。当对话框被接受时,父组件使用更改修改原始项目。然后,Vue 的响应性反映了子组件中的状态变化。

通常,这些交互帮助我们识别在 第 1 步 中不明显需要的额外组件,例如设计模式的实现……这是下一步。

第 4 步 – 识别设计模式和权衡

此步骤回答的问题是:在考虑权衡的情况下,我们可以应用哪些最佳设计模式来满足用例?

决定使用哪些模式可能是一个非常创造性的过程。没有银弹,多个解决方案可以提供不同的结果。通常需要制作几个原型来测试不同的方法。

在我们的新应用程序中,我们引入了模态对话框的概念来捕获用户输入。当操作需要用户操作或决策才能继续时,会使用模态对话框。用户可以接受或拒绝对话框,并且在做出决定之前不能与应用程序的任何其他部分交互。在这些条件下,一个可能的模式是应用 异步 Promise 模式。

在我们的代码中,我们希望将模态对话框作为一个 promise 打开,这个 promise 按定义将提供给我们一个 resolve()(接受)或 reject()(取消)函数。此外,我们希望能够在多个项目中,以及在我们的应用程序中全局地使用这个解决方案。我们可以创建一个插件来实现这个目的,并使用 依赖注入模式 从任何组件访问模态功能。这些模式将为我们提供所需的解决方案,使我们的模态对话框可重用。

在这个阶段,我们几乎准备好从概念上开始实现组件。然而,为了创建一个更适合且更坚固的应用程序,并实现上述模式,我们应该花点时间来更多地了解 Vue 组件。

深入了解组件

组件是框架的构建块。在 第一章Vue 3 框架 中,我们看到了如何与组件一起工作,声明响应式变量,以及更多。在本节中,我们将探索更多高级功能和定义。

本地组件和全局组件

当我们开始我们的 Vue 3 应用程序时,我们在 main.js 文件中将主组件 (App.vue) 挂载到一个 HTML 元素上。之后,在各个组件的脚本部分,我们可以通过以下命令导入其他组件以本地使用:

import MyComponent from "./MyComponent.vue"

以这种方式,为了在另一个组件中使用 MyComponent,我们需要在这个组件中再次导入它。如果一个组件在多个组件中连续使用,这种重复操作会打破开发 DRY 原则(参见 第二章软件设计原则和模式)。另一种选择是将组件声明在 main.js 文件中,我们可以使用 App.component() 方法来实现这种情况:

Main.js

Import { createApp } from "vue"
import App from './App.vue'
Import MyComponent from "./MyComponent.vue"
createApp(App)
    .component('MyComponent', MyComponent)
    .mount("#app")

component() 方法接收两个参数:一个表示组件 HTML 标签的 String,以及一个包含组件定义的对象(可以是导入的或内联的)。注册后,它将可供我们应用程序中的所有组件使用。然而,使用全局组件有一些缺点:

  • 即使从未使用过,组件也将包含在最终的构建中

  • 全局注册会模糊组件之间的关系和依赖

  • 本地导入的组件可能会发生名称冲突

建议只全局注册那些提供通用功能的组件,并避免那些是工作流程或特定上下文不可或缺部分的组件。

静态、异步和动态导入

到目前为止,我们导入的所有组件都使用 import XYZ from "filename" 语法以静态方式定义。例如 Vite 这样的打包器将它们包含在一个单一的 JavaScript 文件中。这增加了包的大小,并且可能会在我们的应用程序启动时造成延迟,因为浏览器需要下载、解析和执行包及其所有依赖项,然后才能进行任何用户交互。此代码可能包含很少使用或访问的功能。这种做法的明显替代方案是将我们的包文件分割成多个较小的文件,并在需要时加载它们。在这种情况下,我们有两种方法——一种由 Vue 3 提供,另一种由最新的 JavaScript 动态导入语法提供。

Vue 3 提供了一个名为 defineAsyncComponent 的函数。此函数接受一个参数,即返回动态导入的另一个函数。以下是一个示例:

import {defineAsyncComponent} from "vue"
const MyComponent = defineAsyncComponent(
                    ()=>import("MyComponent.vue")
                 )

使用此函数使其在大多数打包器中安全使用。Vue Router 使用此语法的替代方案,我们将在第五章“单页应用程序”中看到:JavaScript 提供的 import() 动态声明(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import),其语法非常相似:

const MyComponent = () => import('./MyComponent.vue')

如您所见,此语法更简洁。然而,它只能在定义 Vue Router 路由时使用,因为 Vue 3 和 Vue Router 处理懒加载组件的方式在内部是不同的。最终,两种方法都将主包文件分割成多个较小的文件,这些文件将在需要时自动加载到我们的应用程序中。

然而,defineAsyncComponent 有一些优点。我们还可以传递任何返回解析为组件的 promise 的函数。这允许我们在运行时动态地控制过程。以下是一个示例,其中我们根据输入值的值决定加载一个组件:

const ExampleComponent=defineAsyncComponent(()=>{
    return new Promise((resolve, reject)=>{
        if(some_input_value_is_true){
            import OneComponent from "OneComponent.vue"
                resolve(OneComponent)
           }else{
               import AnotherComponent from
                   "AnotherComponent.vue"
               resolve(AnotherComponent)
           }
    })
})

defineAsyncComponent 的第三种语法可能是最有用的。我们可以将一个具有属性的对象作为参数传递,这提供了对加载操作的更多控制。它具有以下属性:

  • loader(必填):它必须提供一个返回加载组件的 promise 的函数

  • loadingComponent: 在异步组件加载时显示的组件

  • delay: 在显示 loadingComponent 之前等待的毫秒数

  • errorComponent: 如果 promise 拒绝或由于任何原因加载失败时显示的组件

  • timeout: 在认为操作失败并显示 errorComponent 之前的时间(毫秒)

这里是一个使用所有这些属性的示例:

const HeavyComponent = defineAsyncComponent({
    loader: ()=> import("./HeavyComponent"),
    loadingComponent: SpinnerComponent,
    delay: 200,
    errorComponent: LoadingError,
    timeout: 60000
    })

当浏览器从 loader 属性检索组件时,我们显示 SpinnerComponent 来通知用户操作正在进行中。根据 timeout 定义的 1 分钟后,它将自动显示 LoadingError 组件。

采用这种方法,我们的代码优化得更好。现在,让我们学习如何通过事件接收数据和通知其他组件。

属性、事件和 v-model 指令

我们已经看到了使用属性和事件作为在组件与其父组件之间传递数据的基本用法。但通过多种语法,可以实现更强大的定义。属性可以在 script setup 语法中使用 defineProps 和以下任何参数格式定义:

  • 作为字符串数组 – 例如:

const $props=defineProps(``)

  • 作为对象,其属性用作名称,值是数据类型 – 例如,

const $props=defineProps(``)

作为对象,其属性定义了一个具有类型和默认值的对象 – 例如,

const $props=defineProps({

**    name: { type: String, default: “John”},**

**    last_name: {type: String, default: “Doe”}**

)

我们需要记住,原始值是通过 value(意味着在子组件内部更改它们的值不会影响父组件中的值)传递给组件的。然而,复杂的数据类型,如对象和数组,作为 引用 传递,因此它们内部键/值的更改将反映在父组件中。

关于复杂类型的说明

当使用默认值定义 ObjectArray 类型的属性时,默认属性必须是一个返回该对象或数组的函数。否则,对象/数组的引用将由组件的所有实例共享。

事件 是子组件向父组件发出的信号。这是在 script setup 语法中定义组件事件的示例:

const $emit=defineEmits(['eventName'])

与属性不同,emits 只接受字符串声明的数组。事件还可以向接收者传递值。以下是从上述定义中调用的示例:

$emit('eventName', some_value)

如您所见,defineEmits 返回一个函数,该函数接受定义数组中提供的相同名称作为第一个参数。第二个参数 some_value 是可选的。

自定义输入控制器

当属性和事件共同作用时,有一个特殊的应用是创建自定义输入控制器。在之前的示例中,我们使用了 Vue 的 v-model 指令在基本的 HTML 输入元素上捕获它们的值。遵循特殊命名约定的属性和事件允许我们创建接受 v-model 指令的输入组件。让我们看一下以下代码:

父组件模板

<MyComponent v-model="parent_variable"></MyComponent>

现在我们已经在父组件中使用了 MyComponent,让我们看看我们如何创建连接:

MyComponent 脚本设置

const $props=defineProps(['modelValue']),
      $emit=defineEmits(['update:modelValue'])

我们使用 Props 的数组定义来简化。请注意,prop 的名称是 modelValue,事件是 update:modelValue。这种语法是预期的。当父组件使用 v-model 分配变量时,值将被复制到 modelValue。当子组件发出 update:modelValue 事件时,父组件变量的值将被更新。这样,您可以创建强大的输入控件。但还有更多——您可以有多个 v-model

让我们考虑在使用 v-modelmodelValue 是默认值。Vue 3 引入了一种新的语法来处理这个指令,以便我们可以拥有多个模型。声明非常简单。考虑以下子组件的声明:

子组件的 props 和事件

const
  $props=defineProps(['modelValue', 'title']),
  $emit=defineEmits(['update:modelValue','update:title'])

在前面的 props 和 emits 定义之后,我们现在可以从父组件中引用它们,如下例所示:

父组件模板

<ChildComponent v-model="varA" v-model:title="varB"></ChildComponent>

如我们所见,我们可以在 v-model:name_of_prop 指令上附加一个修饰符。在 Child 组件中,事件的名称现在必须包含 update: 前缀。

使用 props 和事件允许在父组件和子组件之间直接进行数据流。这意味着如果需要与多个子组件共享数据,它必须在父级进行管理。当父组件需要将数据传递给不是子组件,而是孙组件或其他深层嵌套的组件时,这种限制就会产生一个问题。这就是 依赖注入模式 来拯救的时刻。Vue 通过 ProvideInject 函数自然地实现了这一点,我们将在下一节中更详细地介绍。

使用 Provide 和 Inject 进行依赖注入

当父组件中的数据需要在深层嵌套的子组件中可用时,仅使用 props,我们就必须在组件之间“传递”数据,即使它们不需要或使用这些数据。这个问题被称为 props 钻孔。同样,事件在相反方向上“冒泡”时也会发生这种情况。为了解决这个问题,Vue 提供了名为 ProvideInject 的两个函数来实现依赖注入模式。使用这些函数,父或根组件可以 提供 数据(无论是按值还是按引用,如对象),这些数据可以被 注入 到其层次树中的任何子组件中。直观上,我们可以将这种情况表示如下:

图 4.4 – Provide/Inject 的表示

图 4.4 – Provide/Inject 的表示

如您所见,这个过程非常简单,以及实现该模式的语法:

  1. 在父(根)组件中,我们导入 Vue 的 provide 函数并创建一个带有键(名称)和要传递的数据的提供:

    import {provide} from "vue"
    provide("provision_key_name", data)
    
  2. 在接收组件中,我们导入 inject 函数并通过键(名称)检索数据:

    import {} from "vue"
    const $received_data = (")
    

我们还可以以下列方式在应用级别提供资源:

const app = createApp({})
app.provide('provision_key_name', data_or_value)

这样,提供的内容可以注入到我们应用程序的任何组件中。值得一提的是,我们还可以提供复杂的数据类型,例如数组、对象和响应式变量。在以下示例中,我们提供了一个包含函数和父方法引用的对象:

在父/根组件中

import {provide} from "vue"
function logMessage(){console.log("Hi")}
const _provision_data={runLog: logMessage}
provide("service_name", _provision_data)

在子组件中

import {inject} from "vue"
const $service = inject("service_name")
$service.runLog()

在这个例子中,我们有效地提供了一个Admin.Users.Individual.Profile,它比user_data更具描述性。命名约定(类似于路径的命名只是一个建议,而不是标准)由团队和开发者来定义。正如本书之前提到的,一旦你决定了一个约定,重要的是在整个源代码中保持一致性。在本章的后面部分,我们将使用这种方法创建一个用于显示模态对话框的插件,但在那之前,我们需要了解一些关于特殊组件和模板的更多概念。

特殊组件

组件的层次结构非常强大,但也有局限性。我们已经看到如何应用依赖注入模式来解决这些问题之一,但还有其他一些情况需要更多的灵活性、可重用性或力量来共享代码或模板,甚至将渲染在层次结构之外的组件移动过来。

插槽,插槽,还有更多的插槽...

通过使用 props,我们的组件可以接收 JavaScript 数据。通过类比推理,我们也可以使用称为插槽的占位符将模板片段(HTML、JSX 等)传递到组件模板的特定部分。就像 props 一样,它们接受几种类型的语法。让我们从最基本的一个开始:默认插槽

假设我们有一个名为MyMenuBar的组件,它充当顶部菜单的占位符。我们希望父组件以我们使用常见 HTML 标签(如headerdiv)的方式填充选项,如下所示:

父组件

<MyMenuBar>
    <button>Option 1</button>
    <button>Option 2</button>
</MyMenuBar>

MyMenuBar 组件

<template>
<div class="...">
    <slot></slot>
</div>
</template>

假设我们在MyMenuBar中应用了必要的样式和类,最终的渲染模板可能看起来像这样:

图 4.5 – 使用插槽的菜单栏

图 4.5 – 使用插槽的菜单栏

应用到的逻辑非常直接。<slot></slot>占位符将在运行时被父组件在子标签内提供的任何内容所替换。在前面的示例中,如果我们检查渲染后的最终 HTML,我们可能会发现如下内容(考虑到我们正在使用W3.css类):

<div class="w3-bar w3-border w3-light-grey">
  <button>Option 1</button>
  <button>Option 2</button>
</div>

这是用户界面设计中的一个基本概念。现在,如果我们需要多个“插槽”——例如,创建一个布局组件呢?在这里,一个称为命名插槽的替代语法就派上用场了。考虑以下示例:

MyLayout 组件

<div class="layout-wrapper">
    <section><slot name="sidebar"></slot></section>
    <header><slot name="header"></slot></header>
    <main><slot name="content"></slot></main>
</div>

如您所见,我们通过 name 属性 命名了每个槽。在父组件中,我们现在必须使用带有 v-slot 指令的 template 元素来访问每个槽。以下是一个父组件如何使用 MyLayout 的示例:

父组件

<MyLayout>
    <template v-slot="sidebar"> ... </template>
    <template v-slot="header"> ... </template>
    <template v-slot="content"> ... </template>
</MyLayout>

v-slot 指令接收一个参数,匹配槽名称,以下是一些注意事项:

  • 如果名称不匹配任何可用的槽,则内容不会被渲染。

  • 如果没有提供名称,或者使用了名称 default,那么内容将在默认的无名槽中渲染。

  • 如果模板没有提供内容,那么在槽定义内部的默认元素将被显示。默认内容放置在槽标签之间:<slot>...default content here...</slot>

v-slot 指令也有简写符号。我们只需在槽名称前加上一个数字符号(#)。例如,前面父组件的模板可以简化如下:

<template #sidebar> ... </template>
<template #header> ... </template>
<template #content> ... </template>

Vue 3 中的槽非常强大,甚至允许在需要时将属性传递给父组件。语法取决于我们是否使用 默认槽命名槽。例如,考虑以下组件模板定义:

向上传递属性组件

<div>
    <slot :data="some_text"></data>
</div>

在这里,槽将一个名为 data 的属性传递给父组件。父组件可以使用以下语法访问它:

接收槽属性的父组件

<PassingPropsUpward v-slot="upwardProp">
    {{upwardProp.data}} //Renders the content of some_text
</PassingPropsUpward>

在父组件中,我们使用 v-slot 指令并给槽传递的属性分配一个本地名称——在这种情况下,upwardProp。这个变量将接收一个类似于属性对象的对象,但作用域限于元素。正因为如此,这类槽被称为 命名作用域槽,语法类似。看看这个例子:

<template #header="upwardProp">
    {{upwardProp.data}}
</template>

槽还有其他高级用法,涵盖了边缘情况,但在这本书中我们将不涉及这些。相反,我鼓励您在官方文档中进一步研究这个主题:vuejs.org/guide/components/slots.html

与此主题相关的一个概念我们将在本书的后面章节中看到,即 第七章数据流管理,它适用于响应式中心状态管理。现在,让我们看看一些有点不寻常的特殊组件。

组合式和混入

在 Vue 2 中,有一个名为 composables 的特殊组件。

组合式是一个使用组合式 API 封装和重用组件之间 状态逻辑 的函数。区分组合式与服务类或其他 业务逻辑 的封装非常重要。组合式的主要目的是共享 用户界面或用户交互逻辑。一般来说,每个组合式都执行以下操作:

  • 提供一个返回 响应式 变量的函数。

  • 遵循以use为前缀的camelCase命名约定 – 例如,useStore()useAdmin()useWindowsEvents()等。

  • 它在其自己的模块中是自包含的。

  • 它处理状态逻辑。这意味着它管理随时间持续变化的数据。

组合式的经典例子是将其附加到环境事件(窗口大小调整、鼠标移动、传感器、动画等)上。让我们实现一个简单的组合式组件,它读取文档的垂直滚动:

DocumentScroll.js

import {ref, onMounted, onUnmounted} from "vue"                //1
function useDocumentScroll(){
    const y=ref(window.scrollY)                                //2
    function update(){y.value=window.scrollY}
    onMounted(()=>{
        document.addEventListener('scroll', update)})          //3
    onUnmounted (()=>{
        document.removeEventListener('scroll', update)})       //4
    return {y}                                                 //5
}
export {useDocumentScroll};                                    //6

在这个小组合式组件中,我们首先从 Vue 导入组件的生命周期事件和响应式构造函数(//1)。我们的主函数useDocumentScroll包含了我们稍后要共享和导出的全部代码(//6)。在//2中,我们创建一个响应式常量并将其初始化为当前窗口的垂直滚动位置。然后,我们创建一个内部函数update,它更新y的值。我们在//3中将这个函数作为监听器添加到文档滚动事件,然后在//4中移除它(来自第二章“清理自己的东西,”原则)。最后,在//5中,我们返回一个包裹在对象中的响应式常量。然后,在一个组件中,我们这样使用这个组合式组件:

SomeComponent.js – 脚本设置

import {useDocumentScroll} from "./DocumentScroll.js"
const {y}=useDocumentScroll()
...

一旦我们导入了响应式变量,我们就可以像往常一样在我们的代码和模板中使用它。如果我们需要在多个组件中使用这段逻辑,我们只需导入组合式组件(DRY原则)。

最后,vueuse.org/为我们项目提供了一个令人印象深刻的组合式组件集合。它值得一看。

带有“component:is”的动态组件

Vue 3 框架提供了一个名为<component>的特殊组件,其任务是作为一个占位符动态渲染其他组件。它使用一个特殊属性:is,可以接收一个带有组件名称的String,或者一个带有组件定义的变量。它还接受一些基本表达式(一个解析为值的代码行)。以下是一个使用表达式的简单示例:

CoinFlip 组件

<script setup>
    import Heads from "./heads.vue"
    import Tails from "./tails.vue"
    function flipCoin(){return Math.random() > 0.5}
</script>
<template>
    <component :is="flipCoin()?Heads:Tails"></component>
</template

当这个组件被渲染时,我们将根据flipCoin()函数的结果看到HeadsTails组件。

在这一点上,你可能想知道,为什么不使用简单的v-show/v-if?当动态管理组件并且不知道在创建模板时哪些组件可用时,这个组件的力量就显现出来了。我们将在第五章中看到的官方 Vue Router,单页应用,使用这个特殊组件来模拟页面导航。

然而,有一个边缘情况,我们需要注意。虽然大多数模板属性将传递到动态组件,但某些指令(如v-model)在原生输入元素上不起作用。这种情况非常罕见,我们不会详细讨论,但可以在官方文档中找到vuejs.org/api/built-in-special-elements.html#component

现在我们对组件有了更深入的了解,让我们将新知识应用于两个项目:一个插件和我们的待办事项应用的新版本。

一个现实世界的例子 – 模态插件

我们已经看到了多种在项目内部共享数据和功能的方法。插件是在项目之间共享功能并同时增强系统功能的设计模式。Vue 3 提供了一个非常简单的接口来创建插件并将它们附加到我们的应用实例。任何暴露install()方法或接受相同参数的函数都可以成为插件。插件可以执行以下操作:

  • 注册全局组件和指令

  • 在应用级别注册可注入的资源

  • 为应用创建和附加新的属性或方法

在本节中,我们将创建一个实现模态对话框作为全局组件的插件。我们将使用依赖注入来提供它们作为资源,并利用 Vue 的响应性通过 promises 来管理它们。

设置我们的开发项目

按照第三章中的说明,设置工作项目,以便您有一个起点。在src/目录下,创建一个名为plugins/的新文件夹,并在其中创建一个名为modals/的子文件夹。将插件放置在plugins/文件夹内的单独目录中是一种标准做法。

设计

我们的插件将全局安装一个组件,并保持一个内部响应式状态来跟踪当前模态对话框的状态。它还将提供一个 API,以便将其作为依赖项注入需要打开模态对话框的组件。这种交互可以表示如下:

图 4.6 – 模态插件表示

图 4.6 – 模态插件表示

组件将实现模态元素,我们将通过代码打开对话框。当模态打开时,它将返回一个遵循异步模式的 promise。当用户接受模态时,promise 将解析,并在取消时拒绝。模态的内容将由父组件通过使用插槽提供。

实现

对于这个插件,我们只需要两个文件——一个用于插件的逻辑,另一个用于我们的组件。请在前往src/plugins/modal文件夹中创建index.jsModal.vue文件。目前,只需使用该节段的脚本设置、模板和样式来搭建组件。我们稍后再回来完成它。有了这些文件,让我们一步一步地开始处理index.js文件:

/src/plugins/modals/index.js

import { reactive } from "vue"                     //1
import Modal from "./Modal.vue"
const
    _current = reactive({}),                       //2
    api = {},                                      //3
    plugin = {
        install(App, options) {                    //4
            App.component("Modal", Modal)
            App.provide("$modals", api)
        }
    }
export default plugin

我们从//1开始,从 Vue 导入reactive构造函数和一个我们尚未创建的Modal组件。然后在//2行中创建一个内部状态属性_current,在//3行中创建一个将成为我们的 API 的对象。目前,这些只是占位符。重要的部分在//4行,我们在这里定义了install()函数。这个函数接收两个参数:

  1. 应用程序实例(App)。

  2. 如果在安装过程中传递了选项,则包含选项的对象。

使用应用程序实例,我们将Modal注册为全局组件,并将 API 作为名为$modals的可注入资源在应用程序级别提供。为了在我们的应用程序中使用此插件,我们必须将其导入到main.js中,并使用use方法进行注册。代码如下:

/src/Main.js

import { createApp } from 'vue'
import App from './App.vue'
import Modals from "./plugins/modals"
createApp(App).use(Modals).mount('#app')

如您所见,创建和使用插件相当简单。然而,到目前为止,我们的插件并没有做很多事情。让我们回到我们的插件代码,并完成 API。我们需要的是以下内容:

  • 一个show()方法,它接受一个标识模态对话框实现的名称,并返回一个 promise。然后我们将名称和resolve()reject()函数的引用保存在我们的响应式状态中。

  • accept()cancel()方法,分别用于解决和拒绝 promise。

  • 一个active()方法,用于检索当前模态的名称。

按照这些指南,我们可以完成代码,使我们的index.js文件看起来像这样:

/src/plugins/modals/index.js

import { reactive } from "vue"
import Modal from "./Modal.vue"
const
_current = reactive({name:"",resolve:null,reject:null}),
api = {
      active() {return _current.name;},
      show(name) {
           _current.name = name;
           return new Promise(
           (resolve = () => { }, reject = () => { }) => {
                _current.resolve = resolve;
                _current.reject = reject;
           })
},
      accept() {_current.resolve();_current.name = "" },
      cancel() {_current.reject();_current.name = "" }
},
plugin = {...} // Omitted for brevity
export default plugin;

我们使用reactive变量来保持内部状态,并且只通过我们的 API 进行访问。一般来说,这对任何 API 来说都是一个很好的设计。现在,是时候在我们的Modal.vue组件中施展魔法,以完成工作流程了。为了简洁,我省略了类和样式,但完整的代码可以在本书的 GitHub 仓库github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices中找到。

我们的模式组件需要执行以下操作:

  • 使用半透明元素覆盖整个可查看区域,以阻止与应用程序其他部分的交互

  • 提供要显示的对话框:

    • 一个属性用于注册组件的名称,由父组件提供。

    • 一个标题用于显示标题。标题也将是一个属性。

    • 一个区域供父组件填充自定义内容。

    • 包含带有接受取消按钮的页脚。

    • 一个在组件应该出现时触发的响应式属性。

在我们的定义到位后,让我们开始处理模板:

/src/plugins/modals/Modal.vue

<template>
<div class="viewport-wrapper" v-if="_show">              //1
  <div class="dialog-wrapper">
   <header>{{$props.title}}</header>                     //2
   <main><slot></slot></main>                            //3
   <footer>
     <button @click="closeModal(true)">Accept</button>   //4
     <button @click="closeModal(false)">Cancel</button>
   </footer>
  </div>
</div>
</template>

在第 //1 行,响应式变量 _show 控制对话框的可见性。我们在第 //2 行显示 title prop,并在第 //3 行预留一个槽位。第 //4 行的按钮将在点击事件中关闭模态,每个按钮都有一个代表性的布尔值。

现在,是时候编写组件的逻辑了。在我们的脚本中,我们需要做以下事情:

  • 定义两个 props:title(用于显示)和 name(用于识别)

  • 注入 $modals 资源,以便我们可以与 API 交互并执行以下操作:

    • 检查模态的名称是否与当前组件匹配(这“打开”了模态对话框)

    • 通过解决或拒绝承诺来关闭模态

按照这些指示,我们可以完成我们的 script setup:

<script setup>
  import { inject, computed } from "vue"                 //1
  const
  $props = defineProps({                                 //2
      name: { type: String, default: "" },
      title: { type: String, default: "Modal dialog" }
      }),
  $modals = inject("$modals"),                           //3
  _show = computed(() => {                               //4
      return $modals.active() == $props.name
  })
  function closeModal(accept = false) {
      accept?$modals.accept():$modals.cancel()           //5
  }
</script>

我们从第 //1 行开始,导入 injectcomputed 函数。在第 //2 行,我们创建具有合理默认值的 props。在第 //3 行,我们注入 $modals 资源(依赖项),我们将在第 //4 行的 computed 属性中使用它来检索当前活动模态并将其与组件进行比较。最后,在第 //5 行,根据按钮的点击,我们触发承诺的解决或拒绝。

要在我们的应用中的任何组件中使用此插件,我们必须遵循以下步骤:

  • template 中,定义一个名为我们在插件中注册的组件(Modal)。注意使用属性为 props:

    <Modal name="myModal" title="Modal example">
          Some important content here
    </Modal>
    
  • 在我们的脚本设置中,使用以下代码注入依赖项:

    const $modals = inject("$modals")
    
  • 使用以下代码通过给定的名称显示模态组件:

    $modals.show("myModal").then(() => {
          // Modal accepted.
    }, () => {
          // Modal cancelled
    })
    

使用这个,我们已经完成了我们的第一个 Vue 3 插件。让我们在我们的新待办事项应用中好好利用它。

实现我们的新待办事项应用

在本章的开头,我们看到了我们新待办事项应用的设计,并将其切割成层次组件(见 图 4**.1)。为了跟随本节的其余部分,你需要从本书的 GitHub 仓库中获取源代码副本(github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices)。随着我们的代码库的增长,不可能详细查看每个实现,因此我们将关注主要更改和特定的代码片段。考虑到这一点,让我们回顾前一个实现中的更改,大致按照文件执行顺序。首先,我们在项目中添加了两个新的目录:

  • /src/plugins,我们放置我们的 Modals 插件的位置。

  • /src/services,我们放置具有我们的业务或中间件逻辑的模块。在这里,我们创建了一个服务对象来处理我们的待办事项列表的业务逻辑:todo.js 文件。

main.js 中,我们导入并添加我们的插件到应用对象,使用 .use(Modals) 方法注册我们的插件。

App.vue文件已经变成了一个主要的布局组件,没有其他应用程序逻辑。我们导入并使用一个头部组件(MainHeader.vue)和一个父组件来管理我们的待办事项和 UI,(ToDoProject.vue),就像图 4.2中所示的设计一样。

ToDoProject组件通过反应变量包含列表的状态,其中我们有以下内容:

  • _items是一个包含我们的待办事项的数组

  • _item是一个辅助反应变量,我们用它来创建新项目或编辑项目的副本

  • _filter是另一个辅助反应变量,我们用它来输入一个字符串以过滤我们的列表

值得注意的是,我们还声明了一个常量$modals,它接收注入的Modals对象 API。注意showModal()函数如何使用此对象打开和管理新项目和编辑项目的对话框结果。然后,相关的模态在模板中显示,通过注释的结尾标记。通常,将所有模态模板放置在组件的末尾,而不是分散在整个模板中。

ToDoProject组件通过 props 将状态数据委派给子组件以显示摘要和列表项。它也从它们那里接收事件,带有操作列表的指令。你可以将这个组件视为功能的。我们的应用程序只有一个,但这开始暗示了网络应用程序如何通过功能开始组织。

另一个值得注意的点,是使用服务对象和类。在我们的应用程序中,我们有todo.js,我们在需要的地方将其导入为todoService。在这种情况下,这是一个单例,但它也可以是一个类构造函数。请注意,它不包含任何接口逻辑,只有应用程序或业务逻辑。这是区分它与之前看到的组合式的一个决定性因素。

另一个变化是待办事项现在有多个状态,我们可以通过点击在它们之间循环。我们在服务的toggleStatus()函数中实现了这个逻辑,而不是在组件中。状态之间的流程可以表示如下:

图 4.7 – 一个循环有限状态机

图 4.7 – 一个循环有限状态机

你可能认出了这个设计,因为它代表了一个switch语句,就像我们例子中的那样:

Todo.js

[function] toggleStatus(status){
    switch(status){
        case "not_started":     return "in_progress"
        case "in_progress":     return "completed"
        case "completed":       return "not_started"
    }
}

这个函数,根据其当前状态,将返回下一个状态。在每次点击时调用此函数,我们可以以干净的方式更新每个项目的状态。

关于这个新实现的最后一个要点是ToDoSummary组件中计算属性的用法。我们使用它们来显示具有不同状态的项目的摘要卡片。注意反应性工作得有多好——当我们改变列表中项目的状态时,摘要会立即更新!

新的实现已经有序进行,现在是时候退一步,用批判性的思维审视我们的工作了。

对我们新的待办事项应用的小批评

与我们的第一次方法相比,这个新的待办事项应用版本是一个明显的改进,但它还可以改进:

  • 我们仍然只有一个任务列表。

  • 所有的操作都只在一个页面上发生。

  • 我们的项目是短暂的。当我们关闭或刷新浏览器时,它们就会消失。

  • 没有安全性,没有多用户的方式,等等。

  • 我们只能添加纯文本。图像或富文本怎么办?

  • 经过一些工作,我们可以扩展我们的应用程序,使其管理多个项目、额外内容、分配等等。

  • 我们已经取得了良好的进展,但仍有许多工作要做。

摘要

在本章中,我们深入研究了组件,学习了它们如何在框架内进行通信、共享功能以及实现设计模式。我们还看到了将粗糙草图或详细设计转化为组件的方法。然后我们了解了特殊组件,使用框架的依赖注入创建了一个模态对话框插件,并应用其他模式使我们的编码更加容易和一致。此外,我们对应用程序进行了重构,扩展了其功能,同时审视了更好的状态管理,独立于我们之前使用的 HTML 元素。我们已经取得了良好的进展,但仍有许多工作要做。

在下一章,我们将使用到目前为止所学的内容创建一个单页应用(SPA)。

复习问题

回答以下问题以测试你对本章知识的掌握:

  • 我们如何从一个视觉设计或原型开始,并使用组件来规划实现?

  • 组件之间可以以多少种方式相互通信?

  • 我们如何在多个组件中重用代码?还有其他方法吗?

  • 插件是什么,我们如何创建一个?

  • 我们在新的待办事项应用中应用了哪些模式?

  • 你会在实现中做哪些改变?

第五章:单页应用

在本章中,我们通过介绍单页应用SPAs)继续提高我们在 Vue 3 中的技能。我们将学习它们与常规网站的区别,并深入研究它们的关键特性。为了将这一点付诸实践,我们将使用 Vue Router 构建我们待办事项应用的新版本,并使用与之前章节不同的通信模式。我们还将通过代码示例学习认证方法。

到本章结束时,您将了解以下内容:

  • 如何使用 Vue 3 创建 SPA

  • 如何组织您的应用以利用 Vue Router 的不同路由策略

  • 如何使用不同模式的实际应用重新实现我们的待办事项应用

  • 如何在您的 SPA 中实现不同的认证模式

虽然上一章在基础知识方面有些繁重,但从现在开始,我们将更多地关注实际问题。因此,您需要访问示例应用以继续学习。

技术要求

本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter05

查看以下视频以查看代码的实际应用:packt.link/RnAyz

什么是 SPA?

要解释什么是单页应用(SPA),我们首先应该解释我们如何与万维网WWWW3)交互。当我们在一个网页浏览器中输入地址时,我们会收到由一个网络服务器发送的网页。在最基本的形式中,一个网站只是一个页面的集合,大多数是我们所说的“静态页面”。在这个语境中,“静态”意味着服务器上的相同文件在发送时没有经过修改。这使得网站非常快且安全。然而,一个纯静态网站与最终用户之间的交互性并不强。有时这被称为Web 1.0。服务器和浏览器脚本的出现解决了这一限制,并催生了多页应用MPAs)。页面现在可以是静态的,也可以在服务器上动态生成,服务器也可以接收请求新页面的调用,这些调用会处理额外的数据并返回一个新的页面作为响应。这些“即时生成”的新页面被称为动态,使得应用成为可能。这些技术使得博客、服务和商业得以迅速发展。

正是在异步通信(AJAX)、更强大的 JavaScript、本地存储方法、网络速度和计算能力的引入下,我们来到了被称为Web 2.0的时代。现在,我们可以将单个文件加载到浏览器中,并使用 JavaScript 控制整个界面和交互性,从而在不生成新页面的情况下创建丰富且高度交互的应用程序。SPA 仅在需要时与服务器联系,加载数据片段、UI 等。现在,可以将传统的“仅桌面”应用程序,如文本编辑器、电子表格、丰富的电子邮件客户端、图形设计套件等迁移到网络技术。Office 365Google DocsPhotoshop 在线TelegramDiscordNetflixYouTube等都是 SPA 的良好例子。重要的是要认识到,SPA 的引入并没有使 MPA 的使用无效或使其过时——每种方法在特定环境中都有其用途。实际上,今天的许多博客和新闻网站都是 MPA,并且仍然是互联网的重要组成部分。当今最复杂的网络应用程序包括 MPA 和 SPA 的混合使用,共同工作。SPA 甚至可以作为混合应用程序安装在桌面和移动设备上。我们将在第六章,“渐进式 Web 应用程序”中看到如何实现这一点。

随着分布式和去中心化计算的爆发,以及智能区块链的出现,构成单页应用(SPAs)的技术获得了更大的相关性。尽管尚未完全融入通用应用,但这个网络演化的新时代被称为Web 3.0。在本章中,我们将更深入地探讨这个主题,并举例说明。

到目前为止,我们制作的所有应用程序都属于 SPA 类别,即使我们还没有充分利用它们的潜力。Vue 3 专门设计用于创建这些类型的应用程序,并且是与ReactAngularSvelte等其他技术一样相关的技术之一。但并非一切都是甜蜜、闪亮和彩虹。与任何技术一样,使用 SPA 也有其权衡。在下一表中,我们列出了一些:

优点 缺点

|

  • 加载速度更快,更智能

  • 本地缓存以提高性能

  • 丰富的 UI 和交互性

  • 比 MPA 更容易开发和测试

  • 与完整页面重新/加载相比,更有效地使用代码和模板,减少网络通信。

|

  • 对搜索引擎索引或发现困难

  • 复杂性增加

  • 加载时间增加和首次交互速度变慢

|

表 5.1 – SPAs 的优缺点和权衡

如你所见,优势列表远远比劣势列表重要。当应用需要显著的用户交互和实时反馈时,你应该考虑使用 SPA。现在我们已经对 SPA 有了更好的了解,让我们看看它们功能核心的关键概念:应用路由

Vue 3 路由

Vue 是一个构建单页应用(SPA)的出色框架,但没有路由,任务很快就会变得相当复杂。Vue 路由是一个官方插件,它接管了应用的导航并将 URL 与组件匹配。这为我们带来了多页应用(MPA)的优势。有了路由,我们可以做以下事情:

  • 创建和管理指向组件的动态路由,如果需要,自动将参数匹配到 props

  • 通过名称识别路由(地址和组件),并通过代码触发导航

  • 当需要时动态加载组件,从而减少包的大小

  • 创建一种自然且逻辑的方法来处理网站导航和代码拆分

  • 使用已知事件控制导航,在导航发生前后

  • 以 MPAs 无法实现的方式创建页面过渡动画

Vue 3 路由的实现简单,遵循与生态系统其他组件相同的方法论。让我们从第四章使用组件的用户界面组合,修改我们的项目以使用 Vue 路由。

安装

当开始一个新的项目时,你可能已经注意到安装程序菜单为你提供了安装 Vue 路由的选项。如果你没有选择此选项,就像我们在示例应用中所做的那样,之后的安装相当简单。在终端中,在项目目录下,只需执行以下命令:

$ npm install vue-router@4

命令将下载并安装依赖项,就像在node_modules目录中的任何其他包一样。为了在我们的应用中使用它,我们需要做以下操作:

  1. 创建我们的路由。

  2. 将路由链接到我们的组件。

  3. 将路由包含在我们的应用中。

  4. 设置路由将显示我们的组件的模板。

与框架中的许多其他组件一样,路由没有指定你的路由应该放在哪个目录或组织结构中,或者你的组件也是如此。然而,我们将会使用一种已经成为行业默认标准的约定。在/src文件夹中,创建以下目录:

  • /router(或/routes):在这里,我们将放置我们应用的 JavaScript 文件,包含路由

  • /views:这个文件夹将包含与应用导航匹配的最高级组件(作为最佳实践)

在这些目录就位后,我们就可以开始修改我们的应用以包含路由导航了。在此之前,让我们看看我们希望通过路由实现什么。

一个新的待办事项应用

我们的新应用程序将重用组件来显示我们的待办事项列表,但也将容纳创建多个列表或项目。我们将显示一个侧边栏,其中包含所有我们的项目,并在选择它们时,列表将更新 11。这些项目也将保存在浏览器中,因此我们可以通过使用 localStorage 在以后返回它们。然后我们将有一个非常简单的导航,包含两个顶级页面(组件):

  • 一个可以创建新项目的登录页面

  • 一个可以处理我们的待办事项列表的项目页面

依据这些简单的原则,我们的应用程序完成后将看起来像这样:

图 5.1 – 我们的登录页面

图 5.1 – 我们的登录页面

如您在 图 5**.1 中所见,登录页面也是我们可以创建新项目的地方。我们使用模态对话框来收集用户输入,就像我们之前做的那样。在侧边栏中,我们显示一个链接到 主页(登录页面)以及一个包含我们创建的所有不同项目名称的列表。当您点击任何一个时,浏览器中的路由(URL)以及页面都将更新,我们将看到如下内容:

图 5.2 – 待办事项项目页面

图 5.2 – 待办事项项目页面

您可能已经认出了这个最后的截图,因为它就是我们的 ToDoProject.vue 组件显示的内容。实际上,要达到这个结果需要非常少的修改。现在,让我们从路由开始。

路由定义和路由对象

要为我们的项目创建路由,我们首先需要在它们自己的模块中定义它们。在 /router 目录中,创建一个 index.js 文件,内容如下:

/src/router/index.js

import {createRouter,createWebHashHistory} from 'vue-router'       //1
import Landing from "../views/Landing.vue"                         //2
const routes = [
    {path: "/",name: "landing",component: Landing},
    {path: "/project/:id",name: "project",
        component: () =>
            import("../views/ToDoProject.vue"),                    //3
        props: true
}],
router = createRouter({                                            //4
    history: createWebHashHistory(),                               //5
    routes,
    scrollBehavior(to, from, savedPosition){return{top:0}}
})
export default router;

我们从 vue-router 包中导入两个构造函数开始我们的文件,在行 //1

  • createRouter,它将创建一个路由对象,我们可以将其作为插件注入到我们的应用程序中

  • createWebHashHistory,这是一个构造函数,我们将将其传递给我们的路由对象,并指示它如何管理浏览器中的 URL 重写

Web hash history 将在 URL 中显示 #(一个数字符号),并指示所有导航都指向单个文件。所有导航和 URL 参数都将遵循此符号。这是最简单的方法,不需要任何特殊配置。然而,其他可用的方法还有 Web history(也称为 HTML5 模式美观的 URL)和 Memory。Web history 不使用哈希符号,但需要特殊的服务器配置。我们将在 第十章 的示例中看到如何完成,部署您的应用程序。Memory 模式不会修改 URL,主要用于网页视图(如 NW.js、Electron、Tauri、Cordova、Capacitor 等混合框架)和 服务器端渲染SSR)。现在,我们将继续使用 Web hash 历史方法。

在行//2中,我们使用静态符号导入了一个组件,并定义了一个包含我们的路由的routes数组。每个路由都由一个至少包含以下字段的对象表示:

  • path:表示与组件关联的 URL 的字符串

  • name:一个字符串,它像一个唯一的 ID 一样作用于路由,并且我们可以通过编程方式调用它

  • component:要渲染的组件

注意在行//2中我们导入了一个静态组件,但在行//3中,我们使用了动态导入符号。这意味着第一个路由(命名为"landing")将被包含在主包中,但第二个路由(在行//3中,命名为"project")只有在第一次需要时才会从单独的包中加载。使用路由,我们可以为提高我们的应用程序加载和包大小创建一个策略。

最后,在行//4中,我们使用构造函数创建我们的router对象,并传递一个选项对象。注意在行//5中,我们如何传递history字段一个我们选择的history方法的构造函数。我们还传递了我们的路由(显然),并且作为一个例子,我们创建了一个可能的导航守卫,以确保在导航到每个路由后,窗口滚动到顶部。如果没有这个,我们可能会遇到一个奇怪的副作用,滚动不会在“页面”之间改变。导航守卫在导航事件之前和之后触发。它们可以在多种情况下使用,例如身份验证控制或数据预加载。请参阅官方文档以获取守卫的完整列表,包括示例(router.vuejs.org/guide/advanced/navigation-guards.html)。

在我们的第二个路由中,我们还包含了路径表示法中的一个变体,通过包含一个以分号(;)为前缀的命名参数(:id)。这个路由将匹配/project/之后的所有内容,并将其分配给一个响应式变量,我们可以通过编程方式访问它(我们将在后面看到它是如何工作的)。该路由还有一个额外的字段,props: true。这表示路径中命名的参数将被自动传递给组件作为 prop,如果组件定义了具有相同名称的 prop。这将在下一节中变得有用和明显。

在定义了我们的路由和路由器之后,现在是时候将它们导入到我们的main.js文件中,并将它们附加到我们的应用程序上了。文件现在看起来是这样的:

/src/main.js

import { createApp } from 'vue'
import router from “./router”
import App from './App.vue'
import Modals from "./plugins/modals"
import styles from "./assets/styles.css"
createApp(App).use(router).use(Modals).mount('#app')

足够简单——现在是时候创建目前缺失的组件,并调整我们已有的组件了。在处理代码之前,让我们看看路由器为我们应用程序提供了哪些新组件。

路由模板组件

当我们将路由器包含到应用程序中时,它会将以下新组件注入到全局作用域中:

  • RouterView:这个组件提供了将要渲染的路由组件的占位符。

  • RouterLink:提供了一种简单的方式来链接到路由;通过使用方便的属性和样式,我们可以控制外观和最终渲染元素。

与路由和路由定义一起,我们模板中的这两个组件使我们能够提供导航并更好地组织我们的代码。在我们深入探讨它们的细节之前,让我们看看它们在我们应用程序中的实际应用。让我们开始修改我们的App.vue组件,将其转换为布局容器(省略了样式):

App.vue

<script setup>
    import Sidebar from './components/Sidebar/Sidebar.vue';
</script>
<template>
<div class="app">
   <Sidebar></Sidebar>
   <main>
      <router-view></router-view>
   </main>
</div>
</template>

如你所见,我们包含了一个新的组件Sidebar,它将包含我们应用程序的主要导航。然后,我们只放置一个<router-view>组件,我们的路由将在其中渲染每个页面。当涉及到样式时,我将参考 GitHub 中的代码来获取详细信息。现在,是时候在/src/components/Sidebar/Sidebar.vue路径下创建Sidebar组件,并从存储库中复制代码。在这个小文件中有许多内容可以查看。让我们从模板开始,看看我们如何使用RouterLink实例。第一个是静态的,指向主页。我们不是仅仅使用一个链接或锚标签,而是定义链接的目标为一个对象,其中直接引用路由的名称:

<RouterLink :to="{name:'landing'}" class="w3-padding" active-class="w3-yellow">Home</RouterLink>

当这个组件被渲染时,默认情况下,它将成为一个锚标签,并且href属性将被动态转换为适当的路由。如果我们更改路由的定义并给它另一个路径,这不会影响此代码。引用路由时使用其名称而不是 URL 是一个好的做法。如果我们需要将一些查询字符串参数传递给 URL,我们可以通过传递一个具有键/值成员的对象作为params属性来轻松完成。以下是一个示例:

<RouterLink :to="{name:'search',params:{text:'abc' }}" >Search</RouterLink>

之前的params属性将被渲染为一个带有?text=abc查询字符串的 URI。正如我们提到的,如果路由有激活的props属性,并且接收到的组件已定义了同名属性,则值将自动分配。这种情况允许我们生成一个链接列表并将其传递给我们的项目页面,如你接下来在文件中看到的:

<div v-for="p in _projects" :key="p.id">
    <RouterLink :to="{name:'project',params:{id:p.id}}">
        {{p.name}}
    </RouterLink>
</div>

当我们在主页上创建一个项目时,我们自动为每个项目分配一个唯一的 ID,我们在之前的代码中使用它。就像其他属性一样,我们可以监视变化并通过加载每个项目的相应待办事项来做出反应。考虑到这一点,我们修改了ToDoProject.vue文件来定义属性(不需要定义类型):

$props=defineProps(["id"])

然后,我们在script部分也设置了一个监视器来检测这些行的变化:

import { watch } from "vue"
watch(()=>$props.id, loadProject)

这个监视器接收一个返回prop属性的函数,然后运行loadProject()函数。此时,你可能会问为什么我们需要这样做,因为每个 URL 都是不同的。答案是 Vue 和路由器只在第一次需要时加载一个组件。只要它保持在视图中,它就不会重新加载它,而只更新响应式属性。由于我们的script setup代码只在第一次加载时运行,在创建的时刻,我们需要一种方法来检测变化以运行非响应式操作,例如从localStorage加载项目的待办事项。

你可以在仓库中查看其余的更改。与处理待办事项列表的组件相比,变化非常少,这正是封装的目的。即使是ToDoProject.vue的修改也很小。然而,有一个设计决策我们需要指出:使用发布/订阅模型来保持侧边栏菜单的同步。

我们使用事件总线(eventBus)创建了一个单例。当我们创建或删除一个新项目时,我们通过这一行触发一个更新事件:

eventBus.emit("#UpdateProjects")

我们在组件的mounting生命周期事件中注册需要监听的事件,并在它unmounted之前注销。在我们的例子中,我们只需要在Sidebar组件中这样做,但根据需要,我们可以在应用程序的任何地方这样做:

onMounted(()=>{
    eventBus.on("#UpdateProjects", updateProjects)
})
onBeforeUnmount(()=>{
    eventBus.off("#UpdateProjects", updateProjects)
})

事件名称很简单,不遵循任何约定。在这本书中,我们根据个人喜好在它前面加上一个数字符号。

在之前的实现中,以及ToDoProject.vue组件中,我们使用父组件作为在兄弟组件之间共享信息的通道,正如我们之前讨论的那样。在这里,我们使用另一种模式,即发布/订阅模式,以避免将此类任务污染App.vue组件。在第七章“数据流管理”中,我们将看到其他中央状态管理的方法。现在让我们更详细地看看使用路由器在更高级场景中的更多示例和细节。

嵌套路由、命名视图和编程导航

到目前为止,我们已经创建了静态和动态路由,甚至在地址中包含一些参数。但路由器可以做得更多。通过使用命名路由,我们还可以创建“子路由”和命名的“子视图”,以创建更深的导航树和复杂的布局。

让我们从例子开始。假设我们有一个三层的数据结构,并且我们希望以响应式的方式向用户展示,使他们能够选择一个层级,然后“深入”到细节。我们还想在 URL 中反映这一点,以便我们可以共享或引用整个案例。在这种情况下,层级将是国家、州和城市。UI 将看起来像这样:

图 5.3 – 使用多个命名视图和子路由的选择

图 5.3 – 使用多个命名视图和子路由的选择

如您从截图中所猜测的,当用户选择国家时,州列表被填充,并且 URL 被更新。当选择州时,城市列表被更新...最后,当选择城市时,信息出现在最后一列。你可能之前见过这种导航方法。有多种实现方式,其中一些比其他方式更高效。我们的意图是将此作为学习练习来实现,所以让我们从路由的定义开始。以下是我们的路由定义数组的一部分:

{
path: "/directory", name: "directory",
component: () => import("../views/Directory.vue"),
children:[
{ path:":country", name: "states", props: true,
  component: ()=>import("../views/State.vue"),
  children:[
       { path:":state", name: "cities", props: true,
         component: ()=>import("../views/City.vue")
       }  ]
} ] }

嵌套路由的定义

初看之下,你会发现变化不大,除了在路由上添加了一个新属性:children[]。这个属性接收一个路由数组,这些路由又可以有其他子路由,正如我们在前面的代码片段中所看到的。子路由将在其父组件的 RouteView 组件中渲染,并且它们的路径将与父路径连接,除非它们以根(反斜杠)开头。

要导航到每个路由,我们可以使用路由器识别的任何方法。然而,使用它们的名称,并通过一个对象传递任何参数或查询字符串,让路由器解析 URL 是一个好习惯。作为一个例子,看看在 Directory.vue 组件中我们是如何使用 RouterLink 元素的:

/src/views/Directory.vue 组件,第 13-18 行

<div v-for="c in countries" :key="c.code">
<RouterLink
      :to="{name:'states', params:{country:c.code}}"
      active-class="selected">
     {{c.name}}
</RouterLink>
</div>

我们在我们的循环中包含了 RouterLink 组件,根据我们的数据创建所需数量的链接。链接的目标被设置为对象,我们传递路由的名称(states),并传递符合路由和组件 props 定义的参数。请注意,组件的路径已被定义为参数(以冒号字符开头——:country),它也匹配 State.vue 中对象的 props 定义。这种关联使得路由器能够自动为我们传递数据。

当你检查代码时,你会注意到在我们的最小子组件 City.vue 文件中,我们在 props 中定义了国家和州。然而,在路由定义中,只出现了一个参数:州(:state)。然而,当你运行示例时,你会注意到 prop 也有数据。这是因为子组件除了继承 URL 路径外,还继承了父路由中定义的所有参数。在这种情况下,我们的组件也接收到了传递给父组件的 :country 参数,即使它没有出现在其特定的路由中。

当你运行应用程序时,你会看到类似于以下截图的内容:

图 5.4 – 嵌套路由示例,带有选择

图 5.4 – 嵌套路由示例,带有选择

为了简单起见,只从静态文件中包含了两个国家。在实际项目中,这些数据将从数据库中检索。

到目前为止,我们使用的是“默认”的 RouteView 组件,但 Vue 路由允许我们在一个组件中包含多个视图,通过为它们分配不同的名称来实现。在这里我们只展示这个表示法,因为实现起来非常简单。考虑以下模板的组件:

<div>
    <RouterView name="header"></RouterView>
    <RouterView name="sidebar"></RouterView>
    <RouterView></RouterView>
</div>

在前面的代码中,我们使用 name 属性为我们路由赋予了一个标识。我们还有一个没有名称的视图,在这种情况下,它被认为是“默认”视图,或者也可以命名为 default。为了使用这种新的布局,路由的定义略有变化。现在,在每个定义中,我们没有 component 属性,而是有一个 components(复数)属性,它期望一个对象。对象中每个字段的名称必须与我们的 RouterView 组件给出的名称相匹配,并且等于一个对象。对于前面的代码片段,等效的路由定义可能如下所示:

{ path:"/layout", name: "main",
  components:{
      default: ()=>import('...'),
      header: ()=>import('...'),
      sidebar: ()=>import('...')
}}

使用这种类型的定义,我们可以创建复杂的布局,因为我们还可以定义子路由来利用父组件中的头部和侧边栏,并且只在默认视图中渲染。我们在构建动态 UI 方面有令人印象深刻的可能性数量。

在进入下一节之前,我们必须讨论的一个重要主题是程序化导航。到目前为止,我们已经使用了路由提供的新组件,但我们可以直接从我们的 JavaScript 中触发导航,而无需依赖于用户触发事件。为此,Vue Router 为我们提供了两个方便的构造函数,可以在组件的脚本中使用:useRouteuseRouter。我们使用以下行将这些构造函数导入到我们的组件中:

import {useRoute, useRouter} from "vue-router"
const     $route=useRoute(),
          $router=useRouter()

如您所想象,$route 为我们提供了有关当前路由的信息,而 $router 允许我们修改和触发导航事件。

$router 对象提供了几个方法,其中最常用的总结如下表:

方法 描述

| .push() | 最重要的方法。它将新的 URL 推入网络历史记录,并导航到相应的组件。它是使用 RouterLink 的程序等效。它接受一个字符串,其中包含要导航的 URL,或者一个具有可选属性的对象。以下是每个接受参数的示例:

// Navigate to an URL
$router.push("/my/route")
// Navigate to a URL, using an object
$router.push({path: "/my/route"})
// Navigate to a route, with parameters
$router.push({
   name:"route-name",
   params:{key:value}
})
// Navigate to a route, with query strings
$router.push({
   name:"route-name",
   query:{key:value}
})

当然,您可以通过传递参数和查询字符串来创建复杂的路由。需要记住的是,.push 将会更新浏览器中的导航历史。|

.replace() 替换当前的导航组件,而不会修改 URL。它接受与 .push 相同的参数。

| .go() | 这个方法接收一个整数作为参数,并使用浏览器的历史记录触发导航。正数向前导航,负数在导航历史中向后导航。它最常见的使用是用于实现应用程序中的“返回”链接。以下是一些示例:

// Go back one entry
$router.go(-1)
// Go forward one entry
$router.go(1)

|

如前所述,这些是最常用的方法,也是你应该掌握的方法。我可以告诉你,使用这些方法可以满足绝大多数常规需求。所有可用方法的完整列表可以在官方文档中找到,并允许你管理可能出现的边缘情况。我鼓励你查看它们,至少要了解它们,可以在router.vuejs.org/api/interfaces/Router.html#properties 中查看。其中一些边缘情况可能包括:动态添加和删除路由(.addRoute().removeRoute())、检索已注册的路由(.getRoutes())、在导航到路由之前检查路由是否存在(.hasRoute())等等。我们不会使用它们,所以在这里详细查看它们并不相关。

相反,$route 对象为我们提供了有关组件正在渲染的当前路径(URL)的信息。与前面的示例一样,这里有一个最常用属性及其功能的列表:

属性 描述
.``name 返回当前路由的名称。
.``params 返回一个对象,其中包含与路径(URL)一起提供的参数。如果这些参数与 props 匹配,则值可能重叠。
.``query 返回一个对象,其中包含附加到当前路径的解码查询字符串。
.``hash 如果存在,则返回包含哈希符号(#)的 URL 路径。
.``fullPath 返回路由的完整路径字符串。

在本书的示例中,我们将多次使用 .name().params().query(),因为它们也是最常用的。所有方法和属性的完整列表可以在官方文档中找到。

重要注释差异

我们一直在使用 useRouteuseRouter 构造函数与 script setup 注释一起在组合式 API 中使用。在选项 API 中,不需要初始化这些对象。它们都通过 this.$routethis.$router 自动提供。此外,当使用组合式 API 时,$route$router 对象在模板中也是自动可用的。

一个完整的代码示例可以在 GitHub 仓库中找到,位于 Chapter 5``/Nested Routes 目录下,URL 为:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/chapter05

现在我们已经知道了如何处理路由、参数和查询字符串,是时候看看在单页应用(SPA)中的一些常见认证模式了,因为许多模式需要不同的路径(URL)。

探索认证模式

当 SPA 背后也有服务器提供额外服务时,其力量就变得明显了。其中一项服务就是身份验证。在大多数应用中,都需要识别用户并根据他们的权利、状态、隐私、群体或其他与应用上下文相关的类别提供额外服务。一个明显的例子是网络邮件应用,如OutlookGmail

当前网络标准为我们提供了与服务器进行异步通信的几种选项。这些通常被称为用于这些网络通信的XMLHttpRequest对象,但新的规范为我们提供了一个直接的功能,fetch(),它在浏览器之间更方便且更标准化。虽然这些方法对于其他用途而不是简单需求是完全可以接受的,但最好使用一个库,它在这些技术之上提供更多功能——例如,一个提供GETPOSTPUTOPTIONSDELETE的库,以便轻松消费RESTful API(其中REST代表表示状态传输,这是一种在网络通信中使用的架构)。我们将在第八章中了解更多,使用 Web Workers 进行多线程。现在,只需记住,一个用于处理网络异步通信的库是更好的选择。在我们的案例中,我们将使用优秀的Axios库(axios-http.com/),您可以使用以下命令将其安装到您的应用中:

$ npm install axios

然后,在您的服务或组件中,您可以使用以下代码导入和使用该库:

import axios from “axios”

该库公开了匹配每个 HTTP 请求(.get().post().put()等等)的方法,每个方法都返回一个解析为请求结果或因错误而拒绝的承诺。

通过这个介绍,我们准备好查看我们应用中用户身份验证的一些常见模式。

简单的用户名和密码认证

这是认证用户的最简单方法,其中凭证的验证是由我们服务器上的实现完成的。在这种情况下,我们的服务器后端提供了 API 来验证由我们的 SPA 收集的一组凭证。传统上,凭证存储在服务器上,在数据库中,通信将在安全套接字层SSL)或加密通信之上进行,这两者实际上是同一回事。让我们从图形上看看工作流程:

图 5.5 – 简单的用户名和密码认证

图 5.5 – 简单的用户名和密码认证

在这个工作流程中,以下情况会发生:

  1. SPA 收集用户名和密码值,并将它们传输到我们服务器上的特定端点进行身份验证。

  2. 服务器使用存储在数据库中的信息来验证用户名和密码。

  3. 操作的结果作为对客户端 SPA 初始查询的响应返回(1)。

尽管图 5.5 显示了步骤的数量,但请考虑所有这些都是在一次网络调用及其回复中完成的。在服务器上开发验证代码超出了本书的范围,但我们在服务或 Vue 3 组件内部的代码看起来会与此类似:

import axios from "axios"
import {ref} from "vue"
const _username=ref(""), _password=ref("")
function doSignIn(){
axios.post("https://my_server_API_URL",
    {username:_username.value,password:_password.value})
  .then(response=>{
     console.log(response.status)
     console.log(response.data)
  }).catch(err=>{...})
}

如您所见,实现相当简单,取决于我们自己的逻辑和服务器 API 设计。重要的是要记住检查响应的状态(200299之间的任何状态都是成功)以及服务器发送回的数据,然后相应地采取行动。Axios 为我们处理所有通信和数据转换(假设我们的 API 接收和处理 JSON 数据)。

在成功的情况下,我们应该在我们的应用程序状态中保存结果,并相应地允许用户访问,主要是通过解锁导航到私有或受限路由。我们可以以多种不同的方式应用这种保护,最常见的是使用导航守卫、创建动态路由等。

这种方法是完全有效的,并且被大多数应用程序普遍实现。然而,它有几个缺点:

  • 我们负责维护一个包含用户名和密码(请加密!)的数据库,并实现验证逻辑

  • 我们根据当地法律对处理用户数据负有法律责任

  • 我们负责整个系统的安全性,从端到端

  • 用户必须记住或对自己的凭证负责

  • 我们应该提供处理边缘情况的方法,以及用户问题和凭证检索的方式

这些缺点绝不是阻碍,而是我们在选择这条路时要牢记的大要点。无论如何,大多数应用程序都需要有一种方式来验证用户身份,这取决于它们自己的逻辑和实现,因为并非所有用户(根据上下文)都愿意使用另一种身份验证方式,正如我们接下来将要看到的。

OpenID 和第三方身份验证

除了安全顾虑之外,处理身份验证时一个主要问题是如何容易地让最终用户丢失或不当处理这些凭证。这种情况我们每个人都可能遇到。我们在线访问的服务越多,用户需要“记住”的凭证数量就越大。有许多不同的方法来解决这个问题,以减轻用户在跟踪所有这些用户名和密码时的负担。其中一种标准是OpenID协议(openid.net/)。

OpenID 协议可以在不需要在网站之间共享凭据(用户名和密码)的情况下验证用户。它基于 OAuth 2.0 协议的工作流程,该协议用于在无需使用密码的情况下安全地共享信息和资源。这是通过在不同参与者之间共享令牌来实现的。这些通信的标准是使用 JSON Web Tokens(JWT)。本段有很多内容需要解释,所以让我们更详细地看看这些术语,以便我们更好地理解该协议的工作原理。

JWT 是一个包含三个部分并由点(.)分隔的字符串,这些部分已被 Base64 编码。每个部分随后编码一个包含以下信息的 JSON 对象:

  • 头部:这包含用于编码令牌的加密信息,例如算法、令牌类型(通常是 JWT),以及在某些情况下甚至有效载荷中提交的数据类型。

  • 有效载荷:此对象包含我们想要(需要)共享的信息,并且主要是“自由格式”,这意味着它可以包含任何所需的 key:value 对。然而,也可以使用一些定义良好的字段,例如“iat”(sub 字段,表示主题)。

  • 签名:签名是通过连接头部和有效载荷的加密字符串表示形式(以 Base64 表示)得到的字符串。对于加密,使用一个秘密密钥(密码),只有认证服务器和网站服务器知道。

当工作流程中的网站收到一个令牌时,它会使用与发行者相同的方法使用秘密密钥对其进行解码和验证。如果签名不匹配,则假定令牌已被损坏或泄露,并将其拒绝。JWT 可以被第三方拦截和解码,因此这种方法作为防止篡改的安全措施。让我们看看创建令牌的例子:

  • {"alg": "HS256", "typ": "JWT"}。在这里,我们使用 HS256 算法并声明使用的类型为 JWT

  • {"sub":"1234567890","name":"Pablo D. Garaguso","iat":1516239022}

  • secret key

在前面的信息的基础上,使用以下公式创建一个签名字段(假设我们有一个使用 HS256 算法加密文本的函数):

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),"secret key")

最后,将 Base64 编码的结果字符串再次连接起来,给我们一个完全有效的令牌。同时,请注意每个部分(头部、有效载荷和签名)是如何通过点(.)分隔的。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwi bmFtZSI6IlBhYmxvIEQuIEdhcmFndXNvIiwiaWF0IjoxNTE2MjM5MDIyfQ.mPr551xpsCgmIzp8EZuSCoy7t7iQNpp_iGzIR14E_Jo

要测试此令牌,您可以使用类似jwt-decoder.com/的服务。然而,要验证它,您将需要使用密钥。您可以在jwt.io测试此,在那里您还可以找到更多关于此标准的信息。

在 OpenID 协议中,JWT 用于在各方之间传输和验证信息,这就是为什么理解这个概念如此重要的原因。该协议认可了几个工作流程。让我们在这里看看协议的授权代码流openid.net/specs/openid-connect-core-1_0.html)的简化表示,以及所有参与者,然后看看我们需要在我们的 Vue 3 SPAs 中实现的部分:

图 5.6 – OpenID 授权代码流的全貌

图 5.6 – OpenID 授权代码流的全貌

如您所见,为了使此工作流程发生,我们需要三个参与者:1) 我们的单页应用(SPA),处理多个路由,2) 认证服务提供商SP)服务器,以及 3) 我们自己的后端服务器。在浏览器中执行我们后端的认证和验证是可能的,这样只需要两个参与者,但这样做并不推荐,因为它会暴露我们的 JavaScript 中的密钥。然而,对于用户无法轻松访问页面代码的嵌入式应用程序(如移动应用)来说,这个选项是存在的。

为了实现工作流程,客户端(我们的应用)必须向认证服务进行注册。这个过程取决于每个实体,但结果是我们将注册以下内容:

  • 一个独特的client_id标识字符串,仅适用于我们的应用。

  • 一个secret_key值,这将只被认证服务器和我们的后端应用所知。这将用于编码和签名我们的令牌。

  • 认证服务器中的一系列端点,以及在我们应用中,用户将在每个步骤被重定向的地方。在这些重定向过程中,将作为 URL 查询字符串的一部分进行适当的令牌交换。

那么,让我们详细看看这些步骤,以及如何在我们的 Vue 3 应用中实现它们:

步骤 描述

| 1 | 用户需要被认证,因此我们将他们重定向到认证服务器给我们的端点。查询字符串需要包含以下(强制)字段:

  • scopeopenid

  • response_typecode

  • client_id:认证服务器提供的客户端标识

  • redirect_uri:用户在成功认证后将被重定向到的与服务器注册的相同地址

  • state:我们希望在认证后返回的任何数据或应用状态

为了准备重定向 URL,我们首先创建一个包含前面字段和值的对象,并使用 URLSearchParams 创建器来创建一个查询字符串(见 developer.mozilla.org/en-US/docs/Web/API/URLSearchParams):const query_data={scope:"openid", ...},``query_string=new URLSearchParams(query_data).toString()接下来,我们可以使用 location 对象来执行重定向:location.assign("https://auth_endpoint" + "?" + query_string) |

| 2 | 在成功认证后,认证服务器将用户重定向到我们注册为接收者的端点。发送的查询参数将取决于操作的结果:

  • 成功登录:

    • code: identity_token

    • state: 我们发送到服务器并希望返回的任何数据。我们可以使用它在我们应用程序内部重定向用户,例如。

  • 登录失败:

    • error: 根据协议指定的错误代码(interaction_requiredinvalid_request_uri 等)。

|

| | 重定向将触发我们的应用程序加载,并且路由器将渲染我们指定的组件。在我们的脚本设置中,我们需要捕获传递给我们的查询字符串,以便在下一步中使用。一种不使用第三方库的方法是以下代码:

import {useRoute} from "vue-router"
const $route=useRoute()
if($route.query.error){
   // The authentication failed, take action
}else{
   // Authentication succeeded do something
   sendToServer($route.query.code)
}

|

| 3 | 在这一步,我们只需将接收到的代码发送到我们的后端,这意味着实现之前提到的 sendToServer() 函数。由于我们现在处理的是自己的实现,这样做是显而易见的。在这个例子中,我们使用 Axios:

import{axios}from "axios"
function sendToServer(code){
axios
  .post("our server URL", {code})
  .then(result=>{
    // Set the token in our headers
    axios.defaults.headers.common={
     "Authorization":"Bearer " + result.data.identity_token
    }
  }).catch(()=>{
   // Handle the error
})}

在前面的例子中,我们已经将 code_token 字符串发送到我们的服务器,并从我们的服务器接收了 identity_token 字符串作为响应。然后我们更进一步,将应用程序的默认头设置为使用标准的 Authorization 头,带有 Bearer 令牌。从那时起,我们的服务器只需要检查头并验证请求的操作属于有效用户。 |

验证令牌以及 步骤 4步骤 5 超出了本书的范围,因为我们专注于 Vue 3 应用程序。正如你所见,我们的单页应用程序(SPA)需要处理的部分相当简单,实际上并不涉及太多代码(为了简洁起见,省略了一些错误检查)。

现在有相当多的联合认证服务,既有免费的也有付费的,我们可以在我们的应用程序中实现。如今最常见的做法是看到徽章将用户重定向到使用它们,例如使用 GoogleFacebookTwitterGitHubMicrosoft 等进行登录。还有提供所有上述提供者并在良好包装的库中的元服务,例如 Auth0 (auth0.com/, 现在是 Okta 的部分,www.okta.com/)。当涉及到实现此工作流程时,我们当然不会缺少选择。

无密码或一次性密码(OTP)认证

另一种移除凭证使用的方法是完全移除它们,实现无密码访问。基本思想是依赖于另一个系统的安全性(电子邮件、短信、认证器应用程序等)来验证用户。这个过程生成一个时间敏感的“一次性使用”代码,并通过后端服务通过支持系统发送给用户。前端服务(应用程序)等待用户在确定的时间框架内输入。例如,一个常见的实现是后端向用户的手机发送包含代码的短信,该代码必须在时间到期之前输入到应用程序中。

这里是这个工作流程的视觉表示,考虑到用户已经通过电子邮件或电话号码注册。这些应该是众所周知的,意味着所有权已经得到验证:

图 5.7 – 基于电子邮件的无密码认证

图 5.7 – 基于电子邮件的无密码认证

在前面的工作流程中,请注意,OTP 代码直到用户输入之前永远不会到达 Web 应用程序。验证发生在后端,而不是前端。这使得我们的应用程序非常简单,因为它只需要首先收集电子邮件并将其提交到服务器,然后等待指定的时间进行新的输入。在一个服务或组件中,使用 Axios,这段代码看起来可能像这样:

const _user_email=ref(""),
      _wait_seconds=ref(0),
      _show_input_code=ref(false),
      _otp_code=ref("")
function signInUser(){
    axios.post("https://requestOTP_url",
                {email:_user_email.value})
    .then(result=>{
        _wait_seconds.value=result.data.wait_time;
        _show_input_code.value=true;
        startOTPtimer();
    }).catch(err=>{...})
}
function startOTPtimer(){
   let interval_id=setInterval(()=>{
   if(_wait_seconds.value>0){_wait_seconds.value--;}
   else{clearInterval(interval_id);}},1000)
}
function checkOTP(){
   axios.post("https://validateOTP_URL",{code:_otp_code.value})
   .then(result=>{
     if(result.status>200 && result.status<300){
        // User validated, proceed to protected route
     }else{
        // Validation failed. Redirect to error route
     }
 }).catch(err=>{...})
}

在前面的代码中,我们省略了导入和模板,因为在这个阶段,它们对读者来说应该是显而易见的。我们的模板应该至少有一个输入来收集用户的电子邮件,第二个输入来收集 OTP 代码,以及两个按钮来触发点击signInUser()函数和checkOTP()函数。第一个按钮会将电子邮件传递到后端,并等待至少一个等待时间的回复,我们使用这个时间来启动计时器(总是让用户知道他们有多少时间输入代码)。如今,对于电子邮件和短信,标准是 60 秒。当这种情况发生时,我们也会隐藏第一个输入,然后显示“OTP”输入表单。当用户输入代码并点击checkOTP()函数被激活时,我们再次将代码传递给服务器等待回复。如果成功,我们可以根据我们的应用程序逻辑将用户重定向到受保护区域。考虑到模板的简单性,对于读者来说,自己创建组件和模板将是一个很好的练习。然后,可以在代码示例中找到一个可能的解决方案,在第五章文件夹中。

遵循渐进式安全方法,下一步是将之前的方法合并到一个新的通用流程中:双因素认证2FA),我们现在将看到。

2FA - 双因素认证

在双因素认证中,我们的应用程序合并了之前提到的两种或多种方法来验证用户。这种方法背后的关键概念是,即使第三方或简单的用户名和密码也不够,用户还需要一个“二级因素”来验证——例如,使用已注册的电子邮件、电话号码(用于短信提交代码)、认证应用(例如:谷歌身份验证器)、USB 设备、安全卡(带有芯片或带读卡器)等等。

工作流程很简单,但确实需要我们的后端比前端应用程序做更多的工作。一旦我们的 SPA 使用之前列出的任何一种方法验证了我们的用户,后端就会触发第二个请求,以提交适当的查询到安全设备。假设我们的用户从我们的服务器收到一个包含代码的短信。我们的 SPA 将等待并收集这个代码在特定的时间范围内(通常是 60 秒),并将其提交到后端的一个特定端点。是服务器验证这个代码。实际上,这就像有两个或多个密码,其验证是级联进行的。任何一步失败,整个操作都会被丢弃。

这里是这个过程的一个可视化:

图 5.8 – 我们 SPA 和服务器与 2FA 的简化视图

图 5.8 – 我们 SPA 和服务器与 2FA 的简化视图

从简化的工作流程中我们可以看出,使用双因素认证(2FA)验证用户的过程(如无密码和一次性密码方法)并不那么依赖于代码或某些特定的加密,而是依赖于巧妙的通信和数据隔离。数据和验证过程永远不会离开我们的服务器,甚至对于最终用户来说也不可见,即使打开我们的单页应用(SPA)的代码。从某种意义上说,你可以将这个工作流程视为 OpenID 或凭证认证的串联,随后是 OTP 的实现。

我们应用程序的主要职责仅仅是收集构成每一步的数据片段并将它们传递到服务器。在这个过程中,我们可以更改路由或更新界面,但这种实现非常简单,所以这里不会看到具体的代码(例如,你可以查看之前如何编程更改路由的方法)。

通常,双因素认证被认为是一种“更安全的方法”,但它并非没有缺点,并且可能并不适合每个应用程序。例如,如果你知道你的用户名和密码,但失去了你的二级设备(你的手机被盗、被黑等),会发生什么?使用这种方法的组织通常会提供一种恢复你身份的方式,通常伴随着昂贵的实现(想想银行和电话服务)。最终,这种方法确实在用户认证中增加了一层复杂性,并且随之而来的是另一个可能的故障点,如果处理不当,最终会导致用户非常沮丧。

接下来,我们将看到另一种正在获得关注的新兴身份验证模式:Web3 身份验证。

Web3 身份验证

在深入探讨这个主题之前,我们需要定义什么是Web3。关于定义的范围似乎存在一些混淆,因此,为了我们的目的,Web3 被认为互联网的下一迭代或进化,其中处理能力在去中心化和分布式服务器上完成,使用区块链技术。如今,这些技术的最知名和最受欢迎的应用是加密货币、去中心化自治组织、去中心化金融、玩赚游戏、分布式云存储等等。

区块链是由分布式计算机网络维护的账本。写入其中的任何内容都是不可变的,并且对网络上的任何人都是公开可见的。一些区块链是“智能”的,这意味着它们不仅可以包含数据,还可以运行应用程序,就像任何后端服务一样。连接到区块链的前端应用程序称为分布式应用程序(DApps),其中大部分是单页应用(SPAs)。对于这个任务,Vue 3 框架非常适合,如我们迄今为止所看到的。一个 DApp 必须连接到目标区块链网络的一部分后端服务器。这类服务器被称为节点。在某些情况下,DApp 可以直接与区块链交互。大多数,如果不是所有,区块链都使用加密货币来规范操作并奖励支持网络的贡献节点。加密货币逻辑上分配给一个唯一的区块链 ID,称为“钱包”。这些钱包在执行操作时通过使用公钥和私钥实现一些非常智能的加密技术来相互验证。用户可能有多个钱包。在区块链中,没有电子邮件或恢复丢失密钥的方法,每个钱包都是唯一的。

为了解决所有这些加密签名和验证问题,并使用户操作更便捷,存在一些特殊的浏览器插件,称为“数字钱包”,以及也实现网页浏览功能的移动应用程序钱包。这些应用程序保存凭证并在处理区块链时承担繁重的工作。当然,也有许多库可以在纯 JavaScript 中执行相同任务,但这超出了本书的范围。接下来我们将看到,在我们的单页应用(SPA)中,如何利用这些技术的力量来识别用户,甚至在访问我们的应用页面时自动识别。

我们将主要关注最大的智能区块链——以太坊网络,作为一个例子。具有更多或更少步骤的相同工作流程适用于使用不同 SDK 的其他网络,因此迁移或合并其他区块链并不遥远。基本概念工作流程如下:

在我们的 JavaScript 中导入一个库来连接到网络,无论是通过web3jswww.npmjs.com/package/web3)、ethjs还是window.ethereum

  • 使用ethereum对象,我们请求用户将他们的钱包连接到我们的网站并检索选定的钱包地址

  • 然后,我们的应用可以将钱包 ID(这是公开的)发送到我们的后端,并用作用户账户的唯一 ID

正如刚才提到的,我们将使用MetaMaskhttps://metamask.io/)注入的对象,因为它是最知名的浏览器钱包之一。在这种情况下,以下是请求当前用户钱包的代码:

ethereum
.request({ method: 'eth_requestAccounts' })
.then(
    result=>console.log(result[0]),
    err=>console.log(err)
)

就这些了!高亮行会提示MetaMask打开一个新窗口并请求用户允许将他们的钱包连接到您的 Web 应用,然后返回一个方便的承诺。如果批准,结果将是一个字符串数组,其中第一个位置是当前网络的钱包地址。如果拒绝,将触发一个错误。

小贴士

使用 MetaMask,您可以在浏览器中打开开发者工具并输入一行代码来测试它。

使用windows对象创建一个新的对象.solana。检查您目标区块链的文档,以熟悉每个实现的细节。

与每个区块链及其代码的交互超出了本书的范围,因此我们将限制自己通过钱包地址来识别用户。获得这种识别后,我们的应用逻辑将负责存储它们以供将来参考,就像用户 ID 一样。

还有第三方解决方案可以用于认证和与多个区块链交互,我们在实现自己的解决方案之前应该考虑它们。

摘要

在本章中,我们显著提高了我们的应用,并使用 Vue 路由创建了具有导航功能的 SPA。这是一个重要的概念,用于分割我们的应用和组织开发团队成员之间的工作。根据导航路径分割我们的应用使得开发和维护更容易接近且更有组织。我们还学习了几个认证标准模式,我们可以考虑用于我们的应用,涵盖了今天行业中使用的许多场景,从最基本的用户名和密码,到新的 Web3 DApps。我们还花时间了解了标准协议如 OAuth 的工作原理,以及 OTP,以及如何将这些协议作为第二因素认证的额外安全层实现。所有这些技能对于今天的网络应用标准都是相关且必要的。

在下一章中,我们将通过介绍渐进式 Web 应用PWAs)来继续扩展我们的技术知识。

复习问题

在本章中,我们涵盖了多个不同的主题,并介绍了新的概念。使用以下问题来巩固您刚刚学到的知识:

  • 在什么情况下使用单页面应用(SPA)比多页面应用(MPA)更好,反之亦然?

  • 使用路由器在我们的 SPA 中有哪些好处?请至少从你自己的分析中列出三个。

  • 你如何使用视图来定义应用程序的布局?

  • 你如何在 JavaScript 中访问传递给路由的参数和查询字符串?

  • 有哪些常见的标准模式用于用户认证?

  • 在 SPA 中认证用户时有哪些安全考虑?

第六章:渐进式网络应用程序

在本章中,我们将看到网络应用程序的下一个进化步骤:渐进式网络应用程序PWAs)。这个术语可能看起来描述不够充分,但它指的是一组技术,这些技术创造了通用概念,可以逐步或部分实现。其背后的基本思想是将网络应用程序从浏览器环境中解放出来,在任何类型的设备上实现,尽可能像原生应用程序一样行动和表现。这是通过在浏览器引擎中实现新的 API 以及桌面和移动设备上最流行的操作系统的集成来实现的。PWA 的起点当然是单页应用程序SPA)。

到本章结束时,我们将学习以下内容:

  • 什么是使 SPA 成为 PWA 的因素,以及涉及哪些技术

  • 如何手动实现响应式 SPA、清单文件、服务工作者、离线存储等

  • 什么是 服务 工作者

  • 如何使用 Vite 插件自动化创建 PWAs

  • 如何使用 Google Lighthouse 测试应用程序的准备工作

从前面的列表中,我们将专注于学习几种技术的脚手架,为以后使用它们打下基础,这些内容在第七章 数据流管理第八章 使用 Web Workers 的多线程中详细实现。到这些章节结束时,您将了解如何创建充分利用当今计算能力的 PWAs,使它们响应、可靠且性能良好。

技术要求

为了跟随本章内容,您需要查看存储库中的代码示例,该存储库位于github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter06。本节中的文本代码示例可能不足以创建一个可工作的示例,除非您从存储库中添加额外的代码。

查看以下视频以查看代码的实际应用:packt.link/SBZys

PWAs 或可安装的 SPA

PWA 不是一个单一的设置或技术,而是对网络应用程序的系统增强,以符合某些条件,无论是多页应用程序MPA)还是 SPA。然而,当这些技术应用于 SPA 时,它们真正闪耀并变得生动,为我们提供了融合在线/离线和桌面或网络之间界限的强大应用程序。这里使用的渐进式一词与我们之前在 Vue 框架中讨论的含义相同——对网络技术的增量应用。

浏览器和操作系统对 PWA 有某种特殊处理。它们可以与原生或桌面应用程序一起安装,并管理网络通信(发送、接收、缓存文件,甚至从服务器推送通知)。在此阶段,重要的是要注意,我们不再仅仅指的是桌面计算机,还包括移动设备,如平板电脑和手机,以及不同的操作系统。正因为这种多平台方法,如果目的是覆盖不同设备上的用户基础,就需要特别注意,例如使用特殊的和专用的 CSS 规则来适应不同大小的 UI(所谓的响应式应用程序),不同的图标和颜色,以与操作系统级别的本地用户自定义设置相融合(例如,浅色和深色模式)等。此外,PWA(就像 SPAs 一样)具有存储内容以供离线使用的功能,并且希望也能提供一些离线使用功能。为了实现所有这些,至少,一个 PWA 必须符合以下要求:

  • 网络应用程序必须通过安全连接(HTTPS)提供服务。

  • 应用程序必须提供一个清单文件。

  • 它必须提供和安装一个服务工作者。

当所有这些条件都满足时,浏览器或操作系统可能会提示用户“安装”应用程序。如果用户接受,则使用清单文件来定制应用程序的外观,以匹配本地操作系统(图标、名称、颜色等),并将在系统中所有其他应用程序旁边显示。当运行时,它将在自己的窗口中打开(如果选择这样做),位于网络浏览器之外,就像常规原生应用程序一样。内部,它仍然使用浏览器引擎通过 Web 技术运行,但目的是让用户感觉这是透明的,提供两者的最佳之处。可能的情况是,用户可能一直在使用 PWA 而不是常规应用程序,而自己却不知道。这种方法的成功案例包括星巴克、Trivago 和 Tinder(medium.com/@addyosmani/a-tinder-progressive-web-app-performance-case-study-78919d98ece0)。

这创造了大量优势,这些优势超过了创建一个适应不同安装场景的 Web 应用程序的复杂性:

  • 使用单一代码库即可在多个设备(桌面、移动设备等)和操作系统(Windows、Linux、macOS、Android、iOS 等)上安装应用程序

  • 支持从服务器推送通知、手动处理缓存、离线使用等功能

  • 它们与本地操作系统集成

  • 更新对用户来说是透明的,并且比传统应用程序(大部分情况下)要快得多

  • 开发 PWA 的成本远低于针对每个平台的等效针对性独立应用

  • 可以使用所有可用的 Web 技术、框架和库

  • 可以被搜索引擎索引,其分发和安装不依赖于专有应用商店

  • 它们是响应式的、安全的和快速的,并且只需一个链接就可以分享

  • 您可以使用标准 Web API 访问本地设备,例如本地文件系统和 USB 设备,使用硬件加速图形等

  • 一些专有应用商店允许你重新打包你的 PWA 并将其作为常规应用分发(例如,微软商店、亚马逊商店、安卓商店等)

有更多优势,但这些可能已经足够让我们为它们辩护。此外,向我们的单页应用(SPA)添加必要元素以使其成为渐进式 Web 应用(PWA)更容易;这可能会让 PWA 看起来像是应用的银弹;然而,还有一些需要注意的注意事项和缺点:

  • PWA 的性能良好,但在某些特定场景中始终会落后于原生应用。在较旧的硬件上也可能发生这种情况——它们可以运行,但性能可能会受到影响。

  • 苹果设备在采用某些 Web 技术或有意限制 PWA 方面稍微落后(例如,服务器推送通知)。

  • 需要投入更多精力来覆盖多设备上的不同用户体验场景(但略多于普通响应式 Web 应用)。

  • 一些应用商店不允许 PWA(特别是在撰写本文时,苹果应用商店)。此外,应用将无法从应用商店的曝光和流量中受益。

总体而言,优势远大于劣势。随着 Web 技术的不断发展,PWA 从中受益更多,变得更加普遍。现在,让我们更好地理解 PWA 是什么以及它能做什么,将我们的 SPA 升级为 PWA。

将 SPA 升级为 PWA

之前提到的第一个要求是在安全连接上提供服务。我们将在第十章“部署您的应用”中看到如何通过在我们的服务器上安装免费的 SSL 证书来实现这一点,使用Let’s Encrypt,在第十章部署您的应用。考虑到这一点,让我们看看如何满足其他要求。

清单文件

添加清单文件是将我们的应用程序转变为 PWA 的起点。这不仅仅是一个包含已知字段的 JSON 文件,它指导浏览器或操作系统如何将应用程序安装在桌面或移动设备上。此文件必须出现在我们的index.html文件的head部分中,尽管它可以任意命名,但惯例是使用名称manifest.jsonapp.webmanifest。官方规范建议使用.webmanifest扩展名,但同时也明确指出,只要文件正确接收,名称实际上并不重要,为了简便起见,这里使用manifest.json

<link rel="manifest" href="/manifest.json">

从前面的代码中可以看出,文件放置在我们的应用程序的根目录中,并且rel属性必须是manifest。我们清单文件中的字段属性可以以任何顺序出现,并且根据上述规范,所有这些都被认为是可选的。然而,一些平台确实期望一组最小属性,我们将它们视为必需的。常见的做法还要求其他属性,我们将它们分类为推荐项,最后,规范中的一些属性在应用商店、社交媒体等地方经常被用来展示或描述应用程序,因此我们将这些称为描述性字段。这种分类不是规范的一部分,但有助于指导你的实现。以下是最常见和有用的属性列表:

分类 属性
必需项
short_name 当没有足够空间显示应用程序的全名时使用的简称。在移动设备上,它通常用于图标名称。
name 应用程序的全名。

| icons | 一个对象数组,每个对象代表一个在不同上下文中使用的独立图标。每个对象至少有两个属性:

  • src: 图片的路径

  • sizes: 包含图像尺寸的字符串

|

start_url 应用程序应启动的 URL,由开发者设置。

| display | 表示应用程序如何呈现的字符串:

  • fullscreen: 全屏显示,但显示浏览器 UI。

  • standalone: 与fullscreen类似,但没有浏览器控制。在桌面设备上,窗口控制仍会显示。

  • minimal-ui: 与standalone类似,但具有基本导航以前进和后退、打印、分享等。

  • browser: 应用程序在默认浏览器中打开。

|

推荐项
theme_color
background_color
orientation
lang
描述
shortcuts
description

| screenshots | 一个对象数组,具有以下字段: |

  • src: 图片的 URL

  • type: 图片的 MIME 类型

  • sizes: 包含图片尺寸的字符串

|

表 6.1 – 清单字段

在实践中,我建议为每个 PWA 完成必要的和推荐的字段,而描述性字段则根据您应用程序的上下文按需使用。此外,研究您的目标平台以获取标准规范之外的额外支持字段。

按照前面的表格,以下是一个manifest.json文件的示例:

{
    "short_name":"PWA Example",
    "name": "Chapter 6: Progressive Web Application Example",
    "start_url":"/",
    "display": "standalone",
    "theme_color":"#2979FF",
    "background_color":"#000",
    "orientation": "portrait"
}

如您所见,创建清单文件并不需要太多额外的工作,并且很容易添加到我们的 SPA 中。

测试您的清单

一旦您创建了清单文件并将其链接到您的index.html文件,您就可以使用浏览器中的开发者工具来检查它是否已正确加载。例如,当使用 Google Chrome 时,在应用程序菜单中,我们可以看到示例文件已被正确加载:

图 6.1 – Google Chrome 中的开发者工具,显示清单文件

图 6.1 – Google Chrome 中的开发者工具,显示清单文件

然而,还有一个与应用程序安装相关的话题需要我们审查:用户何时以及如何知道该 Web 应用程序可以安装?这就是安装提示发挥作用的地方,我们将在下一节中看到。

安装提示

每个平台(移动或桌面)都有自己确定符合安装标准 PWA 可以安装的方法。这可能会在一段时间后触发一个通知,让用户接受安装,或者只提供一个用户界面来执行此操作。在移动设备上,已安装的 PWA 将被放置在主屏幕上,与其他原生应用程序并列,而在桌面上,它可能被放置在浏览器内和/或主菜单中。此外,在像 Android 这样的移动操作系统中,将自动创建一个带有主题和背景颜色以及清单中提供的应用程序图标的启动画面。无论 PWA 可以如何和何时安装,重要的是要知道,它只能通过用户的同意和主动操作来完成。在没有用户批准的情况下,我们不能从代码中自动触发安装。

基本安装流程如下:

  1. 当平台检测到我们的应用程序可以安装时,它将在窗口对象中触发一个名为 beforeinstallprompt 的事件。我们可以缓存这个事件,以便稍后从我们的应用程序中触发提示。

  2. 用户可以通过平台 UI 或通过我们提供的 PWA 方法(如按钮)启动安装。

  3. 平台将提示用户接受或拒绝安装。

  4. 如果用户接受,它将安装 PWA 并触发另一个名为 appinstalled 的事件。

这是一个相当简单的流程。然而,beforeinstallprompt 事件只会触发一次,所以如果用户拒绝安装,我们需要等待浏览器再次触发该事件。

现在我们已经了解了事情的工作原理,是时候看看代码中的实现了。假设在我们的 Vue 3 组件模板中,我们有以下元素:

<p v-show="_install_ready && !_app_installed">
   Install this app
   <button @click="installPWA()">Install</button>
</p>
<p v-show="_app_installed">
   Progressive Web Application installed
</p>

如您所见,我们有两个段落将根据 _install_ready_app_installed 这两个布尔值反应变量显示。第一个将在 PWA 准备安装时出现,并提供一个按钮通过 installPWA() 函数触发安装。第二个将在安装完成后显示。

我们在脚本部分中的代码也是相当直接的:

import { onMounted, ref, onBeforeUnmount } from 'vue'
const
    _install_ready=ref(false),
    _install_prompt=ref(null),
    _app_installed=ref(false)
// Detect PWA installable
onMounted(()=>{
    window.addEventListener("beforeinstallprompt",savePrompt)
    window.addEventListener("appinstalled",handleAppInstalled)})
function savePrompt(event){
    event.preventDefault(); // Prevents mobile prompt
    // Save reference to the event, to activate it later
    _install_prompt.value=event;
    // Notify UI that the application can be installed
    _install_ready.value=true;
}
function installPWA(){
    // Trigger the installation prompt
    if(_install_prompt.value){
        _install_prompt.value.prompt()
    }
}
function handleAppInstalled(){
    _install_prompt.value=null;
    _app_installed.value=true;
}

在前面的代码中,当我们的组件被挂载到页面上时,我们注册了两个监听器,一个用于管理和缓存安装提示,另一个用于检测应用程序何时已安装。为了保持代码简单,一些部分已被省略,但完整的组件(包括样式)可以在 GitHub 仓库中找到。

尽管前面的例子相当简单,但有一些众所周知的模式可以促进或向最终用户介绍安装选项。它们都依赖于相同的逻辑,即捕获事件并在稍后显示触发元素。实现很简单,更多与设计有关,而不是编码模式,所以我们在这里只展示原型图:

  • 简单的安装按钮(如我们的示例应用程序所示):

图 6.2 – 简单安装按钮

图 6.2 – 简单安装按钮

  • 菜单 安装 按钮一放置在主导航中:

图 6.3 – 主菜单安装按钮

图 6.3 – 主菜单安装按钮

  • 一个叠加通知:

图 6.4 – 叠加通知

图 6.4 – 叠加通知

  • 一个在顶部叠加的元素,例如安装横幅(在页眉之前或视口底部):

图 6.5 – 安装提示横幅

图 6.5 – 安装提示横幅

一旦应用程序已安装,我们希望防止不断提示用户进行安装。在这种情况下,建议我们在 localStorage、cookie、indexeDB 上保存离线标志,或将我们应用程序的起始 URL 标记为特定位置。我们将在 第七章 数据流管理 中看到离线持久存储选项。现在,是时候看看使我们的 SPA 成为真正的 PWA 的最后一项:服务工作者。

服务工作者

服务工作者是一个在单独的线程上运行的 JavaScript 脚本,作为应用程序的后台进程。它充当网络的代理,拦截所有调用,并根据预定的策略提供页面和数据。

我们可以有多个服务工作者,因为每个服务工作者都负责其作用域。作用域定义为服务工作者源文件所在的目录(URL 路径)。因此,放置在应用程序根目录的服务工作者将处理整个 SPA/PWA。

服务工作者无需用户干预即可安装,因此即使用户没有安装 PWA,也可以使用它们。它们有一个定义良好的生命周期(见 https://web.dev/service-worker-lifecycle/),为每个完成状态触发事件。首先,服务工作者需要先 注册,然后它变为 激活,最终我们也可以 注销 它。一旦服务工作者被激活,它将不会控制应用通信,直到下次访问网站。

编程服务工作者的最常见策略如下:

  • 仅提供缓存

  • 仅提供网络

  • 尝试首先提供缓存,然后回退到网络

  • 尝试首先提供网络,然后回退到缓存

  • 首先提供缓存,然后更新缓存

在考虑缓存和离线策略时,我们需要考虑我们的应用程序运行所需的哪些文件和资产将几乎不会改变,以便进行缓存。我们还需要确定永远不会被缓存的路径。

要使用服务工作者,我们在 main.js 文件中通过以下行进行注册:

if(navigator.serviceWorker){
   navigator.serviceWorker.register("/service_worker.js")
}

在这些行中,我们首先测试当前浏览器是否有使用服务工作者的能力,如果有,我们就注册它。正如我们所见,我们将工作者放置在根目录。对于这个例子,我们将手动对所有网络调用使用缓存优先、网络回退策略:

// Set strategy, cache first, then network
const CACHE_NAME="MyCache"
self.addEventListener("fetch", event=>{
    // Intercepts the event to respond
    event.respondWith((async ()=>{
    // Try to find the request in the cache
    const found=await caches.match(event.request);
    if(found){
        return found;
    }else{
        // Not cached fount, fall back to the network
        const response=await fetch(event.request);
        // Open the cache
        const cache=await caches.open(CACHE_NAME);
        // Place the network response in the cache
        cache.put(event.request, response.clone());
        // Return the response
        return response;
    }
  })())
})

之前的代码几乎完全基于在developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Offline_Service_workers提供的Mozilla 开发者网络文档中的示例。代码中的注释将帮助您理解实现策略的逻辑。然而,如果不说得太多,使用服务工作者可用的基本 API 可能会很繁琐。相反,使用框架或库来处理它们并实现更复杂的策略会更方便。今天的标准是使用由Googlehttps://developer.chrome.com/docs/workbox/)制作的Workbox。我们不会直接使用它,而是通过下一节将要看到的 Vite 插件来使用它。

到目前为止,我们看到的所有代码,我们的 PWA 已经准备好工作并安装。如果我们运行示例应用程序在开发服务器上,我们会注意到它可以安装。使用浏览器 UI 或我们的安装按钮,我们将收到以下提示:

图 6.6 – 本地主机上的 PWA 安装提示

图 6.6 – 本地主机上的 PWA 安装提示

将我们的 SPA 手动调整为 PWA 并不复杂,但这确实需要一些手动工作。然而,有了我们选择的工具,我们可以做得更好。有一种更简单的方法可以直接将清单文件和服务工作者作为工作流程的一部分生成并注入我们的 SPA:使用 Vite 插件。

Vite-PWA 插件

在 Vite 插件生态系统中,有一个出色的零配置 Vite-PWA 插件(vite-pwa-org.netlify.app/)。开箱即用,它为我们提供了许多功能,而无需太多手动工作。我们使用以下终端命令将插件作为开发依赖项安装:

$ npm install –-save-dev vite-plugin-pwa

一旦安装,我们必须在 Vite 配置中注册它。修改vite.config.js文件以匹配以下内容:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
    registerType: "autoUpdate",
    injectRegister: 'auto',
    devOptions: { enabled:true },
    workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}']
    },
    includeAssets:
              ['fonts/*.ttf','images/*.png','css/*.css'],
    manifest: {
        "short_name": "PWA Example",
        "name": "Chapter 6 - Progressive Web Application Example",
        "start_url": "/",
        "display": "standalone",
        "theme_color": "#333333",
        "background_color": "#000000",
        "orientation": "portrait",
        "icons": [
           {
            "src": "/images/chapter_6_icon_192x192.png",
            "sizes": "192x192",
            "type": "image/png"
           },
           {
            "src": "/images/chapter_6_icon.png",
            "sizes": "512x512",
            "type": "image/png"
           },
           {
            "src": "/images/chapter_6_icon.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose":"maskable"
           }
         ],
         "prefer_related_applications": false
    }
  })]
})

使用此插件,我们将生成服务工作者和 Web 清单的负担卸载给打包器。这是必要的,因为随着每次生产构建,Vite 都会根据我们在上一章中讨论的按需加载组件的策略为每个脚本生成不同的文件名。

在前面的示例中,我们将一个包含一些合理选项的对象传递给 VitePWA() 插件,用于自动创建和注入我们的清单和工作者。如果我们需要更精细地控制创建的服务工作者策略以及网络清单,可以使用“注入模式”使用插件,并为我们提供服务工作者的基础文件。在这种情况下,脚本将注入构建过程中生成的文件。在底层,此插件使用 workbox 字段。深入探讨不同实现和策略的细节超出了本书的范围,但读者应查阅有关 Vite-PWA 插件和 Workbox 的文档,以了解特定上下文和使用案例。

使用 Google Lighthouse 测试您的 PWA 评分

基于 Chrome 的浏览器与开发者工具一起提供了一个名为 Lighthouse 的工具,专门用于测试和评分网页以及 PWA 的就绪状态。要访问此工具,在浏览器中打开您的应用程序后,请按照以下步骤操作:

  1. 打开开发者工具(在 Windows/Linux 上按 F12,在 Mac 上按 Fn + F12,或通过浏览器菜单)。

  2. 点击位于更右侧的 Lighthouse 菜单。

  3. 选择 移动桌面,并确保已勾选 渐进式网络应用 类别。

  4. 在工具的右上角点击 分析页面加载

开发者工具应该看起来像这样:

图 6.7 – Lighthouse 工具

图 6.7 – Lighthouse 工具

工具将运行一系列测试,每个不同的类别都会显示一个评分,以及一个详细的项目列表,显示这些项目是已通过还是未通过。如果我们的应用程序不符合 PWA 的标准,标记为红色的项目将告诉我们原因以及如何修复它们:

图 6.8 – Lighthouse 中第六章代码示例的评分

图 6.8 – Lighthouse 中第六章代码示例的评分

我们的示例代码应用程序完全符合 PWA 的标准,并且所有测试都轻松通过。当然,对于较小的应用程序来说,这更容易实现。在实践中,任何超过 90 分的评分都是非常好的。

摘要

在本章中,我们从一个简单的单页面应用(SPA)开始,学习了如何手动以及通过 Vite 插件将其升级为渐进式网络应用(PWA)。用户可以在他们的平台上安装 PWA 并与它们交互,即使它们没有连接到互联网。PWA 相比纯网络应用提供了许多优势。我们还看到了如何使用 Lighthouse 在几个行业标准类别中衡量和评估我们的应用程序。随着本章的结束,我们使用网络技术构建应用程序的增量构建也随之结束,从此我们将专注于内部性能和效率的模式和模型。

复习问题

为了帮助您巩固本章学到的概念,请回答以下问题:

  • SPA 和 PWA 之间的区别是什么?

  • PWA 的优势是什么?

  • 一个 Web 应用要被视为 PWA,必须遵守的基本三个要求是什么?

  • 我们可以使用哪些工具来逐步准备我们的应用成为 PWA?

  • 什么是服务工作者,以及有哪些策略可以使用它?

  • 什么是 Web 清单,为什么它是必要的?

第七章:数据流管理

在前面的章节中,我们专注于理解 Vue 3 框架,并为创建 Web 应用程序提供上下文。在本章中,我们将关注我们的组件之间如何相互通信以及共享信息,以使我们的应用程序得以运行。我们之前已经简要地触及了这个话题,但现在我们将通过同时实现这些模式来深入探讨一些模式。应用适当的信息工作流程是一项重要的技能,它可以使应用程序成功或失败。特别是,我们将看到以下方法和代码示例:

  • 父子兄弟通信

  • 使用单例观察者模式实现消息总线

  • 使用可组合组件实现基本反应式状态

  • 使用功能强大的 Pinia 反应式存储实现集中式数据仓库

  • 审查浏览器提供的替代方案以共享和存储信息

  • 在行动中实验反应性、可组合组件和代理模式

如我们之前所做的那样,我们将一次构建一个概念,逐步增加复杂性。到本章结束时,你将看到清晰的实现示例,这样你就可以根据你应用程序的需求决定何时应用每一个。其中一些更适合小型应用程序,而另一些则更适合大型、复杂的应用程序。你将更好地准备控制你应用程序的信息工作流程。

技术要求

本章将探讨概念,并将模式应用于控制组件之间的通信和信息流。你应该能够跟随本文本中展示的代码,但要更好地理解和体验上下文,你将受益于检查本章的完整应用程序代码,该代码可在本书的存储库中找到:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter07

如果你正在启动一个新的项目,只需遵循第三章中设置工作项目的说明,如设置一个 工作项目

查看以下视频以查看代码在行动:packt.link/ZKTBJ

组件的基本通信

我们之前已经看到,父组件及其子组件有相当简单直接的方式进行通信。父组件通过props将数据传递给子组件,而子组件通过触发事件(emits)来吸引父组件的注意。就像函数中参数和参数的可比性一样,props通过复制接收简单数据,并通过引用接收复杂类型(对象、数组等)。然后,我们可以传递一个包含成员函数的普通对象,从父组件传递给子组件,并让子组件运行这些函数来访问父组件的数据。尽管“这样做”是可行的,但这更像是一种暗模式或反模式,因为它隐藏了关系,使得理解数据流变得困难。在组件树中向上传递数据的正确方式是通过事件(emits)。话虽如此,我们必须指出,子组件之间是“无知”的,这意味着它们没有直接相互通信的方式。我们可以传递一个反应性变量,让每个涉及的组件访问它,这当然是一个可行的替代方案,如果不是一个干净的方案。在某些情况下,这可以提供一个简单的解决方案,但同样,它可能导致隐藏的副作用。

为了以干净的方式管理数据的工作流程,我们有几种遵循良好实践和设计模式的替代方案。作为一个一般规则和原则,声明变量的组件是它的所有者,并且应该是操作它的组件。考虑到这一点,在最基本的通信中,信息需要由父组件维护和处理,并在子组件之间共享。我们可以利用 Vue 的反应性系统来传播信息。关键在于只有父组件会操作它。让我们通过一个例子来看看这在实践中是如何工作的,实现一个小型的简单应用程序,如图图 7**.1所示:

图 7.1 – 直接基本通信和反应性

图 7.1 – 直接基本通信和反应性

在这个应用程序中,父组件有三个直接子组件,并与它们共享一个反应性计数器。所有组件都显示一个带有计数器值的标签,并有一个按钮来触发增加...但是只有父组件执行实际的数据操作。Vue 处理反应性,这意味着当父组件修改值时,子组件也会接收到它们。这很简单——让我们看看实现这一点的关键部分:

/basic/ParentBasic.vue

<script setup>
import {ref} from "vue"
import ChildComponent from "./Child.vue"
const _counter = ref(0);                                  //1
function incrementCounter() {                             //2
   _counter.value++;
}
</script>
<template>
<div>
   <strong>Counter </strong>
   <span>{{ _counter }}</span>
   <button @click="incrementCounter()">                   //3
       Increment
   </button>
</div>
<section>
<ChildComponent title="Child component 1"
  :counter="_counter" @increment="incrementCounter()">    //4
</ChildComponent>
<ChildComponent title="Child component 2"
  :counter="_counter" @increment="incrementCounter()">
</ChildComponent>
<ChildComponent title="Child component 3"
  :counter="_counter"
  @increment="incrementCounter()"></ChildComponent>
</section>
</template>

在这个组件中,我们声明了一个 _counter 响应式变量(第 //1 行)和一个 incrementCounter() 函数来操作其值(第 //2 行)。我们像在第 //3 行看到的那样,在父按钮的点击事件中触发这个函数。现在,为了看到这个模式的实现,我们只需将我们的响应式 _counter 变量作为属性传递给每个子组件,并将我们的 incrementCounter() 函数链接到每个子组件的增量事件(第 //4 行)。足够简单——让我们看看每个子组件是如何实现其部分的:

/basic/Child.vue

<script setup>
const
    $props=defineProps(['counter', 'title']),         //1
    $emit=defineEmits(['increment'])
function incrementCounter(){$emit("increment")}      //2
</script>
<template>
<h3>{{$props.title}}</h3>
<span class="badge">{{$props.counter}}</span>        //3
<button @click="incrementCounter()">                 //4
    Increment
</button>
</template>

我们子组件的实现同样简单。我们首先在第 //1 行定义了接收计数器变量的属性,还定义了我们的 increment 自定义事件,以便我们可以通知父组件。为了做到这一点,我们在第 //2 行创建了一个函数。在我们的模板中,我们在第 //3 行显示我们的属性,并在第 //4 行触发我们的增量函数。请注意,我们的子组件并没有修改计数器。这是父组件的责任,所以我们尊重这个模式。

这是一个我们将非常频繁使用的模式,但它确实有一些限制。例如,当数据需要达到父组件、兄弟组件或孙组件时会发生什么?我们是否需要向上和向下传递数据,即使组件没有使用它?我们可以这样做,但同样,这很混乱,冗长,并不是最好的方法。我们有更好的工具来做这件事。

第四章 “使用组件的用户界面组合”中,我们看到了父组件可以通过使用 provideinject 将数据和功能传递给树中的任何子组件。由于那里提供的例子相当全面,我们在这里不再重复。我鼓励您回顾如何创建和注入提供。我们不再重复,让我们继续我们的议程中的下一个项目,以便在组件树中的任何地方共享信息:实现一个 消息总线(也称为 事件总线)。

使用单例和观察者模式实现事件总线

消息总线是我们在 第二章 “软件设计原则与模式”中看到的 观察者模式 的一个实现。为了简要回顾主要概念,我们试图创建一个对象或结构,它可以接收和发出事件,我们的组件可以订阅并对其做出反应。这个模式独立于组件树结构运行,因此任何 组件和服务 都可以加以利用。从视觉上,我们可以将这种关系表示如下:

图 7.2 – 组件与消息总线关系的简化视图

图 7.2 – 组件与消息总线关系的简化视图

从前面的图中,我们可以立即看出消息总线对每个组件都是平等对待的。每个组件将其一个或多个方法订阅到特定的事件,同时也有相同的发布事件的权限。这使得它非常灵活,因为事件也可以传输数据。

让我们通过一个实现示例将这些概念转化为代码。我们首先创建一个服务,使用单例模式,为我们提供一个消息总线。在我们的例子中,我们只是包装了mitt包,它提供了这个功能(参见 https://github.com/developit/mitt#usage)。

可以使用以下终端命令在我们的应用程序中安装mitt包:

$ npm install mitt

我们的服务看起来如下:

/services/MessageBus.js

import mitt from "mitt"
const messageBus = mitt()
export default messageBus

这将给我们一个事件发射器和调度器的单例,即我们的消息总线。在我们的例子中,我们将通过它发送文本消息,每个接收组件将显示它。我们的组件将看起来像这样:

/bus/Child.vue

<script setup>
import messageBus from '../services/MessageBus';         //1
import {ref, onMounted, onBeforeUnmount} from 'vue';
const
   $props=defineProps(['title']),
   message=ref("")                                       //2
    onMounted(()=>{
        messageBus.on("message", showMessage)})                 //3
    onBeforeUnmount(()=>{
        messageBus.off("message",showMessage)})
    function showMessage(value){                               //4
        message.value=value;}
    function sendMessage(){                                    //5
        messageBus.emit("message",`Sent by ${$props.title}`)}
</script>
<template>
    <h4>{{$props.title}}</h4>
    <strong>Received: </strong>
    <div>{{message}}</div>
    <button @click="sendMessage()">Send message</button>       //6
</template>

在这个例子中,我们从第//1行开始导入我们的messageBus对象(检查你的实现中的正确路径),并声明一个初始化为空字符串的message响应式变量。注意我们如何也导入并使用组件的生命周期中的onMounted()onBeforeUnmount()方法,从第//3行开始订阅和取消订阅message事件。我们注册的函数在第//4行,它从事件接收一个值,我们将其传递给我们的内部变量以在模板中显示。我们还需要一个函数来发布事件以通知他人,这个函数可以在第//5行找到。在这种情况下,我们发布组件的标题。这个函数由按钮触发,如第//6行所示。

如果你用一些额外的最小化样式运行应用程序示例,这段代码将产生类似以下的结果:

图 7.3 – 通过观察者模式实现的数据共享

图 7.3 – 通过观察者模式实现的数据共享

这种处理数据工作流程的方法在它所做的事情上相当有效,但也有局限性。事件是通知多个组件同时触发动作的好方法,而不管它们在组织树中的位置。当一个应用程序有多个子系统需要响应应用程序状态变化时,这是一个很好的模式。然而,当主要处理应用程序数据时,这种模式有一个重要的缺点:每个组件都保留信息的内部副本。这使得内存处理效率相当低,因为数据的传播意味着在我们的应用程序的不同部分进行复制。有些情况下这是必要的或期望的,但绝对不是每个情况都如此。如果我们有 50、100 或 1,000 个组件订阅了同一个事件,它们都会拥有相同的数据副本吗?如果每个组件需要独立于其他组件处理和可能修改数据,这可以正常工作...但如果我们想更好地利用 Vue 的响应式并提高我们的内存处理,我们需要使用不同的方法。这就是我们接下来将要看到的基本响应式应用程序状态。

实现基本响应式状态

如前所述,使用消息总线共享数据的一个缺点是相同数据的多个副本,包括处理事件的额外开销。相反,我们可以利用 Vue 的响应式引擎,特别是 reactive() 辅助构造函数来创建一个单一实体来保存我们的应用程序状态。就像之前一样,我们可以用单例模式包装这个响应式对象,以便在组件和纯 JavaScript 函数、对象和类之间共享。值得一提的是,这是 Vue 3 和新组合 API 的一个重大优势。

从示例代码中,我们将得到一个基本的例子,如下所示:

图 7.4 – 用于状态管理的共享响应式对象

图 7.4 – 用于状态管理的共享响应式对象

正如您在前面的屏幕截图中所看到的,在这个例子中,状态是由这个示例的所有组件共享(或访问)的。任何子组件都可以修改其任何值,并且变化会立即在整个应用程序中反映出来。与之前的例子相比,这种模式的实现既简单又直接。让我们首先通过创建一个包含我们的响应式状态的服务来深入了解它:

/service/SimpleState.js

import {reactive} from "vue"                       //1
const _state=reactive({counter: 0})                //2
function useState(){return _state;}                //3
export default useState;

如果这段代码看起来很简单,那是因为它确实很简单。我们创建一个 JavaScript 文件,并从 Vue 中导入 reactive 构造函数(行 //1)。然后,我们声明一个带有初始对象的响应式常量(行 //2)。这将是我们通过 useState() 函数返回的应用程序状态,该函数遵循组合组件的模式(行 //3)。这个函数是我们的模块导出。

利用这种集中式状态也非常简单,正如我们在这里可以看到的:

/simple/ChildSimple.vue

<script setup>
    import useState from "../../services/SimpleState"          //1
    const $state=useState()
</script>
<template>
    <strong>State: </strong><br>
    <pre>{{$state}}</pre>                                      //2
    <div>
    <button @click="$state.counter++">Increment</button>       //3
    <button @click="$state.counter--">Decrement</button>
    </div>
</template>

我们通过导入 useState 工厂函数开始我们的组件,并使用它声明一个响应式常量(行 //1)。我们就像使用任何其他变量一样在我们的模板中使用这个响应式变量(行 //2),同样地,我们可以直接访问对象的成员字段来修改它们,就像修改任何其他对象一样,正如你在行 //3 中所看到的。完成这些后,正如预期的那样,一旦组件修改了任何值,这个变化就会在整个应用程序中传播。

这种简单的方法非常有用,适用于从小型到中型甚至更大的应用程序。它有许多好处,例如以下这些:

  • 实现和理解都很简单。

  • 它利用了 Vue 的响应性系统。

  • 它是灵活的,因为我们可以在初始化后添加新的响应式成员。

  • 它建立了一个单一的真实来源,意味着我们的状态是应用程序数据的集中存储库。没有必要保持内部或私有变量同步。

如果考虑我们至今为止所看到的选项,这无疑是一个巨大的进步。然而,在某些情况下,这些选项可能还不够:

  • 如果在函数修改其值之前其他组件已经进行了修改,会发生什么?

  • 这种方法不允许我们处理需要在每个组件中实现的计算数据

  • 调试可能很困难,因为没有针对开发者工具的具体支持

正如之前提到的,这种方法适合简单的需求。对于更稳健的方法,我们将深入研究 Vue 项目提供的官方中央状态管理解决方案:Pinia

使用 Pinia 实现一个强大的响应式存储

中央状态管理不仅仅是一个属于 Vue 的概念,同样的模式也可以在其他库和框架中找到。就像我们基本的响应式示例一样,Pinia 是一个中央状态管理工具,它为我们提供了一个单一的真实来源,这意味着其值的变化会以响应式的方式传播到整个应用程序的任何使用位置。这种状态在应用程序的组件之间是共享的,并使我们能够通过一个定义良好的接口访问 Vue 提供的完整范围的响应式工具。如果我们首先构建一个示例来展示使用它的结果,那么理解 Pinia 会更容易。运行代码示例将给我们类似以下的结果:

图 7.5 – 使用 Pinia 的中央状态管理

图 7.5 – 使用 Pinia 的中央状态管理

在这个例子中,我们构建了一个商店,它不仅暴露了响应式状态,还实现了计算值。作为一个官方支持的项目,Pinia 还公开了 Options 和 Composition APIs 的实现。要使用 Pinia,我们首先需要在项目的根目录中用以下命令将其包含到我们的项目中:

$ npm install pinia

安装完成后,我们应该创建一个存储并将其附加到我们的应用程序中,以便所有组件都可以使用。存储就像我们上一节中的响应式单例,意味着一个将具有要在我们的应用程序中共享的响应式字段的对象,以及相关的业务逻辑。因此,每个存储将包含以下项目:data、称为getters的计算属性和称为actions的方法。我们将其定义在自己的文件中作为一个模块,定义每个项目。使用选项 API,存储将看起来像这样:

选项 API 基本存储

import { defineStore } from 'pinia';                    //1
const useCounterStore = defineStore('counter', {        //2
  state: () => {return {count: 0, in_range: false}},    //3
  getters: {
    doubleCount: (state) => {                           //4
      if(state.count>=0){
            return state.count *2;
      }else{
       return 0
      }
  }, inRange: (state)=>return state.count>=0},
  actions: {                                            //5
    increment(){this.count++},
    decrement(){this.count--;}
  },
})
export {useCounterStore}

在这个存储中,我们首先从Pinia包中导入defineStore构造函数(第//1行),并使用它来创建存储(第//2行)。此构造函数接收两个参数:

  • 存储的名称,作为一个字符串。这必须在存储中是唯一的,因为它在内部用作 ID。

  • 具有以下成员的存储定义的对象:

    • state(第//3行):这是一个返回对象的函数。请注意,我们没有声明它为响应式。Pinia 将负责这一点。

    • getters(第//4行):这是一个对象,其成员将成为计算属性。每个成员将状态作为第一个参数接收,作为一个响应式对象。

    • actions(第//5行):这同样是一个对象,其成员是函数,可以访问和修改状态,但必须通过使用this关键字来访问它。

使用选项 API 定义存储是理解其组成部分的好方法。然而,gettersactions之间的语法变化可能会令人困惑,并导致无意中的错误,因为一个通过参数访问状态,而另一个通过使用this引用来访问。然而,如果我们花点时间看看构造函数,我们可以看到gettersactions类似于计算属性和组件方法(函数)。有了这个想法,让我们看看如何使用组合 API 重写这个存储,这是我们将在示例代码中使用的:

/stores/counter.js

//Composition API
import {ref,computed} from 'vue'                          //1
import {defineStore} from 'pinia'
const useCounterStore=defineStore('counter',()=>{         //2
    const
        count = ref(0),                                   //3
        in_range=ref(true),
        doubleCount = computed(() => {                    //4
             if(count.value>=0){
                 return count.value *2;
             }else{
                 return 0
        }}),
        inRange = computed(()=>return count.value>=0);
    function increment() {count.value++}                  //5
    function decrement(){count.value--;}
    return {                                              //6
        count, doubleCount, inRange,
        increment, decrement
    }
})

使用组合 API 使得存储看起来更像我们的应用程序的其他部分,因为我们采用了相同的方法。我们首先从 Vue 中导入所需的构造函数,就像使用相同 API 的组件一样,在第 //1 行。这次,当我们使用 defineStore 构造函数时,我们传递一个函数(或箭头函数),该函数将返回构成存储的响应式属性和方法。您可以在第 //2 行中看到这一点,然后在第 //6 行的 return 对象。正如您所预期的,在该函数内部,我们声明我们的响应式属性(第 //3 行)和计算属性(第 //4 行),以及方法(第 //5 行)。响应式属性将成为响应式属性。计算属性将成为我们的获取器,函数将成为动作。到目前为止,这种语法没有我们习惯使用的 <script setup> 标签的语法糖,但函数体是相同的(心态)方法,我们与组件一起使用。

现在我们有了存储(并且我们可以有多个),在我们实际上可以使用它之前,我们需要在我们的应用程序中实现 Pinia。为此,在我们的 main.js 文件中,包括以下突出显示的行:

./main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

此步骤是启用整个应用程序的 Pinia 引擎所必需的。现在剩下的就是导入我们组件中需要的存储。例如,如果您查看示例存储库,您将找到此文件:

/pinia/ChildPinia.vue

<script setup>
import { useCounterStore } from '../../stores/counter';   //1
const $store=useCounterStore()                            //2
</script>
<template>
    <h4>Child component</h4>
    <code :class="{'red': !$store.in_range}">             //3
        {{$store}}
    </code>
    <button @click="$store.increment()">                  //4
        Increment</button>
    <button @click="$store.decrement()"
        :disabled="!$store.in_range">Decrement
    </button>
</template>
<style scoped>
.red{color: red;}
</style>

我们在第 //1 行导入存储构造函数,并在第 //2 行创建我们的响应式对象。要使用它们的值或执行它们的方法,我们直接使用点(.)符号,就像它们是普通对象一样。注意在第 //3 行我们如何访问 in_range 的值,稍后,在第 //4 行,我们执行 increment() 函数。正如我们所期望的,任何对存储值的修改都将自动同步到我们的应用程序中。

与之前的方法不同,Pinia 存储和状态是可追踪的,并显示在开发者工具中。对于中等大小以上的应用程序,当需要集中状态时,使用 Pinia 几乎是必需的。

Pinia 是 Vue 3 的官方解决方案,用于集中状态管理,取代了 Vue 2 分支的 Vuex。在实践中,它们实现了相同的功能,但前者有一些优势,这使得 Vue 团队选择了它并赞助它。深入审查不是我们目的的主题,但以下是一个简短的变更或 Pinia 的优势列表:

  • 对存储的不同方法。在 Pinia 中,每个存储都是其自己的模块,并且它们都是动态的。Vuex 则有一个单一的存储,模块以分区形式存在。

  • Pinia 的语法和 API 比 Vuex 简单且不那么冗长。

  • 更好的 TypeScript 支持,以及 IDE 自动完成功能的可发现性。

  • 支持选项和组合 API。

  • 更好地利用 Vue 的新响应式模型。

  • 开发者工具支持。

  • 一个用于扩展 Pinia 的插件架构。

从 Vuex 到 Pinia 的转变使得对使用它的项目进行一步替换升级变得困难。然而,Pinia 团队在官方网站上发布了一个很好的迁移指南,您可以通过以下链接找到:pinia.vuejs.org/cookbook/migration-vuex.html。对于 Pinia 中所有可用选项的完整参考,我建议阅读官方文档在 pinia.vuejs.org

使用 Pinia,我们已经看到了控制组件(和服务!)之间数据流的最常见和相关的模式,但这些并非我们唯一可用的选项。我们将看到现代网络浏览器提供的默认存储库,以及如何使用它们。

浏览器数据存储 – 会话、本地和 IndexedDB

浏览器提供其他功能来本地存储数据,这些数据不仅可以被任何其他组件读取,也可以被同一页面上运行的任何脚本读取。我们不会讨论 cookies,但新提供作为键值存储的方法:SessionStoreLocalStore。但这些并非唯一选项,因为浏览器还提供了一个名为 IndexedDB 的数据库,它提供了更多的存储空间,并且可以在我们应用程序窗口的不同线程中访问到。我们将在 第八章 中更详细地了解,即 使用 Web Workers 的多线程,而在这里,我们首先将专注于理解每个的基本概念和限制。

SessionStorage 是为每个页面来源创建的一个只读对象。它只存储可以通过简单接口访问和检索的字符串数据。这些数据仅在 浏览器标签页 的持续时间存在,并且在刷新期间持续存在。这种用途的一个明显例子是持久化表单数据。该对象附加到 window 对象(window.sessionStorage)上,并且可以被页面上的任何脚本访问。

LocalStorage 在功能和数据存储方面与 SessionStorage 类似。它具有相同的接口,并且也限制在页面的相同来源。主要区别在于它超越了页面的生命周期,并且在同一来源的所有打开页面上共享。网站和应用可以使用它来存储数据并在同一浏览器的多个会话中检索数据。

SessionStorageLocalStorage 具有相同的接口:

  • .setItem(item_name, item_data): 在这里,item_name 是一个字符串,它唯一标识 item_data,它也是一个字符串

  • .getItem(item_name): 获取存储在 item-_name 下的字符串数据,如果未找到则返回 null

  • .removeItem(item_name): 通过 item_name 从存储中删除数据

  • .clear(): 从存储中删除所有数据

前面的方法代表了这两个存储的 API 端点的全部。很简单——我们可以将数据序列化以记录在这些存储中。例如,要存储一个 JSON 对象,我们会使用以下方法(我们可以省略window对象引用,因为它被认为是一个全局对象):

localStorage.setItem("MyData", JSON.stringify({…});

然后,为了检索它,我们会使用以下方法:

let data=localStorage.getItem("MyData")
if(data){
   data=JSON.parse(data);
}

两个存储库都有一些限制和一些注意事项:

  • 浏览器之间没有为每个存储库可以存储多少字符设置标准限制。字符串以 UTF-16 格式存储,因此每个字符可能占用 2 个字节或更多(见en.wikipedia.org/wiki/UTF-16),这使得计算变得困难。规范建议每个存储至少 5 MB。

  • 当这些存储空间耗尽时,一些浏览器会崩溃页面,而另一些则会提示用户同意扩展存储空间。

  • 存储和检索数据的访问是顺序的,可能会阻塞渲染过程,使页面/应用程序看起来无响应...但这只发生在长时间操作中。

  • 对于sessionStorage,复制标签页也会复制存储。相反,对于localStorage,两个标签页将访问相同的信息。

  • 无论是 localStorage 还是 sessionStorage,都不是响应式的,也不提供监听值变化的监听器。

前面的限制绝不是威胁或建议不要使用它们的理由。相反,它们是使用它们的边界和限制,因为所有数据都存储在用户的浏览器本地,并且没有任何东西被发送回服务器(如 cookies 所做的那样)。

与这些 Web 存储对象相比,IndexedDB是一个完全不同的系统。它是一个事务型数据库的完整实现,在唯一键下存储 JavaScript 对象。我们可以打开多个数据库,与它们建立连接,并定义模式,所有操作都是异步的,因此没有应用程序阻塞。大小限制也已扩展,软限制为 50 MB。如果数据库增长超过这个限制,用户将被提示同意扩展它,并给予更多空间。理论上,根据每个浏览器的实现,它可以占用与可用空间一样多的空间。实际上,每个浏览器都有自己与本地操作系统协商可用空间的方式,因此无法给出适用于所有情况的硬性数字。

好奇心

Chrome 引擎提供了一个标志,可以在没有限制的情况下构建IndexedDB引擎,除了可用的磁盘空间。这个标志也可以在混合框架如 NW.js 或从源构建浏览器时激活。

IndexedDB存在一个主要问题,那就是它的 API 复杂且繁琐,因此很少有应用程序会直接访问它。相反,由于IndexedDB非常灵活且快速,有许多库在其之上创建了自己的数据库实现,或者提供了一个更简单的接口(例如使用外观模式)。这些库和框架的精选列表可以在Mozilla 开发者网络文档中找到(https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API#see_also)。在我们的第八章“使用 Web Workers 的多线程”的实现示例中,我们将使用这些库之一。为了本章的目的,只需记住每个浏览器都为你提供了一个强大的数据库,你可以通过各种模式和途径访问它。

尝试使用反应性和代理模式

是时候在第二章“软件设计原则与模式”中看到的模式的光照下,将本章学到的知识付诸实践了,通过一个小型的实验项目。我们希望创建一个选项,使sessionStorage数据表现得像一个反应式中央状态管理器,这样我们就可以在组件中使用它。这种方法的可能用途包括在刷新期间持久化用户输入的数据、通知组件数据变化等等。

由于SessionStorage没有提供我们可以监听的 API,我们的方法将是使用装饰器模式创建一个代理处理程序,以匹配并保持存储中的值与内部和私有反应属性同步。我们将将其封装在单例中,并使用中央状态管理器方法在应用程序中共享它。让我们首先创建我们的核心服务:

/services/sessionStorage.js

import { reactive } from 'vue';
let handler = {                                                  //1
    props: reactive({}),                                         //2
    get(target, prop, receiver) {                                //3
        let value = target[prop]
        if (value instanceof Function) {
            return (...args) => {
                return targetprop
            }
        } else {
            value = target.getItem(prop)
            if (value) {
                this.props[prop] = value;
            }
            return this.props[prop]
        }
    },
    set(target, prop, value) {                                   //4
        target.setItem(prop, value)
        this.props[prop] = value
        return true;
    }
}
const Decorator= new Proxy(window.sessionStorage, handler);      //5
function useSessionStorage(){                                    //6
     return Decorator;
}
export { useSessionStorage }

在这个service模块中,我们将使用Proxy对象的本地 JavaScript 实现来捕获对window.sessionStorage对象 API 的特定调用。在 JavaScript 中,Proxy 对象的使用相当高级,所以我建议你查看 MDN 上的文档:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy。我们首先从 Vue 导入reactive()构造函数,然后创建一个名为handler的普通对象(行//1),它将充当我们的代理/装饰器。这个对象将被放置以拦截对原始sessionStorage的调用。在它内部,我们声明一个prop属性作为反应式(行//2),并用一个空对象初始化它。这个对象将与存储同步。然后,我们创建两个陷阱(或拦截器):一个用于获取或读取操作(行//3),另一个用于设置或写入操作(行//4)。

get() 函数接收三个参数,其中我们只使用两个。目标指的是 sessionStorage,而 prop 是请求的方法或属性的名称。因为 prop 可以是任一者,所以我们用 if 语句测试它是否是函数,如果是,我们返回一个接收所有参数并返回带有它们的原始函数调用的函数。如果不是函数,则从存储库中检索项目,测试它是否是我们内部反应性属性的一部分,并最终返回值。这确保了我们的内部 props 对象与装饰器实现之前创建的值保持同步。

set() 函数比较简单,因为我们只需取传递的值并将其存储在两个地方:我们的内部属性和存储库中。

在我们的处理程序准备好后,在行 //5 中,我们使用原生 JavaScript 构造函数创建一个 Decorator 代理对象,并在行 //6 中提供一个 useSessionStorage() 函数,以便我们可以将其作为单例导出。

在创建我们的装饰器后,现在我们可以在组件中使用它,与 Vue 3 中的标准方法相同:

/session_storage/ChildSession.vue

<script setup>
    import {useSessionStorage} from "../../services/SessionStorage"
    const $sessionStorage = useSessionStorage()
</script>
<template>
    <strong>Child Component</strong>
    Counter: {{ $sessionStorage.counter }}
</template>

注意,现在我们可以将此对象用作 Pinia 存储或简单的反应性对象,并且 sessionStorage 的值将始终同步并持久化,即使我们刷新页面。要查看完整示例,请检查 GitHub 仓库中代码示例的实现。当你运行它时,你会看到一个类似这样的部分:

图 7.6 – 我们的反应式 $sessionStorage 对象示例

图 7.6 – 我们的反应式 $sessionStorage 对象示例

在这个例子中,我们还实现了一个带有输入元素的父组件。当你修改值时,它会自动同步并反映在子组件中,同时也在 sessionStorage 中。如果你打开浏览器的开发者工具并导航到 Web Storage 部分,你会看到这种反映。以下是 Chrome 在 Ubuntu 系统上的截图:

图 7.7 – 示例中的会话存储项

图 7.7 – 示例中的会话存储项

与此同时,我们为会话存储实现了这种模式,我们也可以通过一些修改将其应用于本地存储。

摘要

在本章中,我们详细介绍了控制我们组件、服务和现代浏览器提供的持久存储之间数据流的不同方法和方法。我们还花时间通过实验会话存储和装饰器模式来整合我们的知识,创建一个反应式/持久中央状态。我们花了时间区分方法,并看到了每种方法的实现代码。所有这些新技能都用于 Vue 3 应用程序的开发中。

在下一章中,我们将探讨通过使用高级 JavaScript 工具(如 web workers)来提高我们应用程序的性能。

复习问题

使用这些问题来复习你在本章中学到的内容:

  • 我们有哪些方法可以用来在兄弟组件之间共享数据?

  • 消息/事件总线是什么,它何时最有用?

  • 中心状态管理方法是什么,我们如何实现它?

  • 会话存储和本地存储之间有什么区别?

  • 我们如何查看在会话或本地存储中存储了哪些信息?

第八章:使用 Web Workers 进行多线程

在本章中,我们将涵盖一些重要主题,这些主题将极大地提高 Web 应用程序的性能,尤其是单页应用程序。首先,我们将学习网站和 JavaScript 是如何工作的,以及如何使用web workers来利用我们的应用程序处理能力、数据访问和网络通信。然后,我们将介绍两种新的概念设计模式,并将它们与我们在之前看到的其他模式一起在一个示例应用程序中实现。在此基础上,我们还将介绍两个库,它们将简化我们的网络通信以及在我们的 IndexedDB 中处理持久数据库。我们还将实现一个简单的 Node.js 服务器,以提供反馈并测试我们在高度解耦的架构中的工作,在这种架构中,我们的前端和后端服务通过 HTTP 协议使用标准 API 进行通信。

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

  • WebWorkers

  • 商业和调度器模式

  • WebWorker内部的网络通信

  • 浏览器持久嵌入式数据库 – IndexedDB

  • 如何构建简单的 Node.js API 服务器进行测试

本章中的概念可以被认为是“高级”的,但我们将把它们浓缩成可理解的片段,并立即实现。到本章结束时,你将拥有如何在 Web 应用程序中实现多线程的扎实知识,以及一个参考框架来扩展和简化复杂浏览器 API 的使用。

技术要求

本章不会对我们的应用程序增加额外的要求。然而,我们只会看到代码的相关部分,因此要查看整个应用程序的工作情况,你应该参考书中第八章使用 Web Workers 进行多线程,在 GitHub 仓库github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter08中的代码示例。

查看以下视频以查看代码的实际应用:packt.link/D4EHt

Web Workers 简介

JavaScript 是一种单线程语言,这意味着它本身没有在单独的线程中生成进程的方法。这使得网络浏览器在网页上运行 JavaScript 时,与其他进程在同一个线程上运行,这直接影响了页面的性能,尤其是渲染过程,它负责在屏幕上呈现页面。浏览器会付出相当大的努力来优化所有这些移动部件的性能,以使页面响应、性能良好、快速且高效。然而,有一些任务网络应用程序必须在 JavaScript 中执行,这些任务很重,并且可能“阻塞渲染”。这意味着浏览器必须关注代码的结果,并使用所有资源来完成正在运行的函数,然后才能专注于渲染(将页面呈现到屏幕上)。如果你在开始操作后(在某些情况下,你的鼠标甚至可能冻结)发现网页上的某个进程使网站看起来“无响应”或“卡顿”,这可能是其中的一个原因。

如果我们在现代浏览器中打开开发者工具,我们可以访问一些性能工具来分析网页的行为以及每个进程步骤花费多少时间。例如,这里是对共享链接中 Firefox for Linux 上 YouTube 首次加载的快速查看:

图 8.1 – 使用开发者工具看到的 YouTube 首次加载的性能

图 8.1 – 使用开发者工具看到的 YouTube 首次加载的性能

上一张截图已经放大到页面的实际处理过程,显示了在第一次渲染之前发生的事情,也就是说,在用户实际上可以在屏幕上看到某样东西之前。这体现在第一行,截图,在这个案例中,第一个可见元素出现在时间轴的末尾(#1)。第二行显示了主要的父进程一直在忙于做什么,如果你注意看,第一个部分(#2)全部都是关于处理 JavaScript 的。渲染器进程,用黑色条带突出显示并显示(#3),甚至在 JavaScript 运行之前都无法开始。当它运行时,它会在屏幕上绘制页面,你就可以看到从 #1 出来的可见内容。这给出了浏览器在屏幕绘制(称为“帧”)之间每个周期所做工作的近似概念。浏览器试图尽可能多地产生每秒帧数fps)。为了保持流畅的 60 fps,它需要在大约 16.67 毫秒或更短的时间内完成所有这些处理。最佳情况下,你的 JavaScript 进程应该在半数时间内解决,以保持用户的流畅体验。考虑到这一点,如果你的 JavaScript 耗时超过这个时间,会发生什么呢?很简单,渲染过程会被推迟,fps 会下降,用户可能会体验到卡顿,甚至可能发生用户界面(UI)冻结的情况。根据你的网络应用程序,这可能会成为一个重要的问题。

你可能会说,“等等,为什么我们不将重任务异步化?这不会解决我们的问题吗?” 答案是:可能和不一定。当你声明一个异步函数时,这只意味着执行将被推迟到顺序代码处理完成的时间点。很可能是将异步代码推到最后或顺序代码执行之后,但随后它将像往常一样顺序执行。如果在那时渲染过程发生,你可能会感觉到性能提升,但如果不是,如果异步函数执行时间更长(因为它将影响下一次渲染),你将面临同样的问题。如果我们把所有函数都改为异步,我们可能会得到与所有内容都是顺序执行相同的结果,加上异步调用的开销:

图 8.2 – 异步代码执行表示,在顺序代码执行之后移动(1)

图 8.2 – 异步代码执行表示,在顺序代码执行之后移动(1)

那么,如果异步操作不能完全解决性能问题,我们该如何解决它?在所有可能的优化之外,你还应该考虑将一种技术放在替代方案列表的顶部:Web Workers API。

Web Workers 是执行在其自身进程(或线程,取决于实现方式)中的 JavaScript 脚本;因此,它们不会影响渲染发生的父进程。浏览器 API 提供了一个相对简单但有效的通信方式:消息系统。这些消息只能传递可序列化的数据。父进程和每个 Web Worker 在自己的环境和内存边界内操作,因此它们不能共享引用或函数,这就是为什么所有在它们之间传递的数据都必须是可序列化的,因为它们被复制到每个进程中。虽然这看起来可能像是一个缺点,但实际上,如果正确使用,它实际上是一个优点,正如我们很快就会看到的。这个架构的另一个缺点是,Web Workers 无法访问文档对象模型DOM)或 Window 对象及其任何服务。然而,它们确实可以访问网络和 IndexedDB。这为你的前端应用程序的架构设计开辟了丰富的机会,因为你可以轻松地分离表示层和业务层。

图 8.3 – 使用 Web Workers 的 Vue 应用程序分层表示,包含后台进程

图 8.3 – 使用 Web Workers 的 Vue 应用程序分层表示,包含后台进程

如前图所示,我们可以实例化多个 Web Worker 来表示我们应用程序中不同类型的层(业务数据通信等)。虽然 Web Worker 可以从父进程随意启动和终止,但这两个动作都是计算密集型的,因此建议一旦创建 Web Worker,就保持其在应用程序运行期间活跃,并在需要时访问。还建议不要过度使用这种资源,创建“太多”的 Web Worker,因为每个 Web Worker 都是一个不同的进程,拥有自己的资源预留。没有明确的定义来界定“太多”,因此建议谨慎行事。根据我的经验,当 Web Worker 的数量保持在个位数时,即使是低功耗设备也应该能够以出色的性能处理你的应用程序。与许多其他事物一样,好事可能做得太多,这也适用于 Web Worker。

现在我们已经了解了什么是 Web Worker 以及它们能为我们做什么,让我们来看看如何在纯 JavaScript 中实现它们,然后是如何使用 Vite 来实现。

实现 Web Worker

在纯 JavaScript 中创建 Web Worker 非常简单直接。window.Worker接收一个参数,即脚本文件的路径。例如,考虑到我们的 Web Worker 包含在my_worker.js文件中,我们可以这样创建它:

if(window.Worker){
    let my_worker=new Worker("my_worker.js")
    ...
}

足够简单,如果构造函数存在于window对象中,那么我们只需直接访问构造函数来创建一个新的 worker。新创建的 worker 再次暴露了一个简单的 API:

  • .postMessage(message): 这将消息发送到 Web Worker。它可以是被序列化的任何数据类型(基本数据类型、数组、对象等)。

  • .onmessage(callback(event)): 当 worker 向父进程发送消息时,将触发此事件。接收的事件有一个.data字段,其中包含 worker 传递的消息/数据。

  • .onerror(callback(event)): 当 worker 中发生错误时,将触发此事件,并且它将包含以下字段:

    • .filename: 生成错误的脚本文件名。

    • .lineno: 发生错误的行号。

    • .message: 包含错误描述的字符串。

这个消息系统允许我们进行原本可能非常复杂的进程间通信(IPC)。由于实现了它,我们的代码应该看起来如下:

let my_worker=new Worker("my_worker.js")
my_worker.onmessage=event=>{
    // process message here
    console.log(event.data)
}
my_worker.onerror=err=>{
    //process error here
}
my_worker.postMessage("Hello from parent process");

为了完成这个任务,我们现在需要实现my_worker.js脚本。对于这个例子,它可以像这样简单:

./my_worker.js

self.onmesssage=event=>{
    console.log(event.data)
})
setTimeout(()=>{
    self.postMessage("Hello from the worker")
},3000)

我们的示例工作线程非常简单。它将接收到的数据打印到控制台,并在激活后 3 秒钟向父进程发送一条消息。请注意,我们正在使用 self 保留字。当在函数内部访问 API 时需要它,因为它引用的是工作线程本身。这就是为什么它需要在 setTimeout 回调内部使用。在根级别,它是可选的,因此你可以像我们的示例中那样写 self.onmessage,或者直接写 onmessage

Web Workers 可以通过 self.importScript() 方法或直接使用 importScript() 来实例化其他工作线程并导入其他脚本。此方法接收一个包含脚本文件名的字符串作为参数。这与我们在主应用程序中的服务和组件中使用 import 语句的方式类似。

当使用 Vite 时,正如我们用来打包 Vue 应用程序那样,我们可以通过使用后缀来提供一种替代方法来导入和创建一个工作线程。例如,在我们的 main.js 脚本中添加以下内容:

./main.js

import MyWorker from "my_worker.js?worker"
const _myWorker=new MyWorker()
_myWorker.postMessage("Hi there!")
_myWorker.onmessage=(event)=>{...}

当使用 worker 后缀表示法时,Vite 会将实现包装在一个构造函数中,我们可以使用它来实例化我们的工作线程。这种方式使得处理工作线程更类似于使用我们应用程序中的任何其他类,因为我们可以使用相同的方法将其包含在我们的应用程序中,这就是我们将在示例中使用的语法。此外,Vite 将处理我们工作线程中的脚本,因此我们可以使用更熟悉的语法来导入资源(import ... from ...),而不是使用原生的 self.importScript()

关于 Web Workers 有更多内容需要学习。就我们的目的而言,这已经足够,我们将使用这些内容。如果您想了解更多信息,请参阅 Mozilla 开发者网络上的文档(developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers)。

通过这些构建块,我们现在可以通过应用设计模式来实现一个健壮且易于处理的对我们的 Web Workers 的连接。在我们这样做之前,我们需要从概念上学习两个更多的模式:业务代理模式和调度器模式。

业务代理模式

此模式通过提供一个具有良好定义和简单(更)接口的单一点访问,用于隐藏从客户端或表示层访问业务服务或业务层的复杂性。它可以在一定程度上被视为我们在 第二章**,软件设计原则和模式 中看到的代理和装饰器模式的变体或演变,但应用于更大逻辑尺度的架构层之间。它通常涉及以下至少实体:

  • 一个 业务代理 实体,作为客户端进入所有可用服务的单一入口点

  • 一个 业务查找或路由器 实体,其功能是将传入请求的执行路由到适当的服务

  • 提供公共接口(直接或通过代理模式)的业务服务

为了我们的目的,这个模式可以用以下图表来表示:

图 8.4 – 业务代理模式的表示

图 8.4 – 业务代理模式的表示

这种模式可以应用于多个架构级别。在我们特定的案例中,我们希望将这种设计应用于我们的 Web Workers 应用程序。我们将把父进程视为我们的表示层,把 Web Worker 视为我们的业务层。在父进程(或主进程)中,我们将拥有我们的 Vue 应用程序,一如既往地主要关注提供卓越的用户体验。然后,工作者将负责为我们提供访问服务,无论是本地的(如 IndexedDB 的情况),还是远程的,封装与我们的服务器和附加服务以及任何额外的计算密集型函数的通信。这种关注点的分离具有许多优点,不仅从性能角度来看,而且从整个应用程序的设计和实现来看。

在我们实现本章的代码之前,我们需要看到另一个我们将要实现的模式,因为我们只能在进程间传递可序列化的数据,而不能像纯业务代理模式所建议的那样执行函数调用。我们将扩展命令模式的想法,并使用所谓的分发器模式。

分发器模式

我们之前已经看到,无论是我们的父进程还是 Web Worker 进程都可以通过向对方发送消息来启动通信。只要定义了适当的监听器(onmessage),任何一个都可以接收并响应这些事件。在分发器模式中,这些消息包含与事件相关的信息,例如数据。区分这种设计模式的关键因素是事件消息必须在线程间发布,并在到达时进行调度。当然,这种调度也可以包括某些任务或函数的“立即执行”。

这个模式的实现相当简单,你可能认为它类似于我们在第二章“软件设计原则与模式”中看到的命令模式,因此我们不会再看到这个模式。相反,我们将利用线程间通信、调度和与数据相关的事件这些概念来创建我们与 Web Workers 协同工作的解决方案。

建立与 Web Worker 的通信管道

我们现在已经看到了我们想要应用于我们的 Vue 应用程序 Web Workers 通信实现的关键概念。这个模型可以在应用程序之间重复使用,并根据需要改进。作为一个一般行动计划,这是我们将会使用迄今为止看到的模式来构建的:

  • 我们将在 Vue 应用程序中创建一个网络工作器,遵循业务代表模式,以实现单一点访问。

  • 每条消息都会引发一个事件,用于处理(父-工作器或工作器-父)并包含命令和有效负载数据,以及调度信息,如调度器模式中所述。

如此简单,前面描述的架构使我们能够建立如这里所示的工作流程:

图 8.5 – 与网络工作器的通信工作流程实现

图 8.5 – 与网络工作器的通信工作流程实现

现在我们已经有了理论基础和广泛的了解,我们将要创建的内容,是时候进入代码编写阶段了。我们将关注实现之前提到的模型的代码中最相关的部分。要查看整个应用程序代码,请查看 GitHub 仓库中的完整源代码。让我们首先创建一个服务,它将成为客户端应用程序的入口点:

./services/WebWorker.js

import WebWorker from "../webworker/index.js?worker"
const _worker = new WebWorker()                               //1
const service = {
    queue:{},                                                 //2
    request(command, payload = {}) {                          //3
        return new Promise((resolve, reject) => {             //4
        let message = {
            id: crypto.randomUUID(),
            command,
            payload
        }
        service.queue[message.id]={resolve, reject}           //5
        _worker.postMessage(message);                         //6
        })
    },
    processMessage(data) {
        let id=data.id
        if(data.success){
            service.queue[id].resolve(data.payload)           //7
        }else{
            service.queue[id].reject(data.payload)
        }
        delete service.queue[id];                             //8
    }
}
_worker.onmessage = (event) => {
    service.processMessage(event.data);                      //9
}
export default service;                                      //10

这种实现简单而有效。它很好地帮助我们理解这些模式是如何工作的。我们首先使用 Vite 的特殊后缀 worker 导入网络工作器构造函数,然后在第 //1 行创建实例引用。像往常一样,这个服务将是一个单例,所以我们将其创建为一个 JavaScript 对象,我们将在第 //10 行将其导出。该服务只有三个成员:

  • queue:在第 //2 行定义,是一个我们将使用它来存储对网络工作器预定调用的字典,使用唯一标识符。每个条目将保存承诺(resolvereject)的解析方法的引用。

  • request() 方法:在这里的第 //3 行定义,将由其他服务和组件(“客户端”)用来从网络工作器请求任务。它总是返回一个承诺(第 //4 行)。传递给网络工作器的消息封装了作为参数接收的 commandpayload 以及一个唯一的标识符。我们在 queue 中保存 resolve()reject() 方法的引用(第 //5 行),最后,使用网络工作器的原生消息方法,在第 //6 行发布消息。

  • processMessage() 方法:这个方法接收网络工作器提交的数据,根据标识符和 .success 属性(布尔值)中传递的操作结果,我们访问 queue 并使用 resolve()reject() 函数来解析或拒绝承诺(第 //7 行)。最后,我们在第 //8 行从 queue 中移除引用。

在此文件的最后一步是将传入的消息直接从工作器链接到第 9 行的service.processMessage()。到现在为止,可能已经很清楚,我们已经就消息的结构和回复做出了一些决定。消息有三个组成部分:idcommandpayload。回复也有三个元素:idsuccesspayload。在客户端,我们选择使用承诺来操作,因为它们不会“超时”。

客户端解决后,现在是我们开始工作在网页工作器脚本的时候了。在webworker目录中创建以下index.js文件:

./webworker/index.js

import testService from "./services/test"
const services=[testService]                                //1
function sendRequest(id, success=false, payload={}){
    self.postMessage({id, success, payload})                //2
}
self.onmessage=(event)=>{                                   //3
    const data=event.data;
    services.forEach(service=>{                             //4
        if(service[data.command]){                          //5
        servicedata.command                 //6
                .then(result=>{
                    sendRequest(data.id, true, result)      //7
                }, err=>{
                    sendRequest(data.id, false, err)
            })
        }
    })
}

网页工作器甚至更短,我们还在每个底层服务实现的接口上做出了一些决定:它们的方法必须返回一个承诺。让我们看看代码,找出原因。

我们从第 1 行开始,导入testService(我们稍后会创建它),并将其包含在一个服务数组中。这将使得通过导入并仅将它们包含在这个数组中来添加新服务变得更容易(这可能是通向插件架构的垫脚石,但现在我们将保持简单)。然后我们定义一个全局的sendRequest()函数,它将向父进程发送一个带有三个字段:idsuccesspayload的编码消息,正如客户端在定义中所期望的那样。这就是第 2 行发生的事情。

在第 3 行,我们定义了onmessage事件处理器来处理传入的消息。当收到一个消息时,我们遍历我们的services数组以找到匹配的命令(第 4 行),当我们找到时(第 5 行),我们通过 JSON 实用程序解析它后,将有效载荷作为参数传递给函数执行(第 6 行)。然后,通过承诺的解决或拒绝,我们在第 7 行将适当的结果传输给客户端。这段简短的代码充当了业务委托和调度器。最后,让我们看看testService是如何工作的:

./webworker/services/test.js

const service={
   test(){
      return new Promise((resolve, reject)=>{
         setTimeout(()=>{
            resolve("Worker alive and working!")
         }, 3000)
      })
   }
}
export default service;

如你所见,这个测试服务除了返回一个承诺并设置一个 3 秒后解决的定时器外,没有做太多其他的事情。这个延迟是人为的,因为否则回复将是立即的。如果你运行示例应用程序,当你点击发送请求按钮时,你将看到消息在 3 秒后从等待...变为工作器活跃并工作!,正如预期的那样:

图 8.6 – 测试应用程序向工作器发送命令并显示结果

图 8.6 – 测试应用程序向工作器发送命令并显示结果

为了实现这一点,在我们的App.vue组件中,我们导入我们的网页工作器服务,并使用命令字符串作为服务中要执行的函数的名称发送我们的请求。对于这个例子,添加以下代码:

import webWorker from "./services/WebWorker.js"
webWorker.request("test").then(data=>{...}, err=>{...})

这些简单的代码行用于创建和管理 Web Worker,为您的应用程序提供了相当大的计算能力和性能提升。现在我们的基础已经打好,是时候用我们的服务 Worker 做些更有意义的事情了。让我们让它访问我们的本地数据库和网络。

在 Web Worker 中使用 DexieJS 访问 IndexedDB

IndexedDB 是一个非常强大的键值数据库;然而,其原生实现提供的 API 相当难以处理。实际建议是不要直接使用它,而是通过框架或库来与之交互。数据库引擎速度快且非常灵活,因此许多库在其基础上构建,并重新创建了原本不存在的功能和特性。一些库甚至模仿 SQL 和基于文档的数据库。以下是一些可用且免费使用的库:

  • DexieJS (dexie.org/):一个非常快且文档齐全的库,实现了基于 NoSQL 的文档数据库。

  • PouchDB (pouchdb.com/):一个模仿 Apache CouchDB 功能的数据库,并提供与远程服务器的内置同步。

  • RxDB (rxdb.info/):这是一个实现了响应式模型的数据库。它还支持与 CouchDB 的复制。

  • IDB (www.npmjs.com/package/idb):这是在 IndexedDB API 之上实现的一个轻量级包装实现,进行了一些改进以提高其可用性。

根据您对本地存储的要求,这些或其他选项将非常适合您。我们将使用 DexieJS 作为此示例,因为它有很好的文档记录,并且在批量操作方面速度惊人。我们将扩展我们之前的示例,创建一个单组件迷你应用程序来存储、检索、删除和查看笔记。这涵盖了非常基础的创建、读取、更新和删除CRUD)操作。当您运行示例代码时,它看起来可能像这样:

图 8.7 – 单组件 CRUD 示例

图 8.7 – 单组件 CRUD 示例

在此示例中,您可以创建新的笔记,查看之前保存的内容(这将基于域名持久化),选择它们以查看文本,还可以删除它们。所有操作都将由 Web Worker 解决。让我们使用npm将 Dexie 包含到我们的应用程序中:

$ npm install dexie

接下来,让我们创建我们的示例组件应用程序:

/src/components/DbNotes.vue

<script setup>
import webWorker from "../services/WebWorker"                 //1
import { ref } from "vue"
const _notes=ref([]),_note=ref({}),_selected=ref({})          //2
loadNotes()
function saveNote(){                                          //3
   if(_note.value.title && _note.value.text){
      webWorker
         .request("addNote", JSON.stringify(_note.value))
         .then(id=>{loadNotes()},err=>{...})
         .finally(()=>{_note.value={}})
   }
}
function deleteNote(id){                                      //4
   WebWorker
      .request("deleteNote", {id})
      .finally(()=>{loadNotes()})
}
function openNote(note){_selected.value=note;}                //5
function loadNotes(){                                         //6
   webWorker
      .request("getNotes",[])
      .then(data=>{_notes.value=data;},
            ()=>{_notes.value=]})
}
</script>
<template>
<div>
   <section>
      <h3>New note</h3>
      <input type="text"
            v-model="_note.title"
            placeholder="Title">
      <textarea v-model="_note.text"
            placeholder="Note text..."></textarea>
      <button @click="saveNote()">Save</button>
   </section>
   <section>
      <h3>Notes</h3>
      <div v-for="n in _notes" :key="n.id">
         <a @click="openNote(n)">{{ n.title }}</a>
         <a @click="deleteNote(n.id)">[X]</a>
      </div>
   </section>
   <section>
      <h3>Selected note</h3>
      <strong>{{ _selected.title }}</strong>
      <p>{{ _selected.text }}</p>
   </section>
</div>
</template>

前面的文件已经去除了样式和其他布局元素,这样我们就可以专注于实现我们所学习操作的代码的活跃部分。我们首先在第//1行导入我们的服务类以处理 web worker,并在第//2行创建一些内部反应性变量。我们将使用_notes来保存从数据库中提取的完整笔记列表,_note作为创建新笔记的占位符,以及_selected来显示从列表中点击的笔记。你可以在每个函数(第//3//6行)中找到 CRUD 操作,并且你会注意到除了处理 UI 反应性元素之外,它们非常相似。它们只是收集创建对 web worker 请求所需的信息,然后应用结果。然而,请注意,在saveNote()函数中,当需要传递描述我们新笔记的对象时,我们正在将 Vue 反应值序列化。这是因为 Vue 用于处理反应性的代理实现不可序列化,除非我们创建一个纯对象的副本或应用其他类似技术来提取值,否则 web worker 通信将失败并抛出错误。确保数据对象作为可克隆对象提供的简单方法是将其转换为字符串,如我们的代码中所示使用JSON.stringify(_note.value)(你也可以直接创建一个克隆,使用JSON.parse(JSON.stringify(_note.value)))。你需要记住信息将如何发送,以便在 web worker 接收端得到适当的处理。现在当我们看到dbService.js在 worker 中时,这一点将变得明显:

./src/webworker/services/dbService.js

import Dexie from "dexie"
const db=new Dexie("Notes")                                 //1
db.version(1).stores({notes: "++id,title"});                //2
const service={
addNote(note={}){                                           //3
  return new Promise(async (resolve, reject)=>{
    try{
      let result_id=await db.notes.add(JSON.parse(note))    //4
      resolve({id:result_id})
    }catch(err){reject({})}
})},
getNotes(){
  return new Promise(async (resolve, reject)=>{
    try{
      let result=await db.notes.toArray();                  //5
      resolve(result)
    }catch{reject([])}
})},
deleteNote({id}){
  return new Promise(async (resolve, reject)=>{
    try{
      await db.notes.delete(id)                             //6
      resolve({})
    }catch{reject({})}
})}}
export default service;

要使用 Dexie,我们首先在第//1行导入构造函数,并创建一个名为Notes的新数据库。在我们实际使用它之前,我们需要定义版本和表格/集合的简单模式,包括将要索引的字段。这就是第//2行发生的事情,在那里我们定义了具有两个索引字段idtitlenotes集合。这些索引字段以字符串形式传递,字段名以逗号分隔。我们还为id字段包含了一个双加号作为前缀。这使得该字段由数据库自动生成并随着每个新记录自动递增。

下一个重要的函数addNote(),将记录添加到notes集合中。由于我们在组件中通过将对象序列化为字符串传递数据,在第//4行,我们需要解析字符串以重新组合对象。

getNotes()函数中,我们只是从集合中检索所有元素,并使用 Dexie 提供的toArray()方法将其转换为 JavaScript 数组(第//5行)。这样,我们可以直接将其作为我们的结果返回以解决承诺。

关于这段代码的最后一句话是关于 deleteNote() 方法的:在第 //6 行,我们没有捕获异步操作的结果。这是因为这个操作不返回可用的值。在这种情况下,这个操作总是会解析,除非数据库引擎错误中断执行。

重要的是要记住,Web Worker 上的错误不会影响父进程,并且该进程中的任何操作都不会受到影响。

现在我们已经设置了服务,是时候稍微修改一下 web worker 的索引文件了。添加以下行:

./src/webworker/index.js

import dbService from "./services/dbService";
const services=[dbService, testService];

对于此文件,无需进行其他更改。正如我们所看到的,在 web worker 上实现 CRUD 操作并不需要太多。尽管这些可以在父进程中完成,并且会有轻微的进程间通信的代价,但性能上的好处是相当可观的,并且值得付出努力。特别是如果我们的应用程序包括应该作为后台进程的内容,比如与远程服务器的同步,这些应该由 web worker 完成。让我们看看接下来我们如何从 worker 访问网络并消费 表示状态转换 APIRESTful API)。

使用 Web Worker 消费 RESTful API

今天在 Web 开发中,网络 API 最常见的应用之一是通过实现 RESTful API。这是一个无状态的协议,每个通信都代表了在目的地所需操作的类型。Web 上使用的 HTTP 协议为这种类型的 API 提供了完美的匹配,因为每个网络调用都暴露了一个标识所需操作类型的方法:

  • GET 操作检索数据和文件

  • PUT 操作更新数据

  • POST 操作在服务器上创建新数据

  • DELETE 操作在服务器上擦除数据

很容易看出这些方法与 CRUD 操作相匹配,因此通过进行适当的网络调用,服务器就知道如何处理在正确端点接收到的数据。在端点之间发送的数据格式有许多标准。特别是其中最常见的一种是 JSON 格式,我们在 JavaScript 中非常方便地使用它。

在浏览器中使用原生实现处理异步调用,至少是繁琐的,但并非不可能。出于实用性和安全性的考虑,建议使用像 Axios 这样的知名库。要安装库,我们需要从终端运行以下命令:

$ npm install axios

几分钟后,库将作为依赖项安装到我们的项目中。该库提供了非常方便的方法来为每个 HTTP 方法发起网络调用。例如,axios.get 发起 GET 请求,axios.post 发起 POST 请求,依此类推。

我们将为我们的学习练习实现一个简单的服务,以便在我们的 web worker 中从远程服务器进行网络调用。为了简单起见,我们只会创建两个方法:

./webworker/services/network.js

import axios from "axios"
axios.defaults.baseURL="http://localhost:3000"
const service={
   GET(payload={}){
     return new Promise((resolve, reject)=>{
       axios
         .get(payload.url,{params:{data:payload.data}})
         .then(result=>{
             if(result.status>=200 && result.status<300){
                  resolve(result.data)
             }else{reject()}
         }).catch(()=>{reject()})
  })},
  POST(payload={}){
    return new Promise((resolve, reject)=>{
      axios
         .post(payload.url,{data:payload.data})
         .then(result=>{
           if(result.status>=200 && result.status<300){
                resolve(result.data)
           }else{reject()}})
         .catch(()=>{reject()})
})}}
export default service;

此服务相当简单。在生产应用中,它将是服务于其他服务的中间件。此示例仅实现了两种方法来匹配相应的 HTTP 请求方法。请注意,它们非常相似,只是更改了方法名称和一些参数的签名。第一个参数始终是连接的端点(URI)。第二个参数是数据或包含选项的对象。我建议您参考官方文档了解如何处理每个具体的请求和处理边缘情况(axios-http.com/docs/intro)。

值得注意的是,在文件的开头,我们为所有其他网络调用设置了默认域名。这样,我们就可以避免在每个调用中重复设置。我们可以使用这个库轻松地设置特定的 HTTP 头和选项,例如JSON Web Tokens,用于身份验证,正如我们在第五章,“单页应用”,提到不同的身份验证方法时所述。

要将此服务包含在我们的 web worker 中,我们导入它并将其添加到我们之前使用的services数组中。修改此文件的开头,使其看起来像这样:

./webworker/index.js

import netService from "./services/network"
const services=[dbService, netService, testService]

通过这个新添加的功能,我们的 web worker 现在已准备就绪。我们现在实现一个单独的组件来测试通信,它看起来像这样:

图 8.8 – 一个简单的测试,其中服务器返回发送的信息

图 8.8 – 一个简单的测试,其中服务器返回发送的信息

我们的组件将允许我们选择 HTTP 请求的方法(GETPOST)并发送一些任意数据。测试服务器将仅将接收到的数据返回给客户端,组件将在屏幕上展示这些数据。实现相当直接:

./src/components/NetworkCommunication.vue

<script setup>
import webWorker from "../services/WebWorker"
import { ref } from "vue"
const
    _data_to_send = ref(""),
    _data_received = ref(""),
    _method = ref("GET")
function sendData(){
    webWorker
        .request(_method.value,                              //1
            {url:"/api/test", data: _data_to_send.value})
        .then(reply=>{_data_received.value=reply},
        ()=>{_data_received.value="Error"
        })
}
</script>
<template>
    <div>
       <section>
           <h4>Text to send</h4>
           <div>
               <label>
                    <input
                        type="radio"
                        value="GET"
                        name="method"
                        v-model="_method">
                    <span>GET Method</span>
               </label>
               <label>
                    <input
                        type="radio"
                        value="POST"
                        name="method"
                        v-model="_method">
                    <span>POST Method</span>
               </label>
           </div>
               <input type="text" v-model="_data_to_send">
               <button @click="sendData()">Send</button>
       </section>
       <section>
           <h4>Data received from server</h4>
               {{ _data_received }}
       </section>
    </div>
</template>

在这个组件中,我们导入webWorker服务并声明三个响应式变量用于发送和接收数据,以及一个用于保存所选方法的请求。我们的简单测试服务器将接收请求并将我们提交的数据返回。我们将在后面看到如何使用 Node.js 创建这个简单服务器。

在模板中,用户可以选择要发送的请求类型(GETPOST),我们将这个选择保存在_method变量中。我们使用这个值作为传递给工作线程的命令(在行//1)。我们将数据作为成员对象作为有效载荷传递。当这个承诺解决时,我们将回复中的值保存到_data_received变量中。在此阶段,源代码的其他部分应该很容易理解,因为它主要涉及模板和信息在屏幕上的展示。在我们结束这一章之前,让我们看看如何使用 Node.js 实现测试服务器。

一个简单的 NodeJS 服务器用于测试

为了测试我们的网络通信,似乎使用 Node.js 实现端点以测试我们正在测试的内容是合适的。在我们的 Vue 应用程序的单独目录中,打开一个终端窗口并输入以下命令:

$ npm init

命令行向导会问你一些问题来创建代表 Node.js 应用程序的package.json文件。完成后,运行以下命令来安装Express.js依赖项,这将为我们提供一个创建 Web 服务器的框架:

$ npm install express cors

一旦过程完成,创建一个包含以下代码的index.js文件:

./server/index.js

const express = require("express")                     //1
const cors=require("cors")                             //2
const app=express()                                    //3
const PORT=3000
app.use(cors())                                        //4
app.use(express.json())                                //5
app.get("/api/test", (req, res)=>{                     //6
    const data=req.query                               //7
    res.jsonp(data)                                    //8
})
app.post("/api/test", (req, res)=>{
    const data=req.body                                //9
    res.jsonp(data)
})
app.listen(PORT, ()=>{                                 //10
    console.log("Server listening on port " + PORT)
})

使用这几行代码,我们可以启动一个小型服务器,该服务器接收并响应 JSON 数据。我们导入 express 构造函数(第//1行),以及一个插件(第//2行)。这样做很重要,这样我们才能从任何域名(源)访问这个服务器。GET请求(第//6行)和另一个用于POST请求。第一个参数是服务器将监听调用的 URL。在这种情况下,它们是相同的,因为唯一的区别将是请求方法的类型。这是标准做法。

每个端点作为最后一个参数接收一个至少包含两个参数的回调函数:req(请求)和res(响应)。这些对象包含关于接收到的请求和创建对客户端响应所需的方法和信息。

对于GET请求,接收到的数据作为“查询字符串”附加到 URL 上,因此为了访问它,Express将其优雅地包装在request.query字段中的对象(第//7行)。由于我们只是回复接收到的相同数据,在第//8行,我们使用res(ponse)对象创建一个带有相同数据对象的填充 JSON 回复。我们这样做是因为我们认为我们可能会从任何域名(因为我们启用了 CORS)接收调用,并确保回复被完全理解。带有填充的 JSONJSONP)是使用不同方法发送响应的方法。我们不需要担心这一点,因为两端(发送者和接收者)都由 Express 服务器和 Axios 客户端处理。

post方法中,区别在于数据包含在消息体中(第//9行),因此有不同的处理方式。最后,服务器开始在指定的端口上监听(第//10行)。现在我们可以通过localhost:3000访问服务器,这是我们配置在网络服务中作为 Axios 默认地址的地址。

通过实现这个服务器,我们现在可以对系统的所有部分进行全面测试。

摘要

在本章中,我们回顾了一些非常重要的概念,这些概念可以从根本上提高我们应用程序的架构和性能。网络工作者是一项惊人的技术,它允许网络应用程序利用现代硬件架构和现代操作系统。从固定点的角度来看,使用网络工作者进行多线程涉及很少的额外努力和复杂性,而且收益是高度可观的。我们还看到了如何利用工作者来访问网络服务以及浏览器提供的本地持久数据库(IndexedDB)。我们学习了两种更多设计模式来实现我们应用程序的可扩展架构,并通过简单的组件和服务测试了这些概念和实现。使用网络工作者在性能和执行方面为精心设计的网络应用程序带来了显著差异。在下一章中,我们将探讨工具和技术来自动测试我们的代码,确保各个部分符合其预期目的,以匹配我们的软件规范和需求。

复习问题

  • JavaScript 有哪些限制可能会损害网络应用程序的性能?

  • 网络工作者是什么?它们的限制是什么?

  • Vue 应用程序如何与网络工作者通信?

  • 使用像业务代表(Business Delegate)这样的设计模式与网络工作者(web workers)一起工作的好处是什么?

  • 你可以在示例代码中做哪些更改来管理多个网络工作者而不是一个?在你看来,何时这样做是可取的?

第九章:测试和源代码控制

我们应用程序的成功取决于许多因素,而不仅仅是代码组织或模式的品质。此外,软件的本质意味着在开发和之后都会有变化,包括需求、范围等方面的变化。随着每个功能的开发,软件中会引入一个复杂性项,形成关系和依赖。新的添加可能会破坏这些连接,引入破坏性变更、错误,甚至完全禁用系统。解决这个问题的方法是跟踪代码变更并在应用程序上进行测试,以识别问题并尽可能确保系统符合所需的软件属性并满足需求。

本章将涵盖以下内容:

  • 测试的不同方法和测试驱动开发(TDD)的概念

  • 为我们的项目安装测试套件(Vitest)和测试工具(Vue Test Utils)

  • 在现有项目中创建和运行同步和异步代码的测试

  • 通过模拟用户交互来测试我们的组件

  • 使用 Git 和在线仓库(如 GitHub 或 GitLab)安装和管理我们的源代码

本章中的概念是介绍对开发者确保交付高质量软件的重要专业技能。通常,这些任务会被搁置或作为事后考虑。然而,缺乏这些任务可能会导致昂贵的错误和长时间的超负荷工作,随着软件复杂性的增长。对于涉及多个开发者的非平凡应用程序,如今几乎无法想象一个不使用这些工具的项目。

在本章中,我们将重点关注单元测试以及 Vue 团队提供的执行它的工具。

技术要求

本章没有对之前代码示例实现的要求的额外要求。最终源代码可以在本书的官方仓库github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter09中找到。

查看以下视频,了解代码的实际应用:packt.link/UqRIi

测试和 TDD 是什么

测试是验证软件是否按照项目需求执行其预期功能的过程。它涉及手动或自动执行工具来评估和测量软件的不同属性和特性,识别错误和缺陷,并为开发者提供反馈,以便采取行动进行纠正。有许多不同的测试方法和类型需要执行,如下所示:

  • 单元测试:这是对源代码的相关单元进行一系列输入和输出的验证。这通常会被自动化。

  • 集成测试:系统中的所有组件作为一个组一起验证,寻找在集成、通信等方面出现的错误和缺陷。

  • 端到端测试:这涉及到对应用程序的完整验证,模拟真实世界的使用,与数据库、网络场景等交互。可以使用模拟人类交互的自动化工具,以及使用真实用户进行的手动测试。

这些类型的测试只是这个领域的一小部分,因为针对软件可能有数百种可能的测试。大型公司可能拥有专门的测试团队,以确保软件的质量。通常,软件越复杂,测试可能越复杂。在实践中,测试计划可能和开发计划一样复杂。正如引言中提到的,我们将专注于 Vue 团队为此任务提供的官方工具。

测试可以在开发之前、开发期间、开发之后或与开发并行进行。TDD是一种将测试负担尽可能提前到项目中的学科,甚至在实际编码开始之前,目标是匹配需求。它包括以下步骤:

  1. 根据应用程序的需求和设计编写一个测试用例,包括关键输入和预期输出。

  2. 运行测试,它应该失败(因为还没有编写代码)。

  3. 编写要测试的实际代码(一个函数、Vue 组件等)。

  4. 对创建的代码运行测试。如果测试失败,重构代码或设计。

  5. 从一个新的测试用例开始,为下一个单元测试。

这个过程会重复进行,预计可以为开发者提供显著的“错误”和错误的减少,并帮助他们专注于需求。这个过程在项目早期就会产生一定的努力开销,与重构相比,测试是在项目后期进行的。

TDD(测试驱动开发)在一些团队和一些框架中已经变得流行,它被认为可以帮助开发者提高自己的代码质量,因为他们现在培养了一种“测试”的思维模式。然而,并没有具体的研究来证实这一点,但这个领域的实践者确实报告说,它改善了他们的代码和设计。这当然引发了一个问题:需要测试什么,我们如何将这项任务简化到我们的工作流程中?这正是我们接下来要讨论的主题。

要测试什么

一个良好的测试计划和实施成功的关键因素是决定测试什么。在考虑内部和外部因素时,不可能测试所有可能性的全集或项目中的 100%的组件和交互。即使尝试全面覆盖所有可能性也会极其昂贵且在实际上不可能实现。相反,我们需要关注在时间和预算限制内可以测试的真实可能性,通过仔细选择那些“成败”我们项目需求的非平凡元素。这通常不是一个容易的任务。

当涉及到 Vue 应用时,我们需要关注执行关键操作的关键服务和组件。我们需要测试以下内容:

  • 服务:自包含的函数,包括同步和异步。不返回值但执行逻辑过程的函数将涉及不同于我们在这里看到的测试类型。这些将涉及模拟网络通信或数据库调用、应用程序策略等。然而,测试这些的原则是相似的。

  • 组件:我们需要测试输入(属性)和输出(事件和 HTML)。将其他组件分组以执行工作流程或业务逻辑的高级组件也可以以相同的方式进行测试(属性、事件和渲染的 HTML)。然而,这些也需要其他类型的测试,例如端到端测试。

我们可以编写自己的函数和工具来执行测试,但除了某些边缘情况外,明显的建议是使用稳定的测试套件和工具。在我们的案例中,对于 Vue,有由同一团队提供的官方资源,称为VitestVue Test Utils。使用测试套件/库有许多好处,类似于在“常规”应用开发中使用框架或库。可能其中一个主要好处与DX(开发者体验)有关,因为它们使开发过程更加流畅或更轻便。让我们通过一个示例应用来学习如何将这些工具应用到我们的工作流程中,我们将在下一节中介绍这个应用。

我们的基础示例应用

最好通过将测试应用到实际项目中通过实践来了解测试的学科并学习工具。作为一个学习练习,我们将首先基于在第二章中介绍的示例之一,即软件设计原则和模式,选择一个正在运行的应用程序。我们将构建一个斐波那契计算器并将 Vitest 测试套件和 Vue 测试工具安装到项目中。稍后,我们将解释在应用 TDD(测试驱动开发)纪律时,这种方法会有哪些变化。

该应用的代码可以在本章的仓库中找到。下载后,您需要执行以下命令来安装依赖项:

$ npm install

然后,为了运行该应用程序,您必须运行以下命令:

$ npm run start

当服务器准备好后,在您的网络浏览器中加载网站应该会显示一个像这样的应用程序:

图 9.1 – 带有斐波那契计算器的示例应用程序

图 9.1 – 带有斐波那契计算器的示例应用程序

这个应用程序的设计是为了学习测试函数和组件的基础,所以它非常基础但足够了。我们面前有一个服务文件(/src/services/Fibonacci.js)和三个组件:App.vueFibonacciInput.vueFibonacciOutput.vue

图 9.2 – 应用程序组件和服务

图 9.2 – 应用程序组件和服务

我们的应用级组件 App.vue 通过一个事件从 FibonacciInput.vue 接收一个正整数,并将其作为属性输入传递给 FibonacciOutput.vue。该组件使用 Fibonacci.js 服务来计算相应的斐波那契数列中的数,并将其展示给用户。尽管这个应用程序听起来很简单,但它为我们提供了创建测试最常见情况的示例,这将为我们提供一个坚实的基础。现在是时候安装我们的测试套件了。

Vitest 的安装和使用

/src/__tests__ 文件夹,一些示例,以及 package.json 文件中的一些额外条目。但除非我们在这个领域有之前的经验,否则所有这些样板代码可能会有些令人困惑。相反,我们从已经创建的项目开始,因此我们将安装 Vitest 作为开发依赖项——这将让我们了解它是如何工作的以及它的组织结构。

使用以下命令在项目的根目录下通过命令行安装 Vitest:

$ npm install -D vitest

包管理器将花费一些时间来包含 Vitest 和所有必要的依赖项,但不会修改我们的源代码或组织结构。为了方便起见,我们还将使用 npm 来运行我们的测试,因此我们需要打开我们的 package.json 文件,并在 scripts 部分输入以下行,以便该部分看起来像这样:

"scripts": {
    "start":"vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:once": "vitest run",
    "test:coverage": "vitest run --coverage"
}

到目前为止,我们现在可以测试我们的测试套件了:

$ npm run test

运行该命令后,你会看到一个红色的消息友好地告诉你测试失败了。太好了。这正是我们期望的,因为我们还没有任何测试!所以,让我们添加它们。我们将从测试我们的 Fibonacci.js 服务开始。

Vitest 允许我们在独立的文件或源文件中编写我们的测试函数,这意味着我们将它们放置在与组件的 JavaScript 相同的目录下。这两种方法都有其优势和权衡,但为了开始,我们将测试代码放置在独立的文件中,每个服务和组件一个文件。这样,我们将这些文件放在它们自己的目录中,按照惯例可以是 /src/tests/src/components/__tests__,但它们也可以放置在单文件组件或服务旁边。Vitest 将扫描整个源文件夹以查找测试文件。尽管我们可以非常创意地放置这些文件,但我们仍将它们放在 /src/test 以保持整洁有序。还有一个需要遵守的惯例,即每个测试文件必须与被测试的文件具有相同的名称,加上 .spec.js.test.js 扩展名。Vitest 使用这个惯例以有组织的方式识别和运行测试。因此,在我们的情况下,我们的 Fibonacci.js 服务将在 /src/tests/Fibonacci.test.js 中有其测试对应物。请继续创建该文件,并输入以下行:

/src/tests/Fibonacci.test.js

import { describe, expect, test } from "vitest"
import { Fibonacci, FibonacciPromise } from "../services/Fibonacci.js"

在第一行,我们导入来自 Vitest 的三个函数,这是所有测试的基础,也是我们将最常使用的函数。以下是每个函数的作用:

  • describe(String, Function): 这个函数将多个测试组合在一起,Vitest 将使用作为第一个参数提供的描述来报告测试组。第二个参数是一个函数,我们将使用 test() 函数在其中运行测试。

  • test(String, Function): 第一个参数是对第二个参数中包含的测试的描述,第二个参数是一个函数。如果在这个函数中没有抛出错误,测试将“通过”。这意味着我们可以根据这个条件编写自己的测试逻辑和工具,当验证失败时抛出 JavaScript 错误。然而,有一个更简单的方法...

  • expect(value): 这是一个执行测试“魔法”的函数。它接收一个唯一的参数,即单个值或解析为单个值的函数。expect() 的结果是可链式对象,它公开了许多不同的几乎与语言自然相符的断言(比较、验证等),可以对参数值执行。在底层,它在一定程度上使用 Chia 语法,并且也与其他测试套件兼容,例如 Jest – 例如,expect(2).toBe(2)。所有可能的断言方法的全列表可以在官方文档中找到:vitest.dev/api/expect.html

在测试文件的第二行,我们直接导入服务中包含的两个函数:Fibonacci()FibonacciPromise()。我们需要导入我们想要测试的每个函数,并为每个函数创建必要的测试组。让我们从添加以下测试组开始,以自包含的 Fibonacci() 函数为例:

describe("Test the results from Fibonacci()", () => {
 test("Results according to the series definition", ()=>{
    // Expected values as defined by the series
    expect(Fibonacci(0)).toBe(0)
    expect(Fibonacci(1)).toBe(1)
    expect(Fibonacci(2)).toBe(1)
    expect(Fibonacci(3)).toBe(2)
    // A known value defined by calculation of the series
    expect(Fibonacci(10)).toBe(55)
 })
})

我们首先使用 describe() 创建一个测试组,并在传递的函数内部创建所需数量的测试。在每一个 test() 函数内部,我们可以创建所需数量的断言,但至少要有一个。注意我们是如何使用不同的参数从服务中执行函数,然后将它们断言为数值序列中定义的预期值。在这种情况下,我们使用 .toBe() 来测试相等性,但同样,我们也可以使用其他断言,如 .not, .toEqual, .toBeGreaterThan 等,来测试字符串、对象、类型等。文档中定义了超过 50 种断言方法(vitest.dev/api/expect.html)。花些时间回顾它们,并记住这些是可链式的,所以你可以一次做出多个断言。

保存此文件后,你可以再次运行测试:

$ npm run test

你应该会收到几条绿色的消息,指示执行了多少次测试以及它们是否通过。如果其中一个引发了错误,它将用红色字母指出,并使用描述性文本和发生错误的行。这是一个开始重构代码的信号(假设测试函数和断言被正确且恰当地编写;否则,你会得到一个假阳性!)。

如果没有断言方法适用于特定的边缘情况,你可以在 test() 内部创建自己的逻辑,使用纯 JavaScript 并在验证失败时抛出错误。例如,这两个代码片段是等价的:

// Using expect
expect(Fibonacci(10)).toBe(55);
// Using your own logic
let result=Fibonacci(10);
if(result!=55) throw Error("Calculation failed");

尽管这个例子很简单,但很容易看出,使用 expect() 的第一种情况,结果会带来更好的开发者体验,因为它简洁、优雅且易于阅读。

Vitest 仍在运行!

可能你已经注意到,运行 npm run test 并不会在测试结束后结束脚本的执行。就像开发者服务器一样,Vitest 会持续等待源代码或测试文件发生变化,并自动为你重新运行所有测试。如果你想只运行一次测试,请使用 npm run test:oncevitest –-run 来标记 Vitest 只运行一次测试然后退出。

特殊断言情况 – 故意失败

到目前为止,所有之前的断言都是使用“积极”方法进行的,即函数将返回预期的结果。在测试中使用“消极”方法是为了确保函数在应该失败时确实会失败。例如,斐波那契数列在负数上没有定义,因此任何计算都不应该返回值,而应该抛出错误。在这些情况下,我们需要将函数的执行封装在另一个函数中,从而测试抛出的错误。这相当于在纯 JavaScript 中使用try..catch块来避免在发生错误时终止脚本的执行。例如,执行Fibonacci(-5)应该抛出错误,因此我们将我们的测试用例写成这样:

test("Out of range, must fail and throw an error", ()=>{
    expect(()=>Fibonacci(-5)).toThrow()
})

前面的断言将按预期工作,而不会中断测试过程。

特殊断言情况 - 异步代码

另一个需要考虑的特殊情况是异步代码,例如网络调用、Promise 等。在这种情况下,解决方案是在expect上使用async..await,而不是在函数上。例如,为了测试FibonacciPromise()异步函数,我们会编写一个像这样的测试:

test("Resolve promise", async ()=>{
await expect(FibonacciPromise(10)).resolves.toBe(55)
})

注意我们是如何将async语法应用于整个测试函数,将await应用于expect()函数的。我们还需要使用.resolves断言来指示值的成功解析以进行验证。如果我们需要测试Promise拒绝,我们将使用.rejects而不是.resolves

通过这种方式,我们已经涵盖了大多数工具和测试方法,以帮助我们开始对纯 JavaScript 函数进行单元测试。然而,所有这些测试都是使用Node.js(JavaScript 的服务器版本)执行的,而不是在 Vue 组件将要执行的浏览器上。在 Node.js 中,没有DOMWindows对象,所以我们没有 HTML...那么我们如何测试我们的单文件组件

答案是向 Vitest 提供一个模拟的 DOM,我们可以在其中挂载我们的组件并运行测试,就像它是一个浏览器窗口一样。这就是 Vue Test Utils 工具发挥作用的地方。

Vue Test Utils 的安装

目前,Vitest 为我们提供了开箱即用的工具来测试纯 JavaScript 函数、类、事件等。为了测试我们的单文件组件,我们需要额外的资源,这些资源再次由官方 Vue 团队以Vue Test Utilstest-utils.vuejs.org/)的形式提供。要安装它们,请运行以下命令:

$ npm install -D @vue/test-utils

安装完成后,我们需要更新我们的vite.config.js文件,以包含组件将被测试的环境,即浏览器上下文。修改配置文件,使其看起来像这样:

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

Vitest 和 Vue Test Utils 都可以无缝集成到 Vite 中,以至于它们共享相同的配置文件。现在,您可以运行测试套件,Vitest 将在第一次运行后尝试下载并安装任何缺失的依赖项。如果由于某种原因,jsdom 的安装没有自动发生,您可以使用以下命令手动安装:

$ npm install –D jsdom

现在,经过这些更改,我们已准备好开始我们的第一个组件测试。让我们开始创建一个文件来测试我们的 FibonacciOutput.vue 组件,因为它是我们应用程序中最简单的,创建以下文件在测试目录中,并使用此代码:

/src/tests/FibonacciOutput.test.js

import { describe, expect, test } from "vitest"
import { mount } from "@vue/test-utils"                             #1
import FibonacciOutput from "../components/FibonacciOutput.vue"     #2
describe("Check Component props and HTML", () => {
    test("Props input and HTML output", () => {
        const wrapper = mount(FibonacciOutput,
            { props: { number: 10 } })                              #3
        expect(wrapper.text()).toContain(55)                        #4
    })
})

上述代码与我们之前所做的基本单元测试没有太大区别,但它做了一些不同的事情。在第 #1 行,我们从 Vue Test Utils 库中导入一个函数,该函数允许我们在模拟浏览器窗口的测试环境中“挂载”我们的组件,使用 Vue 3。在第 #2 行,我们以通常的方式导入我们的组件,然后像以前一样继续编写我们的测试组。这里的区别在于第 #3 行。我们使用 mount 函数通过将其作为第一个参数传递来创建我们的实时组件,作为第二个参数,我们传递一个包含将应用于组件的属性的对象。在这种情况下,我们传递了值为 10number 属性。mount 函数将返回一个表示我们的组件的包装器对象,提供了一个我们可以访问的 API 来执行我们的断言。在这种情况下,在第 #4 行,我们检查组件渲染的纯文本是否包含值 55,当测试运行时,我们会发现这是真的。正是通过使用这个包装器对象,我们可以通过访问适当的方法来访问组件属性、事件、插槽和渲染的 HTML。我们将在本章中仅讨论其中的一些,但完整的列表可在官方文档中找到,网址为 test-utils.vuejs.org/api/#wrapper-methods

这个简短的示例为我们提供了一个编写测试的模板,但现在我们转向一个更复杂的示例来测试我们的 input 组件。在测试目录中,创建以下文件:

/src/tests/FibonacciInput.test.js

import { describe, expect, test } from "vitest"
import { mount } from "@vue/test-utils"
import FibonacciInput from "../components/FibonacciInput.vue"
describe("Check Component action and event", ()=>{
    test("Enter value and emit event on button click",()=>{
        let wrapper=mount(FibonacciInput)                           #1
        wrapper.find("input").setValue(10)                          #2
        wrapper.find("button").trigger("click")                     #3
        // Capture the event parameters
        let inputEvents=wrapper.emitted("input")                    #4
        // Assert the event was emitted, and with the correct value
        // Each event provides an array with the arguments passed
        expect(inputEvents[0]).toEqual([10])                        #5
        // or
        expect(inputEvents[0][0]).toBe(10)                          #6
    })
})

这个最后的例子与之前一样,首先通过导入我们将用于描述测试、挂载组件以及组件本身的函数开始。我们的目的是通过在input字段中输入一个值、点击按钮,然后以编程方式捕获事件和传递的值,在一定程度上模拟用户与组件的交互。我们将像之前一样依赖这些方法。我们从第#1行开始,挂载我们的组件并创建包装器。请注意,这次我们没有传递任何选项,因为我们不需要它们。在第#2行,我们使用包装器的find()方法定位一个input元素并设置值为10find()方法使用与浏览器窗口中querySelector相同的语法字符串检索元素。返回的对象是围绕元素的包装器,它再次暴露了用户与之交互的方法——在这种情况下,.setValue()。使用类似的逻辑,在第#3行,我们也定位了按钮并触发click事件,这将触发我们组件中的input事件。注意在第#2行和第#3行中操作我们的组件是多么容易。通过这种方式,我们可以以编程方式访问和与之交互,就像它可能发生在端到端测试中一样。从理论上讲,我们可以使用这个工具创建我们的端到端测试,但还有更好的选择,例如Cypress (www.cypress.io/),它与 Vitest 配合得非常好,为我们提供了极佳的开发体验。

在第#3行,我们点击了一个按钮,我们知道它应该触发一个事件。在第#4行,我们捕获所有名为input的事件。结果是包含包装事件的数组,我们可以通过引用每个事件的序号索引在断言中使用它。在这种情况下,我们只触发了一个事件,所以我们在第#5行将其传递给预期的函数作为inputEvents[0]。然而,请注意,断言将输出与数组[10]匹配,而不是我们在第#2行输入的值。为什么是这样?答案是每个事件都可能传递一个不确定数量的参数,所以这些参数被捕获在一个数组中。这里在第#6行显示了等效的表示法,我们将第一个事件参数数组中的第一个元素的值直接传递给expect()inputEvents[0][0]。然后,我们可以直接使用.toBe(10)验证结果。现在,这种方法可能看起来有点复杂和笨拙,需要以这种方式引用事件及其值,但它非常强大。考虑一下,我们可以在一行中断言一个包含相关值的完整数组!

在这两个文件中,我们现在已经测试了组件的输入和输出,甚至验证了预期的交互性。我们还学习了如何检索渲染的元素并访问它们的属性。在这些函数中抛出的任何错误都将使测试无效,并指引我们找到正确的方向、行和注释,以便修复它。将测试放在单独的文件中是一个非常方便的替代方案。然而,Vitest 也接受源代码测试,我们将在下一节中看到。

源代码测试

使用源代码测试,我们可以指示 Vitest 在我们的 JavaScript 和单组件文件中查找测试运行,而不是特定文件。这些替代方案不是互斥的,因此我们可以在同一时间同时使用它们。背后的原因是,在某些情况下,测试用例可能受益于与它试图断言的原始代码“靠近”。这样的代码必须按照以下格式放置在我们的文件末尾:

if (import.meta.vitest) {
    const { describe, test, expect } = import.meta.vitest
   //... Test functions here
}

然后,为了使 Vitest 在我们的文件中找到这段代码,我们还需要修改vite.config.js文件,包括以下内容:

export default defineConfig({
    test: {
        includeSource: ['src/**/*.{js,ts}'],
        // other configurations here...
    },
})

最后,为了从生产构建中消除测试代码,我们需要在打包之前添加以下内容:

export default defineConfig({
    define: { 'import.meta.vitest': 'undefined' },
    // Other configurations...
})

通过这些更改,我们可以在 JavaScript 文件末尾包含测试,并带来相应的利弊。例如,如果有一个内部服务被多个项目共享或使用,将测试放在与每个项目重复的同一文件中可能是个好主意。

现在我们已经设置了测试,让我们看看使用 Vitest 我们能获得的两项额外好处:覆盖率实时 web UI

覆盖率

覆盖率的概念非常简单,它回答了我们的代码有多少部分被自动化测试覆盖的问题。我们知道,对于小型应用来说,100%的覆盖率是可能的,因为在大项目上投入相同的努力很快就会落入 Vitest 提供的递减回报定律。Vitest 通过运行vitest –coverage命令提供了一个简单的方式来回答这个问题。在我们的案例中,我们已经在package.json脚本部分设置了此选项,因此我们可以运行以下命令:

$ npm run test:coverage

当运行前面的命令时,如果任何依赖项缺失,它将提示我们是否想要尝试下载和安装它:

图 9.3 – Vitest 提示我们安装缺失的依赖以进行覆盖率

图 9.3 – Vitest 提示我们安装缺失的依赖以进行覆盖率

对于我们的章节代码示例,覆盖率报告应该看起来像这样:

图 9.4 – Vitest 覆盖率报告示例

图 9.4 – Vitest 覆盖率报告示例

如果需要,我们可以从文件中检索此信息(作为jsontexthtml)。为此,我们只需在vite.config.js文件中添加一行:

test:{
    coverage: {reporter: ['text', 'json', 'html']},
    //...
}

再次运行命令的结果将在我们项目的根目录下一个新的名为coverage的目录中放置一个网站。这个静态网站提供了导航并在报告中深入挖掘。在我们的例子中,它看起来是这样的:

图 9.5 – 覆盖率 HTML 报告

图 9.5 – 覆盖率 HTML 报告

根据我们的需求,这个简单的工具可能为我们提供对项目的洞察,这些洞察在其他情况下很难找到。如果需要将我们的项目与其他报告软件或格式集成,导出到 JSON 文件也非常方便。还有一个可能很有用的替代方案:Vitest 还提供了一个仪表板形式的 Web UI,可以查看和交互测试。我们将在下一节看到这一点。

Vitest UI

由于 Vitest 基于 Vite,它确实很好地利用了一些其功能,不仅用于实时测试,还提供实时显示测试的开发服务器。要使用此选项,我们只需要按照以下方式安装适当的依赖项:

$ npm install –D @vitests/ui

然后,为了方便起见,我们应该在我们的package.json文件中添加以下行,这样我们就可以使用npm运行应用程序:

scripts:{
    "test:ui": "vitest –-ui"
    // Other settings...
}

我们可以使用以下命令行启动服务器:

$ npm run test:ui

开发测试服务器将启动并提供一个地址,以便我们在浏览器中打开。对于我们的应用程序,它看起来是这样的:

图 9.6 – Vitest UI 仪表板

图 9.6 – Vitest UI 仪表板

Web UI 也有新的可能性与测试用例进行交互,甚至以图形方式查看组件和服务之间的关系,一直到底层的测试代码。

现在我们已经清理了代码并运行了测试,是时候考虑另一个工具来跟踪更改了,这是今天的一个基本概念:使用 Git 的源代码控制。

源代码控制是什么...为什么?

软件开发是一个“人力密集型”学科,这意味着它严重依赖于开发者的创造力和参与度以及他们的专业知识。尝试不同的方法来应对相同的情况,编写和重写代码是很常见的。即使在测试后的重构过程中,也需要在代码中进行更改。在这个过程中,我们需要“回退”到之前的代码,当更改或方法没有达到预期时,这并不罕见。如果我们不断覆盖相同的文件...我们如何跟踪哪里发生了变化?以及由谁引起的?当时间和复杂性增长时,我们自己的记忆是不够的。用不同的名字保存文件?这很快就会变得不切实际。那么,如何合并多个开发者的源代码?我们可以很快看到,对于非平凡项目来说,管理源代码本身就是一个非常重要的任务。

计算机科学早期这个问题的历史解决方案是创建额外的软件来跟踪代码中的更改,允许开发者回溯,并简化将多个开发者的代码合并成一个统一源代码的工作。这个任务的兴起学科被称为源代码控制SC),而实现它的软件被称为源代码控制系统SCS)或源代码管理系统SCMS)。现在和过去都有许多不同的系统在使用,例如MercurialSubversionClearCaseGitBitKeeper。每个系统都有自己的权衡。特别是,Git 现在被全球大多数项目和开发者使用。互联网上的统计数据显示了最受欢迎的系统的不同百分比,但每个都显示了这一趋势。因此,学习如何使用 Git 非常重要,这是我们接下来要讨论的主题。

Git 的源代码控制

目前,最流行的 SCS 是 Git,它是由林纳斯·托瓦兹创建的,他也是 Linux 内核的创造者。据说 Linux 内核项目曾使用BitKeeper进行源代码控制,但随着项目复杂性和分布式开发特性的增长,团队遇到了许多问题。沮丧的林纳斯·托瓦兹决定创建自己的 SCS 来解决他们面临的实际问题……而这只花了他一个周末!(参见www.linux.com/news/10-years-git-interview-git-creator-linus-torvalds/。)这就是 Git 谦逊的开始,从那时起,它在开源社区以及企业界都变得流行起来。

Git 是一个分布式的 SCMS,从命令行使用简单且有效。它提供了以下功能:

  • 创建并管理一个仓库,其中收集每个源文件的源代码和更改历史。

  • 允许通过将远程仓库克隆到本地项目来共享项目。

  • 允许项目进行分支和合并。这意味着你可以拥有具有不同代码的同一项目的不同副本(一个分支),在它们之间切换,将它们合并,并按请求统一它们(一个合并)。

  • 将远程仓库中的更改同步到本地副本(称为拉取)。

  • 将本地更改发送到远程仓库(称为推送)。

让我们通过将 Git 应用于本章的当前项目来学习如何使用 Git。让我们首先在我们的系统中安装它,这样它就可以用于所有我们的项目。

在 Windows 系统上安装

在 Windows 系统上安装 Git 最简单、最推荐的方式是从官方 Git 网站git-scm.com/download/win下载安装程序。根据您的操作系统(32 位或 64 位)选择您想要使用的版本,然后按照指示运行安装程序。

图 9.7 – Windows 的官方 Git 安装程序

图 9.7 – Windows 的官方 Git 安装程序

安装完成后,命令行工具将安装到您的系统上,因此我们可以通过终端运行它们。此外,如果您正在使用 Visual Studio Code 等代码编辑器,它将集成这些工具并提供一个 GUI 来处理基本操作。

Linux 系统上的安装

在 Linux 系统中,安装是通过命令行完成的,使用发行版的包管理器。在几乎所有发行版中,包名简单地为 git。在 Debian 和 Ubuntu 系统中,可以使用以下命令进行安装:

$ sudo apt install git

然而,在这些发行版中,可能没有最新版本,所以如果您需要最新的稳定版本,您需要添加官方的 PPA 仓库。在这种情况下,按照以下顺序运行以下命令:

$ sudo add-apt-repository ppa:git-core/ppa
$ sudo apt update
$ sudo apt install git

上述命令将更新您的系统依赖项并在您的系统上安装(或升级)Git。有关安装 Git 的完整列表和命令,请参阅官方文档 git-scm.com/download/linux

macOS 系统上的安装

在 macOS 系统中,安装 Git 有不同的方法:

  • 如果您已安装 Homebrew,请在终端中运行 $ brew install git

  • 如果您有 MacPorts,请在终端中运行 $ sudo port install git

  • 如果您已安装 Xcode,Git 已包含在内

对于其他替代方案,请查看 https://git-scm.com/download/mac 的官方文档。

使用 Git

无论您在哪个系统上工作,或您进行了哪种安装类型,Git 都将安装到您的本地路径中,因此可以从任何终端窗口执行它。要验证安装和版本,请运行此命令(不需要管理员权限):

$ git –-version

在撰写本文时,当前稳定版本为 2.39.2。完成此操作后,在项目的根目录中打开一个终端窗口。要开始使用 Git,我们需要使用此命令创建本地仓库:

$ git init

执行完成后,文件夹中将会创建一个新的隐藏目录。您不必担心它,因为它将由 Git 管理。如果您的文件资源管理器已禁用查看隐藏文件选项,那么您可能不会注意到它的创建。建议您在系统中激活“显示/查看隐藏文件”。

一旦我们创建了仓库,我们就可以开始使用它。与文件一起工作的步骤通常包括以下阶段:

图 9.8 – Git 的工作阶段

图 9.8 – Git 的工作阶段

一旦我们创建了文件或进行了编辑,下一步就是“暂存”这些文件。这表示 Git 需要跟踪更改并将文件包含在下一个提交事件中。提交是将这些文件/更改移动到仓库的行为。如果文件未暂存,则不会包含在提交中。要添加文件,请运行以下命令:

$ git add [filename1] [filename2]..

这将添加文件,但相当冗长。相反,如果你想添加所有文件中的所有更改,请运行以下命令:

$ git add .

这将在初始化存储库时的第一次提交中派上用场。运行此命令后,所有文件都将开始被跟踪。然而,我们不想在根目录中跟踪所有内容,因此要排除文件或目录,我们可以使用一个名为 .gitignore 的特殊文件。如果你在示例目录中打开此文件,你会找到如下内容:

/第十章/.gitignore

logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

这是一个纯文本文件,指示 Git 不要跟踪每行中指示的文件和目录。你还可以使用通配符,如星号(*)和问号(?),来包含匹配模式。这非常有用,因为你的代码库中有一些部分你不需要跟踪,例如节点依赖项和二进制文件(图像等)。在大量暂存之前,请确保你的目录中有此文件。

一旦你已暂存文件,你可以使用此命令检查它们:

$ git status

在我们的示例项目中,它将显示如下内容:

图 9.9 – Git 中的第一次暂存

图 9.9 – Git 中的第一次暂存

注意 Git 也告诉我们我们处于 master 分支,并且还没有任何提交。master 分支是代码的主要分支,默认创建。这是一个特殊的分支,用于保留我们应用程序的稳定代码。在 GitLab 和 GitHub 等工具中(我们稍后会讨论它们),这些分支在提交后也会触发某些事件。现在,让我们继续前进,使用此命令创建我们的第一个提交:

$ git commit –m "First commit"

我们将看到如下结果:

图 9.10 – 第一次提交的结果

图 9.10 – 第一次提交的结果

通过这些简单的行,我们已经开始跟踪我们的源代码。现在,如前所述,我们已经将初始代码提交到 master 分支。Git 允许我们快速复制代码的状态,就像截图一样,然后从这里继续工作而不会影响原始代码。这被称为分支,是使用 Git 的重要部分。

管理分支和合并

使用分支来控制我们的开发是一个非常好的前进方式。以下是管理分支的最常见命令:

操作 命令示例
创建分支并切换到它
$ git checkout –b [branch_name]

|

创建分支但保持在当前分支
$ git branch [branch_name]

|

删除分支
$ git branch –d [branch_name]

|

切换到分支
$ git checkout [branch_name]

|

将分支与当前合并
$ git merge [branch_name]

|

检查当前分支
$ git branch

|

一旦你切换到另一个分支,你可以执行所有常规的 Git 操作(编辑和删除文件等),而不会影响其他文件。

合并冲突

当合并多个分支或与 master 合并时,很可能会出现一些文件与当前分支存在差异。在这种情况下,合并 将失败,用户将被提示解决差异。Git 做的是用标记标记目标文件(当前分支中的文件),以便用户编辑。一旦编辑完毕,文件就可以暂存和提交,从而结束 合并。让我们通过故意创建一个差异来修复它,不使用代码。按照以下步骤操作:

  1. 使用此命令创建一个新的分支 dev$ git checkout –``b dev

  2. 编辑 index.html,在行 11(在脚本标签之前)添加以下内容:<div>在 branch dev 中创建的 div</div>

  3. 保存文件,将其暂存,并使用 $ git add index.html$ git commit –m "在 dev 中添加 div" 提交更改。

  4. 现在,我们将使用 $ git checkout master 切换到 master 分支。

  5. 注意到带有 div 的第 11 行已从 index.html 中消失。这是因为对这个文件从未进行过编辑。现在,添加以下内容到该行:<p>此更改是在 master 中进行的</p>

  6. 保存文件,将其暂存,并使用不同的消息提交(查看 步骤 3)。

现在,我们将尝试合并两个分支,由于 index.html 在两个分支上都有不同的代码,所以会失败!要开始 合并,运行 $ git merge dev

你应该在终端上看到一个错误,以及添加到 index.html 中的新行,指示差异。在我们的代码示例中,它看起来像这样:

图 9.11 – 合并冲突

图 9.11 – 合并冲突

  1. 要解决冲突,只需根据你的最佳判断编辑源代码(也删除 Git 添加的额外标签),然后保存文件,将其暂存,并最终提交。你将收到一条消息,表明 合并 已解决。

在分支上工作并解决出现的合并冲突是一种常见的做法,并且非常有用,但我们还没有充分利用 Git 的全部潜力。正如你所记得的,Git 是一个分布式版本控制系统,这依赖于其巨大的潜力。进入远程仓库...

与远程仓库一起工作

与我们处理本地仓库的方式相同,Git 也可以与远程仓库同步代码。这使得世界各地的团队成员能够共同在同一个代码库中协作,解决冲突,并将自己的代码与其他人的贡献同步。与远程仓库一起工作涉及以下步骤来设置:

  1. 必须创建远程仓库,并提供一个 URL 以连接到它。

  2. 我们使用以下命令将远程仓库作为新的源添加到本地仓库:

    $ git remote add origin URL
    
  3. 我们将我们的 master 分支设置为与远程仓库同步:

    $ git push –set-upstream origin master
    
  4. 我们从远程仓库检索更改:

    $ git pull origin master
    
  5. 我们将我们的更改提交到远程仓库:

    $ git push origin master
    

完成步骤 1 到步骤 3 后,常规活动将涉及步骤 4 和步骤 5。这些活动将保持您的本地仓库与远程仓库同步。实际上,现代 IDE(如 Visual Studio Code)已经提供了图形工具来执行这些操作,这使得在项目上工作时更加方便。它们还包括在合并过程中解决冲突的视觉工具。

在您的本地网络中设置 Git 服务器超出了本书的范围,但如果没有提到GitHubGitLab,这篇介绍就不会完整。通常,当人们第一次听说 Git 时,他们会将其与 GitHub 联系起来,这是可以理解的,因为后者在媒体上拥有更广泛的知名度。GitHub 不是 Git。它是一个基于 Git 构建的工具平台,用于托管在线项目和远程仓库。因此,您可以在本地完美地使用 Git,并与 GitHub 或 GitLab 的远程仓库同步。这是最常见的情况。

GitHub 提供消息和文档工具,还有更多——甚至允许检测仓库中的事件以触发某些动作和服务的附加服务,一些是在本地(付费)提供的,另一些是在远程(例如,webhooks)提供的。例如,您可以在本地提交,将更改推送到 GitHub 上的master分支,然后启动一系列程序,从编译到网站展示。再次强调,管理所有这些选项超出了本章当前的范围,但重要的是要记住,所有这些都基于 Git 构建,所以如果您理解它是如何工作的以及它做什么,您就有了一个坚实的基础来继续使用其他工具和服务。还有一个与这个主题相关的概念变得熟悉,那就是持续集成和持续交付,我们将在下一节中看到。

持续集成和持续交付

持续集成CI)是由我们迄今为止看到的科技所启用的一种实践,开发者尽可能频繁地将他们的更改提交到一个中央(远程)仓库。中央仓库检测到传入的更改,并对代码执行自动化测试。然后,它编译/构建最终产品。这是持续进行的,而不是在发布前指定日期进行合并和编译的实践。

持续交付CD)在持续集成(CI)的基础上进一步发展,它还包括将发布的产品部署到其最终位置。您可以配置此过程以创建软件或 Web 应用的预发布版本(例如,测试版、夜间构建等),并为最终位置和客户交付(有时,这部分可能涉及自己的流程,称为持续部署)设置发布日期。前面提到的两种服务(GitHub 和 GitLab)都提供这些类型的服务。

通过使用这些概念,您可以从桌面到网络设置一个完整的自动化工作流程,其中简单的 Git 提交和服务器推送将触发您的应用程序进行测试和发布到其在线目的地。实现此工作流程的方法取决于用于实现 CI 和 CD 的工具。

摘要

在本章中,我们介绍了关于我们代码的护理和质量的重要概念。我们学习了如何安装官方工具以在我们的代码和组件中执行自动化测试,以及如何跟踪源代码中的更改和管理。虽然这里提供的示例和信息是入门级的,但它们足够详细,可以在您自己的项目中实施,并保持您的学习技能不断增长。CI 和 CD 的概念,以及在线存储库提供的服务,也为您提供了一个坚实的基础,以便学习如何使用它们,因为它们都基于 Git 提供的功能。所有这些工具对开发者都具有专业价值,并且在当今行业中是必需的。

复习问题

  • 自动化测试为什么重要?它是否消除了执行手动测试的需要?

  • 在 Vue 中测试我们的单文件组件需要什么?

  • 源代码控制是什么,为什么它是必要的?

  • Git 是什么,它与 GitHub/GitLab 有何不同?

  • 当您在分支中修改文件时,它是否会在所有其他分支中修改?为什么会发生或不发生这种情况?

  • 控制 Git 的命令在所有平台上都相同吗?

  • CI 和 CD 代表什么,它们为工作流程增加了什么价值?

第十章:部署您的应用程序

如果我们不能发布最终产品,那么我们在开发和我们的应用程序上的工作将走向一个悲伤的结局。虽然相当直接,但在互联网上展示我们的应用程序确实需要关注一些细节,并熟悉一些术语和托管可能性。

在本章中,我们将学习以下内容:

  • 在互联网上发布网络应用程序涉及哪些内容

  • 为部署构建我们的应用程序的考虑因素

  • 熟悉注册域名的术语和流程

  • 配置网络服务器以托管我们的单页应用程序SPA)或渐进式网络应用程序PWA

  • 使用 Let’s Encrypt 保护我们的网络应用程序服务器

本章的主要目的是为您提供工具,以了解发布和保障网站所需的步骤,以及由此延伸出的我们的 SPA 或 PWA。

技术要求

本章主要是信息性的,但已将一些配置文件上传到本书的存储库作为示例,可以在以下位置找到:github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter10

发布一个网络应用程序涉及哪些内容?

发布 Vue 3 网络应用程序与发布任何其他网站并没有太大的不同,除了几个关键差异。在本章中,我们将考虑一个干净的安装,这意味着我们将自行获取所有涉及的元素。在最基本的情况下,我们需要考虑以下内容:

  • 我们网站/应用程序的域名

  • 我们应用程序的目标路径

  • 托管服务或类型

  • 网络服务器软件

  • 获取安全证书

前述项目也为我们准备提供了一个简单的公式。让我们逐一解释,在前进的过程中解释每个必要的术语和关注点。

域名、域名服务器(DNS)和 DNS 记录

连接到网络的每台计算机都会收到一个独特的标识地址,以区分同一网络上的其他计算机。这些被称为互联网协议IP)地址,如今,有两种在运行——IP 版本 4 和 6。

  • 127.0.0.1,代表回环到我们自己的计算机。这些地址也可以有一个掩码,用于定义网络中的子段。很可能是您的家庭网络内部使用此协议。

  • IPv6 中的127.0.0.10000:0000:0000:0000:0000:0000:0000:0001,然后可以缩写为0:0:0:0:0:0:0:1或简称为::1

关于网络地址还有很多内容,但仅通过这个简要介绍,我们已经可以看到这里存在一个可用性问题。这些地址对计算机来说工作得很好,但与“人类记忆”不太友好。在庞大的互联网上,有成百万的连接计算机,仅使用 IP 地址进行导航是不可能的。这就是为什么在网络的架构中有特殊的服务器,它们可以将“人类友好的名称”转换为正确的 IP 地址。这些友好的名称被称为域名,提供转换的服务器被称为域名系统DNS)。所有这些都由互联网名称与数字地址分配机构ICANN)组织进行监管。

域名是我们每天用来访问互联网上任何网站或应用程序的工具。这些域名是从有权销售它们的实体那里购买的,称为注册商。一旦期限到期,可以在有限的时间内进行续费,否则可以被其他人获得。通常,域名按年出售,价格从几美分到几千美元不等。域名也按组组织,从右到左由点分隔,如下所示:

图 10.1 – 构成完整域名各部分

图 10.1 – 构成完整域名各部分

最高级域名由 ICANN 管理,虽然.com商业网站是最著名的,但还有许多其他选择,如下所示:

  • .org: 用于组织

  • .net: 用于企业内部网或组织门户

  • .mil: 用于军事用途

  • .gov: 用于官方政府网站

新的顶级域名经常被创建。您可以在以下位置找到它们的增长列表:en.wikipedia.org/wiki/List_of_Internet_top-level_domains

当我们购买一个域名(如前图中的mydomain)时,它会附加到我们选择的顶级域名。注册商为我们提供了选择域名并检查它们是否可供购买的选择。为了发挥作用,域名需要在 DNS 上进行注册,以便指向一个 IP 地址。这样做的方式是创建DNS 记录,这通常是通过出售域名的同一注册商完成的,或者我们可以在注册商处记录将具有目标 IP 的 DNS。关于这一点,我们稍后会详细介绍,但现在是,请记住这个概念。域名最常见的 DNS 记录如下:

记录 类型(名称) 值和描述
A 一个 IPv4 地址。这是指向您的服务器公共 IP 的主要记录。
AAAA 一个 IPv6 地址。指向您的服务器公共 IPv6 地址。
CNAME 创建一个指向域的别名,因此您可以将多个域名指向同一目的地,而无需创建多个 A/AAAA 记录。这可以用于创建子域名。
TXT 一种纯文本记录,通常与某种形式的域所有权验证一起使用。

表 10.1 – DNS 记录类型

根据注册商和聘请的服务,您可能永远看不到或必须处理这些记录,因为一些注册商/网络托管商会自动为您管理它们。

子域名不需要从注册商那里购买,只需配置即可。您可以为您的域名创建尽可能多的子域名。以下是一些常见的子域名:

  • www:用于万维网,或一个网页。如今,这个子域名通常被用作域的同义词。

  • app:用于应用程序。

  • admin:用于管理访问。

  • mail:用于电子邮件服务。

使用子域名,您可以在同一域名/主机上托管多个网站。我们将在稍后看到如何为我们的应用程序在 Web 服务器上配置一个。在此阶段,我们需要记住的是,域名或子域名将指向您的服务器作为最终目的地。

关于回环地址的说明

按照前面的示例,为回环(本地)地址保留的“域名”是 localhost

我们应用程序将要托管的主机域是我们在互联网上拥有存在感的第一步。有了它,我们就位,可以转向下一个考虑因素——它将在该域中的位置。

构建我们的应用程序以进行部署的注意事项

一旦我们有了我们的域名/子域名,我们需要决定(或知道)应用程序将位于哪个路径上。路径是跟随域的部分,由正斜杠(/)分隔的段——例如,mydomain.com/store/product.html。这些部分被称为“路径”,因为它们遵循与本地存储中镜像相同的目录结构。我们的应用程序将通过一个 folder/subfolder/file...)来提供服务。内部,我们的服务器将匹配域名请求到本地目录中的文件。这就是我们需要知道 Vue 应用程序是否将放置在根目录,还是在一个路径(目录)上,因为如果我们使用 Vue Router 在 Web 历史模式下,我们需要为此指示构建过程(如果您需要复习此主题,请参阅第五章,“单页应用程序”)。在这种情况下,我们需要进行两项修改:

  • 在我们的路由器配置中指明应用程序的路径

  • 配置网络服务器以更改目录/文件服务并将所有请求路由到 index.html 文件

如果我们的应用程序使用网络历史模式放置在 mydomain.com/app,那么我们需要通过将“基本路径”传递给创建函数来修改我们的路由定义。因此,如果我们查看我们的 SPA 示例应用程序中的路由,即第五章,“单页应用程序”,我们可以按以下方式修改它:

/chapter 5/to-do SPA/src/router/index.js

import { createWebHistory } from "vue-router"
// ...
router = createRouter({
history: createWebHistory('/app'),
routes,
// ...
}
})

注意到的小变化,我们将基本路径传递给createWebHistory构造函数,而不是使用createWebHashHistory()。当然,如果应用程序使用哈希模式,它放置在我们的路径中的位置无关紧要。这是因为在这种模式下,所有路由导航都将传递给哈希,并指向我们的index.html文件。例如,如果我们的路由器有一个名为/description的路由,使用哈希模式将使地址变为mydomain.com/app/description(Web 历史模式)。

网址中的哈希值

地址中的哈希表示指向页面/文件的某个部分的链接,根据 HTML 标准,Vue 在哈希模式下管理定义的路由时使用它。

在为我们的应用程序指定目的地后,我们现在可以通过在终端上运行以下命令通过 Vite 构建生产代码:

 $ npm run build

默认情况下,我们整个应用程序的最终生产就绪文件将放置在/dist文件夹中(与我们的/src文件夹处于同一级别)。现在,有了我们的构建分发文件,一旦我们设置了适当的配置,我们就可以将它们上传到服务器。

Web 服务器选项和配置

当我们准备将应用程序上传到服务器时,我们会面临许多选项,这些选项基于提供的服务类型和 Web 服务器应用程序。这些项目的组合通常被称为“托管”服务器,包括操作系统、机器配置、架构类型,特别是 Web 服务器软件。以下是每个类别的一些最常见选项列表:

操作系统 Linux 或 Windows 对于我们 Vue 3 应用来说,这个选择 无关紧要
托管类型 共享 我们的应用程序将驻留在存储的私有区域的服务器上,但将与其他应用程序共享所有资源。通常通过网页控制面板进行配置访问
虚拟专用服务器 (VPS) 我们获得了一个具有完全访问整个配置和资源的虚拟机,通常通过远程终端的直接连接
托管 VPS 与 VPS 类似,但我们提供网页控制面板或其他服务来管理该机器
私有服务器 在这里,我们从托管提供商那里租用真实硬件,并拥有所有资源的完全自由
自托管 我们用自己的方式将服务器直接连接到互联网,并拥有互联网连接
托管 我们向服务器农场提供服务器,他们负责物理需求。我们通过远程管理服务器,拥有软件和硬件的完全控制权
Web 服务器 Apache HTTP 该服务器在 Linux 和共享托管中稳定且使用广泛
Nginx 一个较小且快速的 Web 服务器,以其高效管理大量并发连接而闻名,资源使用效率高。非常易于管理,在 VPS 托管中非常受欢迎

表 10.2 - 每个类别的常见托管选项

在 Vue 3 应用程序的情况下,我们的目标是拥有一个快速且可靠的 Web 服务器,能够通过提供静态文件来同时处理多个请求。由于我们不在服务器上运行代码,因此我们不需要太多的 CPU 处理能力,所以我们对硬件和软件的要求非常低,以至于实际上任何“静态文件服务器”都可以。很可能会是我们的应用程序成为更大基础设施的一部分,具有其他要求,但专门用于为我们自己的 Vue 3 应用程序提供服务的要求是较低的。

这里的关键考虑因素是,再次强调,我们是否在我们的路由器中使用 Web 历史模式。在这种情况下,我们需要向 Web 服务器软件添加配置,以便在请求不匹配标准(文件夹目录中的文件)时将所有请求定向到我们的 Vue 应用程序的入口点(我们的index.html)。这可能听起来很复杂,但实际上相当简单。直接从官方 Vue Router 文档中,以下是两个 Web 服务器的示例。

Apache HTTP 服务器配置

Apache HTTP 服务器在共享主机提供商中被广泛使用,并允许我们通过在 Web 应用程序的根目录中放置单个文件来更改请求的配置。这非常方便且简单,但确实需要主机提供商已启用(或通过管理面板允许用户启用)一个特殊模块,允许我们重写传入的请求。官方文档(router.vuejs.org/guide/essentials/history-mode.html#apache)展示了此示例:

/.htaccess

<IfModule mod_negotiation.c>
  Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /                                //1
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

前面的文件应该放置在我们的index.html文件旁边。然后,每个进入的请求都将被路由到它,并由 Vue Router 在 Web 历史模式下捕获。注意,在行//1中,有RewriteBase规则。这就是我们更改应用程序路径的地方,如果不在域根目录下。

Nginx 服务器配置

在 VPS 和私有服务器的情况下,NGINX 服务器因其灵活性和性能而非常受欢迎。它可以作为反向代理、负载均衡器等等。在 Linux/Windows 的 VPS 上安装此服务器相当简单,但这里不会涉及。您可以在每个系统的文档中查看www.nginx.com/resources/wiki/start/topics/tutorials/install/

与使用 .htaccess 文件的 Apache 不同,我们需要修改我们网站的服务器配置文件。在 Linux 中,这通常位于 /etc/nginx/sites-available。文件遵循一个简单的模式,其中,对于每个虚拟服务器,我们声明位置路径(如域名路径)和本地存储的位置(目录或文件夹)。以下是一个来自 Linux 服务器的示例文件:

/etc/nginx/sites-available/default

server {
    listen 80;
    index index.html;
    root /home/user/www;                              //1
    server_name www.mydomain.com mydomain.com;        //2
    location / {                                      //3
        try_files $uri $uri/ /index.html;             //4
    }
}

让我们看看前面的代码:

  • 在第 //1 行,我们放置本地存储的绝对路径到我们的应用程序。

  • 在第 //2 行,我们声明将与该服务器块关联的域名和子域名。

  • 在第 //3 行,我们声明要处理的位置路径。在这个例子中,我们将应用程序放置在根目录(/)。如果放置在 mydomain.com/app,我们将写 location /app

  • 最后,在第 //4 行,我们告诉 NGINX 尝试找到一个有效的目录/文件,如果没有,则将其传递到我们的 index.html 文件。

与之前一样,如果我们使用的是 Web 哈希模式,那么我们不需要进行这些更改。我们可以直接使用默认配置从磁盘提供文件。

其他服务器

在这里无法涵盖许多其他正在使用和可能的配置服务器。然而,官方 Vue Router 文档为其他服务器提供了非常好的示例,并为未涵盖的内容提供了指南。您可以通过此链接找到参考:router.vuejs.org/guide/essentials/history-mode.html#example-server-configurations

让我们看看现在如何将我们的文件移动到我们的在线服务器上。

将您的文件传输到服务器

现在我们已经有了指向我们服务器的域名和配置就绪,现在是时候上传我们的发行文件了。根据您选择的托管方式,这可以通过 Web 界面、文件传输协议FTP)应用程序或通过安全外壳协议SSH)进行安全传输来完成。对于后两种选项,建议使用一个负责繁重工作的应用程序。一个很好的选择是使用 FileZilla (filezilla-project.org/),它处理上述协议。它适用于 Linux、Windows 和 macOS。

正如我们在第九章“测试和源代码控制”中提到的,你也可以配置你的 VPS/服务器,使用本地仓库中的/dist文件夹从远程仓库拉取源代码。例如,我们可以打开到服务器的远程终端,触发同步(拉取),然后在服务器本身打包应用程序,拉取已经打包应用程序的分支,直接将我们的提交推送到服务器,等等。使用 Git 时有很多选项,使用像GitHubGitLab这样的服务时还有更多选项,这些服务提供了强大的持续集成和交付工具。如果你不想使用 S/FTP 应用程序或想自动化这个过程,这是一个值得探索的话题。每种实现都是特定的,超出了本书的范围,所以我们将继续下一个主题,假设我们的文件现在已经在服务器上了。

使用 Let’s Encrypt 保护你的网络应用程序

互联网地址包含在使用协议的最开始部分。默认情况下,所有网页导航都使用超文本传输协议HTTP),虽然它是基础性的,但并不被认为是安全的。当在客户端和服务器之间提供加密层时,通信将通过HTTPS(其中的S代表安全)进行。这个加密层由认证机构提供和验证,因此证书必须从这样的机构购买。托管提供商通常有在他们的服务器上购买和安装一个证书的选项,但Let’s Encrypt基金会也提供了一个免费且可靠的替代方案(letsencrypt.org/)。

要安装Let’s Encrypt证书,你需要对服务器有 shell 访问权限。如果没有,那么你必须依赖托管商提供的服务。兼容的认证托管提供商列表在这里:certbot.eff.org/hosting_providers

如果我们通过远程 shell 访问服务器,过程也是直接的。Let’s Encrypt 基金会和电子前沿基金会EFF)提供了一个名为certbot(认证机器人)的应用程序,该程序自动化了安全证书的安装,并配置了本地网络服务器文件以使用 HTTPS。在这种情况下,我们有两种选择:

  • 为每个域名和每个子域名安装证书

  • 安装一个通配符证书,它覆盖每个域名及其所有可能的子域名

安装certbot并运行过程的说明因操作系统、网络服务器和证书类型的不同而不同。因此,电子前沿基金会(EFF)提供了一个网页,其中包含针对每种可能变体的可配置选项和易于遵循的步骤。向导可以在certbot.eff.org/找到。

图 10.2 – Certbot 为 NGINX 和 Ubuntu 20 的说明

图 10.2 – Certbot 为 NGINX 和 Ubuntu 20 的说明

通常,说明遵循以下步骤:

  1. 安装 certbot

  2. 运行 certbot。这将显示一系列选项,包括在给定的 Web 服务器上找到的所有域名。

  3. 选择要安装的证书类型。

  4. 如果启用,选择证书的自动续订。拒绝此选项将需要手动续订。

免费证书的有效期仅为 3 个月,而商业证书可以购买更长时间。3 个月后,必须手动续订。幸运的是,certbot 包含一个在到期期限之前执行自动更新的功能。

即使对于简单的测试应用程序,始终使用安全证书保护网站也是重要且值得推荐的。让我们也记住,拥有安全证书并通过 HTTPS 提供应用程序是 PWAs 的硬性要求。

摘要

在本章中,我们介绍了在互联网上我们自己的公共空间发布 Vue 应用程序的基本知识。我们还学习了在购买和预订域名时理解指令的重要概念,以及如果需要设置 DNS 记录。我们还学习了如何在 Vue Router 中的 HTML5 历史模式下调整我们的包配置,我们可以租用的不同类型的在线托管,将我们的分发文件复制到生产服务器的选项,以及使用免费的 Let’s Encrypt 证书来保护我们的网站,以通过 HTTPS 提供应用程序的指南。所有这些技能都很重要,并且你将受益于至少执行这些技能一次的经验。

随着我们应用程序的部署,本书中涵盖了构建 Vue 3 应用程序的主要步骤和主题,从框架的介绍到测试我们的单个组件,再到在 Web 服务器上安装我们的生产就绪文件。在某些情况下,我们已经超越了基础知识,探讨了高级主题,这些主题对于专业开发者来说是一个重要的补充。如果你已经跟随了这些概念和代码示例,你已经获得了帮助你在职业发展中取得重要技能。但这本书还没有结束,因为你可以找到额外的额外内容。

我衷心感谢您,亲爱的读者,您对 Vue 3 的兴趣以及购买这本书,这本书总结了多年开发应用程序的经验。我希望它成为您继续学习的参考资料和鼓励,并祝您在个人和职业生涯中取得最佳成功。

诚挚地,

Pablo D. Garaguso

复习问题

  • 什么是顶级域名,它与域有什么不同?

  • 我们可以为我们的域名创建多少个子域名?为什么?

  • DNS 是什么?DNS 记录是什么?

  • 目前有哪些选项可以用于在互联网上发布您的 Web 应用程序?

  • 当使用 Vue Router 在 Web 历史模式下时,我们在代码和 Web 服务器方面需要考虑哪些因素?

  • 在共享主机和 VPS 中常见的 Web 服务器有哪些?

  • Certbot 是什么?它有什么作用?

第十一章:奖励章节 - UX 模式

很久以前,计算机软件是与重型印刷手册一起销售的,在许多情况下,还附有专门的培训。虽然其中一些仍然适用于企业应用程序和专用硬件,但这种概念几乎已经完全从消费市场消失。想象一下,对于每个网站,你都必须阅读手册并参加三个月的培训课程,才能开始使用它。那么,为什么今天,用户可以面对一个新应用程序,并且能够一眼就完成基本任务呢?毫无疑问,对这种当前情况做出巨大贡献的是人类计算机交互领域多年的研究和进步,这个学科几乎与计算机本身一样古老。

在本章中,我们将从软件模式和工作原理的复杂性以及架构中退一步,看看一些为设计用户界面UI)和用户体验UX)而建立的良好模式。我们将做以下几件事:

  • 理解 UI 和 UX 之间的区别

  • 熟悉网络应用中的常见 UX 模式

  • 了解 UX 的阴暗面——暗模式。

到本章结束时,你将广泛了解 UX 模式是什么,它们基于哪些原则,目前最常见的方法有哪些,以及它们对用户产生的影响,无论是积极的还是消极的。你还将学习和发展一种共同的语言,以便与 UI 和 UX 设计师以及其他开发者进行交流。

技术要求

本章主要是信息性的;然而,本章讨论并实现了 Vue 3 中的小型示例,因为它们展示了本书到目前为止尚未见到的技术。对于完整的源代码,请参考书中项目文件夹的完整源代码,该文件夹位于书籍仓库的github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter11

查看以下视频,看看代码的实际应用:packt.link/5ymkr

UI 设计与 UX 设计对比

常常听到这两个术语被关联或无区别地使用,在某些情况下,这两个职责合并为一个团队中的同一角色,这增加了混乱。虽然有些重叠,正如在计算机科学学科中经常发生的那样,我们将专注于学习目的的差异:

  • UI 设计负责定义表示信息和捕获用户输入以与应用程序交互的视觉语言。它涵盖了视觉风格、排版、交互、颜色、尺寸、动画、声音等设计,这些构成了人与计算机(软件和硬件)之间的界面。这适用于视觉媒介(网络、移动等)以及其他类型,如自然语言界面(例如,考虑 Siri、Alexa 等 AI 助手)。

  • UX 设计包括影响和指导 UI 设计的条件,但具有更广泛的视角,关注用户对系统、公司或他们与之互动的功能的认知。它涉及软件或硬件之外的元素,如支持、推广、售后服务等。目标是创造一个广泛且希望成功的正面用户体验。根据这个定义,它还与其他学科合作,如营销、客户支持、分销、产品管理、品牌识别等。其主要目标是改变或创造用户对产品、服务或系统易于使用、高效,并且最重要的是,对他们有用的认知。

理解每个学科的原则和目标将帮助我们开发更好的软件,并在与这些任务中的合作者交流时有一个共同的基础。没有 UI 和 UX,即使是最优秀的软件也可能被遗忘。软件历史充满了公司走向灭绝的例子,即使他们的产品比竞争对手更优越,也是因为忽视了用户体验或视觉设计不佳。有时,书的封面和内容一样重要...

UI 设计的原则

UI 设计的核心目标是创建一个用户认为易于使用、高效、信息丰富且令人愉悦的界面。用户保留和满意度等营销概念在很大程度上依赖于产品设计。就我们的目的而言,我们将 UI 的介绍限制在显示屏上的应用程序界面(通过视觉媒介,如屏幕或触摸设备展示)。

有大量的文档详细研究了 UI 设计,具有工程精度和明确的行业标准。每个方面都有自己的规则,优秀的 UI 设计师需要牢记。例如,网页设计师和工业设计师看待事物的方式不同。在我们的案例中,UI 设计的模式大多数从一开始就被包含在 HTML 标准中,所以我们将看到的模式大多数,如果不是全部,对你和最终用户来说都是熟悉的。然而,它们是如何工作的,或者它们遵循的原则,并不是通常讨论或显而易见的事情。例如,为什么关闭窗口的“X”在右上角?每个不同的菜单图标代表什么?为什么“开始”按钮出现在屏幕的右上或左下角?为什么有些功能难以找到,而其他功能则一目了然?所有这些问题都有根植于 UI 设计和 UX 模式的解决方案。考虑到这一点,让我们回顾一些 UI 原则,然后继续讨论 UX 模式。

元素之间要有足够的对比度或区分度

这个原则指出,页面上的元素应该彼此清晰可辨,并代表其功能。它反映了使用大小、颜色、字体、边距和空白来组织视觉元素,以便每个功能都能清晰表示并与其他功能区分开来。主要目标是引导用户的注意力到界面的焦点。让我们以 Packt 主页([www.packtpub.com/](https://www.packtpub.com/))为例:

图 11.1 – Packt 的主页和颜色对比

图 11.1 – Packt 的主页和颜色对比

在这个例子中,大部分内容使用有限的颜色(一个“调色板”),两个按钮具有高对比度,这吸引了用户的注意力:免费试用立即开始学习按钮。显然,设计师已经为这两个动作设置了焦点。

与此原则相关,在这个截图中有一种设计“规则”被应用,这是一个值得记住的实用规则:60-30-10 颜色规则。这意味着该部分必须有 60%的基础色(在这里是深棕色),30%是主色(用于常规文本、菜单和图像,在这里是白色),而 10%则保留用于辅助色或高对比度(用于焦点或“行动号召”按钮,在这里是橙色)。让我们继续分析这个页面,看看其他 UI 设计原则。

激发重复和保持一致性

这个原则基于人们通过重复学习的概念。它表明,即使是通过不同的界面完成的相同任务,也应该模拟出更多或更少的重复行为。例如,如果你要求用户打开一个文字处理器,并要求他们打开一个文件,他们会尝试在哪里点击?大多数“经验丰富”的用户会将鼠标移到窗口的左上角,寻找代表“打开”的图标,或者文件菜单。为什么是这样?因为这已经成为了标准,我们通过重复学习知道了在哪里可以找到它。如果你将文件菜单放置在屏幕的右下角,大多数,如果不是所有用户,在没有指示的情况下都会很难找到它。

当显示视觉元素时,重复和一致性的另一个例子出现在它们是列表或常见集合的一部分时。让我们通过 Packt 出版社网站的一个例子来继续:

图 11.2 – 书籍卡片

图 11.2 – 书籍卡片

在前面的例子中,设计师通过重复使用垂直的“卡片”来展示黄金时段的书籍。每个卡片都重复了相似的布局、颜色和格式。一旦你理解了一个卡片显示的内容,同样的规则也适用于其他所有卡片:这是视觉设计的重复,这一点很重要,因此用户不需要为每一本书重新学习界面。

简而言之,它是放置、动作和视觉风格的重复。让我们转向下一个。

注意对齐和方向

元素(图形、字体等)的对齐创造出一种秩序和组织感,表明这些元素属于同一组或具有相同的权重或重要性。我们在处理字体时大多熟悉对齐(和间距),但同样的概念也适用于图标、部分、图像等图形元素。从上一幅图中,注意标签是如何对齐的,以及卡片及其内容。仅通过对齐和风格,我们就可以区分哪些属于哪个自然分组。

当仅使用排版来显示菜单和功能时,可以看到对齐的另一个例子。例如,在这个页面的页脚中,即使没有使用图标或视觉边界,仅通过使用空间和对齐,也很容易看出哪个选项属于每个自然分组:

图 11.3 – Packt 网站页脚,使用对齐显示自然分组

图 11.3 – Packt 网站页脚,使用对齐显示自然分组

虽然这个例子很简单,但它已经展示了对比(粗体与正常字体粗细)、重复的一致性、对齐,以及我们接下来将要看到的下一个原则:邻近性。

使用邻近性和距离来显示自然分组

这个原则遵循起来很简单:将自然相关的元素放置在彼此附近。这使得用户更容易找到和理解。排版和图标学也可以用来显示邻近性和类似的功能。微软在其 Office 应用程序中引入的著名“功能区”就大量使用了这个概念,并很快成为标准。例如,这里是一个处理对齐、列表、间距和缩进的主页标签段:

图 11.4 – 段落图标

图 11.4 – 段落图标

注意到修改段落类型、间距和对齐的图标彼此靠近,而没有“混合”。

这些不是 UI 设计的唯一原则,但它们是我们开发组件时应该牢记的最基本的原则。如果你与包括 UI 设计师在内的团队一起工作,你可能会收到模板或原型,甚至故事板,以实现 Vue 组件,正如我们在第四章,“组件的用户界面组合”中看到的。如果你是“单打独斗的团队”的一员,并且界面的设计落在了你的肩上,这些原则将极大地帮助你创建专业且实用的 UI。但还有更多...

UI 设计定律

就像我们有原则一样,几个研究已经发布或确定了设计“定律”,这些是可以预测某些软件属性的可测量函数,例如可用性和友好性。特别是以下这些突出。

Fitt’s 定律

这条规则规定,用户获取目标所需的时间是目标距离指针和目标大小的函数。这里的重要概念是目标距离和大小:目标之间的距离越长,这些目标应该越大。

这条规则的应用使得窗口关闭按钮(X)位于屏幕的一个角落(当窗口最大化时),开始按钮位于屏幕左下角,显示桌面位于屏幕右下角,等等。如果用户将鼠标移动到这些方向中的任何一个,迟早会到达这些目标,并且继续在同一方向上移动不会影响结果。在 UI 设计的语言中,这些按钮是“无限”的,因为一旦光标通过屏幕边缘到达一个角落,继续用鼠标在同一方向上滚动仍然会击中目标。

这条规则的一个影响是,相似的功能也应该放置在彼此附近(例如图 11.4中的例子)。有关这条规则更详细的信息可以在维基百科上找到,网址为en.wikipedia.org/wiki/Fitts’s_law

Hick’s law

这条规则表示,用户做出选择所需的时间是基于可用选项数量及其复杂性的对数函数。简单来说,选项过多会让人困惑,并使用户花费更多时间来做出决定。这条规则的一些影响如下:

  • 将复杂任务分解成更小的组,以便用户可以快速做出决定

  • 避免创建选项过多的界面,因为它们会使用户感到不知所措

  • 如果一个功能是时间敏感的,将选项减少到最基本的情况

在日常软件中,我们可以在多个地方看到这条规则的应用——例如,在特定软件的“安装程序”中,在安装开始前或开始时,以顺序方式向用户展示带有选项的连续窗口,而不是填写表格。有关这条规则更详细的信息可以在维基百科上找到:en.wikipedia.org/wiki/Hick%27s_law

本·施奈德曼的八条规则

1986 年,本·施奈德曼教授出版了名为《设计用户界面:有效人机交互策略》的书籍,其中他规定了八条界面设计规则。这些规则自创建以来一直具有相关性,因此在这里值得提及:

  1. 力求一致性(在动作、步骤、位置等方面)。

  2. 允许使用快捷键执行常见任务(无论是使用键盘还是图标)。

  3. 提供有信息的反馈(尤其是在发生错误时)。

  4. 设计带有终点的对话框

  5. 提供简单的错误处理,以便用户可以快速采取行动,避免用户犯严重错误。规则#4#5的经典实现是在执行永久性操作(如删除内容)之前进行“确认对话框”。

  6. 允许轻松撤销操作(多亏了这一点,我们才有 Ctrl + Z!)。

  7. 支持用户的控制感。没有什么比用户感觉到机器“在做它自己的事情,失控”更糟糕的了。如果你不小心发送了一个 800 页的文件去打印,并且在你能够真正取消操作之前需要翻到第 12 页……这就是这个问题所在。

  8. 减少短期记忆负担。用户一次只能记住少量项目和任务,屏幕上的元素(菜单等)过多会引发拒绝。这一原则也与希克定律有关。

对于实际应用和该主题的介绍,这些原则、规则和法律应该为我们提供一个稳固的基础。

关于这些规则和本·施奈德曼的更多信息可以在维基百科上找到(en.wikipedia.org/wiki/Ben_Shneiderman)。

UX 设计原则

UX 设计也有其自己的目标和原则,这些原则适用于模式。最重要的是,UX 的主要目标是向用户提供良好的感知,通过精心定制交互流程,与品牌或产品建立联系。在这种情况下,解决方案必须如下:

  • 有用且易用:首先,应用程序必须完成它打算做的事情,做得好,并且用户使用起来容易。

  • 易于学习和记忆:用户必须能够学习和理解所呈现的信息,并将其内化以供将来使用。

  • 可信并赋予用户控制权:当用户与应用程序交互时,必须感觉到它正在执行用户意图的事情,并且结果对用户来说是“安全”的。

最后一条原则非常重要。如果用户感觉在交互过程中应用程序在某个地方“失去了对正在发生的事情的控制”,那将是一场灾难。遗憾的是,这种情况在我们将在本章后面看到的暗模式中发生得太频繁了,但现在,让我们看看一些好的模式,这些模式可以为用户提供良好的体验。

数据操作常见模式

这些模式通常由纯 HTML 元素匹配,而另一些则是近年来通过巧妙地使用这些元素的样式而出现的。这些已成为行业标准,并且用户一眼就能理解。以下是一个非排他性的列表,简要描述了何时使用每个模式。

HTML 输入元素

HTML 提供的标准输入字段是接收用户输入的清晰模式。如今,由于type属性,输入元素有许多变体,允许输入除了纯文本以外的内容。在表单和验证库中使用,这些元素是现成的,可以读取和格式化从文本和数字到 URL、日期、时间、图像和颜色。今天浏览器支持的可用类型完整列表可以在此处找到:developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#input_types

在大多数情况下,这些元素与基本功能和一些重 CSS 样式一起使用。当需要用户输入文本信息时,使用inputs(和textareas)。今天的浏览器为更复杂类型提供了原生外观的小部件,例如日期和颜色选择器。

复选框、单选按钮和切换开关

复选框和单选按钮由 HTML 原生提供,并按照本地操作系统或环境的格式呈现给用户。复选框向用户表示多个选项,他们可以从组中自由选择。相比之下,单选按钮只允许从列表中选择一个选项:

图 11.5 – 左侧的复选框和右侧的单选按钮

图 11.5 – 左侧的复选框和右侧的单选按钮

随着原始 iPhone的发布,一种新的复选框变体变得非常流行:切换开关。它不是 HTML 标准原生提供的,但可以通过 CSS 轻松地“伪装”成复选框。切换开关有两种状态,启用和禁用,它通常用于激活或禁用功能或特性。这是一个重要的区别,因为复选框应该关注选项或替代方案。以下是一个例子:

图 11.6 – 每个状态的切换开关

图 11.6 – 每个状态的切换开关

当切换开关向左时,切换开关处于“关闭”状态(或 false),当向右时处于“开启”状态。通常,切换还会影响颜色,关闭时以灰度哑光色调显示,激活时以鲜艳的颜色显示。内部,这两个状态通常表示为true(开启)和false(关闭),它们应该用于激活或禁用设置、功能等。您可以在本章的存储库中找到实现样式和v-model代码的Toggle组件。

芯片、药丸或标签

这种模式包括一个圆形框内的简短文本(或“副本”在 UI 术语中),副本可以伴随一个图标以强调选中时的状态,或者一个动作,例如“span元素”)。

图 11.7 – 带有药丸的物品列表

图 11.7 – 带有药丸的物品列表

这是一个捕获用户输入的 UI 模式简短列表,但它确实涵盖了最常见的类型:HTML 输入和样式变化。

数据可视化的常见模式

这些模式将信息反馈给用户,无论是响应用户操作还是应用程序事件。以下是一个非排他性的模式列表。

工具提示

此模式向用户显示有关目标元素的一些浮动小文本信息,通常当用户使用某些操作(悬停、点击、选择等)激活元素时。信息以“话泡”的形式显示在元素上方、下方或旁边(如漫画书中)。以下是一个示例:

图 11.8 – 显示图标名称/操作的提示和快捷键的工具提示

图 11.8 – 显示图标名称/操作的提示和快捷键的工具提示

此模式主要用于显示有关目标对象的帮助信息,但也可以用于显示上下文菜单。例如,在在线文本编辑器中选择此段落中的单词将显示一个带有菜单的弹出窗口:

图 11.9 – 用于显示上下文菜单的工具提示模式

图 11.9 – 用于显示上下文菜单的工具提示模式

可能会有一些关于这种最后使用的是工具提示还是上下文菜单的讨论,但它在选择时打开的事实表明是前者。传统上,上下文菜单是通过次要操作打开的,例如右键鼠标按钮(在 Windows 和 Linux 中)。在任何情况下,此模式的概念是要在用户选择或预激活时向用户显示信息,以帮助他们决定下一步要做什么。

通知点、气泡、标记或徽章

此模式由在较大图标上显示的小图标组成,以表示已发生事件并需要用户的注意,但不是紧急的。这个小图标可以是点、气泡等。如果通知中包含数字,它也被称为徽章。以下是一些此模式的示例:

  • 在电子邮件图标中的带有数字的小圆圈表示收到的新电子邮件

  • 在聊天应用程序中的双勾表示对方已接收并阅读了消息

  • 在任务栏上的一个小标记表示已打开应用程序

关键概念是使用某种小变化来表示图标需要未来关注,但这不是紧急的,也不会影响用户当前的活动。

吐司通知

这种模式在多个应用程序和操作系统中被使用。它包括在短时间内显示一个带有用户快速信息的叠加浮动窗口。通常,它包括一个简短的文字片段和合适的图标。根据操作系统,这可以在屏幕顶部中央、右侧或系统托盘上方右侧显示。Web 应用程序可以在浏览器窗口内实现自己的通知,或者要求用户授权并使用本地操作系统显示“原生通知”。以下是一个通知的例子:

图 11.10 – 桌面上的原生通知

图 11.10 – 桌面上的原生通知

这些通知有助于通知用户需要他们注意的环境变化,报告异步操作的结果(成功、错误等),等等。这些引起注意的调用是短暂的,因此它们不应该成为重要工作流程的关键部分,除非是上述提到的条件。

旋转木马或图片滑块

旋转木马或图片滑块是一种在同一空间内按顺序显示不同带有图像和内容的板块的模式。通常,使用计时器,这些板块每隔几秒钟向用户展示一次,同时也提供了通过点状导航跳转到任何一个板块的选项。虽然它们几年前非常流行,但使用这些模式也有一些反对方,因为“不耐烦”的用户可能永远看不到旋转木马的全部内容。在实践中,建议尽可能缩短板块列表,大约在三个到五个元素之间。

这种实现的例子在互联网上随处可见,因为它们主要在网站的首页实现。实际上,几乎所有购物和新闻网站都使用了这种模式——例如,亚马逊的首页(www.amazon.de):

图 11.11 – 亚马逊的旋转木马式展示商店的优惠

图 11.11 – 亚马逊的旋转木马式展示商店的优惠

图片滑块可以像旋转木马一样大,也可以小得多,用于显示缩略图。这里再次给出一个来自亚马逊的例子:

图 11.12 – 亚马逊很好地利用图片滑块向用户展示商品

图 11.12 – 亚马逊很好地利用图片滑块向用户展示商品

虽然实现起来很简单,但使用旋转木马和图片滑块是向用户展示信息的好方法。不过,有一个警告,滥用这种模式可能会导致用户感到压倒性和饱和,造成混淆,甚至引发不希望出现的副作用,如感官过载和内容回避。

进度条和旋转器

进度条是任何能够向用户展示工作流程当前状态进度的元素。虽然名字似乎暗示了一个“条”,但实际上,任何通过有限数量的动作显示进度的元素都属于这一类别。基本目标是通知用户耗时任务的进度,并显示系统正在“忙碌”地处理它们,从而为用户提供可见性和控制感。进度条对于防止用户无意识地执行负面操作非常重要。如果一个长时间运行的任务在后台(比如在 web worker 中)执行,没有任何关于进度的反馈,用户可能会认为任务尚未开始,已经失败,或者计算机“挂起了”。让用户感到困惑是一种负面的用户体验。以下是一些进度条元素的示例:

图 11.13 – 进度条的示例

图 11.13 – 进度条的示例

除了样式之外,进度条还可以用于“不确定状态”,这意味着应用程序无法计算一个过程可能需要多少时间或多少步骤;然而,它仍然想通知用户系统正在忙碌,不应该被打断。HTML 标准确实提供了一个专门用于处理这些情况的元素(进度元素;见developer.mozilla.org/en-US/docs/Web/HTML/Element/progress),但在这些情况下还有其他模式可以使用,例如spinner

如“spinner”这个名字所暗示的,这是一个“自转”的图标,给人一种应用程序正在忙碌并工作的印象。以下是一个带有文本指示器的 spinner 示例:

图 11.14 – Spinner 圆形,指示应用程序正在忙碌

图 11.14 – Spinner 圆形,指示应用程序正在忙碌

这种方法在操作系统和应用程序中已经使用了一段时间,因此用户理解其含义。在使用这种模式时有一个需要注意的问题,那就是经过一段时间后,它可能会引起焦虑,因此建议伴随某种动作指示器。在 Vue 中实现spinner组件相当简单,主要使用 CSS:

./components/Spinner.vue

<script setup>
const $props=defineProps(['caption'])
</script>
<template>
    <div>
        <span class="spinner"></span> {{ $props.caption }}
    </div>
</template>
<style scoped>
.spinner{
    display: inline-block;
    height: 1rem; width: 1rem;
    border: 2px solid;
    vertical-align: middle;
    border-radius: 50%;
    border-top-color: #06c9c9;
    animation: rotate 1s linear infinite;
}
@keyframes rotate {
    0%{ transform: rotate(0deg);}
    100%{transform: rotate(360deg);}
}
</style>

在这个简单的组件中,我们只需要定义一个用于文本的 prop 和一个用于旋转元素的 class。这里通过设置边框半径并定义一个边框的颜色来制作圆形,这样旋转动作就非常明显。

分页和无限滚动

当我们需要向用户显示长列表项时,有两种模式会浮现在脑海中,作为人们熟知的解决方案:分页和无限滚动。

分页 中,数据集被分成顺序排列的固定大小的更小部分。每个子集被称为 页面,并通过序号(通常是数字)进行引用。这允许在页面之间进行轻松导航,例如通过页码进行随机和顺序访问。此外,在数据中提供相同的排序函数允许在不同的会话中快速轻松地“返回”到数据。允许用户在分页数据中导航的元素通常被称为 分页器,并且通常的做法是将它放置在项目列表的顶部和底部。一个典型的分页器可能看起来像这样:

图 11.15 – 典型呼机元素

图 11.15 – 典型呼机元素

在前面的图中,你可以看到典型分页器的不同元素,通常用于表格或内容列表。然而,分页器可能采取的形状不止这一种。例如,它可以使用下拉菜单来显示页码,显示页面范围等。这个模式的重要概念是集合的划分和快速导航到每个单独的小组。

分页的另一种选择是使用 无限滚动器。在这个模式中,数据集中每个项目的确切位置可能需要也可能不需要,项目在用户滚动网页时呈现给用户。当用户到达列表的末尾时,新项目会以块的形式加载到页面上,直到用户停止滚动或整个数据集已加载完毕。以下是这种模式的图形表示:

图 11.16 – 无限滚动器的实现

图 11.16 – 无限滚动器的实现

为了检测用户行为和加载数据或预加载数据,使用了多种技术。其中一种最简单的实现方式是通过使用一个 交叉观察者,这是浏览器在 JavaScript 中提供的一个原生元素,当这样的观察者与其它元素(在这种情况下,与视口)相交时,会触发一个事件。由于这是一个新概念,我们将实现一个最小化的无限滚动器,其外观如下:

图 11.17 – 限制在 div 元素上的示例无限滚动器

图 11.17 – 限制在 div 元素上的示例无限滚动器

如果你通过任何可用的方式(鼠标、键盘等)在 div 边界处向下滚动,列表将生成新的项目,并且永远不会停止,给人一种滚动是无限的印象。以下是该组件的源代码:

./src/components/InfiniteScroller.vue

<script setup>
import { ref, onMounted } from "vue"
const _max_value = ref(30),                               #1
    _scroll_watcher = ref(null),                          #2
    observer = new IntersectionObserver(triggerEvent)     #3
onMounted(() => {
    observer.observe(_scroll_watcher.value)               #4
})
function triggerEvent() {_max_value.value += 20;}
</script>
<template>
    <div v-for="elem in _max_value" :key="elem">
        item {{ elem }}
    </div>
    <div     ref="_scroll_watcher"></div>                 #5
</template>

前面的组件是最基本的,但它确实说明了这种技术。我们将有一个数字列表,初始限制将触发容器中的溢出(#1)。这很重要,因为在第一次加载时,用户会知道有一个滚动条和更多内容在下面(如图 11.17所示)。现在,这里的技巧是定义一个指向null的响应式变量_scroll_watcher。这个变量将后来具有列表底部元素的值,我们将其标记为参考(#5)。我们使用null是为了 Vue 在此点不运行任何优化。在行#3中,我们创建了一个新的IntersectionObserver,并将triggerEvent函数的引用作为值传递,我们将简单地增加列表的边界。在我们的模板中,我们使用v-for指令生成列表中每个整数的元素,这些元素应该出现在我们的滚动监视器元素之前。魔法发生在行#4,一旦我们的组件被创建并挂载到页面上。在这个时候,Vue 已经将_scroll_watcher分配给了 HTML 元素的引用,因此我们可以将其传递给我们的IntersectionObserver实例。由于我们使用默认值,它将在相关 div 出现在视口中时运行triggerEvent函数,这将在我们到达列表末尾时发生。在这个函数中,我们再次增加项目数量,使 Vue 将更多元素注入到网页中,并将滚动监视器 div 再次推出视口。这个过程无限重复,给我们一个简单但有效的无限滚动器。

除了实现 UX 模式之外,这种技术是将响应式变量绑定到 DOM 元素上的正确方法,并且它使我们免于编写直接的 JavaScript DOM 操作,例如document.getElementById("#someId")和处理诸如 ID 冲突等问题。Vue 为我们解决了这个问题。

交互和导航的常见模式

这些模式控制着交互或为用户提供控制应用程序过程和导航的选项。通常情况下,其中一些模式也可能适合其他类别。

菜单的位置

菜单的位置也是三种基本布局中已经标准化的模式之一:

  • 菜单栏(水平)通常放置在屏幕顶部的一个“粘性”位置(这意味着它们不会随着页面滚动而滚动)。

  • 导航栏(水平),主要适用于移动设备,是在屏幕底部放置的一个图标菜单,用于导航到应用程序的不同部分。

  • 侧边栏覆盖整个屏幕高度,并且具有可变宽度。这些显示带有图标和/或文本的菜单。在屏幕真实状态对移动和桌面应用程序很重要的应用中,通常有一个选项可以切换它进入或退出视口。触发此功能的标准图标已成为“汉堡”图标(见本章后面的内容)。

遵循桌面和移动端的标准布局将确保用户能够轻松地导航网站。通常,一些应用如视频游戏会打破这些标准,但除非有强有力的理由这样做,否则应避免。

面包屑

面包屑是一个链接的层级列表,显示了网页在整体网站组织中的当前位置。每个链接都允许用户返回一个层级,无需使用浏览器的后退按钮或深入主菜单。当前的做法是将面包屑放置在页面顶部,在主要内容之前。以下是一个面包屑路径的示例:

Home > Level 1 > Level 2 > Level 3 > Current page

习惯上使用大于号(>)来分隔每个导航页面,但在这方面有很多艺术许可。这种导航的另一种表示方法是使用树状结构,如下所示:

Home
  └── Level 1
   └── Level 2
    └── Level 3
     └── Current Page

这种“文件夹式”结构在主要导航中并不常见,但主要用于嵌套内容,如论坛中的评论和回复。

模态对话框

模态对话框是一个在应用程序前面打开的小窗口,完全控制焦点。它阻止用户与应用程序的其他部分交互,直到对话框中展示的活动得到解决。模态对话框应专注于单一操作,并通过提供清晰的选项,为用户提供足够的信息以便采取行动。以下是一个示例:

图 11.18 – 确认对话框

图 11.18 – 确认对话框

我们之前已经实现了一个在 Vue 3 中显示模态对话框的系统,因此您可以查看第五章,“单页应用程序”中的代码,以查看实现方法。

菜单图标

除了菜单项的普通文本命名约定外,今天还有许多图标用于在用户第一眼看到菜单时展示预期的交互类型。以下是您今天可以找到并使用的标准图标:

图 11.19 – 菜单图标

图 11.19 – 菜单图标

下面是它们的描述:

  • 汉堡图标(三个水平线):用于主菜单和导航,此图标切换侧边栏中宽网站菜单的显示。这些显示和隐藏方便的侧边栏类型被称为抽屉,在移动网站和应用中非常流行。

  • 咖喱棒图标(三个垂直点):由谷歌的 Material Design 流行起来,表示当前元素或活动有更多选项,它们将在模态对话框中显示。

  • 肉丸图标(三个水平点):显示在列表项旁边,表示有包含额外操作的弹出菜单。

  • Bento 图标(3x3 网格中的 9 个方块):用于指示弹出菜单,在同一个解决方案或环境中导航不同的应用程序

  • 多纳圈图标(不同大小的三个堆叠线条):这表示使用可选顺序选项(通常在弹出窗口中)对列表条目进行排序的选项。然而,这个图标并没有像其他图标那样流行。

展开菜单

这种模式的基本用途是在标题下分组内容,并在用户选择时显示它,一次只显示一个组。如今,它常用于常见问题解答页面和侧边栏菜单。以下是从 Packt 网站(www.packtpub.com/)的一个示例:

图 11.20 – 用于常见问题解答的展开菜单

图 11.20 – 用于常见问题解答的展开菜单

展开菜单是一个用户理解良好的模式,并且实现起来相对简单。它有助于保持设计整洁,并使用户能够只关注对他们重要的事情。

大菜单

到目前为止,我们看到的多数模式的目标是隐藏复杂性,以避免使用户感到不知所措。然而,这种模式乍一看似乎打破了这一规则。当导航的复杂性会使功能难以找到(例如,超过三个级别的深度:组 -> 子组 -> 子子组)时,一个替代方案是使用大菜单。这种模式通常用于政府网站和其他具有大量不可避免内容的复杂组织。基本概念是展示一个包含所有(或大多数)可选选项的大菜单。这并不意味着在这些部分之后不会有“深入挖掘”,但它确实使访问更快。例如,让我们看看芬兰 Hyvinkää市的官方网站(www.hyvinkaa.fi):

图 11.21:Hyvinkää 市主页大菜单

如您所见,在顶部菜单中只需单击一下,就有许多引人注目的选项。然而,请注意它们的组织多么井然有序,并且彼此之间视觉上分离。大菜单模式打破了简单性规则,但并未忘记 UI 设计的其他规则和原则,使其仍然赏心悦目。在其他情况下,也可以考虑将大菜单视为其他模式的占位符,例如侧边栏或展开菜单。官方 Packt 网站(www.packtpub.com/)在主菜单书籍 & 视频选项中使用了这种模式:

图 11.22 – Packt 大菜单带有侧边栏以分类和筛选选项

图 11.22 – Packt 大菜单带有侧边栏以分类和筛选选项

大菜单可能是一个创新的地方,因为它更容易让用户理解和使用。它并不是每个 Web 应用的可行选项,但使用得当的话可以非常强大。当没有其他选项,只能显示大量快捷方式或选项时,这是一个值得记住的好模式。

下拉菜单

当下拉菜单和选择框通过触发区域(图标、文本或按钮)被激活(例如点击或触摸)时,它们会向用户显示一个选项列表。在这里,隐藏选项并在用户“请求”时显示它们的概念比具体的实现更重要。例如,手风琴菜单和超级菜单就利用了这个概念。手风琴菜单可以被视为一系列下拉菜单,并不那么遥远。HTML 为选择列表提供了一个原生解决方案(select元素),但同样的概念可以在许多情况下应用,并且通过一些创意,可以产生新的实现。

内容组织常见模式

我们接下来要看到的模式与网站或应用的总体组织和布局有关。

响应式应用

“响应式”这个术语与应用布局如何适应屏幕大小有关。你可能听说过“移动优先方法”,这是一种设计理念,首先为较小的屏幕设计,然后向上移动到可能的分辨率,达到桌面,这被认为是最大的。虽然可以通过 JavaScript 实现这一点,但最合理的直接方法还是使用经过深思熟虑的设计和 CSS 媒体查询。

根据应用的不同,有一些公式可以创建一个响应式网站,但分析许多替代方案超出了本书的目标。相反,我们将只看到一个例子,使用“切换列”方法。这种方法基本上为移动(或窄肖像屏幕)设置了单个垂直列中的主要内容,部分部分。主导航栏或菜单放置在屏幕的顶部或底部,始终可见。然后,对于桌面,导航栏完全或部分移动到顶部菜单或侧边栏,主列的内容移动到水平部分,一个接一个地堆叠。这个方法通过这个图例来说明,更容易理解:

图 11.23 – 从移动优先到桌面端的转换

图 11.23 – 从移动优先到桌面端的转换

如前图所示,部分始终保持在相同的顺序,但内容内部从垂直布局方向适应到水平布局方向。这个概念设计起来既简单又干净,已经成为大多数着陆页的标准。一旦你理解了这个模式,你开始看到它在任何地方的应用。

通过使用 CSS flexbox 模型并在部分级别改变方向从垂直到水平,这是一种简单的编码方法来实现这种变化。以下是一个例子:

// For mobile
    @media only screen and (max-width: 600px) {
         section{
             display: flex;
             flex-direction: column;
         }
    }
// For desktop
section{
    display: flex;
    flex-direction: row;
}

注意代码中已经包含了一个在 600 像素宽度的断点。你可以通过应用多个带有断点的媒体查询来控制不同的屏幕尺寸。

首页链接

这种模式如此普遍,以至于我们甚至没有意识到它。主要的公司标志被放置在页面左上角,作为返回主页的链接。这个位置不是随机的,它源于用户“扫描”页面的一种方式。不同的用户在页面加载时会快速浏览,引导眼睛在页面中做ZLT形状的运动。将标志作为链接放置在左上角确保它是用户首先注意到的项目。在本章中,图 11.21图 11**.22是这种模式的良好例子。但是,也有一些位置上的例外,比如谷歌的主页:

图 11.24 – 谷歌的主页是这种模式的例外,因为标志不是链接

图 11.24 – 谷歌的主页是这种模式的例外,因为标志不是链接

然而,前面图示中的例外只是暂时的,因为谷歌在展示搜索结果后会回到这个模式:

图 11.25 – 谷歌的搜索结果应用了这种模式

图 11.25 – 谷歌的搜索结果应用了这种模式

主页链接模式应该将您带回到主页或流程的第一步。这种模式非常普遍,并且用户都能理解,因此任何例外都应该非常谨慎地做出,并且对用户群体的行为和交互有很好的理解。

英雄部分、行动号召和社交媒体图标

英雄部分是当页面在浏览器中加载时首先显示的部分,从最顶部(主页链接和主菜单所在的位置)延伸到屏幕的可见底部。这个部分以下的所有内容被称为“页脚以下”,意味着要看到它,用户需要滚动页面。英雄部分被认为是主页最重要的部分,也是初始行动号召放置的地方。我们在这章之前讨论 UI 设计原则和对比时已经提到了这个概念。如今,大多数网站都会遵循这个模式,并通过对比显示英雄部分,使用大图像或轮播图,以及突出的行动号召。

图 11.26 – Kobold Press 主页的英雄部分 – 鲜艳且直接

图 11.26 – Kobold Press 主页的英雄部分 – 鲜艳且直接

在 Kobold Press 的先例(koboldpress.com/)中,我们可以看到他们如何应用了这里看到的一些模式,从主页链接和主菜单到英雄部分和行动号召。注意,在左侧,社交媒体图标作为浮动侧边栏的显示。将此类图标及其链接放置到每个机构的社交媒体地址,或者在某些帖子或文章中,链接到“分享”内容,已经成为一种模式。以下是一些常见的放置社交媒体图标栏的位置:

  • 在网站页脚

  • 在文章的开头和/或结尾

  • 在产品描述中

当包括链接以分享来自 Vue 应用程序(例如,来自在线商店的产品)的直接内容时,链接和 Vue 应用程序直接指向此类项目非常重要。需要仔细注意导航 URL 的形成和 Vue 应用程序在首次加载时的解释方式,以避免分享会打开主屏幕而不是应用程序所需部分的链接。

其他模式

还有其他模式,如果不说数百也有数千,它们对用户体验有直接影响。我鼓励你继续调查这些模式,例如以下内容:

  • 购物车模式

  • 用户入门

  • 游戏化模式

  • 注册和注销模式

然而,用户体验也有其阴暗面,这与使用操纵和欺骗性做法有关。作为一名道德的开发者,你应该避免使用任何这些暗模式。

暗模式

暗模式是精心设计的界面和交互,其唯一目的是操纵或欺骗用户执行非预期操作,甚至使他们陷入恶意结果。在这样定义之后,你可能认为这样的行为属于互联网最阴暗的角落。遗憾的是,即使是主流公司也经常遵循这些不道德的做法。实际上,这些章节中复制的例子都属于这一类,而且正如设计模式经常发生的那样,许多这些模式相互重叠或可以嵌套在一起。让我们逐一看看。

陷阱问题

这种模式是一种简单或复杂的文字游戏,目的是欺骗用户去做与他们意图相反的事情。以下是一个例子:

图 11.27 – 有关订阅通讯的陷阱问题

图 11.27 – 有关订阅通讯的陷阱问题

正如你在本例中看到的,如果一个用户填写表格时不想收到公司的通讯,他们可能会被诱惑不勾选这个复选框。常见的倾向是关注加粗的文字,这相当于一个标题。复选框的实际操作可以在段落的末尾找到,但大多数用户不会阅读:如果您不想收到 我们的通讯,请勾选此选项。

悄悄放入购物车

这种暗模式出现在许多购物应用中,在诸如托管和机票等服务中相当常见。它包括在结账时将商品放入购物车,但用户并未选择,通常以优惠或“必需”的小额商品的形式呈现。以下是一个例子:

图 11.28 – 购物车中已添加额外项目

图 11.28 – 购物车中已添加额外项目

在这个例子中,在订购新域名后,购物车中自动添加了一个新项目,首次设置。这个项目没有其他解释,与购买的主要目的相比,金额看起来“很小”。通常,这类项目是“欺诈性”的,没有其他意图,只是通过一点一滴地增加最终金额。在某些情况下,在确认购买之前可能有选项移除此类项目,但很多时候却没有。

蟑螂旅馆

这种模式在服务和订阅中相当常见。它包括在免费试用期后进行非常容易的购买,条件是费用将继续,直到用户明确取消。这就是暗模式出现的地方:通过使“取消订阅”过程变得复杂或无法完成。例如,一些公司要求用户通过带有法律身份证件的签字信件联系支持团队。基本概念是“陷阱”用户,使取消合同服务变得几乎不可能。

隐私诱导

这种暗模式是以一家知名社交媒体公司的创始人为名的。它包括向用户提供大量免费服务,同时应用程序会监控用户的活动和行为。然后,这些数据在幕后被收集并出售给第三方公司,而用户并不知情。通常,这种做法会在相当长的服务条款合同中提及,用户在使用服务之前需要接受。这样,公司声称用户已经给予了同意并且知情,而实际上很少有用户会正确阅读或解释这样的协议。

阻止价格比较

在这种模式中,网站向用户展示了一系列服务计划,但故意隐藏或伪装了功能或个别价格,因此用户无法直接比较以选择最合适的选项。

价格被隐藏或伪装得让用户无法做出明智的决定,必须根据功能或其他属性选择选项。

引导错误

这是另一种常用于购物车工作流程的模式。它包括使用对用户来说令人困惑的名称和选项,以及预先选择的选项,这些选项隐藏了替代方案和更好的价格优惠。如果系统有一个浮动价格值(例如,酒店或飞机票),这种模式通常被用来操纵用户选择对公司最有利的选项。

隐藏成本

在这种模式中,用户选择的产品或服务并未披露涉及的相关成本的总和或全面描述(除税收外)。无论是在初始购买还是后续购买中,支付的总金额都高于用户最初估计的价格。

诱饵和更换

这种模式被在线广告商广泛使用,并且是导致用户最讨厌的一种。它简单地将一个选项伪装成执行另一个或用户原本意图的相反操作。一个典型的例子是,当弹出窗口显示一个关闭按钮(通常是简单的X)时,但用户点击它意图关闭对话框窗口时,它会打开一个新标签页,显示广告网站。

确认羞辱

这是一个高度操纵性的模式,它涉及故意使用措辞或行动来羞辱和嘲笑用户,使他们做交易开始时不想做的事情。它可以从轻微的烦恼到直言不讳的侮辱。它经常与其他暗黑模式一起使用。以下是一个例子:

图 11.29 – 从购物车中删除服务时的确认对话框

图 11.29 – 从购物车中删除服务时的确认对话框

在示例中注意,操作按钮的措辞非常具有操纵性,尽管原始问题并非如此。这种模式是一把双刃剑,因为用户可能会感到整个服务的拒绝并取消整个操作。

伪装广告

当广告被注入到页面中时,它可以伪装成适当的内容,模仿样式和操作按钮,目的是欺骗用户触发重定向或下载文件。在某些情况下,伪装如此之好,以至于无法区分网站的号召性用语和广告。这种模式在提供托管文件下载的免费网站上很受欢迎,在这些网站上,页面上通常会有多个下载按钮,但只有一个是实际下载所需文件,其他则将用户重定向到第三方网站。以下是一个例子:

图 11.30 – FileHippo.com 托管免费软件。一些广告伪装成下载按钮

图 11.30 – FileHippo.com 托管免费软件。一些广告伪装成下载按钮

在这个来自FileHippo.com的屏幕截图中,如果你访问 VLC 媒体播放器的下载页面,该网站会提供多个下载按钮。如果用户没有注意,可能会触发与预期软件下载不同的操作。

友好型垃圾邮件

在这个暗黑模式中,应用程序请求访问用户的联系人,目的是扩大他们的网络或社交圈。如果用户同意,他们的整个联系人列表将被“垃圾邮件”式地发送电子邮件,仿佛来自用户,提供该服务。通常,一旦这些信息被分享,也会与第三方实体和广告商分享。

暗黑模式的列表可能不直接与单一媒体或框架相关联,但这个行业的从业人员的道德责任是避免或预防它们。

摘要

在本章中,我们看到了向用户呈现满意用户体验的重要模式。我们还学习了与设计师平等对话并相互理解的重要术语——这是协作和向用户基础提供最佳可能积极体验的必要点。我们还回顾了一些最常见的暗模式,这些模式是操纵用户的技术和工作流程实现,通常剥夺他们的隐私和资源。虽然主要是信息性的,但本章应该让你更好地理解构建网络应用程序的环境,以及为了易于使用应遵循的标准。所有这些都是优秀工程师和开发者应该了解的知识。

复习问题

这里有一些简单的问题可以帮助您总结本章学到的内容:

  • UI 和 UX 模式是什么?

  • 你能说出使用 UX 模式的益处吗?

  • 你如何在 Vue 3 组件中使用标准模式获得好处?

  • 什么是暗模式,为什么应该避免它们?

附录:从 Vue 2 迁移

将 Vue 2 应用程序迁移到 Vue 3 并不像只是替换框架那样简单。虽然 Options API 仍然完全兼容,并且不应该需要迁移到 Composition API,但还有一些其他破坏性变化我们应该注意。

版本之间的变化不仅影响核心框架,还影响生态系统(新路由、状态管理等)和其他依赖项。在 Vue 3 中,还有一个新的官方打包器 Vite(取代 WebPack),一个新的路由和状态管理(Pinia,取代 Vuex),以及其他插件。这里列出的变化列表是一个快速参考,以帮助您迁移应用程序,但可能不会详尽无遗地涵盖每个特定项目特定需求的细微差别。因此,我将向您推荐官方迁移文档 v3-migration.vuejs.org/

这里是一个非排他性的主要变化列表,除了新的 Composition API 之外:

  • 启动和启动应用程序的不同方式

  • 全局组件和插件注册的变化

  • data 属性的变化

  • v-modelpropsemits 的变化

  • 响应式选项

  • 框架浏览器兼容性

  • 目录和文件组织的变化

  • 路由和状态管理的变化

这个列表并没有显示框架在内部经历了所有变化,但它将为您提供一个起点,将您的工作应用程序从 Vue 2 迁移到 Vue 3。现在,让我们更详细地看看这些变化。

启动和启动应用程序的不同方式

启动和启动我们的应用程序的方式已经改变。现在需要我们从 Vue 包中导入一个构造函数。让我们从 main.js 中比较两种实现:

Vue 2 应用程序实例化

import Vue from "vue"
const app=new Vue({el:"#app"})

在 Vue 2 中,我们导入 Vue 构造函数并传递一个包含选项的对象。在 Vue 3 中,在创建应用程序之后,在将应用程序挂载到顶级组件之前,我们附加组件、插件等。以下是针对 Vue 3 重写的示例:

Vue 3 应用程序实例化

import {createApp} from "vue"
const app=createApp({..})
app.mount("#app")

index.html文件的位置也已经改变,现在放置在我们的应用程序的根目录下。你可以在第三章设置工作项目中看到对文档结构的更多更改。

注册全局组件、插件等

在 Vue 2 中,我们通过将其附加到 Vue 根实例来声明一个应用程序范围内的组件(全局)。以下是一个示例:

import Vue from "vue"
import MyComponent from "MyComponent.vue"
vue.component("myComponent", MyComponent)
const app=new Vue({...})

在 Vue 3 中,我们而是在创建并挂载应用程序之后注册组件和插件。component(用于组件)、use(用于插件)和directive(用于指令)方法都是可链式的。以下是前面示例在 Vue 3 中的样子:

import { createApp }from "vue"
import MyComponent from "MyComponent.vue"
const App=createApp({...})
App.component("myComponent", MyComponent).mount("#app")

如果我们不需要引用应用程序,我们只需像这个示例中那样连接应用程序的实例化即可:

import { createApp }from "vue"
import MyComponent from "MyComponent.vue"
createApp({...}).component("myComponent", MyComponent) .mount("#app")

应用程序引导与描述组件(选项 API、组合 API 或 script setup)所使用的语法无关。

数据属性现在始终是一个函数

在 Vue 2 应用程序中,data属性存在差异。根组件有一个直接是响应式定义的属性,而所有其他组件都需要提供一个函数作为data属性,该函数返回一个对象。这导致了组件创建的不一致性。这个问题在 Vue 3 中得到了解决,因此现在所有组件都被同等对待,这意味着数据属性始终必须是一个返回对象的函数,该对象的成员将是响应式属性。

下面是main.js中根组件的一个示例:

createApp({
    data(){return {...}}
})

然后在所有其他组件中,你有以下内容:

export default {
    data(){return {...}}
}

注意,对于这些示例,我们使用选项 API 来提高清晰度。当使用script setup语法时,你不需要声明data属性。

有更多可选择的响应式选项

当使用组合式 API 时,我们有两种选项来创建响应式属性:ref()reactive()。第一个返回一个具有.value属性的响应式对象。第二个将作为参数传递的对象转换为具有响应式属性的相同对象。以下是一个示例:

<script setup>
import {reactive, ref} from "vue"
const
    data=reactive({name:"John", surname:"Doe"}),
    person=ref({name: "Jane", surname:"Doe"})
    // Then, to access the values in JavaScript
    // Reactive object
    data.name="Mary"
    data.surname="Sue"
    // Reactive ref
    person.value.name="Emma"
    person.value.surname="Smith"
</script>
<template>
    <strong>{{data.surname}}, {{data.name}}</strong><br>
    <strong>{{person.surname}}, {{person.name}}</strong>
</template>

注意语法上的差异。在这个阶段,你可能需要考虑何时使用其中一个。以下是对何时使用每个选项的小比较:

ref() reactive()

|

  • 适用于任何数据类型,而不仅仅是原始类型。

  • 当应用于对象或数组时,你可以替换它们。

  • 它使用 getter 和 setter 来检测更改并触发响应性。

  • 对于简单数据,默认使用它。对于数组和对象(复杂类型),建议在处理其内部元素时使用reactive()。当整个对象将被替换时,使用ref()会更方便。

|

  • 适用于对象和数组,但不适用于原始数据类型。使它们的属性具有响应性。

  • 对象不能被替换,只能替换其属性。

  • 它使用Proxy()处理器的本地实现来检测变更并触发响应性。

  • 当您需要将大量必须“一起旅行”的变量分组时使用。

|

表 A.1 - 选择 ref()和 reactive()之间的简单指南

每种方法都有自己的优点。从复杂类型响应属性的角度来看,您使用哪个都无关紧要。在某些情况下,由于浏览器中使用了本地实现,reactive()可能更高效。

v-model、props 和事件的变更

这是从 Vue 2 的一个重大变化,可能会破坏您的代码。在 Vue 3 中,我们不再接收和发射属性值。相反,任何属性都可以作为输入/输出,例如v-model。默认的v-model属性以名为modelValueprop接收,对应的emit前缀为update:,因此称为update:modelValue

在 Vue 3 中,我们现在可以同时拥有多个v-models。例如,我们可以在组件中使用v-model:person="person",并将属性定义为"modelPerson",事件定义为"update:modelPerson"

Props 和 emits 现在是宏(宏是打包器或框架提供的特殊函数)。Props与 Vue 2 中的占用空间相同,因此您可以将其定义为数组、对象、包含类型、默认值等。

这里有一个带有默认 v-model 和注记模型的示例:

const $props=defineProps(['modelValue','modelPerson']),
$emit=defineEmits(['update:modelValue','update:modelPerson'])

在本书的第四章中更详细地讨论了 Props 和 emits,组件的用户界面组合

移除了旧浏览器的兼容性

Vue 3 是为了速度和“现代”JavaScript 而构建的。已移除对旧浏览器的向后兼容性。现在,用于响应性的许多内部函数默认使用本地实现(例如,Proxy API)。如果您需要支持旧版浏览器中的应用程序,您应该考虑继续使用 Vue 2,但不必担心!有一个官方插件可以让 Vue 2 使用新的Composition API,包括script setup语法:

  • Vue 2.7 无需插件即可包含它(blog.vuejs.org/posts/vue-2-7-naruto.html

  • 如果您使用 Vue 2.6 或以下版本,您可以在以下位置找到插件:github.com/vuejs/composition-api

  • 如果您仍然想要 Vue 3 的速度,有一个特殊的迁移构建,其 API 几乎与 Vue 2 相同(见v3-migration.vuejs.org/migration-build.html

  • 为什么移除旧浏览器的兼容性?原因有很多,包括以下:

    • 旧浏览器的全球使用率已经下降到显著百分比以下,预计未来还会继续下降。

    • 通过移除旧代码和兼容性检查,得到的 Vue 核心实现更轻量级和性能更高。速度的提升和包大小的减少非常显著,使得我们的应用程序加载更快,响应更灵敏。

实际上,有两个浏览器引擎占据了大部分市场份额:基于 Chromium 的浏览器和基于 Mozilla Firefox 的浏览器。如果您需要使用可能在旧浏览器中不可用的功能,请检查 www.caniuse.com

目录和文件组织的变化

Vue 2 中目录结构的组织在一定程度上受到当时打包器的影响,index.html 已经从 Public/ 文件夹移动到根文件夹。现在它在打包过程中有更突出的位置。这些和其他变化可以在 第三章设置工作项目 中找到。

路由和状态管理的变化

组件和模块化的新方法也影响了路由和状态管理。虽然 Vue 3 提供了新的路由版本,但状态管理的官方解决方案已经从 Vuex 转向 Pinia。关于新路由和 Pinia 的更多信息可以在 第五章单页应用程序第七章数据流管理 中找到。

新的路由现在采用不同的方法来定义模式,使用构造函数如 createWebHashHistory(哈希模式)、createWebHistory(历史模式)和 createMemoryHistory(仅内存导航)。此更改还影响了生产包的配置。在 WebPack 中,当处于历史模式时,部署路径是打包器配置的一部分。现在,路径作为参数传递给构造函数,由路由器完全处理。

新组件和其他变化

Vue 3 还引入了新的组件,例如 teleport(一个特殊组件,允许将响应式模板放置在 Vue 组件树之外,另一个 DOM 元素内部),同时也突破了 Vue 2 的一些限制。例如,组件现在可以有多个根元素。请参阅官方文档了解 Vue 3 中的新组件。

其他破坏性变化

要查看此处未提及的所有破坏性变化的完整列表,请检查官方文档 v3-migration.vuejs.org/breaking-changes/

摘要

从 Vue 2 迁移到 Vue 3 有一条清晰的路径,只需要注意少数破坏性变更。然而,新的组合 API 确实需要心态上的改变,但在使用script setup语法时,这种改变是自然而然的。但 Vue 3 最重要的特性是性能的提升和体积的减小。简而言之,Vue 3 速度快,非常快,迁移是值得的。对于支持过时浏览器的项目,Vue 2.x 分支的插件提供了一些 Vue 3 的优势,但对于寻求 Vue 3 积极收益的其他项目,迁移是值得的。

最后的话

恭喜你到达这本书的结尾!我们已经涵盖了从 Vue 的非常基础到最终产品部署的广泛主题。让我们一起来回顾每一章的主要概念:

  • 第一章Vue 3 框架,我们介绍了 Vue 的关键概念和编写组件时可用的一些不同语法选项

  • 第二章软件设计原则和模式,我们深入探讨了构建代码时的重要概念和经过良好测试的模式

  • 第三章设置工作项目,和 第四章使用组件的用户界面组合,我们学习了如何启动 Vue 项目以及如何将设计转换为可工作的代码

  • 第五章单页应用程序,和 第六章渐进式 Web 应用程序,可能是最重要的章节,我们学习了如何通过浏览器的原生功能创建具有导航和安装的高级应用程序

  • 第七章数据流管理,和 第八章使用 Web Workers 的多线程,我们学习了更多关于如何通过良好的实践提高性能和控制信息流的方法

  • 第九章测试和源代码控制,介绍了自动化程序测试的工具,以确保我们代码的良好质量

  • 第十章部署您的应用程序,展示了发布和通过安全协议保护我们的服务器所需的步骤和资源

  • 第十一章bonus 章节 -UX,从用户的角度提供了视角,以及与 UI/UX 设计师协作的常用词汇

的确,这是一段漫长的旅程,但我有信心和积极的态度相信,这些内容将提高你作为开发者和专业人士的技能。

接下来去哪里

技术每天都在进步,因此还有很多东西需要学习。新的工具和模式经常被创造出来。正因为如此,不可能在一本书中涵盖所有这些内容。通常,在准备章节时,由于本书的范围和广度,我仅限于对一些技术和概念进行表面上的探讨。例如,除了网页之外,Vue 还可以用 NW.js (nwjs.io/)、Electron (www.electronjs.org/)、Tauri (tauri.app/) 等工具开发混合应用程序。

了解这个框架及其所基于的技术将为你提供重要的技能。

最后...

我对你在这一学科上的奉献表示感谢,并感谢你购买这本书。祝愿你在未来的努力和职业生涯中取得美好和辉煌的成就。

诚挚地,

巴勃罗·大卫·加拉斯索

www.pdgaraguso.com

posted @ 2025-09-08 13:04  绝不原创的飞龙  阅读(68)  评论(0)    收藏  举报