Vue3-初学者指南-二-

43 阅读1小时+

Vue3 初学者指南(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:Vue.js 中的事件和数据处理

主要框架不仅因其能够将大页面分解成小可重用组件的能力而受到欢迎,还因为它们可以以简单的方式相互通信。

在本章中,我们将关注数据如何在不同的组件之间流动。组件之间的信息交换以两种不同的方式处理:通过属性从父组件到子组件,以及通过事件从子组件到父组件。

我们将从这个章节开始,介绍自上一章节以来在伴侣应用中提交的一些变化。这些修改可以用作一个好的指南,帮助你提高技能并巩固你已经学到的知识。我们将通过创建一个简单的按钮作为我们的第一个可重用组件,来重新审视属性,并学习更多高级技术,如validatorrequired。然后,我们将学习如何处理原生事件,如clickchange,最后,介绍自定义事件,这将允许我们的组件向应用的其他部分广播消息。

本章包括以下部分:

  • 探索伴侣应用的变化

  • 深入了解 props

  • 在 Vue.js 中处理原生事件

  • 使用自定义事件连接组件

到本章结束时,你将学会如何处理多个组件之间的通信。有了这些新知识,你应该能够从单个组件转向宏观层面思考,考虑包含多个组件的更复杂的应用结构。

技术要求

为了能够跟随本章,你应该使用名为CH06的分支。要拉取此分支,请运行以下命令或使用你选择的 GUI 来支持此操作:

git switch CH06

这个分支将包括一些变化。这些变化将在本章的第一节中解释,但你也可以开始并运行应用,熟悉其更新的外观并浏览仓库。

本章的代码文件可以在github.com/PacktPublishing/Vue.js-3-for-Beginners找到。

探索伴侣应用的变化

到目前为止,伴侣应用的外观和感觉非常基础,其变化完全是由我们在章节中编写的代码所引起的,但情况已经改变。

让我们看看现在应用看起来是什么样子:

图 6.1:更新设计和组件的伴侣应用

图 6.1:更新设计和组件的伴侣应用

如你在前面的屏幕截图中所观察到的,伴侣应用不仅在外观上进行了更新,采用了新的样式,而且还展示了将在本章和未来章节中使用的新组件。

这些变更已被应用,以便充分利用书籍内容,使我们能够专注于新的学习材料,而无需花费太多时间创建组件的脚手架和基本结构,但你应该花一些时间探索所做的变更,并尝试理解为什么以及如何实现这些变更。

自上次章节以来添加的所有变更和修改都使用了你已经接触过的功能,逐一查看每个变更是一个完美的练习,可以加强你的学习。

对应用进行了两组主要的变更:

  • 文件夹和文件变更

  • 逻辑变更

让我们详细查看这些变更,从影响文件夹和文件的变更开始。

文件夹和文件变更

CH06分支中,应用文件夹和文件中发生了一些变更。这些变更是为了开始将应用结构化,使其更像一个真实的生产应用,并远离应用至今为止简单的“概念证明”外观和结构。

文件结构所做的变更如下:

  • 我们添加了第一个原子组件TheLogo.vue

    在上一章中,我们伴侣应用的 logo 的SVG元素被硬编码在头部。现在,它已经被移动到自己的文件中,并已导入到TheHeader.vue中。

  • 在 UI 中添加了一个名为SideBar.vue的侧边栏。

    我们的应用布局已修改,并在organism文件夹中添加了一个名为SideBar.vue的新侧边栏。

  • 使用CreatePost.vue创建帖子的功能已搭建。

    我们应用的主要容器现在包含了一个旨在创建新帖子的新组件。这个组件只包括 HTML 结构和一些样式,但还没有逻辑。该组件位于molecules文件夹中的CreatePost.vue

  • 我们将TheWelcome.vue重命名为SocialPosts.vue

    为了更好地将我们的组件与更新的布局对齐,需要移除非常通用的TheWelcome.vue组件,并将其与组件结构对齐。为此,我们将组件重命名为SocialPosts.vue,因为它包含了一系列社交帖子,并将其移动到molecules文件夹中。

现在我们已经了解了所有文件和文件夹的变更,是时候查看可能包含在这个更新中的任何代码逻辑变更了。

逻辑变更

就像文件夹变更一样,应用也经历了一些修改。这些变更将使应用适应未来的变更,并且通过添加更多文件和更复杂的文件夹结构,让你一窥在真实项目中工作的样子。

如果你以包含少量组件的存储库结束这本书,你将无法体验到在真实应用中工作的感觉,因此你将无法学习如何导航代码库,更重要的是,如何结构化你的代码库。因此,应用被增强,以拥有更多的结构:

  • 定义 homeView.vue:在这个时间点,应用程序是一个单页应用,但这一点会在以后发生变化。为了开始并适应这些变化,我们已经开始定义 src/views/HomeViews.vue 以包括侧边栏、页眉和主页主体。这将允许我们在未来创建其他页面而无需重复此结构。

  • 清理页眉中的代码:页眉文件已经被清理。我们移除了硬编码的标志,并在“欢迎”信息旁边添加了一个新图标。为了完成这些更改,我们还为我们的组件添加了一些样式。查看此文件以提醒自己如何加载外部组件。

  • 使用 v-for 使 SocialPost 渲染动态内容:到目前为止,SocialPost 帖子是在 SocialPosts.vue(之前称为 TheWelcome.vue)中单独和手动加载的。现在多个帖子的渲染是动态的,并由 v-for 处理。使用 v-for 不仅提高了组件的可读性,还使其动态化,允许我们无需手动更改 HTML 就可以将 post 添加到我们的列表中。

图 6.2:SocialPost 的硬编码版本与使用 v-if 的版本之间的 Git 差异

图 6.2:SocialPost 的硬编码版本与使用 v-if 的版本之间的 Git 差异

我们现在已经解释了本章中包含的结构和逻辑更改。在我们继续前进之前,有一个小细节我想提到,那就是关于 :key 属性的。

如果你仔细检查之前的屏幕,你会注意到新版本的代码(右侧)有一个额外的属性叫做 :key。这个属性在使用 Vue 组件的 v-for 时是必需的。

键值被框架用来防止整个列表的不必要重新渲染。实际上,Vue.js 使用这个属性来跟踪哪个特定的组件实例已更改,并只更新特定的节点而不是整个列表。

因此,在前进的过程中,每次你使用 v-for 指令时,都应该记得使用 :key 设置一个唯一的键。这通常会被代码检查工具作为警告提出。提供这个值有助于 Vue.js 识别它使用 v-for 创建的所有不同节点,并在其中任何一项发生变化时加快重新渲染的速度。你应该尽可能定义一个键,除非创建的 DOM 非常简单。

避免使用数组索引

很常见的是看到数组索引被用作 v-for 循环的 :key 值。不幸的是,这是一个非常不好的做法,因为如果移除数组项,索引将会改变,迫使 Vue.js 重新渲染整个列表,并可能产生难以发现的错误。

我们现在已经熟悉了我们所做的所有更改。如前所述,你应该花几分钟熟悉这些更改,并了解所做的工作及其原因。

在下一节中,我们将开始我们的数据流之旅,并仔细研究属性。这个主题已经在上一章中介绍过,但它还有更多内容,现在是学习它的时候了。

深入了解属性

第三章中,我们介绍了并开始使用属性作为从组件传递信息到其子组件的方式。随着我们对 Vue.js 理解的加深,是时候扩展我们对这个基本功能的知识了。

就像快速回顾一样,到目前为止,我们已经了解到属性(props)是通过defineProps编译器宏定义的,如下所示:

const props = defineProps({
  name: String
});

这样做将允许我们的组件接受一个名为nameString类型属性:

<myComponent :name="myName" />

在本节中,我们将学习属性还有哪些其他配置可以提供。定义属性类型的能力并不是唯一的配置。

属性配置

在大多数情况下,只需配置属性类型,如之前所示,就足够了,但有时需要精细控制,以下配置将有助于你。

我们用来声明属性的语法是一个名称后跟期望的类型,PropsName: Type,但为了能够使用高级配置,我们需要将属性更改为接受一个对象。因此,我们之前提供的name: String在对象语法的示例将如下所示:

const props = defineProps({
  name: {
    type: String
  }
});

现在属性有了对象,我们可以添加额外的配置。

多种类型

Vue.js 属性可以接受多种类型。要做到这一点,我们只需将类型定义为数组:

name: {
  type: [ String, Number ]
}

设置必需属性

属性有两种类型,requiredoptional。默认情况下,框架将所有属性设置为optional,这是故意的,以与原生 HTML 处理属性的方式保持一致(例如,disabledreadonly)。

在开发组件时,你应该始终问自己组件是否可以在没有属性值的情况下渲染,如果不能,请确保将属性设置为required

name: {
  type: String,
  required: true
}

当一个属性是必需的,如果用户尝试不传递它来实现组件,组件将抛出错误,在某些情况下,根据你的设置,甚至可能根本不渲染组件。

使用默认值设置属性回退

当一个属性被设置为optional且未传递时,组件将给它一个undefined值。这在大多数情况下是可以的,但有时你可能希望属性有一个回退值。

对于初学者开发者来说,使用 HTML 中的v-if来实现这一点是很常见的:

<button>{{ welcomeMsg ? welcomeMsg : "Welcome" }}</button>

这个解决方案非常冗长,应该用默认属性值替换:

welcomeMsg: {
  type: String,
  default: "Welcome"
}

welcomeMsg属性未传递时,组件将渲染Welcome字符串。现在我们的 HTML 可以更干净地用于逻辑:

<button>{{ welcomeMsg }}</button>

数组和对象的默认初始化

对象和数组对于default有不同的语法,必须由工厂函数返回:default: () => []default: () => {}

验证你的属性

我们将要介绍的最后一个配置是验证接收到的值的能力。这在你的属性只能接受一组特定值或它们必须以特定方式格式化时非常有用。验证器是一个接收等于属性值的参数的函数,并期望返回true以标记验证或返回false以使验证无效。

例如,如果我们想创建一个只能接受两个字符串lightdark的属性,我们将设置validator函数如下:

theme: {
  type: String,
  validator: (value ) => ["light", "dark"].includes(value)
}

通过前面的代码行,我们的组件属性theme将只接受这两个值。如果传递了错误的值,组件将不会渲染并抛出错误。

创建一个基本按钮

为了将我们所学的一切付诸实践,我们将创建一个简单的基按钮。基础组件,如由组件库提供的组件,接受多个属性,通常是利用高级属性设置的最好选择。

我们的按钮将具有以下功能:

  • 它将需要可以是一个字符串或数字的值

  • 它将有一个optionalwidth属性,默认为100px

  • 它将有一个optional主题,只接受lightdark

新的按钮可以在atoms文件夹下以TheButton.vue的名称找到:

<template>
  <button
    :class="theme"
  >
    {{ value }}
  </button>
</template>
<script setup>
  defineProps({
})
</script>
<style scoped>
button {
  width: v-bind(width);
}
.light {
  background-color: #1DA1F2;
  color: white;
}
.dark {
  background-color: black;
  color: #1DA1F2;
}
</style>

文件基本上已经定义。其 HTML 包括一个button元素,其中包含我们属性和样式的占位符,准备好容纳我们的属性值。这个组件剩下的只是定义其属性。在阅读以下解决方案之前,尝试使用前面的信息自己定义属性。

当完全定义后,定义的属性应该看起来像这样:

defineProps({
  value: {
    type: [String, Number],
    required: true
  },
  width: {
    type: String,
    default: "100px"
  },
  theme: {
    type: String,
    default: "light",
    validator: (value) => ["light", "dark"].includes(value)
  }
})

属性配置非常强大且易于使用。正确使用它们可以帮助在模板中节省许多行代码,并有助于使组件更加健壮。

在本节中,我们学习了如何使用属性配置并创建了一个基础按钮来帮助我们理解其实际用法。我们学习了如何提供多种类型,如何设置属性为required,如何定义默认值,以及最后但同样重要的是,如何验证它。

在下一节中,我们将开始将注意力转移到数据处理的其他部分:事件。事件被子组件用来与父组件通信。我们将首先介绍原生元素,然后继续定义自定义元素。

在 Vue.js 中处理原生事件

由于 JavaScript 的诞生,事件始终在编程语言的成功中扮演着至关重要的角色。因此,所有 JavaScript 框架都确保它们提供强大的解决方案来处理原生和自定义事件。

我们将原生事件称为内置在 HTML 元素和 API 中的事件,例如由<button>触发的click事件,由<select>触发的change事件,或由<img>触发的load事件。

就像与 props 和指令一样,Vue.js 事件处理无缝地与现有的原生语法结合,以处理 HTML 元素提供的事件。在原生 HTML 中,所有事件处理器都以 on 字符开头,所以一个 click 事件变为 onclick,一个 change 事件变为 onchange。Vue.js 通过创建一个以 v- 开头的指令(正如我们所知)来遵循这个约定,即 v-on。因此,Vue 中的 click 事件使用 v-on:click 处理,而 change 事件使用 v-on:change 监听。

你可能已经注意到了,这并不是我们在代码库中迄今为止使用的语法。实际上,如果你打开 SocialPost.vue,你会注意到我们使用了不同的语法,其中事件以 @ 符号作为前缀。

这只是 Vue.js 框架提供的一个简洁的缩写。使用 @ 符号不仅简化了事件的实际编写,而且与其他指令明显区分开来。

让我们看看这些不同的方法在应用于 <button> 时会是什么样子:

// HTML Native
<button onclick="method()">Click Me</button>
// Vue.js
<button v-on:click="method">Click Me</button>
<button @click="method">Click Me</button>

即使语法不完全相同,使用起来也非常相似且直观。

有一个小的差异需要注意。实际上,在原生处理事件的方法中,传递给 onclick 的方法实际上被调用为 onclick="method()",而在 Vue.js 的情况下,方法不会被调用,它们只是作为方法名称的引用传递给框架引擎,即 @click="method"

是时候通过打开 SideBar.vue 来对代码库进行一些修改了:

<template>
<aside>
  <h2>Sidebar</h2>
  <button>Create Post</button>
  <div>
    Current time: {{currentTime}}
  </div>
  <button>Update Time</button>
</aside>
</template>
<script setup>
import { ref } from 'vue';
const currentTime = ref(new Date().toLocaleTimeString());
</script>

这个文件负责在我们布局中显示新的侧边栏。该组件包含一些元素和按钮,这些元素和按钮将在本章节和未来的章节中作为学习材料使用。

对于本节,我们将关注屏幕上显示的当前时间。更具体地说,我们将创建一个在点击按钮时更新这个时间的能力。

处理事件分为两个步骤。首先,我们创建一个包含触发事件所需所有逻辑的方法,然后将它附加到 HTML 上。

让我们首先创建这个方法:

<template>
<aside>
  <h2>Sidebar</h2>
  <button>Create Post</button>
  <div>
    Current time: {{currentTime}}
  </div>
  <button @click="onUpdateTimeClick">Update Time</button>
</aside>
</template>
<script setup>
import { ref } from 'vue';
const currentTime = ref(new Date().toLocaleTimeString());
const onUpdateTimeClick = () => {
  currentTime.value = new Date().toLocaleTimeString();
};
</script>

通过前面的修改,点击标记为 更新时间 的按钮将更新屏幕上显示的当前时间。让我们分析一下我们是如何实现这一点的。

首先,我们为事件逻辑创建一个方法。这个方法将以 on 字符开头,后面跟着实际事件的标识符——例如,updateTimeClick——生成一个名为 onUpdateTimeClick 的名称。

这个特定事件的逻辑相当简单,但正如我们在上一章所学,方法可以是复杂的,也可能有副作用,因此事件可以实现的目标没有限制。

接下来,我们在 HTML 中添加 click 指令。我们可以使用 v-on:click@click。这将自动将我们的 Vue 方法与原生的 click 事件链接起来。

这个例子不应该与我们在早期章节中已经覆盖的例子有太大不同,但事件处理并没有停止。事实上,我们需要了解两个重要的概念:事件修饰符,我们现在将介绍,以及稍后在本章中将要介绍的参数。

事件修饰符

在使用 JavaScript 处理事件时,通过阻止默认行为(event.preventDefault())或停止事件传播(event.stopPropagation())来修改事件是非常常见的。

这就是事件修饰符发挥作用的地方。Vue.js 为我们提供了一个简单的语法,可以直接从 HTML 中触发这些逻辑,而无需通过事件对象进行导航。

Vue.js 提供了许多修饰符——从事件修饰符(如.prevent.stop.once)到允许您监听特定按钮(如.enter.tab.space)的键盘修饰符,最后是允许您通过.left.right.middle触发特定鼠标点击事件的鼠标修饰符。

让我们分解 Vue 指令的语法:

图 6.3:Vue 指令的语法分解

图 6.3:Vue 指令的语法分解

Vue.js 中的指令由四个主要部分组成:

  • 指令的名称——例如,v-onv-if,或者在使用可用的缩写时使用@

  • 例如,对于click事件使用click,或者属性的名称。

  • 例如preventstop这样的修饰符。

  • 指令实际需要的值。在事件处理程序的情况下,值将是当事件被触发时运行的函数,而在属性的情况下,值将是一个简单的变量。

事件修饰符附加到事件指令上,所以例如,对于preventDefault,您将写@click.prevent="methodName"

了解所有这些修饰符超出了本书的范围,因为其中一些(如capturepassive)有非常具体的用法,并不总是必需的。如果您想了解更多信息,可以在 Vue.js 官方文档中找到更多细节(vuejs.org/guide/essentials/event-handling#event-modifiers)。

在继续之前,我们将尝试在我们的应用程序中使用这些修饰符之一:.once。这个修饰符可以防止事件处理器被触发多次。当您有只能执行一次的操作时,这非常有用,例如,保存新条目或更新表格行。

我们将应用这个方法到我们最近写的同一个click事件上。通过这样做,我们只能更新一次当前时间,因为多次点击按钮不会执行任何操作:

<button @click.once="onUpdateTimeClick">Update Time</button>

修饰符的使用非常简单,正如我们刚才所展示的。我们只需要在事件指令后添加单词 .once,以防止事件被触发多次。

修饰符不仅用于事件,也用于所有指令

如前所述,向指令提供修饰符的能力并不仅限于事件;实际上,修饰符是所有指令都可用的一项功能。唯一具有修饰符的内建指令是 v-on,但你可以创建自己的自定义指令或使用外部包,该包可能有自己的修饰符。

在对原生事件做了简要介绍之后,现在是时候学习自定义事件以及它们如何用于在组件之间广播消息了。

使用自定义事件连接组件

如 Vue.js 这样的框架允许我们将应用程序分解成非常小的组件,有时甚至小到单个 HTML 元素。没有强大的通信系统,这是不可能的。

原生 HTML 元素提供事件作为它们触发动作和向父元素传递信息的方式,Vue.js 组件通过提供自定义事件使用一个非常相似的模式。

自定义事件并不是什么新鲜事物,因为它们在 JavaScript 中已经存在了一段时间,但它们在纯 JavaScript 中通常使用起来非常冗长,而使用 Vue.js,创建、发出和监听自定义事件则感觉既简单又直观。让我们看看这些是如何定义和使用的。

要了解自定义事件,我们将通过添加用户删除帖子的功能来修改伴侣应用:

图 6.4:显示父组件和子组件之间数据流的流程图

图 6.4:显示父组件和子组件之间数据流的流程图

如前图所示,属性如 Posts 由父组件用于向子组件发送信息。另一方面,事件用于子组件向父组件发出信息。

在图 6*.4* 中所示的示例中,SocialPosts.vue 文件向子组件发送一系列社交帖子,子组件使用名为 delete 的自定义事件请求父组件删除帖子。

让我们看看如何在 SocialPost.vue 文件中实现这个自定义事件。第一步需要我们在点击 删除 图标时触发一个原生的 click 事件。就像我们在上一节中所做的那样,我们通过在 HTML 中添加 @click 指令来实现这一点:

<div class="header">
  <img class="avatar" :src="img/avatarSrc" />
  <div class="name">{{ username }}</div>
  <div class="userId">{{ userId }}</div>
  <IconDelete @click="onDeleteClick" />
</div>

然后,我们在 <script> 标签中添加 onDeleteClick 方法:

...
onMounted( () => {
  console.log(props.username);
});
const onDeleteClick = () => {}
</script>

接下来,我们需要定义事件,并在 onDeleteClick 被触发时触发它:

...
onMounted( () => {
  console.log(props.username);
});
const emit = defineEmits(['delete']);
const onDeleteClick = () => {
  emit('delete');
}
</script>

我们使用一个名为 defineEmits 的编译器宏来定义 emits。这与 defineProps 的语法相同,因为它接受一个可以由组件发出的事件的数组。在我们的例子中,我们只定义了一个 delete 事件。

defineEmits 返回一个函数,可以用来触发我们的事件。建议将这个常量命名为 emit,以与 Vue.js 中可用的原生 $emit 方法保持一致。

这个函数接受两个参数。第一个是事件名称,第二个是传递给事件监听者的参数。在我们的例子中,这是通过 emit('delete') 定义的,其中 delete 是我们的自定义事件名称。

现在子自定义事件已经完全设置,是时候确保父组件正在监听这个事件了。让我们回顾一下 SocialPosts.vue 并运用我们的技能:

    ...
    :retweets="post.retweets"
    :key="post.userId"
    @delete="onDelete"
  ></SocialPost>
</template>
<script setup>
import { reactive } from 'vue';
import SocialPost from '../molecules/SocialPost.vue'
const onDelete = () => {
  posts.splice(0, 1);
}
...

在 Vue.js 中监听自定义事件与监听原生事件没有区别。事实上,两者都使用 v-on(或 @)指令,如 @delete="onDelete" 所示。就像其他事件监听器一样,这段代码将等待事件被触发后再运行提供的函数。

由事件触发的方法称为 onDelete,并使用纯 JavaScript 从 Posts 对象中删除项目。如果我们尝试当前的代码,我们会看到功能按预期工作,并且在点击 删除 图标时删除了第一个帖子。

这很好,但还不够完美,因为我们希望选择要删除的帖子,而不是将这个值硬编码为第一个帖子。为了修复这个错误,我们必须在声明我们的自定义事件时添加一个参数来定义要删除的正确帖子。

事件参数

直到这一刻,在伴侣应用中处理的所有事件都不需要访问事件对象或外部参数。

现在,我们将学习如何扩展事件处理程序以接受这些额外的值。

由于附加到事件的方法可以访问组件的所有响应式数据,你可能会认为传递参数可能不是非常有用,但这并不是事实。

事实上,当从使用 v-for 的元素中触发事件时,参数非常有用——就像在我们的例子中,我们需要通知事件 post 索引。访问触发事件的帖子的正确值对于功能正确运行至关重要。

为了能够删除正确的帖子,我们首先需要在触发事件处理程序时添加 post 索引的值,然后确保这个值被读取并用于方法中。

  1. 让我们通过在 v-for 中暴露它来使 post 索引可用:

    <SocialPost
    
      v-for="(post, index) in posts"
    
      :username="post.username"
    
  2. 接下来,将索引添加到事件声明中:

    <SocialPost
    
      v-for="(post, index) in posts"
    
      :username="post.username"
    
      ...
    
      :key="post.userId"
    
      @delete="onDelete(index)"
    
    ></SocialPost>
    
  3. 最后,在事件处理程序中添加 postIndex 参数:

    const onDelete = ( postIndex ) => {
    
      posts.splice(postIndex, 1);
    
    }
    

经过这三次修改后,我们应该能够从我们的伴侣应用中删除正确的帖子。

通过 $event 获取事件原生对象

事件处理程序可以访问一个名为 $event 的特殊参数。这个参数自动传递给所有没有参数或可以直接从 HTML 传递自定义参数的事件:<button @click="handler($event, customParameter) />"

摘要

在本章中,我们通过分析伴侣应用程序中的变化,回顾了之前学过的主题。然后,通过介绍所有可能的选项,我们扩展了对 props 的知识。接下来,我们转向事件处理,解释了如何在 Vue.js 组件中使用原生 HTML 事件,并在本章结束时定义了自定义事件处理程序以及如何使用它来在组件之间传递信息。

现在,你应该能够创建和使用自定义事件。有了这些知识,再加上关于 props 的额外知识,你应该能够处理具有多个组件的更大应用程序。你还应该利用在伴侣应用程序中做出的更改,学习如何导航新的代码库。

在下一章中,我们将通过移除硬编码的值、开始使用外部 API、通过观察值变化来学习如何处理副作用,以及克服计算属性的局限性,继续围绕“增加范围”这一主题进行构建。这些变化将进一步增加我们应用程序的复杂性,同时,也为我们提供了增加对 Vue.js 框架知识的重要技能。

第七章:使用 Vue.js 处理 API 数据和异步组件管理

第六章中,我们关注了组件如何通过使用属性进行相互通信,这些属性用于父到子通信,以及事件来处理从子组件发送给父组件的消息。

在本章中,我们将继续探讨通信的话题,展示如何与外部资源,如 API 进行通信。在外部通信是开发无法使用静态数据的动态网站时,这是一个非常常见的方法。学习如何管理异步操作不仅会导致干净的用户体验,而且有助于保持应用程序的性能。

从外部源,如 API 加载数据使得数据处理更加复杂。实际上,当数据是硬编码时,我们不需要担心任何事情,因为信息是立即可用的。而当我们处理来自外部源的数据时,我们不仅需要考虑数据加载期间应用程序的空状态,还要考虑数据加载失败的可能性。

我们将首先移除硬编码的帖子并动态加载它们;然后我们将对评论做同样的处理,通过按需加载数据。然后我们将增强我们的应用程序,使其能够自动使用watch加载更多帖子。最后,我们将学习如何使用<Suspense>定义和使用异步组件。

本章将涵盖以下主题:

  • 使用 Vue.js 生命周期从 API 加载数据

  • 使用watch函数监视组件中的变化

  • 使用 Suspense 处理异步组件

到本章结束时,你将学会如何动态加载数据和组件。你将知道如何创建按需加载数据的组件,以及这给我们的应用程序带来的好处。你还将能够使用watch处理副作用,最后定义并处理异步组件以确保应用程序正确渲染。

技术要求

在本章中,分支被称作CH07。要拉取这个分支,运行以下命令或使用您选择的 GUI 来支持此操作:

Git switch CH07.

作为本章的一部分,我们还将使用一个名为 Dummyapi.io 的外部资源。这个网站将提供一个模拟 API,我们将用它来动态加载我们的帖子。要使用 API,您需要注册并生成一个APP ID。创建APP ID是完全免费的,您可以通过以下链接创建账户来获取:dummyapi.io/sign-in

这个新分支,CH07,仅包含一些样式更改以及用我们在上一章TheButton.vue中创建的自定义按钮组件替换了原生按钮。

该章节的代码文件可以在github.com/PacktPublishing/Vue.js-3-for-Beginners找到。

使用 Vue.js 生命周期从 API 加载数据

对于大多数为网络构建的应用来说,暴露一定程度的动态内容是很常见的。提供即时加载数据的能力是 JavaScript 框架(如 Vue.js)增长的最重要因素之一。

到目前为止,伴随应用一直是使用直接在组件中加载的静态数据构建的。在真实应用中,硬编码的值并不常见,应用中使用的帖子评论只是权宜之计,帮助我们专注于 Vue.js 的基本功能,但现在是我们学习如何动态加载数据的时候了。

能够成功处理异步数据加载非常重要。无论你的下一个应用有多大或多小,你很可能需要处理外部数据。

在本节中,我们将更新我们应用的两个部分。首先,我们将更新SocialPosts.vue以从外部源加载帖子,然后我们将更改SocialPostComments.vue以动态加载评论,但会有一个小变化,因为我们将会实现一个叫做“按需加载数据”的功能。然后我们将简要讨论动态加载可能对我们应用性能和用户体验产生的影响。

从 API 加载数据的社会帖子

到目前为止,我们应用显示的帖子总是相同的,这是由于在SocialPosts.vue中定义的posts数组是硬编码的。在本节中,我们将使用DummyAPI提供的公共 API(dummyapi.io/)来使我们的帖子动态化。

DummyAPI等服务对于开发应用框架和帮助你练习技能非常有用。互联网上有许多这样的免费服务,并且可以通过搜索引擎轻松找到。

研究是开发的一部分

花几分钟时间浏览DummyAPI网站,尝试理解我们将如何使用 API 以及我们将使用哪些端点。学习外部资源是网络开发中非常重要的一个部分。

使用 Vue.js 方法、原生的 Fetch API 和 Vue.js 生命周期将实现外部数据的加载。首先,我们将从SocialPosts.vue中移除旧的、硬编码的数据。这个文件可以在molecules文件夹中找到,因为它是一个渲染我们伴随应用主页大部分内容的组件:

const posts = reactive([]);

然后,在同一文件中,我们将创建一个调用外部 API 以获取新帖子的方法:

const fetchPosts = () => {
  const baseUrl = "https://dummyapi.io/data/v1";
  fetch(`${baseUrl}/post?limit=5`, {
    "headers": {
      "app-id": "1234567890"
    }
  })
    .then( response => response.json())
    .then( result => {
      posts.push(...result.data);
    })
}

上述代码使用原生的 JavaScript fetch方法(developer.mozilla.org/en-US/docs/Web/API/Fetch_API)向dummyapi.io API 发送GET请求。由于 API 的要求,我们需要在请求中传递app-id。正如技术要求部分所述,这可以免费获得。

我们随后使用response.json()json格式获取结果,最后将返回的数据追加到帖子的Reactive属性中。

在定义方法之后,是时候“调用”它了。在这种情况下,当触发一个async请求时,我们利用 Vue.js 的生命周期来确保我们的请求在正确的时间被触发。

第二章中,我们介绍了不同的生命周期,并提到created生命周期是异步数据的正确选择。我们对它的描述如下:

“[created生命周期]是触发异步调用来收集数据的完美阶段。现在触发慢速请求将帮助我们节省时间,因为这个请求将在组件渲染的同时在幕后继续。”

让我们去调用我们新创建的方法fetchPosts,在created生命周期中。与mounted等其他生命周期不同,created不需要显式定义。官方文档的解释如下:

“因为setup是在beforeCreatecreated生命周期钩子周围运行的,所以你不需要显式地定义它们。换句话说,任何应该写入这些钩子内部的代码都应该直接写在setup函数中。”

这简化了我们的需求,意味着我们只需要在我们的组件 JavaScript 逻辑体中定义方法后调用它:

const posts = reactive([]);
const fetchPosts = () => {
   ...
}
fetchPosts();

在这个阶段,我们的帖子应该从 API 动态加载,但工作还没有完成;事实上,应用程序显示的帖子是错误的:

图 7.1:显示损坏 UI 的伴侣应用

图 7.1:显示损坏 UI 的伴侣应用

上述错误是由什么引起的?

在阅读答案之前,你为什么不尝试调查一下是什么导致了图 7.1中显示的渲染问题呢?你会如何着手修复它?

API 获取的数据已成功加载并应用于我们的“posts”,但数据的结构并不符合我们之前设置的。这个问题与我们之前章节中学到的高级属性设置有关。

修复 SocialPost.vue 属性的不匹配

在实际应用中,属性不匹配是非常常见的问题,但这是可以避免的。事实上,应用渲染出破碎的用户界面是因为我们没有指定在SocialPost.vue中哪些属性是期望的“必需”属性,因此 Vue.js 试图使用它拥有的数据来渲染应用,导致缺失的数据被设置为null

让我们比较帖子数组之前的硬编码结构与通过 API 接收的新结构,看看这两个结构如何比较以及确保伴侣应用可以正确渲染帖子信息所需进行的更改:

图 7.2:帖子之前结构与新 API 提供结构的过渡

图 7.2:帖子之前结构与新 API 提供结构的过渡

图 7.2 显示了我们将要对我们应用进行的更改以适应新的数据。一些字段需要更改以匹配新的对象属性,评论和转发已被完全移除。调用<SocialPost>组件现在将更改为以下内容:

<SocialPost
  v-for="(post, index) in posts"
  :username="post.owner.firstName"
  :id="post.id"
  :avatarSrc="post.image"
  :post="post.text"
  :likes="post.likes"
  :key="post.id"
  @delete="onDelete(index)"
></SocialPost>

上述突出显示的代码显示了旧组件实例与新组件实例之间的差异。请注意,我们必须将userId替换为仅id,这将在以后是必需的。现在,是时候修改子组件以确保它可以与新数据一起工作。这将涉及几个步骤:

  1. 从 UI 中移除UserId,因为它太长了:

    <div class="name">{{ username }}</div>
    
    <div class="userId">{{ userId }}</div>
    
    <IconDelete @click="onDeleteClick" />
    
  2. props声明中移除commentsretweets

    const props = defineProps({
    
      username: String,
    
      userId: Number,
    
      avatarSrc: String,
    
      post: String,
    
      likes: Number,
    
      comments: Array,
    
      retweets: Number
    
    });
    
  3. 将 UI 中的interactionscommentsNumber重构为仅likes

    <div class="interactions">
    
      <IconHeart />
    
      {{ interactions }}
    
      <IconCommunity />
    
      {{ commentsNumber }}
    
      {{ likes }}
    
  4. 移除与interactionscommentsNumber相关的逻辑:

    const commentsNumber = computed( () => {
    
      return props.comments.length;
    
    });
    
    const interactions = computed( ()=> {
    
      const comments = props.comments.length;
    
      console.log(comments, props.likes, props.retweets);
    
      return comments + props.likes + props.retweets;
    
    Show comment button:
    
    

    <TheButton

    v-show="hasComments"

    @click="onShowCommentClick"

    value="显示评论"

    width="auto"

    theme="dark"

    />

当阅读这些更改时,它们可能看起来相当复杂,但它们遵循一个逻辑模式。它们都是相互关联的,一个组件的修改可能会导致另一个组件的变化,依此类推。例如,从父组件中移除retweet属性会导致在defineProps中的子组件属性被移除,进而导致与这些属性相关联的任何代码逻辑被移除,最终,组件模板中任何对这些属性的引用也会被移除。

随着你对框架越来越熟悉,上述更改将显得微不足道,但我故意在这里添加它们并逐步介绍,是为了给你一些关于如何逻辑思考组件及其数据流的思路。

到目前为止,应用应该可以正确渲染,主页应该显示来自我们已实现的虚拟 API 的帖子:

图 7.3:显示来自外部 API 的帖子伴侣应用

图 7.3:显示来自外部 API 的帖子伴侣应用

伴随应用程序正确地显示了帖子,但仍然缺少一些东西。用于显示评论的逻辑现在不再正确工作。

在我们继续前进之前,你应该通过改进其属性来提高我们刚刚制作的组件的可靠性。

你的任务 - 改进 SocialPost.vue 中的属性

花几分钟时间来增强 SocialPost.vue 中设置的属性。查看每个属性,并决定是否应该使用 required 属性将其设置为必需的,或者是否可以通过定义 default 值将其设置为可选的。

按需加载评论

posts 数据是硬编码时,与单个帖子相关联的评论在第一次渲染时是可用的,并且我们可以立即在父组件和子组件之间传递它们。但现在信息是动态加载的,我们可以改变我们的逻辑,只按需加载评论。

想象一个应用程序,其中所有数据都在主页上加载,并且需要在数十个组件之间传递。无论你多么努力工作,代码都很难维护。在组件多层之间传递属性在业界被称为“属性钻取”。

可以使用两种技术来避免属性钻取:

  • 按需加载数据

  • 使用状态管理

在本章中,我们将介绍第一种技术,按需加载数据,而状态管理将在第十一章中稍后介绍。

我们刚才执行的改变防止了一些数据立即加载,而是按需获取。这是一个非常好的实践,可以提高性能和代码结构。

当分析评论在我们应用程序中的行为时,我们可以看到,当我们知道应用程序将仅在用户交互(按钮点击)时显示它们时,立即加载与帖子相关联的所有评论将是相当浪费的。

我们将处理两个文件。首先,我们将用帖子 ID 替换 SocialPost.vue 中的 comments 属性。然后,我们将在 SocialPostComments.vue 中创建按需加载评论所需的功能。

到目前为止,你应该有足够的知识来从组件中移除 comments 属性,并用另一个名为 post-id 的属性替换它。这将在稍后用于从模拟 API 请求正确的评论,代码更改应如下所示:

<SocialPostComments
  v-if="showComments"
:comments="comments"
  :post-id="id"
  @delete="onDeleted"
/>

因为在上一节中,comments 属性已经被从 defineProps 中移除,所以剩下的工作就是确保我们从 <SocialPostComments> 组件声明中移除该属性,并用一个名为 post-id 的属性替换它。

下一步需要我们重写处理评论加载的逻辑。我们将从修改属性开始,移除 comments 并用新传递的 post-id 替换它:

<script setup >
  import IconDeleteVue from '../icons/IconDelete.vue';
  const props = defineProps({
    comments: Array,
    postId: String
  })
</script>

我们需要帖子 ID 才能从 API 获取正确的评论。在传递 ID 时,通常会在其前面加上实际上下文作为前缀。因此,在我们的情况下,我们不是将我们的 prop ID 命名为 postId,而是将其命名为 postId. 这样的小改进真的可以帮助保持你的代码整洁和可读。

使用短横线命名法定义多词属性

你注意到我们刚刚定义的属性在组件声明中被称为 postId,但在组件中使用时使用的是短横线命名法,post-id?HTML 不区分大小写,所以使用 postId 就等同于 postid。因此,为了更好地定义多词属性,我们使用短横线命名法——即“单词-单词。”

现在是时候进行必要的更改,以确保 SocialPostComments.vue 能够正确地与新的帖子结构一起工作,并创建加载评论所需的逻辑。

我们将使用以下 API 路径,/post/{postId}/comment。这将返回给定 postId 的评论,其中 postId 是所选帖子的实际 ID。

让我们分解所有需要进行的更改:

  1. 首先,我们从 vue 中导入 reactive 并使用它来定义一个新的 comments 数组:

    import { reactive } from 'vue';
    
    const props = defineProps({
    
      postId: String
    
    });
    
    const comments = reactive([]);
    
  2. 第二,我们创建了一个名为 fetchComments 的新方法,它接受一个名为 postId 的参数:

    const fetchComments = (postId) => {  }
    
  3. 接下来,我们在新创建的方法体中创建一个获取请求,并使用 postId 创建正确的请求 URL。就像之前一样,我们确保传递正确的 app-id

    const fetchComments = (postId) => {
    
      const baseUrl = "https://dummyapi.io/data/v1";
    
      fetch(`${baseUrl}/post/${postId}/comment?limit=5`,
    
      {
    
        "headers": {
    
          "app-id": "1234567890"
    
        }
    
      });
    
    }
    
  4. 然后,我们获取响应的 JSON 并将其分配给 comments 响应式:

    fetch(...)
    
      .then( response => response.json())
    
      .then( result => {
    
        Object.assign(comments, result.data);
    
    })
    
  5. 最后,我们在组件加载时调用此函数。如前所述,在脚本设置体中调用函数等同于在创建生命周期中调用它。该函数将接收 postId 属性作为其参数:

    const fetchComments = (postId) => {
    
      ...
    
    };
    
    fetchComments(props.postId);
    

在这个阶段,我们的伴侣应用将渲染从 API 请求中接收到的 comments 内容:

图 7.4:在伴侣应用中显示的评论正文

图 7.4:在伴侣应用中显示的评论正文

从显示在 图 7.4* 中的截图,我们可以推断出我们需要在组件中实施的两个主要更改:

  • 当没有评论可用时改进 UI

  • 格式化评论的正文,只显示作者的名字和消息,而不是 API 接收到的原始对象

为了提高用户体验,并在没有评论的帖子中显示不同的消息,我们可以使用内置的 v-ifv-else 指令:

<template v-if="comments.length === 0"></template>
<template v-else></template>

就像简单的 if/else 语句一样,当一个 Vue.js 组件接收到包含 v-ifv-else 指令的元素时,它将只渲染两个中的一个,具体取决于接收到的条件。在我们的例子中,如果评论数组为空且长度为零,则将渲染第一个块,而如果存在评论,则将渲染第二个块。

使用 <template> 避免未使用的 HTML 元素

您可能已经注意到,在介绍v-if/v-else代码块时,我们使用了名为<template>的元素。<template>元素是一个特殊的 Vue.js 元素,它允许您在不添加 HTML 元素的情况下添加逻辑。实际上,如果模板元素不可用,我们就必须添加<span><div>来仅允许我们添加逻辑。每次您使用<template>元素时,它都会消失,并且不会在 DOM 中渲染任何内容。它在使用v-ifv-elsev-if-elsev-for的逻辑中非常有用。

让我们用正确的 HTML 填充我们刚刚创建的代码块。第一个块将仅显示一个静态消息用于空状态,而对于第二个,我们需要分析 API 接收到的对象,并理解我们想要显示的内容。

现在是时候关注注释的结构了。comment对象似乎包含多个属性,但我们应该使用的只有用户名和消息,因此分别是owner.firstNamemessage

<template v-if="comments.length === 0">
  There are no comments for this post!
</template>
<template v-else>
  <p>Comments:</p>
  <div v-for="{owner, message} in comments" class="comment">
    <p>{{ owner.firstName }}: <strong>{{ message }}</strong></p>
  </div>
</template>

v-if语句包含一个简单的静态消息,而v-else包含一个使用v-for指令创建的循环,<div v-for="{owner, message} in comments" class="comment">,它包括两个 mustache 模板来格式化我们的字符串,<p>{{ owner.firstName }}: <strong>{{ message }}</strong></p>

经过最新的修改后,我们的 Companion App 应该会显示一个格式良好的布局来展示我们的注释。

图 7.5:Companion App 的格式化注释

图 7.5:Companion App 的格式化注释

在本节中,我们学习了如何使用外部 API 异步加载数据;然后我们探讨了确保我们的应用程序能够与新动态数据一起工作的必要更改。本节还包含了一些有助于提高您 Vue.js 技能的技巧,例如使用<template>来保持 HTML 的整洁,需要用短横线命名法声明多词属性,以及定义良好的属性以确保我们的组件在值缺失时不会渲染错误。

在下一节中,我们将学习如何在监视组件数据变化时触发副作用,例如 API 请求。

使用 watch 监视组件的变化

在前一节中,我们学习了如何在组件渲染周期中触发 API 请求来动态加载数据。在本节中,我们将通过描述如何处理作为监视数据变化副作用触发的 API 请求来展示异步数据加载的另一个方面。

正如我们在*第五章**中学习的那样,计算属性是监视其他属性和内部数据以创建新变量的绝佳工具,但当我们需要触发副作用时,例如 DOM 更改或 API 调用,它们就不太方便了。

这就是 Vue.js 的一个功能watch发挥作用的地方。watch就像computed一样,允许你监听任何属性、响应式数据或另一个计算属性的变化,但它还提供了在数据变化时触发回调的能力。

watch仅用于边缘情况,而不是日常使用

如果你发现在开发过程中经常使用watch,这意味着你使用methodscomputed的方式不正确。对于经验不足的 Vue.js 开发者来说,过度使用watch是非常常见的。我个人在多年的开发中只使用了它几次。

我们将修改我们的应用程序,使其在屏幕上显示的帖子少于四个时自动加载更多帖子。如前所述,我们将监视一个组件变量(在我们的例子中,是posts数组),然后在满足一定条件时触发副作用(在我们的例子中,是另一个 API 调用)。

让我们一起来开发这个功能。首先,我们需要更新我们的fetch方法,使其接受一个页面参数,以确保我们获取新的帖子而不是总是相同的帖子:

const fetchPosts = (page) => {
  const baseUrl = "https://dummyapi.io/data/v1";
  fetch(`${baseUrl}/post?limit=5&page=${page}`, {
    "headers": {
      "app-id": "1234567890"
    }
  })
    .then( response => response.json())
    .then( result => {
      posts.push(...result.data);
    })
}

fetchPosts方法现在接受一个名为page的参数,并将其附加到请求查询参数中,${baseUrl}/post?limit=5&page=${page}

然后,我们将创建一个新的ref名为page,它将保存当前页面的值。为了实现这一点,我们将从 Vue.js 导入ref

import { reactive, ref} from 'vue';

接下来,我们定义并初始化变量。因为帖子列表的第一页将是 0,我们将使用这个数字作为初始化值:

const page = ref(0);

最后,我们将在调用fetchPosts时传递这个ref

fetchPosts(page.value);

记住,因为我们使用了ref来定义我们的页面变量,所以我们需要使用.value表示法来访问它的值。

现在,是时候创建我们的watch了。就像computed一样,watch将依赖于一个或多个其他响应式值,如RefReactive,并将包含一个回调值。语法是watch(依赖数据, 回调函数(新值, 旧值))。所以,在我们的情况下,代码将看起来像这样:

<script setup>
import { reactive, ref, watch } from 'vue';
import SocialPost from '../molecules/SocialPost.vue'
watch(
  posts,
  (newValue, old) => {
    if( newValue.length < 4 ) {
      page.value++;
      fetchPosts(page.value);
    }
  }
)

watch是用来观察变量变化并触发回调的。在我们的例子中,应用程序会观察名为posts的变量,并在触发回调时更改page变量并获取新的帖子,使用我们的 API。由watch触发的回调提供两个值;第一个是观察变量接收的新值,第二个是旧值。

大多数时候,你可能只会使用第一个值(因此,观察数据的新值),但是当触发的效果依赖于变量的“变化”时,访问这两个值非常有用。例如,如果你有一个根据值增加或减少显示不同效果的动画,这可能就是必要的。这只有在你有旧值和新值进行比较的情况下才可能实现。

我们的伴侣应用现在应该准备好进行测试了。为了测试我们的新功能,点击它们旁边的删除图标删除几篇帖子,然后查看新帖子动态加载。

watch还有一些其他选项可用,例如在 DOM 中副作用发生后触发它,或者组件渲染时立即触发它的可能性。然而,这些内容超出了本书的范围,因为它们适用于更高级的使用,在这个阶段可能会造成混淆。

记住使用watch是有代价的。事实上,观察一个值是有代价的,但在每次值变化时触发回调的性能成本更大。因此,建议仅在需要时使用watch,并确保回调体的资源消耗不是太密集。

使用计算属性最小化watch回调

如果你正在观察一个变化过于频繁的变量,并想尝试提高性能,你可以使用computed创建一个新的变量。然后可以监视这个新的计算属性。由于计算属性是缓存的,这种方法性能更优。

这就完成了我们对watch的介绍。这是一个有用的功能,当正确使用时,可以帮助你创建干净且易于阅读的组件。

在本节中,我们学习了什么是watch以及如何使用它来改进我们的组件,给我们机会从应用程序中触发副作用,例如 API 请求或 DOM 修改。然后我们在伴侣应用中进行了更改,以更好地理解这个主题。最后,我们解释了使用watch的缺点,并讨论了何时以及如何使用它。

在下一节中,我们将介绍一个名为<Suspense>的内置组件。这个功能简化了我们处理异步组件加载状态的方式。

使用<Suspense>处理异步组件

处理动态数据加载从来都不容易。事实上,当数据是静态且硬编码时,显示信息不需要任何努力,因为数据在首次渲染时即可获得,但当你需要从外部来源获取数据,例如数据库或第三方 API 时,复杂性就会增加。在异步加载数据时,数据在第一次加载时不可用,迫使我们处理“加载状态”直到数据可用,或者在加载事件未能完成时显示错误状态。

为了防止需要在多个组件中处理状态变化和代码重复,Vue.js 引入了一个全局可用的内置组件,称为<Suspense>

使用<Suspense>,我们可以一次性以非常干净的语法编排所有加载状态。在接下来的章节中,我们将首先了解是什么使组件异步,然后了解如何使用它来简化我们的代码。

一个实验性功能

在撰写本文时,<Suspense> 仍然是一个实验性功能,没有确定的时间表,也不知道它何时以及是否会成为 Vue.js 框架的核心部分。团队正在修复一些与之相关的错误,更重要的是,在将其转变为完整功能之前,正在开发服务器端支持。

理解异步组件

在前面的介绍中,我们通过一个需要动态加载数据的组件的例子来讨论了异步组件,但这并不是异步组件的实际定义。

异步组件是“一个组件在渲染初始化之前需要执行并完成异步函数”。

从前面的定义中,我们可以得出的重要结论是“异步操作”和“需要它完成”是定义组件为异步组件的要求。

事实上,如果我们看看我们的当前应用程序,我们可以看到我们已经有动态数据被加载到 SocialPosts.vue 中,但这并不使它成为一个异步组件,因为组件是立即渲染的,并不等待 fetch 操作完成。

异步组件的特点是存在一个顶级的 <script setup> 代码块。

异步组件会影响页面渲染

异步组件将停止自身及其所有子组件的渲染,直到数据完全加载。这仅应在组件没有理由在没有数据的情况下渲染时使用。

让我们看看我们的伴侣应用程序,并尝试找到一个好的候选者来转换为异步组件。目前,只有两个组件之间存在异步操作,即 SocialPosts.vueSocialPostComments.vue

如果我们看看 SocialPostComments.vue 中的逻辑,我们可以看到该组件目前没有正常工作。当前组件逻辑在评论数组为空时显示一条消息,“<p>此帖子没有评论!</p>”,但这条消息也会在组件首次渲染时显示。这是因为组件立即渲染,即使 fetch 请求仍在进行中。

这是一个非常适合转换为异步组件的候选者。事实上,这个组件既有“异步操作”,也有“需要它完成”的需求,正如异步组件的定义中提到的。

将组件转换为异步组件

Vue.js 提供了一种非常简单的方式来定义一个异步组件。实际上,我们只需要确保组件在其主体中包含一个或多个 await 函数。

当 Vue.js 框架看到 await 函数时,它会自动将该组件定义为异步。

让我们修改 SocialPostComments.vue,使其等待 fetch 方法:

<script setup >
import { reactive } from 'vue';
const props = defineProps({
  postId: String
});
const comments = reactive([]);
const fetchComments = (postId) => {
  const baseUrl = "https://dummyapi.io/data/v1";
  return fetch(`${baseUrl}/post/${postId}/comment?limit=5`,
  {
    "headers": {
      "app-id": "1234567890"
    }
  })
    .then( response => response.json())
    .then( result => {
      Object.assign(comments, result.data);
    })
};
await fetchComments(props.postId);
</script>

我们的组件逻辑只需要进行两个小的更改,就可以将组件转换为动态组件。首先,我们确保我们的fetchComments方法通过在fetch方法之前添加return来返回一个 promise。然后,在调用方法时添加await。这两个更改就是确保组件转换为异步组件所需的所有内容。

现在剩下的就是学习如何使用异步组件了。实际上,在这个阶段,Companion App 无法加载评论,点击“加载评论”按钮会记录以下错误:

图 7.6:Vue.js 在尝试错误地加载异步组件时触发的错误信息

图 7.6:Vue.js 在尝试错误地加载异步组件时触发的错误信息

错误信息提到,异步组件需要嵌套在<Suspense>中以进行渲染。让我们了解这个<Suspense>是什么,以及它是如何被用来加载异步组件的。

渲染异步组件

在前一个部分,我们将SocialPostComments.vue文件转换为异步的,现在是时候学习如何处理这个组件以确保它被正确加载。

正如我们之前提到的,当一个组件被转换为异步组件时,我们就需要处理它的加载状态。以我们目前在应用程序中做的方式正常加载这个组件是不行的,因为这个组件不是立即可用的,所以我们需要找到一种优雅地处理其加载的方法。

像往常一样,Vue.js 核心团队努力为我们提供所有快速完成复杂操作所需的工具。

该组件在官方 Vue.js 文档中的定义如下:

<Suspense>是一个用于在组件树中编排异步依赖的内置组件。它可以在等待多个嵌套的异步依赖在组件树中解决时渲染加载状态。”

将一个或多个组件包裹在<Suspense>中可以防止它们在所有异步操作完成之前渲染。此外,<Suspense>还允许你在异步操作完成时显示一个“加载中”组件。

<Suspense>已经在应用程序中预加载,不需要导入。让我们打开SocialPost.vue并更改我们的代码,以正确加载我们的异步组件:

...
<div class="post" v-text="post"></div>
<Suspense v-if="showComments" >
  <SocialPostComments
    v-if="showComments"
    :post-id="id"
    @delete="onDeleted"
  />
</Suspense>
<div class="interactions">
...

使用这个内置组件非常简单。实际上,我们只需要将SocialPostComments包裹在<Suspense>中,就像前一个代码块中突出显示的那样,并将v-if指令v-if="showComments"SocialPostComments移动到内置的Suspense组件。经过这些更改后,SocialPostComments将在组件内的异步操作解决后简单地渲染。

在某些情况下,你可能需要在异步操作完成时显示一个加载指示器。<Suspense> 提供了一个名为 fallback 的命名插槽,可以处理这种情况。让我们通过在帖子评论加载时添加回退消息来学习如何使用这个功能。

要实现回退消息,我们的代码需要进行以下修改:

<Suspense v-if="showComments" >
  <SocialPostComments
    :post-id="id"
    @delete="onDeleted"
  />
  <template #fallback>
    fetching comments...
  </template>
</Suspense>

要在获取评论时添加消息,我们使用 SocialPostComments。这个插槽将使用 <template #fallback> 语法定义。这个插槽的内容将在 SocialPostComments 内部的异步操作运行时显示,一旦组件渲染完毕,它就会消失。

自定义错误处理

在这个阶段,错误处理不是由 <Suspense> 处理的,需要手动使用 onErrorCapture() 钩子来处理。解释这一点超出了本书的范围。

概述

我们现在已经完成了关于异步数据和组件加载的所有学习内容。我们通过移除硬编码的 posts 并用动态加载的虚拟数据替换它们来开始本章。然后我们修复了由于数据变化引起的属性不匹配问题,并学习了如何通过改进属性类型和验证来防止未来发生这种情况。

然后,我们学习了如何将我们的数据流改为按需加载评论,并定义了何时应该使用它,以及它带来的性能和用户体验优势。然后我们介绍了与异步操作相关的另一个功能,watch。我们使用这个功能来触发副作用,并在帖子数量达到一定数量时自动加载更多帖子。

最后,我们学习了如何创建和处理异步组件。我们描述了异步组件的特点,并将我们的伴侣应用修改为在加载组件之前先获取评论。通过介绍内置的 <Suspense> 组件,我们正确地加载了异步组件,并检查了在组件加载期间显示文本的回退功能。

在这个阶段,你应该能够完全处理异步数据加载、副作用以及需要在显示之前满足 JavaScript 承诺的组件。

在下一章中,我们将把重点从 Vue.js 转移到测试我们的应用。我们将学习使用 Cypress 进行端到端测试和 Vitest 进行单元测试的基础知识。

第三部分:通过 Vue.js 及其核心库扩展您的知识

在我们的学习旅程的这个阶段,是时候介绍 Vue.js 生态系统中的外部库了,这些库是构建生产就绪应用所必需的。

本部分包含以下章节:

  • 第八章*,使用 Vitest 和 Cypress 测试您的应用*

  • 第九章*,高级 Vue.js 技术介绍 – 插槽、生命周期和模板引用*

  • 第十章*,使用* Vue Router处理路由

  • 第十一章*,使用 Pinia 管理你的应用程序状态

  • 第十二章*,使用 VeeValidate 实现客户端验证

第八章:使用 Vitest 和 Cypress 测试你的应用程序

无论你在编写代码方面有多少经验,测试你的应用程序都是必须的,以确保代码库的质量。市面上有各种各样的测试工具,但在这本书中,我们将学习 Vitest 用于单元测试和 Cypress 用于端到端测试。

测试是一个很大的主题,在本章中,我们将学习两种测试方法的基础,省略了你在职业生涯过程中可能遇到的更高级的技术。

首先,我们将学习不同的测试方法以及它们各自如何有助于产生高质量的软件。然后,我们将通过学习如何使用 Vitest 和 Vue Test Utils 来测试应用程序中的单个组件来介绍单元测试。接着,我们将把注意力从单个组件转移到整个应用程序上,通过引入使用 Cypress 的E2E端到端)测试来实现。然后,我们将编写一个端到端测试来覆盖一个小用户旅程,在进入最后一部分之前,这部分将用于介绍诸如模拟和间谍等高级主题。

本章包括以下部分:

  • 测试金字塔

  • 使用 Vitest 进行单元测试

  • 使用 Cypress 进行端到端测试

  • 介绍高级测试技术

到本章结束时,你将获得对测试的一般基本理解。你将能够在你未来的项目中设置 Vitest 和 Cypress,并知道如何在单元测试和端到端测试中编写基本测试。最后,你还将接触到你在职业生涯中可能遇到的未来测试技术。

技术要求

在本章中,分支被命名为CH08。要拉取这个分支,请运行以下命令或使用你选择的 GUI 来支持你进行此操作:

git switch CH08.

该分支包含从上一章更新的所有文件。

本章的代码文件可以在github.com/PacktPublishing/Vue.js-3-for-Beginners找到。

测试金字塔

清洁代码、编码标准和同行评审是良好应用程序的重要组成部分,但它们并不是唯一的。事实上,一个良好且可靠的应用程序不仅是良好开发的来源,也是应用程序内良好测试覆盖率的结果。

测试覆盖范围非常广泛。一些公司进行非常少的测试,通过向最终用户发布新代码以寻找错误和错误,让他们成为实际的测试者,而其他公司则投入时间和预算来开发全面的测试集,并将它们添加到他们的流程中。

即使公司在测试上投入的时间不同,所有开发者至少可以同意,增加测试通常会导致向用户发布的错误更少,并且应用程序更加灵活。

可以为应用程序开发多个测试级别,并且它们被分为层,这些层共同形成一个金字塔(因此得名测试金字塔)。

图 8.1:测试金字塔

图 8.1:测试金字塔

金字塔的底部是单元测试所在的位置。单元测试检查单个单元,这通常是一个组件或辅助文件。这些测试运行非常快,因此通常以大量产生。然后,我们有集成测试,这是系统不同部分连接在一起以确保所有部分都能协同工作的地方。集成测试可以通过将代码和数据库集成在一起来测试方法是否成功地将条目添加到数据库中,因此得名集成测试。这些测试运行时间更长,需要更大的架构,因此预计它们的使用频率将低于单元测试。在金字塔中向上移动,我们发现端到端测试。端到端测试封装了由质量保证团队运行的手动测试,以及使用 Cypress 或 Playwright 等工具运行的自动端到端测试。手动端到端测试由质量保证团队手动运行,而自动端到端测试由 Cypress 或 Playwright 等工具运行。单个端到端测试覆盖整个用户旅程,由于其范围很大,你只需要少量测试来覆盖你的整个应用程序。

对于本书的范围,我们将涵盖单元测试和端到端测试。由于端到端工具的进步,集成测试将不会涉及,因为它们的有用性正在逐渐降低。

测试类型之间的差异不仅由它们可以捕获的不同错误定义,还由它们的“开发成本”和“运行速度”定义。让我们看看以下图表中不同测试的表现:

图 8.2:不同测试类型的比较

图 8.2:不同测试类型的比较

如果一个应用程序只做端到端测试,它会在开发后期才捕获到错误,从而减慢开发过程。相反,如果一个应用程序只做单元测试,它将无法捕捉到由系统不同部分协同工作产生的错误和不一致性,因为单元测试专注于单个单元,因此不能确保应用程序的不同部分按预期工作。

测试在成本上也有差异。测试成本是以编写测试所需的时间和努力来衡量的,以及它的“运行速度”,这指的是单个测试所需的时间。

在一个理想的世界里,测试的所有方面都应该是您开发过程的一部分。但这远非事实。在我的职业生涯中,我参与了许多项目,我可以数出只有少数企业真正拥有了自己的测试,实施了整个金字塔。不幸的是,对于许多企业来说,测试被视为一种额外负担(即,一种不直接产生收入的成本)。

在继续到下一章并开始开发我们的第一个测试之前,尝试定义使测试有价值的内容是很重要的。我们已经讨论了测试在客户端之前早期捕获 bug 的能力。但这并非唯一的好处;事实上,当被上级管理层问及为什么他们应该投资于测试时,我通常会通过引入“增加灵活性和降低变更风险”这一主题来证明我的观点。

增加灵活性和降低变更风险

在进行大型迁移时,首要任务是确保所有端到端测试都已设置。同样,在开发重设计时,组件的单元测试是必不可少的。经过良好测试的应用程序不仅会降低达到客户端的 bug 级别,而且在更新时也更加灵活。知道您可以轻松测试应用程序的主要功能,并且如果有什么东西退步或损坏,可以迅速得到通知,这是敏捷开发的关键方面,有助于使应用程序成功并降低对代码库进行更改的风险。

我希望这足以让您理解测试的重要性。现在是时候继续前进,学习如何在我们的应用程序中编写测试了。在现有应用程序中实施测试可能相当棘手,而且在开始之前,您还需要克服更高的学习曲线,但这一投资的回报对于您的职业生涯和代码质量是无价的。

让我们看看 Vitest 如何设置和实施,以测试我们伴侣应用程序的一些组件。

使用 Vitest 进行单元测试

Vitest 是 JavaScript 生态系统中的主要单元测试框架之一,其语法与 Jest 非常相似。在本章中,您将获得的大部分知识都可以转移到其他测试框架,因为它们都遵循一个非常相似的结构。

Vitest 已经在我们的应用程序中设置好了,但我们将介绍添加到您的新或现有应用程序所需的基本步骤。

在您的应用程序中安装 Vitest

需要的第一步是安装 VitestVue Test Utils 包,它们分别是测试运行器和 Vue 组件测试框架。我们可以在应用程序的根目录中打开终端并运行以下命令来实现:

npm install -D vitest @vue/test-utils

现在包已经安装,我们只需要将一个脚本添加到 package.json 文件中,这样我们就可以在将来简单地运行它:

...
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview",
  "test:unit": "vitest"
}...

使用的命令字符串是任意的,但命名它为"test:unit"是常见的做法。这将允许我们稍后定义"test:e2e",并清楚地定义两种不同的测试过程。

要运行 Vitest,我们只需在终端中运行新创建的脚本,使用npm run test:unit.。结果应该是这样的:

图 8.3:测试结果的终端截图

图 8.3:测试结果的终端截图

控制台终端应该显示错误消息,theButton.vue

编写我们的第一个单元测试

现在一切准备就绪,是时候开始使用我们的基础按钮组件编写第一个测试了。

单元测试有一个非常重要的目标——测试你应用程序的单个单元。在 Vue.js 这样的框架中,单元被定义为组件、组合式或存储文件。

当测试一个逻辑单元时,你应该关注其功能,而不是其静态内容,如文本。

创建一个仅比较文本值的文本焦点测试,不仅不会为我们的应用程序提供任何好处,而且由于其易变性(由于测试将不得不每次我们更改组件的副本时都进行更改)而难以维护。

在创建测试文件之前,让我们打开TheButton.vue并看看这个组件的哪个部分可以被测试:

<template>
<button
  :class="theme"
>
  {{ value }}
</button>
</template>
<script setup>
defineProps({
  value: {
    type: [String, Number],
    required: true
  },
  width: {
    type: String,
    default: "100px"
  },
  theme: {
    type: String,
    default: "light",
    validator: (value) => ["light", "dark"].includes(value)
  }
})
</script>
<style scoped>
...
</style>

我们的组件是一个标准的按钮,因此它包括我们期望的所有基本功能。这个组件的一些测试场景可能如下:

  • 检查组件是否成功渲染,确保组件结构没有错误

  • 检查默认样式是否默认加载

  • 检查主题属性是否可以更改主题

这三个初始测试对我们开始这个主题非常有帮助。正如我之前所说,测试可能有一个相当陡峭的学习曲线,所以从小处开始,并逐渐建立知识是很好的。

现在是时候创建我们的测试文件了。测试文件按照nameOfFileTested.spec.js的格式命名,所以在我们这个例子中,它将是 TheButton.spec.js。

我们可以在任何我们想要的文件夹中创建测试文件,但遵循项目标准并始终如一地遵循它是良好的实践。在我们的例子中,我们将在__tests__文件夹中创建这个文件。文件的完整路径应该是这样的:

/src/components/__tests__/TheButton.spec.js

在我们开始讨论测试结构之前,我们需要导入一些模块,这将允许我们创建测试。我们将从@vue/test-utils导入expectdescribeitmount方法,最后是组件本身。我们的测试文件顶部的import语句应该看起来像这样:

import { expect, describe, it } from 'vitest'
import {mount} from "@vue/test-utils";
import component from '../atoms/TheButton.vue'

现在我们已经设置了导入,是时候开始学习如何构建测试结构了。测试的结构遵循回调方法,其中每个方法都有一个回调到另一个方法,依此类推。

即使这种做法,也被称为回调地狱,通常是 JS 中的不良实践,但它为你的测试提供了最佳的结构。

要编写一个结构良好的单元测试,我们可以使用 Given、When 和 Then 语法来回答以下三个问题:

  • 我们在测试什么(Given)?

  • 我们在测试什么场景(When)?

  • 预期结果是什么(Then)?

然后,我们可以通过创建一个简单的句子来使用GivenWhenThen来创建自己的测试用例。所以,在我们的按钮例子中,句子可以是这样的:“Given TheButton.vue,when 它被挂载,then 它应该 正确渲染。”

通过前面的句子,我们现在可以编写我们的第一个单元测试。

首先用文字编写你的测试

通过首先用句子来编写测试,将有助于你更好地定义测试并确保你完全覆盖了组件。阅读和比较几个句子比在测试编写后做同样的事情要简单。

为了结构化我们的测试,我们将利用 Vitest 提供的describeit方法:

describe('TheButton.vue', () => {
  describe('when mounted', () => {
    it('renders properly', () => {
      // Test goes here
    });
  });
});

测试使用describe方法来定义上述 Given 和 When 语法,使用it方法来定义实际的测试(Then)。一个测试可以有多个嵌套描述。测试名称非常重要,因为测试框架在测试失败时会在错误消息中使用它们。拥有一个非常结构化的名称将有助于你在测试失败时节省时间。让我们尝试第一次运行我们的空测试并查看输出。要运行我们的测试,我们需要打开终端,访问project文件夹,并运行以下npm命令:

npm run test:unit

前一个命令的输出应该是以下内容:

图 8.4:Vitest 的终端输出

图 8.4:Vitest 的终端输出

测试结果遵循我们在测试中定义的相同嵌套结构。在describeit方法中使用的单词形成一个可读的单词。如果错误在本地或任何部署环境中触发,日志将显示文件名,然后是我们用来声明测试的不同单词。在我们的例子中将是TheButton.vue when mounted 渲染正确

Vitest 速度快——非常快

在上一节中,我们提到单元测试速度快,但尚未提到在所有单元测试框架中,Vitest 是最快的。其速度应归因于 Vite 服务器,Vitest 就是基于这个服务器构建的。

我们的第一个测试还没有测试任何东西,因为它有一个空的主体。让我们回到去看我们如何测试我们的组件。为了完成这个任务,我们将使用 Vue Test Utils 提供的mount方法和expect方法,后者用来告诉单元测试引擎我们想要测试的内容。

mount 方法用于通过在测试框架中渲染组件来初始化组件,而 expect 用于定义我们的测试用例。

在每个测试中,我们首先设置我们的组件和可能需要的任何状态。在我们的例子中,我们只需要挂载它:

const wrapper = mount(component, {});

接下来,我们将通过检查它是否包含一个 button 元素来断言我们的组件已成功渲染:

expect(wrapper.html()).toContain('button');

expect 方法接受被测试的值作为其参数,然后将其链接到对给定值的测试。Vitest 随带大量断言。

例如,如果我们想创建一个检查两个数字是否相等的测试,我们会这样做:

const numberFive = 5;
expect(numberFive).toEqual(5);

完整的测试文件应该看起来像这样:

import { expect, describe, it } from 'vitest'
import component from '../atoms/TheButton.vue'
import {mount} from "@vue/test-utils";
describe('TheButton.vue', () => {
  describe('when mounted', () => {
    it('renders properly', () => {
      const wrapper = mount(component, {});
      expect(wrapper.html()).toContain('button');
    });
  });
});

因此,总结一下,我们了解到测试遵循一个非常结构化的方法。它们要求我们定义我们要测试的内容,以及我们正在考虑的场景和断言。

让我们再添加一个测试,以创建以下测试句子:“Given TheButton.vue,when it is mounted,then it defaults to the light theme。”

如您从句子的前两部分中可以看到,GivenWhen 是相同的,这意味着我们可以重用现有的代码块,只需再添加另一个测试。

为了检查是否应用了正确的主题,我们将检查 CSS 类 light 是否已应用于组件。

import { expect, describe, it } from 'vitest'
import component from '../atoms/TheButton.vue'
import {mount} from "@vue/test-utils";
describe('TheButton.vue', () => {
  describe('when mounted', () => {
    it('renders properly', () => {
      const wrapper = mount(component, {});
      expect(wrapper.html()).toContain('button');
    });
    it('defaults to light theme', () => {
      const wrapper = mount(component, {});
      expect(wrapper.classes()).toContain('light');
    });
  });
});

如前一个代码块中突出显示的文本所示,添加遵循相同设置的额外测试相当简单。为了完成新的测试,我们再次使用 mount 来初始化组件的一个版本。然后我们使用 wrapper.classes() 提取组件的所有类,并使用 expect().toContain() 断言这包含 light

由于两个测试都有相同的 When,我们能够重用 describe 方法 describe('when mounted'..)。这样做将帮助我们逻辑上分割测试。

测试会自动更新,所以当文件保存时,我们的终端应该输出新的结果,显示两个通过测试:

图 8.5:Vitest 测试结果显示两个通过测试

图 8.5:Vitest 测试结果显示两个通过测试

你的回合

编写最后一个单元测试来完成 TheButton.vue 的测试。最后一个测试应该测试当传递正确的属性时,组件是否渲染了深色主题。研究 Vue Test Utils 和 Vitest 文档,了解如何在挂载组件时定义属性。下一章分支将包括为你准备的测试,这样你可以检查你的测试是否编写正确。

单元测试是一个很大的主题,这只是一个非常简单的介绍,以提供一些必要的基本信息。每个项目都会创建略有不同的测试,这可能是由于项目结构、组件的分解,甚至是使用的命名约定。因此,真正学习如何编写测试的最佳方式是实践。

在我们继续前进之前,我想分享一个额外的技巧,以确保你正确地测试你的代码。实际上,在编写单元测试时,让它们过于依赖组件的内部结构是很常见的。单元测试的预期是测试当给定特定输入时,单个代码单元会输出什么。这意味着你应该能够测试一个组件,而无需打开文件,只需知道它接受的属性和它发出的 UI/事件即可。在编写你的单元测试时,始终关注输入和输出,并将实现细节排除在测试之外。

在本节中,我们介绍了单元测试,并讨论了如何使用 Vitest 来测试我们的组件。我们学习了编写单元测试所需的语法,并介绍了 Vue Test Utils 以帮助我们处理 Vue 组件。然后,我们讨论了测试名称的重要性,以及它是如何遵循“给定、当、然后”方法的。

在下一节中,我们将攀登金字塔,学习如何编写端到端测试。我们将能够重用本节学到的部分知识,并继续学习如何使我们的代码更加可靠。

使用 Cypress 进行端到端测试

是时候将我们的测试焦点从单元测试提供的微观层面,转移到端到端测试提供的宏观层面了。如果单元测试关注单个组件的单个状态,端到端测试将帮助我们测试完整的用户旅程。

正如我之前提到的,我们将能够使用之前章节中学到的知识,因为端到端测试的结构与我们在 Vitest 中学到的类似。

让我们将这一节分解为三个不同的部分。首先,我们将学习如何在你的应用程序中安装 Cypress。然后,我们将学习端到端测试文件的基礎结构和它在项目中的位置。最后,我们将通过为我们的应用程序编写端到端测试来结束这一节。

在 JS 行业中最常用的端到端(E2E)工具是 Cypress 和 Playwright。Cypress 已经存在了相当长的时间,并且目前占据了很大的市场份额。Playwright 相对较新,但由于它与 IDE 的集成以及广泛的浏览器模拟,它正在迅速增加市场份额。

就像单元测试一样,市场上不同工具的语法非常相似,所以你在一个中学习的多数功能和语法,如果你后来决定切换,也可以用在另一个中。

让我们从将 Cypress 安装到你的项目中开始。

将 Cypress 安装到你的项目中

今天的 JS 生态系统为我们提供了具有惊人用户体验的工具,它们提供了非常简单的安装过程,而 Cypress 就是其中之一。

我们的 Companion App 已经包含了一组测试,它们是通过我们介绍的 Vue 项目初始化安装的。然而,了解如何将 Cypress 添加到您的新项目或现有项目中有助于学习。由于 Cypress 已经是 Companion App 的一部分,您将不得不在新的文件夹中遵循以下步骤。

cypress.io提供的官方文档中提供了两种安装方法,一种是直接下载,另一种是npm 安装

我们将使用npm包管理器来跟随安装。为此,在您首选的终端中访问您项目的根目录,并输入以下命令:

npm install cypress

下一步是运行 Cypress。这可以通过以下npx命令实现(注意命令是npx而不是npm):

npx cypress open

几秒钟后,您应该会看到一个欢迎屏幕。

图 8.6:Cypress 的启动仪表板

图 8.6:Cypress 的启动仪表板

Cypress 可以设置为端到端和组件测试。图 8.6中的向导将我们的测试标记为未配置。让我们通过点击左侧块开始配置端到端测试。这样做将生成一组示例文件,我们需要运行我们的第一个测试。

图 8.7:Cypress 向导测试文件创建

图 8.7:Cypress 向导测试文件创建

向导创建了四个文件。第一个是主配置文件,位于我们项目的根目录下,文件名为cypress.config.js,其中包含所有与 Cypress 相关的设置。第二个和第三个是支持示例文件,用于创建可重用的命令;命令是为更高级的用户准备的,本书没有涉及。最后一个文件是一个可访问于cypress/fixtures/example.json的固定示例 JSON 文件。固定文件用于保存可重用的信息,例如文本输入或 API 响应。

如果我们在安装向导中继续前进,我们将看到一个浏览器选择屏幕。这个屏幕将在每次运行 Cypress 时显示,并允许您选择您想要使用的浏览器。

图 8.8:Cypress 的“选择浏览器”屏幕

图 8.8:Cypress 的“选择浏览器”屏幕

让我们通过点击 Chrome 打开测试运行器。

图 8.9:Cypress 的 chrome 测试运行器的第一个实例

图 8.9:Cypress 的 chrome 测试运行器的第一个实例

由于我们选择了 Chrome 作为测试运行器,我们将在一个新的 Chrome 窗口中开始。这个运行器将提供创建示例测试或生成测试模板的能力。

我们将在安装向导的这个点上停止;是时候回到我们的 Companion App,继续我们的旅程,了解更多关于端到端测试和 Cypress 的信息了。

安装和阅读示例测试

“scaffold example specs” 提供的测试非常广泛且编写良好。这些示例测试为您提供了对 E2E 测试可以实现的惊人洞察。在进入下一章之前,花几分钟安装并阅读这些测试。

学习 E2E 测试的文件格式和文件位置

在上一节中,我们学习了如何将 Cypress 安装到我们的项目中。在本节中,我们将在创建第一个测试之前学习这些 E2E 测试文件的文件位置和格式。请记住,我们现在正在我们的 Companion App 中工作,所以你应该用你选择的 IDE 打开它。

我们 E2E 测试的位置在 Cypress 配置文件中定义。正如我们在上一节中学到的,这个文件位于我们应用程序的根目录,称为 cypress.config.js。此文件包含一些设置,其中之一被称为 specPattern。此配置设置告知 Cypress 在哪里找到测试:

specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}'

此模式期望 E2E 文件位于 cypress/e2e 文件夹中,并且文件名以 cy,specjs,jsx,ts 以及 tsx 的组合结尾。

因此,一个测试文件可以命名为 mytest.spec.tsmytest.cy.js,如果它位于 cypress/e2e 文件夹中,Cypress 测试运行器将能够看到并利用它。

让我们创建第一个测试,命名为 homepage。因为我想要区分单元测试和 E2E 测试,所以我会以 cy.js 结尾这个文件(单元测试以 spec.js 结尾)。新文件的完整位置如下:

Cypress/e2e/homepage.cy.js

现在文件已经创建,是时候学习如何构建这些文件了。正如我之前提到的,E2E 测试和单元测试的结构有一些相似之处。事实上,这两个测试都遵循相同的语法,分别以回调和 describeit 方法为基础的结构。

测试脚手架应该看起来像这样:

describe('Homepage', () => {
  it('default journey', () => {});
});

如您所见,代码结构非常明显,就像 Vitest 提供的那样,唯一的区别是,在这种情况下,我们不需要导入 describeit 方法,因为它们会自动为我们导入。

另一个小的不同之处在于测试的名称。当我们在一个单元测试中定义一个名称时,它是测试的一个重要部分,具有定义良好的 GivenWhenThen 方法。在 E2E 测试中,名称的重要性略低,主要是因为这些测试可以具有很大的范围,例如对主页的全面检查,并且提供一句话来定义我们正在测试的内容并不总是可能的。

在我们继续前进之前,我们将尝试在我们的应用程序中运行 E2E 测试,并学习执行此操作所需的步骤。

在上一节中,我们使用了 npx cypress open 来启动 Cypress。这仍然可以使用,但我们的 package.json 中有一些脚本供我们使用,这些脚本带有额外的配置,简化了我们的开发体验:

"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'"

应用程序提供了两个脚本,"test:e2e""test:e2e:dev"。第一个用于在预览构建上运行 E2E,而第二个用于在带有热重载的开发构建上运行测试。这将允许我们在 E2E 测试运行器准备运行的同时对我们的应用程序进行修改。

尝试通过在终端中运行以下命令来运行开发 E2E 测试环境:

npm run test:e2e:dev

此命令的结果应该是我们之前看到的 Cypress 仪表板。就像之前一样,你应该点击E2E 测试,并选择Chrome作为运行测试的浏览器。

结果应该是一个 Chrome 浏览器,如下面的截图所示:

图 8.10:Chrome 中的 Cypress 测试仪表板

图 8.10:Chrome 中的 Cypress 测试仪表板

浏览一下

在继续本章之前,你应该花几分钟时间熟悉测试运行器。了解它提供的内容以及它是如何工作的,将非常有利于你未来的学习和 E2E 测试的使用。

要运行我们的测试,点击测试名称homepage.cy.js。这将加载 Cypress 测试运行器:

图 8.11:Cypress 测试运行器

图 8.11:Cypress 测试运行器

目前,测试运行器并不十分有用,因为我们唯一的测试是空的。

现在,是时候进入下一节了,我们将要在 Cypress 中编写我们的第一个测试。

编写你的第一个 E2E 测试

E2E 测试通常被称为旅程。这个名字来源于它封装了特定用户的旅程。这些可能包括完成联系我们表单所需的步骤,或者购买产品所需的步骤。

由于我们当前应用程序的大小,这个旅程将会相当小,但仍然可以确保我们构建一个稳定的程序。

我们的测试将完成以下方面:

  • 访问网站

  • 显示应用程序标题

  • 确保帖子被加载

  • 检查没有评论的帖子是否显示正确的空消息

  • 检查带有评论的帖子是否正确显示评论。

E2E 测试是按顺序编写的,就像你手动完成相同的旅程一样。因此,我们将从使用cy.visit命令访问主页开始我们的测试:

describe('Homepage', () => {
  it('default journey', () => {
    cy.visit('/');
  });
});

当使用visit命令时,你可以传递任何相对 URL。在我们的场景中,我们只需传递一个正斜杠,因为我们将要访问主页。

基础 URL 预设

注意,我们能够通过使用单个正斜杠来访问主页,因为我们已经在cypress.config.js中设置了一个baseUrl预设。如果没有baseUrl设置,你将不得不在cy.visit命令中插入完整的 URL。

接下来,我们将检查是否存在标题Companion App。这是通过使用两个新方法getshould实现的:

...
cy.visit('/');
cy.get('h1').should('be.visible');
...

get方法就像document.querySelector一样,允许你在页面上选择一个给定的元素。should方法允许你设置一个预期——也就是说,它定义了测试是成功还是失败。should方法接受一个参数,这是我们测试选择器的逻辑。这些参数被称为链式调用器,有数百种不同的可能性。学习所有可用链式调用器的最好方法是查阅文档(docs.cypress.io/guides/references/assertions)或使用 IntelliSense IDE。

图 8.12:链式调用器的 IntelliSense 弹出窗口

图 8.12:链式调用器的 IntelliSense 弹出窗口

当你编写测试预期时,链式调用器列表会自动显示在你的 IDE 中。

无论你使用文档还是 IDE,如图图 8**.12所示,真正重要的是你要熟悉这些不同的链式调用器,并了解你可以使用什么,不能使用什么。

在前面的例子中,我们只是在调用should方法时传递了一个参数,但它也可以接受两个参数。第二个参数用于在第一个参数定义的条件需要传递值时传递一个值。例如,我们可能需要比较屏幕上元素的数量与一个变量,比较两个字符串,或者确保 API 返回的值与特定对象匹配。在我们的当前测试中,我们只是检查<H1>是否存在,但我们并没有真正检查它是否是正确的标题,因此我们可以将我们的实现更改为使用两个参数:

...
cy.visit('/');
cy.get('h1').should('contain.text', 'Companion App');
...

当使用contain.text时,我们比较我们选择的元素的innerText字符串与一个任意值。

我们端到端(E2E)测试的下一步将是确保帖子成功加载,通过使用get方法获取元素和have.length链式调用器来确保结果值是我们预期的:

...
cy.get('h1').should('contain.text', 'Companion App');
cy.get('.SocialPost').should('have.length', 5);
...

当应用程序正确加载时,它将加载五个帖子,为了在我们的测试中检查这一点,我们选择所有具有SocialPost类的元素,并使用should('have.length', value)语法比较它们的长度。

接下来,我们需要测试评论组件。我们将通过点击SocialPost.vue组件来实现这一点,我们会发现找到按钮的最佳方式是使用一个大的选择器,.SocialPost .interactions button。这个选择器不是一个最佳解决方案,不仅因为它非常冗长,而且还因为它过于依赖于组件的结构,因此非常脆弱。为了避免使用复杂的选择器,我们可以添加一个端到端(E2E)属性。这通常是通过给一个元素添加一个data-cy属性并用于测试目的来定义的(cy在数据中代表 cypress)。

在你的代码中添加一个数据属性将使你的测试更加健壮,避免与 CSS 类和 HTML 元素结构变化相关的易变测试的创建。

在继续我们的测试之前,让我们打开 SocialPost.vue 并添加所需的属性:

<TheButton
  @click="onShowCommentClick"
  value="Show comment"
  width="auto"
  theme="dark"
  data-cy="showCommentButton"
/>

data-cy 只是一个简单的数据属性,但它已成为行业标准,并被许多开发者用于为端到端测试提供直接选择器。我们现在能够更新我们的测试以使用新声明的 showCommentButton 属性来选择我们的按钮:

cy.get('.SocialPost').should('have.length', 5);
cy.get('[data-cy="showCommentButton"]').first().click();

由于我们页面上有五篇帖子,但我们只想点击第一篇,我们将使用一个名为 first 的辅助方法来检索 get() 返回的第一个元素。然后,我们通过调用 click 方法来结束链,模拟在按钮上点击鼠标。结果应该是加载的评论组件。

如果你检查显示我们的测试运行器的浏览器窗口,你应该能够看到加载在第一个元素中的 comment 组件。

图 8.13:Cypress 测试显示已加载评论的 Companion App

图 8.13:Cypress 测试显示已加载评论的 Companion App

接下来,我们将使用上一节学到的知识来测试带有评论的帖子的正确渲染。如图 图 8.13 所示,第一篇帖子没有评论。为了更好地提高我们的覆盖率,我们还应该找到一个带有评论的帖子。一个带有评论的帖子在最后一篇帖子(编号 5)中找到,因此我们可以使用它来确保评论被正确显示。

根据我们所学的,我们应该能够编写一个看起来像这样的测试:

cy.get('[data-cy="showCommentButton"]').first().click();
cy.get('.SocialPostComments').should('contain.text', 'There are no comments for this post!');
cy.get('[data-cy="showCommentButton"]').last().click();
cy.get('.SocialPostComments').last().should('not.contain.text', 'There are no comments for this post!');

代码显示了三个命令。第一个检查社交帖子评论组件的内容。我们使用主类 .SocialPostComments 来选择组件,并使用 contain.text 命令。此命令检查该选择器的 innerText 字符串,并将其与作为参数传递的字符串进行比较。在我们的情况下,我们传递了 There are no comments for this post!。然后,我们通过复制之前使用的命令并替换 first()last() 来点击最后一篇帖子。最后,我们再次复制我们最近使用的命令来检查字符串的内容,并通过在链式调用前添加单词 not 来检查相反的情况。在创建端到端测试时,通常会有测试某个东西(例如,一个字符串或一个数字)的命令,然后是一个测试该断言不再发生的命令。由于这种重复出现的场景,端到端框架为您提供了在链式调用前添加单词 not 的能力来检查相反的情况。因此,如果这个 should('contain.text', 'hello') 检查元素是否包含单词 hello,那么 should('not.contain.text', 'hello') 就检查相反的情况——即确保选定的元素不包含单词 hello

这是我们端到端旅程的结束。在下一节中,我们将介绍本章尚未涵盖但可能对您的未来测试体验非常有用的高级技术。

介绍高级测试技术

在本节的结尾,我们将介绍几个属于测试但在此章节中未涉及的主题。正如我在本章开头提到的,测试是一个非常大的主题,这只是一个快速介绍,以确保你知道如何开始并创建简单的测试。然而,这绝对不是一本完整的指南,还需要进一步的学习。

本节的目标是解释一些你在未来创建端到端测试或单元测试时可能会遇到测试的方面。其中一些适用于端到端和单元测试,而其他则是单一测试方法的独特之处。

测试是一个非常有趣的主题,因为你所学到的关于测试的知识完全取决于你所从事的工作。因此,有些人可能需要学习如何编写高度依赖外部 API 的测试,因此专注于实现存根和模拟,而其他开发者可能从事依赖于状态管理的应用程序,可能需要学习使用状态测试组件的方方面面。

让我们深入探讨,分享一些关于这三个主题的讨论:

  • 模拟

  • 监视

  • 浅层挂载

我们将从模拟开始。

模拟

模拟是单元测试和端到端测试中使用的两种方法。模拟的定义如下:

“模拟是测试过程中用来隔离并集中关注被测试代码,而不是外部依赖的行为或状态,通过模拟方法或对象来实现。”

在编写测试时,有些情况下你可能想避免使用真实服务(方法或对象)。例如,你可能想在每次测试运行时避免创建订单,或者不得不使用付费 API 来获取一些模拟数据。另一个例子是模拟原生的 API,如 fetch 和 IntersectionObserver。

为了避免这些外部依赖,我们可以创建一个模拟方法或对象来模拟真实的第三方应用或服务。这被称为模拟

模拟是确保你的测试范围明确且不依赖于外部因素的一种基本技术。

监视

间谍与模拟非常相似;实际上,它们赋予你分析特定方法使用情况的能力。然后,这些信息可以用于测试预期——例如,能够断言一个方法被调用了X次。与模拟的主要区别在于,间谍实际上不会改变原始方法,只是监听其使用情况。

监视对于确保应用程序的正确执行非常有用,而不会干扰实际的方法。添加监视器与使用代理非常相似,所有对监视方法的请求都会首先通过测试框架。

你可能需要监视存储中的某个操作以确保它在测试执行期间被调用,监视日志方法以确保正确的值传递给它,或者关注窗口对象中的全局方法。

虚拟化是为了防止我们使用外部服务,而监视(spying)则更多地旨在为我们提供一个工具,以便在测试中进行正确的假设和断言。

就像虚拟化一样,我们可以使用测试框架提供的工具来监视方法和模块。

shallowMount(仅限单元测试)

这个最后的功能仅适用于单元测试,更确切地说,是用于测试组件。在使用 Vitest 进行单元测试部分,我们使用了mount方法来创建组件的实例,但还有一个可用的方法,称为shallowMount,在本节中,我们将解释它们之间的主要区别。

单元测试全部关乎速度,在编写单元测试时,始终选择使用更少资源并更快完成测试的更快方法至关重要。这些经济性之一可能来自于我们初始化组件的方式。

当使用mount时,Vue Test Utils 会渲染组件及其包含的任何其他组件。因此,在app.vue上运行mount将渲染其中的完整应用程序。由于单元测试预期将专注于特定的单元,你可能只需要渲染你正在测试的组件,而不是其子组件。

为了实现这一点,我们可以使用shallowMount

shallowMount将渲染组件,但随后通过仅渲染占位符 HTML 来模拟子组件。这样做将减少测试所需的资源,并使其性能更优。

选择使用哪一个取决于你的偏好和应用程序的整体架构。我个人更喜欢为大多数组件使用mount,以确保它们即使在依赖项的情况下也能正确加载,而对于具有多个子组件的复杂组件,我则依赖shallowMount

摘要

在本章中,我们介绍了测试金字塔,涵盖了测试的重要性以及软件开发中可用的不同测试实践。然后,我们转向单元测试,学习了如何使用 Vitest 和 Vue Test Utils 测试我们的应用程序。接着,我们上升到了测试金字塔,并介绍了使用 Cypress 的端到端测试。我们创建了一个覆盖简单用户旅程的小测试,并学习了一些选择和测试应用程序的技术。最后,我们通过介绍测试生态系统中的未来测试功能来结束本章,这些功能可能对未来的学习有所帮助。

轮到你了

花几个小时尝试和测试更多组件,以了解更多关于单元测试的知识,并扩展我们的端到端测试的用户旅程。确保阅读官方文档,其中包含了两个测试框架的所有可用命令,并且是查找所需信息的最佳资源。

在下一章中,我们将介绍两个高级技术,称为slotRefs

第九章:高级 Vue.js 技术介绍 – 插槽、生命周期和模板引用

到目前为止,我们已经了解了 Vue.js 提供的基本功能和技巧。例如属性和计算属性是 Vue.js 框架的基础,您在用 Vue.js 开发下一个应用程序时将每天使用它们。在本章中,我们将介绍一些我称之为“高级”的功能。这不是因为它们的复杂性而被描述为“高级”,而是因为您在日常使用中不太可能使用它们。例如插槽和模板引用用于解决特定的用例,并不期望在您的常规任务中遇到,而更可能用于在项目过程中发生的较少的特定情况。您在本章中学到的知识可能不会立即使用,所以记住它的存在,并在需要任何这些功能时确保您能回想起它。

在本章中,我们将向我们的伴侣应用程序添加更多功能。首先,我们将增强我们的基本按钮以引入插槽的概念。接下来,我们将创建一个共享布局,使用命名插槽在我们的静态页面中使用。然后,我们将关注添加一个新功能,这将使我们能够添加新帖子。在这样做的时候,我们将学习如何使用模板引用来访问CreatePost.vue组件。为了结束本章,我们将构建一个全新的功能,这将使我们能够展开和折叠侧边栏。在这样做的时候,我们不仅将遍历 Vue.js 的生命周期钩子,而且还将利用这个机会回顾之前学过的技术,如方法、动态类和指令。

本章将分为以下几部分:

  • 插槽的力量

  • 使用模板引用访问组件元素

  • 深入了解真实应用的生命周期

到本章结束时,您将能够使用提供插槽的组件,并开发暴露一个或多个插槽的组件。您还将能够使用模板引用在组件内访问元素,并最终在使用生命周期时做出更好的决策,以确保应用程序无 bug。

技术要求

在本章中,分支被命名为CH09。要拉取此分支,请运行以下命令或使用您选择的 GUI 来支持您进行此操作:

git switch CH09.

本章的代码文件可以在github.com/PacktPublishing/Vue.js-3-for-Beginners找到。

插槽的力量

在本书的前几章中,我们学习了如何使用属性和事件让父组件和子组件相互通信。这种通信方式不是很灵活,因为组件暴露信息的唯一方式是创建一个新的属性。

在许多情况下,属性提供的僵化性正是我们想要确保组件正确渲染的,但有时需要更多的灵活性,这就是插槽发挥作用的地方。

让我们考虑我们的基础按钮组件。它的外观和感觉由其属性定义,其值也是如此,但如果我们想在值之前创建一个带有图标的按钮会怎样呢?

根据我们对 Vue.js 的当前了解,我们会求助于创建一个新的属性 iconprependIcon),它接受一个图标。然后,进一步的要求可能需要我们在值之后添加一个图标,因此我们又会求助于一个新的属性 iconappendIcon)。每个额外的请求可能会导致一个新的属性,使得我们的组件难以维护。幸运的是,所有上述要求都可以使用插槽来解决。

插槽将你的组件变成一个包装器,允许人们传递任何任意的 HTML 或组件到其中。插槽并不是什么新东西;实际上,原生 HTML 提供了非常类似的功能,你一直在不知不觉中使用它。<div> 只是一个包装其他元素的容器,<h1> 可以包含文本,也可以在其内容中包含其他元素,以此类推。

Vue.js 插槽也提供了原生按钮、标题或 span 提供的相同功能。

让我们考虑一下在 HTML 中如何使用按钮。我们会用 <button> 打开元素,然后在其内容中添加一些东西——这可以是简单的文本,另一个元素,或者两者都有。最后,我们会用 </button> 关闭元素,就像这样:

<button>
  <icon src="img/myIcon" />
  My button with icon
</button>

好吧,My button 文本和之前代码片段中显示的 <icon> 元素,在 Vue.js 中我们称之为插槽内容。正如之前提到的,使用属性是确保传递给组件的值是特定类型的一种很好的方式,但这种优势很容易变成劣势,使得组件变得相当僵化。

让我们通过修改 TheButton.vue 来看看如何使用插槽。我们将移除名为 value 的属性,并用一个插槽来替换它,这样我们的按钮将像原生 HTML 按钮一样工作。

让我们修改我们的组件:

<template>
  <button :class="theme">
    <slot></slot>
  </button>
</template>
<script setup>
defineProps({
  value: {
    type: [String, Number],
    required: true
  },
  width: {
    type: String,
    default: "100px"
  },
  theme: {
    type: String,
    default: "light",
    validator: (value) => ["light", "dark"].includes(value)
  }
})
</script>

首先,我们从 defineProps 对象中移除了名为 value 的属性,然后在组件模板中添加了一个 Vue.js 元素 <slot> 来添加一个插槽。这个元素不需要导入,因为它可以在全局范围内访问。

现在,我们需要找到所有 TheButton 的出现,并替换之前使用属性值时的语法:

<TheButton value="Example value" />

我们需要用新的插槽来替换它:

<TheButton>Example value</TheButton>

上述更改需要在 CreatePost.vueSocialPost.vueSidebar.vue 中发生。

插槽不仅仅是文本

记住,现在我们的组件值不仅接受文本,还可以接受其他 HTML 元素和 Vue 组件。

现在我们已经了解了插槽的基础知识,让我们继续深入学习,从插槽默认值开始。

为插槽添加默认值

Vue.js 的插槽不仅允许我们复制原生的功能,还提供了一些额外功能,其中之一是能够向我们的插槽添加默认值。这可以是对话框标题或表单提交按钮中显示的文本;默认值可以帮助你保持代码的整洁。

为你的插槽添加默认值非常简单;你只需要直接在<slot>声明中添加你想要的值。如果没有传递其他替代值给组件,这个值将被使用,否则,它将被移除并从接收到的插槽中覆盖。

让我们通过一个快速示例来了解默认插槽的声明和使用。

首先,我们将向TheButton.vue添加一个默认文本点击我

<template>
  <button :class="theme">
    <slot>Click Me</slot>
  </button>
</template>

现在我们已经添加了默认值,我们的按钮将自动显示新的文本,在这种情况下没有传递内容。

要看到默认插槽的实际效果,我们需要调用我们的按钮而不包含任何插槽内容,因此代码看起来是这样的:

<TheButton></TheButton>

这将渲染我们的默认文本。

图 9.1:默认按钮,值为“点击我”

图 9.1:默认按钮,值为“点击我”

文本只是作为一个后备;实际上,在插槽内添加一个值将覆盖文本:

<TheButton>Show this</TheButton>

正如我们之前看到的,在插槽中添加文本将显示它,就像你预期在原生的 HTML 元素中一样:

图 9.2:默认按钮,值为“显示此内容”

图 9.2:默认按钮,值为“显示此内容”

插槽与属性对比

在我们学习关于插槽的另一个特性之前,我想说几句话来澄清为什么我们需要两种方法(插槽和属性)来实现非常相似的结果。

插槽和属性解决了两种不同的用例,并且两者都有优点和缺点:

  • 插槽:这些主要用于需要值灵活性的组件。这些组件仅提供结构,例如对话框或按钮或标题等结束元素。

  • 属性:这些用于需要精细控制传递给它们的元素的元素。这可能是由于某些样式会因不同内容而破坏,或者因为需要验证或格式化接收到的值。

我们可以使用原生的 HTML 元素来强调我们刚刚定义的差异。对于插槽,我们有标题标签,如<h1><h2>。这些是非常通用的元素,它们提供样式,主要是以字体大小和间距的形式为其内容提供样式。这个元素需要灵活性,使用插槽是完美的。事实上,原生的标题元素就像插槽一样包裹其内容。

另一方面,我们有 <input> 元素来证明属性值(Vue.js 中的属性)的有用性。输入元素需要一个特定的属性,称为 value。输入字段提供的值只接受字符串或数字作为其值。提供接受属性的语法,<input value="text" /> 确保传递给输入字段的值是经过验证的。

提供多个带有命名插槽的插槽

如您可能已经注意到的,插槽有很多有用的功能,对于构建复杂组件非常有用。在前一节中,我们看到了如何定义单个插槽,但 Vue.js 提供的不仅仅是这些。

实际上,Vue.js 中的组件可以同时定义多个插槽。这是通过一个称为命名插槽的功能实现的。

命名插槽的常见用途是定义布局。您可以定义一个接受侧边栏、主要内容以及页脚的布局,然后允许用户在各个部分中传递他们认为合适的内容。另一个很好的例子是一个提供标题和副标题的主要英雄组件,或者是一个提供标题及其内容的对话框组件。

让我们为我们的静态内容创建一个非常基本的布局。我们打算将新组件存储在一个名为 templates 的文件夹中,并命名为 StaticTemplate.vue。相对 URL 将是 src/components/templates/StaticTemplate.vue

<template>
  <h1></h1>
  <main></main>
  <footer></footer>
</template>
<style scoped>
h1, main, footer {
  grid-column-start: 1;
  grid-column-end: 3;
}
h1{
  align-items: center;
}
main {
  padding: 16px 32px;
}
footer {
  border-top: solid 1px lightgray;
}
</style>

我们的布局将包含一些基本的样式,这些样式是创建正确间距并显示各个部分之间差异所必需的。模板还包括三个不同的部分:使用 <h1> 定义的标题,使用 <main> 元素定义的主要内容,以及最后的 <footer>

现在,让我们给我们的模板添加一个命名插槽。命名插槽的语法与普通插槽非常相似,因此也是使用 <slot> 元素定义的,但增加了一个 name 属性。为了强调之前的学习材料,我们将为页脚提供一个默认值:

<template>
  <h1><slot name="heading"></slot></h1>
  <main><slot name="default"></slot></main>
  <footer>
    <slot name="footer">
    Copyright reserved to Vue.js for beginners
    </slot>
  </footer>
</template>

在前面的代码中,StaticTemplate.vue 文件可以调用,并具有传递三个不同部分的可能性。让我们查看 Views 文件夹中的 AboutView.vue 页面,并尝试使用这个新布局。

命名插槽的语法与默认插槽略有不同,因为我们需要定义我们正在定义的章节的实际名称。要使用命名插槽,您使用 <template #slotName></template> 语法。

让我们尝试将其应用到我们的 AboutView 页面上:

<template>
<StaticTemplate>
  <template #heading>About Page</template>
  <template #default>
    This is my content and default slot
  <template>
</StaticTemplate>
</template>
<script setup>
import StaticTemplate from '../components/templates/staticTemplate.vue'
</script>

首先,我们在脚本部分导入了新组件,然后就像使用 <div><span> 一样使用该组件。最后,我们通过定义标题和默认部分来为我们的插槽添加内容。由于关于页面不需要覆盖页脚,所以我们将其排除在我们的实例之外,以便可以渲染默认值。

如果我们访问“localhost:5173/about”上的关于页面,我们应该看到以下内容:

图 9.3:使用新布局的关于页面

图 9.3:使用新布局的关于页面

页面正在显示我们预期的所有三个部分。

默认插槽不需要<template #default>语法

如果我们愿意,我们可以在不使用<template #default>的情况下调用默认插槽,但这不是一个建议的方法,因为它会产生难以阅读的代码,并且混合了不同的方法。

Vue.js 插槽实现提供的一个额外功能是作用域插槽。这是一个非常高级的技术,允许你定义将子组件的作用域暴露给其父组件的插槽。由于它们的复杂性,这些内容不会在本书中讨论。

插槽是一个非常强大的技术,它帮助我们使组件更容易使用,提高其可读性,并提供增强的灵活性。就像其他每个特性一样,插槽也有自己的优缺点,并且应该只在需要时使用。使用插槽带来的灵活性可能会对 UI 产生不期望的副作用,有时通过使用属性来强制设置值,可以更容易地控制组件的布局。

到目前为止,我们通过将它们与原生 HTML 元素中现有的功能进行比较,介绍了插槽的概念,然后学习了定义和使用默认插槽所需的语法。之后,我们比较了插槽和属性,并试图区分在哪些情况下一种方法可能比另一种方法更可取。最后,我们学习了如何为插槽定义默认值以及定义多个具有命名插槽的可能性。

我们将继续本章,介绍另一个高级主题,介绍如何使用模板引用来访问子组件或 HTML 元素。

使用模板引用访问组件元素

在本节中,我们将通过学习如何访问 DOM 元素来提高我们组件的控制能力。

当我最初开始学习 Vue.js 时,我被它在不访问 DOM 元素的情况下能完成多少事情所震惊。Vue.js 引擎的结构是这样的,我们可以使用 props、data、methods 和计算属性来完成所有基本操作。

就像其他所有事情一样,有时候我们可能需要一些额外的控制,而这可以通过使用模板引用来实现。使用这个特性会暴露出定义了引用的 DOM 元素。这和直接使用原生 JavaScript 提供的querySelectorgetElementById方法是一样的。

相同的引用,不同的用法

你可能想知道为什么我们学习 Refs,因为我们已经在书的开始部分学习了它来定义组件的私有属性。嗯,这个 Refs 是不同的。它是用相同的语法定义的,但它将持有 HTML 元素或组件的值,而不是字符串和数字等原始数据。

Refs 可用于两种不同的场景。第一种是访问原生元素以访问其原生 API;这样的事情之一可能是聚焦一个元素或触发输入字段的原生验证。第二种是访问另一个 Vue 组件或包以公开方法或其内部信息。一个常见的场景是需要设置或获取 WYSIWYG(所见即所得)或代码编辑器的值。第二种场景不是常见做法,仅应在紧急情况下使用。不建议使用模板引用访问子组件的原因是它将两个元素耦合在一起,而在基于组件的架构中,这应该始终避免。

专注于 onMounted 元素

让我们通过学习如何获取输入字段的引用并在页面加载时将其聚焦来开始玩转模板引用。聚焦功能原本可以通过autofocus属性实现,但我们将手动开发这个功能来展示 Refs 的使用。

我们将在CreatePost.vue中工作,并自动聚焦用于撰写新帖子的textarea

我们需要对组件进行三项修改。首先,我们将定义一个ref,就像我们在整本书中学到的。接下来,我们将模板引用分配给textarea,并使用onMounted生命周期访问该元素并聚焦组件:

<template>
  <form>
    <h2>Create a Post</h2>
      <textarea rows="4" cols="20" ref="textareaRef">
      </textarea>
      <TheButton>Create</TheButton>
  </form>
</template>
<script setup>
  import { onMounted, ref } from 'vue';
  import TheButton from '../atoms/TheButton.vue';
  const textareaRef = ref(null);
  onMounted( () => {
    textareaRef.value.focus();
  });
</script>

使用 Refs 的顺序是特定的;我们首先创建const textareaRef = ref(null) Ref,此时 Ref 将是 null。然后我们将 ref 分配给组件内的一个特定元素;在我们的场景中,我们将其添加到textarea。由于组件尚未渲染,元素尚未在页面上存在,因此 Ref 仍然是空的。最后,我们在onMounted生命周期中触发我们的逻辑。因为onMounted生命周期是在组件完全渲染后触发的,所以 Ref 将完全定义并准备好使用。

在我们继续之前,我想关注代码块中的三个更多部分。首先是一个提醒,Vue.js 中的 Refs 需要附加 .value 才能访问它们的内部值。这适用于正常的 Refs,如字符串和数字,以及我们在本书的这一部分中介绍的模板 Refs。其次,我想强调,与元素关联的模板 Ref(在我们的例子中是 textareaRef)在 onMounted 触发之前将是 null。这意味着如果我们将其用于计算属性或方法,我们需要确保检查 null 值以避免错误。最后是关于命名约定的问题。模板 Ref 的名称和用于 HTML 元素的名称必须匹配才能正常工作。模板 Ref 变量(const textareaRef)和用于 HTML 属性(ref="textareaRef")的名称必须匹配才能成功工作。

Refs 仅适用于特殊情况

Vue.js 提供了我们大部分需要的现成功能,因此 Refs 不应过度使用。Refs 的使用应仅限于特定的用例,而不是成为常规。如果你在项目中发现 Refs 的使用次数超过几次,那么你很可能误用了其他 Vue.js 功能。

从原生验证访问

本节将关注高级主题;我们将通过实现另一个使用模板 Ref 访问元素的示例来重述我们刚刚介绍的主题。我们将继续处理创建帖子的代码,通过添加一些使用 HTML 表单输入提供的原生 HTML 验证来执行验证。

我们将继续在 CreatePost.vue 中工作。首先,我们将在 <textarea> 元素内添加一些特定的验证:

<textarea
  rows="4"
  cols="20"
  ref="textareaRef"
  required="true"
  minlength="10"
></textarea>

我们将 textarea 设置为必填,并定义了最小字符长度为 10。接下来,我们将创建一个新的 Ref,这次我们将获取页面中主 <form> 元素的访问权限:

<template>
<form ref="createPostForm">
   ...
</form>
</template>
<script setup>
import TheButton from '../atoms/TheButton.vue';
import { onMounted, ref } from 'vue';
const textareaRef = ref(null);
const createPostForm = ref(null);

就像我们之前的例子一样,创建 Ref 需要两个步骤。首先,我们给一个元素添加一个 Ref 属性,然后创建一个与 Ref 名称匹配的常量。

在这一最后步骤中,我们将创建一个在表单提交时被调用的方法,并使用我们新创建的模板 Ref 来访问原生的表单验证 API:

<template>
  <form ref="createPostForm" @submit="createPost">
    <h2>Create a Post</h2>
    <textarea
      rows="4"
      cols="20"
      ref="textareaRef"
      required="true"
      minlength="10"
    ></textarea>
    <TheButton>Create Post</TheButton>
  </form>
</template>
<script setup>
import TheButton from '../atoms/TheButton.vue';
import { onMounted, ref } from 'vue';
const textareaRef = ref(null);
const createPostForm = ref(null);
const createPost = (event) => {
  event.preventDefault();
  if(createPostForm.value.reportValidity()){
    //code to create post
  };
}
...

为了实现这一点,我们使用了在 第六章 中获得的事件知识,并在表单提交时触发了名为 createPost 的方法。然后,我们创建了一个方法来阻止原生提交触发,并使用 createPostForm Ref 通过 createPostForm.value.reportValidity() 检查表单的有效性。

此方法将返回一个布尔值(因此是 truefalse),取决于表单是否有效,并且它还会在屏幕上显示原生错误消息。

让我们启动我们的应用程序,并尝试通过一个空输入来触发表单:

图 9.4:在 textarea 上显示的本地 HTML 验证错误

图 9.4:在 textarea 上显示的本地 HTML 验证错误

我们将保留这个表单的原样,即使它不完全功能,因为提交表单所需的代码将在第十二章中完成。

在本节中,我们了解到 Ref 不仅用于定义组件值,还用于保存 HTML 元素的值。我们通过开发在CreatePost.vue中创建新帖子所需的功能和验证来应用这项新学到的技术。最后,我们迭代了定义 Refs 的流程以及它如何需要完成挂载状态才能使其可访问。

在下一节中,我们将添加扩展和最小化应用侧边栏的能力,并迭代生命周期概念。

深入研究真实应用的生命周期

生命周期不是一个新概念,在本书中被提及过一两次,但由于其重要性,通过向我们的伴侣应用添加更多功能来迭代它们是个好主意。

在本节中,我们将添加新的功能,使我们能够扩展和最小化应用的左侧边栏,并将值存储在localStorage中,以确保在刷新时用户偏好保持持久。我们将学习如何更好地理解生命周期可以改善我们应用提供的用户体验。

在我们深入代码本身之前,让我们思考一下实现这个新功能所需的步骤。思考问题有助于你记住我们所学 Vue.js 框架的所有不同方面,并支持你实现任何可能存在的知识或理解上的缺乏。

要完成使侧边栏能够折叠和展开的任务,我们需要做以下几步:

  1. 添加用户触发此操作的 UI。

  2. 添加一些样式以改善侧边栏的外观和感觉。

  3. 创建一个新的值来保存侧边栏的状态。

  4. 扩展侧边栏以对不同状态有不同的渲染。

  5. 添加处理侧边栏状态变化并保存值的逻辑。

  6. 最后,在加载时读取用户偏好并应用到组件上。

列表可能看起来非常长,但这些都是你可以不依赖我的帮助就能完成的小任务。让我们通过着手新的 UI 更改开始我们的工作。

向侧边栏添加条件渲染

让我们从使用v-ifv-else重述 Vue.js 指令开始,有效地显示两种不同的视图。一个将显示展开的侧边栏,而另一个将显示一个仅包含展开侧边栏按钮的最小布局。

所有后续的开发都将发生在包含我们侧边栏所需逻辑的 SideBar.vue 文件中。在我们对其布局进行更改之前,我们将导入两个新图标,IconLeftArrowIconRightArrow

<script setup>
import { ref } from 'vue';
import TheButton from '../atoms/TheButton.vue'
import IconLeftArrow from '../icons/IconLeftArrow.vue'
import IconRightArrow from '../icons/IconRightArrow.vue'
...

接下来,我们将使用 v-if/v-else 提供的条件逻辑添加两个布局:

<template>
<aside>
  <template v-if="">
    <IconRightArrow />
  </template>
  <template v-else>
    <h2>Sidebar</h2>
    <IconLeftArrow />
    <TheButton>Create post</TheButton>
    <div>Current time: {{currentTime}}</div>
    <TheButton @click="onUpdateTimeClick">Update Time</TheButton>
  </template>
</aside>
</template>

<aside> 包含两个不同的块,都由 <template> 元素界定。第一个块只包含右箭头图标,将用于显示我们的折叠布局,而另一个布局包括我们在开始修改之前组件中存在的现有代码,并添加了左箭头图标。

如果你仔细查看前面的代码,你可能意识到某些内容缺失或不完整。事实上,如 if/else 这样的条件语句需要一个假设,而我们的代码中目前缺少这个假设:<template v-if="" >

为了使该语句生效,我们需要添加一个条件,该条件将由 Vue.js 编译器评估以显示正确的布局。我们将创建一个名为 closed 的 Ref,它将保存一个布尔值。这个变量将用于我们的 if/else 语句,以定义应该显示哪个布局。

为了实现这一点,我们首先在我们的脚本部分定义 ref

aside {   display: flex;   flex-direction: column;   position: relative;   &.sidebar__closed{     width: 40px;   }   .sidebar__icon{     position: absolute;     right: 12px;     top: 22px;     cursor: pointer;   } }

为了更好地样式化我们的两个布局,我们声明了两个类。第一个是 `sidebar__closed`,用于减小侧边栏的宽度。第二个是 `sidebar__icon`,用于定义箭头的大小和位置。

`sidebar__icon` 应用于两个图标,而 `sidebar__closed` 仅在 `closed` 的值为 `true` 时分配给 `<aside>`。为此,我们使用了 `:class="{ 'sidebar__closed': closed}"` 语法。这种语法很有用,因为它允许你在满足特定条件时轻松地应用类,从而创建复杂的样式。

在这个阶段,侧边栏不仅功能正常,而且其折叠和展开布局也被正确地样式化了。剩下要做的就是使数据 *持久化*。在开发中,我们描述数据为持久化,当其值在浏览器刷新后仍然保持一致时。

![图 9.5:展开的侧边栏](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/a147677659fb4d9aa4c886f24b97fb3a~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771340449&x-signature=c6Rl6NO9r9ggzHpZVwU4Bbo3rgw%3D)

图 9.5:展开的侧边栏

![图 9.6:折叠的侧边栏](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/6f8b4306dccd470582034943bbbfdad3~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771340449&x-signature=2jyK1zQI0KV9fC7ezSHEwpAzDn8%3D)

图 9.6:折叠的侧边栏

现在侧边栏的外观已经得到改善,是时候让它刷新后也能保持其价值了。

## 在 localStorage 中保存和读取用户偏好

在这个阶段,即使侧边栏逻辑完全正常工作,其数据还没有持久化。实际上,如果你将侧边栏设置为折叠状态并刷新页面,你会看到它会回到默认的展开视图(我们用来初始化 `closed` 引用的值是 `false`)。

为了实现持久性,我们将使用 `localStorage` 来保存我们的值,并在页面加载时重新读取它。

在我们查看代码之前,我们应该尝试定义实现这一目标的最佳方法。实际上,本节是以 Vue.js 生命周期命名的,但到目前为止,我们还没有使用它们。

为什么正确使用生命周期很重要?

在继续前进之前,花几分钟时间尝试理解使用不正确的生命周期从 `localStorage` 加载数据的后果。考虑不同的生命周期,它们何时被触发,以及它们可能对应用程序产生的影响。

如我们在 *第二章* 中所学,存在不同的生命周期,支持不同的用例。在我们的场景中,我们计划使用一个生命周期来读取我们的 `closed` 变量的值,并将其应用于组件。在执行此类操作时,你通常应该问自己几个问题。第一个问题是数据是否异步,第二个问题是数据是否在应用程序渲染到屏幕之前就必需。

每个生命周期都在组件生命周期的不同阶段发生。例如,`beforeCreate` 在组件甚至创建之前就被触发,而其他如 `onMounted` 则在组件完全挂载到 DOM 中后触发,因此选择正确的生命周期对于我们的特定场景非常重要。

在我们的情况下,数据是从 `localStorage` 中获取的,这是一个同步操作;它需要在组件完全渲染或显示之前,也称为“在挂载之前”。

最适合我们需求的生命周期是`onBeforeMount`。这将触发在组件渲染之前,但在所有方法和 Refs 初始化之后。

让我们将这个逻辑添加到我们的组件中:

```js
<script setup>
import { ref, onBeforeMount } from 'vue';
import TheButton from '../atoms/TheButton.vue'
import IconLeftArrow from '../icons/IconLeftArrow.vue'
import IconRightArrow from '../icons/IconRightArrow.vue'
const currentTime = ref(new Date().toLocaleTimeString());
const closed = ref(false);
const toggleSidebar = () => {
  closed.value = !closed.value;
  window.localStorage.setItem("sidebar", closed.value);
}
...
onBeforeMount( () => {
  const sidebarState = window.localStorage.getItem("sidebar");
  closed.value = sidebarState === "true";
});
</script>

为了实现持久性,我们首先从 Vue 包中导入了onBeforeMount方法,然后每次触发toggleSidebar方法时,我们都保存closed Ref 的值。为了实现这一点,我们使用了window对象中可用的localStorage.setItem方法。最后,我们使用onBeforeMount从 localStorage 中读取值,并将其分配给closed ref。

在这个阶段,我们的应用不仅允许用户切换侧边栏,而且其值在刷新时也会保持持久。

在完成本章之前,我想重点强调为什么正确使用生命周期很重要。实际上,如果我们使用了另一个生命周期,比如onMounted,那么在读取并应用localStorage的值之前,侧边栏就已经被完全渲染(错误地)了。这类 bug 的主要问题是,在开发过程中它们可能不会复现,或者非常难以发现。

在创建将要改变组件视图的代码时,确保你已经使用了正确的生活周期,如果处理异步数据,那么在组件的其他部分执行之前,必须定义正确的加载状态,或者等待 promise 完成。这种做法一开始可能难以理解,但代码实践和错误将帮助你提高对 Vue.js 组件的理解,并提高你的技能。

摘要

在本章中,我们介绍了一些高级主题,例如插槽、生命周期和 refs。本章的目标并不是提供你在这方面的所有信息,而是让你对这些概念有所了解,以便你在接下来的开发中能够实践它们,并在扩展你对 Vue.js 知识的过程中继续学习。

我们已经学习了如何使用插槽来扩展组件的灵活性。插槽和命名插槽可以用于简单的情况,例如<button>、样式元素,如<div>,或者用于更高级的技术,例如定义具有不同区域的页面布局。

然后,我们继续讨论模板Ref,这是一个我们在之前章节中部分介绍过的主题。我们学习了如何使用模板Ref来访问组件的 DOM 元素。这被定义为一种高级技术,因为,在 Vue.js 提供的所有功能中,你很少需要以这种方式使用模板Ref

最后,我们再次回顾了生命周期。Vue.js 的生命周期非常重要,需要大量的实践来帮助你理解它们的用法以及,更重要的是,它们的执行顺序。我们在我们的伴侣应用中增加了一个额外功能,以便我们能够理解其一个使用案例,并思考如果使用不同的生命周期会产生什么样的可能结果。

在下一章中,我们将学习如何使用vue-router定义多个路由。对于大多数应用来说,定义多个页面是一个必要的步骤,而vue-router提供了一个非常简单的语法,这将帮助我们在我们的小伴侣应用中实现这一功能。