VueJS-前端项目-全-

VueJS 前端项目(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

你是否想使用 Vue.js 进行 Web 应用开发,但不知道从何开始?使用 Vue.js 进行前端开发项目将帮助你构建开发工具包,并准备好应对现实世界的 Web 项目。你将通过实际示例和活动掌握这个 JavaScript 框架的核心概念。

通过本书中的用例,你将了解如何在 Vue 组件中处理数据,定义组件间的通信接口,以及处理静态和动态路由以控制应用流程。你将掌握 Vue CLI 和 Vue DevTools,并学习如何处理过渡和动画效果以创建引人入胜的用户体验。在关于测试和部署到网络的章节中,你将获得像经验丰富的 Vue 开发者一样工作的技能,并构建其他人可以使用的专业应用。

你将参与现实的项目,这些项目以小规模练习和活动形式呈现,让你以愉快且可行的方式挑战自己。这些迷你项目包括聊天界面、消息应用、购物车和价格计算器、待办事项应用以及用于存储联系信息的个人资料卡生成器。

到本书结束时,你将自信地处理任何 Web 开发项目,并应对现实世界的客户端开发问题。

关于作者

雷蒙德·坎贝尔是 IBM 的开发者倡导者。他的工作集中在 MobileFirst 平台、Bluemix、混合移动开发、Node.js、HTML5 以及一般性的 Web 标准上。他是一位出版作者,在会议和用户组上就各种主题发表演讲。雷蒙德可以通过他的博客、Twitter 或电子邮件联系。他是许多开发书籍的作者,包括《Apache Cordova 实战》和《客户端数据存储》。

雨果·迪·弗朗索瓦是一位与 JavaScript 有着广泛合作的软件工程师。他拥有伦敦大学学院(UCL)数学计算硕士学位。他在像佳能和爱思唯尔这样的公司使用 JavaScript 构建了可扩展且性能卓越的平台。他目前正使用 Node.js、React 和 Kubernetes 解决零售运营空间的问题,同时运营同名网站 Code with Hugo。在工作之外,他是一位国际击剑运动员,在全球范围内进行训练和比赛。

克利福德·格尼是一位以解决方案为导向、以结果为目标的初创公司技术负责人。他在沟通设计和广泛参与领导数字化转型计划方面的背景丰富了他使用 Vue JS 设计概念性前端解决方案的能力。克利福德在 Vue JS 墨尔本聚会中发表过演讲,并与志同道合的人合作,提供一流的数字体验平台。

菲利普·柯克布里奇拥有超过 5 年的 JavaScript 经验,并驻扎在蒙特利尔。他在 2011 年从一所技术学院毕业,自那时起,他一直在各种角色中与网络技术合作。

玛雅·沙文是一位高级前端开发者、演讲者、博主、Storefront UI 核心成员,以及 VueJS 以色列 Meetups 的创始人兼组织者。

本书面向对象

本书面向刚开始使用 Vue.js 的开发者,他们希望获得对单页应用(SPA)模式的基本理解,并学习如何使用 Vue.js 创建可扩展的企业级应用。如果你已经使用 React 或 Angular,并希望开始学习 Vue.js,这本书也会对你很有帮助。为了理解本书中解释的概念,你必须熟悉基本的 HTML、CSS、JavaScript(如对象、作用域、this 上下文以及值与引用),以及Node 包管理器(NPM)。

关于章节

第一章开始你的第一个 Vue 项目,让你立即创建 Vue 组件。你将学习 Vue.js 的基础知识,以及理解 JavaScript 应用程序中的响应性。

第二章处理数据,提供了关于使用计算数据属性、使用监视器观察状态变化以及利用异步 API 等更多组件构建块的信息。

第三章Vue CLI,深入探讨了 Vue 的生活质量工具包。你将了解如何使用 Vue CLI 和浏览器开发者工具。

第四章嵌套组件(模块化),探讨了在组件之间传递数据以实现模块化的方法。

第五章全局组件组合,深入探讨了在 Vue.js 代码库中共享组件功能的方法。

第六章路由,涵盖了 Vue 中使用标准路由和动态路由。你将学习如何在 Vue 中创建具有复杂多页应用的 SPA。

第七章动画与过渡,涵盖了 Vue 内置的动画和过渡,以及如何使用外部 JavaScript 库为 Vue 添加动画。我们将在演示应用中创建自定义动画。

第八章Vue.js 状态管理的现状,提供了对 Vue.js 状态管理不同方法的视角。

第九章使用 Vuex – 状态、获取器、动作和突变,介绍了 Vuex 库,用于 Vue 中的状态管理。

第十章使用 Vuex – 获取远程数据,讨论了如何使用 Vuex 和远程 API。

第十一章使用 Vuex – 组织大型存储,帮助你组织和管理工作量大的 Vuex 存储。

第十二章单元测试,探讨了 Vue.js 应用程序中各个部分的测试,包括组件、过滤器以及混入。

第十三章端到端测试,介绍了 Cypress,它用于编写 Vue.js 应用程序的端到端测试。

第十四章,“将您的代码部署到 Web 上”,探讨了持续集成/持续部署的现代最佳实践,并考虑了如何将 Vue.js 应用程序部署到多个托管提供商。

习惯用法

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:

panic()函数接受一个空接口。”

您在屏幕上看到的单词,例如在菜单或对话框中,也以相同的格式出现。

代码块设置如下:

<template>
    <div>
        Vue Template Code
    </div>
</template>

新术语和重要单词如下所示:“这些行为统称为方法集。”

对其他章节和图像的引用如下所示:“图 2.15显示了前面代码生成的输出。”

代码片段的关键部分如下突出显示:

      <input
        id="phone"
        type="tel"
        name="phone"
        v-model="phone"
        class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
      />

长代码片段将被截断,GitHub 上相应代码文件的名称将放置在截断代码的顶部。整个代码的永久链接将放置在代码片段下方。它应该看起来如下:

Exercise1-12.vue
17 <script>
18 export default {
19   data() {
20     return {
21       list: [
22         'Apex Legends',
23         'A Plague Tale: Innocence',
24         'ART SQOOL',
25         'Baba Is You',
26         'Devil May Cry 5',
27         'The Division 2',
28         'Hypnospace Outlaw',
29         'Katana ZERO',
30       ],
31     }
32   },
33   methods: {
34     deleteItem(value) {
35       this.list = this.list.filter(item => item !== value)
36     },
37   },
38 ...
The complete code for this step is available at https://packt.live/3pJGLvO.

在开始之前

每一段伟大的旅程都始于一个谦逊的步伐。我们即将开始的 Vue.js 之旅也不例外。在我们能够使用 Vue 做些令人惊叹的事情之前,我们需要准备好一个高效的环境。在接下来的简短章节中,我们将看到如何做到这一点。

运行 Node.js 应用程序的最低硬件推荐

为了能够运行书中推荐的全部工具,建议您具备以下条件:

  • 1 GHz 或更快的桌面处理器

  • 至少 512 MB 的 RAM(更多更好)

  • Windows 32/64 位、macOS 64 位、Linux ARMv7/v8

安装 Node.js

为了让 Vue.js 运行,您的计算机上必须安装 Node.js。按照nodejs.org/en/download/上 Windows、macOS 和 Linux 的说明下载最新 LTS 版本。Node.js 安装和使用都是免费的。

安装 Git

Node.js 应用程序使用版本控制工具 Git 来安装额外的工具和代码。您可以在git-scm.com/book/en/v2/Getting-Started-Installing-Git找到 Windows、macOS 和 Linux 的安装说明。Git 安装和使用都是免费的。

安装 Yarn

一些练习将使用 Yarn 依赖管理器来安装和运行 Vue.js 应用程序。您可以在classic.yarnpkg.com/en/docs/install找到 Windows、macOS 和 Linux 的下载和安装 Yarn 的说明。Yarn 安装和使用都是免费的。

安装 Vue.js CLI(Vue 命令行界面)

一些练习将要求您使用 Vue.js CLI。您可以在cli.vuejs.org/guide/installation.html找到下载和安装 Vue.js CLI 的说明。

安装 Visual Studio Code(编辑器/IDE)

您需要一个用来编写 Vue 源代码的工具。这个工具被称为编辑器或集成开发环境IDE)。如果您已经有了喜欢的编辑器,您可以使用它来配合这本书。

如果您还没有编辑器,我们推荐您使用免费的 Visual Studio Code 编辑器。您可以从code.visualstudio.com下载安装程序:

  1. 下载完成后,打开 Visual Studio Code。

  2. 从顶部菜单栏选择视图

  3. 从选项列表中选择扩展。左侧应该会出现一个面板。在顶部是一个搜索输入框。

  4. 输入Vetur。第一个选项应该是一个名为Vetur的扩展,由Pine Wu提供。

  5. 点击该选项中的安装按钮。等待出现一条消息,表明已成功安装。

安装代码包

从 GitHub 下载代码文件,请访问packt.live/3nOX2xE。请参考这些代码文件以获取完整的代码包。

联系我们

我们欢迎读者的反馈。

customercare@packtpub.com

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

copyright@packt.com,并附上材料的链接。

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

请留下评论

请在亚马逊上留下详细、公正的评论,告诉我们您的看法。我们非常重视所有反馈——它帮助我们继续制作优质产品并帮助有抱负的开发者提升技能。请抽出几分钟时间留下您的想法——这对我们来说意义重大。

本书最初是从sanet.st下载的。 sa_logo

第一章:1. 开始你的第一个 Vue 项目

概述

在本章中,你将学习 Vue.js 的关键概念以及为什么你应该考虑 Vue.js 作为你的下一个项目。你将学习如何从命令行运行 Vue 项目;描述 Vue.js 项目的架构;使用各种样式和 HTML 语法风格创建 Vue 单文件组件;并且能够熟练地编写 Vue 方法和数据对象以及控制 HTML 模板。

到本章结束时,你将能够描述 Vue 生命周期钩子和表达式的基础知识。

简介

行业中的开发者需要快速解决前端开发问题,同时尽量减少对现有工作流程或后端架构的影响。在许多情况下,UI 直到项目结束时才被完全忽视,这可能是由于资源不足、不断变化的产品需求或现有的前端是简单部分的态度。然而,像苹果和谷歌这样的公司已经证明,前端的设计思维对于打造一个能够激发和吸引用户、带来更高投资回报和更成功业务的产品或平台至关重要。

如果你已经发现了 Vue.js,你可能也遇到了其他前端框架,如 Ember、Angular 或 React,它们表面上似乎解决了相同的问题。在表面层面上,它们都试图使响应式前端开发更加可靠,并引入使这更容易做的模式。然而,与 Angular 或 React 项目相比,Vue 项目可能会有一些关键的不同之处。让我们来看看它们。

Angular 与 Vue 的比较

Angular 是由谷歌构建的模型-视图-视图模型MVVM)框架,过去,由于谷歌的支持以及 Angular 从一开始就是为与 TypeScript 一起使用而创建的事实,因此,企业公司通常更倾向于选择 Angular。支持 Angular 的生态系统包括预编译AoT)渲染、路由和 CLI 管理,但未能提供简化的全局状态管理系统;开发者需要学习和使用Flux或采用NgRx。Vue 采用了 Angular 的稳健性和可靠性核心思想,并通过其无差别的开发方法改善了开发体验,通过移除对开发者强制执行的代码风格限制。通过 Vue 的单文件组件系统简化了熟悉的 Angular 模式,如 HTML 指令和依赖注入,以实现模块化,这使开发者受益于无需学习和记住各种结构(注入器、组件、管道、模块等)。Vue 对 TypeScript 和类型支持出色,且没有 Angular 可能具有的强制编码语言和开发风格的缺点。React 和 Vue 都专注于组件驱动开发,这减少了学习新框架所需的时间和精力。

React 与 Vue 的比较

React 的流行和庞大的开发者社区背后的动力归功于 Facebook 的专职工程师以及它在 2013 年开源发布,当时 Angular 2+还无处可寻。React 的 JSX 模式(一种在 JavaScript 中编写 HTML 和 CSS 的方式)为新手开发者带来了更高的学习曲线,他们不仅需要学习另一种语言,还需要理解基于组件的架构。组件允许开发者以模块化的方式构建应用程序;单个组件描述其自己的功能部分和生命周期,当需要时可以实例化,当不再使用时可以销毁。Vue 将这些模块化编码的核心概念结合起来,使开发者能够使用 JSX 或像传统单文件 Web 应用程序一样编写 HTML、CSS 和 JavaScript 来构建这些组件。Vue 在单文件组件中的关注点分离简化了开发者的模块化结构。

使用 Vue 进行项目的优势

Vue 的学习曲线平缓,生态系统活跃。这使任何规模的团队能够受益,因为它不需要大量的开销来教育开发者如何使用 Vue.js 框架。

  • Vue.js 是开发中一个易于学习但难以掌握的模式的另一个例子。Vue 的一个关键优势是其对新老开发者都易于接近

  • 开箱即用,开发者可以使用一个优化良好且性能出色的框架来构建任何规模的动态前端应用程序。

  • 单文件组件(SFC)模式提供了一个模块化和灵活的蓝图,简化了开发过程,并为所有级别的开发者提供了愉快的体验,为组件混乱带来了秩序。单文件组件使 Vue 真正多功能,你可以实现基本功能,并逐步将静态站点的部分内容集成到 Vue 中,而不是彻底改造整个网站。

  • 对于熟悉ReduxNgRx模式的任何开发者来说,官方的全局状态管理支持应该是一种安慰。尽管这些库在正确使用时非常强大,但Vuex是创建健壮的全局状态模式的绝佳中间方案,这些模式具有灵活性,可以满足大多数开发需求。

对于那些希望快速上手的开发者来说,除非个别用例确实需要,否则不要重新发明轮子,构建自定义的响应式模式。你可以通过使用 Vue 作为框架来节省时间和金钱,因为它已经性能出色,并且官方支持构建端到端应用程序所需的库,包括vue-router、Vuex 状态管理、开发工具等。

在本章中,我们将首先介绍 Vue 架构,然后让你熟悉 Vue 独特的 SFC 模式和 HTML 模板语法糖。你将学习如何使用 Vue 特定的模板语法和编码模式,包括 Vue 绑定、指令、生命周期钩子、作用域和局部状态。在 Vue 官方插件生态系统中,我们将主要关注核心 Vue 库。首先,让我们看看 Vue 的项目架构。

简单 Vue 应用中的 Vue 实例

开始使用 Vue 的一种最简单的方法是通过Vue函数导入 Vue 包。每个 Vue 应用都由一个根 Vue 实例组成,该实例是通过new Vue函数创建的。所有创建的相应 Vue 组件也使用相同的语法定义,然而,它们被视为嵌套 Vue 实例,可以包含它们自己的选项和属性:

var vm = new Vue({
  // options
})

注意

vm是一个常用术语,用来指代vm,它可以帮助你在代码块中跟踪你的 Vue 实例。

在这个例子中,我们使用jsdelivr CDN 导入 Vue,这将允许你使用 Vue 函数:

<!DOCTYPE html>
<html>
<head>
    <title>Vue.js CDN</title>
    <script src="img/vue.js">
    </script>
</head>
</html>

使用类、ID 或数据属性在<body>标签中声明一个元素。Vue 因其能够使用简单的模板语法(如双大括号)声明性地将数据渲染到 DOM 中而闻名,例如,{{ text }}

<!DOCTYPE html>
<html>
<head>
    <title>Vue.js CDN</title>
    <script src="img/vue.js">
    </script>
</head>
<body>
    <div>
        <p class="reactive-text">{{ text }}</p>
    </div>
</body>
</html>

<head>标签中,我们看到一些在 DOM 加载时触发的原生 JavaScript 代码。它构建了一个与带有class .reactive-text的元素绑定的 Vue 组件。标记为text的数据属性将替换大括号占位符,并用字符串Start using Vue.js today!定义:

<head>
    <title>Vue.js CDN</title>
    <script src="img/vue.js">
    </script>
    <script>
        document.addEventListener('DOMContentLoaded', function () {
            new Vue({
                el: '.reactive-text',
                data: {
                    text: "Start using Vue.js today!"
                }
            })
        })
    </script>
</head>
<body>
    <div>
        <p class="reactive-text">{{ text }}</p>
    </div>
</body>
</html>

在前面的脚本中,你将带有reactive-text类的<p>元素绑定到新的 Vue 实例。因此,现在 Vue 理解了这个 HTML 元素,你可以使用{{ text }}语法在<p>元素内部输出数据属性text

上述代码的输出将如下所示:

Start using Vue.js today!

虽然 CDN 是一种非常便携的方式来开始在你的项目中包含 Vue.js,但使用包管理器是 Vue 推荐的安装方法,因为 Vue 是通过 webpack 编译的,这允许你轻松控制第三方库版本。你可以在这里访问它:vuejs.org/v2/guide/installation.html。接下来,我们将探索 webpack 示例的样子。

Webpack Vue 应用

Vue 项目结构与许多现代基于 node 的应用类似,这些应用在项目的根目录下包含一个package.json文件和一个node_modules文件夹。通常,其他配置文件也位于根目录级别,例如babel.config.js.eslintrc.js,因为它们通常会对整个项目产生影响。以下截图显示了默认的 Vue 应用文件夹结构:

![图 1.1:默认 Vue 应用文件夹结构]

![img/B15218_01_01.jpg]

图 1.1:默认 Vue 应用程序文件夹结构

Vue 项目结构遵循一种模式,其中大部分源代码都管理在/src目录中。你可以将 Vue 文件细分为各种文件夹,例如,使用components文件夹来存储可复用的 Vue 组件。默认情况下,Vue 将创建资产和一个components文件夹来拆分默认文件。对于初学者来说,遵循这种模式直到你更熟悉以对应用程序有意义的任何方式拆分代码是很好的:

![图 1.2:默认 Vue 应用程序 src 文件夹结构图片

图 1.2:默认 Vue 应用程序 src 文件夹结构

public文件夹是一个特殊目录,用于包含需要直接传输到输出位置的文件。以下截图显示了此文件夹的外观:

![图 1.3:默认 Vue 应用程序 public 文件夹图片

图 1.3:默认 Vue 应用程序 public 文件夹

默认情况下,public文件夹将包含一个index.html文件,该文件作为加载 Vue 应用程序的占位符。index.html文件可以被修改以包含所需的头部和尾部脚本,例如 Google 字体或作为 webpack 包一部分之外的第三方 JavaScript 库。

Vue 单页组件

组件是大多数现代框架的构建块。通常将工作拆分成更小的块不仅使代码更容易理解,而且从功能上遵循了不要重复自己DRY)的原则。对于 Vue 用户来说,最具独特性的模式之一,也是最有益的模式之一,是单文件组件SFC)模式。SFC 将外观和行为的责任集中到一个文件中,通常简化了项目架构,使开发过程更简单,能够在不切换文件的情况下引用 HTML、CSS 和 JavaScript 逻辑。你的默认.vue 文件结构如下:

![图 1.4:默认.vue 文件结构图片

图 1.4:默认.vue 文件结构

许多新 Vue 开发者容易陷入的一个陷阱是编写超过 500 行代码的巨大型 Vue 文件,仅为了 HTML 本身。通常这意味着你可以将这个长组件拆分成一些更小的组件;然而,我们将在未来的章节中介绍文件导入和代码拆分。

例如,在应用程序的头部,你可能有一个需要在不同页面上保持一致的复用 logo 元素。你将创建一个如logo.vue的组件:

// logo.vue
<template>
      <img src="img/myLogo.png" />
</template>

你可以将其导入到名为header.vue的头部组件中:

// header.vue

<template>
    <header>
      <a href="mywebsite.com"><logo /></a>
    </header>
</template>

<script>
    import logo from 'components/logo.vue'
    export default {
        components: {
          logo
        }
    }
</script>

很快,你将拥有大量这些语义化结构的文件,它们使用这些小块的可复用语法,你的团队可以在应用程序的各个区域实现。

在下一节中,我们将了解数据属性。

数据属性(Props)

在构建 Vue 组件时,最常用且具有响应性的术语之一是数据属性。这些在 Vue 实例的数据函数中体现出来:

<template>
    <div>{{color}}</div>
</template>
<script>
    export default {
        data() {
          return {
            color: 'red'
          }
        }
    }
</script>

您可以使用数据属性来存储您想在 Vue 模板中使用的基本信息。当这个数据属性更新或更改时,它将在相应的模板中响应式地更新。

练习 1.01:构建您的第一个组件

在这个练习中,我们将在 Vue 项目内部构建我们的第一个组件。在这种情况下,组件是通过 yarn 导入并安装的。这些将在 前言 中介绍。到练习结束时,您将能够自信地使用 Vetur 创建新的 Vue 组件并将它们导入到项目中。

要访问此练习的代码文件,请参阅 packt.live/35Lhycl

  1. 打开命令行终端,导航到 Exercise 1.01 文件夹,并按顺序运行以下命令:

    > cd Exercise1.01/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

    注意

    当您保存新的更改时,您的应用程序将进行热重载,因此您可以立即看到它们。

  2. code . 命令中,进入 src/App.vue 目录,并删除该文件中的所有内容并保存。

  3. 在您的浏览器中,一切应该是空白的,作为开始工作的清洁画布。

  4. 构成一个单文件组件的三个主要组成部分是 <template><script><style> 块。如果您已从 前言 中安装了 Vetur 扩展,请输入 vue 并按 Tab 键选择提示的第一项。这是设置默认代码块的最快方式,如下面的截图所示:图 1.5:VSCode Vetur

    // src/App.vue
    <template>
    </template>
    <script>
    export default {
    }
    </script>
    <style>
    </style>
    
  5. components 文件夹中创建另一个名为 Exercise1-01.vue 的文件,并重复相同的步骤,使用 Vetur 搭建 Vue 块:

    // src/components/Exercise1-01.vue
    <template>
    </template>
    <script>
    export default {
    }
    </script>
    <style>
    </style>
    
  6. 在我们的 Exercise1-01.vue 组件中,编写一组 <div> 标签,并在 <template> 标签内包含一个 <h1> 元素和标题:

    <template>
      <div>
        <h1>My first component!</h1>
      </div>
    </template>
    
  7. <style> 块内添加以下样式:

    <template>
      <div>
        <h1>My first component!</h1>
      </div>
    </template>
    <style>
      h1 {
        font-family: 'Avenir', Helvetica, Arial, sans-serif;
        text-align: center;
        color: #2c3e50;
        margin-top: 60px;
      }
    </style>
    
  8. 使用 ES6 import 方法将我们的组件导入到 App.vue 中,并在 <script> 块中的 components 对象内定义组件。现在我们可以通过使用其名称在 camelCasekebab-case(两者都有效)来在 HTML 中引用此组件:

    <template>
      <Exercise />
    </template>
    <script>
    import Exercise from './components/Exercise1-01'
    export default {
      components: {
        Exercise,
      }
    }
    </script>
    

    当您点击 保存 时,https://localhost:8080 应该重新加载并显示以下输出:

    图 1.6:Exercise 1.01 的本地主机输出

图 1.6:Exercise 1.01 的本地主机输出

在这个练习中,我们看到了如何使用模板标签构建 Vue 组件,使用 Vetur 搭建基本 Vue 组件,输出 HTML,以及使用 ES6 语法将 Exercise1-01 组件导入到 App.vue 中。

注意

<template> 标签内只能有一个根 HTML 元素。复杂组件应该被您选择的包含 HTML 标签包裹。<div><article><section> 都是语义化的 HTML 组件包装器。

使用内联插值的数据绑定语法

内联插值是将不同性质的东西插入到其他东西中的过程。在 Vue.js 的上下文中,这就是你将使用mustache语法(双花括号)来定义可以注入数据到组件 HTML 模板中的区域的地方。

考虑以下示例:

new Vue({
  data() {
    title: 'Vue.js'
  },
  template: '<span>Framework: {{ title }}</span>'
})

数据属性title绑定到 Vue.js 响应式数据,并且会根据 UI 及其数据的状态变化实时更新。我们将在下一个练习中更深入地探讨如何使用内联插值以及如何将其绑定到数据属性。

练习 1.02:使用条件语句的内联插值

当你想将数据输出到模板中或使页面上的元素具有响应性时,可以通过使用花括号将数据内联到模板中。Vue 可以理解并替换这些占位符为数据。

要访问此练习的代码文件,请参阅packt.live/3feLsJ3

  1. 打开命令行终端,导航到Exercise 1.02文件夹,并按顺序运行以下命令:

    > cd Exercise1.02/
    > code .
    > yarn
    > yarn serve
    

    访问https://localhost:8080

  2. Exercise1-02.vue组件内部,让我们通过添加一个名为data()的函数并在其中返回一个名为title的键,其值为你的标题字符串,来在<script>标签内添加数据:

    <script>
    export default {
      data() {
        return {
          title: 'My first component!',
        }
      },
    }
    </script>
    
  3. 通过将<h1>文本替换为内联值{{ title }}来引用数据title

    <template>
      <div>
        <h1>{{ title }}</h1>
      </div>
    </template>
    

    当你保存此文档时,数据标题现在将出现在你的h1标签内。

  4. 在 Vue 中,内联将解析花括号内的任何 JavaScript。例如,你可以使用toUpperCase()方法在花括号内转换你的文本:

    <template>
      <div>
        <h1>{{ title.toUpperCase() }}</h1>
      </div>
    </template>
    

    你应该看到以下截图所示的输出:

    ![图 1.7:保存文件——你现在应该有一个大写标题]

    ![图片 B15218_01_07.jpg]

    图 1.7:保存文件——你现在应该有一个大写标题

  5. 除了解析 JavaScript 方法外,内联也可以处理条件逻辑。在数据对象内部,添加一个布尔键值对isUppercase: false

    <template>
      <div>
        <h1>{{ isUppercase ? title.toUpperCase() : title }}</h1>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          title: 'My first component!',
          isUppercase: false,
        }
      },
    }
    </script>
    

    上述代码将生成以下输出:

    ![图 1.8:包含内联条件语句的练习 1.02 输出]

    ![图片 B15218_01_08.jpg]

    图 1.8:包含内联条件语句的练习 1.02 输出

  6. 将此条件添加到花括号中,当你保存时,你应该看到非大写标题。通过将isUppercase更改为true来玩转这个值:

    <script>
    export default {
      data() {
        return {
          title: 'My first component!',
          isUppercase: true,
        }
      },
    }
    </script>
    

    以下截图显示了运行前面代码后生成的最终输出:

    ![图 1.9:最终练习 1.02 输出]

    ![图片 B15218_01_09.jpg]

图 1.9:最终练习 1.02 输出

在这个练习中,我们能够通过使用布尔变量在内联标签(花括号)内使用内联条件来使用内联条件。这允许我们在不过度复杂的条件下修改组件内部显示的数据,这在某些用例中可能很有用。

我们现在将学习如何使用各种方法来设置组件样式。

组件样式

当使用 Vue 组件时,webpack 编译器允许你使用你喜欢的几乎所有前端模板语言风格。例如,有几种方法可以组合 CSS,无论是直接还是通过预处理。在 Vue 模板中启用这些表达性语言的最简单方法是在使用 Vue CLI 设置项目之前安装它们。

当你在 Vue 组件中使用 style 标签时,如果你已安装适当的 webpack 加载器,你可以指定一个语言。在 Exercise 1.01 中,如果你选择安装 SCSS 预处理器,你可以在 style 标签中添加 lang="scss" 属性以开始使用 SCSS。

例如,如果你选择安装 Stylus 预处理器,你可以在 style 标签中添加 lang="stylus" 属性以开始使用 Stylus:

<style lang="stylus">
ul 
  color: #2c3e50;
  > h2 
  color: #22cc33;
</style>

Vue 作用域是一个方便的方法,可以阻止单个组件从虚拟 DOM 头继承样式。将 scoped 属性添加到你的 style 标签中,并编写一些特定于组件的样式,这些样式将覆盖全局样式表中的任何其他 CSS 规则。一般规则是不作用域全局样式。定义全局样式的常见方法是将这些样式分离到另一个样式表中,并将其导入到你的 App.vue 中。

练习 1.03:将 SCSS 导入到作用域组件中

在此练习中,我们将利用 style 标签将 SCSS 预处理样式添加到组件中,并导入外部样式表。

要访问此练习的代码文件,请参阅 packt.live/3nBBZyl

  1. 打开命令行终端,导航到 Exercise1.03 文件夹,并按顺序运行以下命令:

    > cd Exercise1.03/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. 在练习文件内部,让我们编写一些可以使用 SCSS 样式的 HTML。让我们继续练习插值方法:

    // src/components/Exercise1-03.vue
    <template>
      <div>
        <h1>{{ title }}</h1>
        <h2>{{ subtitle }}</h2>
        <ul>
          <li>{{ items[0] }}</li>
          <li>{{ items[1] }}</li>
          <li>{{ items[2] }}</li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          title: 'My list component!',
          subtitle: 'Vue JS basics',
          items: ['Item 1', 'Item 2', 'Item 3']
        }
      },
    }
    </script>
    
  3. lang 属性添加到 style 标签中,并将值设置为 scss 以在 style 块内启用 SCSS 语法:

    <style lang="scss"></style>
    
  4. src/ 目录下创建一个名为 styles 的文件夹。在这个新文件夹中创建一个名为 typography.scss 的文件:

    src/styles/typography.scss
    
  5. typography.scss 内部,为你在组件中编写的模板添加一些样式:

    /* typography.scss */
    $color-green: #4fc08d;
    $color-grey: #2c3e50;
    $color-blue: #003366;
    h1 {
      margin-top: 60px;
      text-align: center;
      color: $color-grey;
      + h2 {
        text-align: center;
        color: $color-green;
      }
    }
    ul {
      display: block;
      margin: 0 auto;
      max-width: 400px;
      padding: 30px;
      border: 1px solid rgba(0,0,0,0.25);
      > li {
        color: $color-grey;
        margin-bottom: 4px;
      }
    }
    h1 + h2 {
       /* Add styling */
    }
    ul > li {
       /* Add styling */
    }
    

    在 SCSS 中,相同的代码可以表示如下:

    h1 {
       + h2 {
          // Add styling
       }
    }
    ul {
       > li {
          // Add styling
       }
    }
    
  6. 在你的组件中,通过使用 SCSS 的 @import 方法导入这些样式:

    <style lang="scss">
    @import '../styles/typography';
    </style>
    

    这将生成以下输出:

    图 1.10:当你保存并重新加载时,你的项目应该已经导入了样式

    图 1.10:当你保存并重新加载时,你的项目应该已经导入了样式

  7. scoped 属性添加到你的 <style> 标签中,以便只将这些样式应用于此组件实例。使用从导入的样式表中导入的变量 $color-blue

    <style lang="scss" scoped>
    @import '../styles/typography';
    h1 {
      font-size: 50px;
      color: $color-blue; // Use variables from imported stylesheets
    }
    </style>
    

    上述代码的输出如下:

    图 1.11:作用域样式的结果

    图 1.11:作用域样式的结果

    检查 DOM,您将注意到在运行时,作用域已将 v-data-* 属性应用于您的 DOM 元素,指定了这些特定规则。我们针对组件作用域的 typography.scss 引用了一个不在组件作用域内的 HTML 标签。当 Vue 向作用域组件添加数据属性时,如果 <body> 标签存在于组件中,它将生成样式。在我们的例子中,它不存在。

    在浏览器开发者工具的 Elements 选项卡中展开 <head><style> 标签后,将显示以下内容:

    图 1.12:观察虚拟 DOM 如何使用数据属性来分配作用域样式

    图 1.12:观察虚拟 DOM 如何使用数据属性来分配作用域样式

  8. styles 文件夹中创建一个新的样式表 global.scss

    /* /src/styles/global.scss */
    body {
        font-family: 'Avenir', Helvetica, Arial, sans-serif;
        margin: 0;
    }
    
  9. 将此样式表导入到您的 App.vue 中:

    <style lang="scss">
    @import './styles/global';
    </style>
    

    现在,我们的应用应该恢复正常,具有全局定义的样式和此组件的正确作用域样式,如下所示:

    图 1.13:练习 1.03 的正确作用域样式

图 1.13:练习 1.03 的正确作用域样式

在这个练习中,我们插值了来自数组的原始数据,然后使用作用域 SCSS 的形式来样式化我们的组件,这些样式可以存在于 <style> 标签内部,或者从我们的项目中的另一个目录导入。

CSS 模块

在响应式框架领域最近流行的一种模式是 CSS 模块。前端开发一直必须面对 CSS 类名冲突、结构不良的 BEM 代码和混乱的 CSS 文件结构等问题。Vue 组件通过模块化并允许您组合 CSS 来帮助解决这个问题,在编译时,将为特定组件生成唯一的类名。您甚至可以在组件之间使用完全相同的类名;然而,它们将通过附加在末尾的随机生成的字符串来唯一标识。

要在 Vue 中启用此功能,您需要将模块属性添加到 style 块中,并使用 JavaScript 语法引用类:

<template>
    <div :class="$style.container">CSS modules</div>
</template>
<style lang="scss" module>
.container {
  Width: 100px;
    Margin: 0 auto;
    background: green;
}
</style>

在前面的示例中,如果您检查 DOM 树,该类将被命名为类似 .container_ABC123 的名称。如果您创建多个具有语义类名如 .container 的组件,但使用 CSS 模块,您将永远不会再次遇到样式冲突。

练习 1.04:使用 CSS 模块样式化 Vue 组件

在这个练习中,您将利用 CSS 模块来样式化一个 .vue 组件。通过在 :class 绑定中使用 $style 语法,您引用了 Vue 实例的 this.$style 作用域。Vue 将根据运行或构建时的组件生成随机类名,确保样式不会与您的项目中的任何其他类冲突。

要访问此练习的代码文件,请参阅 packt.live/36PPYdd

  1. 打开命令行终端,导航到 Exercise1.04 文件夹,并按顺序运行以下命令:

    > cd Exercise1.04/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. Exercise1-04.vue 内部,编写以下代码:

    <template>
      <div>
        <h1>{{ title }}</h1>
        <h2>{{ subtitle }}</h2>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          title: 'CSS module component!',
          subtitle: 'The fourth exercise',
        }
      },
    }
    </script>
    
  3. 添加带有 SCSS 语言的 <style> 块,并将 module 作为属性而不是 scoped

    <style lang="scss" module>
    h1,
    h2 {
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
      text-align: center;
    }
    .title {
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
      color: #2c3e50;
      margin-top: 60px;
    }
    .subtitle {
      color: #4fc08d;
      font-style: italic;
    }
    </style>
    
  4. 要在模板中使用 CSS 模块,您需要通过使用 :class 语法将它们绑定到您的 HTML 元素上,这与 v-bind:class 指令相同:

    <h1 :class="$style.title">{{ title }}</h1>
    <h2 :class="$style.subtitle">{{ subtitle }}</h2>
    

    保存时,您的项目应该看起来像这样:

    图 1.14:使用 CSS 模块实现的练习 1.04 输出

图 1.14:使用 CSS 模块实现的练习 1.04 输出

如果检查虚拟 DOM,您将看到它如何为绑定元素应用了唯一的类名:

图 1.15:虚拟 DOM 树生成的 CSS 模块类

图 1.15:虚拟 DOM 树生成的 CSS 模块类

在这个练习中,我们看到了如何在 Vue 组件中使用 CSS 模块以及它是如何与 CSS 作用域不同的。在下一个练习中,我们将学习如何用 PUG (HAML) 编写模板。

注意

结合文件拆分和导入 SCSS,CSS 模块是这里作用域组件样式的首选方法。这安全地确保了单个组件样式和业务规则不会相互覆盖,并且不会因组件特定的样式要求而污染全局样式和变量。可读性很重要。类名也暗示了组件名称,而不是 v-data 属性,这在调试大型项目时可能是有益的。

练习 1.05:在 PUG (HAML) 中编写组件模板

启用正确的加载器后,您可以使用 HTML 抽象,如 PUG 和 HAML,来模板化您的 Vue 组件,而不是编写 HTML。

要访问此练习的代码文件,请参阅 packt.live/2IOrHvN

  1. 打开命令行终端,导航到 Exercise1.05 文件夹,并按顺序运行以下命令:

    > cd Exercise1.05/
    > code .
    > yarn
    

    前往 https://localhost:8080

  2. 如果 Vue 在命令行中运行,请按 Ctrl + C 停止实例。然后运行以下命令:

    vue add pug
    yarn serve
    
  3. Exercise1-05.vue 内部,编写以下代码,并在 <template> 标签上指定 lang 属性 pug

    <template lang="pug">
      div
        h1(class='title') {{ title }}
    </template>
    <script>
    export default {
      data() {
        return {
          title: 'PUG component!',
        }
      },
    }
    </script>
    <style lang="scss">
    .title {
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
      text-align: center;
      color: #2c3e50;
      margin-top: 60px;
    }
    </style>
    

    上述代码将生成以下输出:

    图 1.16:PUG 练习的输出

图 1.16:PUG 练习的输出

在这个练习中,我们看到了如何使用其他 HTML 语言进行模板化,并在 PUG 格式中插值数据。在安装 Vue.js PUG 插件后,您可以在这些模板标签内使用 PUG 编写组件语法,通过添加具有值 puglang 属性。

Vue 指令

Vue 的模板语言允许您将 HTML 代码与 JavaScript 表达式和 Vue 指令进行插值。这种模板模式通常被称为语法糖,因为它不会改变代码本身的工作方式,只是改变了您使用它的方式。语法糖允许您在 HTML 中清晰地定义特定于模板的逻辑,而无需在项目的其他地方抽象此逻辑或直接从 JavaScript 代码中返回大量的 HTML。所有基于 Vue 的指令都以 v-* 前缀开头,这表明它是一个 Vue 特定的属性:

  • v-text: v-text 指令具有与反应性插值相同的特性,除了您在指令内部引用相同的数据片段。插值(花括号){{ }} 比指令 v-text 更高效;然而,您可能会发现自己处于这样的情况,即您从服务器预先渲染了文本,并希望在 Vue 应用程序加载后覆盖它。例如,您可以在 DOM 等待 datav-text 属性最终替换它时预先定义静态占位文本。

  • v-once: 作为指令,这是独一无二的,因为它可以与其他指令配对以增强其功能。通过 v-text 或插入花括号将数据传递到添加了此属性的 HTML 元素中,将阻止 Vue 实例重新加载元素以新的数据,从而移除元素的反应性。这在您想要使用数据渲染装饰性元素,但又不希望它们在初始渲染后数据更改时更新时非常有用。

  • v-html: 这个指令将在绑定到其上的 HTML 元素内的数据字符串中渲染有效的 HTML。与其他指令相比,这个指令的操作更重,因此当其他选项不可用时应限制使用。

    注意

    <script> 标签可以在这个指令中运行。仅渲染来自安全或可信来源的内容。

  • v-bind: 这个指令是 Vue 中使用最广泛的指令之一。在 Vue 的整个框架中,您将通过这个指令的 :attr 快捷方式将响应式数据绑定到 HTML 属性,并使用它将数据传递给 props,而不是使用 v-bind:attr

  • v-if: 为了控制模板中 HTML 元素的显示状态,您通常会使用 v-if 完全从 DOM 树中移除元素。到目前为止,您已经看到了如何插入条件,例如 {{ isTrue ? 'Show this': 'Not this' }}。使用 v-if 指令,您可以控制整个 HTML 语法块。v-else-if 可以像 else if 语句一样使用,并以 v-else 结尾,它是传统 JavaScript 中 else { ... } 语句的 catch { ... } 声明的等价物。

  • v-show:您可以通过使用 v-show 来控制 HTML 元素的可见状态,它不会从 DOM 树中删除元素,而是应用 display: none 样式。v-ifv-show 之间的区别在于 v-show 将作为块元素保留在 DOM 树中,但将通过 css 隐藏而不是从 DOM 树中删除。您也不能将 v-showv-elsev-else-if 连接。

  • v-for:将此指令应用于您想要重复或迭代的元素。此指令需要一个额外的属性 :key,以便它可以正确地响应式渲染;它可以是简单的唯一数字。

    考虑一个例子,其中我们迭代列表元素五次。每个列表项将渲染其计数(1,2… 5):

    <ul><!-- do not apply v-for to this <ul> element -->
        <li v-for="n in 5" :key="n">{{ n }}</li>
    </ul>
    

现在,让我们看看一些基本指令是如何工作的。

练习 1.06:基本指令(v-text、v-once、v-html、v-bind、v-if、v-show)

更复杂的组件将使用多个指令来实现所需的输出。在这个练习中,我们将构建一个组件,该组件使用多个指令来绑定、操作并将数据输出到模板视图。

要访问此练习的代码文件,请参阅 packt.live/3fdCNqa

  1. 打开命令行终端,导航到 Exercise1.06 文件夹,并按顺序运行以下命令:

    > cd Exercise1.06/
    > code .
    > yarn
    > yarn serve
    

    访问 https://localhost:8080

  2. Exercise1-06.vue 内部编写以下语法。这使用了我们在之前的练习中使用的插值方法,并且到这一点上应该非常熟悉:

    <template>
      <div>
        <h1>{{ text }}</h1>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // v-text
          text: 'Directive text',
        }
      },
    }
    </script>
    <style lang="scss" scoped>
    h2 {
      margin: 40px 0 0;
      font-weight: normal;
    }
    </style>
    
  3. 将插值替换为 v-text 属性。您将注意到输出不会改变:

    <template>
      <div>
        <h1 v-text="text">Loading...</h1>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // v-text
          text: 'Directive text',
        }
      },
    }
    </script>
    

    图 1.17 展示了前面代码的输出:

    ![图 1.17:v-text 指令的输出与插值方法非常相似 img/B15218_01_17.jpg

    图 1.17:v-text 指令的输出与插值方法非常相似

  4. 在同一元素上添加 v-once 指令。这将强制此 DOM 元素在页面存在期间只加载一次 v-text 数据:

    <template>
      <div>
        <h1 v-once v-text="text">Loading...</h1>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // v-text
          text: 'Directive text',
        }
      },
    }
    </script>
    ...
    
  5. h1 元素下方,包含一个新的 h2 元素,该元素使用 v-html 属性。添加一个新的数据键 html,其中包含如下格式的字符串:

    <template>
      <div>
        <h1 v-once v-text="text">Loading...</h1>
        <h2 v-html="html" />
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // v-text
          text: 'Directive text',
          // v-html
          html: 'Stylise</br>HTML in<br/><b>your data</b>',
        }
      },
    }
    </script>
    ...
    

    运行前面的代码将生成如下输出:

    ![图 1.18:渲染 HTML 元素后的输出 img/B15218_01_18.jpg

    图 1.18:渲染 HTML 元素后的输出

  6. data 对象中添加一个新的 link 对象,其中包含诸如 URL、目标、标题和标签索引等信息。在模板内部,添加一个新的锚点 HTML 元素,并使用冒号语法将 link data 对象绑定到 HTML 元素上,例如,:href="link.url"

    <template>
      <div>
        <h1 v-once v-text="text">Loading...</h1>
        <h2 v-html="html" />
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // v-text
          text: 'Directive text',
          // v-html
          html: 'Stylise</br>HTML in<br/><b>your data</b>',
        }
      },
    }
    </script>
    ...
    

    以下截图显示了输出:

    图 1.19:将 Vue 实例的响应式数据绑定到    将 Vue 实例绑定到任何 HTML 属性

    图 1.19:将 Vue 实例的响应式数据绑定到任何 HTML 属性的输出

  7. v-if="false" 应用到 h1 元素上,将 v-else-if="false" 应用到 h2 上,将 v-else 应用到 a 标签上,如下所示:

    <template>
      <div>
        <h1 v-if="false" v-once v-text="text">Loading...</h1>
        <h2 v-else-if="false" v-html="html" />
        <a
          v-else
          :href="link.url"
          :target="link.target"
          :tabindex="link.tabindex"
          v-text="link.title"
        />
      </div>
    </template>
    

    你应该只能看到页面中的 <a> 标签,因为我们已经将条件语句设置为 false

    v-else 条件将显示如下:

    ![图 1.20:v-if 语句将整个 HTML 元素从 DOM 中隐藏]

    ![图片 B15218_01_20.jpg]

    图 1.20:v-if 语句将整个 HTML 元素从 DOM 中隐藏

  8. 将模板更改为使用 v-show 而不是 v-if 语句:

    <template>
      <div>
        <h1 v-show="true" v-once v-text="text">Loading...</h1>
        <h2 v-show="false" v-html="html" />
        <a
          :href="link.url"
          :target="link.target"
          :tabindex="link.tabindex"
          v-text="link.title"
        />
      </div>
    </template>
    

    上述代码的输出将如下所示:

    ![图 1.21:将 v-show 设置为 true 将显示元素]

    ![图片 B15218_01_21.jpg]

图 1.21:将 v-show 设置为 true 将显示元素

当你打开浏览器开发者工具的 Elements 选项卡时,你应该能够观察到 h2 的显示状态设置为 none,如下所示:

![图 1.22:h2 显示 "display: none" 的 false 条件]

![图片 B15218_01_22.jpg]

图 1.22:在 false 条件下,h2 显示 "display: none"

如果 v-show 结果为 true 布尔值,它将保持 DOM 元素不变。如果解析为 false,它将应用 display: none 样式到该元素上。

在这个练习中,我们学习了 Vue 的核心指令,用于控制、绑定、显示和隐藏 HTML 模板元素,而无需在本地状态中添加任何新的数据对象之外的 JavaScript。

在下一节中,我们将学习如何在 Vue 的 v-model 的帮助下实现双向绑定。

使用 v-model 实现双向绑定

Vue 通过创建一个专门监视 Vue 组件内部数据属性的指令,简化了实现双向数据绑定的方式。当 Vue 监视的绑定数据属性发生变化时,Vue 指令 v-model 会做出响应性变化。这个指令通常对需要同时显示数据和修改数据的 HTML 表单元素很有用,例如输入、文本区域、单选按钮等。

通过向要绑定的元素添加 v-model 指令并引用数据属性来实现双向绑定:

<template>
    <input v-model="name" />
</template>
<script>
      export default {
        data() {
          return {
            name: ''
          }
        }
      }
</script>

图 1.23 代表运行上述代码生成的输出:

![图 1.23:v-model 示例的输出]

![图片 B15218_01_23.jpg]

图 1.23:v-model 示例的输出

使用此指令时要小心,因为以这种方式绑定大量数据可能会影响应用程序的性能。考虑你的 UI 并将其拆分为不同的 Vue 组件或视图。Vue 中的本地状态数据不是不可变的,可以在模板的任何地方重新定义。

练习 1.07:使用 v-model 实现双向绑定

我们将使用 Vue 的双向数据绑定属性 v-model 来构建一个组件。考虑一下双向绑定数据片段的含义。这种数据模型的应用场景通常是表单,或者你期望既有输入又有输出数据的地方。到练习结束时,我们应该能够在一个表单上下文中使用 v-model 属性。

要访问此练习的代码文件,请参阅packt.live/2IILld8

  1. 打开命令行终端,导航到Exercise1.07文件夹,并按顺序运行以下命令:

    > cd Exercise1.07/
    > code .
    > yarn
    > yarn serve
    

    访问https://localhost:8080

  2. 首先,在模板区域使用v-model组合一个 HTML 标签和输入元素,并将其绑定到name数据属性:

    <div class="form">
       <label>
         Name
         <input type="text" v-model="name" />
       </label>
    </div>
    
  3. 通过在<script>标签中返回一个名为name的响应式数据属性来完成文本输入的绑定:

    <script>
    export default {
      data() {
        return {
          name: '',
        }
      },
    }
    </script>
    
  4. 使用模板区域内的v-model组合一个标签和可选择的 HTML 列表,并将其绑定到数据属性language

        <div class="form">
          <label>
            Name
            <input type="text" v-model="name" />
          </label>
          <label>
            Preferred javascript style
            <select name="language" v-model="language">
              <option value="Javascript">JavaScript</option>
              <option value="TypeScript">TypeScript</option>
              <option value="CoffeeScript">CoffeeScript</option>
              <option value="Dart">Dart</option>
            </select>
          </label>
        </div>
    
  5. 通过在<script>标签中返回一个名为language的响应式数据属性来完成选择输入的绑定:

    <script>
    export default {
      data() {
        return {
          name: '',
          language: '',
        }
      },
    }
    </script>
    
  6. 在表单字段下方,使用花括号(例如,{{ name }})在无序列表结构(<ul><li>)中输出名称和语言:

    <template>
      <section>
        <div class="form">
          <label>
            Name
            <input type="text" v-model="name" />
          </label>
          <label>
            Preferred javascript style
            <select name="language" v-model="language">
              <option value="Javascript">JavaScript</option>
              <option value="TypeScript">TypeScript</option>
              <option value="CoffeeScript">CoffeeScript</option>
              <option value="Dart">Dart</option>
            </select>
          </label>
        </div>
        <ul class="overview">
          <li><strong>Overview</strong></li>
          <li>Name: {{ name }}</li>
          <li>Preference: {{ language }}</li>
        </ul>
      </section>
    </template>
    
  7. 在组件底部的<style>标签内添加样式,并将lang属性设置为scss

Exercise1-07.vue
37 <style lang="scss">
38 .form {
39   display: flex;
40   justify-content: space-evenly;
41   max-width: 800px;
42   padding: 40px 20px;
43   border-radius: 10px;
44   margin: 0 auto;
45   background: #ececec;
46 }
47
48 .overview {
49   display: flex;
50   flex-direction: column;
51   justify-content: space-evenly;
52   max-width: 300px;
53   margin: 40px auto;
54   padding: 40px 20px;
55   border-radius: 10px;
56   border: 1px solid #ececec;
57
58   > li {
59     list-style: none;
60     + li {
61       margin-top: 20px;
62     }
63   }
64 }
65 </style>
The complete code for this step is available at https://packt.live/36NiNXH.

您的输出应该如下所示:

图 1.24:数据更新后的最终表单显示

图 1.24:数据更新后的最终表单显示

您的表单应该看起来像这样。当您更新表单中的数据时,它也应该同步更新概览区域。

在这个练习中,我们使用了v-model指令来绑定名称和 JavaScript 风格的下拉选择到我们本地状态的数据。当您更改数据时,它将响应式地更新我们输出此绑定数据的 DOM 元素。

匿名循环

要在 Vue 中遍历 HTML 元素,您将使用v-for循环指令。当 Vue 渲染组件时,它将迭代您添加指令的 HTML 元素,以使用解析到指令中的数据。:key。当键或绑定到键的内容发生变化时,Vue 知道它需要重新加载循环内的内容。如果您在一个组件中有多个循环,请使用额外的字符或与上下文相关的字符串随机化键,以避免:key重复冲突。

匿名循环的示例如下;请注意,您可以使用引号或反引号(`)来描述字符串:

          <div v-for="n in 2" :key="'loop-1-' + n">
    {{ n }}
</div>
<!-- Backticks -->
<div v-for="n in 5" :key="`loop-2-${n}`">
    {{ n }}
</div>

上述代码的输出应该如下所示。

图 1.25:匿名循环示例的输出

图 1.25:匿名循环示例的输出

理解循环对于不仅使用 Vue,而且一般使用 JavaScript 都是关键。现在我们已经介绍了如何使用v-for语法处理循环以及绑定:key属性以向循环内容添加响应性的重要性,我们将在下一个练习中利用这个功能。

练习 1.08:使用 v-for 遍历字符串数组

在这个练习中,我们将使用 Vue 的v-for指令执行匿名循环。这将对那些之前在 JavaScript 中使用过forforeach循环的人很熟悉。

要访问此练习的代码文件,请参阅 packt.live/390SO1J

执行以下步骤以完成练习:

  1. 打开命令行终端,导航到 Exercise1.08 文件夹,并按顺序运行以下命令:

    > cd Exercise1.08/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. 通过在组件中添加 <h1> 标题和一个 <ul> 元素(其中包含一个 <li> 标签,该标签具有 v-for 指令,其值为 n5)来在 Exercise1-08.vue 内部编写以下语法:

    Exercise1-08.vue
    1 <template>
    2   <div>
    3     <h1>Looping through arrays</h1>
    4     <ul>
    5       <li v-for="n in 5" :key="n">
    6         {{ n }}
    7       </li>
    8     </ul>
    The complete code for this step is available at https://packt.live/3pFAtgB.
    

    这将生成以下输出:

    图 1.26:遍历任意数字也将允许你输出索引

    图 1.26:遍历任意数字也将允许你输出索引

  3. 现在让我们遍历一个字符串数组,并使用 n 计算数组的迭代次数。在 data() 函数中准备一个包含你个人兴趣的数组。通过在 interests 数组中查找 (item, n),item 输出数组的字符串,而 n 是循环索引:

    <template>
      <div>
        <h1>Looping through arrays</h1>
        <ul>
          <li v-for="(item, n) in interests" :key="n">
            {{ item }}
          </li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          interests: ['TV', 'Games', 'Sports'],
        }
      },
    }
    </script>
    

    运行上述代码将生成以下输出:

    图 1.27:遍历字符串数组

图 1.27:遍历字符串数组

在这个练习中,我们学习了如何遍历任意数字和特定的字符串数组,输出数组的字符串值或索引。我们还了解到,键属性需要是唯一的,以避免 DOM 冲突并强制 DOM 正确重新渲染组件。

遍历对象

当从 API 请求数据时,你通常会遍历包含逻辑和原始内容的对象数组。Vue 通过其指令语法使控制数据的各种状态变得简单。条件指令控制 Vue 中 DOM 元素的显示状态。HTML 语法在你的组件中设置显示规则时提供了清晰的可见性。

练习 1.09:使用 v-for 循环遍历对象数组并使用它们的属性进行 v-if 条件

在这个练习中,我们将控制 Vue 数据数组,并遍历其内部的对象。

要访问此练习的代码文件,请参阅 packt.live/32YokKa

  1. 打开命令行终端,导航到 Exercise1.09 文件夹,并按顺序运行以下命令:

    > cd Exercise1.09/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. Exercise1-09.vue 内部编写以下语法,并创建一个包含 title 字符串和 favorite 字符串数组的对象。我们将像遍历字符串数组一样遍历 interests 对象;然而,你需要引用 interests 对象内的 title 键:

    <template>
      <div>
        <h1>Looping through array of objects</h1>
        <ul>
          <li v-for="(item, n) in interests" :key="n">
            {{ item.title }}
          </li>
        </ul>
      </div>
    </template>
    

    运行上述代码的输出将如下所示:

    图 1.28:现在你应该在前端看到一系列标题

    图 1.28:现在你应该在前端看到一系列标题

  3. 让我们创建第二个v-for循环来遍历你的收藏列表。注意,我们在嵌套循环中使用不同的键——favm——这是因为你仍然可以在嵌套循环的上下文中使用itemn的值:

    <template>
      <div>
        <h1>Looping through array of objects</h1>
        <ul>
          <li v-for="(item, n) in interests" :key="n">
            {{ item.title }}
            <ol>
              <li v-for="(fav, m) in item.favorite" :key="m">            {{ fav }}</li>
            </ol>
          </li>
        </ul>
      </div>
    </template>
    

    图 1.29 显示了通过对象数组执行循环的输出:

    ![图 1.29:嵌套有序列表详细说明你的收藏 图片

    图 1.29:嵌套有序列表详细说明你的收藏

  4. 为了优化 DOM 树,我们可以使用Exercise 1.09中的v-if条件指令来隐藏不必要的 DOM 元素:![图 1.30:在虚拟 DOM 中显示空 DOM 元素 图片

    图 1.30:在虚拟 DOM 中显示空 DOM 元素

  5. 我们将检查数组中是否有超过0个元素以显示有序列表 HTML 元素。向<ol>添加一个v-if指令,条件为item.favorite.length > 0

    // src/components/Exercise1-09.vue
    <template>
      <div>
        <h1>Looping through array of objects</h1>
        <ul>
          <li v-for="(item, n) in interests" :key="n">
            {{ item.title }}
            <ol v-if="item.favorite.length > 0">
              <li v-for="(fav, m) in item.favorite" :key="m">            {{ fav }}</li>
            </ol>
          </li>
        </ul>
      </div>
    </template>
    

    这不会影响你页面的视觉效果,但当你检查浏览器中的虚拟 DOM 树时,你会注意到在开发模式下有一个 HTML 注释,这允许你理解v-if语句可能为false的位置。当你为生产构建时,这些 HTML 注释不会出现在你的 DOM 中。

    ![图 1.31:显示生产构建中没有 HTML 注释的输出 图片

图 1.31:显示生产构建中没有 HTML 注释的输出

通过在开发模式下使用v-if指令,你会看到一个 HTML 注释。这些在产品构建中不会存在。

在这个练习中,我们能够遍历复杂对象的数组,输出这些对象的嵌套键,并根据长度条件控制 DOM 元素的状态。

Vue 中的方法

Vue 方法是在 Vue 实例内的methods对象中定义的,可以像正常的 JavaScript 函数一样编写,其中定义了一段要执行的逻辑。当你使用 JavaScript 函数时,通常你会返回一个值或者简单地执行一个全局操作。编写函数和 Vue 方法的主要区别在于 Vue 方法的作用域限定在你的 Vue 组件内,并且可以在组件内部任何位置运行。由于方法的作用域限定在组件的 Vue 实例内,你可以在 HTML 模板中轻松地通过事件指令引用它们。在 Vue 中绑定 HTML 元素的事件时,你会使用@符号;例如,v-on:click等同于@click

练习 1.10:触发方法

在这个练习中,我们将构建一个使用 Vue 方法 API 的组件。考虑这些 Vue 方法可以像你自己的命名函数一样编写,因为它们的行为非常相似。通过练习的结束,我们应该能够使用方法并在 HTML 模板中触发它们。

要访问此练习的代码文件,请参阅packt.live/3kMTWs5

  1. 打开命令行终端,导航到 Exercise1.10 文件夹,并按顺序运行以下命令:

    > cd Exercise1.10/
    > code .
    > yarn
    > yarn serve
    

    访问 https://localhost:8080

  2. 让我们遍历一个方法触发器,并将它的数字传递给一个方法。在 HTML 列表上设置一个匿名 v-for 循环,并在列表元素内部添加一个锚元素。将循环设置为迭代 5 次:

    <template>
      <div>
        <h1>Triggering Vue Methods</h1>
        <ul>
          <li v-for="n in 5" :key="n">
            <a href="#">Trigger</a>
          </li>
        </ul>
      </div>
    </template>
    
  3. 添加一个名为 triggerAlert 的方法,并使用 @click 指令引用它,将 n 的值作为参数传递。使用花括号将值 n 输出到锚元素中:

    <template>
      <div>
        <h1>Triggering Vue Methods</h1>
        <ul>
          <li v-for="n in 5" :key="n">
            <a href="#" @click="triggerAlert(n)">Trigger {{ n }}</a>
          </li>
        </ul>
      </div>
    </template>
    
  4. methods 对象内部,添加一个带有 n 参数的 triggerAlert(n) 键。在这个方法内部,添加一个 alert 函数,它将输出值 n 加上一些静态文本:

    <script>
    export default {
      methods: {
        triggerAlert(n) {
          alert(`${n} has been clicked`)
        },
      },
    }
    </script>
    
  5. 在组件底部的 <style> 标签内添加样式,并将 lang 属性设置为 scss

    Exercise1-10.vue
    22 <style lang="scss" scoped>
    23 ul {
    24   padding-left: 0;
    25 }
    26 li {
    27   display: block;
    28   list-style: none;
    29 
    30   + li {
    31     margin-top: 10px;
    32   }
    33 }
    34 
    35 a {
    36   display: inline-block;
    37   background: #4fc08d;
    38   border-radius: 10px;
    39   color: white;
    40   padding: 10px 20px;
    41   text-decoration: none;
    42 }
    43 </style>
    The complete code for this step is available at https://packt.live/374yKZZ.
    
  6. 您的页面应该包含一个按钮列表,当点击按钮时,会触发一个包含您点击的按钮编号的消息的警告,如下所示:![图 1.32:输出触发器列表]

    ![图片 B15218_01_32.jpg]

![图 1.32:输出触发器列表]

当点击触发器时,会显示以下提示:

![图 1.33:显示包含索引数字的浏览器警告]

![图片 B15218_01_33.jpg]

![图 1.33:显示包含索引数字的浏览器警告]

注意

虽然您可以将事件指令添加到任何 HTML 元素中,但建议将它们应用于原生 HTML 交互元素,如锚标签、表单输入或按钮,以帮助提高浏览器可访问性。

在这个练习中,我们能够利用 Vue 方法 API 定义和从 HTML 模板触发方法,并将参数动态地解析到每个方法中。

练习 1.11:使用 Vue 方法返回数据

在这个练习中,我们将学习如何将 Vue 方法作为一个函数在 Vue 实例和模板内部返回数据。

在 web 应用程序中,我们通常希望元素根据条件是否满足而出现在页面上。例如,如果我们的产品缺货,我们的页面应该显示缺货的事实。

因此,让我们弄清楚我们如何根据我们的产品是否有库存来有条件地渲染这些元素。

要访问此练习的代码文件,请参阅 packt.live/3pHWCeh

  1. 打开命令行终端,导航到 Exercise1.11 文件夹,并按顺序运行以下命令:

    > cd Exercise1.11/
    > code .
    > yarn
    > yarn serve
    

    访问 https://localhost:8080

  2. 让我们遍历一个随机金额并触发addToCart方法。设置两个数据对象totalItemstotalCost,当用户点击我们的购物按钮时,这些对象将被更新。接下来,通过指定this在 Vue 的script块中引用数据对象。例如,在template块中,我们将totalItems引用为{{ totalItems }},但在script块中,我们将它引用为this.totalItems。对于方法,使用相同的模式,其中addToCart将在另一个方法中引用为this.addToCart

    <template>
      <div>
        <h1>Returning Methods</h1>
        <div>Cart({{ totalItems }}) {{ totalCost }} </div>
        <ul>
          <li v-for="n in 5" :key="n">
            <a href="#" @click="addToCart(n)">Add {{ n }}</a>
          </li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          totalItems: 0,
          totalCost: 0,
        }
      },
      methods: {
        addToCart(n) {
          this.totalItems = this.totalItems + 1
          this.totalCost = this.totalCost + n
        },
      },
    }
    </script>
    <style lang="scss" scoped>
    ul {
      padding-left: 0;
    }
    li {
      display: block;
      list-style: none;
      + li {
        margin-top: 10px;
      }
    }
    a {
      display: inline-block;
      background: rgb(235, 50, 50);
      border-radius: 10px;
      color: white;
      padding: 10px 20px;
      text-decoration: none;
    }
    </style>
    

    这将生成以下输出:

    图 1.34:按下任意按钮将演示购物车逻辑

    图 1.34:按下任意按钮将演示购物车逻辑

    当你点击按钮时,项目计数器应该增加1,但成本将增加n值,这应该展示正常的购物车功能(点击加 2,然后加 5):

    图 1.35:显示递增后的返回方法的输出

    图 1.35:显示递增后的返回方法的输出

  3. 让我们谈谈金钱。我们可以使用方法来执行逻辑操作,根据事件增强或格式化字符串。创建一个名为formatCurrency的方法,它接受一个参数。我们将返回相同的值,在它后面加上两位小数和一个$符号。要在模板中使用此方法,只需将其添加到插值大括号中,并将方法内的值作为参数传递:

    <template>
      <div>
        <h1>Returning Methods</h1>
        <div>Cart({{ totalItems }}) {{ formatCurrency(totalCost) }}      </div>
        <ul>
          <li v-for="n in 5" :key="n">
            <a href="#" @click="addToCart(n)">Add           {{ formatCurrency(n) }}</a>
          </li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          totalItems: 0,
          totalCost: 0,
        }
      },
      methods: {
        addToCart(n) {
          this.totalItems = this.totalItems + 1
          this.totalCost = this.totalCost + n
        },
        formatCurrency(val) {
          return `$${val.toFixed(2)}`
        },
      },
    }
    </script>
    

    以下截图显示了前面代码的输出:

    图 1.36:现在所有值都应看起来像货币,    同时保留购物车计数器

图 1.36:现在所有值都应看起来像货币,同时保留购物车计数器

在这个练习中,我们能够利用 Vue 的方法 API 将参数解析为方法,返回修改后的值,并在一个逼真的场景中使用方法来更新本地数据状态。

Vue 生命周期钩子

Vue 组件生命周期事件包括以下内容:

  • beforeCreate: 当你的组件被初始化时运行。data尚未变为响应式,DOM 中的事件也没有设置。

  • created: 你将能够访问响应式数据和事件,但模板和 DOM 尚未挂载或渲染。这个钩子通常在从服务器请求异步数据时很好用,因为你很可能希望在虚拟 DOM 挂载之前尽可能早地获取这些信息。

  • beforeMount: 这是一个非常不常见的钩子,因为它直接在组件首次渲染之前运行,并且在服务器端渲染中不会被调用。

  • mounted: 挂载钩子是你将最常使用的钩子之一,因为它们允许你访问你的 DOM 元素,以便集成非 Vue 库。

  • beforeUpdate:在组件发生变化后立即运行,在它被重新渲染之前。在渲染之前获取响应式数据的状态很有用。

  • updated:在 beforeUpdate 钩子之后立即运行,并使用新的数据更改重新渲染你的组件。

  • beforeDestroy:在销毁组件实例之前直接触发。组件在 destroyed 钩子被调用之前仍然可以正常工作,这允许你停止事件监听器和数据订阅以避免内存泄漏。

  • destroyed:所有虚拟 DOM 元素和事件监听器都已从你的 Vue 实例中清理。此钩子允许你向任何需要知道此操作已完成的人或元素传达这一信息。

练习 1.12:使用 Vue 生命周期控制数据

在这个练习中,我们将学习如何以及何时使用 Vue 的生命周期钩子,以及它们通过 JavaScript alerts 触发的情况。到练习结束时,我们将能够理解和使用多个 Vue 生命周期钩子。

要访问此练习的代码文件,请参阅 packt.live/36N42nT

  1. 打开命令行终端,导航到 Exercise1.12 文件夹,并按顺序运行以下命令:

    > cd Exercise1.12/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

    注意

    随意将 alert 替换为 console.log()

  2. 首先,创建一个数组来在列表元素中迭代,将键设置为 n,并在 <li> 元素内部使用花括号输出值 {{item}}

    <template>
      <div>
        <h1>Vue Lifecycle hooks</h1>
        <ul>
         <li v-for="(item, n) in list" :key="n">
            {{ item }} 
         </li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          list: [
            'Apex Legends',
            'A Plague Tale: Innocence',
            'ART SQOOL',
            'Baba Is You',
            'Devil May Cry 5',
            'The Division 2',
            'Hypnospace Outlaw',
            'Katana ZERO',
          ],
        }
      }
    }
    </script>
    
  3. 在 data() 函数下方添加 beforeCreated()created() 作为函数。在这些钩子内部设置一个 alert 或 console log,以便你可以看到它们何时被触发:

    <script>
    export default {
       ...
      beforeCreate() {
        alert('beforeCreate: data is static, thats it')
      },
      created() {
        alert('created: data and events ready, but no DOM')
      },
    }
    </script>
    

    当你刷新浏览器时,你应该在看到你的列表在页面上加载之前看到这两个 alert:

    ![图 1.37:首先观察 beforeCreate() 钩子 alert]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_01_37.jpg)

    图 1.37:首先观察 beforeCreate() 钩子 alert

    以下截图显示了在 beforeCreate() 钩子之后的 created() 钩子 alert:

    ![图 1.38:在 beforeCreate() 钩子之后观察 before() 钩子 alert]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_01_38.jpg)

    图 1.38:在 beforeCreate() 钩子之后观察 before() 钩子 alert

  4. 在 created() 钩子下方添加 beforeMount()mounted() 作为函数。在这些钩子内部设置一个 alert 或 console log,以便你可以看到它们何时被触发:

    <script>
    export default {
    ...
      beforeMount() {
        alert('beforeMount: $el not ready')
      },
      mounted() {
        alert('mounted: DOM ready to use')
      },
    }
    </script>
    

    当你刷新浏览器时,你也应该在看到你的列表在页面上加载之前看到这些 alerts:

    ![图 1.39:在 create() 钩子之后观察 beforeMount() 钩子 alert]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_01_39.jpg)

    图 1.39:在 create() 钩子之后观察 beforeMount() 钩子 alert

    以下截图显示了在 beforeMount() 钩子之后的 mounted() 钩子 alert:

    ![图 1.40:观察在 beforeMount() 钩子之后的 mounted() 钩子 alert]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_01_40.jpg)

    图 1.40:观察在 beforeMount() 钩子之后的 mounted() 钩子 alert

  5. 在您的<li>元素内部添加一个新的锚点元素,它位于项目输出旁边。使用@click指令将此按钮绑定到名为deleteItem的方法,并将item值作为参数传递:

    <template>
      <div>
        <h1>Vue Lifecycle hooks</h1>
        <ul>
          <li v-for="(item, n) in list" :key="n">
            {{ item }} <a @click="deleteItem(item)">Delete</a>
          </li>
        </ul>
      </div>
    </template>
    
  6. hooks下方添加一个名为deleteItem的方法到methods对象中,但位于data()函数下方。在这个函数中,将value作为参数传递,并从列表数组中过滤出与值不匹配的项,然后用新列表替换现有列表:

    Exercise1-12.vue
    17 <script>
    18 export default {
    19   data() {
    20     return {
    21       list: [
    22         'Apex Legends',
    23         'A Plague Tale: Innocence',
    24         'ART SQOOL',
    25         'Baba Is You',
    26         'Devil May Cry 5',
    27         'The Division 2',
    28         'Hypnospace Outlaw',
    29         'Katana ZERO',
    30       ],
    31     }
    32   },
    33   methods: {
    34     deleteItem(value) {
    35       this.list = this.list.filter(item => item !== value)
    36     },
    37   },
    The complete code for this step is available at https://packt.live/3pJGLvO.
    
  7. 在组件底部的<style>标签内添加样式,并将lang属性设置为scss

    <style lang="scss" scoped>
    ul {
      padding-left: 0;
    }
    li {
      display: block;
      list-style: none;
      + li {
        margin-top: 10px;
      }
    }
    a {
      display: inline-block;
      background: rgb(235, 50, 50);
      padding: 5px 10px;
      border-radius: 10px;
      font-size: 10px;
      color: white;
      text-transform: uppercase;
      text-decoration: none;
    }
    </style>
    
  8. mounted()钩子下方添加beforeUpdate()updated()作为函数,并在这些钩子内部设置一个警告或控制台日志,以便您可以查看它们何时被触发:

    <script>
    export default {
        ...
      beforeUpdate() {
        alert('beforeUpdate: we know an update is about to       happen, and have the data')
      },
      updated() {
        alert('updated: virtual DOM will update after you click OK')
      },
    }
    </script>
    

    当您通过在浏览器中点击删除按钮删除列表项时,您应该看到这些警告。

  9. updated()钩子下方添加beforeDestroy()destroyed()作为函数。在这些钩子内部设置一个警告或控制台日志,以便您可以查看它们何时被触发:

    <script>
    export default {
       ...
      beforeDestroy() {
        alert('beforeDestroy: about to blow up this component')
      },
      destroyed() {
        alert('destroyed: this component has been destroyed')
      },
    }
    </script>
    
  10. 向您的list数组添加一个新项:

    <script>
    export default {
      data() {
        return {
          list: [
            'Apex Legends',
            'A Plague Tale: Innocence',
            'ART SQOOL',
            'Baba Is You',
            'Devil May Cry 5',
            'The Division 2',
            'Hypnospace Outlaw',
            'Katana ZERO',        
          ],
        }
      },
    

    在您使用 localhost 运行并保存此更改后,在浏览器中显示更新警告之后,您还应该看到销毁警告。这将生成以下输出:

    ![图 1.41:显示 Vue 生命周期钩子的输出]

    ![图片 B15218_01_41.jpg]

    图 1.41:显示 Vue 生命周期钩子的输出

  11. 每个生命周期钩子都会运行警告。尝试删除元素,在列表数组中添加新元素,并刷新页面以查看每个这些钩子何时发生。这将生成以下输出:![图 1.42:在每次触发时显示消息]

    ![图片 B15218_01_42.jpg]

图 1.42:在每次触发时显示消息

每次您在页面上操作某个元素时,都会触发一个警告,演示每个可用的 Vue 生命周期。

注意

Mountedcreated生命周期钩子将在组件每次加载时运行。如果这不是您期望的效果,请考虑在父组件或视图中运行您想要运行的代码一次,例如App.vue文件。

在这个练习中,我们学习了 Vue 生命周期钩子是什么以及它们何时触发。这将与触发方法和控制 Vue 组件中的数据相结合非常有用。

活动一.01:使用 Vue.js 构建动态购物清单应用程序

在这个活动中,我们将构建一个动态购物清单应用程序,通过使用 SFC 的所有基本功能来测试您对 Vue 的了解,例如表达式、循环、双向绑定和事件处理。

此应用程序应允许用户创建和删除单个列表项,并一键清除整个列表。

以下步骤将帮助您完成活动:

  1. 使用绑定到v-model的输入在一个组件中构建一个交互式表单。

  2. 添加一个输入字段,您可以将购物清单项添加到其中。通过将方法绑定到@keyup.enter事件,允许用户使用Enter键添加项。

  3. 用户可以通过删除所有项目或逐个删除它们来清除列表。为此,你可以使用一个可以传递数组位置作为参数的 delete 方法,或者简单地覆盖整个购物清单数据属性,使其成为一个空数组 []

    预期的结果如下:

    图片

    ![图 1.43:最终输出 图片

图 1.43:最终输出

注意

本活动的解决方案可以通过这个链接找到。

摘要

在本章中,你学习了如何使用命令提示符运行 Vue 项目以及如何创建基本的 Vue 组件。在这些 Vue 组件中,你可以构建模板,使用 Vue 的独特指令和 HTML 语法糖来遍历数据或使用条件语句控制 DOM 状态。通过使用数据属性和 v-model 绑定,我们探讨了响应式数据的关键概念,并在利用 Vue.js 方法和生命周期的实际示例中使其变得有用。

在下一章中,我们将学习更多高级的响应式数据概念,这些概念将建立在第一章的基础上:使用计算属性和监听器以及从外部源获取异步数据。

第二章:2. 处理数据

概述

在本章中,你将通过介绍更多控制 Vue 组件内部数据的方法来扩展上一章所学的内容。你将学习如何设置高级监视器来观察组件内部的数据变化,并利用 Vue 强大的反应性数据特性,计算数据属性,在模板中简洁地输出所需的数据。你还将能够利用异步方法为 Vue 组件获取数据。

到本章结束时,你将能够监视、管理和操作 Vue.js 组件中的各种数据源。

简介

在上一章中,你被介绍了单文件组件的概念和 Vue API,它提供了访问方法、指令和数据属性的方式。基于这些基础,我们将介绍计算属性,它们与数据属性一样,在 UI 中是反应性的,但可以执行强大的计算,并且它们的结果是可缓存的,这提高了你项目的性能。当构建电子商务商店时,你通常希望用户与你的 UI 交互时,反应性地计算定价和购物车项目,这在过去需要在不重新加载页面的情况下使用类似 jQuery 的方法来实现。Vue.js 通过引入计算属性,这些计算属性可以立即对前端用户输入做出反应,轻松处理这些常见的前端任务。

让我们从介绍可以即时计算的反应性数据开始,并了解如何调用和操作异步数据。

计算属性

计算属性是一种独特的数据类型,当属性中使用的源数据更新时,它们会反应性地更新。它们看起来可能像 Vue 方法,但实际上不是。在 Vue 中,我们可以通过将数据属性定义为计算属性来跟踪数据属性的变化,在这个属性中添加自定义逻辑,并在组件的任何地方使用它来返回一个值。Vue 会缓存计算属性,这使得它们在返回数据方面比数据属性或使用 Vue 方法更高效。

你可能使用计算属性的场景包括但不限于:

  • total 数据属性小于 1。每当向 items 数组添加新的数据时,total 的计算属性将更新:

    <template>
        <div>{{errorMessage}}</div>
    </template>
    <script>
        export default {
            data() {
                return {
                    items: []
                }
            },
            computed: {
                total() {
                    return this.items.length
                },
                errorMessage() {
                    if (this.total < 1) {
                        return 'The total must be more than zero'
                    } else {
                        return ''
                    }
                }
            }
        }
    </script>
    

    这将生成以下输出:

    The total must be more than zero
    
  • formalName,可以在你的组件中使用:

    <template>
        <div>{{ formalName }}</div>
    </template>
    <script>
        export default {
            data() {
                return {
                    title: 'Mr.',
                    surname: 'Smith'
                }
            },
            computed: {
                formalName() {
                    return this.title + ' ' + this.surname
                }
            }
        }
    </script>
    

    这将生成以下输出:

    Mr. Smith
    
  • post。你将使用简化和语义化的计算属性将信息输出到组件模板中。本例中的计算属性使得识别和使用作者的完整姓名、查看他们发布了多少帖子以及显示他们的特色帖子变得更容易:

    <template>
        <div>
            <p>{{ fullName }}</p>
            <p>{{ totalPosts }}</p>
            <p>{{ featuredPosts }}</p>
        </div>
    </template>
    <script>
        export default {
            data() {
                return {
                    post: {
                        fields: {
                            author: {
                                firstName: 'John',
                                lastName: 'Doe'
                            },
                            entries: [{
                                    title: "Entry 1",
                                    content: "Entry 1's content",
                                    featured: true
                                },
                                {
                                    title: "Entry 2",
                                    content: "Entry 2's content",
                                    featured: false
                                }
                           ]
                        }
                    }
                }
            },
            computed: {
                fullName() {
                    // Return string
                    return this.post.fields.author.firstName + ' ' +                  this.post.fields.author.lastName
                },
                totalPosts() {
                    // Return number
                    return this.post.fields.entries.length
                },
                featuredPosts() {
                    // Return string
                    return this.post.fields.entries.filter(entry => {
                        // If featured is true, return the entry title
                        if (entry.featured) {
                            return entry
                        }
                    })
                }
            }
        }
    </script>
    

    这将生成以下输出:

    图 2.1:计算名称输出

    图片 B15218_02_01.jpg

图 2.1:计算名称输出

计算属性对于创建高性能组件的 Vue 开发者来说非常有价值。在下一个练习中,我们将探讨如何在 Vue 组件内部使用它。

练习 2.01:将计算数据集成到 Vue 组件中

在这个练习中,你将使用计算属性来帮助你减少在 Vue 模板内部需要编写的代码量,通过简洁地输出基本数据。要访问此练习的代码文件,请参阅 packt.live/3n1fQZY

  1. 打开命令行终端,导航到 Exercise 2.01 文件夹,并按顺序运行以下命令:

    > cd Exercise2.01/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. 创建一个用于第一个名称的输入字段,使用 v-model 将数据属性 firstName 绑定到该字段:

    <input v-model="firstName" placeholder="First name" />
    
  3. 创建第二个输入字段用于姓氏,并使用 v-model 将数据属性 lastName 绑定到该字段:

    <input v-model="lastName" placeholder="Last name" />
    
  4. 通过在 data() 函数中返回这些新的 v-model 数据属性,将它们包含在 Vue 实例中:

    data() {
        return {
          firstName: '',
          lastName: '',
        }
      },
    
  5. 创建一个名为 fullName 的计算数据变量:

    computed: {
        fullName() {
          return `${this.firstName} ${this.lastName}`
        },
      },
    
  6. 在你的输入字段下方,使用 heading 标签输出计算数据:

    <h3 class="output">{{ fullName }}</h3>
    

    这将生成以下输出:

    ![图 2.2:计算数据的输出将显示姓名和姓氏]

    ![img/B15218_02_02.jpg]

图 2.2:计算数据的输出将显示姓名和姓氏

在这个练习中,我们看到了如何在计算数据属性内部编写表达式,使用 v-model 的数据,并将第一个名称和姓氏合并成一个可以重用的单个输出变量。

计算设置器

在上一个练习中,你看到了如何编写可维护和声明式的计算属性,这些属性是可重用和响应式的,并且可以在组件内的任何地方调用。在某些实际情况下,当调用计算属性时,你可能需要调用外部 API 来与该 UI 交互或更改项目中的其他数据。执行此功能的东西被称为设置器。

计算设置器在以下示例中演示:

data() {
  return {
    count: 0
  }
},
computed: {
    myComputedDataProp: {
      // getter
      get() {
        return this.count + 1
      },
      // setter
      set(val) {
        this.count = val - 1
        this.callAnotherApi(this.count)
      },
    },
  },
}

默认情况下,计算数据仅是获取器,这意味着它只会输出你的表达式的结果。在此示例中,计算 myComputedDataProp 数据属性将在你的 Vue 组件中输出 1

  get() {
    return this.count + 1
  },

然后,使用计算属性中的设置器可以让你响应式地监听数据并运行一个回调(设置器),该回调包含从获取器返回的值,这些值可以可选地用于设置器中。

在此示例中,设置器将更新数据属性 count 到其新值(在获取器中反映)并调用组件内的一个方法 callAnotherApi。在这里,我们传递 count 数据属性来模拟将此信息发送到某个有用的地方:

  set(val) {
    this.count = val - 1
    this.callAnotherApi(this.count)
  },

在以下练习中,你将了解到如何将计算数据作为获取器和设置器使用。

练习 2.02:使用计算设置器

在这个练习中,你将使用计算属性作为设置器和获取器,这两个属性在用户输入触发时都会输出表达式并设置数据。

要访问此练习的代码文件,请参阅packt.live/2GwYapA

  1. 打开命令行终端,导航到Exercise 2.02文件夹,并按顺序运行以下命令:

    > cd Exercise2.02/
    > code .
    > yarn
    > yarn serve
    

    访问https://localhost:8080

  2. 创建一个v-model值绑定到名为incrementOne的计算数据值的输入字段,在 getter 中返回名为count的 Vue 数据变量的值,并在 setter 中设置count变量:

    <template>
      <div class="container">
        <input type="number" v-model="incrementOne" />
        <h3>Get input: {{ incrementOne }}</h3>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          count: -1,
        }
      },
      computed: {
        incrementOne: {
          // getter
          get() {
            return this.count + 1
          },
          // setter
          set(val) {
            this.count = val - 1
          },
        },
      },
    }
    </script>
    

    上述代码的输出将如下所示:

    图 2.3:计算 setter 和 getter 的第一步

    图 2.3:计算 setter 和 getter 的第一步

  3. 接下来,让我们再次使用 setter。我们将把新的val参数除以2,并将其保存到名为divideByTwo的新数据变量中:

    <template>
      <div class="container">
        <input type="number" v-model="incrementOne" />
        <h3>Get input: {{ incrementOne }}</h3>
        <h5>Set division: {{ divideByTwo }}</h5>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          count: -1,
          divideByTwo: 0,
        }
      },
    ...
    </script>
    ...
    
  4. 将 setter 更新为除以val,并将这个新值绑定到divideByTwo变量:

          set(val) {
            this.count = val - 1
            this.divideByTwo = val / 2
          },
    

    divideByTwo值的输出应生成从输入字段中输入的值的输出,如下所示:

    图 2.4:divideByTwo 值的输出

图 2.4:divideByTwo 值的输出

在这个练习中,我们看到了如何通过将计算变量绑定到v-model来使用计算数据在我们的模板中反应性地获取和设置数据。

监听器

Vue oldValnewVal。这可以帮助你在写入或绑定新值之前比较数据。监听器可以观察对象以及stringnumberarray类型。当观察对象时,只有当整个对象发生变化时,才会触发处理程序。

第一章开始您的第一个 Vue 项目中,我们介绍了在组件生命周期特定时间运行的生存周期钩子。如果将immediate键设置为true,则当组件初始化时,将运行此监听器。你可以通过包含键和值deep: true(默认为false)来监视任何给定对象中的所有键。为了清理你的监听器代码,你可以将处理程序参数分配给定义的 Vue 方法,这对于大型项目来说是最佳实践。

监听器补充了计算数据的用法,因为它们可以被动地观察值,不能像正常 Vue 数据变量那样使用,而计算数据必须始终返回一个值并且可以被查询。请记住,除非你不需要 Vue 的this上下文,否则不要使用箭头函数。

以下示例展示了immediatedeep可选键;如果myDataProperty对象中的任何键发生变化,它将触发控制台日志:

watch: {
    myDataProperty: {
        handler: function(newVal, oldVal) {
          console.log('myDataProperty changed:', newVal, oldVal)
        },
        immediate: true,
        deep: true
    },
}

现在,让我们在监听器的帮助下设置一些新值。

练习 2.03:使用监听器设置新值

在这个练习中,你将使用监听器参数来监视数据属性的变化,然后使用这个监听器通过一个方法设置变量。

要访问此练习的代码文件,请参阅packt.live/350ORI4

  1. 打开命令行终端,导航到Exercise 2.03文件夹,并按顺序运行以下命令:

    > cd Exercise2.03/
    > code .
    > yarn
    > yarn serve
    

    前往https://localhost:8080

  2. 通过添加一个折扣和带有一些样式的oldDiscount数据变量来设置文档:

    <template>
      <div class="container">
        <h1>Shop Watcher</h1>
        <div>
          Black Friday sale
          <strike>Was {{ oldDiscount }}%</strike>
          <strong> Now {{ discount }}% OFF</strong>
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          oldDiscount: 0,
          discount: 5,
        }
      },
    }
    </script>
    <style lang="scss" scoped>
    .container {
      margin: 0 auto;
      padding: 30px;
      max-width: 600px;
      font-family: 'Avenir', Helvetica, sans-serif;
      margin: 0;
    }
    a {
      display: inline-block;
      background: rgb(235, 50, 50);
      border-radius: 10px;
      font-size: 14px;
      color: white;
      padding: 10px 20px;
      text-decoration: none;
    }
    </style>
    
  3. 通过将discount属性添加到watch对象中,来观察discount属性。触发名为updateDiscount的方法。在方法内部,将oldDiscount数据属性设置为this.discount + 5

      watch: {
        discount(newValue, oldValue) {
          this.oldDiscount = oldValue
        },
      },
    
  4. 包含一个将增加discount变量并触发观察者的方法:

      methods: {
        updateDiscount() {
          this.discount = this.discount + 5
        },
      },
    

    现在添加一个换行符,并添加一个带有绑定到updateDiscount方法的@click指令的锚点元素:

        <br />
        <a href="#" @click="updateDiscount">Increase Discount!</a>
    

    前一个命令的输出将如下所示:

    图 2.5:一个商店观察者页面应该看起来像这样

图 2.5:一个商店观察者页面应该看起来像这样

在这个练习中,我们看到了如何使用观察者来观察和响应式地操作数据,当数据被 Vue 组件中的其他方法更改时。

在下一节中,我们将学习关于深度观察的概念。

深度观察概念

当使用 Vue.js 观察数据属性时,你可以有目的地观察对象内的键以进行更改,而不是观察对象本身的更改。这是通过将可选的deep属性设置为true来完成的:

data() {
  return {
      organization: {
        name: 'ABC',
        employees: [
            'Jack', 'Jill'
        ]
      }
  }
},
watch: {
    organization: {
      handler: function(v) {
        this.sendIntercomData()
      },
      deep: true,
      immediate: true,
    },
  },

此示例将观察组织数据对象内部的所有可用键以进行更改,因此如果组织内的name属性发生变化,组织观察者将触发。

如果你不需要观察对象内的每个键,可以通过指定为myObj.value字符串来仅观察对象内的特定键以进行更改,这可能会更高效。例如,你可能允许用户编辑他们的公司名称,并且只有当该键被修改时,才将数据发送到 API。

在下面的示例中,观察者专门观察了organization对象的name键。

data() {
  return {
      organization: {
        name: 'ABC',
        employees: [
            'Jack', 'Jill'
        ]
      }
  }
},
watch: {
    'organization.name': {
      handler: function(v) {
        this.sendIntercomData()
      },
      immediate: true,
    },
  },

我们看到了深度观察是如何工作的。现在,让我们尝试下一个练习,并观察数据对象的嵌套属性。

练习 2.04:观察数据对象的嵌套属性

在这个练习中,你将使用观察者来观察对象内的键,这些键将在用户在 UI 中触发方法时更新。

要访问此练习的代码文件,请参阅packt.live/353m59N

  1. 打开命令行终端,导航到Exercise 2.04文件夹,并按顺序运行以下命令:

    > cd Exercise2.04/
    > code .
    > yarn
    > yarn serve
    

    前往https://localhost:8080

  2. 首先定义一个包含pricelabeldiscount键的product对象。将这些值输出到模板中:

    <template>
      <div class="container">
        <h1>Deep Watcher</h1>
        <div>
            <h4>{{ product.label }}</h4>
            <h5>${{ product.price }} (${{ discount }} Off)</h5>
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          discount: 0,
          product: {
            price: 25,
            label: 'Blue juice',
          },
        }
      },
    }
    </script>
    <style lang="scss" scoped>
    .container {
      margin: 0 auto;
      padding: 30px;
      max-width: 600px;
      font-family: 'Avenir', Helvetica, sans-serif;
      margin: 0;
    }
    a {
      display: inline-block;
      background: rgb(235, 50, 50);
      border-radius: 10px;
      font-size: 14px;
      color: white;
      padding: 10px 20px;
      text-decoration: none;
    }
    </style>
    
  3. 设置一个按钮,将修改产品的价格。通过添加一个带有绑定到updatePrice方法的click事件的按钮元素来实现这一点,该方法递减价格值:

    <template>
    ...
        <a href="#" @click="updatePrice">Reduce Price!</a>
    ...
    </template>
    <script>
    ...
      methods: {
        updatePrice() {
          if (this.product.price < 1) return
          this.product.price--
        },
      },
    ...
    </script>
    

    当你点击按钮时,它应该像以下屏幕截图所示那样降低价格:

    图 2.6:显示蓝汁降价价格的屏幕截图

    图 2.6:显示蓝汁降价屏幕

  4. 是时候观察嵌套观察者了。我们将观察 product 对象的 price,并增加 discount 数据属性:

      watch: {
        'product.price'() {
          this.discount++
        },
      },
    

    现在,随着你减少 price,由于观察者的作用,discount 值将会上升:

    ![图 2.7 显示增加的折扣值]

    ![img/B15218_02_07.jpg]

图 2.7 显示增加的折扣值

在这个练习中,我们使用了观察者来观察对象中的键,然后使用或不用观察者解析的可选参数设置新数据。

方法与观察者与计算属性的比较

在 Vue.js 工具箱中,我们有方法、观察者和计算属性。你何时应该使用其中一个或另一个?

方法最适合在 date.now() 事件发生时做出反应。

在 Vue 中,你会组合一个由 @click 标记的动作,并引用一个方法:

<template>
    <button @click="getDate">Click me</button>
</template>
<script>
export default {
    methods: {
        getDate() {
            alert(date.now())
        }
    }
}
</script>

计算属性最适合在响应数据更新或为我们在模板中组合复杂表达式时使用。在这种情况下,如果 animalList 数据发生变化,animals 计算属性也将通过从数组中切片第二个项目并返回新值来更新:

<template>
      <div>{{ animals }}</div>
</template>
<script>
export default {
    data() {
        return {
            animalList: ['dog', 'cat']
        }
    },
    computed: {
          animals() {
              return this.animalList.slice(1)
          }
    }
}
</script>

它们的响应性使得计算属性非常适合从现有数据中组合新的数据变量,例如当你引用一个更大、更复杂对象的特定键时,有助于简化模板的可读性。在这个例子中,我们以两种不同的方式输出了作者两次。然而,请注意在 authorName 计算属性中,你可以干净地组合条件逻辑,而不会使 HTML 模板膨胀:

<template>
    <div>
        <p id="not-optimal">{{ authors[0].bio.name }}</p>
        <p id="optimal">{{ authorName }}</p>
    </div>
</template>
<script>
export default {
    data() {
       return {
           authors: [
              {
                 bio: {
                    name: 'John',
                    title: 'Dr.',
                 }
              }
           ]
       }
    },
    computed: {
         authorName () {
              return this.authors ? this.authors[0].bio.name :                 'No Name'
         }
    }
}
</script>

当你需要监听数据属性的变化或对象中的特定数据属性变化,并执行一个动作时,应该使用数据观察者。由于观察者的独特 newValoldVal 参数,你可以观察一个变量直到达到某个值,然后才执行动作:

<template>
    <div>
        <button @click="getNewName()">Click to generate name           </button>
        <p v-if="author">{{ author }}</p>
    </div>
</template>
<script>
    export default {
        data() {
            return {
                data: {},
                author: '',
            }
        },
        watch: {
            data: function(newVal, oldVal) {
                this.author = newVal.first
                alert(`Name changed from ${oldVal.first} to                   ${newVal.first}`)
            }
        },
        methods: {
            async getNewName() {
                await fetch('https://randomuser.me/api/').                  then(response => response.json()).then(data => {
                    this.data = data.results[0].name
                })
            },
        },
    }
</script> 

有了这个想法,我们将使用一个方法、计算属性和观察者来构建一个简单的搜索功能,以实现类似的效果并展示每种方法的能力。

练习 2.05:使用 Vue 方法、观察者和计算属性处理搜索功能

在这个练习中,你将创建一个组件,允许用户使用 Vue 中的三种不同方法搜索数据数组。到练习结束时,你将能够亲眼看到每种不同方法是如何工作的。

要访问此练习的代码文件,请参阅 packt.live/32iDJVe

  1. 打开命令行终端,导航到 Exercise 2.05 文件夹,并按顺序运行以下命令:

    > cd Exercise2.05/
    > code .
    > yarn
    > yarn serve
    

    前往 https://localhost:8080

  2. data 对象中,添加一个框架列表数组,分配给 frameworkList 值。包括一个空字符串作为输入键和一个空数组作为 methodFilterList 键:

    <script>
    export default {
      data() {
        return {
          // Shared
          frameworkList: [
            'Vue',
            'React',
            'Backbone',
            'Ember',
            'Knockout',
            'jQuery',
            'Angular',
          ],
          // Method
          input: '',
          methodFilterList: [],
        }
      },
    }
    </script>
    
  3. 在模板中,包括一个div容器、一个title和一个column容器。在这个column容器内部,创建一个绑定到v-model输入的输入框,并将输入框上的keyup事件绑定到searchMethod方法:

    <template>
      <div class="container">
        <h1>Methods vs watchers vs computed props</h1>
        <div class="col">
          <input
            type="text"
            placeholder="Search with method"
            v-model="input"
            @keyup="searchMethod"
          />
          <ul>
            <li v-for="(item, i) in methodFilterList" :key="i">          {{ item }}</li>
          </ul>
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // Shared
          frameworkList: [
            'Vue',
            'React',
            'Backbone',
            'Ember',
            'Knockout',
            'jQuery',
            'Angular',
          ],
          // Method
          input: '',
          methodFilterList: [],
        }
      },
      methods: {
        searchMethod(e) {
         console.log(e)
        },
      },
    }
    </script>
    <style lang="scss" scoped>
    .container {
      margin: 0 auto;
      padding: 30px;
      max-width: 600px;  font-family: 'Avenir', Helvetica, Arial, sans-serif;
    }
    .col {
      width: 33%;
      height: 100%;
      float: left;
    }
    input {
      padding: 10px 6px;
      margin: 20px 10px 10px 0;
    }
    </style>
    

    前面代码的输出将如下所示:

    ![图 2.8:控制台应输出关键输入 图片

    图 2.8:控制台应输出关键输入

  4. 在我们的searchMethod方法中,编写一个过滤表达式,将methodFilterList数据属性绑定到基于输入值的过滤frameworkList数组。在created()生命周期钩子上触发searchMethod,以便当组件加载时,有一个列表存在:

    <script>
    export default {
      ...
      created() {
        this.searchMethod()
      },
      methods: {
        searchMethod() {
          this.methodFilterList = this.frameworkList.filter(item =>
            item.toLowerCase().includes(this.input.toLowerCase())
          )
        },
      },
    }
    </script>
    

    运行前面的代码后,你将能够像图 2.9中所示的那样过滤列表:

    ![图 2.9:你现在应该能够使用 Vue 方法过滤列表 图片

    图 2.9:你现在应该能够使用 Vue 方法过滤列表

  5. 让我们使用计算属性来创建一个过滤器。包括一个新的数据属性input2,并创建一个名为computedList的计算属性,它返回与searchMethod相同的过滤器,但不需要绑定到另一个数据属性:

    <template>
      <div class="container">
    
       ...
        <div class="col">
          <input type="text" placeholder="Search with computed"         v-model="input2" />
          <ul>
            <li v-for="(item, i) in computedList" :key="i">          {{ item }}</li>
          </ul>
        </div>
       ...
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
           ...
          // Computed
          input2: '',
          ...
    
        }
      },
    ...
      computed: {
        computedList() {
          return this.frameworkList.filter(item => {
            return item.toLowerCase().includes(this.input2\.          toLowerCase())
          })
        },
      },
    ...
    }
    </script>
    

    现在你可以借助计算属性过滤框架的第二列,如下面的截图所示:

    ![图 2.10:使用计算属性过滤框架的第二列 图片

    图 2.10:使用计算属性过滤框架的第二列

  6. 最后,让我们使用一个观察者来过滤相同的列表。包含一个带有空字符串的input3属性和一个带有空数组的watchFilterList属性。同时创建一个第三列div,其中包含一个绑定到input3v-model的输入框,以及输出watchFilterList数组的列表:

    <template>
      <div class="container">
        …
        <div class="col">
          <input type="text" placeholder="Search with watcher"         v-model="input3" />
          <ul>
            <li v-for="(item, i) in watchFilterList" :key="i">          {{ item }}</li>
          </ul>
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          ...
          // Watcher
          input3: '',
          watchFilterList: [],
        }
      },
     ...
    </script>
    
  7. 创建一个观察者,它监视input3属性的变化,并将frameworkList过滤的结果绑定到watchFilterList数组。将input3的立即键设置为true,以便它在组件创建时运行:

    <script>
    export default {
    ...
      watch: {
        input3: {
          handler() {
            this.watchFilterList = this.frameworkList.filter(item =>
              item.toLowerCase().includes(this.input3.toLowerCase())
            )
          },
          immediate: true,
        },
      },
    ...
    }
    </script>
    

    在观察者的帮助下,你现在应该能够过滤第三列,如下面的截图所示:

    ![图 2.11:在第三列使用观察者过滤列表 图片

图 2.11:在第三列使用观察者过滤列表

在这个练习中,我们看到了如何使用方法、计算属性和观察者来实现过滤列表。每个都有自己的优点、缺点和使用场景,具体取决于你想要在应用程序中实现什么。

异步方法和数据获取

JavaScript 中的异步函数是通过 async 函数语法定义的,并返回一个 AsyncFunction 对象。这些函数通过事件循环异步操作,使用隐式的 promise(一个可能在未来返回结果的对象)。Vue.js 使用这种行为允许你在方法中包含 async 关键字来声明异步代码块。然后,你可以链式调用 then()catch() 函数,或者在 Vue 方法中使用 {} 语法并返回结果。

Axios 是一个流行的 JavaScript 库,允许你使用 Node.js 进行外部数据请求。它具有广泛的浏览器支持,使其在执行 HTTP 或 API 请求时成为一个多才多艺的库。我们将在下一个练习中使用这个库。

练习 2.06:使用异步方法从 API 获取数据

在这个练习中,你将异步从外部 API 源获取数据,并使用计算属性在前端显示它。

要访问此练习的代码文件,请参阅 packt.live/353md9h

  1. 打开命令行终端,导航到 Exercise 2.06 文件夹,并运行以下命令来安装 axios

    > cd Exercise2.06/
    > code .
    > yarn
    > yarn add axios
    > yarn serve
    

    访问 https://localhost:8080

  2. 让我们从将 axios 导入我们的组件并创建一个名为 getApi() 的方法开始。使用 axios 调用 api.adviceslip.com/advice 的响应,并使用 console.log 输出结果。包括一个按钮,将其 click 事件绑定到 getApi() 调用:

    <template>
      <div class="container">
        <h1>Async fetch</h1>
        <button @click="getApi()">Learn something profound</button>
      </div>
    </template>
    <script>
    import axios from 'axios'
    export default {
      methods: {
        async getApi() {
          return   axios.get('https://api.adviceslip.com/advice').        then((response) => {
            console.log(response)
          })
        },
      },
    }
    </script>
    
    <style lang="scss" scoped>
    .container {
      margin: 0 auto;
      padding: 30px;
      max-width: 600px;
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
    }
    blockquote {
      position: relative;
      width: 100%;
      margin: 50px auto;
      padding: 1.2em 30px 1.2em 30px;
      background: #ededed;
      border-left: 8px solid #78c0a8;
      font-size: 24px;
      color: #555555;
      line-height: 1.6;
    }
    </style>
    

    上述代码的输出将如下所示:

    图 2.12:显示控制台中一个非常大的对象的屏幕

    图 2.12:显示控制台中一个非常大的对象的屏幕

  3. 我们只对 response 对象内部的数据对象感兴趣。将此数据对象分配给一个名为 response 的 Vue 数据属性,我们可以重用它:

    export default {
      data() {
        return {
          axiosResponse: {},
        }
      },
      methods: {
        async getApi() {
          return axios.get('https://api.adviceslip.com/advice').        then(response => {
            this.axiosResponse = response.data
          })
        },
      },
    }
    
  4. 使用计算属性输出 response 属性对象内部的 quote,该计算属性将在 response 属性更改时更新。使用三元运算符执行条件语句以检查 response 属性是否包含 slip 对象,以避免错误:

    <template>
      <div class="container">
        <h1>Async fetch</h1>
        <button @click="getApi()">Learn something profound</button>
        <blockquote v-if="quote">{{ quote }}</blockquote>
      </div>
    </template>
    <script>
    import axios from 'axios'
    export default {
      data() {
        return {
          axiosResponse: {},
        }
      },
      computed: {
        quote() {
          return this.axiosResponse && this.axiosResponse.slip
            ? this.axiosResponse.slip.advice
            : null
        },
      },
      methods: {
        async getApi() {
          return axios.get('https://api.adviceslip.com/advice').        then(response => {
            this.axiosResponse = response.data
          })
        },
      },
    }
    </script>
    

    图 2.13 显示了上述代码生成的输出:

    图 2.13:显示模板中引用输出的屏幕

    图 2.13:显示模板中引用输出的屏幕

  5. 作为最后的润色,包括一个 loading 数据属性,以便用户可以看到 UI 是否正在加载。默认将 loading 设置为 false。在 getApi 方法中,将 loading 设置为 true,然后在 then() 链中使用 setTimeout 函数在 4 秒后将它设置回 false。你可以使用三元运算符在加载状态和默认状态之间切换按钮文本:

    <template>
      <div class="container">
        <h1>Async fetch</h1>
        <button @click="getApi()">{{
          loading ? 'Loading...' : 'Learn something profound'
        }}</button>
        <blockquote v-if="quote">{{ quote }}</blockquote>
      </div>
    </template>
    <script>
    import axios from 'axios'
    export default {
      data() {
        return {
          loading: false,
          axiosResponse: {},
        }
      },
      computed: {
        quote() {
          return this.axiosResponse && this.axiosResponse.slip
            ? this.axiosResponse.slip.advice
            : null
        },
      },
      methods: {
        async getApi() {
          this.loading = true
          return axios.get('https://api.adviceslip.com/advice').        then(response => {
            this.axiosResponse = response.data
    
            setTimeout(() => {
              this.loading = false
            }, 4000);
          })
        },
      },
    }
    </script>
    

上述代码的输出将如下所示:

图 2.14:显示模板中加载按钮状态输出的屏幕

图 2.14:在模板中显示加载按钮状态输出的屏幕

在这个练习中,我们看到了如何从外部源获取数据,将其分配给计算属性,在我们的模板中显示它,并给我们的内容应用加载状态。

活动二.01:使用 Contentful API 创建博客列表

在这个活动中,我们将构建一个博客,列出来自 API 源的文章。这将通过使用所有基本的async方法从 API 获取远程数据以及使用计算属性来组织深层嵌套的对象结构来测试您对 Vue 的了解。

Contentful是一个无头内容管理系统CMS),允许您将内容与代码存储库分开管理。您可以使用 API 在所需的任何代码存储库中消费此内容。例如,您可能有一个作为信息主要来源的博客网站,但您的客户想要一个独立页面的不同域名,该页面只拉取最新的特色文章。使用无头 CMS 本质上允许您开发这两个独立的代码库,并使用相同的数据源。

这个活动将使用无头 CMS Contentful。访问密钥和端点将在解决方案中列出。

以下步骤将帮助您完成活动:

  1. 使用 Vue CLI 创建一个使用babel预设的新项目。

  2. contentful依赖项安装到您的项目中。

  3. 使用计算属性从 API 响应中输出深层嵌套的数据。

  4. 使用data属性输出用户的name职位名称描述

  5. 使用SCSS来设置页面样式。

预期结果是以下内容:

![图 2.15:使用 Contentful 博客文章的预期结果img/B15218_02_15.jpg

图 2.15:使用 Contentful 博客文章的预期结果

注意

该活动的解决方案可以通过此链接找到。

活动完成后,您应该能够使用async方法从 API 源将远程数据拉入您的 Vue 组件。您会发现计算属性是一种将信息分解成更小的可重用数据块的高级方式。

摘要

在本章中,您被介绍了 Vue.js 的计算和观察属性,这些属性允许您观察和控制响应式数据。您还看到了如何使用axios库异步从 API 获取数据,以及如何使用计算属性将数据扁平化,以便在 Vue 模板中使用。通过构建使用每种方法的搜索功能,演示了使用方法和计算以及观察属性之间的区别。

下一章将介绍 Vue CLI,并展示如何管理并调试使用这些计算属性和事件的 Vue.js 应用程序。

第三章:3. Vue CLI

概述

本章介绍了 Vue CLI,包括 Vue-UI 和 Vue.js DevTools,这些工具在开发用于生产的 Vue 应用程序时会被使用。Vue-UI 允许你通过一个伴随的图形用户界面来创建、开发和管理工作室 Vue 项目。Vue.js DevTools 是一个独立的应用程序和浏览器扩展,用于调试 Vue.js 应用程序。我们将详细介绍使用 Vue CLI 功能的使用案例和好处,这将教会你如何使用这些 Vue 命令。除了命令行控制之外,我们还将设置并运行 Vue 项目,利用新的 Vue GUI。我们将结合前几章积累的知识来创建新的 Vue 应用程序,这些应用程序使用 v-model 指令和双向绑定概念。然后我们将深入探讨如何原型化 Vue 组件。我们还将学习如何构建用于生产的 Vue 原型并在本地提供服务。随着我们的进展,你将看到如何设置和调试你的 Vue 应用程序并展示其功能。

到本章结束时,你将牢固掌握如何使用 Vue CLI 的功能、原型化 Vue 组件以及利用 Vue.js DevTools。

简介

在上一章中,我们介绍了如何使用 Vue.js 在组件模板中反应性地管理和操作数据。在本章中,我们将探讨如何使用 Vue CLI 支持此类模板的开发。Vue.js 利用了npm和 webpack 生态系统,正如在第一章开始你的第一个 Vue 项目在简单的 Vue 应用程序中的 Vue 实例示例中看到的那样。这些工具帮助开发者快速搭建和构建出色的 Web 应用程序。Vue.js 内部的一些显著模式包括vue.config(允许你添加 webpack 规则,而无需直接编辑 webpack 文件本身)、双向数据绑定单文件组件(SFCs),正如在第一章开始你的第一个 Vue 项目使用 V-Model 进行双向绑定示例中看到的那样。

使用Vue 命令行界面(Vue CLI)实例化的 Webpack 项目将自带热重载功能。热重载是一种前端开发模式,当检测到代码更改时,浏览器中的应用程序将自动更新。你想要这个功能的原因是,这样你不会丢失任何浏览器状态,你代码中的更改将立即反映在浏览器中,这在处理用户界面(UI)时非常有用。偶尔,可能需要进行完整的页面重载,因为 JavaScript 是一种非常状态化的语言。

Vue CLI 是 Vue 开发的核心工具,因为它允许程序员通过一组描述性和预配置的命令更舒适地维护他们的项目。在开发项目中,一个经常被忽视的过程是代码检查,这是一个程序将标记你代码中潜在的错误或问题的过程,这可能在现有项目中相当困难。当使用 Vue CLI 时,如果你的 webpack 项目在创建 Vue CLI 项目时选择了代码检查选项,它将自带代码检查功能。

我们将使用 Vue CLI 配置一个 Vue 项目,并运行每个基本命令,以便你了解构建 Vue 应用程序所需的工具。默认情况下,Vue CLI 支持 BabelTypeScriptESLintPostCSSPWAs测试等。

使用 Vue CLI

使用 Vue CLI 工具创建的项目可以访问一些常用任务,这些任务将帮助你在本地浏览器中运行(运行项目)、构建(为生产编译文件)和检查(检查代码中的错误)你的项目。Vue CLI 服务开发依赖包会自动与新的项目一起安装,并允许你运行以下命令:

  • npm run serveyarn serve – 在 localhost:8080 上运行项目代码,并具有热重载功能。端口号 8080 是任意指定的,因为它高于其他计算领域使用的知名端口号 1-1023。如果你同时运行多个 Vue 项目,它们将具有递增的端口号,例如 :8080:8081 等。

  • npm run buildyarn build – 运行生产构建,减小项目文件大小,并可以从主机提供服务。

  • npm run lintyarn lint – 运行代码检查过程,这将突出显示代码错误或警告,使你的代码更加一致。

现在你已经了解了 Vue CLI 是什么,以及可用的命令,我们将学习如何使用 Vue CLI 从零开始设置 Vue.js 项目。

练习 3.01:使用 Vue CLI 设置项目

在这个练习中,你将使用 Vue CLI 命令创建你的第一个 Vue.js webpack 项目。但是,首先,请确保你已经遵循了前言指南安装了 NodeVue CLI 4。建议在 OS X 上使用 iTerm2,因为它非常适合你的开发流程。如果你使用 Windows,建议使用 PowerShell,因为它可能比默认的命令提示符和 GIT bash 更高效。

要访问此练习的代码文件,请参阅 packt.live/3ph2xXt

  1. 打开命令提示符。你的窗口应该看起来如下:图 3.1:一个空的命令提示符窗口

    图 3.1:一个空的命令提示符窗口

  2. 运行命令 vue --version。确保你使用的是 Vue CLI 的最新版本,因为以下说明在 Vue CLI 2 或更早版本中可能无法正常工作。

    在前面的命令之后,你的屏幕应该看起来如下:

    图 3.2:检查 Vue 版本时的命令提示符

    图 3.2:检查 Vue 版本时的命令提示符

    您的@vue/cli版本至少应该是 4.1.2。

  3. 运行以下 Vue CLI 命令:

    vue create my-app
    

    运行前面的命令后,您应该会看到一个已保存预设的列表,如下面的截图所示:

    图 3.3:显示已保存预设的列表

    图 3.3:显示已保存预设的列表

  4. 通过按一次向下箭头键然后按Enter键来选择最后一个选项Manually select features

    ? Please pick a preset: (Use arrow keys)
      default (babel, eslint)
     > Manually select features
    
  5. 您会注意到带有括号内星号的功能。每个功能代表一个您可以在您的应用程序中启用的预设。您不必知道这些代表什么。现在,我们将通过使用箭头键导航,在每个选项上按空格键,然后按Enter键来选择BabelCSS Pre-processorsLinter/Formatter

    ? Check the features needed for your project:
     (*) Babel
     ( ) TypeScript
     ( ) Progressive Web App (PWA) Support
     ( ) Router
     ( ) Vuex
     (*) CSS Pre-processors
    >(*) Linter / Formatter
     ( ) Unit Testing
     ( ) E2E Testing
    
  6. 由于您选择启用预处理器,您现在可以选择您偏好的 CSS 预处理器。在本练习中,我们将使用Sass/SCSS (with dart-scss)

    ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys)
    > Sass/SCSS (with dart-sass)
      Sass/SCSS (with node-sass)
      Less
      Stylus
    

    注意

    dart-scssSass库的纯 JavaScript 编译版本,与node-sass(它是 SCSS 的 C++实现包装器)相比,它是一个更小的依赖项,并且不需要在 Node 升级版本之间重新构建。

  7. 我们现在将选择Eslint + Prettier选项,它将以一致的方式自动格式化代码:

    ? Pick a linter / formatter config: (Use arrow keys)
     ESLint with error prevention only
     ESLint + Airbnb config
     ESLint + Standard config
    > ESLint + Prettier
    
  8. 要在保存工作自动格式化代码,请选择Lint on Save选项:

    ? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)
     >(*) Lint on save
     (*) Lint and fix on commit
    

    注意

    Lint on save是一个有用的工具,可以在编写代码时对其进行格式化。在步骤 8中选择两个 linting 选项,以确保您的工作在编写过程中都经过 linting,从而使代码更易于阅读和一致。

  9. 接下来,我们将选择In dedicated config files选项,以根据我们的偏好放置配置:

    ? Where do you prefer placing config for Babel, PostCSS,  ESLint, etc.? (Use arrow keys)
     > In dedicated config files
     In package.json
    

    注意

    package.json的一个论点是保持所有配置以 JSON 格式和在一个文件中保持一致格式。对于较小的项目,这可能是可接受的,但是大型项目往往会生成一个非常长的package.json文件。这就是文件拆分更可取的地方。将配置拆分到单独的文件中,在编辑这些选项时减少了认知负荷,并在提交日志中更强调正在更改的内容。例如,当您编辑babelrc文件时,您知道更改与 Babel 配置有关,而不是package.json更改,后者可能涉及任何内容。

  10. 选择Save preset (y)选项以保存您的预设。

    您可以用任何名称调用预设。在示例中,它将被命名为My preset

    下次您想要安装此预设时,尝试运行vue create favourite -p "My preset"(使用vue create [project-name] -p [preset-name]的一般命令行语法):

    ? Save this as a preset for future projects? Yes
    ? Save preset as: My preset
    
  11. 运行包安装器。如果安装器没有自动启动,请运行yarn install命令:

    yarn install v1.16.0
    info No lockfile found.
     [1/4] Resolving packages...
    
  12. 一旦包安装器完成,serve 您的项目以编译您的代码并在 http://localhost:8080 上提供服务:

    yarn serve
    

    如果端口 8080 已经被另一个应用程序占用,请使用 --port 标志指定另一个端口,例如 9000

    yarn serve --port 9000
    

    运行前面的命令,我们将看到一个默认的 Vue 项目屏幕,如图 3.4 所示:

    ![图 3.4:默认 Vue 项目屏幕将出现在您的 localhost:8080 上 图片

图 3.4:默认的 Vue 项目屏幕将出现在您的 localhost:8080 上

在这个练习中,我们看到了如何使用命令提示符中的 Vue CLI 命令创建一个 Vue.js webpack 项目。接下来,我们将探讨如何在不创建 webpack 项目的情况下原型化一个 Vue.js 组件。

Vue 原型化

假设,有一天你醒来,有一个关于组件的绝佳想法,或者你参与了一个大型项目,并且你想要在不复杂的现有项目依赖关系下调试组件。Vue 原型化可以帮助你创建新的组件或调试现有的组件,即使是大型项目。这是通过直接在单独且隔离的编译器中运行 .vue 文件来完成的,无需任何本地依赖。以这种方式运行 .vue 文件可能会节省时间,因为你不需要安装如 练习 3.01 中描述的完整 Vue 项目。相反,您只需要安装 npm install -g @vue/cli-service-globalyarn global add @vue/cli-service-global

一旦安装完成,您将能够访问以下两个命令:

  • vue serve – 此命令编译 Vue.js 代码并在浏览器中的本地主机环境中运行。

  • vue build – 此命令将 Vue.js 代码编译成可分发包。

原型化入门

要开始,您首先需要通过打开命令终端并运行以下 install 命令来安装全局包:

npm install -g @vue/cli-service-global
# or
yarn global add @vue/cli-service-global

这将生成以下截图:

![图 3.5:安装 Vue 原型化所需的全球依赖图片

图 3.5:安装 Vue 原型化所需的全局依赖

安装可能需要几分钟,具体取决于您的互联网连接速度。您将知道安装何时完成,因为您将能够在终端中编写其他命令。如果安装失败,请简单地重新打开终端并运行相同的命令。

要开始使用原型化,创建一个名为 helloWorld.vue 的示例组件:

// helloWorld.vue
<template>
    <h1>Hello World!</h1>
</template>

在您的终端窗口中(与您的新的 .vue 文件相同的目录),使用以下命令:

vue serve helloWorld.vue

前一个命令将显示如下:

![图 3.6:vue serve 命令正在 D:\ 目录下的文件上运行图片

图 3.6:vue serve 命令正在 D:\ 目录下的文件上运行

运行 serve 命令后,组件将在终端窗口中编译一段时间,然后才能在浏览器中访问,如下所示:

![图 3.7:vue serve 命令将在本地主机环境中提供您的 Vue 文件]

图片 B15218_03_07.jpg

![图 3.7:vue serve 命令将在本地主机环境中提供您的 Vue 文件]

编译完成后,使用浏览器导航到命令窗口中指定的本地主机 URL。这里,它是http://localhost:8080/。在您的浏览器中,您应该看到文本Hello World!

Hello World!

我们现在已经学会了如何在不创建完整的 webpack 项目的情况下即时原型化 Vue 组件。让我们看看我们如何导入字体或库以在原型中使用。

定义您的入口点

在您的原型中,您可能需要使用外部库,如字体或脚本,以完成您的原型组件。index.html文件是 Vue.js HTML 模板的入口点。如果您没有定义index.html文件,将使用全局默认文件。

要定义自定义入口点,在相同目录下创建一个index.html文件。使用以下从默认索引页面派生的代码,您将看到已向<head>标签添加了 Google 字体:

// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width,      initial-scale=1.0">
    <title>Hello World</title>
    <link ref="https://fonts.googleapis.com/css2?family=Roboto&      display=swap" rel="stylesheet">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

要在您的原型组件中使用此字体,在index.html文件所在的同一目录下创建一个helloWorld.vue组件,然后对该组件应用一些css样式:

// helloWorld.vue
<template>
    <h1>Hello World!</h1>
</template>
<style>
h1 {
  font-family: 'Roboto';
}
</style>

要查看对这些文件所做的更改,请在您的终端中运行以下命令:

vue serve helloWorld.vue

这将生成以下截图:

![图 3.8:在您想要原型化的文件上运行 vue serve 命令]

图片 B15218_03_08.jpg

![图 3.8:在您想要原型化的文件上运行 vue serve 命令]

运行serve命令后,组件将在浏览器可访问之前在终端窗口中进行编译,具体如下:

![图 3.9:vue serve 命令将在本地主机环境中提供您的 Vue 文件]

图片 B15218_03_09.jpg

![图 3.9:vue serve 命令将在本地主机环境中提供您的 Vue 文件]

在您的浏览器中打开本地主机 URL。您应该会看到使用新字体格式化的文本。输出将如下所示:

Hello World!

我们现在已经学会了如何在 Vue 组件原型中包含外部库,而无需创建完整的 webpack 项目。接下来,我们将看到如何构建一个可以托管在网站上或由您的团队外部预览的原型。

构建用于生产的原型

当您完成原型制作并希望与团队中的其他人分享或提交给技术负责人进行审查时,您可以导出您的代码作为可分发文件。

这意味着您的代码可以在其他机器上运行或在外部服务器上托管,而无需 Vue CLI 运行它(即vue serve命令)。

使用之前的示例文件进行此操作,打开您的命令终端,导航到包含您的 Vue 原型的文件夹,并运行vue build helloWorld.vue命令。这将生成以下输出:

![图 3.10:运行 build 命令后的终端输出]

图片

图 3.10:运行构建命令后的终端输出

将创建一个包含你的原型编译版本的dist文件夹,你可以将其上传到网络主机。在dist文件夹内,你可以期待看到以下文件:

  • 一个index.html文件

  • /css文件夹

  • /js文件夹

所有这些文件都是正确运行你的编译原型所必需的。如果你双击index.html文件,它不会加载你的应用。要在本地机器上查看或服务可分发文件,你需要一个可以服务静态网站或单页应用的库。一个名为servenpm包就是为了这个目的而构建的。

要服务你的dist文件夹,通过打开命令行终端并使用以下命令全局安装serve包:

npm install -g serve
# or
yarn global add serve

serve命令通过允许你指定要服务的目录或文件来工作:

serve [path/to/serve]

在你的命令行终端中,确保你位于你的 Vue 原型dist文件夹所在的根目录。要服务此文件夹目录中的dist文件夹,请运行以下命令:

serve dist

前面的命令将显示如下输出:

![图 3.11:运行 serve 命令后的终端输出]

图片

图 3.11:运行 serve 命令后的终端输出

导航到http://localhost:5000,你将看到以下输出中显示的原型项目:

Hello World!

我们现在已经学会了如何构建一个可以托管在网站上的 Vue 组件原型,以及如何在本地预览构建的文件。现在,我们将看到这些 Vue 原型概念在下一个练习中的应用。

练习 3.02:使用 Vue CLI 进行即时原型设计

在这个练习中,你将创建一个使用 Vue 的即时双向数据绑定的 Vue 组件。使用即时原型设计可以使你快速利用 Vue 语法,并附加热重载等好处。

要访问此练习的代码文件,请参阅packt.live/35kZrd3

  1. Exercise 3.02文件夹中,创建一个名为prototype.vue的文件。

  2. 在此文件夹内打开一个命令行终端,并使用vue serve prototype.vue命令。

  3. 使用vue,然后按Tab键以即时创建 Vue 组件结构:

    <template>
    
    </template>
    <script>
    export default {
    }
    </script>
    <style>
    </style>
    
  4. 创建一个名为heading的数据属性,其字符串值为Prototype Vue Component,然后在模板中将它包裹在h1标签中。在浏览器中的localhost:8080查看结果:

    <template>
        <h1>{{ heading }}</h1>
    </template>
    <script>
    export default {
        data() {
            return {
                heading: "Prototype Vue Component"
            }
        }
    }
    </script>
    <style>
        h1 {
            font-family: Arial, Helvetica, sans-serif;
        }
    </style>
    

    前面的代码将显示如下输出:

    Prototype Vue Component
    

    要为生产构建此组件,请运行vue build prototype.vue命令。运行此命令后,你将在原型组件所在的同一目录中生成一个dist文件夹,如图图 3.12所示:

    ![图 3.12:此练习的最终输出包含一个/dist 文件夹]

    图片

    图 3.12:本练习的最终输出包含一个/dist 文件夹

  5. 在构建你的可分发文件后,在你的命令行终端中运行serve dist。然后,在你的浏览器中导航到终端中指定的 localhost URL。你将能够以以下方式查看你构建的原型:

    Prototype Vue Component
    

在这个练习中,你看到了如何通过命令行运行原型化的 Vue 组件,而无需安装全新的项目。你还看到了如何将新原型构建成可分发文件,然后提供服务。接下来,我们将探讨如何使用 Vue-UI 启动和运行 Vue 应用程序。

Vue-UI

Vue-UI 是一个图形界面,允许你控制 Vue 属性,而无需过多了解命令行的工作方式或如何配置单个文件,如package.json或 webpack 文件。Vue-UI 提供了对诸如vue ui命令等信息轻松访问。在撰写本文时,Vue-UI 仍在测试版。如果你在使用此工具时遇到任何问题,请停止命令,然后再次运行vue ui

Vue-UI 可用于新项目和现有项目。通常,你会在项目开始时选择使用 Vue CLI 的预设,例如使用哪个SCSS编译器、测试框架或lint方法。使用 Vue-UI,即使是新的 Vue 开发者也可以轻松地在任何时间配置 Vue 预设,包括诸如输出目录或开启sourcemaps这样的晦涩的 webpack 设置。以下截图显示了常规设置页面:

图 3.13:在 Vue-UI 中轻松配置的项目设置

图片

图 3.13:在 Vue-UI 中轻松配置的项目设置

npm包系统非常庞大。然而,对于有经验的用户来说,导航起来相当容易。通常,有经验的开发者会通过命令行安装一个包,然后它会自动更新package.json文件并锁定新包。锁定的文件是记录你与项目一起提交的npm包所需相互依赖关系的生成记录器。Vue 有一些特定的包称为插件,这些是 Vue 的特别npm包,不仅会安装依赖项,通常还会以有用的方式增强你的项目。例如,如果你安装了vue router cli插件,它将自动在你的项目中生成一个route.js文件,让你更快地上手。

Vuetify是一个在尝试快速搭建更复杂组件或项目时非常有用的框架,它包含了许多常见的 UI 元素和组件,如按钮和输入字段,这样你就可以专注于界面而不是构建单个组件本身。如果你使用 Vue CLI 插件包安装Vuetify,它将自动为你设置 Vuetify。

我们现在已经了解了 Vue-UI,以及新和有经验的开发者如何使用这个工具来管理和依赖项。接下来,我们将使用 Vue-UI 来创建和运行 Vue.js 项目。

练习 3.03:从 Vue-UI 创建和构建新项目

在这个练习中,你将逐步学习如何使用 Vue-UI 设置和安装 Vue.js 项目。你还将被要求安装并使用Vuetify库作为依赖项。安装后,你将使用 Vue-UI 运行此项目,并看到 Vuetify 元素在页面上运行。

要访问此练习的代码文件,请参阅packt.live/35jOsAH

  1. 打开命令行终端并运行vue ui命令。你会看到以下屏幕:![图 3.14:Vue-UI 中没有项目 图片

    图 3.14:Vue-UI 中没有项目

  2. 点击“创建”以启动新项目。导航到你希望安装项目的文件夹:![图 3.15:安装项目 图片

    图 3.15:安装项目

  3. 在“项目文件夹”字段中,输入demo-ui,选择yarn作为你的包管理器,然后点击“下一步”,如图所示:![图 3.16:Vue-UI 的项目创建界面 图片

    图 3.16:Vue-UI 的项目创建界面

  4. 选择“手动”,你将被带到“功能”屏幕。在此屏幕上,选择Babel、“CSS 预处理器”、Linter/Formatter和“使用配置文件”。图 3.22显示了选择这些选项的截图:![图 3.17:在 Vue-UI 中为你的新项目启用功能 图片

    图 3.17:在 Vue-UI 中为你的新项目启用功能

  5. 选择“Sass/SCSS(使用 dart-sass)”预处理器和ESLint + Prettier配置,并启用以下截图所示的附加 lint 功能:![图 3.18:在 Vue-UI 中为新项目启用配置选项 图片

    图 3.18:在 Vue-UI 中为新项目启用配置选项

  6. 当提示时,选择“继续不保存预设”,并等待项目安装。你应该会看到一个类似图 3.19的屏幕:![图 3.19:Vue 创建和安装项目依赖时请耐心等待 图片

    图 3.19:Vue 创建和安装项目依赖时请耐心等待

  7. 导航到插件页面,点击“安装依赖”,搜索vuetify,并安装vue-cli-plugin-vuetify。你可以在“依赖”页面上观察到vuetify已自动添加到项目依赖列表中,如下所示:![图 3.20:依赖搜索和安装的干净界面 图片

    图 3.20:依赖搜索和安装的干净界面

  8. 导航到“项目任务”页面,点击serve任务。然后,点击以下截图所示的“运行任务”图标:![图 3.21:serve 任务仪表板包含运行任务按钮 图片

    图 3.21:serve 任务仪表板包含运行任务按钮

  9. 等待 Vue 编译应用。当应用准备就绪时,点击如图 3.22 所示的“打开应用”按钮:图 3.22:打开应用按钮将直接带你到浏览器中的应用

    图 3.22:打开应用按钮将直接带你到浏览器中的应用

    你应该在浏览器中看到你的应用,如下截图所示:

    图 3.23:在 http://localhost:8080 上,你应该看到一个 Vuetify 风格的页面

    图 3.23:在 http://localhost:8080 上,你应该看到一个 Vuetify 风格的页面

  10. 为了将此项目准备用于生产,请回到 Vue-UI 浏览器标签页,并在“项目任务”中点击“构建”标签。点击“开始任务”按钮旁边的“参数”按钮。开启“现代模式”并确保“输出目录”设置为dist。“现代模式”将你的代码转换为两个版本,一个轻量级且针对现代浏览器,另一个详细且用于支持旧浏览器。这将是你编译后找到文件的地方。你的屏幕应该显示如下截图:图 3.24:Vue-UI 构建参数

    图 3.24:Vue-UI 构建参数

  11. 要为生产构建此项目,请点击“开始任务”按钮并让它运行。

    注意

    你不需要停止 serve 任务来完成此操作。

    任务完成后,你的屏幕将显示如下:

    图 3.25:当构建完成后,你将看到一个包含有用分析仪表板的控制台

图 3.25:构建完成后,你将看到一个包含有用分析仪表板的控制台

在这个练习中,你看到了如何创建一个全新的项目,配置预设,使用 serve 任务运行一个应用,以及如何通过 Vue-UI 构建用于生产的项目。你应该足够熟悉,可以添加新的 Vue CLI 插件并管理 npm 包依赖。

Vue.js DevTools

Vue.js DevTools 是一个适用于 Chrome 和 Firefox 的浏览器扩展,以及一个可以从你的电脑上运行的 Electron 桌面应用,可以帮助你调试本地运行的 Vue.js 项目。这些工具在生产和远程运行的项目中不起作用(例如,如果你提供了一个生产构建的项目或在线查看网站)。你可以从 Chrome 扩展页面下载 Vue.js DevTools 扩展,如下截图所示:

图 3.26:Vue.js DevTools Chrome 扩展页面

图 3.26:Vue.js DevTools Chrome 扩展页面

你也可以从 Firefox 下载 Vue.js DevTools 扩展(addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/):

图 3.27:Vue.js DevTools Firefox 扩展页面

图 3.27:Vue.js DevTools Firefox 扩展页面

DevTools 是 Vue 开发者的最佳伴侣,因为它们将在浏览器的开发者控制台中揭示您通常不会看到的有用信息。这包括 Vue 组件加载性能和跟踪 Vue 应用程序运行期间触发的事件。有几个选项卡,我们现在将查看。

组件 选项卡帮助您导航虚拟 < > 检查 DOM,这将直接带您到 Chrome 或 Firefox DOM 树中该组件的位置。使用以下 图 3.28 中的“选择”目标图标(左面板右上角)直接从浏览器 UI 中选择 Vue 元素。

您的屏幕应该看起来如下:

图 3.28:Vue.js DevTools 中的组件选项卡

图 3.28:Vue.js DevTools 中的组件选项卡

Vuex - 使用此选项卡,您可以导航 Vuex 的全局状态。您将看到 Vuex 存储中发生的突变记录,如下所示:

图 3.29:Vue.js DevTools 中的 Vuex 选项卡

图 3.29:Vue.js DevTools 中的 Vuex 选项卡

关于这一点,将在未来的章节中详细介绍。

事件 – 使用此选项卡,您可以导航从您的组件中发出的自定义事件。关于这一点,将在未来的章节中详细介绍。默认情况下,事件将记录如下截图所示:

图 3.30:Vue.js DevTools 中的事件选项卡

图 3.30:Vue.js DevTools 中的事件选项卡

路由 – 使用此选项卡,您可以在该面板中观察路由历史和事件。关于这一点,将在未来的章节中详细介绍。当路由事件发生时,它们将记录如下截图所示:

图 3.31:Vue.js DevTools 中的路由选项卡

图 3.31:Vue.js DevTools 中的路由选项卡

性能 – 使用此选项卡,您可以在应用程序运行时导航到记录组件帧率和渲染时间的性能区域,以优化最终用户体验。当您点击“开始”按钮以收集性能指标时,它们将以蓝色条的形式显示,如下截图所示:

图 3.32:Vue.js DevTools 中的性能选项卡

图 3.32:Vue.js DevTools 中的性能选项卡

图 3.32 中的蓝色柱状图表示加载时间(毫秒)。

设置 – 使用此选项卡,您可以自定义 Vue.js DevTools 的体验,如下截图所示。对于新开发者,默认设置无需更改:

图 3.33:Vue.js DevTools 中的设置选项卡

图 3.33:Vue.js DevTools 中的设置选项卡

刷新 – 点击此按钮将刷新浏览器中的 Vue.js DevTools 实例。

我们现在已经了解了 Vue.js DevTools,这将帮助你在开发下一个 Vue 应用程序的组件时。接下来,我们将构建一个 Vue 组件,并使用 Vue.js DevTools 来检查代码并操作组件内部的数据本地状态。

练习 3.04:使用 DevTools 调试 Vue 应用程序

在这个练习中,你将构建一个使用你在前几章中探索的几个 Vue.js 模式的组件,并且你将使用 DevTools 来探索这些模式。确保你正在使用 Chrome 或 Firefox,并且已安装 DevTools。你将使用 Vue.js DevTools 来检查代码并操作组件内部的数据本地状态。

要访问此练习的代码文件,请参阅packt.live/3eLIcVe

  1. 导航到Exercise3.04项目文件夹,并在 VS Code 中打开它。在你的命令提示符中,通过运行yarn命令安装所需的脚本。

  2. yarn使用的相同命令提示符中,运行项目使用yarn serve

  3. 在你的浏览器中导航到localhost:8080,以便你可以查看以下步骤中做出的更改。

  4. App.vue中创建响应式数据,通过添加一个数据属性frameworkList,填充一个字符串数组,以及一个值为空字符串的input属性:

    <script>
    export default {
      data() {
        return {
          frameworkList: [
            'Vue',
            'React',
            'Backbone',
            'Ember',
            'Knockout',
            'jQuery',
            'Angular',
          ],
          input: '',
        }
      },
    }
    </script>
    
  5. 接下来,创建一个名为computedList的计算属性,用于使用input属性值筛选frameworkList属性:

      ...
      computed: {
        computedList() {
          return this.frameworkList.filter(item => {
            return item.toLowerCase().includes(this.input.          toLowerCase())
          })
        },
      },
      ...
    
  6. 在 Vue 的template块中,添加一个使用 v-model 绑定到input数据属性并循环computedListinput字段。添加一些样式(可选):

    <template>
      <div id="app" class="container">
        <h1>Vue devtools debugging</h1>
        <input type="text" placeholder="Filter list" v-model=      "input" />
        <ul>
          <li v-for="(item, i) in computedList" :key="i">{{ item }}
          </li>
        </ul>
      </div>
    </template>
    <style lang="scss" scoped>
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
      margin-top: 60px;
    }
    ul {
      max-width: 200px;
      margin: 0 auto;
      list-style: none;
      padding: 0;
      > li {
        background: #42b983;
        color: white;
        padding: 6px;
        border-radius: 6px;
        margin-bottom: 2px;
        max-width: 200px;
      }
    }
    input {
      padding: 10px 6px;
      margin: 20px 10px 10px 10px;
    }
    </style>
    

    上述代码将生成以下截图:

    图 3.34:检查点 – 你的列表是可筛选的

    图 3.34:检查点 – 你的列表是可筛选的

  7. 在你的浏览器中,你可以查看你的应用,右键点击并选择Inspect以打开开发者控制台或使用快捷键 Ctrl + Shift + J(Mac 用户:Cmd + Shift + J)并导航到Vue标签。这将生成以下截图:图 3.35:Vue.js DevTools 的 Chrome 扩展

    图 3.35:Vue.js DevTools 的 Chrome 扩展

  8. 默认情况下,你将在Components标签中。选择Anonymous Component以检查与该组件关联的数据。点击到Filter list输入字段并输入V。你会观察到两个事情发生:在右侧面板中,数据属性input现在具有值V和计算列表。computedList现在只包含字符串Vue。在浏览器中,这些数据将在 UI 中反映出来,如图 3.36所示:图 3.36:Vue.js DevTools 的 Chrome 扩展

    图 3.36:Vue.js DevTools 的 Chrome 扩展

  9. 通过点击 输入 属性旁边的 铅笔 图标直接在右侧面板中编辑数据,并输入 R。DOM 将响应式地更新,如以下截图所示,这是从 DevTools 直接更改输入属性所做的直接更改:图 3.37:在您的 Vue 项目中编辑实时值非常简单

    图 3.37:在您的 Vue 项目中编辑实时值非常简单

    在更改 Vue.js DevTools 中的值后,UI 中的值将响应式地改变,在这个例子中,输入值现在是 R,然后触发响应式的 computedList 数组只显示包含字母 r 的值,如 图 3.38 所示:

    图 3.38:计算列表更新到 DevTools 中写入的值

    图 3.38:计算列表更新到 DevTools 中写入的值

  10. 前往 性能 选项卡,点击 组件渲染 开关,然后点击 开始 按钮。当它运行时,在输入框中输入几个项目,例如 A,然后 B,然后 V。当您在输入框中输入文本时,您将看到性能指标作为蓝色条形,如下面的截图所示:图 3.39:计算列表更新到 DevTools 中写入的值

    图 3.39:计算列表更新到 DevTools 中写入的值

  11. 点击 停止 并观察 组件渲染 选项卡中的 毫秒 计时,这反映了您的组件加载所需的时间,如下面的截图所示:图 3.40:在右侧面板中选择组件将打开    左侧的生命周期钩子

图 3.40:在右侧面板中选择组件将打开左侧的生命周期钩子

注意

重复测试将允许您比较基准,然而,如果您刷新页面,您将丢失它们。

在这个练习结束时,您已经看到了如何使用 Vue.js DevTools 通过 组件 选项卡导航 Vue 应用程序中的基本组件。您知道如何在 DevTools 中观察和编辑数据,因为您已经看到计算属性会响应您的数据属性更改。您知道 性能 选项卡在哪里,以及如何在创建 Vue 应用程序时使用它。

活动 3.01:使用 Vue-UI 和 Vuetify 组件库构建 Vue 应用程序

在这个活动中,您将使用命令行构建一个 Vue 项目,然后将其导入到 Vue-UI 中,并比较安装 Vuetify 之前后的构建大小。这将测试您控制可用的各种 Vue 工具的能力,并突出您在实际场景中使用这些工具的情况。

以下步骤将帮助您完成活动:

  1. 使用 Vue CLI 创建一个新的项目,并使用 Babel 预设。

  2. 使用 Vue-UI 导入您新创建的项目。

  3. 使用 VueUI 安装 Vuetify 插件,并在项目中使用 Vuetify 的 Vue 组件。

  4. 从 Vuetify 网站复制一个预制的布局,或者使用他们的组件构建自己的布局:vuetifyjs.com/en/getting-started/pre-made-layouts

预期结果如下:

![图 3.41:最终结果图 3.41

图 3.41:最终结果

这个活动还有一个可切换的菜单,如图 3.42 所示:

![图 3.42:显示可切换菜单的输出图 3.42

图 3.42:显示可切换菜单的输出

注意

这个活动的解决方案可以通过这个链接找到。

活动完成后,你应该能够使用 Vue CLI 和 Vue-UI 来管理你未来的 Vue 项目。你会发现,在某些情况下,这两个工具可以互换使用,或者根据你更舒适的方式结合使用。

摘要

在本章中,你被介绍了多种 Vue.js 工具,这些工具可以帮助你维护和管理你的 Vue 应用程序。你从命令行和新的 Vue-UI 创建了 Vue.js 项目,安装了新的依赖项,并从这两个界面中提供了服务和构建了你的项目。你可以一起使用这些工具或单独使用—— whichever you feel more comfortable with。Vue.js DevTools 拥有许多提高生活质量的特性,这些特性将帮助你通过本书的高级部分,并在你开始在组件和路由页面之间传递 props 时提供帮助。

在下一章中,你将学习更多高级的 Vue 组件概念,例如通过使用数据 props 和模板插槽在不同组件之间传递和验证信息。

第四章:4. 组件嵌套(模块化)

概述

在本章中,你将发现如何使用组件层次结构和嵌套来模块化 Vue.js 应用程序。本章介绍了 props、events、prop 验证和 slots 等概念。你将学习如何对比它们并根据情况确定应该应用哪个概念。然后,你将练习实现一个使用 refs 封装直接 DOM 操作的组件。你还将学习如何识别可以使用 slots、命名 slots 和作用域 slots 的组件组合场景。然后,你将确定何时将功能抽象为过滤器。

到本章结束时,你将能够使用 props、events 和验证器定义组件之间的通信接口。你将接触到如何将 JavaScript 库作为 Vue.js 组件封装以及在使用组件时 Vue.js 上下文的潜在问题。

简介

在上一章中,我们学习了如何初始化、构建和调试一个简单的 Vue.js 应用程序。在本章中,我们将更深入地了解如何利用组件组合来实现代码重用。

可重用和可扩展的组件是围绕组件库构建产品的核心。组件库允许团队以高速度和高一致性构建项目。

如果 Vue.js 中的组件库没有暴露正确的扩展点,通常会发生的情况是将库中的组件复制到应用程序的代码库中。这导致了代码的重复和从设计角度的凝聚力降低。

第三章Vue CLI 中,我们学习了如何在 Vue 中创建简单组件。组件是 Vue 实例,可以被实例化和渲染多次。由于只能有一个根组件,应用程序中的大多数组件都是由另一个组件渲染的。为了使父组件与其子组件通信,我们使用 props 和 prop 传递。

传递 Props

this) 并在组件的 template 中。

prop 的值取决于父组件在渲染时传递给子组件的 template 中的内容。

定义一个接受 Props 的简单组件

让我们看看一个简单的 Hello 单文件组件。这可以在 ./src/components/Hello.vue 文件中找到(在一个 Vue CLI 生成的 项目中)。注意 who 值是如何在 props 数组中设置的,并且它是通过使用 {{ who }} 作为值进行插值的。Vue.js 组件的 props 属性可以是字符串数组或对象字面量。

当在 props 中定义一个值时,它随后在 Vue.js 组件的 template 部分作为一个实例变量可访问:

<template>
  <div>
    <h1>Hello {{ who }}</h1>
  </div>
</template>
<script>
export default {
  props: ['who']
}
</script>

现在,我们将学习如何使用 props 渲染组件。

使用 Props 渲染组件

接下来是一个如何在我们 Vue.js 应用程序中使用 Hello 组件的演示。

首先,我们需要导入,然后将其设置在想要渲染此导入组件的 Vue.js 组件的 components 属性中。

然后,在template部分,我们需要将<Hello>渲染出来,并将who属性设置为"Vue.js",如下所示:

<template>
  <div id="app">
    <Hello who="Vue.js"/>
  </div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
  components: {
    Hello
  }
}
</script>

这将在页面上渲染以下内容:

Hello Vue.js

我们现在已经看到了如何在 Vue.js 应用程序中使用组件并通过 props 传递它。这对于代码重用和将应用程序行为抽象为组件大小的块非常有用。

接下来,我们将学习如何与已注册的组件一起工作。

组件注册技巧

关于components属性,有几个需要注意的事项。

已注册的组件既可以用CamelCaseName格式,也可以用kebab-case-name格式,因此如果我们把前一个示例中的模板部分改为使用<hello />而不是<Hello />,它将无任何问题地工作:

<template>
  <div id="app">
    <hello who="Vue.js"/>
  </div>
</template>

更新的模板在浏览器中渲染相同的内容,如下所示输出:

Hello Vue.js

components属性倾向于使用 ES6 简写属性语法。简写属性语法意味着我们不需要写{ Hello: Hello },而是可以写{ Hello }。我们可以在以下示例中看到它的实际应用,该示例注册了Hello组件:

import Hello from './components/Hello.vue'
export default {
  components: {
    Hello
  }
}

Vue 的components声明不知道组件的名称。它使用components对象中的键来注册,无论是驼峰式还是短横线命名法:

<template>
  <div id="app">
    <Hey who="Vue.js"/>
  </div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
  components: {
  Hey: Hello
  }
}
</script>

上述代码将生成以下输出:

Hello Vue.js

我们现在已经学会了如何在 Vue.js 中使用components属性和 ES6 简写对象属性语法来注册组件。

接下来,我们将查看一个Greeting组件的实际示例。

练习 4.01:实现问候组件

利用我们对如何从父组件向子组件传递 props 的知识,我们将创建一个组件,允许你自定义问候语(例如,HelloHeyHola)以及被称呼的对象(例如,WorldVue.jsJavaScript 开发者)。

要访问此练习的代码文件,请参阅packt.live/35jGd7B

按照以下步骤完成这个练习:

  1. ./src/components目录下创建一个名为Greeting.vue的新文件。这将是我们单文件组件。

  2. 首先,用空的templatescript标签搭建组件的框架:

    <template>
      <div>Empty</div>
    </template>
    <script>
    export default {}
    </script>
    
  3. 接下来,我们需要告诉 Vue.js 我们的组件期望 props。为此,我们将在组件定义(在script部分的export default设置的 object)中添加一个props属性,并向其中添加一个greetingwho属性:

    export default {
      props: ['greeting', 'who']
    }
    
  4. 现在,我们想要渲染greetingwho。正如我们所见,当在props中定义值时,它们在template的最高级别中可用:

    <template>
      <div>{{ greeting }} {{ who }}</div>
    </template>
    

    我们现在可以渲染App.vue中的Greeting组件。

  5. 打开src/App.vue文件,并将Greeting组件从./src/components/Greeting.vue导入到script部分:

    <script>
    import Greeting from './components/Greeting.vue'
    </script>
    
  6. 接下来,在components中注册Greeting组件:

    <script>
    export default {
      components: {
        Greeting
      }
    }
    </script>
    
  7. 现在组件已经注册,我们可以在template中渲染它:

    <template>
      <div id="app">
        <Greeting greeting="Hey" who="JavaScript"/>
      </div>
    </template>
    

    你将在浏览器中看到以下内容(确保你在 Exercise4.01 目录中运行了 npm installnpm run serve):

    Hey JavaScript
    
  8. 使用 template 中的属性值修改 greetingwho 属性:

    <template>
      <div id="app">
        <Greeting greeting="Hi" who="Everyone"/>
      </div>
    </template>
    

    运行前面的代码后,你应该在浏览器中看到以下类似的输出(确保你在 Exercise4.01 目录中运行了 npm install,然后运行 npm run serve):

    Hi Everyone
    

在这个练习中,我们学习了如何使用属性和属性传递来通过泛化组件来增加组件的重用场景。组件不是渲染静态数据,而是其父组件传递数据以进行渲染。

在下一节中,我们将学习如何动态设置属性值。

动态属性与数据绑定

我们到目前为止看到的示例都使用了硬编码的属性值作为属性。但如果我们想从父组件传递实例数据到子组件怎么办?

这就是 v-bind: 的用法,但你也可以使用 : 作为缩写;它们是等价的。

Greeting 组件的 who 属性绑定到 appWho 应用组件的实例属性:

<template>
  <div id="app">
    <Hello v-bind:who="appWho"/>
  </div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
  components: {
    Hello
  },
  data() {
    return {
      appWho: 'Vue.js'
    }
  }
}
</script>

简写形式下,template 将如下所示:

<template>
  <div id="app">
    <Hello :who="appWho"/>
  </div>
</template>

两种版本都将输出以下视图到浏览器:

Hello Vue.js

注意

v-bind:prop-name:prop-name 有惊人的相似之处,因为 v-bindprop-name 之间的分隔符是 :(一个分号)。在 Vue.js 单文件组件中,由于模板在构建时编译,它们在功能上是等价的。

以下是一个示例,展示如何将值从父组件(App)传递到子组件(Hello),其中包含两个按钮,用于更改 Hello 消息的受众。

按钮调用一个名为 setWho 的组件方法,setWho 函数更新 appWho 实例属性:

<template> 
  <div id="app">
    <Hello :who="appWho"/>
    <button @click="setWho('JavaScript')">JavaScript</button>
    <button @click="setWho('Everyone')">Everyone</button>
  </div>
</template>
<script>
import Hello from './components/Hello.vue'
export default {
  components: {
    Hello
  },
  data() {
    return {
      appWho: 'Vue.js'
    }
  },
  methods: {
    setWho(newWho) {
      this.appWho = newWho
    }
  }
}
</script>

初始输出到浏览器的显示为 Hello Vue.js,如下面的截图所示:

图 4.1:浏览器中初始的 Hello Vue.js 输出

图 4.1:浏览器中初始的 Hello Vue.js 输出

当点击 JavaScript 按钮,appWho 变量更新,绑定的 Hello 组件的 who 属性也会更新。因此,显示为 Hello JavaScript,如下所示:

图 4.2:点击 JavaScript 按钮后的 Hello JavaScript

图 4.2:点击 JavaScript 按钮后的 Hello JavaScript

当点击 Everyone 按钮,appWho 变量更新,绑定的 Hello 组件的 who 属性也会更新。因此,显示为 Hello Everyone,如下所示:

图 4.3:点击 Everyone 按钮后的 Hello Everyone

图 4.3:点击 Everyone 按钮后的 Hello Everyone

我们现在已经看到了如何将属性绑定到值,以便它们保持同步。

大多数 Vue.js 应用程序利用组件不仅用于模块化渲染组件(正如我们在 GreetingHello 组件中所做的那样),还用于其他方面。

正如我们所看到的,我们能够绑定属性,以便对父组件中任何值的更新都会导致子组件的更新。

使用Greeting组件渲染当前的greetingwho

到目前为止,应用程序在浏览器中应显示相同的问候语,如下面的输出所示:

要访问此练习的代码文件,请参阅packt.live/3kovKfo

让我们重构data方法,使其只存储默认索引并创建查找索引以生成基于当前索引的greetingwho的计算属性(使用中间的currentGreeting计算属性):

  1. 浏览器将显示一条消息,如下所示(确保你在Exercise4.02目录中运行了npm installnpm run serve):

    <template>
      <div>{{ greeting }} {{ who }}</div>
    </template>
    <script>
    export default {
      props: ['greeting', 'who']
    }
    </script>
    
  2. ./src/App.vue组件中,将./src/components/Greeting.vue导入为Greeting组件,并注册为组件,以便你可以渲染它:

    <script>
    import Greeting from './components/Greeting.vue'
    export default {
      components: {
        Greeting
      }
    }
    </script>
    
  3. script部分,创建一个返回初始greetingwho的顶级data方法:

    export default {
      data() {
        return {
          greeting: 'Hello',
          who: 'Vue.js'
        }
      }
    }
    
  4. 图 4.5:在 3n + 1 次按钮点击后“嘿,大家好”

    <template>
      <div id="app">
        <Greeting :greeting="greeting" :who="who"/>
      </div>
    </template>
    

    要知道使用哪个问候语,我们将实现一个具有多个问候语并遍历它们的greeter应用程序。

    在初始加载和点击New Greeting按钮的3n次之后,应用程序显示Hello Vue.js,如下面的截图所示:

    ![图 4.4:在 3n 次按钮点击后“你好,Vue.js”]

  5. 注意

    <script>
    // imports
    const possibleGreetings = [
        { greeting: 'Hello', who: 'Vue.js' },
        { greeting: 'Hey', who: 'Everyone' },
        { greeting: 'Hi', who: 'JavaScript' }
    ]
    // components export
    </script>
    
  6. 由于计算属性可以清理代码,我们不需要更新我们的模板。相反,我们用同名的计算属性替换了greetingwho实例属性。

    <script>
    // imports and greetings
    export default {
      // components definition
      data() {
        return {
          currentIndex: 0
        }
      },
      computed: {
        currentGreeting() {
          return possibleGreetings[this.currentIndex]
        },
        greeting() {
          return this.currentGreeting.greeting
        },
        who() {
          return this.currentGreeting.who
        }
      }
    }
    </script>
    

    图 4.4:在 3n 次按钮点击后“你好,Vue.js”

    Hello Vue.js
    

    ![图 4.6:在 3n + 2 次按钮点击后“嗨,JavaScript”]

    我们现在需要在script部分实现newGreetingnewGreeting应该移动到下一个问候语(通过增加currentIndex)。或者,如果我们已经到达了possibleGreetings数组的末尾,它应该重置currentIndex

  7. 让我们添加一种遍历这些问候语的方法。这需要有一个按钮,点击后会在我们的template中调用一个newGreeting函数:

    <template>
      <div id="app">
        <Greeting :greeting="greeting" :who="who"/>
        <button @click="newGreeting()">New Greeting</button>
      </div>
    </template>
    
  8. 注意

    <script>
      // imports and greetings
    export default {
        // other component properties
      methods: {
        newGreeting() {
          this.currentIndex = this.currentIndex ===         possibleGreetings.length – 1
            ? 0
            : this.currentIndex + 1
        }
      }
    }
    </script>
    

    创建一个./src/components/Greeting.vue组件,并用我们之前实现的Greeting组件初始化它。这就是我们将显示问候语的方式:

    注意

    以下代码需要你了解我们已在第二章与数据一起工作中介绍的计算属性。如果你需要复习,现在请回到那一章。

练习 4.02:传递随时间变化的属性

在第一次点击和点击New Greeting按钮的3n + 1次之后,应用程序显示Hey Everyone,如下所示:

![图 4.5:在 3n + 1 次按钮点击后“嘿,大家好”]

![图 4.4:在 3n 次按钮点击后“你好,Vue.js”]

按照以下步骤完成此练习:

在第二次点击和点击New Greeting按钮的3n + 2次之后,应用程序显示Hi JavaScript,如下所示:

我们现在将一些greeting/who配对作为数组添加到script部分:

![图 4.6:在 3n + 2 次按钮点击后“嗨,JavaScript”]

图 4.6:在 3n + 2 次按钮点击后说“Hi JavaScript”

注意

这段代码可以进一步改进;例如,possibleGreetings.length - 1是常量,因为我们从未添加或删除问候语。我们可以在newGreeting方法之外,仅计算一次,而不是在每次newGreeting调用时计算。读取数组的长度和简单的算术(-1)并不太昂贵,但这是对可变值与常量值思考方式的好复习。

通过这样,我们已经看到了如何使用属性和属性绑定来从父组件向它们渲染的子组件传递变化的数据。为了扩展代码库或广泛共享代码,当用户使用代码不正确时,给出提示是有帮助的。

接下来,我们将学习如何为组件的属性添加类型提示以确保它们被正确使用。

属性类型和验证

属性定义了 Vue.js 组件的接口。由于 JavaScript 是一种动态类型语言,Vue.js 提供了一个我们可以用来验证属性形状和类型的工具。

要验证属性类型,应使用其对象字面量形式的props组件属性(而不是更简单的数组形式)。

原始属性验证

假设我们想要一个Repeat.vue组件,它接受一个times属性,以及一个content属性。我们可以定义以下内容:

<template>
  <div>
    <span v-for="r in repetitions" :key="r">
      {{ content }}
    </span>
  </div>
</template>
<script>
export default {
  props: ['times', 'content'],
  computed: {
    repetitions() {
      return Array.from({ length: this.times });
    }
  }
}
</script>

我们的Repeat组件可以这样使用:

<template>
  <div id="app">
    <Repeat :times="count" content="Repeat." />
    <button @click="increment()">Repeat</button>
  </div>
</template>
<script>
import Repeat from './components/Repeat.vue'
export default {
  components: {
    Repeat
  },
  data() {
    return { count: 1 }
  },
  methods: {
    increment() {
      this.count += 1
    }
  }
}
</script>

上述代码将在浏览器中产生以下输出:

![图 4.7:重复示例动作输出(无点击)]

img/B15218_04_07.jpg

图 4.7:重复示例动作输出(无点击)

点击Repeat按钮几次后,Repeat组件将每次点击额外重复一次,生成如下输出:

![图 4.8:重复示例在点击五次后的输出]

img/B15218_04_08.jpg

图 4.8:重复示例在点击五次后的输出

为了使该组件正常工作,我们需要times是一个Number类型,理想情况下content是一个String类型。

注意

现在是提醒学生 JavaScript 原始类型的好时机:StringNumberBooleanArrayObjectDateFunctionSymbol

Vue.js 支持在props字段中使用所有 JavaScript 原始类型构造函数作为类型提示。

在这种情况下,我们将times属性定义为Number类型,将content属性定义为String类型:

<script>
export default {
  props: {
    times: {
      type: Number
    },
    content: {
      type: String
    }
  },
  // rest of component definition
}
</script>

要使用此组件,我们可以更新script部分如下:

<script>
import Repeat from './components/RepeatTyped.vue'
// no other changes
</script>

timescontent分别是NumberString类型时,组件的行为仍然相同。

如果我们更新App使其故意传递错误类型的属性。在这种情况下,times是一个String类型,而content是一个Number类型。

<template>
  <div id="app">
    <Repeat :times="count" :content="55" />
  </div>
</template>
<script>
// no changes to imports
export default {
  data() {
    return { count: 'no-number-here' }
  },
  // other properties
}
</script>

在这里,Repeat组件将无法渲染,以下错误将被记录到控制台:

![图 4.9:Vue.js 属性输入错误示例]

img/B15218_04_09.jpg

图 4.9:Vue.js 属性输入错误示例

times 属性检查失败,消息解释说我们传递了一个本应为 NumberString 属性:

Invalid prop: type check failed for prop "times". Expected Number with value NaN, got String with value "no-number-here"

content 属性检查失败,消息解释说我们传递了一个本应为 StringNumber 属性:

Invalid prop: type check failed for prop "content". Expected String with value "55", got Number with value 55

注意

根据 Vue.js 文档,null 和 undefined 值将通过任何类型验证,这意味着类型验证并不是万无一失的,因此为组件添加自定义验证是有意义的。

联合和自定义属性类型

在前面的例子中,我们只是渲染了内容,所以类型是什么并不重要。

Vue.js 支持联合类型。联合类型是一种可以是许多其他类型之一的类型。例如,StringNumber 是联合类型。

在 Vue.js 中,联合类型使用数组来表示属性 type 属性,例如,为了支持 content 作为数字和字符串:

<script>
export default {
  props: {
    // other prop definitions
    content: {
      type: [String, Number]
    }
  }
  // rest of component definition
}
</script>

在这种情况下,我们可以无错误地消费 RepeatTyped 组件,如下所示:

<template>
  <div id="app">
    <Repeat :times="3" :content="55" />
  </div>
</template>

这会显示 55 三次。在这里,55 被作为 Number 传递,而我们的组件现在支持这一点。这可以在以下输出中看到:

55 55 55

任何有效的构造函数都可以用作属性类型。例如,Promise 或自定义的 User 构造函数可以用来。在以下示例中,我们定义了一个 TodoList 组件属性接口:

<script>
import User from './user.js'
export default {
  props: {
    todoListPromise: {
      type: Promise
    },
    currentUser: {
      type: User
    }
  }
}
</script>

该组件公开的属性接口可以如下使用:

<template>
  <div>
    <template v-if="todosPromise && !error">
      <TodoList
        :todoListPromise="todosPromise"
        :currentUser="currentUser"
      />
    </template>
    {{ error }}
  </div>
</template>
<script>
import TodoList from './components/TodoList.vue'
import User from './components/user.js'
const currentUser = new User()
export default {
  components: {
    TodoList
  },
  mounted() {
    this.todosPromise = fetch('/api/todos').then(res => {
      if (res.ok) {
        return res.json()
      }
      throw new Error('Could not fetch todos')
    }).catch(error => {
      this.error = error
    })
  },
  data() {
    return { currentUser, error: null }
  }
}
</script>

我们已经看到了如何使用 unioncustom 类型来验证 Vue.js 属性。

注意

Vue.js 在内部使用 instanceof,所以请确保任何自定义类型都是使用相关构造函数实例化的。

传递 nullundefined 将会失败 instanceofArrayObject 的检查。

传递一个数组将通过 instanceofObject 的检查,因为在 JavaScript 中,Array 实例也是 Object 实例。

使用验证器进行数组的自定义验证、对象形状等

Vue.js 允许使用 validator 属性将自定义验证器用作属性。这允许我们实现有关对象和数组形状的深度检查,作为原始类型的自定义逻辑。

为了说明这一点,让我们看看一个 CustomSelect 组件。在基本层面上,select 的属性接口包括一个 options 数组和一个 selected 选项。每个选项应该有一个 label,表示在选择中显示的内容,以及一个 value,它对应于传递给 API 的值。例如,selected 选项可以是空的,或者应该对应于 options 中的一个 value 字段。

我们可以将 CustomSelect 以以下简单方式实现(不验证输入):

<template>
  <select>
    <option
      :selected="selected === o.value"
      v-for="o in options"
      :key="o.value"
    >
      {{ o.label }}
    </option>
  </select>
</template>
<script>
export default {
  props: {
    selected: {
      type: String
    },
    options: {
      type: Array
    }
  }
}
</script>

然后,CustomSelect 可以用来显示 src/App.vue 中的列表):

<template>
  <div id="app">
    <CustomSelect :selected="selected" :options="options" />
  </div>
</template>
<script>
import CustomSelect from './components/CustomSelect.vue'
export default {
  components: {
    CustomSelect
  },
  data() {
    return {
      selected: 'salt-vinegar',
      options: [
        {
          value: 'ready-salted',
          label: 'Ready Salted'
        },
        {
          value: 'cheese-onion',
          label: 'Cheese & Onion'
        },
        {
          value: 'salt-vinegar',
          label: 'Salt & Vinegar'
        },
      ]
    }
  }
}
</script>

前面的应用程序输出了一个选择,其中 Salt & Vinegar 是默认选中选项,如下面的截图所示:

![图 4.10:折叠的 CustomSelect,选择了 Salt & Vinegar]

图片 B15218_04_10.jpg

图 4.10:折叠的 CustomSelect,选择了 Salt & Vinegar

以下截图显示了三种口味选项,其中一个是被选中的:

图 4.11:打开带有口味选项和盐味芥末的 CustomSelect

图 4.11:打开带有口味选项和盐味芥末的 CustomSelect

为了进一步验证我们关于形状选项的业务逻辑,我们可以实现以下属性验证器:

<script>
export default {
  // other component properties
  props: {
    // other prop definitions
    options: {
      type: Array,
      validator(options) {
        return options.every(o => Boolean(o.value && o.label))
      }
    }
  }
}
</script>

如果我们传递一个缺少valuelabel的选项,我们将在控制台得到以下消息:

图 4.12:当自定义验证器失败时 Vue.js 的警告

图 4.12:当自定义验证器失败时 Vue.js 的警告

通过这些,我们已经学习了如何使用自定义 Vue.js 验证器对复杂属性进行深入检查。接下来,我们将学习required属性类型属性是如何工作的。

必需属性

要将 Vue.js 属性标记为必需,我们可以使用required属性类型属性。

CustomSelect示例中,我们可以将selected属性标记为必需。

要做到这一点,我们需要修改属性定义,使其包括required: true,如下所示:

<script>
export default {
  // other component properties
  props: {
    selected: {
      type: String,
      required: true
    }
    // other prop definitions
  }
}
</script>

现在,如果我们修改CustomSelect的消费者,使其不传递selected属性,我们将看到以下错误:

图 4.13:当选定的必需属性缺失时 Vue.js 的警告

图 4.13:当选定的必需属性缺失时 Vue.js 的警告

通过这些,我们已经学习了如何标记 Vue.js 属性为必需,以及当不传递必需属性时会发生什么。接下来,我们将学习将属性默认为最佳选择。

默认属性

有时候,将属性默认为最佳组件接口。

这的一个例子是一个PaginatedList组件,它接受一个列表并根据limitoffset参数显示该列表的子集。在这种情况下,我们可能最好将limit默认为25,将offset默认为0(默认情况下,我们显示第一页,包含25个结果)。

这是我们如何实现没有默认值的PaginatedList组件的示例:

<template>
  <ul>
    <li
      v-for="el in currentWindow"
      :key="el.id"
    >
      {{ el.content }}
    </li>        
  </ul>
</template>
<script>
export default {
  props: {
    items: {
      type: Array
    },
    limit: {
      type: Number
    },
    offset: {
      type: Number
    }
  },
  computed: {
    currentWindow() {
      return this.items.slice(this.offset, this.limit)
    }
  }
}
</script>

我们可以使用以下代码来使用它:

<template>
  <div id="app">
    <PaginatedList :items="snacks" :offset="offset" :      limit="limit"/>
    <button @click="offset++">
      Increment Offset (current: {{ offset }})
    </button>
    <button @click="limit++">
      Increment Limit (current: {{ limit }})
    </button>
  </div>
</template>
<script>
import PaginatedList from './components/PaginatedList.vue'
export default {
  components: {
    PaginatedList
  },
  data() {
    return {
      offset: 0,
      limit: 0,
      snacks: [
        {
          id: 'ready-salted',
          content: 'Ready Salted'
        },
        {
          id: 'cheese-onion',
          content: 'Cheese & Onion'
        },
        {
          id: 'salt-vinegar',
          content: 'Salt & Vinegar'
        },
      ]
    }
  }
}
</script>

通过将限制增加到 3,我们可以显示整个列表,如下所示:

Hello Vue.js

然后,通过增加偏移量,我们可以跳过列表中的前X个元素。以下截图显示了PaginatedList

图 4.14:带有限制 3 和偏移 1 的 PaginatedList

图 4.14:带有限制 3 和偏移 1 的 PaginatedList

现在,为了使我们的PaginatedList具有弹性,我们将默认limit25,将offset默认为0。为此,我们可以设置相关属性的default属性:

<script>
export default {
  props: {
    // other props
    limit: {
      type: Number,
      default: 25,
    },
    offset: {
      type: Number,
      default: 0,
    }
  },
  // other component properties
}
</script>

使用这些默认值,我们将默认从列表开头显示25个条目。

在数组或对象的情况下,default存在一个问题(例如,如果我们想将items默认),根据 Vue.js 文档;即,“对象或数组默认必须从工厂函数返回”。

factory function 是一个函数——在这种情况下,称为 default——它返回我们想要的默认值。

items 的情况下,我们可以编写以下内容:

<script>
export default {
  props: {
    items: {
      type: Array,
      default() {
        return []
      }
    }
    // other props
  },
  // other component properties
}
</script>

通过这样,我们已经学习了如何设置 Vue.js 组件属性的默认值。当我们希望为可选参数提供值,以便 Vue.js 组件实现不需要处理默认属性值时,这很有帮助。

练习 4.03:验证对象属性

在这个练习中,我们将重写 Repeat 组件,使其支持单个 config 属性,用于传递 times(一个数字)和 content(一个字符串)。

我们将不得不编写一个自定义验证器来确保 timescontent 存在且类型正确。

要访问此练习的代码文件,请参阅 packt.live/2Ui1hVU

按照以下步骤完成此练习:

  1. 我们希望我们的 src/components/Repeat.vue 组件支持一个 config 属性。这将是一个 Object,它产生以下 <script>

    <script>
    export default {
      props: {
        config: {
          type: Object
        }
      }
    }
    </script>
    
  2. 接下来,我们希望当传递 config 时渲染某些内容。为此,我们将创建一个数组,通过 v-for 遍历一个计算机属性。数组的长度将基于 config.times 的值:

    <script>
    export default {
      // other component properties
      computed: {
        repetitions() {
          return Array.from({ length: this.config.times })
        }
      }
    }
    </script>
    
  3. 下一步是设置 <template> 以渲染 config.content,对于每个 repetitions 项目:

    <template>
      <div>
        <span v-for="r in repetitions" :key="r">
          {{ config.content }}
        </span>
      </div>
    </template>
    
  4. 目前,我们正在确保 contenttimes 已设置且类型正确。为此,我们将在配置属性 validator 中实现 typeof 检查:

    <script>
    export default {
      props: {
        config: {
          type: Object,
          validator(value) {
            return typeof value.times === 'number' &&
              typeof value.content === 'string'
          }
        }
      },
      // other component properties
    }
    </script>
    
  5. 最后,我们可以在 src/App.vue 中消耗 Repeat。我们需要导入它,在 script 中注册它,并在 template 中渲染它:

    <template>
      <div id="app">
        <Repeat :config="{}" />
      </div>
    </template>
    <script>
    import Repeat from './components/Repeat.vue'
    export default {
      components: {
        Repeat
      }
    }
    </script>
    

    很遗憾,这不会渲染任何内容,因为 config 是一个空对象。你将观察到以下警告:

    图 4.15:由于配置属性的定制验证器检查失败而导致的 Vue.js 警告

    图 4.15:由于配置属性的定制验证器检查失败而导致的 Vue.js 警告

    我们将在以下情况下看到相同的错误:

    a) 我们只添加一个 times 属性;即 <Repeat :config="{ times: 3 }" />

    b) 我们只添加一个 content 属性;即 <Repeat :config="{ content: 'Repeat me.' }" />

    c) times 的类型不正确;即 <Repeat :config="{ times: '3', content: 'Repeat me.' }" />

    d) content 属性的类型不正确;即 <Repeat :config="{ times: 3, content: 42 }" />

  6. 为了使 Repeat 正确工作,我们可以修改在 template 中消耗它的行,如下所示:

    <Repeat :config="{ times: 3, content: 'Repeat me.' }" />
    

    这在控制台中没有显示错误,并渲染 Repeat me. 三次,如下所示:

    Repeat me. Repeat me. Repeat me.
    

通过这样,我们已经展示了如何验证属性以更好地定义 Vue.js 组件的接口。

接下来是对 slots 的深入探讨,这是一种机制,我们可以通过延迟模板逻辑来组合我们的组件。

插槽、命名插槽和作用域插槽

另一个在 Vue.js 中实现可重用性的组件组合模式是 slots

插槽是组件中的部分,其中模板/渲染被委托回组件的消费者。

在这里,props 可以被视为从父组件传递给子组件的数据,以便子组件执行某些逻辑或进行渲染。

插槽可以被视为从父组件传递给子组件以进行渲染的模板或标记。

将标记传递给子组件进行渲染

最简单的插槽类型是默认的 child 插槽。

我们可以定义一个具有插槽的 Box 组件如下。请注意,这个 Box 组件做得很少:

<template>
  <div>
    <slot />
  </div>
</template>

以下标记用于父组件(src/App.vue):

<template>
  <div>
    <Box>
      <h3>This whole h3 is rendered in the slot</h3>
    </Box>
  </div>
</template>
<script>
import Box from './components/Box.vue'
export default {
  components: {
    Box
  }
}
</script>

在浏览器中,前面的代码将如下所示:

This whole h3 is rendered in the slot

Vue 单文件组件的作用域中的 template 部分,是用父组件的作用域编译的。

考虑以下示例:

<template>
  <div>
    <Box>
      <h3>This whole h3 is rendered in the slot with parent count {{         count }}</h3>
    </Box>
    <button @click="count++">Increment</button>
  </div>
</template>
<script>
import Box from './components/Box.vue'
export default {
  components: {
    Box
  },
  data() {
    return { count: 0 }
  }
}
</script>

前面的代码将根据父组件中的 count 值渲染 count。它无法访问 Box 实例数据或 props,并将生成以下输出:

图 4.16:初始的 h3,计数为 0,根据父组件的初始数据

图 4.16:初始的 h3,计数为 0,根据父组件的初始数据

增加计数确实会更新模板,正如我们预期的那样,如果模板中的变量绑定到父组件上的数据。这将生成以下输出:

图 4.17:在父组件的作用域中计数增加五次后的 h3,计数为 5

图 4.17:在父组件的作用域中计数增加五次后的 h3,计数为 5

插槽是将子组件的一部分渲染委托给父组件的方式。任何对实例属性、数据或方法的引用都将使用父组件实例。这种类型的插槽无法访问子组件的属性、props 或数据。

在下一节中,我们将探讨如何使用命名插槽来渲染多个部分。

使用命名插槽来委托多个部分的渲染

命名插槽用于当子组件需要能够将多个部分的模板委托给父组件时。

例如,一个 Article 组件可能会将 headerexcerpt 的渲染委托给其父组件。

在这种情况下,这将在 Article.vue 文件中如下所示。命名插槽是具有 name 属性的 slot 条目,表示插槽的名称:

<template>
  <article>
    <div>Title: <slot name="title" /></div>
    <div>Excerpt: <slot name="excerpt" /></div>
  </article>
</template>

通过这样做,您可以在另一个组件中消费此组件。

为了传递插槽的内容,我们使用 v-slot:name 指令(其中 name 应替换为插槽的名称)。

例如,对于名为 title 的插槽,我们将使用 v-slot:title,而对于 excerpt 插槽,我们将使用 v-slot:excerpt

<template>
  <div>
    <Article>
      <template v-slot:title>
        <h3>My Article Title</h3>
      </template>
      <template v-slot:excerpt>
        <p>First paragraph of content</p>
        <p>Second paragraph of content</p>
      </template>
    </Article>
  </div>
</template>
<script>
import Article from './components/Article.vue'
export default {
  components: {
    Article
  }
}
</script>

当在浏览器中看到前面的应用程序时,它将如下所示:

图 4.18:使用命名插槽渲染由父组件定义的模板

图 4.18:使用命名插槽渲染父组件定义的模板

如您所见,命名插槽确实渲染了预期的内容。

v-slot:slot-name 的简写语法是 #slot-name。我们可以重构我们的模板,以消费 Article 如下:

<template>
  <div>
    <Article>
      <template #title>
        <h3>My Article Title</h3>
      </template>
      <template #excerpt>
        <p>First paragraph of content</p>
        <p>Second paragraph of content</p>
      </template>
    </Article>
  </div>
</template>

v-slot 不能与原生元素一起使用。它只能使用 template 和组件。例如,以下 <template> 部分尝试在 h3 元素上设置 v-slot

<template>
  <div>
    <Article>
      <h3 v-slot:title>My Article Title</h3>
    </Article>
  </div>
</template>

这个模板将因为 v-slot 只能在组件或 <template> 上使用而失败,如下面的截图所示:

图 4.19:原生元素上的 v-slot – 编译错误

图 4.19:原生元素上的 v-slot – 编译错误

Vue.js 的早期版本允许使用另一种语法来表示命名插槽的内容(这在 Vue 2.6.0+ 中已被弃用)。而不是使用 v-slot:slot-name 指令样式,使用了 slot="slot-name"slot 语法被允许在原生元素、模板和组件上使用。

注意

适用于默认插槽的一切也适用于命名插槽。事实上,默认插槽是一个名为 default 的命名插槽。这意味着命名插槽也可以访问父实例,但不能访问子实例。

默认插槽只是一个名为 default 的插槽,并且由于在没有任何 nameslot 中默认使用,Vue.js 对其进行了特殊处理。

默认插槽隐式推断如下:

<template>
  <MyComponent>
    <template>Default template</template>
  </MyComponent>
</template>

默认插槽可以用简写槽表示法表示。

<template>
  <MyComponent>
    <template #default>Default template</template>
  </MyComponent>
</template>
The default slot can be denoted with longhand slot notation.
<template>
  <MyComponent>
    <template v-slot:default>Default template</template>
  </MyComponent>
</template>

我们已经看到,命名插槽允许组件将某些部分的模板委托给消费者,以及这些命名插槽如何有一个默认模板来处理命名插槽可选的情况。

在下一节中,我们将学习如何使用作用域插槽来封装属性传递逻辑。

使用作用域插槽封装属性传递逻辑

我们迄今为止探索的插槽类型只能访问它们声明的组件实例。

有时,让父组件决定渲染,同时让子组件以某种方式转换数据是有用的。这就是作用域插槽的用途。

一个 slot 元素通过使用 v-bind 或简写 : 绑定了一些属性。

在这种情况下,item 被绑定到 elel 是传递给此 PaginatedList 组件的 items 属性的一个元素:

<template>
  <ul>
    <li
      v-for="el in currentWindow"
      :key="el.id"
    >
      <slot :item="el" />
    </li>
  </ul>
</template>
<script>
export default {
  props: ['items', 'limit', 'offset'],
  computed: {
    currentWindow() {
      return this.items.slice(this.offset, this.limit)
    }
  }
}
</script>

在消费者端(父组件),我们可以将槽模板视为带有所有绑定到子组件槽中的数据的对象被调用。因此,这些槽被称为 scoped;它们通过子组件定义的 scope 对象传递。

在这个例子中,我们可以这样消费 PaginatedList

<template>
  <div>
    <PaginatedList :items="snacks">
      <template #default="{ item }">
        {{ item.content }}
      </template>
    </PaginatedList>
  </div>
</template>

#default="{ item }" 是默认作用域插槽的简写表示法,允许我们将槽的作用域解构为 item

槽的模板定义的长版本如下:

<template v-slot="slotProps">
  {{ slotProps.item.content }}
</template>

item随后用于渲染{{ item.content }}。带有要渲染的零食的script部分如下:

<script>
import PaginatedList from './components/PaginatedList.vue'
export default {
  components: {
    PaginatedList
  },
  data() {
    return {
      snacks: [
        {
          id: 'ready-salted',
          content: 'Ready Salted'
        },
        {
          id: 'cheese-onion',
          content: 'Cheese & Onion'
        },
        {
          id: 'salt-vinegar',
          content: 'Salt & Vinegar'
        },
      ]
    }
  }
}
</script>

在浏览器中,我们得到以下输出:

图 4.20:使用作用域插槽显示的零食,意味着渲染逻辑在父组件中

图片

图 4.20:使用作用域插槽显示的零食,意味着渲染逻辑在父组件中

通过这样,我们已经学会了作用域插槽如何使组件具有更大的灵活性,可以将模板逻辑委托给消费者。

注意

作用域插槽还有一个已弃用的(自 Vue.js 2.6.0+起)slot-scope语法。v-slot:name="slotProps"的弃用等效语法是slot="name" slot-scope="slotProps"。有关更多信息,请参阅 Vue.js 文档:vuejs.org/v2/guide/components-slots.html#Scoped-Slots-with-the-slot-scope-Attribute

现在,让我们学习如何借助这些命名插槽实现卡片组件。

练习 4.04:使用命名插槽实现卡片组件

在这个练习中,我们将使用命名插槽实现一个卡片组件。卡片将包含标题、图片和描述部分。我们将使用插槽允许父组件定义titleimagedescription

要访问此练习的代码文件,请参阅packt.live/2UhLxlK

按照以下步骤完成此练习:

  1. 我们将首先创建一个新的src/components/Card.vue组件,该组件有一个支持三个插槽的模板——titleimagedescription

    <template>
      <div>
        <slot name="image" />
        <slot name="title" />
        <slot name="description" />
      </div>
    </template>
    
  2. 然后,我们将Card.vue组件导入到新src/App.vue文件的script部分:

    <script>
    import Card from './components/Card.vue'
    export default {
      components: {
        Card
      }
    }
    </script>
    
  3. 我们现在可以在template中使用Card

    <template>
      <div id="app">
        <Card>
          <template #image>
            <img src="img/300" />
          </template>
          <template #title>
            <h2>My Holiday picture</h2>
          </template>
          <template #description>
            <p>Here I can describe the contents of the picture.</p>
            <p>For example what we can see in the photo is a nice           landscape.</p>
          </template>
        </Card>
      </div>
    </template>
    

    现在,我们可以使用npm run serve启动vue-cli dev服务器,并查看Card组件的实际效果。输出将如下所示:

    图 4.21:带有图片、标题和描述的卡片组件

    图片

图 4.21:带有图片、标题和描述的卡片组件

通过这样,我们已经学会了不同类型的插槽如何帮助创建更通用的组件。插槽允许子组件将自身某些部分的渲染推迟到父组件(消费者)。

要在单个模板中重用功能,我们可以使用过滤器。我们将在下一节学习如何使用它们。

使用过滤器共享模板逻辑

Vue.js 使用过滤器来共享模板逻辑。

过滤器可以在 mustache 插值({{ interpolatingSomething }})或表达式中使用(例如,在绑定值时)。filter是一个函数,它接受一个值并输出可以渲染的内容(通常是StringNumber)。

因此,一个名为truncate的示例过滤器可以在模板中使用,如下所示(这里我们放置了一些长占位文本):

<template>
  <div id="app">
    {{ message | truncate }}
  </div>
</template>
<script>
export default {
  data() {
    return {
      message: 'Lorem ipsum dolor sit amet, consectetur adipiscing         elit, sed do eiusmod tempor incididunt ut labore et dolore         magna aliqua. Ut enim ad minim veniam, quis nostrud         exercitation llamco laboris nisi ut aliquip ex ea commodo         consequat. Duis aute irure dolor in reprehenderit in         voluptate velit esse cillum dolore eu fugiat nulla         pariatur. Excepteur sint occaecat cupidatat non proident,         sunt in culpa qui officia deserunt mollit anim id         est laborum.'
    }
  }
}
</script>

truncate也可以在 Vue.js 绑定表达式中使用。例如,<MessageComponent :msg="message | truncate">message的截断输出绑定到msg

要定义truncate过滤器,我们可以在组件的script部分的filters属性中定义它。

truncate过滤器将文本截断到120个字符:

<script>
export default {
  filters: {
    truncate(value) {
      return value.slice(0, 120)
    }
  },
  // other component properties
}
</script>

没有截断过滤器,我们得到446个字符的Lorem ipsum,如下所示:

![图 4.22:未截断的 Lorem ipsum]

![img/B15218_04_22.jpg]

图 4.22:未截断的 Lorem ipsum

使用truncate过滤器,我们减少到120个字符,如下所示 截图:

![图 4.23:使用truncate过滤器的 Lorem ipsum]

![img/B15218_04_23.jpg]

图 4.23:使用truncate过滤器的 Lorem ipsum

编写这个truncate过滤器的更防御性的方式是,如果val为假,则提前返回,然后toString它(这将把数字转换为字符串,例如)然后再进行.slice的输出:

<script>
export default {
  filters: {
    truncate(value) {
      if (!value) return
      const val = value.toString()
      return val.slice(0, 120)
    }
  },
  // other component properties
}
</script>

通过这样,我们已经学会了如何为组件注册和实现 Vue.js 过滤器。我们还学会了如何在组件的模板中使用管道语法在插值表达式中使用过滤器。

在接下来的练习中,我们将学习如何实现省略号过滤器。

练习 4.05:实现省略号过滤器

过滤器非常适合重复的文本处理任务。在这个练习中,我们将实现一个按如下方式工作的ellipsis过滤器。

如果传递的文本超过14个字符,则应将其截断到11个字符,并在文本末尾添加省略号()。

当传递的文本为空或不是String时,我们应该相当宽容,要么返回空值,要么在我们处理之前将其转换为String

要访问此练习的代码文件,请参阅packt.live/2IsZyuv

按照以下步骤完成此练习:

  1. 首先,我们需要设置模板,以便它将少于 14 个字符的字符串14 个字符的字符串多于 14 个字符的字符串通过ellipsis传递,以检查在所有可能的条件下是否按预期工作(我们将按照标准的 Vue CLI 设置,在src/App.vue中这样做)。我们还应该将数字和一个空值(null)通过ellipsis传递:

    <template>
      <div id="app">
        <p>{{ '7 char' | ellipsis }}</p>
        <p>{{ '14 characters' | ellipsis }}</p>
        <p>{{ 'More than 14 characters' | ellipsis }}</p>
        <p>{{ null | ellipsis }}</p>
        <p>{{ 55 | ellipsis }}</p>
      </div>
    </template>
    

    在这个阶段,应用程序应该只显示控制台中的文本。应该有一些警告表明ellipsis过滤器尚未定义,如下所示截图:

    ![图 4.24:显示未更改文本的应用程序]

    ![img/B15218_04_24.jpg]

    图 4.24:显示未更改文本的应用程序

    以下截图显示了警告:

    ![图 4.25:Vue.js 警告缺少省略号过滤器]

    ![img/B15218_04_25.jpg]

    ![图 4.25:Vue.js 警告缺少省略号过滤器]

  2. 接下来,我们将在组件的script部分实现过滤器的初始版本。这将检查传入值的长度,如果它超过 14 个字符,则将其截断到11并添加

    <script>
    export default {
      filters: {
        ellipsis(value) {
          return value.length > 14 ? `${value.slice(0, 11)}...` :         value
        }
      }
    }
    </script>
    

    在这个阶段,组件无法渲染,Vue.js 记录了一个错误,显示为Cannot read property 'length' of null,如下面的截图所示:

    ![图 4.26:null 被导入到应用中 图片

    图 4.26:null 被导入到应用中

  3. 接下来,我们需要修改ellipsis实现,以便在传入的值为false时短路(以避免null的问题):

        ellipsis(value) {
          if (!value) return
          // rest of the function
    }
    

    我们现在有了ellipsis过滤器正在工作;它适用于我们包含的所有测试用例。输出将如下所示:

    ![图 4.27:省略号过滤器对给定输入的工作情况 图片

图 4.27:省略号过滤器对给定输入的工作情况

过滤器对于在组件中共享简单的文本处理逻辑很有用。过滤器是 Vue.js 的一个原始操作,它将模板和格式化关注点保留在模板中,例如截断内容和添加省略号。

Vue.js 在 DOM Web API 之上提供了一个抽象层。然而,当需要直接访问 DOM 时,例如集成 DOM 库,Vue.js 通过 refs 提供了一种一等的方式来这样做。我们将在下一节学习 Vue.js 的 refs。

Vue.js refs

在 Vue.js 中,refs是对 DOM 元素或其他组件的引用。这是程序化发生的。

Refs 的一个大用途是直接 DOM 操作和与基于 DOM 的库(通常需要一个它们应该挂载到的 DOM 节点)的集成。

Refs 在模板中的原生元素或子组件上使用ref="name"定义。在以下示例中,输入将被存储在theInput refs 中:

<template>
  <div id="app">
    <input ref="theInput" />
  </div>
</template>

Refs 可以通过 Vue.js 组件实例通过this.$refs[name]访问。因此,在前面的示例中,我们有一个定义为ref="theInput"的 refs,我们可以通过this.$refs.theInput来访问它。

要在按钮点击时聚焦输入,我们可以编写以下代码:

<template>
  <div id="app">
    <input ref="theInput" />
    <button @click="focus()">Focus Input</button>
  </div>
</template>
<script>
export default {
  methods: {
    focus() {
      this.$refs.theInput.focus()
    }
  }
}
</script>

当点击“聚焦输入”按钮时,输入将被聚焦,如下面的截图所示:

![图 4.28:按钮点击时聚焦输入图片

图 4.28:按钮点击时聚焦输入

通过这样,我们已经学习了如何在 Vue.js 组件中使用$refs来抽象 DOM 操作逻辑。在 Vue.js 中直接选择 DOM 节点合理的地方,建议使用ref而不是使用 DOM 选择 API(querySelector/querySelectorAll)。

在以下练习中,我们将学习Countable库如何帮助提高项目的交互性。

练习 4.06:使用 Vue.js 包装 Countable.js

Countable 是一个库,给定一个元素(通常是 HTML textarea 或输入),将为段落、单词和字符添加实时计数。在捕获的文本上的实时度量可以非常有用,可以增加编辑文本为核心关注点的项目中交互性。

在 Vue.js 中使用 refs 的一个大型用例是能够与直接作用于 DOM 的库集成。

在这个练习中,我们将通过使用 Countable.js 和 Vue.js refs 创建一个具有段落/单词/字符计数的 textarea 组件。

要访问此练习的代码文件,请参阅 packt.live/36oOuGz

按照以下步骤完成此练习:

  1. npm 安装 countable。在这里,我们将运行 npm install --save countable,这将将其添加到我们的依赖项中

  2. 接下来,我们将创建一个新的 src/components/TextEditorWithCount.vue 组件,其中包含一个我们将有 reftextarea

    <template>
      <div>
        <textarea
          ref="textArea"
          cols="50"
          rows="7"
        >
        </textarea>
      </div>
    </template>
    
  3. 接下来,我们将导入并渲染 src/App.vue 中的组件:

    <template>
      <div id="app">
        <TextEditorWithCount />
      </div>
    </template>
    <script>
    import TextEditorWithCount from './components/  TextEditorWithCount.vue'
    export default {
      components: {
        TextEditorWithCount
      }
    }
    </script>
    

    应用程序渲染了一个 textarea,如下所示:

    ![图 4.29:应用程序渲染的裸文本区域]

    ![图片 B15218_04_29.jpg]

    图 4.29:应用程序渲染的裸文本区域

  4. 我们现在需要集成 Countable。我们将导入它,并用 this.$refs.textArea 初始化它。我们还将把计数存储在实例上作为 this.count

    <script>
    import * as Countable from 'countable'
    export default {
      mounted() {
        Countable.on(this.$refs.textArea, (count) => {
          this.count = count
        })
      },
      data() {
        return {
          count: null
        }
      }
    }
    </script>
    
  5. 通过对 template 的小更新,我们可以显示我们关心的计数:

    <template>
      <div id="app">
        <!-- textarea -->
        <ul v-if="count">
          <li>Paragraphs: {{ count.paragraphs }}</li>
          <li>Sentences: {{ count.sentences }}</li>
          <li>Words: {{ count.words }}</li>
        </ul>
      </div>
    </template>
    

    现在,我们可以看到当 textarea 为空时,计数设置为 0,如下所示:

    ![图 4.30:当文本区域为空时,计数设置为 0]

    ![图片 B15218_04_30.jpg]

    图 4.30:当文本区域为空时,计数设置为 0

    如果我们在 textarea 中放入一些 Lorem ipsum,计数将相应更新,如下所示:

    ![图 4.31:当填充时更新的计数文本区域]

    ![图片 B15218_04_31.jpg]

    图 4.31:当填充时更新的计数文本区域

  6. 我们最后需要做的一件事是在组件销毁时移除 Countable 事件监听器:

    <script>
    // imports
    export default {
      mounted() {
        Countable.on(this.$refs.textArea, (count) => {
          this.count = count
        })
        this.$once('hook:beforeDestroy', function () {
          Countable.off(this.$refs.textArea)
        })
      },
      // other component properties
    }
    </script>
    

    注意

    我们已经通过程序性监听器实现了这一点,尽管我们也可以通过 beforeDestroy 生命周期方法实现相同的效果。

在 Vue.js 中将 JavaScript/DOM 库集成到应用中是 Vue.js refs 的关键应用。Refs 允许我们从现有的库生态系统中选择,并将它们包装或集成到组件中。

Vue.js refs 对于集成 DOM 库或直接访问 DOM API 非常有用。

为了结束我们对组件组合的学习,我们需要知道如何从子组件向父组件传递数据。

Vue.js 子父组件通信事件

我们已经看到,props 用于从父组件传递数据到子组件。

要从子组件将数据传递回父组件,Vue.js 有自定义事件。

在组件中,可以使用$emit实例方法来触发一个事件。它可以在script部分使用this.$emit('eventName', /* payload */)来使用,但也可以在template部分作为$emit暴露。

假设我们有一个响应式实例属性this.message,我们可以在script部分使用this.$emit来发出一个带有message值的send事件。这可以作为一个MessageEditor组件的基础:

<script> 
export default {
  data () {
        return {
            message: null
        }
    },
  methods: {
    send() {
      this.$emit('send', this.message);
    }
  }
}
</script>

在相同的场景中,我们可以从template部分触发一个send事件:

<template>
  <div>
    <input v-model="message" />
    <button @click="$emit('send', message)">Emit inline</button>
  </div>
</template>

从父组件中,我们可以使用v-on:event-name或简写的@event-nameevent-name必须与传递给$emit的名称匹配;eventNameevent-name不等同。

例如,父组件会是如何使用@send监听send事件并保存包含在$event魔法值中的事件负载。要在方法调用中使用事件负载,我们可以使用@eventName="methodToCall($event)"

<template>
  <div id="app">
    <p>Message: {{ message }}</p>
    <MessageEditor @send="message = $event" />
    <button @click="message = null">Reset</button>
  </div>
</template>
<script>
import MessageEditor from './components/MessageEditor.vue'
export default {
  components: {
    MessageEditor
  },
  data() {
    return {
      message: null
    }
  }
}
</script>

使用内联和方法版本的$emit会产生相同的结果。完整的MessageEditor应用应如下所示:

![图 4.32:Hello World!从子组件到父组件发出的消息]

图片 B15218_04_32.jpg

图 4.32:Hello World!从子组件到父组件发出的消息

Vue.js 自定义事件支持将任何 JavaScript 类型作为负载传递。然而,事件名称必须是一个String

注意

将监听器绑定到 Vue.js 自定义事件与绑定到原生事件(如click)非常相似。

现在,让我们根据我们迄今为止所学的内容完成一个活动。

活动 4.01:具有可重用组件的本地消息视图

此活动旨在利用组件、属性、事件和 refs 来渲染一个聊天界面,用户可以添加消息,并且它们会被显示。

按照以下步骤完成此活动:

  1. 创建一个MessageEditor组件(在src/components/MessageEditor.vue),向用户显示一个textarea

  2. MessageEditor添加一个message响应式实例变量,默认值为''

  3. 监听textareachange事件,并将message的值设置为textarea内容(它作为事件的值暴露)。

  4. 添加一个Send按钮,当点击时,会发出一个带有message作为负载的send事件。

  5. src/App.vue中添加一个main App组件,渲染MessageEditor

  6. App中监听来自MessageEditorsend事件,并将每条消息存储在messages响应式实例变量中(messages是一个数组)。

  7. 创建一个MessageFeed(在src/components/MessageFeed.vue),它有一个必需的messages属性,它是一个数组。

  8. MessageFeed中,将messages属性中传递的每条消息渲染为一个段落(p元素)。

  9. MessageFeed导入到App中,并将messages应用实例变量绑定为MessageFeedmessages属性。

  10. 改进MessageEditor,以便在发送消息时重置消息。为此,我们需要使用 Vue.js 的 ref 设置textarea.value并重置message实例变量。

    注意

    重置textarea的更简单的方法本来是直接使用v-model="message",而不是绑定@change并手动同步textarea.valuemessage

    预期的输出如下:

    ![图 4.33:发送了 Hello World!和 Hello JavaScript 的消息应用]

    ![图片 B15218_04_33.jpg]

图 4.33:发送了 Hello World!和 Hello JavaScript 的消息应用

注意

该活动的解决方案可以通过此链接找到。

摘要

在本章中,我们探讨了 Vue.js 的基本功能,这些功能使我们能够以高效的方式构建组件。

Props 和 slots 用于将组件内的行为延迟到渲染它们的父组件。具有验证能力的 Props 非常适合将数据传递到嵌套组件中。slots 旨在将渲染控制权交还给父组件。事件使子组件能够将数据发送回父组件,从而完成父子通信周期(props 向下,events 向上)。

全局模板辅助函数可以被封装在过滤器中,以减少样板代码并增加代码复用。通过允许我们直接访问 DOM 元素,Refs 可以解锁与第三方 JavaScript 或 DOM 库的集成机会。

我们现在能够组合和创建组件,这些组件通过输入(props 和 slots)和输出(渲染模板和事件)清楚地定义了它们的接口,同时访问常见的用例(包装 DOM 库、在过滤器中抽象模板关注点等)。

在下一章中,我们将探讨高级组件组合模式和技巧,这些模式和技巧能够实现更好的代码复用。

第五章:5. 全局组件组合

概述

在本章中,你将学习如何使用全局抽象、新的组合模型和新的组件类型来减少你的 Vue.js 应用程序代码中的重复。你将实验 Vue.js 的混合插件和新的组件类型以及它们的组合方式。

到本章结束时,你将能够识别在 Vue.js 应用程序中使用混合插件以实现全局组合并保持代码 DRY(不要重复自己)的情况,以及如何定义全局组件、功能组件和非 Vue 文件中的组件。你还将能够对比全局组合的优点和缺点,并选择正确的抽象以最大化组件的灵活性。

简介

组件嵌套是一种组合方法,其中应用程序由更小的单元(组件)构建而成。应用程序可以被视为组件相互嵌套。在这种情况下,任何共享功能将通过组件提供。Vue.js 提供了其他组合方法。

基于组件的组合可能会非常冗长,这意味着我们会在需要某个特定功能的地方重复导入。这不符合 DRY 原则。为了避免这种重复和冗长,我们可以在MyComponent的每个消费者中全局注册import MyComponent from ...

同样,应用程序也可以由不同类型的原语(混合、插件和组件)构建而成。为了最大灵活性,组件可以以不同的方式定义,而不仅仅是 Vue.js 的单文件组件文件(.vue文件)。在这个类别中,我们有功能组件以及使用render函数定义的组件。每种类型的组件都有其优点和缺点。

为了保持代码 DRY(Don't Repeat Yourself),组件应该易于使用和扩展。本章将探讨一些我们可以遵循的技巧,以使组件更具可重用性,从而使应用程序更加 DRY。

混合

混合可以为使用它们的组件添加方法、属性和默认的生命周期方法。在下面的示例中,我们正在定义一个混合,它向组件的data函数添加一个greet方法和一个greeting字段:

export default {
  methods: {
    greet(name) {
      return `${this.greeting} ${name}`
    }
  },
  data() {
    return {
      greeting: 'Hello'
    }
  }
}

混合(Mixins)允许独立定义多个组件的共享功能。它们通过一个mixins组件属性来使用,该属性接受一个数组。

App.vue文件中,我们可以通过设置组件的mixins属性来使用混合。

混合的属性和方法随后将在组件中可用(就像它们在组件本身中定义一样):

<template>
  <div>{{ greet('World') }}</div>
</template>
<script>
import greeter from './mixins/greeter.js'
export default {
  mixins: [greeter]
}
</script>

这将在浏览器中显示以下消息:

![图 5.1:使用 greeter 混合的 Hello World

![img/B15218_05_01.jpg]

图 5.1:使用 greeter 混合的 Hello World

当组件和混合器在实例属性或方法名称相同的情况下发生冲突时,组件获胜。这可以理解为组件默认采用混合器行为,除非该组件声明了相同的实例属性或方法。在这种情况下,混合器中定义的实例访问将访问组件的实例。

例如,让我们向具有greeting设置为HiApp组件添加一个data()初始化器:

<script>
// other imports
export default {
  // other component properties
  data() {
    return {
      greeting: 'Hi'
    }
  }
}
</script>

混合器定义了一个data方法,但组件也定义了。在这种情况下,组件获胜,因此显示的问候语是Hi(在组件中定义),而不是Hello(在混合器中定义),如下所示:

![图 5.2:使用重写数据的 greeter mixin 实现的 Hi World]

![图片 B15218_05_02.jpg]

图 5.2:使用重写数据的 greeter mixin 实现的 Hi World

注意,当组件没有定义data方法时,将使用混合器的实现,但如果混合器和组件都定义了它,组件将获胜。

Vue.js 生命周期钩子是提取到混合器的首选候选者。我们可以使用的生命周期钩子(按执行顺序)是beforeCreatedcreatedbeforeMountmountedbeforeUpdateupdatedbeforeDestroydestroyed

生命周期钩子是之前提到的混合器/组件冲突解决规则的例外。在 Vue.js 生命周期钩子函数的情况下,对于每个混合器、组件和钩子,钩子函数都是按照混合器的顺序(按添加到组件的顺序)首先执行,组件最后执行。

我们可以在以下示例中看到这一点。让我们创建两个实现mounted生命周期钩子的混合器,并在组件中实现该钩子。这说明了混合器/组件冲突解决的案例:

<template>
  <div ref="zone" />
</template>
<script>
const firstMixin = {
  mounted() {
    console.log('First mixin mounted hook')
  }
}
const secondMixin = {
  mounted() {
    console.log('Second mixin mounted hook')
  }
}
export default {
  mixins: [firstMixin, secondMixin],
  mounted() {
    console.log('Component mounted hook')
  }
}
</script>

此组件的浏览器控制台输出(按顺序)将是First mixin mounted hookSecond mixin mounted hookComponent mounted hook,如下所示:

![图 5.3:显示在组件钩子之前执行的混合器中定义的钩子的浏览器控制台输出]

![图片 B15218_05_03.jpg]

图 5.3:显示在组件钩子之前执行的混合器中定义的钩子的浏览器控制台输出

我们所看到的所有示例都直接使用混合器将功能注入到组件中。混合器也可以通过使用Vue.mixin函数调用全局创建。

例如,我们可以使我们的问候函数成为全局实例方法:

Vue.mixin({
  methods: {
    $greet(greeting, name) {
      return `${greeting} ${name}`
    }
  }
})

this.$greet现在将在Vue.mixin调用之后声明的所有 Vue 实例上可用。然而,此用例最好通过插件来实现。

注意

Vue.js 中用于由 Vue.js 应用程序实例提供的方法(而不是当前组件实例)的约定是$methodName

练习 5.01:创建自己的混合器

在这个练习中,我们将创建一个名为 debug 的混入,它将返回传入输入的 JSON 字符串表示形式。debug 执行所谓的漂亮打印,以便我们可以更容易地阅读它。

这在调试 Vue.js 应用程序时也可能很有用,尤其是在 Vue.js DevTools 不可用或不可靠的情况下打印数据。要访问此练习的代码文件,请参阅 packt.live/38ivgFq

我们将从一个干净的 Vue CLI 项目开始(这可以通过 vue new exercise5.01 命令创建)。Vue CLI 项目中的应用程序可以通过 npm run serve 启动。

按照以下步骤完成此练习:

  1. 创建一个新的 src/mixins 文件夹和一个 src/mixins/debug.js 文件,我们将在这里定义混入的框架:

    export default {}
    
  2. 混入将添加一个 debug 方法,我们应该在 methods 下定义它。debug 方法将接受一个 obj 参数,并返回该数据的 JSON.stringify 输出。我们将使用 JSON.stringify(obj, null, 2) 来输出两空格缩进的漂亮打印 JSON:

    export default {
      methods: {
        debug(obj) {
          return JSON.stringify(obj, null, 2)
        }
      }
    }
    
  3. 我们现在能够从 src/App.vue 导入 debug 混入,并在 mixins 属性下注册它:

    <script>
    import debug from './mixins/debug.js'
    export default {
      mixins: [debug],
    }
    </script>
    
  4. 要查看 debug 方法的实际效果,我们将在 src/App.vue 文件中添加一个 data 方法和一个 created 钩子(从中我们可以打印 debug 的输出):

    <script>
    // imports
    export default {
      // other component properties
      data() {
        return {
          myObj: {
            some: 'data',
            other: 'values'
          }
        }
      },
      created() {
        console.log(this.debug(this.myObj))
      }
    }
    </script>
    

    你应该得到以下输出:

    ![图 5.4:由于创建钩子而产生的浏览器控制台输出]

    ![图片 B15218_05_04.jpg]

    ![图 5.4:由于创建钩子而产生的浏览器控制台输出]

  5. debug 也在模板中可用;我们可以在 pre 标签中插入其输出,以便尊重空白字符:

    <template>
      <div id="app">
        <pre>{{ debug(myObj) }}</pre>
      </div>
    </template>
    

    应用程序以及此模板将如下所示:

    ![图 5.5:使用混入的 debug 方法打印 myObj]

    ![图片 B15218_05_05.jpg]

![图 5.5:使用混入的 debug 方法打印 myObj]

通过这种方式,我们学习了如何使用混入以相当明确的方式(mixins 属性)将共享功能注入到多个组件中。我们还看到了当组件的实现覆盖了混入提供的属性和方法时会发生什么(组件通常获胜)。

我们现在将探讨如何注入实例和全局功能,并通过插件进行分发。

插件

Vue.js 插件是一种向 Vue.js 全局添加自定义功能的方法。插件的良好候选者通常是应用程序的核心,并且被广泛使用。插件候选者的经典例子包括翻译/国际化库(例如 i18n-next)和 HTTP 客户端(例如 axiosfetchGraphQL 客户端)。插件初始化器可以访问 Vue 实例,因此它是一个很好的方式来包装全局指令、混入、组件和过滤器定义。

插件可以通过注册指令和过滤器来注入功能。它们还可以添加 globalinstance Vue.js 方法,以及定义全局组件混入。

Vue.js 插件是一个暴露 install 方法的对象。install 函数使用 Vueoptions 调用:

const plugin = {
  install(Vue, options) {}
}

install 方法中,我们可以注册指令、过滤器、混入,并添加全局和实例属性和方法:

const plugin = {
  install(Vue, options) {
    Vue.directive('fade', { bind() {} })
    Vue.filter('truncate', str => str.slice(0, 140))
    Vue.mixin({
      data() { return { empty: true } }
    })
    Vue.globalProperty = 'very-global-value'
    Vue.prototype.$myInstanceMethod = function() {}
  }
}

插件使用 Vue.use 方法进行注册:

import plugin from './plugin'
Vue.use(plugin)

Vue.use 也可以将选项作为第二个参数传递。这些选项传递给插件:

Vue.use(plugin, { optionProperty: true })

Vue.use 的一个特性是不允许你注册相同的插件两次。这是一个很好的特性,可以避免在尝试多次实例化或安装相同插件时出现的边缘情况行为。

在与 Vue.js 结合使用时,axios 是一个流行的 HTTP 客户端。通常,我们会通过拦截器或 axios 选项来配置 axios,以实现重试、传递 cookies 或跟随重定向等功能。

可以使用以下命令安装 axiosnpm install –save axios

练习 5.02:创建自定义 Axios 插件

为了避免必须添加 import axios from 'axios' 或将我们的自定义 axios 实例包装在 httptransport 内部模块下,我们将我们的自定义 axios 实例注入到 Vue 对象和 Vue 组件实例的 Vue.axiosthis.axios 下。这将使它在我们的应用程序中使用,该应用程序需要使用 axios 作为 HTTP 客户端调用 API,变得更加容易和舒适。要访问此练习的代码文件,请参阅 packt.live/36po08b

我们将从一个干净的 Vue CLI 项目开始(可以使用 vue new exercise5.02 命令创建)。Vue CLI 项目中的应用程序可以使用 npm run serve 启动。

按照以下步骤完成此练习:

  1. 为了正确组织我们的代码,我们将在 src/plugins 中创建一个新的文件夹,并在 src/plugins/axios.js 中为我们的 axios 插件创建一个新的文件。在新文件中,我们将构建 axios 插件:

    import axios from 'axios'
    export default {
      install(Vue, options) {}
    }
    
  2. 现在我们将在 src/main.js 中的 Vue.js 实例上注册我们的 axios 插件:

    // other imports
    import axiosPlugin from './plugins/axios.js'
    // other code
    Vue.use(axiosPlugin)
    // Vue instantiation code
    
  3. 我们现在将通过以下命令使用 npm 安装 axios。这将允许我们导入 axios 并通过插件在 Vue 中暴露它:

    npm install --save axios
    
  4. 现在我们将在 src/plugins/axios.js 中将 axios 添加到 Vue 作为全局属性:

    import axios from 'axios'
    export default {
      install(Vue) {
        Vue.axios = axios
      }
    }
    
  5. axios 现在在 Vue 中可用。在 src/App.vue 中,我们可以向一个 API 发送请求,该 API 将填充 todos 列表:

    <template>
      <div id="app">
        <div v-for="todo in todos" :key="todo.id">
          <ul>
            <li>Title: {{ todo.title }}</li>
            <li>Status: {{ todo.completed ? "Completed" :           "Not Completed" }}</li>
          </ul>
        </div>
      </div>
    </template>
    <script>
    import Vue from 'vue'
    export default {
      async mounted() {
        const { data: todos } = await       Vue.axios('https://jsonplaceholder.typicode.com/todos')
        this.todos = todos
      },
      data() {
        return { todos: [] }
      }
    }
    </script>
    

    下面的输出是预期的:

    图 5.6:全局 Vue.axios todo 显示示例

    图片

    图 5.6:全局 Vue.axios todo 显示示例

  6. 在我们的情况下,必须添加 import Vue from 'vue' 有点奇怪。通过插件注入 axios 的全部目的是为了消除 import 模板。更好的方法是通过对组件实例进行暴露;即 this.axios。为此,我们需要更新 src/plugins/axios.js 文件中的安装步骤,并将 axios 添加到 Vue.prototype,这样任何 new Vue() 组件都将将其作为属性:

    // imports
    export default {
      install(Vue, options) {
        // other plugin code
        Vue.prototype.axios = axios
      }
    }
    
  7. 我们现在可以删除 import Vue from 'vue' 行,并在 src/App.vue 中通过 this.axios 访问 axios

    <script>
    export default {
      async mounted() {
        const { data: todos } = await       this.axios('https://jsonplaceholder.typicode.com/todos')
        this.todos = todos
      },
      data() {
        return { todos: [] }
      }
    }
    </script>
    

    以下为输出结果:

    ![图 5.7:Vue 实例 axios todo 显示示例 图片

图 5.7:Vue 实例 axios todo 显示示例

有了这个,我们已经使用插件注入了全局和实例级别的属性和方法,以及学习了如何以易于分发的方式使用它们来创建指令和其他 Vue 构造。

现在,我们将探讨如何在代码库中全局注册组件,以帮助减少高使用频率组件的样板代码。

全局注册组件

使用插件的一个原因是减少所有 Vue 应用程序文件中的样板代码,通过删除 导入 并用对 this 和/或 Vue 的访问来替换它们。

Vue.js 组件通常在单个文件组件中定义,并显式导入。出于与定义全局方法和属性相同的原因,我们可能希望全局注册组件。这将允许我们在所有其他组件模板中使用这些组件,而无需导入它们并在 components 属性下注册它们。

这种情况在使用设计系统或组件在代码库中跨模块使用时非常有用。

全局注册组件有助于某些类型的更新,例如,如果文件名未暴露给消费者,那么在更改文件名时,只有一个路径需要更新,而不是每个用户一个。

假设我们在 CustomButton.vue 文件中有一个 CustomButton 组件,其外观如下:

<template>
  <button @click="$emit('click', $event)">
    <slot />
  </button>
</template>

我们可以将 CustomButton 全局注册如下(这通常在 main.js 文件中完成):

// other imports
import CustomButton from './components/CustomButton.vue'
Vue.component('CustomButton', CustomButton)
// other global instance setup

我们现在可以在 App.vue 文件中使用它,而无需本地注册或导入:

<template>
  <div>
    <CustomButton>Click Me</CustomButton>
  </div>
</template>

这将按预期渲染,按钮名为“点击我”:

![图 5.8:CustomButton 渲染与“点击我”按钮图片

图 5.8:使用“点击我”按钮的 CustomButton 渲染

通过这样,我们已经探讨了如何在代码库中频繁使用组件时,全局注册组件可以减少样板代码。

接下来,我们将探讨如何在 Vue.js 中提高组件的灵活性的一些技巧。

最大化组件灵活性

Vue.js 组件接受 props 和 slots 作为输入;它们的输出以 HTML 渲染,并发出事件。

为了最大化组件的灵活性,始终利用插槽和 props 是很有意义的。

精确利用 props 和默认值意味着组件可以被重用和扩展。例如,我们可以在组件中不硬编码值,而是将其设置为默认 prop。在这种情况下,date 默认为当前日期,new Date()。然后我们使用计算属性提取纪元:

<template>
  <div>Date as epoch: {{ epoch }}</div>
</template>
<script>
export default {
  props: {
    date: {
      type: Date,
      default() {
        return new Date()
      }
    }
  },
  computed: {
    epoch() {
      return Number(this.date)
    }
  }
}
</script>

当注册并使用时,渲染如下:

Date as epoch: 1574289255348

插槽可以被视为组件将渲染委托回其消费者的一种方式。将模板的部分委托给父组件有助于可重用性。

使用插槽来最大化重用性的一个特定例子是无渲染组件模式。例如,在时代显示示例中,我们可以利用作用域插槽并从组件中移除任何渲染逻辑:

<template>
  <div>
    <slot :epoch="epoch" />
  </div>
</template>

在父组件中,可以使用作用域插槽来定义渲染:

<template>
  <div>
    <Epoch>
      <template v-slot:default="{ epoch }">
        Epoch as rendered with parent template {{ epoch }}
      </template>
    </Epoch>
  </div>
</template>

这意味着组件的委托被委托给了父组件,并显示以下内容:

Epoch as rendered with parent template 1574289270190

下一组实践通过使它们的 API 可预测来最大化组件的重用。在许多方面,前向属性、利用styleclass属性合并以及实现v-model接口是使 Vue.js 自定义组件表现得更像 HTML 元素的一种方式。

前向属性可能很有趣。例如,一个CustomInput组件(在CustomInput.vue文件中)可能需要传递type属性,以及required属性:

<template>
  <input v-bind="$attrs">
</template>

CustomInput组件可以用来渲染任何类型的组件(src/App.vue):

<template>
  <div id="app">
    <fieldset>
      <label for="textinput">
        Text Input
      </label>
      <CustomInput
      type="text"
      name="textinput"
      id="textinput"
      />
    </fieldset>
    <fieldset>
      <label for="dateinput">
        Date Input
      </label>
      <CustomInput
        type="date"
        name="dateinput"
        id="dateinput"
      />
    </fieldset>
  </div>
</template>
<script>
import CustomInput from './components/CustomInput.vue'
export default {
  components: {
    CustomInput
  }
}
</script>

这正确渲染了文本和日期输入:

![图 5.9:具有文本和日期类型的 CustomInput图片 B15218_05_09.jpg

图 5.9:具有文本和日期类型的 CustomInput

Vue.js 在类/内联样式方面做了很多繁重的工作,因为它将组件上定义的styleclass对象与该组件根元素的styleclass对象合并。根据文档,“类和样式属性有点智能,所以两个值都会合并” (Vue.js 组件属性指南vuejs.org/v2/guide/components-props.html#Replacing-Merging-with-Existing-Attributes)。

在 Vue.js 中,输入元素和组件倾向于通过v-model进行双向响应式绑定,v-model是使用v-bind:valuev-on:input来提供值并保持与子组件或元素的输出同步的简写。

传递的value仅用作起始值;当输入被捕获完成时(例如,完成输入)会发出input事件。

如果组件实现了v-model形状,它可以直接替换表单元素。

例如,实现了v-model接口的TextInput可以与inputtextarea互换使用:

<template>
  <div>
    <textarea
      v-if="type === 'long'"
      :value="value"
      @input="$emit('input', $event.target.value)"
    >
    </textarea>
    <input
      v-else
      :value="value"
      @input="$emit('input', $event.target.value)"
      type="text"
    />
  </div>
</template>
<script>
export default {
  props: ['value', 'type']
}
</script>

这可以在src/App.vue中如下使用:

<template>
  <div id="app">
    <label>Short Text: {{ shortText }}</label>
    <TextInput v-model="shortText" type="short" />
    <label>Long Text: {{ longText }}</label>
    <TextInput v-model="longText" type="long" />
  </div>
</template>
<script>
import TextInput from './components/TextInput.vue'
export default {
  components: {
    TextInput
  },
  data() {
    return {
      shortText: '',
      longText: ''
    }
  }
}
</script>

应用程序渲染如下:

![图 5.10:实现 v-model 的自定义组件图片 B15218_05_10.jpg

图 5.10:实现 v-model 的自定义组件

这样,我们就已经探讨了如何利用 props 和 slots、继承属性以及实现已知的 Vue.js 接口来帮助最大化组件的灵活性。

下一节将专门介绍如何通过学习在不使用.vue文件的情况下使用 Vue.js 组件来加深我们对 Vue.js 组件的理解。

使用 Vue.js 组件而不使用.vue 单文件组件

我们所看到的 Vue.js 组件的大部分示例都利用了 .vue 单文件组件。

这不是定义 Vue.js 组件的唯一方法。在本节中,我们将探讨四种不同的方法来定义 Vue.js 组件,而不使用 .vue 文件。

评估这些选项将帮助我们理解 Vue.js 组件的核心是什么。

使用字符串模板的运行时定义

组件可以使用接受字符串值的 template 属性。这通常被称为 字符串模板。此模板在运行时(在浏览器中)被评估。

我们可以在 StringTemplate.js 文件中定义一个组件,通过定义一个具有 template 属性的对象:

export default {
  template: `<div>String Template Component</div>`
}

然后,可以从 App.vue 文件中消费它,如下所示:

<template>
  <div id="app">
    <StringTemplate />
  </div>
</template>
<script>
import StringTemplate from './components/StringTemplate.js'
export default {
  components: {
    StringTemplate
  }
}
</script>

不幸的是,这会在加载时崩溃,并在控制台显示以下 Vue 警告:

图 5.11:Vue 运行时编译器缺失警告

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_05_11.jpg)

图 5.11:Vue 运行时编译器缺失警告

根据 Vue 警告,为了使此组件在导入时工作,我们需要在运行时构建中包含 Vue.js 编译器。为此,在 Vue CLI 项目中,我们可以在 vue.config.js(Vue CLI 配置文件)中将 runtimeCompiler 选项设置为 true

您的 vue.config.js 应该看起来像以下这样:

module.exports = {
  runtimeCompiler: true
};

设置此选项并重新启动开发服务器后,来自 StringTemplate 组件的消息会出现在浏览器中:

String Template Component

可以使用 .vue 组件对象定义属性和其他组件实例属性。

渲染函数

Vue.js 单文件组件的 template 部分在构建时被编译成一个 render 函数。

render 函数通常用于 Vue CLI 项目的 main.js 文件中 - 特别是 new Vue() 调用:

new Vue({
  render: h => h(App),
}).$mount('#app')

一个 render 函数接受一个 createElement 参数,并返回一个虚拟 DOM 节点。这是通过调用 createElement 函数(在上面的示例中,这是 h)来完成的。

h 由于其紧凑性,常被用作 createElement 的缩写。

我们可以在 JavaScript 文件(RenderFunction.js)中定义一个具有 render 属性的组件,如下所示:

export default {
  render(createElement) {
    return createElement(
      'h2',
      'Render Function Component'
    )
  }
}

这可以在 App.vue 文件中这样呈现:

<template>
  <div id="app">
    <RenderFunction />
  </div>
</template>
<script>
import RenderFunction from './components/RenderFunction.js'
export default {
  components: {
    RenderFunction
  }
}
</script>

此组件在浏览器中显示一个 h2,内容为 渲染函数组件

Render Function Component

除了在非 .vue 文件中编写组件外,render 函数对于高度动态的组件也很有用。

JSX

JSX 由 React 推广。根据 React 文档,JSX 是 JavaScript 的语法扩展。我们建议与 React 一起使用它来描述 UI 应该是什么样子 (reactjs.org/docs/introducing-jsx.html)。JSX 是 JavaScript 的超集,允许使用花括号进行 HTML-style 标签和插值。

与 Vue.js 一样,React 不会将 JSX 渲染到 DOM 中。与 Vue.js 模板类似,React 应用程序构建工具将 JSX 编译为在运行时使用的 render 函数,以便它们可以渲染到虚拟 DOM。然后虚拟 DOM 与真实 DOM 进行 reconcile(同步)。

JSX 编译为 render 函数,Vue.js 支持 render 函数的组件定义。此外,Vue CLI 3+ 默认编译 JSX。

这意味着我们可以编写以下内容,这与 RenderFunction 组件、JSXRender.js 文件等效:

export default {
  render() {
    return <h2>JSX Render Function Component</h2>
  }
}

没有 JSX 的等效 render 函数如下所示(基于上一节中的示例):

export default {
  render(createElement) {
    return createElement(
      'h2',
      'JSX Render Function Component'
    )
  }
}

以下 App.vue 文件将 JSXRender 渲染到浏览器中:

<template>
  <div id="app">
    <JSXRender />
  </div>
</template>
<script>
import JSXRender from './components/JSXRender.js'
export default {
  components: {
    JSXRender
  }
}
</script>

现在,我们可以在屏幕上看到 JSXRender 中的 h2,内容符合预期:

JSX Render Function Component

通过这些,我们已经了解到 Vue.js 组件只是具有 rendertemplate 函数的对象。.vue 组件的 template 部分在构建时编译为 render 函数,这意味着要使用字符串模板,我们需要在应用程序运行时包含 Vue.js 编译器。我们还学习了如何使用 render 函数以及 JSX 来定义组件,并指出了 React 和 Vue.js 在实现方面的一些共同点。在决定使用 JSX 或 render 函数时,JSX 可以更容易阅读,同时具有 render 函数的完整灵活性(而常规模板并不总是具备)。

我们现在将探讨如何使用 Vue.js 的 component 标签从运行时数据动态渲染组件。

Vue 组件标签

JSX 和 render 函数非常适合需要非常动态渲染的组件的情况。

在常规 Vue.js 模板中实现这一点的办法是使用 component 标签。

component 标签使用 is 属性来动态选择将渲染哪个组件。

要渲染一个动态组件,我们使用一个带有绑定 is 属性的 component 标签(在这里,我们使用缩写 :is,它等同于 v-bind:is):

<component :is="componentName" />

现在,我们将学习如何使用名称或组件引用渲染动态组件。

通过名称或组件引用渲染动态组件

假设我们有一个网格,其中包含可以切换显示为卡片显示(一个包含图像和文本的设计元素)或仅图像视图的项目。

首先,我们需要导入相关组件并将它们注册为组件。我们还将设置一些固定数据以循环网格:

<template>
  <div id="app">
    <div class="grid">
      <component
        class="grid-item"
        v-for="item in items"
        :key="item.id"
      />
    </div>
  </div>
</template>
<script>
import Card from './components/Card.vue';
import ImageEntry from './components/ImageEntry.vue';
export default {
  components: {
    Card,
    ImageEntry
  },
  data() {
    return {
      items: [
        {
          id: '10',
          title: 'Forest Shot',
          url: 'https://picsum.photos/id/10/1000/750.jpg',
        },
        {
          id: '1000',
          title: 'Cold cross',
          url: 'https://picsum.photos/id/1000/1000/750.jpg',
        },
        {
          id: '1002',
          title: 'NASA shot',
          url: 'https://picsum.photos/id/1002/1000/750.jpg',
        },
        {
          id: '866',
          title: 'Peak',
          url: 'https://picsum.photos/id/866/1000/750.jpg'
        },
      ]
    }
  }
}
</script>

我们可以按名称引用组件——即 cardimage-entry——并将 itemComponent 设置为 is 的值:

<template>
    <!-- rest of template -->
    <component
      :is="itemComponent"
      class="grid-item"
      v-for="item in items"
      :key="item.id"
    />
    <!-- rest of template -->
</template>
<script>
// rest of script
export default {
  // other component properties
  data() {
    return {
      itemComponent: 'card',
      // other data properties eg. `items`
    }
  }
}
</script>

在这种情况下,Card 组件将被渲染,因为我们传递了其小写名称(card)给 component 标签。

如果我们将 itemComponent 改为 image-entry,则 ImageEntry 组件将被渲染。此切换可以使用 v-model 如下进行:

<template>
  <!-- rest of template -->
  Display mode:
  <input
    type="radio"
    name="style"
    value="card"
    v-model="itemComponent"
    id="card-radio"
  />
  <label for="card-radio">Card</label>
  <input
    type="radio"
    name="style"
    value="image-entry"
    v-model="itemComponent"
    id="image-radio"
  />
  <label for="image-radio">Image</label>
  <!-- rest of template -->
</template>

我们还可以使用组件引用本身(而不是名称)将组件传递给 is。例如,我们可以将 itemComponent 设置为 Card

<script>
// rest of script
export default {
  // other component properties
  data() {
    return {
      itemComponent: Card,
      // other data properties eg. `items`
    }
  }
}
</script>

在这种情况下,在卡片视图和图像视图之间切换会更困难,因为我们需要使用组件引用而不是使用名称。

我们可以将属性传递给使用 component 动态渲染的组件,就像我们使用 v-bind:prop-name:prop-name 简写传递常规属性一样:

<template>
    <!-- rest of template -->
    <component
      class="grid-item"
      v-for="item in items"
      :key="item.id"
      :is="itemComponent"

      :url="item.url"
      :title="item.title"
    />
    <!-- rest of template -->
</template>

给定以下 CardImageEntry 组件,我们得到一个具有可切换视图的网格项的应用程序。

Card.vue 渲染图像和标题,并具有最大宽度 150px

<template>
  <div class="card">
    <img :src="img/url" width="100%" />
    <h3>{{ title }}</h3>
  </div>
</template>
<script>
export default {
  props: {
    url: String,
    title: String
  }
}
</script>
<style scoped>
.card {
  margin: 10px;
  max-width: 150px;
}
h3 {
  font-weight: normal;
}
</style>

您的输出将显示卡片视图中的条目,如下所示:

![图 5.12:在卡片视图中渲染网格条目]

![图片 B15218_05_12.jpg]

图 5.12:在卡片视图中渲染网格条目

使用 ImageEntry.vue 以卡片视图的两倍宽度渲染图像:

<template>
  <img class="image" :src="img/url" />
</template>
<script>
export default {
  props: {
    url: String
  }
}
</script>
<style scoped>
.image {
  margin: 20px;
  max-width: 300px;
}
</style>

您现在将看到图像视图中的条目,如下面的截图所示:

![图 5.13:在图像视图中渲染网格条目]

![图片 B15218_05_13.jpg]

图 5.13:在图像视图中渲染网格条目

component 标签的一个注意事项是,当它不再显示时,渲染的动态组件会被完全销毁。在这个例子中,正在渲染的动态组件没有任何状态,所以这种销毁不会引起任何问题。

我们现在将学习如何缓存动态组件状态。

使用 Keep-Alive 缓存动态组件状态

通过 component 标签动态渲染的组件可以有状态,例如在多部分表单中,下一页有 name 字段和 address 字段。

让我们使用 component 标签来实现这一点,如下所示:

<template>
  <div id="app">
    <component
      :is="activeStep"
      @next="activeStep = 'second-step'"
      @back="activeStep = 'first-step'"
    />
  </div>
</template>
<script>
import FirstStep from './components/FirstStep.vue'
import SecondStep from './components/SecondStep.vue'
export default {
  components: {
    FirstStep,
    SecondStep
  },
  data() {
    return {
      activeStep: 'first-step',
    }
  }
}
</script>

通过这样做,我们可以在 Name 字段中输入数据:

![图 5.14:在名称字段中输入我的名字]

![图片 B15218_05_14.jpg]

图 5.14:在名称字段中输入我的名字

如果我们使用 Next 导航到表单的地址部分,然后使用 Back,名称将会消失,如下面的截图所示:

![图 5.15:在地址步骤中点击 Next 然后 Back 后的空名称字段]

![图片 B15218_05_15.jpg]

图 5.15:在地址步骤中点击 Next 然后 Back 后的空名称字段

这是因为组件在不是当前渲染的动态组件时会被销毁(摧毁)。

为了解决这个问题,我们可以在 component 标签周围使用 keep-alive 元素:

<template>
  <!-- rest of template -->
  <keep-alive>
    <component
      :is="activeStep"
      @next="activeStep = 'second-step'"
      @back="activeStep = 'first-step'"
    />
  </keep-alive>
  <!-- rest of template -->
</template>

以这种方式,填写名称并从表单的地址部分返回将显示以下内容:

![图 5.16:导航后,我的名字仍然是名称字段中的值]

![图片 B15218_05_16.jpg]

图 5.16:导航后,我的名字仍然是名称字段中的值

通过这种方式,我们已经学习了如何使用 component 标签来表示一个区域,在这个区域内我们可以根据字符串或组件本身(如导入的)动态显示组件。我们还探讨了如何解决 component 的主要问题;即如何使用 keep-alivecomponent 标签中不是正在积极使用的组件时保持组件状态。

练习 5.03:使用 component 标签创建动态卡片布局

现代应用程序布局是一个带有卡片网格。Card 布局的好处是适合移动、桌面和平板显示器。在这个练习中,我们将创建一个具有三种不同模式和选择其中之一的方式的动态 card 布局。此布局将允许用户选择屏幕上显示多少信息以适应他们的偏好。

Rich 视图将显示一个项目的所有详细信息,包括图片、标题和描述。

Compressed 视图将显示所有详细信息,但不显示图片预览。

List 视图将仅显示标题,应采用垂直布局。

每个 card 视图都将作为一个单独的组件实现,然后使用 component 标签动态渲染。要访问此练习的代码文件,请参阅 packt.live/3mYYvkq

按照以下步骤完成此练习:

  1. src/components/Rich.vue 中创建丰富的布局。它包含三个属性:url(图片 URL)、titledescription,分别渲染图片、标题和描述:

    <template>
      <div class="card">
        <img :src="img/url" width="100%" />
        <h3>{{ title }}</h3>
        <p>{{ description }}</p>
      </div>
    </template>
    <script>
    export default {
      props: ['url', 'title', 'description']
    }
    </script>
    <style scoped>
    .card {
      display: flex;
      flex-direction: column;
      max-width: 200px;
    }
    h3 {
      font-weight: normal;
      margin-bottom: 0;
      padding-bottom: 0;
    }
    </style>
    
  2. 使用一些固定数据设置 src/App.vue

    <template>
      <div id="app">
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          items: [
            {
              id: '10',
              title: 'Forest Shot',
              description: 'Recent shot of a forest overlooking a             lake',
              url: 'https://picsum.photos/id/10/1000/750.jpg',
            },
            {
              id: '1000',
              title: 'Cold cross',
              description: 'Mountaintop cross with snowfall from             Jan 2018',
              url: 'https://picsum.photos/id/1000/1000/750.jpg',
            },
          ]
        }
      }
    }
    </script>
    
  3. Rich 视图组件导入到 src/App.vue 并本地注册:

    <script>
    import Rich from './components/Rich.vue'
    export default {
      components: {
        Rich
      },
      // other component properties, eg. "data"
    }
    </script>
    
  4. 一旦我们获得了 Rich 视图组件,将其连接到 src/App.vue 应用程序中,使用 component 渲染它,并通过相关属性传递:

    <template>
      <!-- rest of template -->
          <component
            v-for="item in items"
            :key="item.id"
            :is="layout"
            :title="item.title"
            :description="item.description"
            :url="item.url"
          />
      <!-- rest of template>
    </template>
    <script>
    export default {
     // other component properties
      data() {
        return {
          layout: 'rich',
          // other data definitions eg. `items`
        }
      }
    }
    </script>
    
  5. 这是一个添加一些样式使网格看起来像网格的好地方:

    <template>
      <!-- rest of template -->
        <div class="grid">
          <component
            v-for="item in items"
            :key="item.id"
            :is="layout"
            :title="item.title"
            :description="item.description"
            :url="item.url"
          />
        </div>
      <!-- rest of template -->
    </template>
    <style scoped>
    .grid {
      display: flex;
    }
    </style>
    

    这将显示以下输出:

    ![图 5.17:动态渲染丰富的组件]

    图片 B15218_05_17.jpg

    图 5.17:动态渲染丰富的组件

  6. 现在,实现 Compressed 视图,它只是 Rich 视图,但在 Compressed.vue 文件中没有图片:

    <template>
      <div class="card">
        <h3>{{ title }}</h3>
        <p>{{ description }}</p>
      </div>
    </template>
    <script>
    export default {
      props: ['title', 'description']
    }
    </script>
    <style scoped>
    .card {
      display: flex;
      flex-direction: column;
      max-width: 200px;
    }
    h3 {
      font-weight: normal;
      padding-bottom: 0;
    }
    p {
     margin: 0;
    }
    </style>
    
  7. src/App.vue 中导入并注册 Compressed 组件:

    <script>
    // other imports
    import Compressed from './components/Compressed.vue'
    export default {
      components: {
        Rich,
        Compressed,
      },
      // other component properties
    }
    
  8. 添加一个 select 来在视图之间切换。它将有两个选项,值为 richcompressed,并使用 v-model 绑定到 layout

    <template>
      <!-- rest of template -->
      Layout: <select v-model="layout">
          <option value="rich">Rich</option>
          <option value="compressed">Compressed</option>
        </select>
      <!-- rest of template -->
    </template>
    

    使用此 select,我们可以切换到 compressed 布局,其外观如下:

    ![图 5.18:打开选择器的压缩布局]

    图片 B15218_05_18.jpg

    图 5.18:打开选择器的压缩布局

  9. List 布局添加到 src/components/List.vuelist 视图是压缩视图,但没有描述:

    <template>
      <h3>{{ title }}</h3>
    </template>
    <script>
    export default {
      props: ['title']
    }
    </script>
    <style scoped>
    h3 {
      width: 100%;
      font-weight: normal;
    }
    </style>
    
  10. List 组件导入到 src/App.vue 并本地注册:

    <script>
    // other imports
    import List from './components/List.vue'
    export default {
      components: {
        Rich,
        Compressed,
        List
      },
      // other component properties
    }
    
  11. 添加一个额外的选项 value="list" 以切换到 List 布局:

    <template>
      <!-- rest of template -->
        Layout: <select v-model="layout">
          <option value="rich">Rich</option>
          <option value="compressed">Compressed</option>
          <option value="list">List</option>
        </select>
      <!-- rest of template -->
    </template>
    

    当切换到list布局时,项目将按如下方式显示为水平行:

    ![图 5.19:水平堆叠错误的列表视图 图片 B15218_05_19.jpg

    图 5.19:水平堆叠错误的列表视图

  12. 要修复这种水平堆叠,创建一个新的grid-column类,将其设置为flex-direction: column(与默认的row相对)并在布局为list时条件性地应用它:

    <template>
      <!-- rest of template -->
        <div class="grid" :class="{ 'grid-column': layout ===       'list' }">
          <!-- grid using component tag -->
        </div>
      <!-- rest of template -->
    </template>
    <style scoped>
    /* existing rules */
    .grid-column {
      flex-direction: column;
    }
    </style>
    

    我们的List布局现在看起来如下:

    ![图 5.20:垂直堆叠的列表视图 图片 B15218_05_20.jpg

图 5.20:垂直堆叠的列表视图

通过这样,我们已经学习了如何使用component标签动态渲染不同组件,无论是通过名称还是使用组件对象本身。我们还探讨了有状态动态组件的陷阱,即组件不再显示时的组件销毁以及如何使用keep-alive元素来规避这些问题。

我们现在将探讨如何仅使用render函数或template标签使用功能组件来实现简单组件。

功能组件

功能组件是常规 Vue.js 组件的子集。它们没有状态或组件实例。它们可以被视为render函数(如本章前面所示),其中传递了属性。

注意

我们可以将组件标记为功能性的,这意味着它们是无状态的(没有响应式数据)和实例化的(没有this上下文)。

更多信息请参阅 Vue.js 文档(vuejs.org/v2/guide/render-function.html#Functional-Components)。

功能组件只能访问从父组件传递过来的属性、子组件、插槽和作用域插槽,它们还接收对父组件监听器的引用。

以下是一个Greet组件(在Greet.vue文件中)。注意template中的functional注解:

<template functional>
  <div>Functional Component: {{ props.greeting }} {{     props.audience }}</div>
</template>

功能组件必须通过props.propName访问属性。功能组件也可以通过functional: true布尔值表示,并使用render函数:

export default {
  functional: true,
  render(h, context) {
    return h(
      'h2',
      `Functional Render: ${context.props.greeting}         ${context.props.audience}`
    )
  }
}

我们可以在App.vue文件中使用这两个功能组件:

<template>
  <div id="app">
    <Greet greeting="Hello" audience="World" />
    <GreetRender greeting="Hello" audience="World" />
  </div>
</template>
<script>
import Greet from './components/Greet.vue'
import GreetRender from './components/GreetRender.js'
export default {
  components: {
    Greet,
    GreetRender
  }
}
</script>

这将在浏览器中渲染以下内容:

![图 5.21:功能组件渲染图片 B15218_05_21.jpg

图 5.21:功能组件渲染

功能组件是封装仅渲染功能的绝佳方式;也就是说,它们从属性中获取模板。由于它们没有关联的响应式状态或组件实例,因此它们比常规组件有更好的性能表现。

我们已经为非功能组件覆盖了一个常见用例,即发出事件,可以如下操作:

<template>
  <input
    type="submit"
    @click="$emit('click', $event)"
  />
</template>

要使用功能组件发出事件,我们可以将元素绑定到listeners对象中的属性。

要将所有事件委派给子组件,我们可以使用v-on="listeners"

<template functional>
  <input
    v-on="listeners"
    v-bind="data.attrs"
  />
</template>

要绑定一个特定的监听器,我们可以使用 v-on:eventName="listeners.listenerName",其中 listenerName 是功能组件的父组件绑定的监听器:

<template functional>
  <input
    type="submit"
    v-on:click="listeners.click"
    v-bind="data.attrs"
  />
</template>

注意

绑定到一个不存在的监听器属性将导致错误。为了避免这种情况,我们可以使用 listeners.listenerName || (() => {}) 表达式。

通过这样,我们已经学习了如何使用功能组件以及 .vue 组件的 template 变体和 render 函数来实现常见的 Vue.js 组件模式。

现在,我们将构建一个使用本章中我们查看的所有模式的待办事项应用程序。

活动五.01:使用插件和可重用组件构建 Vue.js 应用程序

在这个活动中,我们将构建一个 jsonplaceholder 作为数据源。

我们的待办事项应用程序将加载待办事项并将它们显示为列表。它将根据待办事项是否已完成显示复选框,以及待办事项的名称。

当勾选待办事项时,应用程序将同步到 API。

我们将作为插件注入 axios 以查询 jsonplaceholder.typicode.com

按照以下步骤完成此活动:

  1. axios 安装到项目中。

  2. 要将 axios 注入为 this 组件实例的属性,创建一个 src/plugins/axios.js 插件文件,在 install 时,这意味着组件实例将有一个 axios 属性。

  3. 为了使插件工作,请在 src/main.js 中导入并注册它。

  4. 我们还希望将我们的 API 的 baseUrl 注入到所有组件中。我们将创建一个内联的 src/main.js 文件插件来完成此操作。

  5. 现在,我们想要从我们的 src/App.vue 中获取所有待办事项。一个好的地方是在 mounted 生命周期方法中做这件事。

  6. 要显示待办事项列表,我们将在 src/components/TodoList.vue 中创建一个 TodoList 功能组件,它接受一个 todos 属性,遍历项目,并在 todo 作用域插槽中延迟渲染待办事项,该插槽绑定待办事项。

  7. 我们现在可以使用 TodoList 组件在 src/App.vue 中渲染我们已获取的待办事项。

  8. 现在,我们需要创建一个 TodoEntry 组件,我们将在这里实现大部分待办事项特定的逻辑。对于组件来说,一个好的做法是让属性非常具体于组件的角色。在这种情况下,我们将处理的 todo 对象的属性是 idtitlecompleted,因此这些应该是我们的 TodoEntry 组件接收的属性。我们不会将 TodoEntry 制作成功能组件,因为我们需要组件实例来创建 HTTP 请求。

  9. 然后,我们将更新 src/App.vue,使其消费 TodoEntry(确保绑定 idtitlecompleted)。

  10. 添加切换 todo 的功能。我们将大部分实现放在 src/components/TodoEntry.vue 中。我们将监听 input 变更事件。在变更时,我们将读取新值并向 /todos/{todoId} 发送一个包含 completed 设置为新值的 PATCH 请求。我们还将想要在 Vue.js 中发出一个 completedChange 事件,以便 App 组件可以更新内存中的数据。

  11. App.vue 中,当触发 completeChange 时,我们希望更新相关的 todo。由于 completeChange 不包括 todo 的 ID,我们需要在设置 handleCompleteChange 函数以监听 completeChange 时从上下文中读取该 ID。

预期输出如下:

![图 5.22:使用 jsonplaceholder 数据的待办事项应用]

图片 5.22

图 5.22:使用 jsonplaceholder 数据的待办事项应用

注意

这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,我们探讨了全局组合模式和高级组件设置,这些可以在 Vue.js 应用程序中减少重复。

首先,我们学习了混合(mixins),它明确地共享功能,同时让组件有最后的决定权,并看到了这个规则的例外情况。然后,我们看到了插件是如何成为深入多个 Vue.js 原语的一个很好的钩子。

接下来,我们探讨了如何通过规定模式在 Vue.js 中最大化组件的可重用性。例如,利用 props 来委派数据、slots 来委派模板,以及实现允许组件使用 Vue-idiomatic 简写(如 v-model)的接口。

我们还深入探讨了 Vue.js 组件是什么,而不仅仅是 .vue 文件。我们通过引入字符串模板、render 函数和 JSX,以及这些方法各自的工作要求,来深入了解 Vue.js 组件。最后,我们看到了功能组件如何巩固我们使用 .vue 文件定义组件的方法。

到目前为止,我们已经学习了如何从组件、混合和插件的角度构建应用程序。要构建跨越多个页面的应用程序,我们需要实现路由。这就是我们在下一章将要解决的问题。

第六章:6. 路由

概述

在本章中,我们将了解路由和 Vue Router 是如何工作的。我们还将使用 Vue Router 在我们的应用中设置、实现和管理路由系统。然后我们将探讨动态路由以传递参数值,以及嵌套路由以在复杂应用中提高复用性。此外,我们还将探讨 JavaScript 钩子,这些钩子可用于认证和错误处理等功能。到本章结束时,您将准备好在任何 Vue 应用中处理静态和动态路由。

简介

在他们的 URL 栏中输入website.com/about,他们将被路由到关于页面。

单页应用SPAs)中,路由允许在应用内部进行平滑导航,无需刷新页面。在 Web 开发中,路由是我们决定如何将 HTTP 请求连接到处理它们的代码的匹配机制。当我们的应用需要 URL 导航时,我们使用路由。大多数现代 Web 应用包含大量不同的 URL,即使是单页应用。因此,路由在创建导航系统方面发挥着重要作用,并帮助用户快速在我们的应用和网络上移动。

简而言之,路由是应用根据提供的 URL 解释用户想要什么资源的一种方式。它是一个基于 URL 的 Web 资源导航系统,例如资产(图像和视频)、脚本和样式的路径。

Vue Router

根据 Vue.js 文档所述,Vue Router 被官方推荐为任何 Vue.js 应用的路由服务。它提供了一个组件间通过路由进行通信的单个入口点,因此可以有效地控制应用流程,无论用户的行为如何。

通过丰富的功能,它简化了页面切换的过程,无需刷新页面。

设置 Vue Router

Vue Router 默认未安装;然而,当使用 Vue CLI 创建应用时,它很容易被启用。通过运行以下命令创建应用:

vue create <your-project-name>

选择如图 6.1 所示的手动选择功能选项:

图 6.1:选择手动预设以创建新的 Vue.js 项目

图 6.1:选择手动预设以创建新的 Vue.js 项目

在选择手动选择功能选项后,您将看到如图 6.2 所示的特性列表。在撰写本文时,默认选中了BabelLinter / Formatter。使用下箭头键,导航到Router选项。当选项高亮时,按空格键启用它,然后按Enter 键继续。

图 6.2:将 Vue Router 添加到项目中

图 6.2:将 Vue Router 添加到项目中

接下来,你将看到一个提示,询问你是否想为路由配置使用history mode,如图 6.3 所示。通过输入Y启用历史模式。历史模式允许在不需要默认 hash 模式重新加载的情况下在页面之间导航。我们将在本章稍后更详细地比较这两种模式:

图 6.3:使用历史模式配置 Vue Router

img/B15218_06_03.jpg

图 6.3:使用历史模式配置 Vue Router

最后,继续进行其余的过程,我们将拥有一个准备就绪的 Vue.js 应用,其中包含 Vue Router。

注意

如果你想要将 Vue Router 添加到现有的 Vue.js 应用中,你可以使用以下命令将其作为应用依赖项安装:

npm install vue-router

下一步是理解vue-router如何实现浏览器 URL 与应用视图之间的同步的基本原理。

首先,让我们看看router-view元素。

路由视图元素

router-view元素是一个功能性组件,其中应用的路由系统加载用户接收到的任何给定 URL 路径的匹配和最新视图内容。

简而言之,router-view是一个 Vue 组件,其任务是执行以下操作:

  • 渲染不同的子组件

  • 自动在任意嵌套级别挂载和卸载,取决于给定的路由路径

没有使用router-view,在运行时几乎不可能正确渲染动态内容。例如,当用户导航到Home页面时,router-view会知道并只渲染与该页面相关的内。

在下一节中,我们将看到如何通过传递一个 prop 来设置应用的入口点(默认路由)。

使用 Props 定义应用的入口点

由于router-view是一个组件,它也可以接收 props。它接收的唯一 prop 是name,这是在初始化阶段在router对象中定义的相应路由记录中注册的相同名称。

任何其他额外的属性都会在渲染过程中直接传递给router-view的子组件。以下是一个带有 class 属性的示例:

<router-view class="main-app-view"/>

如果router-view作为子组件渲染,我们可以在关联的模板中定义布局。一个非常简单的模板示例如下:

<template>
  <div>Hello World</div>
</template> 

子组件接收传递的 class 属性,渲染后的实际输出如下:

<div class="main-app-view">Hello World</div>

当然,为了使我们的模板有用,它也应该包含<router-view/>元素,这样我们想要路由的内容就有地方渲染。一个常见的设置是在模板中有一个导航菜单,下面是router-view。这样,内容在页面之间变化,但菜单保持不变。

导航到App.vue并确保你的模板中有以下代码:

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>

让我们移除<div id="app">内的所有代码,只留下一个单独的<router-view/>组件:

<div id="app">
    <router-view/>
  </div>

我们现在将注释掉所有routes的代码,如下所示:

const routes = [
  // {
  //   path: '/',
  //   name: 'Home',
  //   component: Home
  // },
  // {
  //   path: '/about',
  //   name: 'About',
  //   // route level code-splitting
  //   // this generates a separate chunk (about.[hash].js) for     this route
  //   // which is lazy-loaded when the route is visited.
  //   component: () => import(/* webpackChunkName: "about" */     '../views/About.vue')
  // }
]

现在我们应用的输出将渲染为在 localhost:8080 运行的空白页面,如图 6.4 所示:

![Figure 6.4:浏览器中的 Hello Vue Router 应用程序]

![img/B15218_06_04.jpg]

图 6.4:浏览器中的 Hello Vue Router 应用程序

输出是一个空白页面,因为我们没有在我们的文件中设置任何路由配置,包括将路径与相关视图进行映射。没有这一步,路由系统将无法选择正确的视图并将其动态渲染到我们的 router-view 元素中。

在下一节中,我们将看到如何设置 Vue Router。

为 Vue 设置 Vue Router

当我们将 Vue Router 添加到我们的项目中时,Vue CLI 会创建并添加一个 router 文件夹到代码目录中,其中包含一个单独自动生成的 index.js 文件。此文件包含我们路由所需的所有配置。

我们将导航到该文件,并查看 Vue Router 的基本预定义配置。

首先,你会注意到我们需要分别从 vuevue-router 包中导入 VueVueRouter。然后我们调用 Vue.use (VueRouter) 来将其作为插件安装到我们的应用程序中:

import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

Vue.use 是一个全局方法,如第五章 全局组件组合 中所述。它触发 VueRouter 的内部 install 方法,以及 Vue 构造函数,一旦 Vue 成为应用程序的全局变量。此方法具有内置机制以防止插件安装超过一次。

执行 Vue.use(VueRouter) 后,以下对象在任意组件中都可以访问:

  • this.$router – 全局路由对象

  • this.$route – 当前路由对象

this 指的是上下文中的组件。

现在我们已经在应用程序中注册了 Vue Router 的使用,接下来进行下一步——定义路由实例的配置对象的路由。

定义路由

在一个 Web 应用程序中,一个 route 是一个 URL 路径模式,并将其映射到特定的处理器。在现代 Web 开发中,handler 是一个组件,定义并位于一个物理文件中。例如,当用户输入路由 localhost:8080//home 时,如果 Home 映射到这个特定的路由,路由系统就会知道触发处理器 Home 来相应地渲染内容。

如前例所示,设置应用程序内导航的路由(或路径)至关重要。

每个路由都是一个对象字面量,其属性由 RouteConfig 接口声明:

interface RouteConfig = {
  path: string,
  component?: Component,
  name?: string, // for named routes
  components?: { [name: string]: Component }, // for named views
  redirect?: string | Location | Function,
  props?: boolean | Object | Function,
  alias?: string | Array<string>,
  children?: Array<RouteConfig>, // for nested routes
  beforeEnter?: (to: Route, from: Route, next: Function) => void,
  meta?: any,
  // 2.6.0+
  caseSensitive?: boolean, // use case sensitive match? (    default: false)
  pathToRegexpOptions?: Object // path-to-regexp options for     compiling regex
}

应用程序所需的所有路由都位于 routesArray 实例中:

const routes = [
  //Route1,
  //Route2,
  //...
]

现在,让我们回到之前的文件,并取消注释 routes 中的代码。将会有两个预定义的路由,homeabout,每个都是一个对象,位于 routes 数组中,以便于我们使用。

让我们以第一个路由为例,进行更详细的了解:

  {
    path: '/',
    name: 'home',
    component: Home
  }

path属性是一个/about路径,将被转换为<app domain>/aboutlocalhost:8080/aboutexample.com/about)。

在这种情况下,Vue Router 将/——空路径——理解为在没有其他指示符(例如,当用户导航到<app-domain><app-domain>/)后的默认路径,用于加载应用程序。

下一个属性是name,它是一个字符串,表示分配给目标路由的名称。尽管它是可选的,但强烈建议为每个路由定义一个名称,以利于代码维护和路由跟踪,我们将在本章后面的传递路由参数部分进一步讨论。

最后一个属性是component,它是一个 Vue 组件实例。router-view使用这个属性作为对视图组件的引用,在路径激活时渲染页面内容。

在这里,我们将路由定义为home路由,将其映射为应用的默认路径,并将其与Home组件关联以显示内容。

Vue CLI 也为这两个示例路由自动生成了两个简单的组件——HomeAbout

在下一节中,我们将介绍一些在加载与路由一起使用的组件时可能有所帮助的技巧。

路由配置中加载组件的技巧

当然,我们需要在同一个index.js文件中导入组件,将其与目标路由关联起来。最经典和最受欢迎的方法是在文件顶部导入,如下所示:

import Home from '../views/Home.vue'

通常这会被添加到主导入之下,如图 6.5所示:

![图 6.5:第 3 行导入 Home 组件 – src/router/index.js

![img/B15218_06_05.jpg]

图 6.5:第 3 行导入 Home 组件 – src/router/index.js

然而,一个更有效的方法是懒加载组件。

懒加载,也称为按需加载,是一种旨在优化网站或 Web 应用程序在运行时内容的技术。它有助于减少首次加载应用程序时的时间和资源消耗。这种优化对于确保最佳的用户体验至关重要,因为每一毫秒的等待都很重要。除此之外,懒加载还允许在路由级别进行更好的代码拆分,并在大型或复杂应用程序中进行性能优化。

我们可以利用webpack的优势来懒加载组件。我们可以在定义about路由名称之后动态添加以下内容,而不是像处理Home组件那样在文件顶部导入About组件(参见图 6.5):

component: () => import(/* webpackChunkName: "about" */   '../views/About.vue')

在这里,我们动态地懒加载about路由的About视图组件。在编译过程中,webpackabout路由生成一个具有指定名称("about")的单独块,并且只有在用户访问此路由时才加载它。

在大多数情况下,由于用户很可能会在第一次访问时停留在默认路径上,因此最好不懒加载默认组件(在我们的应用程序中是 Home),而是以正常方式导入它。因此,这里的建议是在设计路由时确定哪些组件应该被懒加载,并将两种方法结合起来以获得最大效益。

我们现在将看到如何设置路由实例。

设置路由实例

在定义了路由之后,最后一步是根据给定的配置选项创建 router 实例:

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

配置是一个对象,由不同的属性组成,有助于形成应用程序的路由。我们现在将在以下小节中检查这些属性。

routes

routes 是必须传递给构造函数的选项。没有这个选项,路由器将无法识别路径并相应地将用户引导到合适的视图内容。

mode

mode 决定了路由器的模式。在 VueRouter 中,URL 有两种模式:

  • history 模式:这通过 HTML5 History API 利用默认的 history.pushState() API。它允许我们在不重新加载页面的情况下进行 URL 导航,并使 URL 路径易于阅读,例如 yourapplication.com/about

  • hash 模式:这使用哈希符号(#)来模拟 URL,例如,yourapplication.com/#about 用于 about 页面或 youapplication/#/ 用于应用程序的 home URL。

base

base 决定了应用程序的基本 URL。它将被设置为 process.env.BASE_URL 以允许开发人员从应用程序代码之外控制它(例如,从 .env 文件中)。因此,开发人员可以在运行时设置代码应该从中提供服务的目录。

现在已经解决了 base 的问题,我们已经创建了 router 实例。剩下要做的就是导出它:

export default router

然后在 main.js 中导入它,在创建主应用实例的 new Vue 对象之前。我们仍然需要在实例配置中指定 router,如下所示:

import router from './router'
Vue.config.productionTip = false
new Vue({
  router, //specify the router configuration for use
  render: h => h(App)
}).$mount('#app')

使用这段更新后的代码,我们的应用程序现在将按以下方式渲染:

图 6.6:浏览器中 Hello Vue Router 应用程序的首页

图 6.6:浏览器中 Hello Vue Router 应用程序的首页

如果我们导航到 localhost:8080/about,我们将看到从自动生成的代码中渲染的 about 组件内容:

<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

网站应该看起来与 图 6.7 中所示相似:

图 6.7:浏览器中 "Hello Vue Router" 应用程序的关于页面

图 6.7:浏览器中 "Hello Vue Router" 应用程序的关于页面

在本节中,我们探讨了如何使用懒加载组件来加速大型和复杂的 SPAs。我们还探讨了在设置路由时可以设置的某些选项,例如路由、模式和基础。在下一节中,我们将学习如何在 Vue Router 的帮助下实现和添加消息源页面。

练习 6.01:使用 Vue Router 实现和添加消息源页面

我们将创建一个新的页面,向用户显示消息列表。用户可以在浏览器中输入localhost:8080/messages路径时访问此页面。

要访问此练习的代码文件,请参阅packt.live/35alpze

  1. 使用vue create生成的应用程序作为起点,或者使用vue-cli创建一个新的应用程序。确保在生成项目时启用了路由,如本章前面所述:

    vue create Exercise6.01
    
  2. 让我们在./src/views/文件夹中添加一个名为MessageFeed.vue的新视图组件:图 6.8:视图目录层次结构

    <template>
      <div>
        <h2> Message Feed </h2>
        <p v-for="(m, i) in messages" :key="i">
        {{ m }}
        </p>
    </div>
    </template>
    <script>
    export default {
      data() {
        return {
          messages: [
            'Hello, how are you?',
            'The weather is nice',
            'This is message feed',
            'And I am the fourth message'
          ]
        }
      }
    }
    </script>
    
  3. src/router/index.js中创建一个路由文件。它应该导入VueRouter并告诉 Vue 使用该路由,如下所示:

    import Vue from 'vue'
    import VueRouter from 'vue-router'
    import Home from '../views/Home.vue'
    Vue.use(VueRouter)
    
  4. 接下来,在./src/router/index.js文件中,我们声明了一个用于MessageFeed的路由,命名为messageFeed,并将其路径设置为/messages。我们还将懒加载该组件。这一步将通过将包含所需信息的对象附加到routes数组中来完成:

    export const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        // route level code-splitting
        // this generates a separate chunk (about.[hash].js) for       this route
        // which is lazy-loaded when the route is visited.
        component: () => import(/* webpackChunkName: "about" */ '../      views/About.vue')
      }, {
        path: '/messages',
        name: 'messageFeed',
        component: () => import(/* webpackChunkName: "messages" */ '../      views/MessageFeed.vue')
      }
    ]
    
  5. 最后,在同一个文件中,使用我们定义的routes创建一个router实例:

    const router = new VueRouter({
      mode: 'history',
      base: process.env.BASE_URL,
      routes
    })
    export default router
    
  6. 使用以下命令运行应用程序:

    yarn serve
    
  7. 当在浏览器中访问localhost:8080/messages时,页面应该显示正确的内容——即如图所示的Message Feed页面:图 6.9:应用程序渲染的消息源页面

图 6.9:应用程序渲染的消息源页面

这展示了如何简单地将一个新的页面路由添加到 Vue.js 应用程序中,同时保持代码组织良好且易于阅读。现在我们已经准备好了可用的路由,我们可以为用户提供在页面之间导航的能力,而无需输入完整的路径。

设置导航链接

如果router-view负责根据 URL 路径渲染正确的活动视图内容,那么router-link是一个 Vue 组件,它帮助用户在启用了路由的应用程序中进行导航。默认情况下,它渲染一个带有由其to属性生成的正确href链接的锚标签<a>

在我们由 Vue CLI 生成的示例应用程序中,由于有两个预填充的路由,因此也在App.vue<template>部分添加了两个router-link实例,位于<router-view/>之前:

<div id="nav">
  <router-link to="/">Home</router-link> |
  <router-link to="/about">About</router-link>
</div>

由于我们使用base选项和history模式,每个router-linkto属性应该接收与目标route对象中声明的path属性相同的值。

此外,由于我们的路由已命名,to属性的一个替代方案是使用与名称相同的值,而不是路径。使用名称被高度推荐,以避免在需要调整应用程序中某些路由的路径时进行复杂的链接重构。因此,我们可以将链接重写如下:

<div id="nav">
      <router-link to="home">Home</router-link> |
      <router-link to="about">About</router-link> |
    </div>

我们还可以选择将位置描述符对象绑定到to属性,其格式类似于route对象。考虑以下示例:

  <router-link :to="{path: '/'}">Home</router-link>

此外,当相关路由处于活动状态时,将向<a>标签添加一个额外的CSSrouter-link-active。这个类可以通过router-link组件的active-class属性进行自定义。

DevTools中,我们可以看到router-link组件的渲染方式如下:

图 6.10:浏览器 DevTools 中的 router-link

图 6.10:浏览器 DevTools 中的 router-link

浏览器中的视图将如下所示:

图 6.11:带有导航链接的 Hello Vue Router 应用首页

图 6.11:带有导航链接的 Hello Vue Router 应用首页

注意,由于我们可以在组件内部访问this.$router,我们可以通过使用this.$router.push()以编程方式触发导航路由,并传递一个路径或类似to的路由对象:

      this.$router.push('/home')

在本节中,我们创建了一个示例页面,在/messages路由位置渲染消息列表。我们还探讨了如何使用<router-link/>元素在视图之间进行导航,其方式与传统 HTML <a>标签类似。

接下来,我们将看到如何以类似于网络浏览器后退按钮的方式,以编程方式将用户发送到他们最后查看的路由。

实现返回按钮的小技巧

有时我们希望导航回上一页。使用this.$router.push()可以实现这一点,但这会在历史堆栈中添加更多路由,而不是返回。正确的技术是使用this.$router.go(steps),其中steps是一个整数,表示在历史堆栈中后退/前进的步数。此功能与window.history.go(steps)类似。

考虑以下示例:

this.$router.go(-1) // similar to window.history.back()  -   go back one page

此外,您还可以使用相同的方法导航到之前加载且仍然存在于历史堆栈中的页面:

this.$router.go(1) // similar to window.history.forward() –   go forward one page

在本节中,我们探讨了如何手动访问路由的历史记录,以便将用户发送到他们之前所在的页面。

在下一节中,我们将利用导航链接将我们的新消息馈送页面添加到应用程序的nav菜单中。

练习 6.02:将导航链接添加到 MessageFeed 路由

我们将使用to属性和router-link(如前文所述)添加一个快速链接到我们在练习 6.01中创建的MessageFeed路由,即使用 Vue Router 实现和添加消息馈送页面

要访问此练习的代码文件,请参阅packt.live/3lr8cYR

  1. 使用 Vue 生成的起始应用程序作为起点,或者使用Vue cli创建一个新的应用程序。确保在生成项目时启用路由,如本章前面所述:

    vue create Exercise6.02
    
  2. ./src/App.vue文件中,除了为homeabout自动生成的router-link组件外,还需要添加另一个指向Message Feed标题下/messages路径的router-link组件:

    <template>
      <div id="app">
        <div id="nav">
          <router-link to="/">Home</router-link> |
          <router-link to="/about">About</router-link> |
          <router-link to="/messages">Message Feed</router-link>
        </div>
        <router-view/>
      </div>
    </template>
    

    我们将看到在任何视图中都可用导航链接,并且当用户导航离开时它们不会消失,因为它们不是router-view组件的一部分。我们的屏幕应该如下所示:

    图 6.12. 更新后的导航链接的 Hello Vue Router 应用首页

    图 6.12. 更新后的导航链接的 Hello Vue Router 应用首页

  3. 让我们将to值更改为指向名为messageFeed的对象,这与在./src/App.vue中为该路由指定的name相同:

    <router-link :to="{ name: `messageFeed` }">Message Feed   </router-link>
    
  4. 导航应该与之前一样工作;点击消息流链接应将您导向/messages,如下面的截图所示:图 6.13:点击消息流链接后的 Hello Vue Router 的消息流页面

    图 6.13:点击消息流链接后的 Hello Vue Router 的消息流页面

  5. 现在,打开位于./src/router/文件夹中的index.js文件,将messageFeed路由定义的路径从/messages/更改为/messagesFeed

    export const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        // route level code-splitting
        // this generates a separate chunk (about.[hash].js) for       this route
        // which is lazy-loaded when the route is visited.
        component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
      }, {
        path: '/messagesFeed',
        name: 'messageFeed',
        component: () => import(/* webpackChunkName: "messageFeed" */       '../views/MessageFeed.vue')
      }
    ]
    
  6. 使用以下命令运行应用程序:

    yarn serve
    

    导航到应用的首页并再次点击消息流。它应该显示与之前相同的消息流页面,但请注意 URL 路径已更改为/messagesFeed

    图 6.14:使用更新后的 URL 路径渲染的消息流页面

图 6.14:使用更新后的 URL 路径渲染的消息流页面

注意,仅用一行代码就可以轻松设置指向/messages路径的链接,以及更新相关路径而不需要重构。到目前为止,我们只定义了一些简单的路由,没有为目标路由添加任何额外的参数。这将是我们的下一个挑战。

传递路由参数

在本章的前几节中,每个路由都是一个独立的视图,不需要传递或连接任何数据到其他路由。但路由的力量不仅限于此。通过命名路由,我们还可以轻松地启用路由之间的数据通信。

在我们的示例应用中,我们希望我们的about页面能够接收一个名为user的数据字符串,作为用户名从链接触发。这可以通过将to属性从字符串字面量更改为对象字面量:to="{ name: 'about' }"来实现,然后向该对象添加一个新的params: { user: 'Adam' }属性:

<router-link :to="{ name: 'about', params: { user: 'Adam' }}">
  About
</router-link>

此更改通知路由器在用户点击目标链接时将所需的参数传递给About页面。这些额外的参数在渲染的href链接中不可见,如下面的截图所示:

图 6.15:生成的 href 链接没有参数

图 6.15:生成的 href 链接没有参数

然而,Vue 系统正在跟踪这些额外的参数。使用 Vue DevTools,我们可以通过展开如图 6.16 所示的to属性来查看参数:

图 6.16:Vue DevTools 中 to 对象的 params

图片 B15218_06_16.jpg

图 6.16:Vue DevTools 中 to 对象的 params

About.vue文件中,由于我们可以访问当前活动的$route(参见本章前面提到的Vue Router部分),我们可以检索通过链接传递的数据,并将其作为$route.params.user获取并打印出来:

<template>
  <div class="about">
    <h1>About {{$route.params.user}}</h1>
  </div>
</template>

输出将如下所示:

图 6.17:About 页面渲染通过路由参数传递的用户

图片 B15218_06_17.jpg

图 6.17:About 页面渲染通过路由参数传递的用户

任何params的 prop 用户都不会出现在 URL 路径上,从而保持路径的整洁,并确保视图之间传递的数据的安全性。

但是使用$route.params.user既不方便也不易读,并且从长远来看不利于组件的可重用性。我们可以通过在组件内部解耦传递的paramsprops来改进这一点。

我们现在将看到如何借助props解耦params

使用属性解耦参数

index.js文件中,让我们调整about路由的配置,添加一个名为props的额外属性。通过将此属性的值设置为true,路由器将自动理解并将$route.params映射到相应的props组件:

{
    path: '/about',
    name: 'about',
    component: () => import(/* webpackChunkName: "about" */       '../views/About.vue'),
    props: true
  }

About.vue文件中,我们将声明props类型如下:

props: {
    user: String
  }

<template>部分,我们将$route.params.user替换为user

<template>
  <div class="about">
    <h1>About {{user}}</h1>
  </div>
</template>

输出仍然相同,如下面的截图所示:

图 6.18:About 页面渲染通过路由参数传递的用户

并映射到属性

图片 B15218_06_18.jpg

图 6.18:About 页面渲染通过路由参数传递的用户并映射到 props

此外,你还可以在route配置的props属性中定义你想要传递的数据。与布尔值不同,现在props可以声明为一个对象,包含所需的数据,如下例所示:

{
    path: '/about',
    name: 'about',
    component: () => import(/* webpackChunkName: "about" */       '../views/About.vue'),
    props: { age: 32 }
  }

通过类似的步骤,我们将在About.vue中将age声明为props组件,并将其作为文本打印到屏幕上:

<template>
  <div class="about">
    <h1>About {{user}}</h1>
    <h2>Age: {{age}}</h2>
  </div>
</template>
<script>
export default {
  props: {
    user: String,
    age: Number
  }
}
</script>

现在当点击About页面时,页面将渲染如下:

图 6.19:使用路由配置中预置的 props 渲染的 About 页面

图片 B15218_06_19.jpg

图 6.19:使用路由配置中预置的 props 渲染的 About 页面

我们之前用户数据不再可见!这是因为,现在,propsAbout路由的配置中声明为静态数据,并且不能从外部覆盖。它的值在整个在应用程序中导航的过程中将保持不变,无论我们在目标router-link组件的to属性的params中传递什么值。

我们现在将学习如何将所选消息的内容传递到新消息页面并打印出来。

练习 6.03:将所选消息的内容传递到新消息页面并打印出来

我们将从练习 6.02继续,将导航链接添加到消息推送路由,在那里我们定义了MessageFeed路由,其 URL 路径为messages。此视图将在视图组件选项的data属性中渲染预定义的消息列表。

在这个练习中,我们将创建一个新的message页面,专门用于渲染用户选择的消息内容。它应该是可重用的。

要访问此练习的代码文件,请参阅packt.live/36mTwTY

  1. ./src/views/文件夹中,我们创建了一个新的单文件组件Message.vue。该组件接收一个类型为stringcontent属性,并在<p>标签下渲染它:

    <template>
        <div>
            <p>{{content}}</p>
        </div>
    </template>
    <script>
    export default {
        props: {
            content: {
                default: '',
                type: String
            }
        }
    }
    </script>
    
  2. 让我们将创建的视图组件注册到./src/router/index.js中的现有routes。我们将定义一个新的路由为message,路径为/message。它还将接受props: true,以便将传递给路由的所有参数相应地映射到相关属性。将要使用的完整路由列表如下:

    export const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import(/* webpackChunkName: "about" */       '../views/About.vue')
      },
      {
        path: '/messages',
        name: 'messageFeed',
        component: () => import(/* webpackChunkName: "messages" */ '../views/MessageFeed.vue')
      },
      {
        path: '/message',
        name: 'message',
        component: () => import(/* webpackChunkName: "message" */ '../views/Message.vue'),
        props: true
      }
    ]
    
  3. 由于路由已注册并准备好使用,我们需要修改./src/views/MessageFeed.vue中的<template>部分,以确保每条消息现在都是可点击的,并且在点击时将用户重定向到新路由。让我们将<p>标签替换为router-click。因为我们已经将新路由命名为message,所以我们将to设置为绑定到{ name: 'message' }

    <template>
      <div>
      <h2> Message Feed </h2>
      <div v-for="(m, i) in messages" :key="i" >
        <router-link :to="{ name: 'message'}">
          {{ m }}
        </router-link>
      </div>
    </div>
    </template>
    
  4. template中,我们将添加一个包含我们messages的一些示例数据的script标签:

    <script>
    export default {
      data() {
        return {
          messages: [
            'Hello, how are you?',
            'The weather is nice',
            'This is message feed',
            'And I am the fourth message'
          ]
        }
      }
    }
    </script>
    
  5. 当你打开./messages页面时,现在所有消息都是可点击的,如下面的截图所示:![图 6.20:更改消息为可点击后的消息推送页面 图片

    图 6.20:更改消息为可点击后的消息推送页面

  6. 现在当用户点击一条消息时,它将打开一个新页面。然而,页面内容将是空的,因为我们没有将任何内容参数传递给<route-click>组件,如下面的截图所示:![图 6.21:无内容生成的消息页面 图片

    图 6.21:无内容生成的消息页面

  7. 让我们回到./src/views/MessageFeed.vue并添加params: { content: m }

    <template>
      <div>
      <h2> Message Feed </h2>
      <div v-for="(m, i) in messages" :key="i" >
        <router-link :to="{ name: 'message', params: { content: m       }}">
          {{ m }}
        </router-link>
      </div>
    </div>
    </template>
    
  8. 现在当你点击第一条消息“你好,你好吗?”,输出将是以下内容:![图 6.22:已渲染点击消息内容的消息页面 图片

图 6.22:已渲染点击消息内容的消息页面

简单,不是吗?我们已经使用router-link以及组件的paramsprops的组合,动态完成了从消息流到单个选中消息详细页面的流程。然而,这种方法有一个显著的缺点。

当你仍然位于第一条消息的./message路径上时,让我们刷新页面。输出将与步骤 5中的相同——一个空的内容页面。刷新后,路由被触发,没有传递任何content params,与用户点击特定链接时不同,之前传递的params也没有被保存或缓存。因此,没有内容。

在以下部分,我们将学习如何拦截导航流程,并使用路由钩子解决这个问题。

路由钩子

路由导航的一般流程在以下图中描述:

图 6.23:导航解析流程图

图片

图 6.23:导航解析流程图

一旦在某个路由上触发导航,Vue Router 为开发者提供了几个主要的导航守卫或钩子,用于保护或拦截该导航过程。这些守卫可以是全局的或组件内的,具体取决于类型。以下是一些示例:

  • 全局:beforeEachbeforeResolveafterEach

  • 每个组件:beforeEnter

  • 组件内:beforeRouteUpdatebeforeRouteEnterbeforeRouterLeave

图 6.23所示,只有当所有钩子或守卫(包括任何异步守卫)都解析完成后,导航才被认为是完成的。现在,让我们看看如何设置beforeEach钩子。

设置beforeEach钩子

beforeEach是一个全局钩子,在导航开始时(前一个视图组件的beforeRouteLeave)被调用。它应该在index.js文件初始化时定义为一个router实例的全局方法,并采用以下语法:

const router = new VueRouter({
  //...
})
router.beforeEach(beforeEachCallback)

在前面的代码片段中,beforeEachCallback是一个hook函数,它接收三个参数:

const beforeEachCallback = (
  to, // The destination route
  from, //The source route
  next //The function to trigger to resolve the hook
) => { … })

或者我们可以直接这样写:

router.beforeEach((to, from, next) => { … })

例如,如果我们想在用户导航到没有用户参数值的About时显示不同的页面来显示通用消息,我们可以将beforeEach钩子配置如下:

router.beforeEach((
  to, // The destination route
  from, //The source route
  next //The function to trigger to resolve the hook
) => {
  if (to.name === 'about' && (!to.params || !to.params.user)) {
    next({ name: 'error' })
  }
  else {
    next();
  }
})

在这里,我们检查目标路由是否为about,并且没有传递任何额外的params,也没有为user参数传递任何值,我们将导航到error路由,而不是正常进行,使用next()

注意

next()必须调用(如果else),否则将出现错误。

我们仍然需要创建一个带有Error.vue视图组件的error页面,该组件显示一条简单的消息:

<template>
    <div>
        <h2>No param passed.</h2>
    </div>
</template>

还要确保相应地注册路径:

{
    path: '/error',
    name: 'error',
    component: () => import(/* webpackChunkName: "error" */       '../views/Error.vue'),
  }

现在,在Home视图中,点击About链接后,应用将渲染Error页面,而不是About页面,如以下截图所示:

图 6.24:点击 About 时未传递任何参数显示的错误页面

图片

图 6.24:点击 About 时未传递任何参数显示的错误页面

现在,让我们转到App.vue文件,并将to属性绑定到{ name: 'about', params: { user: 'Adam' }}对象上:

      <router-link :to="{ name: 'about', params: { user: 'Adam'         }}">About</router-link>

让我们导航回应用的Home页面并点击About链接。由于我们传递了正确的params,输出将如下所示:

图 6.25:当在中传递用户时显示的“关于”页面

图 6.25:当在params中传递用户时显示的“关于”页面

此外,从现在开始,每次我们刷新“关于”页面时,都会被重定向到“错误”页面,因为没有在刷新时传递user参数。

我们现在将探讨beforeEachbeforeResolve钩子之间的一些关键区别点。

区分 beforeEach 和 beforeResolve 钩子

我们还可以使用相同的语法使用beforeResolve注册全局钩子。然而,与在导航创建阶段触发的beforeEach不同,beforeResolve将在导航执行和确认之前触发,在所有钩子(全局和组件内)解析之后

router.beforeResolve((
  to, // The destination route
  from, //The source route
  next //The function to trigger to resolve the hook
) => {
  if (to.name === 'about' && (!to.params || !to.params.user)) {
    next({ name: 'error' })
  }
  else {
    next();
  }
})

输出结果将与图 6.25相同:

图 6.26:当在中传递用户时显示的“关于”页面

图 6.26:当在params中传递用户时显示的“关于”页面

现在我们来详细看看afterEach钩子。

afterEach 钩子

afterEach()钩子是在导航确认后(这意味着在beforeResolve()之后)被触发的最后一个全局导航守卫。与其他全局守卫不同,传递给afterEach()的钩子函数不会接收next函数,因此它不会影响导航。

此外,tofrom参数是只读的Route对象。因此,afterEach的最佳用例是保存数据,例如为“返回”按钮保存最后访问的Route对象,传递给路由目标的params,或页面视图跟踪。例如,我们可以设置一个默认值user,并在需要时赋值并保存:

let user = 'Adam';
router.beforeEach((to, from, next) => {
  if (to.name === 'about' && (!to.params || !to.params.user)) {
    next({ name: 'about', params: { user }})
  }
  else {
    user = to.params.user;
    next()
  }
});
router.afterEach((to, from) => {
  if (to.name === 'about' && to.params && to.params.user) {
    user = to.params.user;
  }
})

现在在App.js文件中,除了Adam外,添加以下内容:

<router-link 
  :to="{ name: 'about', params: { user: 'Adam' }}"
>
  About
</router-link>

让我们将其更改为Alex

<router-link 
  :to="{ name: 'about', params: { user: 'Alex' }}"
>
  About
</router-link>

点击About链接时的输出如下所示:

图 6.27:显示新用户名字的“关于”页面 – Alex

图 6.27:显示新用户名字的“关于”页面 – Alex

但是在重新加载时,由于传递了params中的用户,About页面将渲染为默认用户Adam,如下所示:

图 6.28:在重新加载时显示默认用户值“Adam”的“关于”页面

图 6.28:在重新加载时显示默认用户值“Adam”的“关于”页面

在本节中,我们探讨了afterEach钩子。我们使用afterEach钩子将数据传递到about页面,而无需在 URL 中包含该数据。同样的技术可以用于更新其他行为,例如按下“返回”按钮时希望的目标页面。

根据路由个性化钩子

而不是定义一个全局钩子,这可能会引起未知的错误并需要路由检查,我们可以在目标路由的配置对象中直接定义 beforeEnter 守卫,例如,我们的 About 路由:

beforeEnter: (to, from, next) => {
      if (!to.params || !to.params.user) {
        to.params.user = 'Adam'
      }
      next()
    }

使用这种方法,无论是重新加载还是点击链接导航到 About 页面,输出现在都是一致的,如下面的截图所示:

![图 6.29:使用用户值 Adam 渲染的关于页面]

![图片 B15218_06_29.jpg]

图 6.29:使用用户值 Adam 渲染的关于页面

注意

使用 beforeEnter()to 是可写的,你将能够访问 this(指向特定的路由 - About)。它只会在用户触发导航到 About 页面时被触发。

在本节中,我们探讨了 Vue 中可用的不同路由钩子,包括 beforeEachbeforeResolveafterEach。我们看到了每个钩子如何在路由过程中的不同点被调用。作为一个实际例子,我们查看了一个路由,如果没有提供参数,则将用户重定向到错误页面。这些钩子在设置认证路由时非常有用。在下一节中,我们将探讨设置组件内部的钩子。

设置组件内部的钩子

最后,我们还可以使用组件内部的钩子作为组件生命周期钩子,在需要将钩子作用域限定在组件级别以更好地维护代码或增强工作流程的情况下。

我们现在可以定义如下的 beforeRouteEnter() 钩子来拥有 About 组件:

<script>
export default {
  data() {
    return {
      user: ''
    }
  },
  beforeRouteEnter(to, from, next) {
    if (!to.params || !to.params.user) {
      next(comp => {
        comp.user = 'Alex'
      })
    }
    else {
      next();
    }
  }
}
</script>

正如你所见,在 beforeRouteEnter 期间,我们没有访问组件的 this 作用域,因为视图组件在触发时的那一刻仍在创建中。幸运的是,我们可以通过传递给 next() 的回调函数来访问实例。每当导航被确认,即组件被创建时,回调函数将被触发,组件实例将作为回调函数的唯一参数(comp)可用。

注意

对于 beforeRouteUpdatebeforeRouteLeave,组件已经被创建,因此这个实例是可用的,不需要为 next() 提供回调函数。实际上,回调函数仅在 beforeRouteEnter() 的使用中支持 next()

当相同的组件被用于不同的路由时,会调用 beforeRouteUpdate。这适用于我们使用动态路由的情况,这将在下一节中讨论。

当组件即将被停用或用户即将离开当前视图时,会触发 beforeRouteLeave。这发生在新导航的 beforeEach 守卫之前,通常用于编辑组件以防止用户在不保存的情况下离开。

在这个守卫中,我们可以通过向 next() 函数传递 false 来取消新的导航。

例如,假设我们在 About.vue 文件的组件选项中添加以下钩子:

//...
  beforeRouteLeave(to, from, next) {
    const ans = window.confirm('You are about to leave the About       page. Are you sure?');
    next(!!ans);
  }

当我们从“关于”页面导航离开时,会出现一个弹出对话框请求确认,如下面的截图所示,然后继续相应地导航:

图 6.30:在离开“关于”页面之前请求确认的对话框

图 6.30:在离开“关于”页面之前请求确认的对话框

在本节中,我们探讨了设置组件内钩子,即仅限于特定组件的钩子。我们为我们的About组件设置了一个组件内钩子,在用户离开页面之前要求用户确认。在下一节中,我们将把消息列表移动到外部文件,以便仅在MessageFeed可见时加载。

练习 6.04:将消息列表提取到外部文件并在 MessageFeed 可见时加载

回到练习 6.03将选中消息的内容传递到新消息页面并打印出来,现在我们将使用beforeEnterbeforeRouteEnter路由钩子进行一些代码增强。这个练习旨在让你更熟悉使用路由钩子。

要访问此练习的代码文件,请参阅packt.live/3lg1F2R

  1. 让我们从./src/views/MessageFeed.vue中提取messages静态数据并将其保存到./src/assets/messages.js中:

    const messages = [
      'Hello, how are you?',
      'The weather is nice',
      'This is message feed',
      'And I am the fourth message'
    ];
    export default messages;
    
  2. ./src/views/MessageFeed.vue中,我们将用props: { messages: { type: String, default: [] }}替换本地数据属性:

    export default {
      props: {
        messages: {
          type: Array,
          default: () => []
        }
      }
    }
    
  3. 现在,我们需要在导航到“消息”路由时加载消息列表并将其分配给messages参数。我们将通过使用路由配置对象中的beforeEnter钩子来完成此操作。别忘了添加props: true以将params标准化为相关的props以进行渲染。你可以通过修改src/router/index.js中定义的route来实现这一点:

    {
        path: '/messages',
        name: 'messageFeed',
        component: () => import(/* webpackChunkName: "messages" */       '../views/MessageFeed.vue'),
        props: true,
        async beforeEnter(to, from, next) {
          next()
        }
      },
    
  4. 我们将使用import懒加载消息列表:

    const module = await import (/* webpackChunkName: "messagesFeed"   */ '../assets/messages.js');
    
  5. 然后,按照以下方式检索所需信息:

      const messages = module.default;
      if (messages && messages.length > 0) {
        to.params.messages = messages;
      }
    
  6. src/router/index.js中路由的完整代码应该是以下内容:

    {
        path: '/messages',
        name: 'messageFeed',
        component: () => import(/* webpackChunkName: "messages" */       '../views/MessageFeed.vue'),
        props: true,
        async beforeEnter(to, from, next) {
          if (!to.params || !to.params.messages) {
            const module = await import (/* webpackChunkName:           "messagesFeed" */ '../assets/messages.js');
          const messages = module.default;
            if (messages && messages.length > 0) {
              to.params.messages = messages;
            }
          }
          next()
        }
      },
    

    在查看网站时,我们应该看到一个类似于上一个练习的消息推送。如下面的截图所示:

    图 6.31:重构后的消息推送页面

图 6.31:重构后的消息推送页面

到目前为止,我们已经学习和实践了如何使用不同的路由钩子配置路由、传递参数以及拦截应用中页面间的导航。在下一节中,我们将探讨一个更高级的主题——动态路由

动态路由

如果有很多遵循相同格式的大量数据,例如用户列表或消息列表,并且需要为每个创建一个页面,我们需要使用路由模式。使用路由模式,我们可以根据一些附加信息从相同的组件动态创建新路由。例如,我们想要为每个用户渲染 User 视图组件,但具有不同的 id 值。Vue Router 提供了我们使用冒号(:)表示的动态段来实现动态路由的能力。

我们不使用 params,因为 params 在刷新时不会持久化其值,也不会出现在 URL 中,我们直接在路径中定义所需的 params,如下所示:

{
    path: '/user/:id',
    name: 'user',
    component: () => import(/* webpackChunkName: "user" */       '../views/User.vue')
  }

在前面的代码中,:id 表示这里的 params 不是静态的。当路由与给定的模式匹配时,Vue Router 将渲染相应的组件,并保持 URL 不变。:id 的值将作为该视图组件实例中的 this.$route.params.id 暴露:

<template>
  <div>
    <h1>About a user: {{$route.params.id}}</h1>
  </div>
</template>

当用户选择 /user/1/user/2 等 URL 时(./src/App.vue),Vue 将自动使用我们的模板生成子页面。

导航路径将被映射到相同的路由模式组件,但带有不同的信息,如下面的截图所示:

![图 6.32:导航到 /user/2图片

图 6.32:导航到 /user/2

当你点击 User 1 时,你会看到以下内容:

![图 6.33:导航到 /user/1图片

图 6.33:导航到 /user/1

我们也可以使用 props: trueid 标准化到 User 组件的 props 中,并与 beforeRouteEnter() 结合,在实例创建和渲染之前加载数据:

<script>
import users from '../assets/users.js';
export default {
  props: {
    id: Number
  },
  data() {
    return {
      name: '',
      age: 0
    }
  },
  beforeRouteEnter(to, from, next) {
    next(vm => {
      const user = users[vm.id];
      vm.name = user.name;
      vm.age = user.age;
    })
  }
}
</script>

现在,我们可以调整 <template> 来打印出用户的详细信息:

<template>
  <div>
    <h1>About a user: {{$route.params.id}}</h1>
    <h2>Name: {{name}}</h2>
    <p>Age: {{age}}</p>
  </div>
</template>

选择 /user/1 时的输出将如下所示:

![图 6.34:使用更新后的 UI 导航到 /user/1图片

图 6.34:使用更新后的 UI 导航到 /user/1

如果我们在 user/:id 路由中更改 :id 为另一个用户,我们需要相应地更新本地数据,因为在这种情况下 beforeRouteEnter 不会再次触发。实际上,组件的所有生命周期钩子都不会被调用,因为组件实例不会被重新创建:

beforeRouteUpdate(to, from, next) {
    const user = users[to.params.id - 1];
    this.name = user.name;
    this.age = user.age;
    next();
  }

在本节中,我们通过设置一个从给定 URL 提取参数的路由来查看动态路由。这项技术允许你创建用户友好的 URL 并动态地将信息传递给路由。在下一节中,我们将查看捕获错误路径。

捕获错误路径

除了主页('/')之外,我们还需要记住处理的其他重要路由包括错误路由,例如当 URL 路径不匹配任何已注册路径时的 404 Not found 等。

对于404 未找到,我们可以使用regex星号*,它代表匹配所有内容来收集所有不匹配路由的情况。此路由器的配置应位于数组routes的末尾,以避免匹配错误路径:

{
    path: '*',
    name: '404',
    component: () => import(/* webpackChunkName: "404" */       '../views/404.vue'),
  }

当我们为/users输入错误的路径时,输出将如下所示:

![图 6.35:当'/users'路径未找到时重定向到 404img/B15218_06_35.jpg

图 6.35:当'/users'路径未找到时重定向到 404

在本节中,我们探讨了如何使用*正则表达式通配符来创建一个显示给所有导航到不存在路由的人的404页面。接下来,我们将实现一个消息路由,使用动态路由模式在 URL 本身传递相关数据。

练习 6.05:使用动态路由模式为每个消息实现消息路由

回到我们的消息源在练习 6.04中,将消息列表提取到外部文件并在 MessageFeed 视图中加载,我们将重构我们的Message路径,使用路由模式在用户选择时动态导航到特定的消息路径。这将使你熟悉在与其他导航钩子结合时创建和维护动态路由。

要访问此练习的代码文件,请参阅packt.live/32sWogX

  1. 让我们打开./src/router/index.js,将消息路由的路径配置更改为/message/:id,其中id将是消息列表中该message的索引:

    {
        path: '/message/:id',
        name: 'message',
        component: () => import(/* webpackChunkName: "message" */       '../views/Message.vue'),
        props: true,
    }
    
  2. 现在导航到./src/views/MessageFeed.vue,并将每个消息的router-linkto属性更改为以下内容:

    <router-link :to="`/message/${i}`">
    
  3. 让我们回到./src/router/index.js,并将beforeEnter定义为异步钩子,用于将消息内容懒加载到我们的Message组件的内容属性中:

    async beforeEnter(to, from, next) {
          if (to.params && to.params.id) {
            const id = to.params.id;
            const { module } = await import (/* webpackChunkName:           "messagesFeed" */ '../assets/messages.js');
            const messages = module.default;
            if (messages && messages.length > 0 && id <           messages.length) {
              to.params.content = messages[id];
            }
          }
          next()
        },
    
  4. 使用以下命令运行应用程序:

    yarn serve
    

    当点击消息源中的第一条消息时,下一页将如下所示:

    ![图 6.36:访问/message/0 路径时显示的页面 img/B15218_06_36.jpg

图 6.36:访问/message/0 路径时显示的页面

现在你已经学会了如何使用动态路由,你可以进一步探索更多层级的路由模式,如message/:id/author/:aid。然而,对于这种情况,我们通常采用更好的方法,嵌套路由

嵌套路由

在现实中,许多应用程序由由多个多级嵌套组件组成的组件构成。例如,/user/settings/general表示一个通用视图嵌套在settings视图中,而这个settings视图又嵌套在user视图中。它代表用户设置页面的通用信息部分。

大多数时候,我们希望 URL 与以下截图所示的结构相对应:

![图 6.37:具有两个嵌套视图的用户 – 信息和额外信息img/B15218_06_37.jpg

图 6.37:具有两个嵌套视图(信息和个人资料)的用户

Vue Router 通过使用嵌套路由配置和router-view组件,使实现这种结构变得简单。

让我们回到之前示例中的User.vue视图(位于./src/views/),并在<template>部分添加一个嵌套的router-view组件:

<div>
  <h1>About a user: {{$route.params.id}}</h1>
  <router-link :to="{ name: 'userinfo', params: { id: id }}">Info     </router-link> |
  <router-link :to="/user/${id}/extra">Extra</router-link>
  <router-view/>
  </div>

为了开始向此router-view渲染组件,我们将配置user路由以具有子选项,该选项接受子路由的路线配置数组。在我们的示例中,我们将为每个用户添加一个“信息”和“额外”页面。这些子路由将通过/user/:id/info/user/:id/extra访问,为每个用户提供唯一的“信息”和“额外”页面:

{
    path: '/user/:id',
    name: 'user',
    component: () => import(/* webpackChunkName: "user" */       '../views/User.vue'),
    props: true,
    children: [{
      path: 'info',
      name: 'userinfo',
      component: () => import(/* webpackChunkName: "info" */         '../views/UserInfo.vue')
    }, {
      path: 'extra',
      component: () => import(/* webpackChunkName: "extra" */         '../views/UserExtra.vue')
    }]
  }

并非所有嵌套路径都需要以/开头作为其父路径,这将避免它们被视为根路径,并使 Vue Router 计算匹配的路由更加容易。

当然,我们将在文件夹中创建两个新的视图,这些视图将根据接收到的id渲染有关用户的所有信息:

<template>
  <div>
    <h2>Name: {{name}}</h2>
    <p>Age: {{age}}</p>
  </div>
</template>
<script>
import users from '../assets/users.js';
export default {
  data() {
    return {
      name: '',
      age: 0
    }
  },
  beforeRouteEnter(to, from, next) {
    next(vm => {
      const user = users[to.params.id - 1];
      vm.name = user.name;
      vm.age = user.age;
    })
  },
  beforeRouteUpdate(to, from, next) {
    const user = users[to.params.id - 1];
    this.name = user.name;
    this.age = user.age;
    next();
  }
}
</script>

我们还创建了UserExtra.vue,它将渲染额外信息(如果有)。在这个例子中,它将仅渲染简单的文本:

<template>
  <div>
    <h2>I'm an extra section</h2>
  </div>
</template>

嵌套视图已准备就绪!每当用户点击“信息”链接时,它将加载“用户信息”视图并更新 URL 如下:

![图 6.38:包含嵌套用户信息视图的用户页面

![图片 B15218_06_38.jpg]

图 6.38:包含嵌套用户信息视图的用户页面

当用户点击“额外”时,他们将看到以下截图所示的内容:

![图 6.39:包含嵌套用户额外视图的用户页面

![图片 B15218_06_39.jpg]

图 6.39:包含嵌套用户额外视图的用户页面

在本节中,我们探讨了嵌套路由,即具有多个子路由的路由。在我们的示例中,子路由是用户信息和用户额外。这种模式允许我们创建扩展其父页面的页面。在前面的示例中,我们现在可以编辑“关于用户”标题,并使其对所有子路由生效。随着项目的增长,利用这种模式将允许您避免在多个视图中重复代码。

在下一节中,我们将利用到目前为止所学的内容来为我们的消息视图组件创建导航标签。

练习 6.06:在消息视图中构建导航标签

我们将把从“嵌套路由”部分学到的知识应用到构建从“练习 6.05”中创建的“消息”视图。

要访问此练习的代码文件,请参阅packt.live/2U9Bn6I

  1. 首先,让我们通过添加以下“作者”和“发送”字段来修改我们的messages数据库,该数据库位于src/assets/messages.js中:

    const messages = [
      {
        content: 'Hello, how are you?',
        author: 'John',
        sent: '12 May 2019'
      }, {
        content: 'The weather is nice',
        author: 'Lily',
        sent: '12 Jun 2019'
      },
      {
        content: 'This is message feed',
        author: 'Smith',
        sent: '10 Jan 2020'
      },
      {
        content: 'And I am the fourth message',
        author: 'Chuck',
        sent: '1 Apr 2021'
      },
    ];
    
  2. 接下来,我们将创建一个MessageAuthor.vue视图,该视图仅渲染消息创建者的姓名:

    <template>
      <div>
        <h3>Author:</h3>
        <p>{{message.author}}</p>
      </div>
    </template>
    <script>
    export default {
      props: {
        message: {
          type: Object,
          default: () => {}
        }
      }
    }
    </script>
    
  3. 然后,我们将创建一个MessageInfo.vue视图,该视图渲染message.sent值:

    <template>
      <div>
        <h3>Message info: </h3>
        <p>{{message.sent}}</p>
      </div>
    </template>
    <script>
    export default {
      props: {
        message: {
          type: Object,
          default: () => {}
        }
      }
    }
    </script>
    
  4. 一旦我们完成了组件,我们需要在我们的 src/router/index.js 路由中的 message 路由的子路由下注册新的嵌套路由:

    {
          path: '/message/:id',
          name: 'message',
        component: () => import(/* webpackChunkName: "message" */       '../views/Message.vue'),
        async beforeEnter(to, from, next) { ... },
        props: true,
        children: [{
          path: 'author',
          name: 'messageAuthor',
          props: true,
          component: () => import(/* webpackChunkName:         "messageAuthor" */ '../views/MessageAuthor.vue'),
        }, {
          path: 'info',
          props: true,
          name: 'messageInfo',
          component: () => import(/* webpackChunkName: "messageInfo"         */ '../views/MessageInfo.vue'),
        }]
      }
    
  5. 最后,在 Message.vue 中,我们将重构代码如下:

    <template>
      <div>
        <p>Message content: {{message.content}}</p>
        <router-link :to="{ name: 'messageAuthor', params: { message }}">Author</router-link> |
        <router-link :to="{ name: 'messageInfo', params: { message }}">Info</router-link>
        <router-view/>
      </div>
    </template>
    <script>
    export default {
      props: {
        id: {
          type: String
        },
        message: {
          default: () => {},
          type: Object,
        }
      }
    }
    </script>
    

    现在我们可以在一个 Message 中在 AuthorInfo 标签页之间导航如下:

    ![图 6.40:选择 Info 的消息页面

    ![图片 B15218_06_40.jpg]

    图 6.40:选择 Info 的消息页面

  6. 使用以下命令运行应用程序:

    yarn serve
    

    当你选择 Author 选项时,你会看到以下内容:

    ![图 6.41:选择作者的消息页面

    ![图片 B15218_06_41.jpg]

图 6.41:选择作者的消息页面

通过这个练习,我们几乎涵盖了 Vue Router 的所有基本功能,特别是处理动态和嵌套路由。在最后一节,我们将介绍如何为视图模板创建可重用的布局。

使用布局

在 Vue.js 应用程序中实现布局有许多方法。其中之一是使用 slot 并在 router-view 之上创建一个静态的包装器布局组件。尽管这种方法具有灵活性,但它会导致高昂的性能成本,包括组件的不必要重新创建以及在每次路由变化时所需的额外数据获取。

在本节中,我们将讨论一种更好的方法,即利用动态组件的力量。组件如下:

<component :is="layout"/>

App.vue 文件中,我们将更改 Vue CLI 生成的默认视图,使其仅包含 <router-view> 和围绕它的包装器。这个包装器是一个动态组件,它将渲染在 layout 变量中定义的任何组件:

<template>
  <div id="app">
    <component :is="layout">
      <router-view/>
    </component>
  </div>
</template>
<script>

默认情况下,我们将在 data 中定义 layoutdefault.vue 布局:

<script>
export default {
  data() {
    return {
      layout: () => import(/* webpackChunkName: "defaultlayout" */         './layouts/default.vue')
    }
  }
}
</script>

layouts 文件夹中,我们将创建一个具有简单头部导航、main 插槽以渲染实际内容(这是 <router-view> 渲染的内容)和页脚的 default 布局组件:

<template>
  <div class="default">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <main class="main">
      <slot/>
    </main>
    <footer>
      <div>Vue Workshop Chapter 06</div>
    </footer>
  </div>
</template>

回到我们的 App.vue 文件,为了使布局 component 能够根据相应的路由变化进行渲染,router-view 应该控制渲染哪个布局。换句话说,layout 应该是可更新的,并由 router-view 内部渲染的视图组件决定。为了实现这一点,我们将在 <router-view> 上使用 sync 定义 currentLayout 属性与 layout 保持同步:

<component :is="layout">
  <router-view :currentLayout="layout"/>
</component>

在创建 Home.vue 组件的实例时,我们将发出一个带有期望更新的布局的 update:currentLayout 事件,并相应地渲染:

import DefaultLayout from '../layouts/default.vue';
export default {
  name: 'home',
  components: {
    HelloWorld,
  },
  created() {
    this.$emit('update:currentLayout', DefaultLayout)
  }
}

输出结果如下:

![图 6.42:使用布局渲染的首页

![图片 B15218_06_42.jpg]

图 6.42:使用布局渲染的首页

由于 layout 组件不是 router-view 组件的一部分,它只有在布局从视图内部发生变化时才会重新渲染。这将保持应用程序在用户导航时的性能。

在本节中,我们探讨了如何使用动态component组件为不同的路由提供不同的布局。这使我们能够拥有不同的通用布局,例如,一个用于用户界面页面的全局菜单和另一个用于管理页面的菜单,这些菜单将根据使用的路由进行渲染。在下一节中,我们将通过创建一个具有动态嵌套路由和布局的消息应用来构建我们在这里学到的内容。

活动 6.01:创建具有动态、嵌套路由和布局的消息 SPA

本活动旨在利用您关于 Vue Router 的知识,包括注册路由、处理动态路由、嵌套路由和路由钩子,以创建消息****SPA。此应用将允许用户编写新消息、查看消息流并在消息之间导航以查看其详细信息:

  1. 创建一个MessageEditor视图(在src/views/MessageEditor.vue),它将为用户渲染一个带有textarea的视图和一个submit按钮来保存消息。

  2. src/router/index.js中将editor路由与MessageEditor视图注册。

  3. 创建一个MessageList视图(在src/views/MessageList.vue),它将渲染一个由a标签包裹的message id值的列表,当选择时,将跳转到具有给定id的单个消息页面。

  4. src/router/index.js中将list路由与MessageList视图注册。

  5. 添加Messages视图(在src/views/Messages.vue),它将渲染指向editorlist的链接作为其嵌套路由,并相应地渲染嵌套视图。

  6. 当用户从editor导航离开时,如果某些内容尚未提交,应显示一条消息询问他们是否在导航离开前保存。选择Yes将继续,选择No将中止导航。

  7. 添加一个Message视图(在src/views/Message.vue),它将从props渲染消息内容并具有一个返回按钮以返回到上一个视图。默认情况下,它应跳转到messages

  8. src/router/index.js中将Message视图与动态路由message/:id注册。

  9. 通过创建两个不同的简单布局来改进 UI,一个用于messages(仅包含标题)和一个用于message(包含标题和返回按钮)。

预期输出如下:

  • 显示消息流的/list视图应如下所示:

![图 6.43:消息应用中的 /list 视图]

图片 B15218_06_43.jpg

图 6.43:消息应用中的 /list 视图

  • 允许用户编写并发送新消息的/editor视图如下所示:

![图 6.44:消息应用中的 /editor 视图]

图片 B15218_06_44.jpg

![图 6.44:消息应用中的 /editor 视图]

  • Message应用中的/message/:id动态路由(这里,/message/0表示具有id0的消息)如下所示:

![图 6.45:消息应用中的 /message/0 视图]

图片 B15218_06_45.jpg

![图 6.45:消息应用中的 /message/0 视图]

当用户尝试带有未保存消息的导航离开时,将显示一个警告,如下面的截图所示:

![图 6.46:当用户尝试带有未保存消息的导航离开时的/editor 视图]

![img/B15218_06_46.jpg]

图 6.46:当用户尝试带有未保存消息的导航离开时的/editor 视图

注意

该活动的解决方案可以通过此链接找到。

摘要

在本章中,我们学习了 Vue Router 为构建任何 Vue.js 应用的路由所提供的最基本和最有用的功能,以有效和有序的方式进行。

router-viewrouter-link允许应用开发者轻松设置导航路径到相关视图,并保持 SPA 概念。它们本身是 Vue 组件的事实为我们开发者提供了 Vue 架构的好处,使我们能够在实现嵌套视图或布局时具有灵活性。

将路由定义为具有不同属性的对象简化了架构过程,包括重构现有路径和向系统中添加新路由。使用路由参数和模式提供了动态路由,具有可重用视图,并允许页面之间的通信和数据保留。

最后,通过 Hooks,我们看到了如何拦截导航流程,在需要的地方设置身份验证,重定向到期望的路径,甚至在用户到达目标页面之前加载并保留一些重要数据。这些 Hooks 有无数的使用场景,例如在实现返回按钮时非常有用。

通过这些,我们现在能够为用户提供适当的导航系统来探索 Vue.js 应用,同时保持每个导航用例(动态路由和嵌套视图)的数据流以及应用设计布局。

在下一章中,你将探索如何将过渡应用到路由上,并为你的 Vue 组件和应用添加不同的动画,以实现美丽的加载效果。

第七章:7. 动画与过渡

概述

本章将介绍如何在 Vue 应用程序中创建过渡和动画效果。在本章中,你将探索 Vue 过渡的基础知识。我们将涵盖单元素过渡、使用过渡组来动画化元素列表,以及使用过渡路由创建全页动画。你还将学习如何创建自己的过渡,以及如何与外部库结合使用以实现各种动画。

到本章结束时,你将准备好为任何 Vue 应用程序实现和处理基本过渡和动画效果。

简介

第六章路由 中,你学习了路由以及如何使用 Vue Router 设置基本的路由导航系统。通过平滑地实现不同路由之间的过渡,或者在用户与应用程序交互时为应用程序提供适当的动画效果,这是达到更高层次的目标。虽然页面之间的平滑过渡提供了更好的用户体验,但如加载或内容渲染方式等动画效果可以保持用户与应用程序的互动。因此,我们接下来的重点是 Vue 中的过渡和动画,以及我们可以如何将这些概念应用到我们的 Vue 应用程序中。

Vue 过渡

与其他框架不同,Vue.js 为开发者提供了内置支持,用于动画化 Vue.js 应用程序,包括过渡和动画。过渡的实现方式简单直接,开发者可以轻松配置并将其添加到他们的应用程序中。Vue.js 过渡机制支持 CSS 过渡、使用 JavaScript 的程序性操作,甚至可以与第三方动画库(如 GSAP 或 Animate.css)集成。

首先,我们将讨论过渡和动画之间的区别。当组件(或元素)从一个状态移动到另一个状态时,就会发生过渡,例如在按钮上悬停、从一个页面导航到另一个页面、显示弹出模态框等。同时,动画类似于过渡,但并不局限于仅两个状态。了解过渡的基础知识将帮助你开始学习动画。

过渡元素

在此示例中,为了为单个组件或元素启用过渡,Vue.js 提供了内置的 transition 组件,它将围绕目标元素包裹,如 ./src/components/HelloWorld.vue 中所示:

<transition name="fade-in">
  <h1>{{ msg }}</h1>
</transition>

transition 组件为任何目标元素或组件添加了两个过渡状态——enterleave,包括具有条件渲染(v-if)和条件显示(v-show)的组件。

此组件接收一个名为 name 的属性,它代表过渡的名称——在本例中是 fade-in——也是过渡类名的前缀,将在下面讨论。

过渡类

Vue.js 实现了基于 CSS 和类的leave/enter过渡效果,因此过渡将通过一组类选择器应用于目标组件。

这些类选择器都有v-前缀,以防在transition组件上没有提供name属性。还有一些标准类被分为两组。

第一组过渡类是用于enter过渡的,当组件首次显示时。以下是一个enter过渡类的列表:

v-enter(或<name>-enter):这是起始状态,在组件添加或更新之前添加到组件上。这个类将在过渡完成后从结束状态中移除。在src/components/HelloWorld.vue<style>部分,我们将.fade-in-enter起始状态设置为完全隐藏,使用opacity: 0

<style>
.fade-in-enter {
  opacity: 0;
}
</style>

v-enter-active(或<name>-enter-active):这个类定义了组件在活动进入过渡时的延迟、持续时间和缓动曲线。它将在组件插入之前添加到组件上,在整个进入阶段应用于组件,并在效果完成后移除。

让我们添加.fade-in-enter-active,它将在 3 秒内将透明度状态进行过渡:

.fade-in-enter-active {
  transition: opacity 3s easein;
}

v-enter-to(或<name>-enter-to):这是进入的最后一个子状态,其中在组件插入后添加效果帧,并在效果完成后移除。在我们的例子中,我们不需要定义任何内容,因为此状态的opacity值应该是1

第二组类包括leave过渡,当组件被禁用或从视图中移除时触发:

  • v-leave(或<name>-leave):这是离开过渡的起始状态。类似于v-enter-to,我们不需要为此状态定义样式效果。

  • v-leave-active(或<name>-leave-active):这个类在离开阶段应用,其行为类似于v-enter-active。由于我们想要实现淡出效果,我们将使用与fade-in-enter-active相同的样式:

    .fade-in-enter-active, .fade-in-leave-active {
      transition: opacity 3s ease-in;
    }
    
  • v-leave-to(或<name>-leave-to):这是与v-enter-to具有相似行为的结束状态。由于组件将从视图中消失,我们将重用为enter阶段的开始阶段定义的样式:

    .fade-in-enter, .fade-in-leave-to {
      opacity: 0;
    }
    

以下截图是到目前为止描述的所有transition状态的总结:

图 7.1:过渡阶段图解

图 7.1:过渡阶段图解

在本节中,我们探讨了进入和离开的三个不同过渡状态,还介绍了使用过渡状态在用户按下按钮时缓慢淡入一些文本的方法。

组件的动画

由于动画基本上是过渡的扩展形式(具有超过两个状态),因此它的应用方式与过渡相同,唯一的区别是v-enter只会在由 Vue.js 触发的animationend事件上被移除。

注意

animationend是一个 DOM 事件,当 CSS 动画完成执行时触发,条件是目标元素仍然存在于 DOM 中,并且动画仍然附加到该元素上。

在下一个示例中,在<template>部分,我们可以定义一个新的名为slide的过渡,使用动画 CSS 效果作为显示 msg 的h1元素的包装器。这个过渡提供了从左侧滑到中心在进入时的动画效果,在离开时则相反。

要开始,使用以下命令使用 CLI 生成一个vue起始项目:

vue create hello-world

接下来,打开项目并进入src/components/HelloWorld.vue,然后修改现有的<h1>{{msg}}</h1>代码:

<transition name="slide">
  <h1 v-if="show">{{ msg }}</h1>
</transition>

<style>中,我们需要为slide动画效果定义关键帧:

@keyframes slide {
  0% { transform: translateX(-100px)}
  100% { transform: translateX(0px)}
}

相关的过渡类将被分配以下样式:

.slide-enter, .slide-leave-to {
  transform: translateX(-100px);
}
.slide-enter-active {
  animation: slide 5s;
}
.slide-leave-active {
  animation: slide 5s reverse;
}

这意味着在进入的起始阶段和离开的结束阶段,文本位置将位于页面指定位置-100px处。浏览器将使用滑动关键帧在 5 秒内对元素进行动画处理,并且在离开的活跃状态下,动画将正好与进入活跃阶段的动画相反。

你还想要添加一个显示数据变量。你可以通过修改现有的导出来实现,如下所示:

<script>
export default {
  name: 'HelloWorld',
  data: {
    showHello: true,
  },
  props: {
    msg: String
  }
}
</script>

有了这些,我们就实现了我们的动画。接下来是下一个挑战:如果我们想将不同的动画或过渡效果组合到进入和离开状态,或者为这些状态使用外部 CSS 库,该怎么办?让我们看看自定义过渡类。

自定义过渡类

在本节中,我们再次从使用 vue create hello-world 创建的默认起始项目开始。我们不是设置过渡名称,让 Vue.js 机制填充所需的类名,而是可以通过以下属性提供自定义类,并替换传统默认值。

对于进入状态,使用以下代码:

  • enter-class

  • enter-active-class

  • enter-to-leave

对于离开状态,使用以下代码:

  • leave-class

  • leave-active-class

  • leave-to-class

我们将从一个基于之前示例的文件开始,但现在我们将对进入状态的活跃阶段使用swing动画效果,对离开状态的活跃阶段使用tada效果。我们将在transition组件中定义enter-active-classleave-active-class属性,如下所示:

<transition
      name="slide"
      enter-active-class="swing"
      leave-active-class="tada"
    >
      <h1 v-if="show">{{ msg }}</h1>
    </transition>

<style>部分,我们只需要定义.tada.swing,无需任何后缀模式:

.tada {
  animation-fill-mode: both;
  animation-name: tada;
  animation-duration: 3s;
}
.swing {
  animation-fill-mode: both;
  transform-origin: top center;
  animation-duration: 2s;
  animation-name: swing;
}

然后添加专用的关键帧来设置动画:

@keyframes tada {
  0% {
    transform: scale3d(1, 1, 1);
 }
  10%, 20% {
    transform: scale3d(.8, .9, .8) rotate3d(0, 0, 1, -5deg);
  }
  30%, 50%, 70%, 90% {
    transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 5deg);
  }
  40%, 60%, 80% {
    transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -5deg);
  }
  100% {
    transform: scale3d(1, 1, 1);
  }
@keyframes swing {
  20% { transform: rotate(5deg); }
  40% { transform: rotate(-10deg); }
  60% { transform: rotate(5deg); }
  80% { transform: rotate(-10deg); }
  100% { transform: rotate(0deg); }
}

你还想要添加一个显示数据变量。你可以通过修改现有的export来实现,如下所示:

<script>
export default {
  name: 'HelloWorld',
  data: {
    showHello: true,
  },
  props: {
    msg: String
  }
}
</script>

当我们使用yarn serve命令运行应用程序时,我们将为进入和离开设置单独的动画。以下截图显示了屏幕现在的样子:

图 7.2:动作上的摆动动画效果

图片

图 7.2:动作上的摆动动画效果

你应该看到欢迎文本在旋转的同时缩小,从图 7.2中显示的过渡到以下:

图 7.3:动作上的 tada 动画效果

图片

图 7.3:动作上的 tada 动画效果

在本节中,我们探讨了创建自定义过渡效果。作为示例,我们创建了swingtada。我们通过在样式表中定义过渡类并为每个效果添加关键帧来实现这一点。这种技术可以用来创建各种自定义过渡效果。在下一节中,我们将探讨 JavaScript 钩子以及它们如何用于更复杂的动画。

JavaScript 钩子

如我们在上一节所学,我们可以使用自定义过渡类来集成外部第三方 CSS 动画库以实现样式效果。然而,有些外部库不是基于 CSS 的,而是基于 JavaScript 的,例如Velocity.jsGreenSock Animation APIGSAP),这些库需要通过 JavaScript 事件和外部动画处理程序设置钩子。

为了在 Vue 应用程序中使用 Velocity.js 或 GSAP 库,你需要分别使用npm installyarn add命令安装它们,如下所示:

  • 要安装 Velocity.js,请使用以下命令:

    npm install velocity-animate 
    #Or
    yarn add velocity-animate
    
  • 要安装 GSAP,请使用以下命令:

    npm install gsap
    #or
    yarn add gsap
    

作为 Vue.js 组件,transition组件支持将自定义处理程序绑定到事件列表的 props 上。考虑以下示例:

<transition
  @before-enter="beforeEnter"
  @enter="enter"
  @leave="leave"
>
  <h1 v-if="show">{{ msg }}</h1>
</transition>

我们将动画方法程序性地绑定到过渡元素的相关事件上:

  • beforeEnter是在组件插入之前的动画状态——类似于 v-enter 阶段。

  • enter用于整个进入阶段的动画——类似于 v-enter-active 阶段。

  • leave用于整个离开阶段的动画。这类似于 v-leave-active 阶段。

我们需要在HelloWorld.vue组件配置的methods部分中定义这些事件处理程序:

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      show: false
    }
  },
  methods: {
    beforeEnter() {
      //...
    },
    enter() {
      //...
    },
    leave() {
      //...
    }
  }
}
</script>

在本例中,我们将使用 GSAP 库提供的TweenMaxTimelineMax功能来创建我们的动画事件,如下所示:

    beforeEnter(el) {
      el.style.opacity = 0;
    },
    enter(el, done) {
      TweenMax.to(el, 2, {
        opacity: 1,
        fontSize: '20px',
        onComplete: done
      })
    },
    leave(el, done) {
      const tl = new TimelineMax({
        onComplete: done
      });

      tl.to(el, {rotation: -270,duration: 1, ease: "elastic"})
        .to(el, {rotation: -360})
        .to(el, {
          rotation: -180,
          opacity: 0
        });
    }

对于TweenMaxTimelineMaxto()动画触发方法的语法相当简单:

TimelineMax.to(<element>, <effect properties>, <time position>)
TweenMax.to(<element>, <effect properties>, <time position>)

大多数效果属性与 CSS 的语法相似,因此它们不难学习和使用。此外,我们必须将事件发射器接收到的 done 回调传递给 onComplete,以确保它被触发,并且钩子不会同步调用。另外,请注意,所有事件发射器也传递 el,它是当前过渡元素的指针,用于使用。

除了这三个事件之外,我们还可以绑定其他事件,具体取决于动画和过渡的复杂度,例如 afterEnterenterCancelledbeforeLeaveafterLeaveleaveCancelled

请注意,如果你仅使用 JavaScript 进行过渡,强烈建议添加 v-bind:css="false"(或 :css="false")。这是为了防止 Vue.js 侦测并应用任何相关的 CSS,从而避免意外地发生过渡干扰:

<transition
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
      :css="false"
    >
      <h1 v-if="show">{{ msg }}</h1>

在本节中,我们探讨了如何使用外部 JavaScript 库进行动画。我们使用 GSAP 库实现了一个简单的缓动,利用其 TweenMaxTimelineMax 函数。

现在,让我们学习如何使用动画效果添加新消息。

练习 7.01:使用动画效果添加新消息

我们将创建一个消息编辑器,用户可以在其中编写和提交新消息。新消息将立即通过从右向左的滑动动画效果显示。

要访问此练习的代码文件,请参阅 packt.live/338ZXJv

注意

在开始此练习之前,运行 vue create 命令以生成 Vue 入门项目。

  1. 首先创建一个名为 MessageEditor.vue 的新组件,位于 /src/components/ 文件夹中。在这个组件中,<template> 将包含两个部分,textarea 用于编写新消息,并有一个提交按钮,以及 section 用于显示新编写的消息:

    <template>
      <div>
        <div class="editor--wrapper">
          <textarea ref="textArea" class="editor">
          </textarea>
          <button @click="onSendClick()" class="editor--submit">
          Submit</button>
        </div>
        <section v-if="message" class="message--display">
          <h4>Your saved message: </h4>
          <span>{{message}}</span>
        </section>
      </div>
    </template>
    
  2. 接下来,将整个消息部分包裹在 transition 元素中,为我们的动画做准备。

    <transition name="slide-right">
          <section v-if="message" class="message--display">
            <h4>Your saved message: </h4>
            <span>{{message}}</span>
          </section>
        </transition>
    
  3. 我们需要一个具有更改消息文本方法的 export 组件。使用以下代码添加它:

    <script>
    export default {
      data() {
        return {
          message: ''
        }
      },
      methods: {
        onSendClick() {
          const message = this.$refs.textArea.value;
          this.message = message;
          this.$refs.textArea.value = '';
        }
      }
    }
    </script>
    
  4. 接下来,我们将使用以下命令在我们的 style 部分使用 @keyframes 定义 slide-right 动画效果:

    <style scoped>
    @keyframes slide-right {
      100% {
        transform: translateX(0)
      }
    }
    </style>
    

    这意味着它将具有此效果的元素在水平方向上(在 X 轴上)重新定位到原始起始点,(0,0)。

  5. 现在,我们将定义两个类,一个用于从左到右滑动(slide-right),另一个用于相反方向(slide-left):

    .slide-right {
      animation: 1s slide-right 1s forwards;
      transform:translateX(-100%);
      transition: border-top 2s ease;
    }
    .slide-left {
      animation: 1s slide-right 1s reverse;
      transform:translateX(-100%);
    }
    
  6. border-top:0 作为 slide-right 过渡的起始点,以便对这一部分的 border-top 产生一点效果:

    .slide-right-enter {
      border-top: 0;
    }
    
  7. 接下来,利用我们学到的关于自定义过渡类的知识,将 enter-active 绑定到 slide-right 类,并将 leave-active 类似地绑定到 slide-left。这三个属性被添加到在 步骤 2 中创建的 transition 元素中:

    <transition
          name="slide-right"
          enter-active-class="slide-right"
          leave-active-class="slide-left"
        >
    Add CSS stylings using CSS Flexbox to make the editor look nice:
    .editor--wrapper {
      display: flex;
      flex-direction: column;
    }
    .editor {
      align-self: center;
      width: 200px;
    }
    .editor--submit {
      margin: 0.5rem auto;
      width: 50px;
      align-self: center;
    }
    .message--display {
      margin-top: 1rem;
      border-top: 1px solid lightgray;
    }
    
  8. 使用 yarn serve 命令运行应用程序。

    这将生成一个组件,将显示带有滑动动画效果的输入消息,如图 图 7.4 所示:

    ![图 7.4:消息编辑器文本区域 图片 B15218_07_04

图 7.4:消息编辑器文本区域

以下截图显示了消息组件具有从左到右滑动动画效果的外观:

![图 7.5:用于显示的消息过渡图片 B15218_07_05

图 7.5:用于显示的消息过渡

从左侧动画进入后,组件应停在居中位置,如图 图 7.6 所示:

![图 7.6:动画后的消息图片 B15218_07_06

图 7.6:动画后的消息

这个练习帮助您熟悉 CSS 中的某些转换效果,例如 translateXtransition。它还展示了在 Vue 应用程序中添加动画是多么容易。对于同一组中的多个元素,如列表,过渡怎么办?我们将在下一个主题中找到答案。

过渡组

到目前为止,我们已经介绍了 Vue 过渡元素的基本知识,适用于简单组件和元素,同时支持自定义 CSS 仅和 JavaScript 仅动画。接下来,我们将探讨如何使用 v-for 在一组组件上应用过渡,例如,同时渲染的项目列表。

Vue.js 为此特定目的提供了另一个组件,即 transition-group 组件。

我们现在假设我们有一个显示在源上的消息列表,我们希望为此列表添加一个过渡效果,以便在屏幕上每个项目出现时产生一些效果。在 ./src/components/Messages.vue 文件中,让我们用 transition-group 组件包裹主要容器,并传递我们之前为 transition 组件使用的相同属性。它们具有相同的属性类型:

<transition-group name="fade">
  <p v-for="message in messages" :key="message" v-show="show">
    {{message}}
  </p>
</transition-group>

我们需要为传递为 fade 的过渡效果设置 CSS 样式效果,遵循与过渡类相同的语法规则:

.fade-enter-active, .fade-leave-active {
  transition: all 2s;
}
.fade-enter, .fade-leave-active {
  opacity: 0;
  transform: translateX(30px);
}

使用 yarn serve 命令运行应用程序后,您的列表项在出现时将具有淡入效果。以下截图显示了您的屏幕应该如何显示:

![图 7.7:列表项的淡入图片 B15218_07_07

图 7.7:列表项的淡入

注意,与不渲染任何包装容器元素的 transition 组件不同,transition-group 将渲染一个实际元素,您可以通过使用 tag prop 来更改元素标签名。默认使用的元素是 span

<transition-group
  name="fade"
  tag="div"
>
  <p v-for="message in messages" :key="message" v-show="show">
      {{message}}
  </p>
</transition-group>

在浏览器中,实际的 HTML 输出将如下所示:

![图 7.8:根据标签属性渲染的过渡容器元素图片 B15218_07_08

图 7.8:根据标签属性渲染的过渡容器元素

此外,所有过渡类只应用于具有 v-for 属性的列表项元素,而不应用于包装器。

最后,你必须为每个列表项设置 :key 属性,以便 Vue.js 能够索引并知道要将转换应用到哪个项上。

我们现在将在列表上创建移动效果。

在过渡列表时创建移动效果

除了 transition 组件中提供的所有类之外,transition-group 还有一个类 v-move,它允许我们在每个项移动到其位置时添加额外的效果。可以通过 move-class 属性手动分配:

.fade-move {
  transition: transform 2s ease-in;
}

接下来,我们将探讨在页面或组件的初始渲染上制作动画。

在初始渲染时制作动画

通常,项目列表将在第一次初始页面加载时显示,我们的动画将不会工作,因为元素已经在视图中。为了触发动画,我们需要使用不同的转换属性 appear,在页面加载后立即强制在初始页面渲染上进行动画:

<transition-group
    appear="true"
    tag="div"
>
    <p v-for="message in messages" :key="message">{{message}}</p>
</transition-group>

我们还可以使用 v-on:after-appearv-on:appearv-on:after-appearv-on:appear-cancelled 设置钩子,或者我们可以使用以下格式创建自定义类:

<transition-group
  appear="true"
  appear-class="fade-enter"
  appear-active-class="fade-enter-active"
  tag="div"
>
  <p v-for="message in messages" :key="message">{{message}}</p>
</transition-group>

在渲染时进行动画是一个常用的功能,可以在许多情况下使用,例如像我们在这里所做的那样淡入组件。在下一节中,我们将探讨如何使用动画对消息列表进行排序。

练习 7.02:使用动画对消息列表进行排序

在这个简短的练习中,我们将向消息列表添加额外的功能:排序。在排序(A-Z 或 Z-A)时,列表将会有翻转动画效果。

要访问此练习的代码文件,请参阅 packt.live/35TFs5l

注意

在开始此练习之前,运行 vue create 命令以生成 Vue 入门项目。

  1. 我们将使用之前用于在 Messages.vue 组件中渲染消息的相同组件代码。列表将被 transition-group 组件包裹,准备进行动画。并且不要忘记设置 appear="true",或者简单地使用 appear,以便元素仅在页面加载完成后进行动画:

    <transition-group
          appear
          name="flip"
          tag="div"
        >
          <p v-for="message in messages" :key="message"
      class="message--item"
          >{{message}}</p>
        </transition-group>
    
  2. 使用 yarn serve 命令运行应用程序。这将生成以下输出:图 7.9:动画前的消息列表

    图 7.9:动画前的消息列表

  3. 没有动画,因为我们还没有为 flip 定义 CSS 动画样式。让我们来做。在 src/components/Messages.vue<style> 部分中,我们将添加 opacity: 0 并将列表中的每个元素垂直(在 Y 轴上)从原始位置移动 20px。这应该是元素进入 flip-enter 或即将离开转换到 flip-leave-to 的初始阶段:

    <style scoped>
      .flip-enter, .flip-leave-to {
        opacity: 0;
        transform: translateY(20px);
      }
    </style>
    
  4. 在相同的<style>部分,为每个消息元素(message-item类)添加自定义 CSS 样式transition: all 2s。这是为了确保元素的过渡效果将在2秒内完成所有 CSS 属性的转换:

    .message--item {
      transition: all 2s;
    }
    
  5. 一旦flip-move开始工作,我们只需要为transform(之前定义为垂直20px偏移)添加过渡效果。我们可以完美地看到每个消息的上下移动效果。此外,我们还需要在过渡处于离开阶段中间时添加position: absolute

    .flip-leave-active {
      position: absolute;
    }
    .flip-move {
      transition: transform 1s;
    }
    
  6. 我们接下来将添加三个按钮——允许从 A 到 Z 排序、从 Z 到 A 排序以及随机洗牌:

    <button @click="sorting()">Sort A-Z</button>
    <button @click="sorting(true)">Sort Z-A</button>
    <button @click="shuffle()">Shuffle</button>
    
  7. 我们还需要添加我们的基本组件导出代码以及我们的消息源数据。请随意使用您喜欢的任何内容作为您的消息:

    export default {
      data() {
        return {
          messages: [
            'Hello, how are you?',
            'The weather is nice',
            'This is message feed',
            'And I am the fourth message',
            'Chapter 7 is fun',
            'Animation is super awesome',
            'Sorry, I didn't know you called',
            'Be patient, animation comes right up'
          ],
          show: false
        }
      },
    }
    
  8. 接下来,我们将添加排序和洗牌的逻辑。methods部分应该位于上一步创建的组件export内部:

      methods: {
        sorting(isDescending) {
          this.messages.sort();
          if (isDescending) { this.messages.reverse(); }
        },
        shuffle() {
          this.messages.sort(() => Math.random() - 0.5);
        }
      }
    

    点击按钮后的输出将类似于以下内容:

    图 7.10:排序动画中的消息列表

图 7.10:排序动画中的消息列表

在这个练习中,我们学习了如何根据元素顺序的变化,使用transition-group动态地为组件列表添加翻转动画效果。接下来,让我们探索如何在页面之间导航时应用过渡效果。

过渡路由

通过结合 Vue Router 的router-element组件和transition组件,我们可以轻松地设置用户在从一个 URL(路由)导航到另一个 URL 时的过渡效果。

为了让您有更深入的理解,我们在以下部分演示了一个基本案例,其中用户从网站的home页面重定向到about页面。

让我们将router-element包裹在transition中,并添加name="zoom"属性:

<transition
  name="zoom"
  mode="out-in"
>
  <router-view/>
</transition>

在这里,我们将使用mode属性来指示过渡模式。目前有两种模式可供设置:

  • in-out:新元素首先进入,然后当前元素才会从视图中消失。

  • out-in:当前元素首先消失,然后新元素才会进入。我们将使用这个模式作为示例,它比上一个模式更常见。

然后,我们只需像往常一样设置带有过渡类的过渡 CSS 效果,任务就完成了。就这么简单:

/**Zoom animation **/
.zoom-enter-active,
.zoom-leave-active {
  animation-duration: 0.3s;
  animation-fill-mode: both;
  animation-name: zoom;
}
.zoom-leave-active {
  animation-direction: reverse;
}
@keyframes zoom {
  from {
    opacity: 0;
    transform: scale3d(0.4, 0.4, 0.4);
  }
 100% {
    opacity: 1;
 }
}

在本节中,我们探讨了过渡路由。过渡效果是在路由渲染之间发生的动画,例如从一个页面导航到另一个页面。在下一节中,我们将探讨如何在我们的应用程序中为每个导航的路由创建过渡效果。

练习 7.03:为每个导航的路由创建过渡效果

在此练习中,我们将根据 过渡路由 部分学到的关于路由元素过渡的知识,为不同的路由创建不同的过渡效果。默认效果将是 fade

要访问此练习的代码文件,请访问 packt.live/376DoXo

注意

在开始此练习之前,运行 vue create 命令以生成 Vue 入门项目。

  1. 使用 Vue Router 创建一个简单的应用程序,并在 src/views/ 文件夹中添加一个名为 Messages.vue 的路由,为 messages 添加一个路由。使用前一个练习中的代码,并在 App.vue 中添加一个指向新创建的路由的链接。

  2. 接下来,我们在 App.vue 中将 router-view 元素包裹在 transition 组件中:

        <transition :name="transition" :mode="mode">
          <router-view/>
        </transition>
    
  3. App.vueexport 部分中,确保 data 函数包含 transitionmode 的值,如下所示:

      data() {
        return {
          transition: 'fade',
          mode: 'out-in',
        };
      },
    
  4. App.vue 中使用以下 CSS 添加淡入淡出的样式:

    <style>
      .fade-enter, .fade-leave-to {
        opacity: 0;
      }
      .fade-enter-active, .fade-leave-active {
        transition: opacity 1s ease-in;
      }
    </style>
    
  5. 到目前为止,所有页面都使用 fade 效果加载,包括 /messages。但我们希望消息页面使用不同的效果——zoom 效果。接下来,在同一个 style 标签内添加相关的 zoom 动画 CSS 代码:

    /**Zoom animation */
    .zoom-enter-active,
    .zoom-leave-active {
      animation-duration: 0.5s;
      animation-fill-mode: both;
      animation-name: zoom;
    }
    .zoom-leave-active {
      animation-direction: reverse;
    }
    @keyframes zoom {
     from {
        opacity: 0;
        transform: scale3d(0.4, 0.4, 0.4);
     }
      100% {
        opacity: 1;
      }
    }
    
  6. 我们现在将使用以下代码帮助添加一些标准的 CSS 样式以应用于应用程序的默认布局:

    #app {
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
    }
    #nav {
      padding: 30px;
    }
    #nav a {
      font-weight: bold;
      color: #2c3e50;
    }
    #nav a.router-link-exact-active {
      color: #42b983;
    }
    
  7. 现在我们需要将 /messages 路由与这个特定的过渡效果相匹配,同时不影响其他路由。为了做到这一点,我们需要在 src/router/index.js 中的此路由配置中添加一个名为 transition 的字段:

      {
        path: '/messages',
        name: 'messages',
        meta: {
          transition: 'zoom',
        },
        component: () => import(/* webpackChunkName: "about" */       '../views/Messages.vue')
      }
    
  8. 检查您的 routes 对象的代码,以确认它与以下代码相同。在这里,我们将我们应用程序的每个 URL 与一个视图文件相匹配:

    const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import(/* webpackChunkName: "about" */       '../views/About.vue')
      },
      {
        path: '/messages',
        name: 'messages',
        meta: {
          transition: 'zoom',
        },
        component: () => import(/* webpackChunkName: "messages" */       '../views/Messages.vue')
      }
    ]
    
  9. 这在浏览器中不会显示,因为此过渡声明尚未与 App.vue 组件的 data 字段绑定,并且需要在视图开始加载之前绑定。为此,我们将利用 第六章 中提到的 $router 全局变量的 created 生命周期钩子和 beforeEach 路由钩子。

  10. 让我们在 App.vue 中添加一个在每次路由更改之前的钩子。我们将检查目标路由(to)是否有自定义的 transition 效果。如果有,我们将把 App 实例中的 transition 值映射到它;否则,在继续导航之前,我们将使用回退默认值,如下所示:

    created() {
        this.$router.beforeEach((
          to, // The destination route
          from, //The source route
          next //The function to trigger to resolve the hook
        ) => {
          let transition = 'fade';
          if (to.meta && to.meta.transition) {
            transition = to.meta.transition;
          }
          this.transition = transition;
          next();
        })
      }
    
  11. 使用以下命令运行应用程序:

    yarn serve
    
  12. 现在如果您在浏览器中打开 localhost:8080 并导航到 /messages,您应该会看到类似于 图 7.11 的内容:图 7.11:带有缩放效果的导航到 /messages

图 7.11:带有缩放效果的导航到 /messages

在导航到其他路由时,我们应该看到 图 7.12 中显示的默认过渡效果:

图 7.12:带有淡入效果的导航到 /home

图 7.12:带有淡入效果的导航到 /home

这个练习演示了我们可以如何通过结合正确的钩子和方法,以最少的努力在不同的页面上设置不同的过渡。你可以通过外部库进一步实验,以使你的应用程序动画更加平滑和生动。

使用 GSAP 库进行动画

GSAP 是一个专注于使用 JavaScript 进行快速动画的开源脚本库,并提供跨平台的兼容性支持。它支持在广泛的元素类型上动画,例如矢量图形 (SVG)、React 组件、画布等。

GSAP 是灵活的,易于安装,并能适应任何配置,从 CSS 属性或 SVG 属性到将对象渲染到画布上的数值。

核心库是一套不同的工具,分为核心和其他,例如插件、缓动工具和实用工具。

安装 GSAP

使用 npm installyarn add 可以直接安装 GSAP:

yarn add gsap
#or
npm install gsap

安装后,你应该会看到一个类似于以下截图的成功输出:

![图 7.13:成功安装后的结果图片 B15218_07_13.jpg

图 7.13:成功安装后的结果

现在我们已经安装了 GSAP,我们将看看 GSAP 中的基本缓动动画。

基本缓动动画

缓动是由 GSAP 库的创建者定义的一个概念,是一个高性能的设置器,用于执行所有基于用户配置输入的所需动画工作。输入可以是动画的目标对象、一个时间段或任何特定的 CSS 属性。在执行动画时,缓动根据给定的持续时间确定 CSS 属性的值,并相应地应用它们。

以下是一些创建基本缓动动画的基本方法。

gsap.to()

最常用的缓动是 gsap.to(),它用于创建动画,基于两个主要参数:

  • #myId

  • 透明度:0旋转:90,或 字体大小:'20px',动画属性如 持续时间:1延迟:0.2,或 缓动:"弹性",以及事件处理程序属性如 onCompleteonUpdate

例如,如果我们想在 HelloWorld.vue 中动画化 Vue 的标志,我们运行以下代码:

gsap.to(el, {duration: 3, opacity: 1, onComplete: done});

或者使用以下方法通过 x 属性移动一个对象(与 transform: translateX() 相同):

gsap.to(".green", {duration: 3, x: 500, rotation: 360});

gsap.from() 和 gsap.fromTo

我们并不总是想为视图中元素定义预期的动画效果。相反,我们定义动画应该从目标元素开始时的默认值。这就是我们使用 gsap.from() 的时候。

例如,假设一个盒子的当前 透明度 值为 1缩放 值为 1x 位置为 0,我们想要设置一个动画,从位置 x300透明度 值为 0缩放 值为 0.5 开始。换句话说,动画将从 {x: 300, 透明度: 0, 缩放: 0.5} 开始,到元素当前拥有的任何值:

gsap.from(".red", {duration: 3, x: 300, scale: 0.5, opacity: 0});

但在许多情况下,我们需要为动画设置起始值和结束值,因为单侧不够好。为此目的,GSAP 提供了gsap.fromTo(),其语法如下:

gsap.fromTo(target, fromValues, toValues)

让我们定义一个动画,将一个灰色框从原始值{ opacity: 0, scale: 0.5, x: 300 }转换为值{ opacity: 1, scale: 1, x: 100, rotation: 360}

gsap.fromTo(".grey",
    { duration: 3, opacity: 0, scale: 0.5, x: 600 },
    { duration: 3, opacity: 1, scale: 1, x: 200, rotation: 360}
  )

为了将所有类似 CSS 的值转换为相应的 CSS 值,GSAP 的核心插件之一是CSSPlugin。此插件将自动检测目标是否为 DOM 元素,拦截传递的值,将它们转换为适当的 CSS 值,然后相应地将其作为内联样式应用到元素上。

在下一节中,我们将通过一个使用 GSAP 创建简单缓动的练习进行讲解。

练习 7.04:使用 GSAP 进行缓动

本练习的目标是让您熟悉使用外部库,如 GSAP。我们将制作一个简单的动画,但您可以在 Vue 代码的任何地方应用此相同的模式。我们将在挂载时应用动画,但 JavaScript 动画可以根据计时器、随机整数或按钮等输入动态触发。

要访问此练习的代码文件,请访问packt.live/3kVO4gm

注意

在开始此练习之前,运行vue create命令以生成 Vue 入门项目。

  1. 通过运行以下命令创建一个 Vue 项目:

    vue create Exercise7.04
    
  2. 使用yarnnpm通过以下命令之一安装 GSAP:

    yarn add gsap
    # OR
    npm install gsap
    
  3. src/App.vue中导入 GSAP:

    import gsap from 'gsap'
    
  4. src/App.vue中找到现有的img标签,并按照以下方式添加ref="logo"

    <img ref="logo" alt="Vue logo" src="img/logo.png">
    
  5. src/App.vue中导出的对象中添加一个名为mounted的函数,该函数将 logo 定义为变量并添加一个动画,该动画为10次旋转,持续30秒:

      mounted() {
        const { logo } = this.$refs;
        gsap.to(logo, {duration: 30, rotation: 3600});
      }
    
  6. 接下来,通过在终端运行yarn serve来启动应用程序。

  7. 打开您的浏览器到localhost:8080,您应该看到默认的 Vue 入门页面,但带有旋转的 logo,如下面的截图所示:![图 7.14:使用 GSAP 的简单动画

    ![img/B15218_07_14.jpg]

图 7.14:使用 GSAP 的简单动画

在这个练习中,我们使用 GSAP 在 Vue 中实现了一个简单的旋转动画。接下来,我们将看到如何通过缓动修改动画的外观和感觉。

使用缓动修改外观和感觉

缓动很重要,因为它决定了动画的原点与目的地之间的运动风格。它控制缓动过程中的变化率;因此,用户有时间看到效果,无论是平滑、突然、弹跳还是其他过渡效果:

gsap.from(".bubble", 2, {
      scale: 0.2,
      rotation: 16,
      ease: "bounce",
    })

此外,GSAP 还提供了额外的内置插件,用于配置额外的缓动效果,例如 power、back、elastic 等。以气泡效果为例;为了使运动在一定范围内平滑,我们使用Back.easeOut.config()Elastic.easeOut.config()并传递相关设置:

gsap.to(".bubble", 2, {
      scale: 0.2,
      rotation: 16,
      ease: Back.easeOut.config(1.7),
    })
    gsap.to(".bubble", 4, {
      scale: 1.2,
      rotation: '-=16',
      ease: Elastic.easeOut.config(2.5, 0.5),
    })

使用ease,我们可以根据设置的样式使相同的动画看起来完全不同。接下来,我们将探讨stagger,这是另一个影响动画外观和感觉的选项。

使用交错修改外观和感觉

在前面的章节中,我们介绍了如何使用 Vue 过渡来动画化一系列项目。对于对象列表,我们应该考虑交错动画,因为它使得此类目标的动画变得简单,并且每个项目动画之间有适当的延迟。

例如,通过将值分配给stagger属性,我们可以在除了延迟持续时间数字(以毫秒为单位)之外创建并应用一些配置选项:

gsap.to('.stagger-box', 2, {
      scale: 0.1,
      y: 60,
      yoyo: true,
      repeat: 1,
      ease: Power1.inOut,
      delay:1,
      stagger: {
        amount: 1.5,
        grid: "auto",
        from: "center"
      }
    })

您可以使用repeat来定义动画应该重复多少次。负数将使其无限重复。

使用时间线

时间线是您完全控制的缓动调度,用于定义缓动之间的重叠或间隔。当您需要根据顺序控制一组动画、构建一系列动画、链式动画以进行最终回调或模块化动画代码以实现可重用时,它非常有用。

为了使用时间线,您可以选择使用内置的gsap.timeline()方法创建时间线实例,或者从核心库中导入TimelineMaxTimelineLite并使用一组配置设置实例,如下所示:

import { TimelineMax } from 'gsap';
const tl = new TimelineMax({
  onComplete: done
})
//OR
const tl = gsap.timeline();

我们将简要介绍时间线的两个主要用例,排序链式

排序

与 GSAP 类似的核心功能,时间线也提供了to()from()fromTo()方法。默认情况下,所有动画都可以按顺序排列,有选项强制使用position属性来控制事物在哪里或何时进行,这是一个可选参数:

var tl = gsap.timeline({ repeat: -1});
      tl.to("#box-green", {duration: 2, x: 550})
      //1 second after end of timeline (gap)
      tl.to("#box-red", {duration: 2, x: 550, scale: 0.5}, "+=1")
      //0.5 seconds before end of timeline (overlap)
      tl.to("#box-purple", {duration: 2, rotation: 360, x:550,         scale: 1.2, ease: "bounce"}, "-=1")

在本节中,我们探讨了使用 GSAP 时间线功能来安排一系列动画,这些动画一个接一个地运行,有些有间隔,有些有重叠。在下一节中,我们将进一步探讨使用链式概念排序动画。

链式

与排序类似,链式排列将动画按顺序排列。而不是每次单独调用每个动画的实例方法,它将被放置在链中。在子缓动之间使用的所有特殊值都可以定义,或者在实例中创建为默认值,或者也可以在第一次调用中,获取链中的其他时间线(动画列表)以继承这些值:

var tl = gsap.timeline({ defaults: { duration: 2 }, repeat: -1});
tl.to("#box-green", { x: 550 })
  .to("#box-red", { scale: 0.5, x: 450 })
  .to("#box-purple", { scale: 1.2, ease: "bounce", x: 500 })

我们还可以使用position拦截每个链式时间线的定时位置,正如之前所描述的:

tl.to("#box-green", { x: 550 })
  .to("#box-red", { scale: 0.5, x: 450 }, "+=1")
  .to("#box-purple", { scale: 1.2, ease: "bounce", x: 500 }, "-=1")

GSAP 有非常详细的文档,所以只需访问greensock.com/get-started并开始动画。

在下一节中,我们将基于我们对 GSAP 的了解来制作一个带有动画过渡的消息查看应用。

活动 7.01:使用过渡和 GSAP 构建消息应用

在此活动中,你将使用 CSS 编写自定义过渡,使用过渡组和路由进行更复杂的过渡,并使用第三方过渡库如 GSAP 在应用中进行动画和过渡。你将创建一个简单的消息应用,利用过渡效果:

注意

在开始此练习之前,运行vue create命令以生成 Vue 入门项目。

  1. 创建一个Messages路由(在src/views/Messages.vue),用于渲染两个嵌套视图:Messagessrc/views/MessageList.vue)显示消息列表和MessageEditorsrc/views/MessageEditor.vue)包含一个textarea和一个用于创建新消息的提交按钮。

  2. 创建一个Message路由(在src/views/Message.vue),用于渲染具有给定 ID 的单条消息视图。

  3. 注册所有路由。

  4. src/App.vue文件中为主router-view添加一个简单的过渡名称fadeout-in模式。

  5. 通过使用自定义过渡类,将过渡添加到src/views/Messages.vue中的嵌套router-view

  6. 编写一个动画效果,在进入路由时放大,在离开路由时缩小。

  7. 为离开事件编写另一个淡入动画效果。

  8. MessageList.vue的消息列表中添加一个弹入效果的过渡。

  9. 使用 GSAP 动画实现弹入效果。

  10. 为出现的项目添加移动效果。

  11. 当从列表页面导航到编辑页面时,你应该看到内容流滑向左侧,同时编辑器出现,如图图 7.15所示:图 7.15:从消息列表视图导航到编辑视图时淡出

图 7.15:从消息列表视图导航到编辑视图时淡出

当从消息视图导航到编辑视图时,你应该看到文本输入向左滑动,如图图 7.16所示:

图 7.16:从编辑视图导航到消息列表视图时淡出

图 7.16:从编辑视图导航到消息列表视图时淡出

接下来,消息列表将以弹跳效果显示,数字旋转,如图图 7.17所示:

图 7.17:在消息列表视图中显示消息源时的弹跳效果

图 7.17:在消息列表视图中显示消息源时的弹跳效果

当点击特定的消息,例如我们的例子中的01,我们的列表将向左滑动,你应该看到消息内容,如图图 7.18所示:

图 7.18:单条消息视图

图 7.18:单条消息视图

注意

此活动的解决方案可以通过此链接找到。

摘要

在本章中,我们探讨了 Vue.js 内置对过渡和动画的支持,包括对单个和多个组件的支持,并看到了如何轻松地设置它们。到这一点,你已经为路由和组件创建了过渡和动画效果,并见证了 Vue.js 过渡的所有基本功能:自定义过渡类、分组过渡和过渡模式。此外,你还了解到了其他领先的第三方动画库,如 GSAP,并看到了如何将它们与你的 Vue 应用程序集成,以便在网页上获得更好的动画效果。

下一章将重点介绍构建生产就绪的 Vue 应用程序的关键主题之一,即状态管理,以及应用程序内部组件如何使用 Vuex(一个状态管理库)相互通信。

第八章:8. Vue.js 状态管理的状态

概述

到本章结束时,你将能够使用和对比在 Vue.js 应用程序中共享状态和保持全局状态的方法。为此,你将使用一个共享的祖先组件来保持那些没有父子关系的组件(兄弟组件)所需的状态。你还将熟悉 Vue.js 应用程序上下文中的事件总线。随着我们的深入,你将了解何时以及如何利用 Vuex 进行状态管理,以及与其他解决方案(如事件总线或 Redux)相比的优势和劣势。在本章的末尾,你将熟悉选择哪些状态部分应该存储在全局和本地,以及如何将它们结合起来构建一个可扩展且性能良好的 Vue.js 应用程序。

在本章中,我们将探讨 Vue.js 状态管理的状态,从局部状态到基于组件的状态共享模式,再到更高级的概念,如利用事件总线或 Vuex 等全局状态管理解决方案。

简介

在本章中,我们将探讨 Vue 中状态管理的概念。

在前面的章节中,我们看到了如何使用局部状态和props来保持状态并在父子组件层次结构中共享它。

我们将首先展示如何利用statepropsevents在不是父子配置的组件之间共享状态。这类组件被称为兄弟组件

![Figure 8.1: 子组件 1 和子组件 2 是“兄弟”组件

![img/B15218_08_01.jpg]

图 8.1:子组件 1 和子组件 2 是“兄弟”组件

在本章的整个过程中,我们将构建一个个人资料卡片生成器应用程序,以展示状态如何在应用程序中以 props 的形式向下流动到组件树,以及如何使用事件、事件总线和存储更新来向上传播更新。

既然我们想要构建一个Header,其中我们将有全局控件并显示页面标题;一个ProfileForm,我们将捕获数据;最后,一个ProfileDisplay,我们将显示个人资料卡片。

![Figure 8.2: 表示个人资料卡片应用程序组件树

![img/B15218_08_02.jpg]

图 8.2:表示个人资料卡片应用程序组件树

我们现在已经看到了如何推理组件树以及我们的应用程序如何在组件树中结构化。

在公共祖先组件中保持状态

为了仅使用组件状态和props来保持状态并使用事件来更新它,我们将将其存储在最近的公共祖先组件中。

状态仅通过props进行传播,并且仅通过events进行更新。在这种情况下,所有state都将存在于需要状态的组件的共享祖先中。由于 App 组件是根组件,因此它是保持共享状态的理想默认选项。

![Figure 8.3: 公共祖先组件通过 props 和事件传播保持状态

![img/B15218_08_03.jpg]

图 8.3:常见的祖先组件通过属性和事件传播持有状态

要更改 state,组件需要向持有状态(共享祖先)的组件发出 event。共享祖先需要根据事件数据和类型更新 state。这反过来又会导致重新渲染,在此期间,祖先组件将更新的 props 传递给读取 state 的组件。

![图 8.4:当祖先持有状态时更新兄弟组件]

图片

图 8.4:当祖先持有状态时更新兄弟组件

要构建一个标题,我们需要在 AppHeader.vue 文件中创建一个 AppHeader 组件,它将包含一个模板和一个带有 TailwindCSS 类的 h2 标题:

<template>
  <header class="w-full block p-4 border-b bg-blue-300     border-gray-700">
    <h2 class="text-xl text-gray-800">Profile Card Generator</h2>
  </header>
</template>

然后,我们将导入它,注册它,并在 App.vue 文件中渲染它:

<template>
  <div id="app">
    <AppHeader />
  </div>
</template>
<script>
import AppHeader from './components/AppHeader.vue'
export default {
  components: {
    AppHeader
  }
}
</script>

上述代码的输出将如下所示:

![图 8.5:在个人资料卡片生成器中显示的 AppHeader]

图片

图 8.5:在个人资料卡片生成器中显示的 AppHeader

我们将同样创建一个 AppProfileForm 文件:

<template>
  <section class="md:w-2/3 h-64 bg-red-200 flex">
  <!-- Inputs -->
  </section>
</template>

我们将创建一个 AppProfileDisplay 文件,其初始内容如下:

<template>
  <section class="md:w-1/3 h-64 bg-blue-200 flex">
  <!-- Profile Card -->
  </section>
</template>

我们的两个容器(AppProfileFormAppProfileDisplay)现在都可以导入并在 App 中渲染:

<template>
    <!-- rest of template, including AppHeader -->
    <div class="flex flex-col md:flex-row">
      <AppProfileForm />
      <AppProfileDisplay />
    </div>
    <!-- rest of template -->
</template>
<script>
// other imports
import AppProfileForm from './components/AppProfileForm.vue'
import AppProfileDisplay from './components/AppProfileDisplay.vue'
export default {
  components: {
    // other component definitions
    AppProfileForm,
    AppProfileDisplay,
  }
}
</script>

上述代码的输出将如下所示:

![图 8.6:带有 AppHeader、AppProfileForm 和 AppProfileDisplay 的 App 骨架]

图片

图 8.6:带有 AppHeader、AppProfileForm 和 AppProfileDisplay 的 App 骨架

要添加一个表单字段,在这种情况下是 name,我们首先将在 AppProfileForm 中添加一个输入:

<template>
  <section class="md:w-2/3 h-64 bg-red-200 flex flex-col p-12     items-center">
    <!-- Inputs -->
    <div class="flex flex-col">
      <label class="flex text-gray-800 mb-2" for="name">Name
      </label>
      <input
        id="name"
        type="text"
        name="name"
        class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
      />
    </div>
  </section>
</template>

上述代码将显示如下:

![图 8.7:带有名称字段和标签的 AppProfileForm]

图片

图 8.7:带有名称字段和标签的 AppProfileForm

为了跟踪名称输入数据,我们将使用 v-model 添加双向绑定到它,并在组件的 data 初始化器中设置一个 name 属性:

<template>
      <!-- rest of the template -->
      <input
        id="name"
        type="text"
        name="name"
        class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
        v-model="name"
      />
      <!-- rest of the template -->
</template>
<script>
export default {
  data() {
    return {
      name: '',
    }
  }
}
</script>

我们还需要一个 submit 按钮,点击时通过发出包含表单内容的 submit 事件将表单数据发送到父组件:

<template>
    <!-- rest of template -->
    <div class="flex flex-row mt-12">
      <button type="submit" @click="submitForm()">Submit</button>
    </div>
    <!-- rest of template -->
</template>
<script>
export default {
  // rest of component
  methods: {
    submitForm() {
      this.$emit('submit', {
        name: this.name
      })
    }
  }
}
</script>

这将显示如下:

![图 8.8:连接好的提交按钮的 AppProfileForm]

图片

图 8.8:连接好的提交按钮的 AppProfileForm

下一步是将表单状态存储在 App 组件中。由于它是 AppProfileFormAppProfileDisplay 的共同祖先,因此它是存储表单状态的理想选择。

首先,我们需要一个由 data() 返回的 formData 属性。我们还需要一种更新 formData 的方法。因此,我们将添加一个 update(formData) 方法:

<script>
export default {
  // rest of component
  data() {
    return {
      formData: {}
    }
  },
  methods: {
    update(formData) {
      this.formData = formData
    }
  }
  // rest of component
}
</script>

接下来,我们需要将 update() 绑定到由 AppProfileForm 发出的 submit 事件。我们将使用 @submit 简写和魔法事件对象表示法 update($event) 来完成此操作:

<template>
    <!-- rest of template -->
      <AppProfileForm @submit="update($event)" />
    <!-- rest of template -->
</template>

要在 AppProfileDisplay 内显示名称,我们需要将 formData 作为属性添加:

<script>
export default {
  props: {
    formData: {
      type: Object,
      default() {
        return {}
      }
    }
  }
}
</script>

我们还需要使用formData.name显示名称。我们将向容器添加一个p-12类来改善组件的外观:

<template>
  <section class="md:w-1/3 h-64 bg-blue-200 flex p-12">
    <!-- Profile Card -->
    <h3 class="font-bold font-lg">{{ formData.name }}</h3>
  </section>
</template>

最后,App需要将formData作为 prop 传递给AppProfileDisplay

<template>
    <!-- rest of template -->
      <AppProfileDisplay :form-data="formData" />
    <!-- rest of template -->
</template>

我们现在能够更新表单上的名称。当你点击提交按钮时,它将在配置文件显示中显示如下:

图 8.9:App 存储状态,作为 props 传递给 AppProfileDisplay

图 8.9:App 存储状态,通过 props 传递给 AppProfileDisplay

我们现在已经看到如何在App组件上存储共享状态,以及如何从AppProfileForm更新它并在AppProfileDisplay中显示它。

在下一个主题中,我们将看到如何向配置文件生成器添加一个额外的字段。

练习 8.01:向配置文件生成器添加职业字段

在存储name共享状态的例子之后,另一个有趣的字段是个人职业。为此,我们将在AppProfileForm中添加一个occupation字段来捕获这个额外的状态,并在AppProfileDisplay中显示它。

要访问此练习的代码文件,请参阅packt.live/32VUbuH

  1. 首先要做的就是在src/components/AppProfileForm中添加新的occupation字段。我们也将借此机会移除section元素上的h-64bg-red-200类(如果存在),这意味着表单将没有背景和固定高度:

    <template>
      <section class="md:w-2/3 flex flex-col p-12 items-center">
        <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2" for="occupation">Occupation</label>
          <input
            id="occupation"
            type="text"
            name="occupation"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
        <!-- rest of template -->
      </section>
    </template>
    

    上述代码的输出将如下所示:

    图 8.10:带有新职业字段的 AppProfileForm

    图 8.10:带有新职业字段的 AppProfileForm

  2. 为了使用双向数据绑定跟踪occupation的值,我们将向data()属性的输出添加一个新属性:

    <script>
    export default {
      // rest of component
      data() {
        return {
          // other data properties
          occupation: '',
        }
      },
      // rest of component
    }
    
  3. 我们现在将使用v-modeloccupation响应式数据属性到occupation输入进行双向数据绑定:

    <template>
      <!-- rest of template -->
          <input
            id="occupation"
            type="text"
            name="occupation"
            v-model="occupation"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
      <!-- rest of template -->
    </template>
    
  4. 为了在点击提交时传输occupation值,我们需要将其添加到submitForm方法中作为submit事件负载的属性:

    <script>
    export default {
      // rest of component
      methods: {
        submitForm() {
          this.$emit('submit', {
            // rest of event payload
            occupation: this.occupation
          })
        }
      }
    }
    </script>
    
  5. 添加此字段的最后一步是在AppProfileDisplay组件中显示它。我们添加一个带有几个样式类的段落。我们也将借此机会从容器中移除h-64bg-blue-200类(如果存在):

    <template>
      <section class="md:w-1/3 flex flex-col p-12">
        <!-- rest of template -->
        <p class="mt-2">{{ formData.occupation }}</p>
      </section>
    </template>
    

    我们的浏览器应该看起来如下:

    图 8.11:AppProfileForm

图 8.11:AppProfileForm

正如我们刚刚看到的,使用共同祖先来管理状态添加新字段是一个在事件中向上传递数据并在 props 中向下传递到读取组件的情况。

我们现在将看到如何使用Clear按钮重置表单和配置文件显示。

练习 8.02:向配置文件生成器添加清除按钮

当我们使用应用程序创建新配置文件时,能够重置配置文件是有用的。为此,我们将添加一个Clear按钮。

一个Clear按钮应该重置表单中的数据,同时也重置AppProfileDisplay中的数据。要访问这个练习的代码文件,请参阅packt.live/2INsE7R

现在让我们看看执行这个练习的步骤:

  1. 我们希望显示一个Clear按钮。我们将借此机会改进ClearSubmit按钮的样式(在src/components/AppProfileForm.vue中):

    <template>
      <!-- rest of template -->
        <div class="w-1/2 flex md:flex-row mt-12">
          <button
            class="flex md:w-1/2 justify-center"
            type="button"
          >
            Clear
          </button>
          <button
            class="flex md:w-1/2 justify-center"
            type="submit"
            @click="submitForm()"
          >
            Submit
          </button>
        </div>
      <!-- rest of template -->
    </template>
    
  2. 要清除表单,我们需要重置nameoccupation字段。我们可以在src/components/AppProfileForm.vue中创建一个clear方法来完成这个操作:

    <script>
    export default {
      // rest of the component
      methods: {
        // other methods
        clear() {
          this.name = ''
          this.occupation = ''
        }
      }
      // rest of the component
    }
    
  3. 我们希望将clear方法绑定到Clear按钮的click事件上以重置表单(在src/components/AppProfileForm.vue中):

    <template>
      <!-- rest of template -->
          <button
            class="flex md:w-1/2 justify-center"
    Submit button, it will propagate data to AppProfileDisplay as follows:![Figure 8.13: AppProfileForm and AppProfileDisplay with data filled in     and submitted with a Clear button    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_13.jpg)Figure 8.13: AppProfileForm and AppProfileDisplay with data filled in and submitted with a Clear buttonUnfortunately, `AppProfileDisplay` still has stale data, as shown in the following screenshot:![Figure 8.14: AppProfileForm and AppProfileDisplay with only AppProfileForm cleared AppProfileDisplay still has stale data    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_14.jpg)Figure 8.14: AppProfileForm and AppProfileDisplay with only AppProfileForm cleared AppProfileDisplay still has stale data
    
  4. 要清除AppProfileDisplay的内容,我们需要通过在src/components/AppProfileForm.vue中发出一个带有空有效负载的submit事件来更新App.vue中的formData

    <script>
    export default {
      // rest of component
      methods: {
        // other methods
        clear() {
          // rest of the clear() method
          this.$emit('submit', {})
        }
      }
    }
    </script>
    

    当我们填写表单并提交时,它看起来如下所示:

    图 8.15:填写并提交数据的 AppProfileForm 和 AppProfileDisplay    并带有清除按钮提交

图 8.15:填写并提交数据的 AppProfileForm 和 AppProfileDisplay,带有清除按钮

我们可以点击Clear并按照以下截图重置AppProfileDisplayAppProfileForm中显示的数据:

图 8.16:数据清除后的 AppProfileForm 和 AppProfileDisplay 被清除(使用清除按钮)

图 8.16:清除数据后的 AppProfileForm 和 AppProfileDisplay(使用清除按钮)

我们已经看到了如何通过共同祖先设置兄弟组件之间的通信。

注意

要跟踪应用程序中需要保持同步的所有状态片段,需要做大量的记录和心智工作。

在下一节中,我们将探讨事件总线是什么以及它如何帮助我们缓解遇到的一些问题。

事件总线

我们将要探讨的第二种情况是当存在全局事件总线时。

事件总线是一个实体,我们可以在这个实体上发布和订阅事件。这允许应用程序的不同部分保持自己的状态并保持同步,而无需将事件传递给或从共同的祖先传递下来。

图 8.17:利用事件总线的读取组件和更新组件的序列图

图 8.17:利用事件总线的读取组件和更新组件的序列图

为了提供这一点,我们的事件总线需要提供一个subscribe方法和publish方法。能够取消订阅也是有用的。

Vue 实例是一个事件总线,因为它提供了三个关键操作:main.js文件:

import Vue from 'vue'
const eventBus = new Vue()

我们的事件总线有几个方法,即$on,它是$on(eventName, callback)

// rest of main.js file
console.log('Registering subscriber to "fieldChanged"')
eventBus.$on('fieldChanged', (event) => {
  console.log(`Received event: ${JSON.stringify(event)}`)
})

我们可以使用$emit来触发订阅者回调。$emit(eventName, payload)是事件总线的$emit支持两个参数——事件的名称(作为字符串)和负载,这是可选的,可以是任何对象。它可以如下使用:

// rest of main.js file
console.log('Triggering "fieldChanged" for "name"')
eventBus.$emit('fieldChanged', {
  name: 'name',
  value: 'John Doe'
})
console.log('Triggering "fieldChanged" for "occupation"')
eventBus.$emit('fieldChanged', {
  name: 'occupation',
  value: 'Developer'
})

在浏览器中运行此文件将产生以下控制台输出,其中首先注册了订阅者,然后在每次$emit上触发回调:

图 18.18:使用订阅者和两个事件发布的 Vue.js 实例作为事件总线的控制台输出

图 18.18:使用订阅者和两个事件发布的 Vue.js 实例作为事件总线的控制台输出

$off,即取消订阅操作,需要使用与订阅操作相同的参数调用。即两个参数,事件名称(作为字符串)和回调(在每次事件发布时以事件作为参数运行)。为了正确使用它,我们需要使用函数的引用(而不是内联匿名函数)来注册订阅者:

// rest of main.js, including other subscriber
const subscriber = (event) => {
  console.log('Subscriber 2 received event: ${JSON.stringify     (event)}')
}
console.log('Registering subscriber 2')
eventBus.$on('fieldChanged', subscriber)
console.log('Triggering "fieldChanged" for "company"')
eventBus.$emit('fieldChanged', {
  name: 'company',
  value: 'Developer'
})
console.log('Unregistering subscriber 2')
eventBus.$off('fieldChanged', subscriber)
console.log('Triggering "fieldChanged" for "occupation"')
eventBus.$emit('fieldChanged', {
  name: 'occupation',
  value: 'Senior Developer'
})

注意,一旦调用 $off,第二个订阅者不会触发,但初始订阅者会触发。在浏览器中运行时的控制台输出将如下所示:

图 8.19:显示 $off 作用的控制台输出

图 8.19:显示 $off 作用的控制台输出

通过在event-bus.js文件中设置事件总线,我们可以避免将数据发送到App组件(公共祖先)的混淆:

import Vue from 'vue'
export default new Vue()

我们可以从AppProfileForm.vue文件在表单提交时向事件总线$emit profileUpdate事件,而不是使用this.$emit

<script>
import eventBus from '../event-bus'
export default {
  // rest of component
  methods: {
    submitForm() {
      eventBus.$emit('profileUpdate', {
        name: this.name,
        occupation: this.occupation
      })
    },
    clear() {
      this.name = ''
      this.occupation = ''
      eventBus.$emit('profileUpdate', {})
    }
  }
}
</script>

AppProfileDisplay.vue文件中,我们可以使用$on订阅profileUpdate事件并在状态中更新formData。请注意,我们已经移除了formData属性。我们使用mounted()beforeDestroy()钩子来订阅和取消订阅事件总线:

<script>
import eventBus from '../event-bus'
export default {
  mounted() {
    eventBus.$on('profileUpdate', this.update)
  },
  beforeDestroy() {
    eventBus.$off('profileUpdate', this.update)
  },
  data() {
    return {
      formData: {}
    }
  },
  methods: {
    update(formData) {
      this.formData = formData
    }
  }
}
</script>

该应用按预期工作。以下截图显示了您的屏幕将如何显示:

图 8.20:AppProfileForm 和 AppProfileDisplay 通过事件总线进行通信

图 8.20:AppProfileForm 和 AppProfileDisplay 通过事件总线进行通信

由于我们移除了formData属性用于AppProfileDisplay,我们可以在App.vue文件中停止传递它。由于我们不依赖于AppProfileFormsubmit事件,我们也可以删除该绑定:

<template>
  <!-- rest of template -->
      <AppProfileForm />
      <AppProfileDisplay />
  <!-- rest of template -->
</template>

我们还可以从App.vue文件中删除未使用的App updatedata方法,这意味着整个App脚本部分如下(仅注册components,不注册状态或处理程序):

<script>
import AppHeader from './components/AppHeader.vue'
import AppProfileForm from './components/AppProfileForm.vue'
import AppProfileDisplay from './components/AppProfileDisplay.vue'
export default {
  components: {
    AppHeader,
    AppProfileForm,
    AppProfileDisplay,
  }
}
</script>

我们现在通过使用事件总线而不是在公共祖先组件中存储共享状态来简化了应用数据流。现在,我们将看到如何将“清除”按钮移动到配置文件生成器的应用头部。

练习 8.03:将清除按钮移动到应用头部配置文件生成器

在我们的配置卡片生成应用程序中,清除按钮清除整个应用程序的状态。它在表单内的存在使得清除按钮的功能不明确,因为它看起来可能只会影响表单。

为了反映清除按钮是全局功能的事实,我们将将其移动到标题中。

要访问此练习的代码文件,请参阅packt.live/2UzFvwZ

以下步骤将帮助我们完成这项练习:

  1. 我们将首先在src/components/AppHeader.vue中创建一个按钮

    <template>
      <header class="w-full flex flex-row p-4 border-b     bg-blue-300 border-gray-700">
        <h2 class="text-xl flex text-gray-800">Profile Card       Generator</h2>
        <button class="flex ml-auto text-gray-800 items-center">
          Reset
        </button>
      </header>
    </template>
    
  2. 我们可以在AppHeader中导入事件总线并创建一个clear()处理程序,其中我们将触发一个带有空有效负载的更新事件(在src/components/AppHeader.vue):

    <script>
    import eventBus from '../event-bus'
    export default {
      methods: {
        clear() {
          eventBus.$emit('profileUpdate', {})
        }
      }
    }
    </script>
    
  3. 我们应该将clear()函数绑定到按钮(在src/components/AppHeader.vue):

    <template>
      <!-- rest of template -->
        <button
          @click="clear()"
          class="flex ml-auto text-gray-800 items-center"
        >
          Reset
        </button>
      <!-- rest of template -->
    </template>
    

    在这个阶段,我们应该能够填写表格,并且应该出现如下所示的重置按钮:

    图 8.21:填写好的表格和标题中的重置按钮

    图 8.21:填写好的表格和标题中的重置按钮

    重置按钮仅重置AppProfileDisplay数据:

    图 8.22:填写好的表格,但卡片部分已被清除

    图 8.22:填写好的表格,但卡片部分已被清除

  4. 为了使重置清除表格,我们需要在AppProfileForm的挂载生命周期方法中订阅profileUpdate事件,并通过重置表格(使用handleProfileUpdate)来响应这些事件:

    <script>
    import eventBus from '../event-bus'
    export default {
      mounted() {
        eventBus.$on('profileUpdate', this.handleProfileUpdate)
      },
      beforeDestroy() {
        eventBus.$off('profileUpdate', this.handleProfileUpdate)
      },
      // rest of component
      methods: {
        // other methods
        handleProfileUpdate(formData) {
          this.name = formData.name || ''
          this.occupation = formData.occupation || ''
        }
      }
    }
    </script>
    
  5. 我们也借此机会删除清除按钮并调整提交按钮:

    <template>
      <!-- rest of template -->
        <div class="flex align-center mt-12">
          <button
            type="submit"
            @click="submitForm()"
          >
            Submit
          </button>
        </div>
      <!-- rest of template -->
    </template>
    

    表单填写并提交后的样子如下:

    图 8.23:填写并提交的表格

图 8.23:填写并提交的表格

现在重置表格会清除表单字段以及AppProfileDisplay

图 8.24:使用重置按钮重置表格和显示

图 8.24:使用重置按钮重置表格和显示

使用事件总线触发事件并监听相同事件是 Vuex 模式的基础,其中事件和状态更新被封装。

与其他模式如 Redux 对比使用 Vuex 模式

我们将要考虑的最后一个场景是使用 Vuex 模式。在这种情况下,所有状态都保存在单个存储中。对状态的任何更新都会派发到这个存储。组件从存储中读取共享和/或全局状态。

Vuex 既是 Vue.js 核心团队提供的状态管理模式,也是库的实现。该模式旨在减轻当全局状态被应用程序的不同部分共享时发现的问题。存储的状态不能直接操作。突变用于更新存储状态,由于存储状态是响应式的,任何 Vuex 存储的消费者都会自动更新。

Vuex 从 JavaScript 状态管理空间中的先前工作中汲取灵感,例如 Flux 架构,它普及了单向数据流的概念,以及 Redux,它是一个 Flux 的单一存储实现。

Vuex 不仅仅是一个 Flux 实现。它是一个针对 Vue.js 的特定状态管理库。因此,它可以利用 Vue.js 特定的东西,如响应性,以提高更新性能。以下图表显示了属性和状态更新的层次结构:

图 8.25:Vuex 属性和状态更新层次结构

图 8.25:Vuex 属性和状态更新层次结构

为了更新全局状态的部分,组件会触发一个在存储中称为突变的更新。存储知道如何处理这种更新。它更新状态并通过 Vue.js 的响应性相应地向下传播属性:

图 8.26:Vuex 全局状态更新序列图

图 8.26:Vuex 全局状态更新序列图

我们可以使用 Vuex 扩展现有应用程序。

首先,我们需要使用 yarn add vuexnpm install --save vuex 命令添加 vuex 模块。

接下来,我们需要在 store.js 文件中使用 Vue.use() 将 Vuex 与 Vue 注册:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

最后,我们创建一个具有默认状态的 Vuex 存储。此状态包括我们在 store.js 文件中使用的相同的 formData 对象。然后我们使用 export default 导出它:

export default new Vuex.Store({
  state: {
    formData: {
      name: '',
      occupation: ''
    }
  },
})

最后,我们需要在 main.js 文件中将我们的存储与 Vue.js 的主应用程序实例注册:

// other imports
import store from './store'
// other imports and code
new Vue({
  render: h => h(App),
  store
}).$mount('#app')

令人兴奋的是,每个组件都有一个对 this.$store 下的存储的引用。例如,要访问 formData,我们可以使用 this.$store.state.formData。使用这个,我们可以用单个计算属性替换 AppProfileDisplay.vue 文件脚本部分中的事件总线订阅和本地状态更新:

<script>
export default {
  computed: {
    formData() {
      return this.$store.state.formData
    }
  }
}
</script>

要触发状态更新,我们需要定义一些突变。在这种情况下,我们需要在 store.js 文件中定义 profileUpdate。突变接收 state(当前状态)和 payload(存储 commit 负载)作为属性。

export default new Vuex.Store({
  // other store properties
  mutations: {
    profileUpdate(state, payload) {
      state.formData = {
        name: payload.name || '',
        occupation: payload.occupation || ''
      }
    }
  }
})

现在我们已经得到了 profileUpdate 突变,我们可以在 AppHeader.vue 文件中更新 Reset 按钮以使用 Vuex $store.commit() 函数:

<script>
export default {
  methods: {
    clear() {
      this.$store.commit('profileUpdate', {})
    }
  }
}
</script>

我们还应该更新 AppProfileForm.vue 文件,将提交操作提交到 $store 而不是通过事件总线发出:

<script>
export default {
  // rest of component
  methods: {
    submitForm() {
      this.$store.commit('profileUpdate', {
        name: this.name,
        occupation: this.occupation
      })
    },
    // other methods
  }
}
</script>

应用程序现在将支持更新名称和职业:

图 8.27:填写并提交的 AppProfileForm 应用程序

图 8.27:填写并提交的 AppProfileForm 应用程序

不幸的是,Reset 按钮没有清除表单:

图 8.28:在点击 Reset 按钮时未清除 AppProfileForm 的应用程序

图 8.28:在点击 Reset 按钮时未清除 AppProfileForm 的应用程序

为了更有效地重置,我们将在 store.js 文件中添加一个 profileClear 突变:

export default new Vuex.Store({
  // other store properties
  mutations: {
    // other mutations
    profileClear(state) {
      state.formData = {
        name: '',
        occupation: ''
      }
    }
  }
})

我们将在 AppHeader.vue 文件中将此操作提交为 profileUpdate 而不是 profileUpdate,使用空数据代替 profileUpdate 使我们的代码清除:

<script>
export default {
  methods: {
    clear() {
      this.$store.commit('profileClear')
    }
  }
}
</script>

最后,我们需要订阅存储更改,并在 AppProfileForm 文件中提交 profileClear 到存储时重置本地状态:

<script>
export default {
  created() {
    this.$store.subscribe((mutation) => {
      if (mutation.type === 'profileClear') {
        this.resetProfileForm()
      }
    })
  },
  // other component properties
  methods: {
    // other methods
    resetProfileForm() {
      this.name = ''
      this.occupation = ''
    }
  }
}
</script>

现在应用的“重置”按钮将正确地与 Vuex 一起工作。我们的屏幕应显示如下:

![图 8.29:应用程序重置按钮清除表单和显示]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_29.jpg)

图 8.29:应用程序重置按钮清除表单和显示

我们现在已经看到了如何使用 Vuex 存储在应用程序中存储全局状态。

练习 8.04:将组织字段添加到配置生成器

在“配置卡生成器”中,除了个人的姓名和职业外,了解他们在哪里工作,换句话说,他们的组织,也是有用的。

要做到这一点,我们将在 AppProfileFormAppProfileDisplay 中添加一个 organization 字段。要访问此练习的代码文件,请参阅 packt.live/3lIHJGe

  1. 我们可以从向 AppProfileForm 添加新的文本输入和标签开始:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2"         for="organization">Organization</label>
          <input
            id="occupation"
            type="text"
            name="organization"
            class="border-2 border-solid border-blue-200           rounded px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    

    新字段看起来如下:

    ![图 8.30:具有新组织字段的应用程序]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_30.jpg)

    图 8.30:具有新组织字段的应用程序

  2. 我们可以将 organization 字段添加到 src/store.js 中的初始状态和突变中,以便 organization 被初始化,在 profileUpdate 时设置,并在 profileClear 时重置:

    // imports & Vuex setup
    export default new Vuex.Store({
      state: {
        formData: {
          // rest of formData fields
          organization: ''
        }
      },
      mutations: {
        profileUpdate(state, payload) {
          state.formData = {
            // rest of formData fields
            organization: payload.organization || '',
          }
        },
        profileClear(state) {
          state.formData = {
            // rest of formData fields
            organization: ''
          }
        }
      }
    })
    
  3. 我们需要在 src/components/AppProfileForm.vue 组件的本地状态中跟踪 organization,使用 v-model 并在 data() 函数中初始化它:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2"         for="organization">Organization</label>
          <input
            id="occupation"
            type="text"
            name="organization"
            v-model="organization"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    <script>
    export default {
      // rest of component
      data() {
        return {
          // other data properties
          organization: ''
        }
      }
    }
    </script>
    
  4. 为了使突变的负载包含 organization,我们需要将其添加到 $store.commit('profileUpdate') 负载中,并在组件触发 profileClear 突变时在表单中重置它:

    <script>
    export default {
      // rest of component
      methods: {
        submitForm() {
          this.$store.commit('profileUpdate', {
            // rest of payload
            organization: this.organization
          })
        },
        resetProfileForm() {
          // other resets
          this.organization = ''
        }
      }
    }
    </script>
    
  5. 为了使 organization 显示,我们需要在 src/components/AppProfileDisplay.vue 中使用条件 span(当没有设置 organization 时隐藏 at)来渲染它:

    <template>
      <!-- rest of template -->
        <p class="mt-2">
          {{ formData.occupation }}
          <span v-if="formData.organization">
            at {{ formData.organization }}
          </span>
        </p>
      <!-- rest of template -->
    </template>
    

    应用程序现在将允许我们捕获 organization 字段并显示它。

    ![图 8.31:支持组织字段的配置卡生成器,已填写并提交]

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_31.jpg)

图 8.31:支持组织字段的配置卡生成器,已填写并提交

它将允许我们无任何问题地清除配置:

![图 8.32:支持组织字段的配置卡生成器,点击重置按钮后]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_08_32.jpg)

图 8.32:支持组织字段的配置卡生成器,点击重置按钮后

我们现在已经看到了如何向使用 Vuex 的应用程序添加字段。Vuex 相比于事件总线或存储祖先组件中的状态的最大好处之一是,随着你添加更多数据和操作,它可以进行扩展。以下活动将展示这一优势。

活动 8.01:将电子邮件和电话号码添加到个人资料卡片生成器

在个人资料生成器中,你查看个人资料以获取有关个人的某些信息。电子邮件和电话号码通常是个人资料卡片上寻找的最关键的信息。这个活动是关于将这些详细信息添加到个人资料卡片生成器中。

要做到这一点,我们将在 AppProfileFormAppProfileDisplay 中添加 EmailPhone Number 字段:

  1. 我们可以先向 AppProfileForm 添加一个新的 email 输入字段和标签,用于 Email 字段。

  2. 然后,我们可以在 AppProfileForm 中添加一个新的 phone 输入字段(类型为 tel)和标签,用于 Phone Number 字段:

    新字段如下所示:

    ![图 8.33:包含新电子邮件和电话号码字段的应用程序]

    图片

    图 8.33:包含新电子邮件和电话号码字段的应用程序

  3. 然后,我们可以在 src/store.js 中的初始状态和突变中添加 emailphone 字段,以便在 profileUpdate 期间设置组织,并在 profileClear 期间重置。

  4. 我们需要在 src/components/AppProfileForm.vue 组件的本地状态中使用 v-model 跟踪 email,并在 data() 函数中初始化它。

  5. 我们需要在 src/components/AppProfileForm.vue 组件的本地状态中跟踪 phone,使用 v-model 并在 data() 函数中初始化它。

  6. 为了使突变的负载包含 emailphone,我们需要将其添加到 $store.commit('profileUpdate') 负载中。我们还想在组件触发 profileClear 突变时在表单中重置它。

  7. 为了显示 email,我们需要在 src/components/AppProfileDisplay.vue 中使用条件段落(在未设置电子邮件时隐藏 Email 标签)来渲染它。

  8. 为了显示 phone,我们需要在 src/components/AppProfileDisplay.vue 中使用条件 span(在未设置电话时隐藏 Phone Number 标签)来渲染它。

    当表单填写并提交时,应用程序应如下所示:

    ![图 8.34:包含电子邮件和电话号码字段的应用程序]

    图片

图 8.34:包含电子邮件和电话号码字段的应用程序

注意

这个活动的解决方案可以通过此链接找到。

何时使用本地状态和何时保存到全局状态

如通过公共祖先、事件总线、Vuex 示例所见,Vue.js 生态系统有管理共享和全局状态的方法。我们现在将探讨如何决定某物属于本地状态还是全局状态。

一个好的经验法则是,如果一个属性通过三个组件的深度传递,那么最好将这块状态放在全局状态中,并以此方式访问它。

决定某事物是局部还是全局的第二种方法是问自己 当页面重新加载时,用户是否希望这个信息保持不变? 这为什么很重要呢?因为全局状态比本地状态更容易保存和持久化。这是由于全局状态的本质是 仅仅是一个 JavaScript 对象,而与组件状态相比,组件状态与组件树和 Vue.js 的联系更为紧密。

另一个需要牢记的关键思想是,在组件中混合使用 Vuex 和本地状态是完全可能的。正如我们在 AppProfileForm 的示例、练习和活动中所看到的,我们可以使用 $store.subscribe 选择性地从突变中同步数据到组件中。

最后,将 Vue.js 的数据属性包装在计算属性中,并通过访问计算属性来使潜在过渡到 Vuex 更容易,这并没有什么问题。在这种情况下,由于所有访问都已经通过计算属性完成,所以只是从 this.privateData 变为 this.$store.state.data 的一个变化。

概述

在本章中,我们探讨了在 Vue.js 应用程序中共享和全局状态管理的不同方法。

在共享祖先中的状态允许通过 props 和事件在兄弟组件之间共享数据。

事件总线有三个操作——Vue.js 应用。我们也看到了如何将 Vue.js 实例用作事件总线。

你知道 Vuex 模式和库包含什么,它们与 Redux 和 Flux 的区别,以及使用 Vuex 存储相对于共享祖先或事件总线的优势。

最后,我们探讨了可以使用哪些标准来决定状态应该存在于本地组件状态还是更全局或共享的状态解决方案,例如 Vuex。这一章是关于 Vue.js 中状态管理领域的介绍。

下一章将深入探讨使用 Vuex 编写大规模 Vue.js 应用程序。

第九章:9. 使用 Vuex – 状态、Getters、Actions 和 Mutations

概述

在本章中,你将学习如何使用 Vuex 构建更复杂的 Vue 应用程序。你将了解如何将 Vuex 添加到 Vue 应用程序中,如何使用 Vuex 存储定义状态,然后使用 getter、actions 和 mutations 从存储中读取数据,并在存储中更新数据。到本章结束时,你将看到多个 Vuex 如何改变你的 Vue 应用程序的例子,使它们能够以更可管理的方式变得更加复杂。

简介

在上一章中,你学习了如何使用事件总线模式来帮助解决一个重要问题:在复杂且高度嵌套的组件集合之间双向通信事件。事件总线模式提供了一个简单的发布/订阅系统,任何组件都可以发出事件,任何组件也可以监听该事件。虽然自己编写解决方案是保持编码技能锐利的好方法,但在这种情况下,使用已经在 Vue 社区中开发、经过充分测试的解决方案会更好——Vuex (vuex.vuejs.org/):

![图 9.1:Vuex 首页图片

图 9.1:Vuex 首页

Vuex 是 Vue 生态系统的一个核心部分,它提供了我们在上一章中构建的内容以及更多。让我们从高层次上了解一下 Vuex 的主要功能。

存储

从高层次来看,一个 Vuex 实例或 Vuex 的一次使用被认为是一个存储。存储是使用以下子节中描述的一切的最高级容器。

状态

Vuex 最重要的方面是它所代表的状态或数据。这是所有组件都可以依赖的单一事实来源。随着状态的变化,任何使用状态的组件都可以确保其副本始终与状态同步。想象一个允许你编辑博客条目的 Vue 应用程序。存储可以包括博客条目本身以及你正在编辑的当前博客条目的值。当在某个地方编辑博客条目时,任何使用它们的其他地方都会立即更新。

Getters

虽然 Vue 可以直接从 Vuex 实例读取状态数据,但有时你可能需要为数据本身提供额外的逻辑或抽象。就像 Vue 为虚拟或派生数据提供computed属性一样,getter为需要在使用前操纵数据的情况提供了对状态的抽象。回到我们之前处理博客条目的例子,想象一个返回按浏览量最高的博客条目的 getter。getter 抽象掉了“热门”博客条目的逻辑,并允许你轻松地在将来更改该逻辑。

变更

在 Vuex 中使用状态数据的组件永远不会直接修改该数据。相反,组件可以执行一个突变。将其视为组件对 Vuex 执行状态更改的命令。通过使用突变来封装对状态的更改,Vuex 可以确保使用该状态的每个组件都能及时了解更改。

行为

行为类似于突变,但它们必须用于处理异步更改。异步行为是任何需要不确定时间才能完成的逻辑。最常见的例子是对远程 API 的网络调用。同步调用是那些立即执行并完成的调用。当你看到它们被使用时,这会更有意义,但一般来说,任何异步操作都应该通过行为完成,而同步逻辑可以通过突变完成。一旦完成了所需的异步工作,行为通常会链接到突变。

模块

本章将要涵盖 Vuex 的最后一个方面是模块。模块只是将更复杂的数据集打包起来,用于更大的应用程序。虽然一个简单的状态可能适合典型应用程序,但一个更大的应用程序可能有一个更复杂的状态,需要通过模块进行更好的组织。在第十一章与 Vuex 一起工作 – 组织更大的存储库中,你将看到如何使用模块来更好地组织 Vuex 实例。

安装 Vuex

根据你正在构建的 Vue 应用程序类型,使用 Vuex 的主要有两种方法。如果你没有使用 CLI 搭建应用程序,只是通过脚本标签添加 Vue,你可以以相同的方式包含 Vuex。假设你已经将 Vue 和 Vuex 下载到一个名为 js 的文件夹中,你会这样加载它们:

<script src="img/vue.js"></script>
<script src="img/vuex.js"></script>

你也可以通过内容分发网络CDNs)加载 Vue 和 Vuex:

<script src="img/vue"></script>
<script src="img/vuex"></script>

注意

确保在 Vue 之后加载 Vuex 非常重要。这样做可以使 Vuex 对你的 Vue 代码可用,而无需任何其他配置。

如果你使用 CLI 创建了应用程序,请记住 CLI 本身将在创建过程中提示你是否要添加 Vuex:

![图 9.2:在应用程序搭建过程中选择 Vuex]

![图片 B15218_09_02.jpg]

图 9.2:在应用程序搭建过程中选择 Vuex

如果你没有这样做,你仍然可以使用 CLI 添加 Vuex:vue add vuex。当 Vuex 被添加(或在搭建过程中选择)时,你的 Vue 应用程序将以我们将要讨论的方式进行修改。

首先,添加一个新的文件夹 store,其中包含一个文件 index.js

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

这是一个空的存储库,为你的状态、突变、行为和模块预留了位置。请注意,这里没有为定义的获取器预留空间,但你绝对可以添加它们。这只是一个新存储库的默认布局,你可以根据需要对其进行修改。

接下来,main.js 被修改为加载和安装此存储库:

import Vue from 'vue'
import App from './App.vue'
import store from './store'
Vue.config.productionTip = false
new Vue({
  store,
  render: h => h(App)
}).$mount('#app')

再次强调,这是 CLI 使用 Vuex 的方式,如果你更喜欢使用另一种方法(可能是一个不同于store的文件夹名),你完全自由这样做。

处理状态

在 Vuex store 的最低级别,你会找到 Vuex 实际管理的数据(状态)。所有组件都可以通过特殊的$store变量访问状态。虽然这个变量中还有更多内容,但要读取状态,你可以使用$store.state.someStateValue。所以,例如:Hello, my name is {{ $store.state.name }}会在组件中输出 Vuex store 中的名称值。对于简单的从 store 读取操作,这就足够了。

我们将在以下练习中学习如何显示状态值。

练习 9.01:显示状态值

在这个练习中,你将创建一个空的 Vue 应用程序并使用 Vuex。前面的部分描述了如何通过 CLI 完成此操作,如果你跟随着做,你现在应该有一个准备好了。如果没有,请现在创建一个,确保你启用了 Vuex。在这个练习中,我们将在状态中设置一些值并在组件中显示它们。

要访问此练习的代码文件,请参阅packt.live/32s4RkN

  1. 一旦你搭建了应用程序,打开store/index.js并修改state块以添加三个新值。这里的数据是任意的,可以是 JavaScript 可以处理的所有内容(字符串、数字、数组等等):

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        name:"Lindy", 
        favoriteColor: "blue", 
        profession: "librarian"
      },
      mutations: {
      },
      actions: {
      },
      modules: {
      }
    })
    
  2. 现在我们需要修改组件以显示来自状态的价值。打开App.vue并按如下方式修改它:

    <template>
      <div id="app">
        <p>
        My name is {{ $store.state.name }} and 
        my favorite color is {{ $store.state.favoriteColor }}. 
        My job is a {{ $store.state.profession }}.
        </p>
        <HelloWorld />
      </div>
    </template>
    <script>
    import HelloWorld from '@/components/HelloWorld';
    export default {
      name: 'app',
      components:{
        HelloWorld
      }
    }
    </script>
    
  3. 接下来,编辑HelloWorld.vue组件以显示来自状态的一个值:

    <template>
      <div>
        <p>
          Hi, I'm a component, and I also have access to state! 
          My name is {{ $store.state.name }}.
        </p>
      </div>
    </template>
    <script>
    export default {
      name: 'HelloWorld'
    }
    </script>
    

    要查看你的应用程序,请在终端中输入npm run serve。当 CLI 完成时,你可以在浏览器中打开显示的 URL 来查看你的应用程序,它应该如下所示:

    My name is Lindy and my favorite color is blue. My job is a librarian.
    Hi, I am a component, and I also have access to state! My name is Lindy.
    

如你所见,主组件和子组件都可以访问状态并看到相同的值。这不应该令人惊讶,但总是很好确认事情按预期工作。

直接访问状态值虽然简单,但让我们看看更复杂的用法:使用 getter 获取派生值。

应用 getter

在上一个练习中,你看到了直接访问状态是多么简单,但有时你可能需要更复杂的对状态视图。为了使这更容易,Vuex 支持一个名为getter的功能。

Getter 在 store 中拥有自己的块,你可以定义尽可能多的 getter。每个 getter 都会将状态作为参数传递,这让你可以使用任何你需要来创建你的值。最后,getter 的名称就是它将被暴露的方式。考虑这个简单的例子:

state: {
  name: "Raymond",
  gender: "male",
  job: "Developer Evangelist"
},
getters: {
  bio(state) {
    return `My name is ${state.name}. I'm a ${state.job}`;
  } 
}

此 store 定义了三个状态值(namegenderjob),还提供了一个名为bio的“虚拟”属性,它返回数据的描述。请注意,getter 只使用了两个状态值,这是完全可以的。

要在组件中引用获取器,你使用 $store.getters.name,其中 name 是获取器的名称。因此,要访问前面代码中定义的 bio 获取器,你会使用以下:

{{ $store.getters.bio }}

除了传递状态外,获取器还通过其第二个参数传递 其他 获取器,这允许一个获取器在必要时调用另一个获取器。

在下一个练习中,我们将看到一个如何使用它的示例。

练习 9.02:向 Vuex 存储添加获取器

在这个练习中,你将构建一个利用获取器功能的示例。你将为 Vuex 存储添加获取器,并从主 Vue 应用程序中调用它。

要访问此练习的代码文件,请参阅 packt.live/36ixlyf

  1. 搭建一个新的应用程序,记得在设置中使用 Vuex(如果你忘记了,只需使用 vue add vuex)。输入 npm run serve 以启动应用程序并在浏览器中打开 URL。

  2. 打开你的存储文件(store/index.js),然后修改它以定义两个状态值和一个获取器,该获取器将返回两者:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        firstName: "Lindy",
        lastName: "Roberthon"
      },
      getters: {
        name(state) {
          return `${state.firstName} ${state.lastName}`;
        }
      },
      mutations: {
      },
      actions: {
      },
      modules: {
      }
    })
    
  3. 现在打开 App.vue 并修改它,以便使用 name 获取器:

    <template>
      <div id="app">
        <p>
        My name is {{ $store.getters.name }}
        </p>
      </div>
    </template>
    

    如以下截图所示,这将显示基于获取器中使用的逻辑的完整名称:

    My name is Lindy Roberthon
    

    虽然相当简单,但希望你能看到获取器在这里实现的力量。目前,我们有一个由姓氏和名字组成的名称概念。这不是非常复杂的逻辑,但通过将其放置在 Vuex 存储中,我们将其定义为我们应用程序中所有组件都可以访问的一个位置。如果这个名称定义发生了变化(例如,姓氏在前,用逗号分隔),你只需修改一次即可完成。

接下来,我们将考虑如何通过附加逻辑增强获取器。

带参数的获取器

虽然获取器可以通过 $store.getters 属性直接访问,但你可能会遇到需要更多控制获取器工作方式的情况。参数提供了一种自定义获取器工作方式的方法。考虑以下存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    books:[
      {type:'nonfiction', title:'Truth about Cats', pages: 200},
      {type:'nonfiction', title:'Truth about Dogs', pages: 100},
      {type:'fiction', title:'The Cat Said Meow', pages: 400},
      {type:'fiction', title:'The Last Dog', pages: 600},
    ]
  },
  getters: {
    fiction(state) {
      return state.books.filter(book => book.type === 'fiction');
    },
    nonfiction(state) {
      return state.books.filter(book => book.type ===         'nonfiction');
    }
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

在此存储中只有一个状态值,它是一个书籍数组。每本书都有一个类型(非小说或小说)、标题和页数。为了便于获取一本书而不是另一本书,使用了两个获取器。它们通过小说或非小说书籍进行筛选。

这是在组件中使用它的方法。首先,我们遍历 fiction 获取器,然后是 nonfiction 获取器:

<h2>Fiction Books</h2>
<ul>
  <li v-for="book in $store.getters.fiction" :key="book.title">
  {{ book.title }}
  </li>
</ul>
<h2>Non-Fiction Books</h2>
<ul>
    <li v-for="book in $store.getters.nonfiction" :key=      "book.title">
    {{ book.title }}
    </li>
</ul>

在前面的模板中,使用了两个无序列表来遍历每种类型的书籍。结果如下所示:

![图 9.3:通过获取器渲染小说和非小说书籍图片

图 9.3:通过获取器渲染小说和非小说书籍

好的,到目前为止一切顺利。但如果你想要根据页数获取书籍呢?这并不是一个简单的布尔值或字符串属性,而是一个数字。但是,因为获取器可以接受参数,我们可以创建一个新的获取器,允许我们请求最大页数。(我们可以支持多个参数,所以,如果你想要一个获取器来请求在一定范围内的书籍,你可以支持最小和最大页数。)为了创建一个接受参数的获取器,您的代码本身必须返回一个函数。

这里有一个示例,我们定义了一个返回全名一部分的获取器:

shortName(state) {
  return function(length) {
    return ('${state.firstName} ${state.lastName}').      substring(0, length);
  }
}

生成的获取器可以使用 length 参数:{{ $store.getters.shortName(10) }}

在下一个练习中,您将构建一个利用带有参数的获取器功能的应用程序。

练习 9.03:使用带参数的获取器

您将在下面的练习中测试这个功能。通过向获取器添加参数,您将能够构建更灵活的获取器,这些获取器在不同组件中更有用。在这个练习中,您将创建一个接受一个参数的获取器。

要访问此练习的代码文件,请参阅 packt.live/2Ioi2vy

  1. 使用 Vuex 再次搭建一个应用程序,一旦打开,编辑 store 以包括一组书籍和用于虚构、非虚构和页码的获取器。这可以在 store 目录中的 index.js 找到:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        books:[
          {type:'nonfiction', title:'Truth about Cats', pages: 200},
          {type:'nonfiction', title:'Truth about Dogs', pages: 100},
          {type:'fiction', title:'The Cat Said Meow', pages: 400},
          {type:'fiction', title:'The Last Dog', pages: 600},
        ]
      },
      getters: {
        fiction(state) {
          return state.books.filter(book => book.type === 'fiction');
        },
        nonfiction(state) {
          return state.books.filter(book => book.type ===         'nonfiction');
        },
        booksByMaxPages(state) {
          return function(pages) {
            return state.books.filter(book => book.pages <= pages);
          }
        }
      }
    })
    
  2. 现在编辑 App.vue 以使用所有三个获取器——首先是虚构和非虚构获取器,然后是页数不超过 150 的书籍:

    <template>
      <div id="app">
        <h2>Fiction Books</h2>
        <ul>
          <li v-for="book in $store.getters.fiction" :key=        "book.title">
            {{ book.title }}
          </li>
        </ul>
        <h2>Non-Fiction Books</h2>
        <ul>
          <li v-for="book in $store.getters.nonfiction" :key=        "book.title">
            {{ book.title }}
          </li>
        </ul>
        <h2>Short Books</h2>
        <ul>
          <li v-for="book in $store.getters.booksByMaxPages(150)"         :key="book.title">
            {{ book.title }}
          </li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      name: 'app'
    }
    </script>
    

    完成后,您可以通过启动您的 Vue 应用程序(在终端中输入 npm run serve)并在浏览器中打开 URL 来查看结果。以下是您应该看到的内容:

    ![图 9.4:参数化获取器的作用

    ![img/B15218_09_04.jpg]

图 9.4:参数化获取器的作用

在这个练习中,我们学习了如何使用更强大的带有参数的获取器。这使得它们更加灵活,更能适应组件可能的需求。现在您已经看到了从 Vuex store 中读取数据的多种方式,是时候看看如何修改状态了。

使用突变修改状态

到目前为止,您已经看到了如何从 Vuex store 中读取数据,无论是通过直接访问状态还是通过使用获取器。但为了实际改变 store 的状态,Vuex 支持突变的概念。突变是在您的 store 中定义的方法,用于处理状态的变化。例如,而不是您的组件简单地设置状态中的新值,您的组件将要求 store 执行突变,而 store 本身处理那个逻辑。

这里有一个简单的例子:

state: {
  totalCats: 5,
  name:'Lindy'
},
mutations: {
  newCat(state) {
    state.totalCats++;
  },
  setName(state, name) {
   state.name = name;
  }
}

在前面的代码片段中,存储在其状态中有两个值,totalCatsname。存在两个突变以允许你更改这些值。所有突变都传递一个状态对象,它为你提供直接访问以读取和更改值。第一个突变 newCat 简单地增加 totalCats 的值。第二个突变 setName 展示了一个接受参数的突变示例。在这种情况下,你可以使用 setName 来更改存储中的名称值。

为了执行一个突变,你的组件将使用 commit 方法。例如,如下所示:

$store.commit('newCat');
$store.commit('setName', 'Raymond');

如果你将它们作为对象而不是简单值传递,你也可以传递多个值。在下一个练习中,你将有机会练习构建你自己的突变。

练习 9.04:使用突变

在这个练习中,你将构建一个使用突变来修改 Vuex 中状态数据的应用程序。搭建一个新的应用程序,一旦准备就绪,打开位于 store/index.js 的存储文件。你的存储将基于前面的示例。

要访问此练习的代码文件,请参阅 packt.live/3kcARiN

  1. 定义一个 totalCats 状态变量和名称状态值,然后定义三个突变来处理它们——一个突变用于增加猫的数量,一个用于减少它,最后一个用于设置名称:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        totalCats:5, 
        name: "Lindy"
      },
      mutations: {
        adoptCat(state) {
          state.totalCats++;
        },
        placeCat(state) {
          if(state.totalCats > 0) state.totalCats--;
        },
        setName(state, name) {
          state.name = name;
        }
      }
    })
    

    接下来,你将构建一个简单的界面来与这个存储进行交互。模板需要有一个 UI 来报告名称和猫的数量。你还需要一个文本字段和按钮来处理名称的更新。

  2. 打开 App.vue 并更新它以包含输出存储中的当前值以及提供一个简单的表单以允许更新:

    <template>
      <div id="app">
        <h1>About Me</h1>
        <p>
          My name is {{ $store.state.name }} and 
          I have {{ $store.state.totalCats }} cats.
        </p>
        <p>
          <input v-model="newName">
          <button @click="setName" :disabled="!newName">Update Name         </button>
        </p>
        <Cat/>
      </div>
    </template>
    <script>
    import Cat from './components/Cat.vue'
    export default {
      name: 'app',
      components: {
        Cat
      },
      data() {
        return {
          newName:''
        }
      },
      methods: {
        setName() {
          if(this.newName) {
            this.$store.commit('setName', this.newName);
            this.newName = '';
          }
        }
      }
    }
    </script>
    
  3. 构建 Cat 组件。此组件将具有简单的按钮来执行我们定义的突变,以增加和减少猫的数量:

    <template>
      <div>
        <button @click="addCat">More Cats!</button>
        <button @click="removeCat">Less Cats :(</button>
      </div>
    </template>
    <script>
    export default {
      name: 'Cat',
      methods: {
        addCat() {
          this.$store.commit('adoptCat');
        },
       removeCat() {
          this.$store.commit('placeCat');
        }
      }
    }
    </script>
    

    完成后,像以前一样使用 npm run serve 启动你的应用程序,并在浏览器中打开显示的 URL。你的应用程序应该看起来是这样的:

    ![图 9.5:具有突变支持的 Vue 应用程序]

    图片

![图 9.5:具有突变支持的 Vue 应用程序]

虽然这是一个简单的练习,但有一些重要的事情需要注意。首先,注意根组件和子组件与存储交互时没有问题。你的组件层次结构可以非常深,而且它只是简单地工作。其次,注意 Vue 应用程序更简单,因为与数据交互的逻辑在存储中。我们处理的两个组件只是显示数据并处理将突变调用传递给存储。如果我们的逻辑需要更新,我们可以在存储中处理,并且一切都会正确更新。

到目前为止,你已经看到了如何以立即、同步的方式实现对存储的更改。现在,你将学习如何处理异步更新。

使用动作进行异步状态更改

Vuex 中的操作是处理存储异步逻辑的主要方式。突变必须是同步的,但操作可以选择异步。另一个区别是,操作会得到一个context对象,它代表存储本身。这使得操作可以调用突变或直接与状态交互。一般来说,大多数开发者会从他们的操作中调用突变。

这可能听起来有些令人困惑,但一般来说,将操作视为您存储的异步支持。一旦我们看到一个或两个示例,它就会变得有意义。

让我们看看一个示例操作。以下代码片段包含一个突变和一个将使用该突变的异步操作:

  mutations: {
    setBooks(state, books) {
      state.books = books;
    }
  },
  actions: {
    loadBooks(context) {
      fetch('/data/books.json')
      .then(res => res.json())
      .then(res => {
        context.commit('setBooks', res);
      });
    }
  },

查看loadBooks,您可以看到它执行网络请求,完成后,它调用前面的突变并允许它存储结果数据。

调用一个操作与突变略有不同;而不是使用commit调用,您使用dispatch

this.$store.dispatch('loadBooks');

与突变一样,操作可以接受参数,这些参数作为第二个参数传递给操作方法。接下来,您将构建一个示例来展示这一点。

练习 9.05:使用操作进行异步逻辑

在这个练习中,您将构建一个需要异步逻辑才能完成的操作示例。这类似于许多现实世界场景,其中应用程序所需的数据位于远程 API 上。您将在 Vuex 存储中实现网络调用并处理结果。

对于这个示例,您将在public文件夹下的一个名为data的子目录中设置一个可用的 JSON 资源。当 Vue 构建您的代码时,它将复制public文件夹中的任何内容到应用程序中,使其在运行时可用。该 JSON 文件包含一个包含四本书的数组。每本书都有一个类型、标题和页数。

要访问此练习的代码文件,请参阅packt.live/3eE6KQd

  1. 虽然不是必需的,但这是 JSON 数据的样子。您可以自由地构建自己的:

    [
        {
            "type": "nonfiction",
            "title": "Truth about Cats",
            "pages": 200
        }, 
         {
            "type": "nonfiction",
            "title": "Truth about Dogs",
            "pages": 100
        },
        {
            "type": "fiction",
            "title": "The Cat Said Meow",
            "pages": 400
        },
        {
            "type": "fiction",
            "title": "The Last Dog",
            "pages": 600
        }
    ]
    
  2. 在一个新的存储(位于store/index.js的常规位置),为书籍设置一个空数组,然后定义一个操作,该操作将使用 Fetch API 检索 JSON 内容。(您将在第十章使用 Vuex - 获取远程数据中看到更多使用 API 的示例,以及一种更强大的 HTTP 操作方式,即Axios库。)当数据被检索后,它应该调用一个突变来存储结果:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        books:[]
      },
      mutations: {
        setBooks(state, books) {
          state.books = books;
        }
      },
      actions: {
        loadBooks(context) {
          fetch('/data/books.json')
          .then(res => res.json())
          .then(res => {
            context.commit('setBooks', res);
          });
        }
      }
    })
    
  3. 为了调用此操作,请在您的组件中添加一个dispatch调用以运行操作,然后添加代码来显示书籍:

    <template>
      <div id="app">
        Books
        <ul>
          <li v-for="book in $store.state.books" :key="book.title">        {{ book.title }}</li>
        </ul>
      </div>
    </template>
    <script>
    export default {
      name: 'app',
      created() {
        this.$store.dispatch('loadBooks');
      }
    }
    </script>
    

    图 9.6中,您可以查看异步操作请求其数据的结果

    图 9.6:异步加载数据的示例

图 9.6:异步加载数据的示例

现在,你已经看到了在 Vuex 存储中处理异步操作的一个例子。请注意,即使你的代码是同步的,你也可以使用操作。如果你不确定数据将来是否将是异步的,这通常是一个好主意。现在让我们看看简化一些 Vuex 语法模板的一个好方法。

使用 mapState 和 mapGetters 简化

作为我们将要使用 Vuex 覆盖的最后一个功能之一,让我们看看 mapStatemapGetters。这些实用的工具帮助将状态值和获取器映射到组件的计算属性中。作为一个实际问题,它使你的 HTML 模板更简单。所以,你不必使用 {{ $store.state.firstName }},你可以简单地使用 {{ firstName }}。不必使用 {{ $store.getters.name }},你只需使用 {{ name }}

mapStatemapGetters 都可以接受一个要映射的值数组,或者是一个对象,其中每个键代表你希望在组件中使用的名称,值是 Vuex 存储中的 state valuegetter。它们都与你的 Vue 应用程序的 computed 块一起使用。

在这个第一个例子中,两个状态值和三个获取器仅通过它们的名称进行映射:

mapState(["age", "rank", "serialNumber"]);
mapGetters(["name", "fiction", "nonfiction"]);

但如果这些名称可能过于通用,或者可能与现有数据冲突,你可以为它们指定其他名称:

mapState({
    howOld:"age",
    level:"rank",
    sn:"serialNumber"
});
mapGetters({
    ourName:"name", 
    fictionBooks:"fictionBooks",
    nonfictionBooks: "nonfictionBooks"
});

为了使用 mapStatemapGetters,你首先需要导入它们:

import { mapState, mapGetters } from 'vuex';

使用这两个功能肯定有助于减少你编写与 Vuex 一起工作的代码量。

你将通过以下练习了解如何添加 mapStatemapGetters

练习 9.06:添加 mapState 和 mapGetters

让我们看看一个简单的例子。在 Exercise 9.02 中,我们使用获取器创建了一个获取名称值的快捷方式。我们可以通过应用我们刚刚学到的知识来简化这段代码。我们可以使用映射函数来简化我们的代码。

要访问此练习的代码文件,请参阅 packt.live/3ldBxpb

  1. 创建一个新的 Vue 应用程序并使用 Vuex,然后将存储(位于 store/index.js)复制到这个新版本中。你需要姓名和姓氏的状态值,以及一个返回完整姓名的获取器:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        firstName: "Lindy",
        lastName: "Roberthon"
      },
      getters: {
        name(state) {
          return `${state.firstName} ${state.lastName}`;
        }
      }
    })
    
  2. 编辑主组件。你将想要编辑存储中的所有三个值(状态值和获取器),但可以使用 mapStatemapGetters 来简化它:

    <template>
      <div id="app">
        <p>
        My name is {{ firstName }} {{ lastName}}, or just {{ name }}.
        </p>
      </div>
    </template>
    <script>
    import { mapGetters } from 'vuex';
    import { mapState } from 'vuex';
    export default {
      name: 'app',
      computed: {
        ...mapState([ "firstName", "lastName" ]),
        ...mapGetters([
          "name"
        ])
      }
    }
    </script>
    

    如你所见,通过使用 mapStatemapGetters,我们为应用的模板部分提供了一种使数据稍微简单一些的方法:

    My name is Lindy Roberthon, or just Lindy Roberthon.
    

完成后,你应该看到与之前完全相同的输出。重要的是,你需要编写的代码量减少了!

在下一节中,我们将简要介绍 mapMutationsmapActions

使用 mapMutations 和 mapActions 简化

我们将要介绍的最终功能与上一个功能非常相似:mapMutationsmapActions。正如你可能猜到的,这两个功能与 mapStatemapGetters 的工作方式非常相似,即它们提供了一种简写方式,可以将您的代码连接到 Vuex 的 mutations 和 actions,而无需编写样板代码。

它们遵循相同的格式,您可以在其中指定一个要映射的项目列表,或者指定一个列表同时提供不同的名称,如下面的示例所示:

mapMutations(["setBooks"]);
mapActions(["loadBooks"]);

这些可以在您的 Vue 组件的 methods 块中使用:

methods:{
    ...mapMutations(["setBooks"]),
    ...mapActions(["loadBooks"])
}

这然后允许您的 Vue 代码调用 setBooksloadBooks 而无需指定 store 对象,或 dispatchcommit

现在,让我们尝试自己创建一个简单的购物车和价格计算器。

活动 9.01:创建简单的购物车和价格计算器

想象一个假设的硬件公司网站,允许员工选择他们需要运送到办公室的产品。这个购物车比典型的电子商务网站简单得多,因为它不需要处理信用卡,甚至不需要询问他们在哪里(IT 知道你在哪里坐!)它仍然需要向您展示一个项目列表,让您选择您想要的数量,并提供一个将向您的部门收取的总价。

在这个活动中,您需要构建一个 Vuex 存储库来表示可用的产品和它们的价格。您将需要多个组件来处理应用程序的不同方面,并正确地与存储数据交互。

步骤:

  1. 在状态中构建一个存储库并定义一个产品数组和购物车。每个产品都将有 nameprice 属性。

  2. 定义一个组件来列出每个产品和价格。

  3. 修改组件以添加或删除购物车中的一个产品按钮。

  4. 定义第二个组件以显示当前购物车(每个产品和数量)。

  5. 使用第三个组件来显示购物车总价,并有一个按钮来完成结账。总价是购物车中每个产品的数量乘以产品数量的总和。对于这个活动,结账 按钮应该简单地提醒用户结账过程已完成,但不要采取其他步骤。

您最初应该得到以下输出,显示一个空购物车:

![图 9.7:购物车的初始显示]

![图片 B15218_09_07.jpg]

![图 9.7:购物车的初始显示]

当您添加和删除项目时,您将看到购物车和总计实时更新:

![图 9.8:添加了多个数量项目的购物车]

![图片 B15218_09_08.jpg]

![图 9.8:添加了多个数量项目的购物车]

如您所见,当产品被添加时,购物车显示会更新以显示数量值,而 结账 部分的总计会准确反映总价。

注意

这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,你已经看到了 Vuex 的大多数功能,现在应该对如何从存储中读取和写入有一个概念。你使用了突变(mutations)来进行同步更改,并使用了动作(actions)来进行异步修改。你创建了获取器(getters)来提供基于你状态的虚拟值的访问。你也看到了组件在处理存储时的样子。它们拥有更少的逻辑,并且简单地将这部分工作交给存储。在更大的 Vue 应用程序中,这一点将变得更加重要。你的组件将处理 UI 和 UX,但让存储处理数据层。因此,拥有作为单一事实来源的存储,将大大减轻你的许多“苦力”工作,你将非常感激 Vuex,即使在较小的应用程序中也是如此。

在下一章中,你将学习如何使用 Vuex 存储与远程数据。在现代网络应用中,与远程 API 一起工作是一个常见的需求。将这些 API 集成到 Vuex 中将使你的 Vue 应用程序的其他部分更容易使用远程服务提供的数据。

第十章:10. 使用 Vuex – 获取远程数据

概述

在本章中,您将学习如何使用Axios库与远程 API 一起工作。您将进行网络调用并使用 Vuex 存储结果。您还将看到一个如何使用 Vuex 存储身份验证令牌并用于后续 API 调用的示例。

到本章结束时,您将了解 Vuex 如何帮助抽象并创建远程 API 的包装器,并简化它们集成到 Vue 应用程序中的过程。这种抽象使得将来迁移到其他 API 变得更容易,确保您的应用程序的其他部分继续正常工作。

简介

第九章使用 Vuex – 状态、获取器、动作和突变中,您被介绍了 Vuex,并看到了多个如何与之交互的示例,以从存储中读取数据并向存储写入数据。我们看到了多个组件如何与存储一起工作,并且在我们这边几乎不需要做任何工作就能保持同步。在本章中,我们将通过使用Axios(一个流行的开源库,使使用网络资源变得容易)将 Vuex 与远程数据集成来扩展我们的 Vuex 使用。让我们从对Axios的深入了解开始。

Axios(github.com/axios/axios)是一个具有asyncawait功能的 JavaScript 库。其他功能包括支持默认参数(对于每个调用都需要键的 API 很有用)以及转换您的输入和输出数据的能力。在本章中,我们不会涵盖每个用例,但您将了解如何为未来的项目使用Axios

为了明确,如果您不喜欢Axios,您不必使用它。您可以使用任何其他库,或者根本不使用库。Fetch API(developer.mozilla.org/en-US/docs/Web/API/Fetch_API)是一个现代浏览器 API,用于处理网络请求,虽然不如Axios强大,但不需要额外的库。

在下一节中,我们将探讨如何安装Axios

Axios 的安装

与 Vuex 类似,您有多种方法可以将Axios包含到项目中。最简单的方法是将指向库的内容分发网络CDN)的<script>标签粘贴到项目中:

<script src="img/axios.min.js"></script>

另一个选项是使用npm。在现有的 Vue 应用程序中,您可以按照以下方式安装Axios

npm install axios

一旦完成此操作,您的 Vue 组件就可以按照以下方式导入库:

import axios from 'axios';

您如何使用Axios将取决于您交互的 API。以下是一个简单的示例,用于调用一个假想的 API:

axios.get('https://www.raymondcamden.com/api/cats')
.then(res => {
  this.cats = res.data.results;
})
.catch(error => {
  console.error(error);
});

在前面的示例中,我们正在对一个虚构的 API 执行GET请求(GET是默认值),即https://www.raymondcamden.com/api/catsAxios返回 promises,这意味着我们可以使用thencatch链式处理结果和错误。结果 JSON(再次强调,这是一个虚构的 API)会自动解析,所以剩下的只是将结果分配给一个值,在这个例子中,是一个名为cats的值,用于我的 Vue 应用程序。

现在让我们看看使用Axios从 API 加载数据的逐步过程。

练习 10.01:使用 Axios 从 API 加载数据

让我们看看一个使用Axios的复杂示例。此示例将对星球大战 API 进行两次不同的 API 调用,并返回两个信息列表。目前,我们将跳过使用 Vuex,以使这个介绍更简单。

要访问此练习的代码文件,请参阅packt.live/3kbn1x1

  1. 创建一个新的 Vue 应用程序,CLI 完成之后,将Axios添加为npm依赖项:

    npm install axios
    
  2. 打开App.vue页面并添加对axios的导入:

    import axios from 'axios';
    
  3. 打开App.vue页面并为filmsships数组添加数据值:

    data() {
        return {
          films:[],
          ships:[]
        }
      },
    
  4. 打开App.vue并使用created方法从 API 加载filmsstarships

    created() {
        axios.get('https://swapi.dev/api/films')
        .then(res => {
          this.films = res.data.results;
        })
        .catch(error => {
          console.error(error);
        });
        axios.get('https://swapi.dev/api/starships')
        .then(res => {
          this.ships = res.data.results;
        })
        .catch(error => {
          console.error(error);
        });
      }
    
  5. 接下来,编辑模板以迭代值并显示它们:

        <h2>Films</h2>
        <ul>
          <li v-for="film in films" :key="film.url">
            {{ film.title }} was released in {{ film.release_date }}
          </li>
        </ul>
        <h2>Starships</h2>
        <ul>
          <li v-for="ship in ships" :key="ship.url">
            {{ ship.name }} is a {{ ship.starship_class }} 
          </li>
        </ul>
    

    注意

    错误处理是通过 catch 处理程序完成的,但只是发送到浏览器控制台。如果远程数据没有加载,最好告诉用户一些信息,但到目前为止,这是可以接受的。另一个建议是处理加载状态,您将在本章后面的示例中看到。

  6. 使用以下命令启动应用程序:

    npm run serve 
    

    在您的浏览器中打开 URL 将生成以下输出:

    图 10.1:浏览器中渲染的 API 调用结果

图 10.1:浏览器中渲染的 API 调用结果

这个简单的示例展示了将Axios添加到 Vue 应用程序是多么容易。请记住,Axios不是 Vue 的必需品,您可以使用任何您想要的库,或者简单地使用浏览器本地的 Fetch API。

现在您已经看到了如何将Axios引入项目,让我们看看Axios的一个更酷的特性:指定默认值。

使用 Axios 的默认值

虽然练习 10.01中的使用 Axios 从 API 加载数据代码运行良好,但让我们考虑一个稍微复杂一点的例子。Axios的一个特性是能够设置在后续调用中使用的默认值。如果您查看前面代码中进行的两个调用,您可以看到它们是相似的。您可以更新created方法来利用这一点:

created() {
  const api = axios.create({
    baseURL:'https://swapi.dev/api/',
    transformResponse(data) {
      data = JSON.parse(data);
      return data.results;
    }
  });
  api.get('films')
  .then(res => this.films = res.data);
  api.get('starships')
  .then(res => this.ships = res.data);
}

在这个更新版本中,我们切换到Axios的一个实例。指定了一个默认的baseURL值,这样在后续操作中可以节省输入。接下来,使用transformResponse功能来转换响应。这让我们可以在数据发送到后续调用处理程序之前对其进行修改。由于所有 API 调用都返回一个结果值,而我们只关心这个值,所以我们通过只返回这个值而不是整个结果来简化事情。请注意,如果你想要构建一个复杂的转换集,Axios允许你在transformResponse中使用一个函数数组。

在下一节,我们将学习如何使用Axios与 Vuex 结合。

使用 Vuex 与 Axios 结合

现在你已经看到了使用Axios的基本方法,是时候考虑如何将它与 Vuex 结合使用了。一种简单的方法是直接使用 Vuex 来处理对 API 的调用封装,使用Axios来执行 HTTP 调用。

练习 10.02:在 Vuex 中使用 Axios

我们将使用之前的功能(加载filmsships数组)并在 Vuex 存储的上下文中重新构建它。和之前一样,你需要使用 CLI 来搭建一个新的应用,并确保你要求包含 Vuex。CLI 完成后,你可以使用npm命令添加Axios

这个练习将与我们在练习 10.01中构建的第一个应用非常相似,即使用 Axios 从 API 加载数据,但有一些细微的差别。让我们首先看看 UI。在初始加载时,FilmsShips都是空的:

图 10.2:初始应用 UI

图 10.2:初始应用 UI

注意到Films部分有一个加载信息。一旦应用加载,我们将发起一个请求来获取这些数据。对于Ships,我们则等待用户明确请求他们想要这些数据。以下是films数组加载后的样子:

图 10.3:应用的渲染电影

图 10.3:应用的渲染电影

最后,在点击Load Ships按钮后,按钮将禁用(以防止用户多次请求数据),然后在数据加载完成后,整个按钮将被移除:

图 10.4:所有内容加载完成后的最终视图

图 10.4:所有内容加载完成后的最终视图

要访问这个练习的代码文件,请参考packt.live/32pUsWy

  1. 从第一个组件App.vue开始,编写 HTML。记住,films在组件中显示,但ships将在自己的组件中。使用v-else添加一个加载信息,这个信息将在Axios进行 HTTP 请求时显示:

    <template>
      <div id="app">
        <h2>Films</h2>
        <ul v-if="films.length">
          <li v-for="film in films" :key="film.url">
            {{ film.title }} was released in {{ film.release_date }}
          </li>
        </ul>
        <div v-else>
          <i>Loading data...</i>
        </div>
        <Ships />
      </div>
    </template>
    
  2. 现在添加必要的代码来加载和注册Ships组件:

    import Ships from './components/Ships.vue'
    export default {
      name: 'app',
      components: {
        Ships
      },
    
  3. 同时导入mapState

    import { mapState } from 'vuex';
    
  4. 接下来,添加代码将我们的存储中的films数组映射到一个本地的计算值。记住要导入mapState

    computed: {
        ...mapState(["films"])
      },
    
  5. 最后,使用created方法在我们的存储器中触发一个动作:

    created() {
      this.$store.dispatch('loadFilms');
    }
    
  6. 接下来,在components/Ship.vue中构建Ships组件。Ships组件也包含数据列表,但使用按钮让用户可以请求加载数据。按钮在完成时应该自动消失,并在加载过程中禁用:

    <template>
      <div>
        <h2>Ships</h2>
        <div v-if="ships.length"> 
          <ul>
            <li v-for="ship in ships" :key="ship.url">
              {{ ship.name }} is a {{ ship.starship_class }} 
            </li>
          </ul>
        </div>
        <button v-else @click="loadShips" :disabled="loading">Load       Ships</button>
      </div>
    </template>
    
  7. 添加处理ships状态映射的代码,并触发 Vuex 中的动作来加载ships

    <script>
    import { mapState } from 'vuex';
    export default {
      name: 'Ships',
      data() {
        return {
          loading:false
        }
      },
      computed: {
        ...mapState(["ships"])
      },
      methods:{
        loadShips() {
          this.loading = true;
          this.$store.dispatch('loadShips');
        }
      }
    }
    </script>
    
  8. 现在,构建存储器。首先,定义state来保存filmsships数组:

    import Vue from 'vue'
    import Vuex from 'vuex'
    import axios from 'axios'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        films:[],
        ships:[]
      },
    
  9. 接下来,添加加载shipsfilms数据的动作。它们都应该使用mutations来将值赋给state

      mutations: {
        setFilms(state, films) {
          state.films = films;
        },
        setShips(state, ships) {
          state.ships = ships;
        }
      },
      actions: {
        loadFilms(context) {
          axios.get('https://swapi.dev/api/films')
          .then(res => {
            context.commit('setFilms', res.data.results);
          })
          .catch(error => {
            console.error(error);
          });
        },
        loadShips(context) {
          axios.get('https://swapi.dev/api/starships')
          .then(res => {
            context.commit('setShips', res.data.results);
          })
          .catch(error => {
            console.error(error);
          });
        }
      }
    })
    
  10. 使用以下命令运行您的应用程序:

    npm run serve
    

    您的输出将是以下内容:

    图 10.5:最终输出

图 10.5:最终输出

总体而言,这并不是与没有 Vuex 的初始版本有巨大变化(如果我们忽略 UI 变化),但现在我们所有的 API 使用都由存储器处理。如果我们决定停止使用Axios并切换到 Fetch,这可以在这里完成。无论我们决定添加缓存系统还是存储数据以供离线使用,都可以在存储器中完成。通过运行npm run serve并在浏览器中打开 URL 来自行测试这个版本。

现在是时候将您所学到的知识应用到下一个活动上了!

活动十.01:使用 Axios 和 Vuex 进行身份验证

Vuex 的一个更有趣的功能是管理身份验证。我们这是什么意思?在许多 API 中,在使用服务之前需要身份验证。用户验证后,他们会被分配一个令牌。在未来的 API 调用中,令牌会随请求一起传递,通常作为头部信息,这会让远程服务知道这是一个授权用户。Vuex 可以为您处理所有这些,而Axios使得处理头部信息变得容易,所以让我们考虑一个实际操作的例子。

在本书中构建具有身份验证和授权的服务器远远超出了本书的范围,因此,我们将采取模拟的方式。我们将使用两个JSONBin.io,这是我们曾在第九章,使用 Vuex – 状态、获取器、动作和突变中使用的服务。第一个端点将返回一个令牌:

{
  "token": 123456789
}

第二个端点将返回一个cats数组:

[
  {
    "name": "Luna",
    "gender": "female"
  },
  {
    "name": "Pig",
    "gender": "female"
  },
  {
    "name": "Cracker",
    "gender": "male"
  },
  {
    "name": "Sammy",
    "gender": "male"
  },
  {
    "name": "Elise",
    "gender": "female"
  }
]

在这个活动中,我们将使用 Vue Router 来处理表示应用程序的两个视图,即登录界面和猫展示界面。

步骤

  1. 为应用程序的初始视图提供一个登录界面。它应该提示用户名和密码。

  2. 将登录凭证传递给端点并获取一个令牌。这部分将进行模拟,因为我们不是在构建一个完整的、真实的身份验证系统。

  3. 从远程端点加载猫,并将令牌作为身份验证头部传递。

初始输出应该是以下内容:

图 10.6:初始登录界面

图 10.6:初始登录界面

登录后,您将看到以下数据:

图 10.7:登录后成功显示数据

图片

图 10.7:登录后成功显示数据

注意

该活动的解决方案可以通过此链接找到。

摘要

在本章中,你学习了 Vuex 的一个重要用例——与远程 API 协同工作。远程 API 可以为你的应用程序提供大量的额外功能,有时对开发者的额外成本几乎为零。你看到了如何使用Axios使网络调用更简单,以及如何将 Vuex 的状态管理功能与之结合。最后,你将其与 Vue Router 结合,创建了一个简单的登录/授权演示。

在下一章中,我们将讨论如何使用模块构建更复杂的 Vuex 存储。

第十一章:11. 使用 Vuex – 组织更大的存储

概述

在本章中,你将学习如何更好地组织更大的 Vuex 存储。随着你的应用程序在复杂性和功能上的增长,你的存储文件可能变得难以操作。随着文件越来越大,甚至简单地找到东西也可能变成一项困难的任务。本章将讨论两种不同的方法来简化存储的组织,以便进行更简单的更新。第一种方法将要求你将代码拆分到不同的文件中,而第二种方法将使用更高级的 Vuex 功能,即模块。

简介

到目前为止,我们处理过的存储都很简单且简短。但是,正如众所周知的那样,即使是简单的应用程序随着时间的推移也会趋向于复杂化。正如你在前面的章节中学到的,你的存储可以包含一个state、一个getters的块、一个mutationsactions的块,以及你将在本章后面学到的内容,即modules

随着你的应用程序增长,拥有一个文件来管理你的 Vuex 存储(store)可能会变得难以管理。修复错误和更新新功能可能会变得更加困难。本章将讨论两种不同的方法来帮助管理这种复杂性并组织你的 Vuex 存储。为了明确,这些都是你可以做的可选事情来帮助管理你的存储。如果你的存储很简单,并且你希望保持这种状态,那也是可以的。你总是可以在将来使用这些方法,而且好处是,没有人需要知道你的存储之外的事情——他们将继续像以前一样使用 Vuex 数据。你可以将这些技巧作为一组工具保留在心中,以帮助你在应用程序需要升级时使用。让我们从最简单的方法,文件拆分,开始。

方法一 – 使用文件拆分

第一种方法,当然也是最简单的一种方法,就是简单地将你的各种 Vuex 部分的代码(如stategetters等)移动到它们自己的文件中。然后,这些文件可以被主 Vuex 存储import并正常使用。让我们考虑一个简单的例子:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    name:"Lindy", 
    favoriteColor: "blue",
    profession: "librarian"
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

这是从第九章中的第一个练习使用 Vuex – 状态、获取器、动作和突变中来的,并且是一个只有三个状态值的存储。要将状态迁移到新文件,你可以在store文件夹中创建一个名为state.js的新文件,并按照如下设置:

export default {
  name: 'Lindy',
  favoriteColor: 'blue',
  profession: 'librarian'
}

然后,回到你的存储中,将其修改为import并使用代码:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
export default new Vuex.Store({
  state,
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

虽然这个例子最终变成了更多的代码行,但你可以看到我们是如何开始将存储的不同部分分离到不同的文件中,以便更容易更新的。让我们考虑一个稍微大一点的例子,再次参考第九章中的第二个练习,使用 Vuex – 状态、获取器、动作和突变。以下是原始存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    firstName: "Lindy",
    lastName: "Roberthon"
  },
  getters: {
    name(state) {
      return state.firstName + ' ' + state.lastName;
    }
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

这个例子只使用了state值和一个getter,但让我们将它们都移动到新文件中。首先,让我们将state移动到名为state.js的文件中:

export default {
  firstName: 'Lindy',
  lastName: 'Roberthon'
}

接下来,让我们将 getters 移入一个名为 getters.js 的文件中:

export default {
  name(state) {
    return state.firstName + ' ' + state.lastName;
  }
}

现在我们可以更新存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
import getters from './getters.js';
export default new Vuex.Store({
  state,
  getters,
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

将相同类型的更新应用于 mutationsactions 将遵循完全相同的模式,并且显然,你不必拆分一切。例如,你可以将状态值保留在主文件中,但只拆分你的函数(gettersmutationsactions)。

练习 11.01:使用文件拆分

在这个练习中,我们将在一个稍微大一点的 Vue store 中使用文件拆分。说实话,它并不大,但我们将会使用文件拆分来处理 stategettersmutationsactions

要访问此练习的代码文件,请访问 packt.live/32uwiKB

  1. 生成一个新的 Vue 应用程序并添加 Vuex 支持。

  2. 修改默认存储的 index.js 文件(位于 src/store/index.js),导入我们将创建的四个文件来表示存储:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    import state from './state.js';
    import getters from './getters.js';
    import mutations from './mutations.js';
    import actions from './actions.js';
    export default new Vuex.Store({
      state,
      getters,
      mutations,
      actions,
      modules: {
      }
    })
    
  3. 编辑新的 state.js 文件,添加姓名和姓氏的值,代表该人拥有的猫和狗数量的数字,以及一个最爱电影:

    export default {
            firstName: 'Lindy',
            lastName: 'Roberthon',
            numCats: 5,
            numDogs: 1,
            favoriteFilm:''
    }
    
  4. 添加一个 getter.js 文件来定义全名和宠物总数的 getter

    export default {
            name(state) {
                    return state.firstName + ' ' +state.lastName
            },
            totalPets(state) {
                    return state.numCats + state.numDogs
            }
    }
    
  5. 接下来,添加一个 mutations.js 文件来添加猫和狗的数量,设置姓名和姓氏,以及添加最爱电影:

    export default {
            addCat(state) {
                state.numCats++;
            },
            addDog(state) {
                state.numDogs++;
            },
            setFirstName(state, name) {
                if(name !== '') state.firstName = name;
            },
            setLastName(state, name) {
                if(name !== '') state.lastName = name;
            },
            setFavoriteFilm(state, film) {
                if(film !== '') state.favoriteFilm = film;
            }
    }
    
  6. 最后,添加 actions.js 文件来定义一个动作,updateFavoriteFilm。这将向 Star Wars API 发起网络请求,以确保只有当新的最爱电影是《星球大战》电影时才允许:

    export default {
        async updateFavoriteFilm(context, film) {
            try {
                let response = await fetch('https://swapi.dev/api/films?search='+encodeURIComponent(film));
                let data = await response.json();
                if(data.count === 1) context.commit               ('setFavoriteFilm', film);
                else console.log('Ignored setting non-Star Wars               film '+film+' as favorite.'); 
            } catch(e) {
                console.error(e);
            }
        }
    }
    
  7. 要看到它的实际效果,更新 src/App.vue 以访问存储的各个部分。这一步的唯一目的是强调你使用存储的方式并没有改变:

    <template>
      <div id="app">
        My first name is {{ $store.state.firstName }}.<br/>
        My full name is {{ $store.getters.name }}.<br/>
        I have this many pets - {{ $store.getters.totalPets }}.<br/>
        My favorite film is {{ $store.state.favoriteFilm }}.
      </div>
    </template>
    <script>
    export default {
      name: 'app',
      created() {
        this.$store.dispatch('updateFavoriteFilm', 'A New Hope');
      }
    }
    </script>
    

    上述代码将生成如下输出:

    ![图 11.1:新组织存储的输出

    ![img/B15218_11_01.jpg]

图 11.1:新组织存储的输出

你现在已经看到了一个(相对简单)的例子,使用文件拆分来管理 Vuex 存储的大小。虽然功能与之前看到的不同,但随着你的应用程序的增长,你可能会发现添加和修复要容易得多。

第二种方法 – 使用模块

在先前的方法中,我们主要只是将代码行移动到其他文件中。正如我们所说的,虽然这使处理存储本身变得更容易,但它并没有改变 Vue 组件使用存储的方式。模块帮助我们处理组件级别的复杂性。

想象一个包含许多不同值的 state 对象,例如这个:

state: {
  name:"Lindy", 
  favoriteColor: "blue", 
  profession: "librarian", 
  // lots more values about Lindy
  books: [
    { name: "An Umbrella on Fire", pages: 283 },
    { name: "Unicorn Whisperer", pages: 501 },
    // many, many more books
  ],
  robots: {
    skill:'advanced',
    totalAllowed: 10,
    robots: [
      { name: "Draconis" },
      // so much robots 
    ]
  }
}

这个例子包含了关于一个人的信息,与书籍相关的数据,以及代表机器人的值集。这是一大批数据,涵盖了三个独特不同的主题。将这些内容移入单独的文件并不一定能使使用变得更简单或有助于保持组织有序。这种复杂性也会影响到gettersmutationsactions。给定一个名为setName的操作,你可以假设它适用于代表个人的状态值,但如果其他状态值有类似的名字,可能会开始变得混乱。

这就是模块的作用。一个模块允许我们定义一个完全独立的stategettersmutationsactions,与或核心存储完全分离。

下面是一个使用resume模块的示例存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    firstName:'Raymond',
    lastName:'Camden'
  },
  getters: {
    name(state) {
      return state.firstName + ' ' + state.lastName;
    }
  },
  modules: {
    resume: {
      state: {
        forHire:true,
        jobs: [
          "Librarian", 
          "Jedi",
          "Cat Herder"
        ]
      },
      getters: {
        totalJobs(state) {
          return state.jobs.length;
        }
      }
    }
  }
})

stategetters也可以公开mutationsactions。注意在resume模块的getters中,totalJobsstate变量引用的是它自己的状态,而不是父状态。这是非常好的,因为它确保你可以在模块内部工作,而不用担心意外修改根或其他模块中的某个值。你可以在getters中使用一个新的第三个参数rootState来访问根状态:

totalJobs(state, anyArgument, rootState)

动作可以通过上下文对象context.rootState使用rootState。然而,从理论上讲,你的模块应该关注它们自己的数据,并且只有在必要时才向外扩展到根状态。

当使用模块值时,你的代码必须知道模块的名称。考虑以下示例:

first name {{ $store.state.firstName }}<br/>
for hire? {{ $store.state.resume.forHire }}<br/>

gettersactionsmutations并没有被区分。这就是你访问getters的方式:

full name {{ $store.getters.name }}<br/>
total jobs {{ $store.getters.totalJobs }}<br/>

这个想法背后的目的是允许一个模块或多个模块可能对相同的调用做出响应。如果你不喜欢这个,你可以使用namespaced选项:

modules: {
  resume: {
    namespaced: true,
    state: {
      forHire:true,
      jobs: [
        "Librarian", 
        "Jedi",
        "Cat Herder"
      ]
    },
    getters: {
      totalJobs(state) {
        return state.jobs.length;
      }
    }
  }
}

然后要引用此模块的gettersmutationsactions,你必须将模块的名称作为调用的一部分传递。例如,现在的 getter 变成了:$store.getters['resume/totalJobs']

大部分来说,这是模块支持的核心,但请注意,还有更多关于模块如何全局暴露自己的选项,这些选项超出了本书的范围。请参阅模块文档的后半部分(vuex.vuejs.org/guide/modules.html)以获取相关示例。最后,请注意,你可以根据需要将模块嵌套在模块中,Vuex 允许这样做!

练习 11.02:利用模块

在这个练习中,我们将与一个 Vuex 存储库一起工作,它使用不止一个模块,为了使它更有趣,其中一个模块将存储在另一个文件中,这表明我们在使用模块时也可以使用第一种方法。

要访问此练习的代码文件,请访问packt.live/35d1zDv

  1. 如同往常,生成一个新的 Vue 应用程序,并确保你添加了 Vuex。

  2. store/index.js存储文件中,为姓氏和名字添加两个state值,并添加一个 getter 来返回两者:

      state: {
        firstName:'Raymond',
        lastName:'Camden'
      },
      getters: {
        name(state) {
          return state.firstName + ' ' + state.lastName;
        }
      },
    
  3. 接下来,向store文件添加一个resume模块。它将有两个state值,一个表示可雇佣值,另一个是一个表示过去工作的数组。最后,添加一个 getter 来返回工作的总数:

      modules: {
        resume: {
          state: {
            forHire:true,
            jobs: [
              "Librarian", 
              "Jedi",
              "Cat Herder"
            ]
          },
          getters: {
            totalJobs(state) {
              return state.jobs.length;
            }
          }
        },
    
  4. 现在为下一个模块创建一个新的文件,store/portfolio.js。这将包含一个表示已工作的网站数组的state值和一个添加值的mutation

    export default {
            state: {
                websites: [
                    "https://www.raymondcamden.com",
                    "https://codabreaker.rocks"
                ]
            },
            mutations: {
                addSite(state, url) {
                    state.websites.push(url);
                }
            }
    }
    
  5. 在主存储的index.js文件中,导入portfolio

    import portfolio from './portfolio.js';
    
  6. 然后将portfolio添加到模块列表中,在resume之后:

      modules: {
        resume: {
          state: {
            forHire:true,
            jobs: [
              "Librarian", 
              "Jedi",
              "Cat Herder"
            ]
          },
          getters: {
            totalJobs(state) {
              return state.jobs.length;
            }
          }
        },
        portfolio
      }
    
  7. 现在,让我们在我们的主src/App.vue文件中使用这些模块。修改模板以添加对存储中各个部分的调用:

        <p>
        My name is {{ $store.getters.name }} and I 
        <span v-if="$store.state.resume.forHire">
            am looking for work!
        </span><span v-else>
            am not looking for work.
        </span>
        </p>
        <p>
          I've had {{ $store.getters.totalJobs }} total jobs. 
        </p>
        <h2>Portfolio</h2>
        <ul>
          <li 
            v-for="(site,idx) in $store.state.portfolio.websites"
            :key="idx"><a :href="site" target="_new">{{ site }}</a></li>
        </ul>
    
  8. 然后添加一个表单,以便我们可以添加一个新网站

        <p>
          <input type="url" placeholder="New site for portfolio"         v-model="site">
          <button @click="addSite">Add Site</button>
        </p>
    
  9. 定义addSite方法的函数。它将提交mutation并清除站点值。务必为站点添加一个本地数据值。以下是完整的脚本块:

    export default {
      name: 'app',
      data() {
        return {
          site:''
        }
      },
      methods: {
        addSite() {
          this.$store.commit('addSite', this.site);
          this.site = '';
        }
      }
    }
    

    结果将如下所示:

    图 11.2:使用模块利用 Vuex 数据的应用程序

    ](https://davestewart.github.io/vuex-pathify/)

图 11.2:使用模块利用 Vuex 数据的应用程序

现在你已经看到了另一种帮助管理你的 Vuex 存储的方法。模块提供了一种更深入、更复杂的组织存储的方式。一如既往,选择最适合你的应用程序需求以及你和你的团队最舒适的方法!

组织 Vuex 存储的其他方法

虽然前两种方法应该为你提供一些管理 Vuex 存储的好选项,但你可能还想考虑其他一些选项。

Vuex Pathify

Vuex Pathify([davestewart.github.io/vuex-pathify/](https://davestewart.github.io/vuex-pathify/))是一个实用工具,它使得通过resumestatejobs访问 Vuex 存储变得更加容易:store.get('resume/jobs')。基本上,它为读取和写入存储中的值以及简化同步创建了一个快捷方式。XPath 的爱好者会喜欢这个。

Vuex 模块生成器(VMG)

statemutationsactions。任何在 Web 开发领域工作过一段时间的人都会熟悉 CRUD 模式,并且绝对会为不必再次编写这些函数而感到高兴。

查看 GitHub 仓库(github.com/abdullah/vuex-module-generator)以获取更多详细信息及示例应用程序。

Vuex ORM

ORM库添加到 Vuex 存储中。ORM代表对象关系映射,是一种帮助简化对象持久化的模式。像 VMG 一样,Vuex ORM 旨在简化 Web 开发者必须编写的相对常见的 CRUD 任务。

Vuex ORM 允许你定义代表你的存储数据结构的类。一旦你定义了数据结构,Vuex ORM 就提供了实用函数,使得在存储中存储和检索数据变得更加简单。它甚至处理数据之间的关系,例如属于它的 cat 对象。

下面是如何定义一种数据类型的示例:

class Cat extends Model {
  static entity = 'cats'
  static fields () {
    return {
      id: this.attr(null),
      name: this.string(''),
      age: this.number(0),
      adoptable: this.boolean(true)
    }
  }
}

在前面的课程中,为 Cat 类定义了四个属性:idnameageadoptable。对于每个属性,都指定了默认值。一旦定义,请求所有数据就像 Cat.all() 一样简单。Vuex ORM 还有更多内容,你可以在 vuex-orm.github.io/vuex-orm/ 上查看。

活动 11.01:简化 Vuex 存储

这个活动将与你之前做过的活动略有不同。在这个活动中,你将使用一个 现有 的应用,该应用使用 Vuex,并应用本章中学到的某些技术来简化存储,使其在未来更新中更容易使用。这在进行功能调整或修复时可能非常有用。

步骤:

  1. 要开始这个活动,你将使用位于 Chapter11/activity11.01/initial 的完成示例(packt.live/3kaqBHH)。

  2. 修改存储文件,将 stategettersmutations 放入它们自己的文件。

  3. 修改 state,使 cat 值位于 module 中。

  4. 将与猫相关的 getter 迁移到 module

  5. 更新 App.vue 文件,使其仍然正确显示最初的数据。

    这是构建后的样子:

    图 11.3:活动的最终输出

图 11.3:活动的最终输出

注意

这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,你学习了多种不同的技术来为你的 Vuex 存储准备增长复杂性。你首先学习了如何将逻辑移动到单独的文件并在你的存储中包含它们。然后你学习了模块以及它们是如何通过存储暴露给组件的。最后,你学习了可能使 Vuex 使用更加强大的某些可选库。

在下一章,你将学习关于开发一个极其重要的方面,单元测试。

第十二章:12. 单元测试

概述

在本章中,我们将探讨对 Vue.js 应用程序进行单元测试的方法,以提高我们的质量和交付速度。我们还将探讨使用测试来驱动开发,即 Test-Driven DevelopmentTDD)。

随着我们继续前进,你将了解为什么代码需要被测试,以及可以在 Vue.js 应用的不同部分采用哪些类型的测试。你将看到如何使用浅渲染和 vue-test-utils 对隔离组件及其方法进行单元测试,你还将学习如何测试异步组件代码。在整个章节的过程中,你将熟悉编写针对 混入过滤器 的有效单元测试的技术。在章节的末尾,你将熟悉包括路由和 Vuex 在内的 Vue.js 应用程序的测试方法,你还将了解如何使用快照测试来验证你的用户界面。

简介

在本章中,我们将探讨有效测试 Vue.js 应用程序的目的和方法。

在前面的章节中,我们看到了如何构建合理的复杂 Vue.js 应用程序。本章是关于测试它们以保持代码质量和防止缺陷。

单元测试将使我们能够编写快速且具体的测试,我们可以针对这些测试进行开发,并确保功能不会表现出不受欢迎的行为。我们将了解如何为 Vue.js 应用的不同部分编写单元测试,例如组件、混入、过滤器以及路由。我们将使用 Vue.js 核心团队支持的工具,如 vue-test-utils,以及开源社区其他部分支持的工具,如 Vue 测试库和 Jest 测试框架。这些不同的工具将用于说明不同的单元测试哲学和方法。

我们为什么需要测试代码

测试对于确保代码按预期执行至关重要。

质量生产软件是经验上正确的。这意味着对于开发人员和测试人员发现的列举案例,应用程序的行为符合预期。

这与已被 证明 正确的软件形成对比,这是一个非常耗时的工作,通常是学术研究项目的一部分。我们仍然处于这样一个阶段,即 正确的软件(已证明)仍在构建,以展示在正确性的约束下可以构建哪些类型的系统。

测试可以防止引入缺陷,如错误和回归(即,当某个功能停止按预期工作时)。在下一节中,我们将了解各种测试类型。

理解不同类型的测试

测试范围从端到端测试(通过操作用户界面)到集成测试,最后到单元测试。端到端测试测试一切,包括用户界面、底层 HTTP 服务,甚至数据库交互;没有任何内容被模拟。例如,如果你有一个电子商务应用程序,端到端测试可能会实际使用真实信用卡下订单,或者它可能会使用测试信用卡下测试订单。

端到端测试的运行和维护成本较高。它们需要使用通过程序性驱动程序(如SeleniumWebdriverIOCypress)控制的完整浏览器。这种测试平台运行成本较高,应用代码中的微小变化都可能导致端到端测试开始失败。

集成或系统级测试确保一组系统按预期工作。这通常涉及确定被测试系统的界限,并允许它运行,通常是对模拟或存根的上游服务和系统进行测试(因此这些服务和系统不在测试范围内)。由于外部数据访问被存根,可以减少许多问题,如超时和故障(与端到端测试相比)。集成测试套件通常足够快,可以作为持续集成步骤运行,但完整的测试套件通常不会由工程师在本地运行。

单元测试在开发过程中提供快速反馈方面非常出色。单元测试与 TDD(测试驱动开发)相结合是极限编程实践的一部分。单元测试擅长测试复杂的逻辑或从预期的输出构建系统。单元测试通常足够快,以至于开发者在将代码提交审查和持续集成测试之前,会先针对它们编写代码。

以下是对测试金字塔的解释。它可以理解为:你应该有大量便宜且快速的单元测试,合理数量的系统测试,以及少数端到端 UI 测试:

![图 12.1:测试金字塔图]

![图 B15218_12_01.jpg]

图 12.1:测试金字塔图

现在我们已经了解了为什么我们应该测试应用程序,让我们开始编写一些测试。

你的第一个测试

为了说明在 Vue CLI 项目中开始自动化单元测试有多快、有多简单,我们将首先设置并使用 Jest 和@vue-test-utils编写一个单元测试。有一个官方的 Vue CLI 包可以用来生成一个包含使用 Jest 和vue-test-utils进行单元测试的设置。以下命令应在已设置 Vue CLI 的项目中运行:

vue add @vue/unit-jest

Vue CLI 将 Jest 作为测试运行器,@vue/test-utils作为官方的Vue.js测试工具,以及vue-jest,它是 Jest 中用于处理.vue单文件组件文件的处理器。它添加了一个test:unit脚本。

默认情况下,它创建一个tests/unit文件夹,我们将删除它。相反,我们可以创建一个__tests__文件夹,并创建一个App.test.js文件,如下所示。

我们将使用 shallowMount 来渲染应用程序并测试它是否显示正确的文本。为了本例的目的,我们将使用文本:"The Vue.js Workshop Blog"。

shallowMount 进行浅渲染,这意味着只渲染组件的最顶层;所有子组件都是占位符。这对于单独测试组件很有用,因为子组件的实现并未运行:

import { shallowMount } from '@vue/test-utils'
import App from '../src/App.vue'
test('App renders blog title correctly', () => {
  const wrapper = shallowMount(App)
  expect(wrapper.text()).toMatch("The Vue.js Workshop Blog")
})

当我们运行 npm run test:unit 时,这个测试将失败,因为我们没有在 App 组件中包含 The Vue.js Workshop Blog

![图 12.2:在命令行中测试失败的博客标题标题]

![图片 B15218_12_02.jpg]

图 12.2:在命令行中测试失败的博客标题标题

为了让测试通过,我们可以在 App.vue 文件中实现我们的博客标题标题:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <h2
        class="leading-loose pb-4 flex justify-center m-auto           md:w-1/3 text-xl mb-8 font-bold text-gray-800 border-b"
      >
      The Vue.js Workshop Blog
      </h2>
    </div>
  </div>
</template>

现在我们已经得到了正确的标题,npm run test:unit 将会通过:

![图 12.3:博客标题测试通过]

![图片 B15218_12_03.jpg]

图 12.3:博客标题测试通过

我们还可以检查它在浏览器中的渲染是否符合预期:

The Vue.js Workshop Blog

你刚刚完成了你的第一个 TDD(测试驱动开发)。这个过程从编写一个失败的测试开始。随后是对测试代码(在本例中是 App.vue 组件)的更新,这使得失败的测试通过。TDD 过程让我们有信心我们的功能已经得到了适当的测试,因为我们可以看到在更新驱动我们功能的代码之前,测试是失败的。

测试组件

组件是 Vue.js 应用程序的核心。使用 vue-test-utils 和 Jest 对它们进行单元测试非常简单。对大多数组件进行测试可以让你有信心它们按设计运行。理想的组件单元测试运行速度快且简单。

我们将继续构建博客应用程序示例。我们现在已经构建了标题,但一个博客通常还需要一个帖子列表来显示。

我们将创建一个 PostList 组件。目前,它将只渲染一个 div 包装器并支持 posts Array 属性:

<template>
  <div class="flex flex-col w-full">
  </div>
</template>
<script>
export default {
  props: {
    posts: {
      type: Array,
      default: () => []
    }
  }
}
</script>

我们可以在 App 组件中添加一些数据:

<script>
export default {
  data() {
    return {
      posts: [
        {
          title: 'Vue.js for React developers',
          description: 'React has massive popularity here are the             key benefits of Vue.js over it.',
          tags: ['vue', 'react'],
        },
        {
          title: 'Migrating an AngularJS app to Vue.js',
          description: 'With many breaking changes, AngularJS developers             have found it easier to retrain to Vue.js than Angular 2',
          tags: ['vue', 'angularjs']
        }
      ]
    }
  }
}
</script>

现在我们有一些帖子,我们可以将它们作为绑定属性从 App 组件传递给 PostList 组件:

<template>
  <!-- rest of template -->
        <PostList :posts="posts" />
  <!-- rest of template -->
</template>
<script>
import PostList from './components/PostList.vue'
export default {
  components: {
    PostList
  },
  // rest of component properties
}

我们的 PostList 组件将在 PostListItem 组件中渲染每篇帖子,我们将按以下方式创建它。

PostListItem 接受两个属性:title(一个字符串)和 description(也是一个字符串)。它分别用 h3 标签和 p 标签渲染它们:

<template>
  <div class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4">
    <h3 class="flex text-md font-semibold text-gray-700">
      {{ title }}</h3>
    <p class="flex leading-relaxed">{{ description }}</p>
  </div>
</template>
<script>
export default {
  props: {
    title: {
      type: String
    },
    description: {
      type: String
    }
  }
}
</script>

现在,我们需要遍历帖子并使用 PostList.vue 组件渲染带有相关属性的 PostListItem 组件:

<template>
  !-- rest of template -->
    <PostListItem
      v-for="post in posts"
      :key="post.slug"
      :title="post.title"
      :description="post.description"
    />
  <!-- rest of template -->
</template>
<script>
import PostListItem from './PostListItem.vue'
export default {
  components: {
    PostListItem,
  },
  // rest of component properties
}
</script>

我们现在可以在应用程序中看到标题和帖子列表:

The Vue.js Workshop blog

要测试 PostListItem 组件,我们可以使用一些任意的 titledescription 属性进行浅渲染,并检查它们是否被渲染:

import { shallowMount } from '@vue/test-utils'
import PostListItem from '../src/components/PostListItem.vue'
test('PostListItem renders title and description correctly',   () => {
  const wrapper = shallowMount(PostListItem, {
    propsData: {
      title: 'Blog post title',
      description: 'Blog post description'
    }
  })
  expect(wrapper.text()).toMatch("Blog post title")
  expect(wrapper.text()).toMatch("Blog post description")
})

运行 npm run test:unit __tests__/PostListItem.test.js 的测试输出如下;组件通过了测试:

![图 12.4:PostListItem 测试输出]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_04.jpg)

图 12.4:PostListItem 测试输出

接下来,我们将看到浅渲染的一个陷阱。当测试PostList组件时,我们所能做的就是测试它渲染的PostListItem组件的数量:

import { shallowMount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
import PostListItem from '../src/components/PostListItem.vue'
test('PostList renders the right number of PostListItem',   () => {
  const wrapper = shallowMount(PostList, {
    propsData: {
      posts: [
        {
          title: "Blog post title",
          description: "Blog post description"
        }
      ]
    }
  })
  expect(wrapper.findAll(PostListItem)).toHaveLength(1)
})

这通过了,但我们测试的是用户不会直接与之交互的东西,即PostList中渲染的PostListItem实例的数量,如下面的截图所示:

图 12.5:PostList 测试输出

图 12.5:PostList 测试输出

一个更好的解决方案是使用mount函数,它渲染完整的组件树,而shallow函数只会渲染正在渲染的组件的子组件。使用mount,我们可以断言标题和描述被渲染到页面上。

这种方法的缺点是我们同时测试了PostList组件和PostListItem组件,因为PostList组件不渲染标题或描述;它渲染一组PostListItem组件,这些组件反过来渲染相关的标题和描述。

代码如下:

import { shallowMount, mount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
// other imports and tests
test('PostList renders passed title and description for each   passed post', () => {
  const wrapper = mount(PostList, {
    propsData: {
      posts: [
        {
          title: 'Title 1',
          description: 'Description 1'
        },
        {
          title: 'Title 2',
          description: 'Description 2'
        }
      ]
    }
  })
  const outputText = wrapper.text()
  expect(outputText).toContain('Title 1')
  expect(outputText).toContain('Description 1')
  expect(outputText).toContain('Title 2')
  expect(outputText).toContain('Description 2')
})

新的测试按照以下npm run test:unit __tests__/PostList.vue的输出通过:

图 12.6:PostList 的浅渲染和挂载测试运行

图 12.6:PostList 的浅渲染和挂载测试运行

我们现在已经看到了如何使用 Jest 和vue-test-utils为 Vue.js 组件编写单元测试。这些测试可以经常运行,测试运行在几秒内完成,这在我们处理新组件或现有组件时提供了几乎即时的反馈。

练习 12.01:构建和单元测试标签列表组件

当创建posts的测试用例时,我们用vueangularjsreact填充了tags字段,但没有显示它们。为了使标签有用,我们将在帖子列表中显示标签。

要访问此练习的代码文件,请参阅packt.live/2HiTFQ1

  1. 我们可以首先编写一个单元测试,说明当传递一组标签作为 props 给PostListItem组件时,我们期望它做什么。它期望每个标签都会有一个前置的井号;例如,react标签将显示为#react。在__tests__/PostListItem.test.js文件中,我们可以添加一个新的test

    // rest of tests and imports
    test('PostListItem renders tags with a # prepended to   them', () => {
      const wrapper = shallowMount(PostListItem, {
        propsData: {
          tags: ['react', 'vue']
        }
      })
      expect(wrapper.text()).toMatch('#react')
      expect(wrapper.text()).toMatch('#vue')
    })
    

    当使用npm run test:unit __tests__/PostListItem.test.js运行此测试时,测试失败:

    图 12.7:PostListItem 标签测试失败

    图 12.7:PostListItem 标签测试失败

  2. 接下来,我们应该在src/components/PostListItem.vue中实现标签列表渲染。我们将添加标签作为Array类型的 props,并使用v-for渲染标签:

    <template>
        <!-- rest of template -->
        <div class="flex flex-row flex-wrap mt-4">
          <a
            v-for="tag in tags"
            :key="tag"
            class="flex text-xs font-semibold px-2 py-1 mr-2           rounded border border-blue-500 text-blue-500"
          >
            #{{ tag }}
          </a>
        </div>
        <!-- rest of template -->
    </template>
    <script>
    export default {
      props: {
        // rest of props
        tags: {
          type: Array,
          default: () => []
        }
      }
    }
    </script>
    

    在实现了PostListItem组件之后,单元测试现在应该通过:

    图 12.8:PostListItem 单元测试通过

    图 12.8:PostListItem 单元测试通过

    然而,标签在应用程序中没有显示:

    ![图 12.9:显示没有标签的 PostList]

    正确的 PostListItem 实现

    图片 B15218_12_09.jpg

    图 12.9:尽管 PostListItem 实现正确,但 PostList 显示没有标签

  3. 我们可以为 PostList 编写一个单元测试,以展示这种行为。本质上,我们将向我们的 posts 列表中传递一些标签,并运行 PostListItem.test.js 文件中已经存在的相同断言。我们将在 __tests__/PostList.test.js 中这样做:

    // rest of tests and imports
    test('PostList renders tags for each post', () => {
      const wrapper = mount(PostList, {
        propsData: {
          posts: [
            {
              tags: ['react', 'vue']
            },
            {
              tags: ['html', 'angularjs']
            }
          ]
        }
      })
      const outputText = wrapper.text()
      expect(outputText).toContain('#react')
      expect(outputText).toContain('#vue')
      expect(outputText).toContain('#html')
      expect(outputText).toContain('#angularjs')
    })
    

    根据我们的应用程序输出,当使用 npm run test:unit __tests__/PostList.test.js 运行时,测试失败:

    ![图 12.10:PostList 标签测试失败]

    图片 B15218_12_10.jpg

    图 12.10:PostList 标签测试失败

  4. 为了修复这个测试,我们可以在 src/components/PostList.vue 中找到问题,这里的 PostListItemtags 属性没有被绑定。通过更新 src/components/PostList.vue 来绑定 tags 属性,我们可以修复单元测试:

    <template>
      <!-- rest of template-->
        <PostListItem
          v-for="post in posts"
          :key="post.slug"
          :title="post.title"
          :description="post.description"
          :tags="post.tags"
        />
      <!-- rest of template -->
    </template>
    

    失败的单元测试现在通过了,如下面的截图所示:

    ![图 12.11:PostList 标签测试通过]

    图片 B15218_12_11.jpg

图 12.11:PostList 标签测试通过

标签也出现在应用程序中,如下面的截图所示:

![图 12.12:带有标签的博客列表渲染]

图片 B15218_12_12.jpg

图 12.12:带有标签的博客列表渲染

我们已经看到了如何使用浅渲染和组件挂载来测试渲染的组件输出。让我们简要了解这些术语的含义:

  • 浅渲染:这将在深度 1 处渲染,这意味着如果子元素是组件,它们将仅作为组件标签渲染;它们的模板将不会运行。

  • 挂载:这将以与在浏览器中渲染相似的方式渲染整个组件树。

接下来,我们将探讨如何测试组件方法。

测试方法、过滤器和方法混合

由于 clickinput changefocus changescroll)。

例如,一个将输入截断为八个字符的过滤器将实现如下:

<script>
export default {
  filters: {
    truncate(value) {
      return value && value.slice(0, 8)
    }
  }
}
</script>

有两种测试它的方法。我们可以直接通过导入组件并在某些输入上调用 truncate 来测试它,就像 truncate.test.js 文件中那样:

import PostListItem from '../src/components/PostListItem.vue'
test('truncate should take only the first 8 characters', () => {
  expect(
    PostListItem.filters.truncate('longer than 8 characters')
  ).toEqual('longer t')
})

另一种方法是检查它在 PostListItem 组件中的使用情况:

<template>
  <!-- rest of template -->
    <h3 class="flex text-md font-semibold text-gray-700">
      {{ title | truncate }}
    </h3>
  <!-- rest of template -->
</template>

现在,我们可以通过在 PostListItem.test.js 文件中将长标题传递给 PostListItem 组件来测试 truncate,我们在以下测试中这样做:

// imports
test('PostListItem renders title and description correctly',   () => {
  const wrapper = shallowMount(PostListItem, {
    propsData: {
      title: 'Blog post title',
      description: 'Blog post description'
    }
  })
  expect(wrapper.text()).toMatch("Blog post title")
  expect(wrapper.text()).toMatch("Blog post description")
})
// other tests

前面的代码将生成以下截图所示的输出:

![图 12.13:PostListItem 测试失败,因为]

标题的内容被截断

图片 B15218_12_13.jpg

图 12.13:PostListItem 的标题测试失败,因为标题的内容被截断

为了修复这个问题,我们可以更新失败的测试,期望 Blog pos 而不是 Blog post title

这两种方法都是测试过滤器的优秀方法。正如我们之前在filters.truncate()测试中看到的那样,它直接访问了truncate过滤器。较宽松的单元测试是使用传入的属性并验证组件输出的测试。更紧密的单元测试通常意味着测试更简单,但这也意味着有时以与最终用户感知非常不同的方式测试功能。例如,用户永远不会直接调用filters.truncate()

我们已经看到了如何测试一个任意的truncate过滤器。现在我们将实现一个ellipsis过滤器并对其进行测试。

ellipsis过滤器将应用于帖子描述,并将其长度限制为40个字符加上

练习 12.02:构建和测试省略号过滤器

我们已经看到了如何测试一个任意的truncate过滤器;现在我们将实现一个ellipsis过滤器并对其进行测试。

要访问此练习的代码文件,请参阅packt.live/2UK9Mcs

现在让我们看看构建和测试ellipsis过滤器的步骤:

  1. 我们可以先为ellipsis过滤器编写一组测试(该过滤器将位于src/components/PostListItem.vue中)。一个测试应该检查如果传入的值少于50个字符,过滤器不做任何处理;另一个测试应该检查如果传入的值超过50个字符,它将截断到50个字符并附加。我们将在__tests__/ellipsis.test.js文件中这样做:

    import PostListItem from '../src/components/PostListItem.vue'
    test('ellipsis should do nothing if value is less than 50   characters', () => {
      expect(
        PostListItem.filters.ellipsis('Less than 50 characters')
      ).toEqual('Less than 50 characters')
    })
    test('ellipsis should truncate to 50 and append "..." when   longer than 50 characters', () => {
      expect(
        PostListItem.filters.ellipsis(
          'Should be more than the 50 allowed characters by a         small amount'
        )
      ).toEqual('Should be more than the 50 allowed characters by     a...')
    })
    
  2. 我们现在可以在src/components/PostListItem.vue中实现ellipsis的逻辑。我们将添加一个带有ellipsisfilters对象,如果传入的值超过50个字符,它将使用String#slice,否则不做任何处理:

    <script>
    export default {
      // rest of component properties
      filters: {
        ellipsis(value) {
          return value && value.length > 50
            ? `${value.slice(0, 50)}...`
            : value
        }
      }
    }
    </script>
    

    在这种情况下,现在测试通过npm run test:unit __tests__/ellipsis.test.js,如图图 12.14所示:

    ![图 12.14:省略号过滤器单元测试通过]

    ![图片 B15218_12_14.jpg]

    图 12.14:省略号过滤器单元测试通过

  3. 现在,我们需要将我们的ellipsis过滤器集成到组件中。为了检查这能否工作,我们首先可以在__tests__/PostListItem.test.js中编写测试:

    // other tests and imports
    test('PostListItem truncates long descriptions', () => {
      const wrapper = shallowMount(PostListItem, {
        propsData: {
          description: 'Very long blog post description that goes         over 50 characters'
        }
      })
      expect(wrapper.text()).toMatch("Very long blog post description     that goes over 50 ...")
    })
    

    这个测试失败了,因为我们没有在组件模板中使用过滤器。输出将如下所示:

    ![图 12.15:PostListItem 省略号测试失败]

    ![图片 B15218_12_15.jpg]

    ![图 12.15:PostListItem 省略号测试失败]

  4. 为了使测试通过,我们需要将description属性通过src/components/PostListItem.vue中的ellipsis过滤器:

    <template>
      <!-- rest of template -->
        <p class="flex leading-relaxed">{{ description | ellipsis }}      </p>
      <!-- rest of template -->
    </template>
    

    现在,测试将通过,如下面的截图所示:

    ![图 12.16:PostListItem 省略号测试通过]

    ![图片 B15218_12_16.jpg]

![图 12.16:PostListItem 省略号测试通过]

我们可以在浏览器中的应用程序界面中看到描述被截断,如下所示:

![图 12.17:博客帖子项描述被截断到 50 个字符]

![图片 B15218_12_17.jpg]

图 12.17:博客帖子项描述被截断到 50 个字符

我们已经看到了如何测试 Vue.js 组件的过滤器和其他属性,不仅可以通过直接针对对象进行测试,还可以通过测试它在组件级测试中的功能来测试。

接下来,我们将看到如何处理使用 Vue.js 路由的应用程序。

测试 Vue 路由

我们目前有一个渲染我们博客主页或feed 视图的应用程序。

接下来,我们应该有帖子页面。为此,我们将使用 Vue Router,如前几章所述,并确保我们的路由通过单元测试按设计工作。

Vue Router 使用npm安装,具体来说,npm install vue-router,并在main.js文件中进行配置:

// other imports
import router from './router'
// other imports and configuration 
new Vue({
  render: h => h(App),
  router,
}).$mount(‹#app›)

router.js文件使用Vue.usevue-router注册到 Vue 中,并实例化一个VueRouter实例:

import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
export default new VueRouter({})

没有路由的路由器并不很有用。我们将在router.js文件中定义根路径(/),以显示PostList组件,如下所示:

// other imports
import PostList from './components/PostList.vue'
// registering of Vue router
const routes = [
  {
    path: '/',
    component: PostList
  }
]
export default new VueRouter({
  routes
})

现在我们已经有了初始路由,我们应该更新App.vue文件以利用由路由器渲染的组件。我们将渲染render-view而不是直接使用PostList。然而,posts绑定保持不变:

<template>
  <!-- rest of template -->
      <router-view
        :posts="posts"
      />
  <!-- rest of template -->
</template>

现在,App.vue文件中的帖子缺少一些核心数据来渲染SinglePost组件。我们需要确保有slugcontent属性,以便在我们的SinglePost页面上渲染有用的内容:

<script>
export default {
  data() {
    return {
      posts: [
        {
          slug: 'vue-react',
          title: 'Vue.js for React developers',
          description: 'React has massive popularity here are the             key benefits of Vue.js over it.',
          content:
            'React has massive popularity here are the key benefits               of Vue.js over it.
            See the following table, we'll also look at how the is               the content of the post.
            There's more, we can map React concepts to Vue and               vice-versa.',
          tags: ['vue', 'react'],
        },
        {
          slug: 'vue-angularjs',
          title: 'Migrating an AngularJS app to Vue.js',
          description: 'With many breaking changes, AngularJS developers             have found it easier to retrain to Vue.js than Angular 2',
          content:
            'With many breaking changes, AngularJS developers have               found it easier to retrain to Vue.js than Angular 2
            Vue.js keeps the directive-driven templating style while               adding a component model.
            It's performant thanks to a great reactivity engine.',
          tags: ['vue', 'angularjs']
        }
      ]
    }
  }
}
</script>

现在,我们可以开始工作在SinglePost组件上。目前,我们只是在模板中添加一些占位符。此外,SinglePost将接收posts作为属性,因此我们也可以填写这个属性:

<template>
  <div class="flex flex-col w-full md:w-1/2 m-auto">
    <h2
      class="font-semibold text-sm mb-4"
    >
      Post: RENDER ME
    </h2>
    <p>Placeholder for post.content</p>
  </div>
</template>
<script>
export default {
  props: {
    posts: {
      type: Array,
      default: () => []
    }
  }
}
</script>

接下来,我们将在router.js中注册SinglePost,使用/:postId路径(这将通过this.$route.params.postId在组件中可用):

// other imports
import SinglePost from './components/SinglePost.vue'
// vue router registration
const routes = [
  // other route
  {
    path: '/:postId',
    component: SinglePost
  }
]
// exports and router instantiation

如果我们切换回实现SinglePost组件,我们将能够访问postId,它将映射到posts数组中的 slug,并且我们也有posts的访问权限,因为它被App绑定到render-view。现在我们可以创建一个计算属性post,它根据postId查找帖子:

<script>
export default {
  // other properties
  computed: {
    post() {
      const { postId } = this.$route.params
      return posts.find(p => p.slug === postId)
    }
  }
}
</script>

从这个计算后的post属性中,我们可以提取titlecontent,如果post存在的话(我们必须注意那些不存在的帖子)。所以,在SinglePost中,我们可以添加以下计算属性:

<script>
export default {
  // other properties
  computed: {
    // other computed properties
    title() {
      return this.post && this.post.title
    },
    content() {
      return this.post && this.post.content
    }
  }
}
</script>

然后,我们可以用计算属性的值替换模板中的占位符。因此,我们的模板最终如下所示:

<template>
  <div class="flex flex-col w-full md:w-1/2 m-auto">
    <h2
      class="font-semibold text-sm mb-4"
    >
      Post: {{ title }}
    </h2>
    <p>{{ content }}</p>
  </div>
</template>

最后,我们应该在PostListItem.vue文件中使整个帖子项成为一个指向正确 slug 的router-link

<template>
  <router-link
    class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4"
    :to="`/${slug}`"
  >
    <!-- rest of the template -->
  </router-link>
</template>

router-link是 Vue Router 特定的链接,这意味着在PostList页面上,点击帖子列表项时,我们将被带到正确的帖子 URL,如下面的截图所示:

图 12.18:在浏览器中显示的帖子列表视图

图 12.18:在浏览器中显示的帖子列表视图

我们将被重定向到正确的 URL,即文章的缩略语,这将通过 slug 渲染正确的文章,如图 图 12.19 所示。

图 12.19:单篇文章视图在浏览器中显示

图 12.19:单篇文章视图在浏览器中显示

要测试 vue-router,我们将探索一个更适合测试具有路由和 Vuex 存储的应用程序的新库,即 Vue 测试库,该库可在 npm 上作为 @testing-library/vue 访问。

我们可以使用 npm install --save-dev @testing-library/vue 来安装它。

要测试 SinglePost 路由和渲染,我们执行以下操作。首先,我们应该能够通过点击 PostList 视图中的文章标题来访问 SinglePost 视图。为了做到这一点,我们通过检查内容(我们将看到两个带有标题的文章)来确认我们处于主页。然后我们点击一个文章标题并检查主页的内容已消失,文章内容已显示:

import {render, fireEvent} from '@testing-library/vue'
import App from '../src/App.vue'
import router from '../src/router.js'
test('Router renders single post page when clicking a post title',   async () => {
  const {getByText, queryByText} = render(App, { router })
  expect(queryByText('The Vue.js Workshop Blog')).toBeTruthy()
  expect(queryByText('Vue.js for React developers')).toBeTruthy()
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
  await fireEvent.click(getByText('Vue.js for React developers'))
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeFalsy()
  expect(queryByText('Post: Vue.js for React developers')).    toBeTruthy()
  expect(
    queryByText(
      'React has massive popularity here are the key benefits of         Vue.js over it. See the following table, we'll also look at         how the is the content of the post. There's more, we can         map React concepts to Vue and vice-versa.'
    )
  ).toBeTruthy()
})

我们应该检查直接导航到有效的文章 URL 将产生正确的结果。为了做到这一点,我们将使用 router.replace('/') 来清除任何设置的状态,然后使用 router.push() 并带有一个文章缩略语。然后我们将使用前一个代码片段中的断言来验证我们是否在 SinglePost 页面,而不是主页:

test('Router renders single post page when a slug is set',   async () => {
  const {queryByText} = render(App, { router })
  await router.replace('/')
  await router.push('/vue-react')
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeFalsy()
  expect(queryByText('Post: Vue.js for React developers')).    toBeTruthy()
  expect(
    queryByText(
      'React has massive popularity here are the key benefits of         Vue.js over it. See the following table, we'll also look at         how the is the content of the post. There's more, we can map         React concepts to Vue and vice-versa.'
    )
  ).toBeTruthy()
})

当使用 npm run test:unit __tests__/SinglePost.test.js 运行这两个测试时,它们按预期工作。以下截图显示了所需的输出:

图 12.20:SinglePost 的路由测试通过

图 12.20:SinglePost 的路由测试通过

我们已经看到了如何使用 Vue.js 测试库来测试一个使用 vue-router 的应用程序。

练习 12.03:构建标签页面并测试其路由

与我们构建的单篇文章页面类似,我们现在将构建一个标签页面,它与 PostList 组件类似,只是只显示具有特定标签的文章,并且每篇文章都是一个链接到相关单篇文章视图的链接。

要访问此练习的代码文件,请参阅 packt.live/39cJqZd

  1. 我们可以从在 src/components/TagPage.vue 中创建一个新的 TagPage 组件开始。我们知道它将接收 posts 作为属性,并且我们希望渲染一个 PostList 组件:

    <template>
      <div class="flex flex-col md:w-1/2 m-auto">
        <h3
        class="font-semibold text-sm text-center mb-6"
        >
          #INSERT_TAG_NAME
        </h3>
        <PostList :posts="[]" />
      </div>
    </template>
    <script>
    import PostList from './PostList'
    export default {
      components: {
        PostList
      },
      props: {
        posts: {
          type: Array,
          default: () => []
        }
      },
    }
    </script>
    
  2. 接下来,我们想在 src/router.js 中将 TagPage 组件连接到路由器。我们将导入它并将其添加到 routes 中,路径为 /tags/:tagName

    // other imports
    import TagPage from './components/TagPage.vue'
    // Vue router registration
    const routes = [
      // other routes
      {
        path: '/tags/:tagName',
        component: TagPage
      }
    ]
    // router instantiation and export
    
  3. 我们现在可以在计算属性中使用 $route.params.tagName 并创建一个 tagPosts 计算属性,该属性通过标签过滤文章:

    <script>
    // imports
    export default {
      // rest of component
      computed: {
        tagName() {
          return this.$route.params.tagName
        },
        tagPosts() {
          return this.posts.filter(p => p.tags.includes(this.tagName))
        }
      }
    }
    </script>
    
  4. 现在我们有了对 tagPoststagName 的访问权限,我们可以替换模板中的占位符。我们将渲染 #{{ tagName }} 并将 tagPosts 绑定到 PostListposts 属性:

    <template>
      <div class="flex flex-col md:w-1/2 m-auto">
        <h3
          class="font-semibold text-sm text-center mb-6"
        >
          #{{ tagName }}
        </h3>
        <PostList :posts="tagPosts" />
      </div>
    </template>
    

    现在,如果我们导航到例如 /tags/angularjs,页面将显示如下:

    图 12.21:angularjs 的标签页面

    图 12.21:angularjs 的标签页面

  5. 下一步是将PostListItem中的标签锚点(a)转换为指向/tags/${tagName}router-link(在src/components/PostListItem.vue中):

    <template>
      <!-- rest of template -->
          <router-link
            :to="`/tags/${tag}`"
            v-for="tag in tags"
            :key="tag"
            class="flex text-xs font-semibold px-2 py-1 mr-2           rounded border border-blue-500 text-blue-500"
          >
            #{{ tag }}
          </router-link>
      <!-- rest of template -->
    </template>
    
  6. 现在是时候编写一些测试了。我们首先检查在主页上点击#angularjs会将我们带到angularjs标签页。我们将在__tests__/TagPage.test.js中如下编写:

    import {render, fireEvent} from '@testing-library/vue'
    import App from '../src/App.vue'
    import router from '../src/router.js'
    test('Router renders tag page when clicking a tag in the post     list item', async () => {
      const {getByText, queryByText} = render(App, { router })
      expect(queryByText('The Vue.js Workshop Blog')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).    toBeTruthy()
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      await fireEvent.click(getByText('#angularjs'))
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).toBeFalsy()
      expect(queryByText('React')).toBeFalsy()
    })
    
  7. 我们还应该测试直接访问标签 URL 是否按预期工作;也就是说,我们看不到不相关的内容:

    // import & other tests
    test('Router renders tag page when a URL is set', async () => {
      const {queryByText} = render(App, { router })
      await router.push('/')
      await router.replace('/tags/angularjs')
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).    toBeFalsy()
      expect(queryByText('React')).toBeFalsy()
    })
    

    测试通过,因为应用程序按预期工作。因此,输出将如下所示:

    图 12.22:TagPage 路由测试通过命令行

图 12.22:TagPage 路由测试通过命令行

我们已经看到了如何实现和测试一个包含vue-router的应用程序。在下一节中,我们将详细了解 Vuex 的测试。

测试 Vuex

为了展示如何测试依赖于 Vuex(Vue.js 的官方全局状态管理解决方案)的组件,我们将实现并测试新闻通讯订阅横幅。

首先,我们应该创建横幅模板。横幅将包含一个“订阅新闻通讯”的行动呼吁和一个关闭图标:

<template>
  <div class="text-center py-4 md:px-4">
    <div
      class="py-2 px-4 bg-indigo-800 items-center text-indigo-100
      leading-none md:rounded-full flex md:inline-flex"
      role="alert"
    >
      <span
        class="font-semibold ml-2 md:mr-2 text-left flex-auto"
      >
        Subscribe to the newsletter
      </span>
      <svg
        class="fill-current h-6 w-6 text-indigo-500"
        role="button"
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 20 20"
      >
        <title>Close</title>
        <path
          d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651
          3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1\.            2
          1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1
          1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1\.            698z"
        />
      </svg>
    </div>
  </div>
</template>

我们可以在App.vue文件中如下显示NewsletterBanner组件:

<template>
  <!-- rest of template -->
    <NewsletterBanner />
  <!-- rest of template -->
</template>
<script>
import NewsletterBanner from './components/NewsletterBanner.vue'
export default {
  components: {
    NewsletterBanner
  },
  // other component properties
}
</script>

然后,我们将使用npm install --save vuex命令安装 Vuex。一旦安装了 Vuex,我们就可以在store.js文件中初始化我们的存储,如下所示:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {},
  mutations: {}
})

我们的 Vuex 存储也已在main.js文件中注册:

// other imports
import store from './store'
// other configuration
new Vue({
  // other vue options
  store
}).$mount('#app')

为了决定是否显示新闻通讯横幅,我们需要在我们的存储中添加一个初始状态:

// imports and configuration
export default new Vuex.Store({
  state: {
    dismissedSubscribeBanner: false
  }
})

要关闭横幅,我们需要一个突变,该突变将dismissedSubscribeBanner设置为true

// imports and configuration
export default new Vuex.Store({
  // other store configuration
  mutations: {
    dismissSubscribeBanner(state) {
      state.dismissedSubscribeBanner = true
    }
  }
})

现在,我们可以使用存储状态和dismissSubscribeBanner突变来决定是否显示横幅(使用v-if)以及是否关闭它(绑定到close按钮的点击):

<template>
  <div v-if="showBanner" class="text-center py-4 md:px-4">
    <!-- rest of template -->
      <svg
        @click="closeBanner()"
        class="fill-current h-6 w-6 text-indigo-500"
        role="button"
        xmlns=http://www.w3.org/2000/svg
        viewBox="0 0 20 20"
      >
    <!-- rest of the template -->
  </div>
</template>
<script>
export default {
  methods: {
    closeBanner() {
      this.$store.commit('dismissSubscribeBanner')
    }
  },
  computed: {
    showBanner() {
      return !this.$store.state.dismissedSubscribeBanner
    }
  }
}
</script>

在这一点上,横幅在浏览器中的样子如下:

图 12.23:浏览器中显示的新闻通讯横幅

图 12.23:浏览器中显示的新闻通讯横幅

要编写单元测试,我们将使用 Vue 测试库,它提供了一个注入 Vuex 存储的功能。我们需要导入存储和NewsletterBanner组件。

我们可以先进行一个合理性检查,即默认情况下,新闻通讯横幅是显示的:

import {render, fireEvent} from '@testing-library/vue'
import NewsletterBanner from '../src/components/  NewsletterBanner.vue'
import store from '../src/store'
test('Newsletter Banner should display if store is initialised   with it not dismissed', () => {
  const {queryByText} = render(NewsletterBanner, { store })
  expect(queryByText('Subscribe to the newsletter')).toBeTruthy()
})

下一个检查应该是,如果存储有dismissedSubscribeBanner: true,则横幅不应显示:

// imports and other tests
test('Newsletter Banner should not display if store is initialised with   it dismissed', () => {
  const {queryByText} = render(NewsletterBanner, { store: {
    state: {
      dismissedSubscribeBanner: true
    }
  } })
  expect(queryByText('Subscribe to the newsletter')).toBeFalsy()
})

我们将要进行的最后一个测试是确保点击横幅的关闭按钮会将突变提交到存储中。我们可以通过将存根作为dismissSubscribeBanner突变注入,并检查在点击关闭按钮时是否被调用来实现这一点:

// imports and other tests
test('Newsletter Banner should hide on "close" button click',   async () => {
  const dismissSubscribeBanner = jest.fn()
  const {getByText} = render(NewsletterBanner, {
    store: {
      ...store,
      mutations: {
        dismissSubscribeBanner
      }
    }
  })
  await fireEvent.click(getByText('Close'))
  expect(dismissSubscribeBanner).toHaveBeenCalledTimes(1)
})

当使用npm run test:unit __tests__/NewsletterBanner.test.js运行时,测试将通过,如下所示:

图 12.24:新闻通讯横幅单元测试通过命令行

图 12.24:通过命令行执行的 NewsletterBanner 单元测试

我们已经看到了如何使用 Vue.js 测试库来测试由 Vuex 驱动的应用程序功能。

我们现在将探讨如何使用 Vuex 实现 cookie 免责声明横幅,以及如何使用 Vue.js 测试库进行测试。

我们将在 Vuex 中存储 cookie 横幅是否显示(默认为true);当横幅关闭时,我们将将其存储在 Vuex 中。

使用模拟 Vuex 存储来测试此打开/关闭操作。要访问此练习的代码文件,请参阅packt.live/36UzksP

  1. 创建一个带有加粗标题Cookies Disclaimer、免责声明和I agree按钮的绿色 cookie 横幅。我们将在src/components/CookieBanner.vue中创建此组件:

    <template>
      <div
        class="flex flex-row bg-green-100 border text-center       border-green-400
        text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative"
        role="alert"
      >
        <div class="flex flex-col">
          <strong class="font-bold w-full flex">Cookies Disclaimer
          </strong>
          <span class="block sm:inline">We use cookies to improve your experience</span>
        </div>
        <button
          class="ml-auto align-center bg-transparent         hover:bg-green-500
          text-green-700 font-semibold font-sm hover:text-white         py-2 px-4 border
          border-green-500 hover:border-transparent rounded"
        >
          I agree
        </button>
      </div>
    </template>
    
  2. 接下来,我们将在src/App.vue中导入、注册并渲染CookieBanner组件到router-view下方:

    <template>
      <!-- rest of template -->
          <CookieBanner />
      <!-- rest of template -->
    </template>
    <script>
    // other imports
    import CookieBanner from './components/CookieBanner.vue'
    export default {
      components: {
        // other components
        CookieBanner
      },
      // other component properties
    }
    </script>
    
  3. 添加一个state切片来控制是否显示 cookie 横幅。在我们的 Vuex 存储中,我们将初始化此acceptedCookie字段为false

    // imports and configuration
    export default new Vuex.Store({
      state: {
        // other state fields
        acceptedCookie: false
      },
      // rest of vuex configuration
    })
    
  4. 我们还需要一个acceptCookie突变来关闭横幅:

    // imports and configuration
    export default new Vuex.Store({
      // rest of vuex configuration
      mutations: {
        // other mutations
        acceptCookie(state) {
          state.acceptedCookie = true
        }
      }
    })
    
  5. 接下来,我们将暴露存储状态作为acceptedCookie计算属性。我们将创建一个acceptCookie函数,该函数触发acceptCookie突变:

    export default {
      methods: {
        acceptCookie() {
          this.$store.commit('acceptCookie')
        }
      },
      computed: {
        acceptedCookie() {
          return this.$store.state.acceptedCookie
        }
      }
    }
    </script>
    
  6. 我们将使用v-if在尚未接受 cookie 时显示横幅。当点击I agree按钮时,通过切换acceptCookie来关闭横幅:

    <template>
      <div
        v-if="!acceptedCookie"
        class="flex flex-row bg-green-100 border text-center       border-green-400
        text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative"
        role="alert"
      >
        <!-- rest of template -->
        <button
          @click="acceptCookie()"
          class="ml-auto align-center bg-transparent         hover:bg-green-500
          text-green-700 font-semibold font-sm hover:text-white         py-2 px-4 border
          border-green-500 hover:border-transparent rounded"
        >
          I agree
        </button>
      </div>
    </template>
    

    现在我们已经得到了一个 cookie 横幅,直到点击I agree才会显示,如下面的截图所示:

    ![图 12.25:浏览器中显示的 cookie 横幅]

    ![img/B15218_12_25.jpg]

    图 12.25:浏览器中显示的 cookie 横幅

  7. 现在,我们将编写一个测试来检查CookieBanner组件是否默认显示:

    import {render, fireEvent} from '@testing-library/vue'
    import CookieBanner from '../src/components/CookieBanner.vue'
    import store from '../src/store'
    test('Cookie Banner should display if store is initialised with   it not dismissed', () => {
      const {queryByText} = render(CookieBanner, { store })
      expect(queryByText('Cookies Disclaimer')).toBeTruthy()
    })
    
  8. 我们还将编写一个测试来检查如果存储中的acceptedCookietrue,则 cookie 横幅不会显示:

    test('Cookie Banner should not display if store is initialised   with it dismissed', () => {
      const {queryByText} = render(CookieBanner, { store: {
        state: {
          acceptedCookie: true
        }
      } })
      expect(queryByText('Cookies Disclaimer')).toBeFalsy()
    })
    
  9. 最后,我们希望检查当点击I agree按钮时,会触发acceptCookie突变:

    test('Cookie Banner should hide on "I agree" button click',   async () => {
      const acceptCookie = jest.fn()
      const {getByText} = render(CookieBanner, {
        store: {
          ...store,
          mutations: {
            acceptCookie
          }
        }
      })
      await fireEvent.click(getByText('I agree'))
      expect(acceptCookie).toHaveBeenCalledTimes(1)
    })
    

    当我们使用npm run test:unit __tests__/CookieBanner.test.js运行我们编写的三个测试时,它们都会通过,如下所示:

    ![图 12.26:cookie 横幅测试通过]

    ![img/B15218_12_26.jpg]

图 12.26:cookie 横幅测试通过

我们已经看到了如何测试依赖于 Vuex 进行状态和更新的组件。

接下来,我们将探讨快照测试,看看它是如何简化渲染输出的测试的。

快照测试

快照测试提供了一种为快速变化的代码片段编写测试的方法,而不需要将断言数据内联到测试中。它们存储快照。

快照的更改反映了输出的更改,这对于代码审查非常有用。

例如,我们可以在PostList.test.js文件中添加一个快照测试:

// imports and tests
test('Post List renders correctly', () => {
  const wrapper = mount(PostList, {
    propsData: {
      posts: [
        {
          title: 'Title 1',
          description: 'Description 1',
          tags: ['react', 'vue']
        },
        {
          title: 'Title 2',
          description: 'Description 2',
          tags: ['html', 'angularjs']
        }
      ]
    }
  })
  expect(wrapper.text()).toMatchSnapshot()
})

当我们再次运行此测试文件时,使用npm run test:unit __tests__/PostList.test.js,我们将得到以下输出:

![图 12.27:第一次运行快照测试]

图片

图 12.27:第一次运行快照测试

快照已写入__tests__/__snapshots__/PostList.test.js.snap,如下所示:

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Post List renders correctly 1`] = `
"Title 1 Description 1 
      #react

      #vue
    Title 2 Description 2 
      #html

      #angularjs"
`;

这使得我们可以快速看到这些更改在具体输出方面的含义。

我们现在已经看到了如何使用快照测试。接下来,我们将把本章学到的所有工具结合起来,添加一个新页面。

活动十二.01:通过测试添加一个简单的按标题搜索页面

我们已经构建了一个帖子列表页面、单个帖子视图页面和按标签分类的帖子页面。

在博客上重新展示旧内容的一个好方法是通过实现良好的搜索功能。我们将向PostList页面添加搜索功能:

  1. 在新文件src/components/SearchForm.vue中创建一个带有输入和按钮的搜索表单。

  2. 现在,我们将通过导入、注册并在src/App.vue中渲染来使表单显示。

    现在,我们可以在应用程序中看到搜索表单,如下所示:

    ![图 12.28:带有搜索表单的帖子列表视图]

    图片

    图 12.28:带有搜索表单的帖子列表视图

  3. 我们现在准备好为搜索表单添加一个快照测试。在__tests__/SearchForm.test.js中,我们应该添加SearchForm should match expected HTML

  4. 我们希望使用v-model跟踪搜索表单输入的内容,以双向绑定searchTerm实例变量和输入内容。

  5. 当提交搜索表单时,我们需要更新 URL 以包含正确的参数。这可以通过this.$router.push()来完成。我们将把搜索存储在q查询参数中。

  6. 我们希望将q查询参数的状态反映在搜索表单输入中。我们可以通过从this.$route.query中读取q并将其设置为SearchForm组件状态中searchTerm数据字段的初始值来实现这一点。

  7. 接下来,我们希望过滤主页上传递给PostList的帖子。我们将使用this.$route.query.q在一个计算属性中过滤帖子标题。这个新的计算属性将替代src/App.vue中的posts

  8. 接下来,我们应该添加一个测试,更改搜索查询参数,并检查应用程序是否显示正确的结果。为此,我们可以导入src/App.vuesrc/store.jssrc/router.js,并使用存储和路由渲染应用程序。然后,我们可以通过使用字段的占位符为Search来更新搜索字段的内容。最后,我们可以通过点击test idSearch(即搜索按钮)的元素来提交表单。

    注意

    这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,我们探讨了测试不同类型 Vue.js 应用程序的不同方法。

通常来说,测试对于从经验上证明系统正在正常工作是有用的。单元测试是最容易构建和维护的,应该是测试功能的基础。系统测试是测试金字塔的下一层级,它使你能够对大多数功能按预期工作充满信心。端到端测试表明整个系统的主流程正在正常工作。

我们已经看到了如何对组件、过滤器、组件方法和混入进行单元测试,以及如何通过层进行测试,以及以黑盒方式测试组件输出而不是检查组件内部以测试功能。使用 Vue.js 测试库,我们已经测试了利用 Vuex 的高级功能,如路由和应用程序。

最后,我们探讨了快照测试,并看到了它如何成为为模板密集型代码块编写测试的有效方式。

在下一章中,我们将探讨可以应用于 Vue.js 应用的端到端测试技术。

第十三章:13. 端到端测试

概述

在本章中,我们将探讨如何使用 Cypress 为 Vue.js 应用程序创建一个端到端(E2E)测试套件。为了编写健壮的测试,我们将探讨常见的陷阱和最佳实践,例如拦截 HTTP 请求和等待元素出现而不设置超时。

随着我们继续前进,你将了解端到端测试及其用例。你将看到如何配置 Cypress 以测试 Vue.js 应用程序,并使用它与之交互和检查用户界面。在整个章节中,你将熟悉任意超时的陷阱以及如何使用 Cypress 的等待功能来避免它们。在章节的末尾,你还将学习何时、为什么以及如何使用 Cypress 拦截 HTTP 请求。

简介

在本章中,我们将为高度异步的应用程序编写端到端测试。

在前面的章节中,我们看到了如何构建复杂的 Vue.js 应用程序以及如何为它们编写单元测试。本章将介绍如何使用 Cypress 为高度交互式且使用 HTTP API 的 Vue.js 应用程序编写端到端测试。我们将看到端到端测试如何通过自动化用户流程,为你提供应用程序按设计工作的信心水平。

理解端到端测试及其用例

大多数开发者都曾见过以下图中展示的测试金字塔版本:

图 13.1:测试金字塔图解

图 13.1:测试金字塔图解

端到端测试属于用户界面UI)测试类别。在本章中,我们将探讨的是使用 Cypress 进行的自动化端到端测试。

E2E 和 UI 测试提供的信心水平高于单元或集成测试。它们测试的是终端用户使用的应用程序。终端用户不关心错误发生的原因或地点,只关心存在错误。错误的原因和地点往往是单元和系统级测试关注的焦点。单元和系统级测试检查系统的内部是否按照规范或代码描述的方式工作。UI 级测试验证应用程序流程是否按预期工作。

一个运行速度快、假阴性(测试失败但应用程序工作)少、假阳性(所有测试通过但应用程序损坏)更少的强大端到端测试套件,可以启用持续部署CD)。持续部署,正如其名所暗示的,涉及持续部署项目或应用程序。在这种设置中,应用程序版本由端到端套件验证,然后自动部署到生产环境。

为 Vue.js 应用程序配置 Cypress

Cypress 是一个 JavaScript E2E 测试框架。它旨在解决使用 JavaScript 编写 E2E 测试的非常具体的需求。这与其他完整的 浏览器自动化 解决方案(如 WebDriverIO (webdriver.io/))、Selenium WebDriver (www.selenium.dev/))、Puppeteer (developers.google.com/web/tools/puppeteer/) 和 Playwright (github.com/microsoft/playwright)形成对比,这些解决方案通常用于编写 E2E 测试。

与其他解决方案相比,Cypress 的一个显著区别是它专注于编写 E2E 测试(而不是通用的浏览器自动化)。测试只能使用 JavaScript 编写(Selenium 支持其他语言),并且直到最近,它只支持 Chrome(Cypress 4.0 现在支持 Firefox 和 Microsoft Edge,请参阅 www.cypress.io/blog/2020/02/06/introducing-firefox-and-edge-support-in-cypress-4-0/)。

Cypress 拥有一个用于本地运行和调试测试的 图形用户界面 (GUI),并附带内置的断言和存根/模拟库。

要使用 Vue CLI 将 Cypress 添加到项目中,我们可以使用 e2e-cypress 插件 (cli.vuejs.org/core-plugins/e2e-cypress.html),其安装说明指导我们在命令行中运行以下命令。作为添加插件的一部分,Cypress 及其支持包将被下载并解压,因此可能需要一段时间才能完成:

vue add @vue/e2e-cypress

插件添加了一个 test:e2e 脚本,我们可以使用以下命令运行它。此命令需要一段时间才能启动,因为它需要运行应用程序的生产构建并启动 Cypress 应用:

npm run test:e2e

最终,我们将看到以下 Cypress GUI:

![图 13.2:插件安装和运行 test:e2e 命令后的 Cypress 图形用户界面 (GUI)]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_13_02.jpg)

图 13.2:插件安装和运行 test:e2e 命令后的 Cypress GUI

插件为我们创建了一个默认的 test.js 文件。默认内容如下。测试会访问应用程序根目录 (/) 并检查页面上的 h1 是否包含 Welcome to Your Vue.js App

// https://docs.cypress.io/api/introduction/api.html
describe('My First Test', () => {
  it('Visits the app root url', () => {
    cy.visit('/')
    cy.contains('h1', 'Welcome to Your Vue.js App')
  })
})

这在空 Vue CLI 项目中是有效的。

我们可以尝试使用 cy.visit(url) 访问 google.com,并通过首先使用 cy.get('input') 选择页面上的输入元素,然后使用 .should('exist') 断言来检查与 Google 主页同义的 input 元素是否存在:

describe('My First Test', () => {
  it('Opens an arbitrary URL', () => {
    cy.visit('https://google.com')
    cy.get('input').should('exist')
  })
})

我们可以通过在 Cypress UI 中单击 test.js 来运行测试(当 npm run test:e2e 正在运行时)如下所示:

![图 13.3:运行 test.js 的 Cypress UI]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_13_03.jpg)

图 13.3:运行 test.js 的 Cypress UI

当 Cypress 运行测试时,会打开一个浏览器窗口来运行它们:

图 13.4:在访问 Google 主页时在 Chrome 中运行的 Cypress 测试

图 13.4:在访问 Google 主页时在 Chrome 中运行的 Cypress 测试

我们现在已经看到了如何安装和使用 Cypress 访问网页。

在下一节中,我们将看到如何使用 Cypress 与 UI 交互和检查。

使用 Cypress 与 Vue.js UI 交互和检查

为了端到端测试具有应用程序名称的 h2),在 App.vue 文件中,我们将有如下代码:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <h2
        class="uppercase leading-loose pb-4 flex justify-center           m-auto md:w-1/3 text-xl mb-8 font-bold text-gray-800           border-b"
      >
        Commentator Pro
      </h2>
   </div>
  </div>
</template>
<script>
export default {}
</script>

为了使用 Cypress 进行测试,我们可以使用以下代码更改 tests/e2e/specs/test.js 文件。我们将使用 cy.visit('/') 访问运行中的应用程序,然后检查页面上的 h2 是否包含 cy.contains('h2', 'Commentator Pro')cy.contains 函数是重载的,可以用一个参数(要匹配的文本)或两个参数(容器的选择器和要匹配的文本)使用:

describe('Commentator Pro', () => {
  it('Has a h2 with "Commentator Pro"', () => {
    cy.visit('/')
    cy.contains('h2', 'Commentator Pro')
  })
})

然后,我们可以使用以下截图所示的 Cypress UI 运行 test.js

图 13.5:在 Chrome 中成功运行的标题内容测试

图 13.5:标题内容测试在 Chrome 中成功运行

现在我们已经看到了如何访问页面并对其内容进行断言,我们将看到如何使用 Cypress 自动化 Vue.js 应用程序中新功能的测试。

练习 13.01:添加“新评论”按钮和相应的端到端测试

为了使“添加新评论”按钮允许用户添加评论。

我们将添加一个带有文本“添加新评论”的蓝色巨型按钮,并使用 Cypress 编写相应的端到端测试。

要访问此练习的代码文件,请参阅 packt.live/36PefjJ

要做到这一点,请执行以下步骤:

  1. 要在应用程序中添加按钮,我们将在 src/App.vue 中添加一个带有一些 TailwindCSS 类的 button 元素:

    <template>
      <div id="app" class="p-10">
        <div class="flex flex-col">
          <!-- rest of template -->
          <button class="flex mx-auto bg-blue-500 hover:bg-blue-700         text-white font-bold py-2 px-4 rounded">
            Add a New Comment
          </button>
        </div>
      </div>
    </template>
    

    输出应如下所示:

    图 13.6:带有“添加新评论”按钮的 Commentator Pro 应用程序

    图 13.6:带有“添加新评论”按钮的 Commentator Pro 应用程序

  2. 接下来,我们将在 tests/e2e/specs/add-new-comment.js 创建一个新的端到端测试。我们将设置测试套件的名称和测试的描述分别为“添加新评论”和“主页应该有一个带有正确文本的按钮”:

    describe('Adding a New Comment', () => {
      it('the homepage should have a button with the right text',     () => {
        // test will go here
      })
    })
    
  3. 为了测试主页,我们必须使用 cy.visit('/') 导航到它:

    describe('Adding a New Comment', () => {
      it('the homepage should have a button with the right text',     () => {
        cy.visit('/')
      })
    })
    
  4. 最后,我们可以编写断言,页面中有一个包含文本“添加新评论”的 button 实例:

    describe('Adding a New Comment', () => {
      it('the homepage should have a button with the right text',     () => {
        cy.visit('/')
        cy.contains('button', 'Add a New Comment')
      })
    })
    
  5. 我们可以使用 Cypress UI 运行此测试(使用 npm run test:e2e 运行):图 13.7:“add-new-comment.js”测试在 Cypress UI 中显示

    图 13.7:“add-new-comment.js”测试在 Cypress UI 中显示

  6. 当我们运行测试时,我们将在 Chrome 中得到以下输出。测试通过,因为主页上有一个带有相关文本的按钮:![图 13.8:Cypress 在 Chrome 中运行我们的 "add-new-comment" 测试 图片 13.8

图 13.8:Cypress 在 Chrome 中运行我们的 "add-new-comment" 测试

我们已经看到了如何访问页面并对其内容进行断言。

在下一节中,我们将探讨如何使用 Cypress 来测试交互行为。Cypress 具有自动选择器重试功能,这使得它非常适合测试高度交互的 Vue.js 应用程序。我们将看到如何使用 Cypress 与 UI 交互并断言我们交互的效果。

使用 Cypress 触发和等待 UI 更新

我们到目前为止编写的测试相当简单,仅检查应用程序在浏览器加载时不会崩溃。

E2E 测试的一个优势是,当用户与之交互时,可以以高保真度测试 UI 的行为是否符合预期。在本节中,我们将使用 Cypress 的选择(.get() 函数)、事件触发(.click() 函数)和断言(.should() 函数)功能来测试 Vue.js 应用程序。Cypress 在 DOM 选择上的自动重试将允许我们编写无需显式等待/超时条件的 E2E 测试。等待和超时是其他 E2E 测试系统的基本要素,并且往往是测试不稳定的原因。

首先,我们将向我们的textarea添加一个评论编辑器,点击添加新评论按钮将切换它。

为了在不处理复杂且脆弱的选择器的情况下继续编写测试,我们将开始添加data-test-id属性;首先,我们可以在App.vue文件中的添加新评论按钮上添加一个:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <!-- rest of template -->
      <button
        class="flex mx-auto bg-blue-500 hover:bg-blue-700           text-white font-bold py-2 px-4 rounded"
        data-test-id="new-comment-button"
      >
        Add a New Comment
      </button>
      <!-- rest of template -->
    </div>
  </div>
</template>

接下来,我们将在App组件的 Vue.js data()方法中添加一个showEditor属性。我们将在编辑器的v-if中使用这个表达式。我们还可以设置新的评论按钮来切换这个实例属性:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <!-- rest of template -->
     <button
        @click="showEditor = !showEditor"
        class="flex mx-auto bg-blue-500 hover:bg-blue-700           text-white font-bold py-2 px-4 rounded"
        data-test-id="new-comment-button"
      >
        Add a New Comment
      </button>
      <!-- rest of template -->
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      showEditor: false
    }
  }
}
</script>

我们可以使用new-comment-editor data-test-id来添加我们的编辑器,该data-test-id可以通过showEditor来切换:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <!-- rest of template -->
      <div v-if="showEditor">
        <textarea
          data-test-id="new-comment-editor"
          class="flex mx-auto my-6 shadow appearance-none             border rounded py-2 px-3 text-gray-700 leading-tight             focus:outline-none focus:shadow-outline"
        >
        </textarea>
      </div>
    </div>
  </div>
</template>

为了测试切换功能,我们可以添加一个测试,打开该应用程序并检查评论编辑器最初不会显示,以及根据在new-comment-button上触发的点击次数来检查它是否显示:

describe('Adding a New Comment', () => {
  // other tests   it('the Add a New Comment button should toggle the editor     display on and off', () => {
    cy.visit('/')
    cy.get('[data-test-id="new-comment-editor"]').should       ('not.be.visible')
    cy.get('[data-test-id="new-comment-button"]').click()
    cy.get('[data-test-id="new-comment-editor"]').should       ('be.visible')
    cy.get('[data-test-id="new-comment-button"]').click()
    cy.get('[data-test-id="new-comment-editor"]').should       ('not.be.visible')
  })
})

上述代码将生成以下结果:

![图 13.9:Cypress 运行 "add-new-comment" 测试,包括新的编辑器切换测试图片 13.9

![图 13.9:Cypress 运行 "add-new-comment" 测试,包括新的编辑器切换测试

我们已经看到了如何编写选择和断言 DOM 元素的 Cypress 测试。

注意

data-test-id实例,作为一种约定,是使测试与应用程序和样式特定的选择器解耦的一种方式。如果编写测试的人不总是编写代码的人,这特别有用。在这种情况下,使用data-test-id允许标记结构和类发生变化,但只要test-id实例保持在正确的元素上,测试就会继续通过。

练习 13.02:添加新评论编辑器输入和提交功能

要能够将新的评论文本发送到 API,我们需要将文本存储在 Vue.js 状态中。添加评论的另一个先决条件是拥有一个虚拟的"提交"按钮。

要访问此练习的代码文件,请参阅packt.live/2HaWanh

添加这些功能和相应的测试,请执行以下步骤:

  1. 要将textarea(编辑器)内容存储在内存中,我们将使用v-model。我们将创建一个新的数据(状态)变量newComment,它被初始化为""。现在,v-model将双向绑定textarea内容和newComment

    <template>
      <div id="app" class="p-10">
            <!-- rest of template -->
            <textarea
              data-test-id="new-comment-editor"
              class="flex mx-auto my-6 shadow appearance-none             border rounded py-2 px-3 text-gray-700 leading-tight             focus:outline-none focus:shadow-outline"
              v-model="newComment"
            >
            </textarea>
            <!-- rest of template -->
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          // other data properties
          newComment: ''
        }
      }
    }
    </script>
    
  2. 我们将在编辑器内部添加一个提交按钮,它应该只在编辑器开启时显示。我们还确保包含一个data-test-id="new-comment-submit"属性,以便稍后用 Cypress 选择它:

    <template>
      <div id="app" class="p-10">
        <!-- rest of template -->
          <div v-if="showEditor">
            <!-- rest of template -->
            <button
              data-test-id="new-comment-submit"
              class="flex mx-auto bg-blue-500 hover:bg-blue-700             text-white font-bold py-2 px-4 rounded"
            >
            Submit
            </button>
            <!-- rest of template -->
          </div>
        <!-- rest of template -->
      </div>
    </template>
    
  3. 现在是时候添加一个端到端测试来测试当我们向其中输入文本时new-comment-editor是否按预期工作。为了实现这一点,我们需要加载应用程序并点击新评论按钮,以便显示编辑器。然后我们可以选择new-comment-editor(通过data-test-id)并使用 Cypress 的.type函数添加一些文本。我们可以将.should('have.value', 'Just saying...')链接起来以验证我们对textarea的交互是否成功:

    describe('Adding a New Comment', () => {
      // other tests
      it('the new comment editor should support text input',     () => {
        cy.visit('/')
        // Get the editor to show
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-editor"]').should       ('be.visible')
        cy.get('[data-test-id="new-comment-editor"]')
          .type('Just saying...')
          .should('have.value', 'Just saying...')
      })
    })
    

    当使用 Cypress UI 运行时,这个添加新评论测试套件应该产生以下结果:

    图 13.10:Cypress 运行"添加新评论"测试,包括    新的编辑器文本输入测试

    图 13.10:Cypress 运行"添加新评论"测试,包括新的编辑器文本输入测试

  4. 最后,我们可以添加一个端到端测试来检查提交按钮默认情况下不会显示,但当我们点击新评论按钮时会出现。我们还可以检查new-comment-submit按钮的文本内容:

    describe('Adding a New Comment', () => {
      // other tests
      it('the new comment editor should have a submit button',     () => {
        cy.visit('/')
        cy.get('[data-test-id="new-comment-submit"]').should       ('not.be.visible')
        // Get the editor to show
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-submit"]').should       ('be.visible')
        cy.contains('[data-test-id="new-comment-submit"]', 'Submit')
      })
    })
    

    当这个测试通过 Cypress UI 运行时,我们看到以下结果:

    图 13.11:Cypress 运行"添加新评论"测试,包括    新的提交按钮测试

    图 13.11:Cypress 运行"添加新评论"测试,包括新的提交按钮测试

  5. 我们还可以添加的一个功能是,在文本编辑器中有文本之前,使 submit 按钮处于禁用状态。为此,我们可以在 new comment submit 按钮上绑定 :disabled!newComment。我们将使用降低的不透明度使按钮看起来被禁用。顺便说一下,我们添加 newCommenttextarea 之间的双向绑定的一大原因是为了启用此类 UI 验证:

    <template>
            <!-- rest of template -->
            <button
              data-test-id="new-comment-submit"
              class="flex mx-auto bg-blue-500 hover:bg-blue-700             text-white font-bold py-2 px-4 rounded"
              :disabled="!newComment"
              :class="{ 'opacity-50 cursor-not-allowed' : !newComment }"
            >
            Submit
            </button>
            <!-- rest of template -->
    </template>
    
  6. 相关测试将检查当文本编辑器内容为空时,new-comment-submit 按钮是否被禁用,使用 Cypress 的 should('be.disabled')should('not.be.disabled') 断言:

    describe('Adding a New Comment', () => {
      // other tests
      it('the new comment submit button should be disabled based     on "new comment" content', () => {
        cy.visit('/')
        // Get the editor to show
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-submit"]').should       ('be.visible')
        cy.get('[data-test-id="new-comment-submit"]')
          .should('be.disabled')
        cy.get('[data-test-id="new-comment-editor"]')
          .type('Just saying...')
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.be.disabled')
      })
    })
    

    当通过 Cypress UI 和 Chrome 自动化运行时,会产生以下输出:

    ![图 13.12:Cypress 运行 "add-new-comment" 测试,包括 新的评论提交按钮禁用测试 图片

图 13.12:Cypress 运行 "add-new-comment" 测试,包括新的评论提交按钮禁用测试

我们已经看到了如何使用 Cypress 来选择、点击和输入文本。我们还看到了检查元素可见性、文本内容、输入值和禁用状态的方法。

熟悉其他自动化框架的任何人都会注意到,在 Cypress 测试中,没有显式的等待或重试。这是因为 Cypress 会自动等待和重试断言和选择。我们编写的大多数测试都没有以这种方式展示这一点,但下一个练习,我们将引入异步性,将会展示这一点。

练习 13.03:向新评论编辑器添加提交状态

为了展示 Cypress 令人印象深刻的自动重试/等待能力,我们将查看为新的评论编辑器添加和测试 submitting 状态。

实际上,我们将对 submit 按钮的点击做出反应,并显示一个持续 2.5s 的加载指示器来模拟对后端 API 的合理慢速 HTTP 请求。这是一个展示 Cypress 自动等待和重试选择能力的示例。这个功能减少了任意等待的需要以及与之相关的易变性。

要访问此练习的代码文件,请参阅packt.live/2UzsYJU

  1. 为了显示加载指示器,我们将 tailwindcss-spinner 包添加到项目中:

    npm install --save-dev tailwindcss-spinner
    # or 
    yarn add -D tailwindcss-spinner
    
  2. 我们需要在 Tailwind 配置文件(tailwind.js)中进行配置。它需要使用 require('tailwindcss-spinner') 导入,添加为插件,并在 theme 中设置相关变量。我们的加载指示器将是 灰色,使用 Tailwind 间距大小 4,边框宽度为 2px,持续时间为 500ms

    module.exports = {
      theme: {
        extend: {},
        spinner: (theme) => ({
          default: {
            color: theme('colors.gray.400'), 
            size: theme('spacing.4'),
            border: theme('borderWidth.2'),
            speed: theme('transitionDuration.500'),
          },
        }),
      },
      variants: {
        spinner: ['responsive'],
      },
      plugins: [require('tailwindcss-spinner')()],
    }
    
  3. 接下来,我们需要在 Vue.js 应用程序的 data() 中添加一个 isSubmitting 状态,这将允许我们切换 submit 按钮的状态。我们将将其初始化为 false,因为我们还没有在用户点击 submit 按钮之前提交任何内容:

    <script>
    export default {
      data() {
        return {
          // other properties
          isSubmitting: false
        }
      }
    }
    </script>
    
  4. 接下来,我们将为 submit 按钮添加一个点击处理程序(作为 methods.submitNewComment)。它将使用 setTimeout 模拟 2.5s 的加载时间:

    <script>
    export default {
      // other component properties
      methods: {
       submitNewComment() {
          this.isSubmitting = true
          setTimeout(() => {
            this.isSubmitting = false
          }, 2500)
        }
      }
    }
    </script>
    
  5. 现在我们已经有一个 fake submit 处理器,我们应该将其绑定到 new-comment-submit 按钮的点击事件上:

    <template>
      <div id="app" class="p-10">
        <div class="flex flex-col">
          <!-- rest of template -->
          <div v-if="showEditor">
            <!-- rest of editor -->
            <button
              data-test-id="new-comment-submit"
              class="flex mx-auto bg-blue-500 hover:bg-blue-700 text-
                white font-bold py-2 px-4 rounded"
              :disabled="!newComment"
              :class="{ 'opacity-50 cursor-not-allowed' : !newComment 
                }"
              @click="submitNewComment()"
            >
            Submit
            </button>
          </div>
        </div>
      </div>
    </template>
    
  6. 现在是我们要对提交按钮做出反应的部分。当 isSubmitting 为真时,我们将显示旋转器。为了做到这一点,我们只需将 spinner 类设置为在 isSubmitting 为真时添加。除此之外,我们还将设置按钮在 isSubmitting 为真时被禁用:

    <template>
      <div id="app" class="p-10">
        <div class="flex flex-col">
          <!-- rest of template -->
          <div v-if="showEditor">
            <!-- rest of editor -->
            <button
              data-test-id="new-comment-submit"
              class="flex mx-auto bg-blue-500 hover:bg-blue-700             text-white font-bold py-2 px-4 rounded"
              :disabled="!newComment || isSubmitting"
              :class="{
                'opacity-50 cursor-not-allowed' : !newComment,
                spinner: isSubmitting
              }"
              @click="submitNewComment()"
            >
            Submit
            </button>
          </div>
        </div>
      </div>
    </template>
    
  7. 最后,我们可以添加一个测试来检查当点击 submit 按钮时加载旋转器是否出现。首先,我们需要设置文本编辑器,以便在点击 add new comment 按钮并设置评论的文本值时,文本编辑器显示并启用。接下来,我们可以点击已启用的 new-comment-submit 按钮并检查它是否被禁用并具有 spinner 类(使用 should() 函数)。之后,我们应该编写另一个断言,按钮不再被禁用且不显示旋转器:

    it('the new comment editor should show a spinner on submit',   () => {
        cy.visit('/')
        // Get the editor to show
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-submit"]').should       ('be.visible')
        cy.get('[data-test-id="new-comment-editor"]')
          .type('Just saying...')
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.be.disabled')
          .click()
          .should('have.class', 'spinner')
          .should('be.disabled')
        // eventually, the spinner should stop showing
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.have.class', 'spinner')
          .should('not.be.disabled')
      })
    

    尽管旋转器显示的时间为 2.5s,但由于 Cypress 的自动重试/等待功能,这个测试仍然通过:

    图 13.13:Cypress 运行 "添加新评论" 测试,包括评论提交加载状态测试

    ]

图 13.13:Cypress 运行 "添加新评论" 测试,包括评论提交加载状态测试

我们已经看到 Cypress 如何通过自动等待/重试来允许我们在应用程序中无缝处理异步性,当断言或选择失败时。

截获 HTTP 请求

如前几节所述,Cypress 被设计为 JavaScript 端到端测试解决方案。这意味着它自带断言、自动等待/重试、运行应用程序的合理默认值以及广泛的模拟功能。

HTTP 请求可能会很慢,并且倾向于给测试引入不稳定的(flaky)行为。所谓的 flaky 指的是间歇性的假阴性,即不是由应用程序问题引起的失败,而是由连接问题(例如,测试运行的服务器和后端主机之间的连接)引起的。

我们还将测试后端系统的实现。当使用持续集成CI)时,这意味着需要在任何需要运行端到端测试的 CI 管道步骤中运行后端系统。

通常,当拦截后端请求并发送模拟响应时,我们也说 HTTP 请求被模拟,以避免测试不稳定(意味着间歇性失败与应用程序更改无关)。

由于请求并没有完全通过堆栈(包括后端 API),这在技术上不再是系统的完整端到端测试。然而,我们可以将其视为前端应用程序的端到端测试,因为整个应用程序由独立的练习组成,并且不是特定于实现的。

为了在 Cypress 中模拟请求,我们需要使用cy.server()cy.route()。Cypress 文档还让我们知道,为了使用 HTTP 拦截功能,我们目前需要一个使用XMLHttpRequest(而不是fetch API)的客户端。

备注

目前正在进行支持 HTTP 级拦截(这意味着fetchXHR等最终都将得到支持)的工作。

我们将使用unfetch库,该库在XMLHttpRequest之上实现了fetch接口。我们可以使用以下命令安装它:

npm install --save-dev unfetch
# or
yarn add -D unfetch

然后,我们可以按照以下方式将其导入到src/App.vue中。

<script>
import fetch from 'unfetch'
// rest of component
</script>

为了展示 HTTP 拦截,我们将从JSONPlaceholder获取评论列表并将它们存储在comments响应式实例变量下。我们可以使用fetch(我们导入unfetch时使用的名称)在mounted()生命周期事件中这样做,如下所示:

<script>
// imports
export default {
  data() {
    return {
      // other data properties
      comments: []
    }
  },
  mounted() {
    fetch('https://jsonplaceholder.typicode.com/comments')
      .then(res => res.json())
      .then(comments => {
        this.comments = comments
      })
  }
  // other component properties
}
</script>

一个示例评论包括 ID、正文和电子邮件等属性。

这意味着我们可以通过创建一个ul容器来渲染评论,该容器仅在存在评论时显示(comments.length > 0)。在ul容器内部,我们可以使用v-for渲染一个具有卡片布局的li元素列表。每个卡片将渲染评论的正文和作者的电子邮件,并在mailto:链接内显示。

注意我们如何为列表容器和列表项分别设置comments-listcomment-carddata-test-ids

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <!-- rest of template -->
      <ul
        v-if="comments.length > 0"
        class="flex flex-col items-center my-4 mx-auto           md:w-2/3 lg:w-1/2"
        data-test-id="comments-list"
      >
        <li
          class="flex flex-col px-6 py-4 rounded overflow-hidden             shadow-lg mb-6"
          v-for="(comment, index) in comments"
          :key="comment.id + index"
          data-test-id="comment-card"
        >
          <p class="flex text-gray-700 text-lg mb-4">            {{ comment.body }}</p>
          <p class="flex text-gray-600 font-semibold text-sm">
            <a :href="'mailto:' + comment.email">              {{ comment.email }}</a>
          </p>
        </li>
      </ul>
    </div>
  </div>
</template>

如果我们不使用 HTTP 拦截来测试,我们必须保持断言相当通用。例如,我们可以在新的端到端测试文件中检查comments-list是否可见,以及是否有(大于 0)个comment-card实例:

describe('Loading Existing Comments', () => {
  it('should load & display comments', () => {
    cy.visit('/')
    cy.get('[data-test-id="comments-list"]')
      .should('be.visible')
    cy.get('[data-test-id="comment-card"]')
      .should('have.length.gt', 0)
  })
})

使用 Cypress GUI 运行的以下测试通过,但测试相当通用。我们无法对特定评论数量或其内容做出任何断言:

图 13.14:Cypress 运行“load-comments”测试,包括通用的加载和显示测试

图 13.14:Cypress 运行“load-comments”测试,包括通用的加载和显示测试

为了拦截请求,我们必须使用cy.server()初始化 Cypress 模拟服务器。然后我们可以使用cy.route()拦截特定的请求,从而产生以下新的测试。当cy.route与两个参数一起使用时,它接受 URL 后跟存根响应,在我们的例子中是一个评论数组。我们将在存根中使用一个虚构的电子邮件地址:

describe('Loading Existing Comments', () => {
  // other tests
  it('should load and display comments correctly', () => {
    cy.server()
    cy.route('**/comments', [
      {
        body: 'Vue is getting great adoption',
        email: 'evan@vuejs.org',
        id: 100,
      },
      {
        body: 'Just saying...',
        email: 'evan@vuejs.org',
        id: 10
      },
      {
        body: 'The JS ecosystem is great',
        email: 'evan@vuejs.org',
        id: 1
      }
    ]).as('getComments')
  })
})

一旦我们设置了存根路由,我们就可以访问页面并使用cy.wait('@getComments')等待评论获取完成,因为我们之前已经使用.as('getComments')将评论获取路由的别名设置为getComments

describe('Loading Existing Comments', () => {
  // other tests
  it('should load and display comments correctly', () => {
    // test setup
    cy.visit('/')
    cy.wait('@getComments')
  })
})

然后,我们首先可以断言comments-list是可见的,然后对comment-card卡片数量进行断言:

describe('Loading Existing Comments', () => {
  // other tests
  it('should load and display comments correctly', () => {
    // test setup
    cy.get('[data-test-id="comments-list"]')
      .should('be.visible')
    cy.get('[data-test-id="comment-card"]')
      .should('have.length', 3)
  })
})

我们还可以使用.contains()函数对卡片的特定内容进行断言:

describe('Loading Existing Comments', () => {
  // other tests
  it('should load and display comments correctly', () => {
    // test setup
    cy.contains('[data-test-id="comment-card"]', 'Vue is       getting great adoption')
      .contains('evan@vuejs.org')
    cy.contains('[data-test-id="comment-card"]', 'Just saying...')
      .contains('evan@vuejs.org')
    cy.contains('[data-test-id="comment-card"]', 'The JS       ecosystem is great')
      .contains('evan@vuejs.org')
  })
})

然后,我们可以使用 Cypress GUI 运行测试套件并看到它通过:

![图 13.15:Cypress 运行“load-comments”测试,包括我们的模拟评论测试]

图片

图 13.15:Cypress 运行“load-comments”测试,包括我们的模拟评论测试

我们现在已经看到了如何以及为什么我们可能会使用 Cypress 来模拟 HTTP 请求。

练习 13.04:提交时将评论 POST 到 API

当前new comment提交按钮仅设置几秒钟的加载状态然后重置 - 实际上评论并没有被发送到任何地方。

让我们使用JSONPlaceholder API 作为发送我们新评论的地方。

当向 API 的 POST 请求成功时,我们将评论添加到评论列表的顶部。

要访问此练习的代码文件,请参阅packt.live/2IIWY3g

为了完成练习,我们将执行以下步骤:

  1. 首先,让submitNewComment方法实际上使用fetch(实际上是unfetch)向c发送数据:

    <script>
    // imports
    export default {
      // other component properties
      methods: {
        submitNewComment() {
          this.isSubmitting = true
          fetch('https://jsonplaceholder.typicode.com/comments', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({
              email: this.email,
              body: this.newComment
            })
          })
        }
      }
    }
    </script>
    

    不幸的是,fetch()调用本身不会更新数据或退出加载状态。为了做到这一点,我们需要链式调用一些.then()函数来处理响应,以及一个.catch函数来处理错误。在成功(.then)的情况下,我们应该获取请求的 JSON 输出并将其添加到comments数组的副本的前面。我们还应该重置isSubmittingnewCommentshowEditor。在错误(.catch)的情况下,我们只需将加载状态isSubmitting重置为 false;我们不会清除编辑器或关闭它,因为用户可能想要再次尝试提交:

    <script>
    // imports
    export default {
      // other component properties
      methods: {
        submitNewComment() {
          this.isSubmitting = true
          fetch(
            // fetch params
          ).then(res => res.json())
          .then(data => {
            this.comments = [
              data,
              ...this.comments,
            ]
            this.isSubmitting = false
            this.newComment = ''
            this.showEditor = false
          }).catch(() => {
            this.isSubmitting = false
          })
        }
      }
    }
    </script>
    

    我们现在应该将新的测试添加到tests/e2e/specs/add-new-comment.js测试套件中。

  2. 首先,为了成为好的JSONPlaceholder用户,我们将模拟add-new-comment套件中所有对/commentsGET请求。为了实现这一点,我们将使用beforeEach钩子来启动 Cypress 模拟服务器(cy.server())并模拟匹配**/comments通配符的任何 URL 的GET请求,使用[]作为响应(cy.route('GET', '**/comments', [])):

    describe('Adding a New Comment', () => {
      beforeEach(() => {
        cy.server()
        // GET comments is not the concern of this test suite
        cy.route('GET', '**/comments', [])
      })
      // tests
    
  3. 然后,我们可以继续更新the new comment editor should show a spinner on submit测试,因为我们不再使用setTimeout,而是使用 HTTP 请求。首先,我们需要模拟/comments POST 请求,我们将使用cy.route的配置对象语法来引入 HTTP 请求中的延迟,以便它不会立即响应。我们使用.as('newComment')来别名此请求:

    describe('Adding a New Comment', () => {
      // setup & tests
      it('the new comment editor should show a spinner on submit',     () => {
        cy.route({
          method: 'POST',
          url: '**/comments',
          delay: 1500,
          response: {}
        }).as('newComment')
        // rest of the test
      })
    })
    
  4. 而不是// 最终,旋转器应该停止显示,我们现在可以使用cy.wait()等待newComment HTTP 请求完成,然后再检查旋转器是否消失:

    describe('Adding a New Comment', () => {
      // setup & tests
      it('the new comment editor should show a spinner on submit',     () => {
        // test setup
        // click the "submit" button
        // check the spinner appears
        cy.wait('@newComment')
        // check that the spinner is gone
      })
    })
    
  5. 我们添加了新的功能,在 submit 操作成功完成后关闭编辑器,因此我们应该添加相关的测试。我们将使用与更新后的 loading state 测试类似的骨架,设置 POST 评论路由的模拟 cy.route('POST', '**/comments', {}', 别名为 .as('newComment'))。然后我们可以获取新的评论编辑器以显示,添加一些文本,并提交表单。然后我们将等待 POST` 请求完成,然后再检查编辑器和提交按钮是否不再可见:

    describe('Adding a New Comment', () => {
      // other tests
      it('adding a new comment should close the editor', () => {
        cy.route(
          'POST',
          '**/comments',
          { body: 'Just saying...', email: 'hi@vuejs.org' }
        ).as('newComment')
        cy.visit('/')
        // Get the editor to show
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-submit"]').should       ('be.visible')
        cy.get('[data-test-id="new-comment-editor"]')
          .type('Just saying...')
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.be.disabled')
          .click()
        cy.wait('@newComment')
        cy.get('[data-test-id="new-comment-editor"]').should       ('not.be.visible')
        cy.get('[data-test-id="new-comment-submit"]').should       ('not.be.visible')
      })
    })
    

    现在可以使用 Cypress GUI 运行此测试,并且会通过:

    ![图 13.16:Cypress 运行 "add-new-comment" 测试,包括 提交时编辑器关闭的测试 图片

    图 13.16:Cypress 运行 "add-new-comment" 测试,包括编辑器关闭的测试

  6. 我们添加的第二项功能是在 HTTP 请求完成后,将新的用例添加到评论列表的前面。为了测试这一点,最好将评论的 GET 请求的响应改为至少包含一个元素(这样我们就可以检查新评论是否被添加到列表的顶部):

    describe('Adding a New Comment', () => {
      // setup & other tests
      it('submitting a new comment should POST to /comments and     adds response to top of comments list', () => {
        cy.route('GET', '**/comments', [
          {
            email: 'evan@vuejs.org',
            body: 'Existing comment'
          }
        ])
      })
    })
    
  7. 然后,我们可以使用一些模拟数据模拟 POST 请求,向编辑器添加文本,并提交表单:

    describe('Adding a New Comment', () => {
      // setup & other tests
      it('submitting a new comment should POST to /comments and     adds response to top of comments list', () => {
        // GET request stubbing
        cy.route({
          method: 'POST',
          url: '**/comments',
          response: {
            email: 'evan@vuejs.org',
            body: 'Just saying...',
          },
        }).as('newComment')
        cy.visit('/')
        cy.get('[data-test-id="comment-card"]').should       ('have.length', 1)
        cy.get('[data-test-id="new-comment-button"]').click()
        cy.get('[data-test-id="new-comment-editor"]')
          .type('Just saying...')
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.be.disabled')
          .click()
        cy.wait('@newComment')
      })
    })
    
  8. 最后,我们可以通过组合使用 cy.get().first().contains() 来断言第一个评论是新添加的评论:

    describe('Adding a New Comment', () => {
      // setup & other tests
      it('submitting a new comment should POST to /comments and     adds response to top of comments list', () => {
        // setup & wait for POST completion
        cy.get('[data-test-id="comments-list"]').should('be.visible')
        cy.get('[data-test-id="comment-card"]')
          .should('have.length', 2)
          .first()
          .contains('[data-test-id="comment-card"]', 'Just saying...')
          .contains('evan@vuejs.org')
      })
    })
    

    当使用 Cypress GUI 运行 add-new-comment 测试套件时,我们可以看到新的测试通过:

    ![图 13.17:Cypress 运行 "add-new-comment" 测试,包括 新评论添加到列表顶部的测试 图片

图 13.17:Cypress 运行 "add-new-comment" 测试,包括新评论添加到列表顶部的测试

我们已经看到了如何使用 Cypress 来拦截 HTTP 请求,所以在下一节中,我们将探讨 Cypress 提供的一些关于视觉回归(快照)测试的报表工具。

使用 Cypress 快照进行视觉回归测试

我们到目前为止使用 Cypress 编写的测试类型主要是功能测试。

功能测试检查应用程序是否按预期 行为。视觉测试检查应用程序是否按预期 外观

有方法可以检查渲染的 CSS,但这种方法通常相当繁琐,并且在标记或样式重构时容易出错(即,相同的视觉输出,但有不同的规则或标记)。

幸运的是,Cypress 通过 cypress-plugin-snapshots 插件允许我们获取并比较应用程序的快照。首先,需要使用以下命令进行安装:

npm install --save-dev cypress-plugin-snapshots
# or 
yarn add -D cypress-plugin-snapshots

插件在 Cypress 选择上添加了 .toMatchImageSnapshot 方法。为了使其工作,我们需要在插件的初始化文件中注册它。

我们应该导入 cypress-plugin-snapshots/plugin 并使用插件初始化数据运行其 initPlugin 导出:

const { initPlugin } = require('cypress-plugin-snapshots/plugin');
module.exports = (on, config) => {
  initPlugin(on, config);
  // rest of plugin config, including return
}

我们还需要从cypress-plugin-snapshots/commands注册相关命令,这可以在commands.js文件中完成:

import 'cypress-plugin-snapshots/commands'

我们还需要在cypress.json中为cypress-plugin-snapshots添加一些配置:

{
  "//": "other config",
  "env": {
    "cypress-plugin-snapshots": {
      "autoCleanUp": false,
      "autopassNewSnapshots": true,
      "diffLines": 3,
      "imageConfig": {
        "createDiffImage": true,
        "resizeDevicePixelRatio": true,
        "threshold": 0.01,
        "thresholdType": "percent"
      },
      "screenshotConfig": {
        "blackout": [],
        "capture": "fullPage",
        "clip": null,
        "disableTimersAndAnimations": true,
        "log": false,
        "scale": false,
        "timeout": 30000
      },
      "backgroundBlend": "difference"
    }
  }
}

最后,我们可以在test.js文件中添加一个快照测试。

首先,我们将清理文件并模拟/comments API 调用(这对于快照测试将特别有用):

describe('Commentator Pro', () => {
  beforeEach(() => {
    cy.server()
    cy.route('GET', '**/comments', [
      {
        body: 'Just saying...',
        email: 'evant@vuejs.org'
      }
    ]).as('getComments')
  })
  // tests
})

接下来,我们可以添加视觉回归测试。我们将打开编辑器以最大化单个快照测试,使用#app获取整个app,并对其进行快照:

describe('Commentator Pro', () => {
  // setup & other tests
  it('visual regression', () => {
    cy.visit('/')
    cy.get('[data-test-id="new-comment-button"]')
      .click()
    cy.wait('@getComments')
    cy.get('[data-test-id="new-comment-editor"]')
      .should('be.visible')
    cy.get('#app').toMatchImageSnapshot({
      threshold: 0.001,
    })
  })
})

当使用 Cypress UI 运行测试时,我们看到以下输出:

图 13.18:Cypress 运行测试,包括视觉回归测试

图 13.18:Cypress 运行测试,包括视觉回归测试

我们可以通过在App.vue文件中将按钮颜色改为红色(从bg-blue-500变为bg-red-500)来显示失败的快照:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <!-- rest of template -->
      <button
        @click="showEditor = !showEditor"
        class="flex mx-auto bg-red-500 hover:bg-blue-700           text-white font-bold py-2 px-4 rounded"
        data-test-id="new-comment-button"
      >
        Add a New Comment
      </button>
      <!-- rest of template -->
    </div>
  </div>
</template>

当我们运行相关测试集时,它们现在失败了(因为我们可以看到,按钮是红色而不是蓝色):

图 13.19:Cypress 运行测试,包含失败的视觉回归测试

图 13.19:Cypress 运行测试,包含失败的视觉回归测试

通过点击比较快照,我们得到一个视觉差异视图,这允许我们更新快照:

图 13.20:Cypress 的失败的视觉回归测试差异视图

图 13.20:Cypress 的失败的视觉回归测试差异视图

我们现在已经看到了如何使用 Cypress 进行视觉回归测试。

我们现在将查看添加新功能及其所有相关测试。

活动 13.01:添加设置用户电子邮件和测试的能力

你会记得我们将evan@vuejs.org硬编码为任何评论的电子邮件。在这个活动中,我们将添加一个电子邮件输入,它将设置评论上的email属性。我们将在新的tests/e2e/specs/enter-email.js测试套件中添加相关测试:

  1. 为了跟踪电子邮件,我们将它在data()中设置为一个响应式状态,并在页面上添加一个电子邮件类型输入,它将使用v-model双向绑定到email。我们还添加了一个标签和相应的标记。注意,我们将在电子邮件输入上设置一个data-test-id属性,设置为email-input

  2. 我们现在将添加一个beforeEach钩子来设置 Cypress 模拟服务器并模拟GET评论(列表)请求。评论列表请求应别名为getComments

  3. 我们将添加第一个测试,检查是否在电子邮件输入中键入工作正确。我们将进入应用,输入电子邮件,并检查我们输入的内容现在是否是输入值。

    当使用 Cypress UI 运行时,我们应该得到以下通过测试:

    图 13.21:Cypress 运行"enter-email"测试,包含电子邮件输入测试

    图 13.21:Cypress 运行"enter-email"测试,包含电子邮件输入测试

  4. 拥有 email 属性是添加评论的先决条件,因此当 email 为空时(!email),我们将禁用 添加新评论 按钮。我们将绑定到 disabled 属性,并根据 email 字段是否已填充来切换一些类。

  5. 使用这个新的 当 email 为空时禁用添加新评论按钮 功能,我们应该添加一个新的端到端测试。我们将加载页面,并在初始加载时检查电子邮件输入是否为空,以及 添加新评论 按钮是否被禁用。然后我们将在电子邮件输入字段中输入电子邮件,并检查 添加新评论 按钮现在是否 禁用,这意味着它已被启用。

    当使用 Cypress UI 运行时,我们应该看到新的测试通过,输出如下:

    ![图 13.22:Cypress 运行 "enter-email" 测试,禁用添加评论按钮测试]

    添加评论按钮测试

    图片 13.22

    图 13.22:Cypress 运行 "enter-email" 测试,禁用添加评论按钮测试

  6. 现在我们有了捕获电子邮件的方法,我们应该在调用 POST 评论时将其传递给后端 API(即提交新评论时)。为了做到这一点,我们应该修改 methods.submitNewComment 中将 email 固定为 evan@vuejs.org 的位置。

  7. 现在我们正在使用用户输入的电子邮件,我们应该编写一个端到端测试来检查它是否被发送。我们将模拟 POST 请求,将其别名为 newComment,并返回一个任意值。然后我们可以访问页面,填写电子邮件输入,打开评论编辑器,填写内容,并提交。然后我们将等待 newComment 请求,并断言请求体中的内容和电子邮件与我们完成它们时相同。

    注意

    我们也可以选择不模拟 POST 请求,而是检查新插入页面上的评论卡片是否包含正确的电子邮件和内容。

    当使用 Cypress UI 运行时,我们得到以下测试运行输出:

    ![图 13.23:Cypress 运行 "enter-email" 测试,电子邮件输入测试]

    图片 13.22

图 13.23:Cypress 运行 "enter-email" 测试,电子邮件输入测试

注意

此活动的解决方案可以通过此链接找到。

摘要

在本章中,我们探讨了如何利用 Cypress 从端到端测试 Vue.js 应用程序。

端到端测试通常非常有用,可以给我们一个高度信心,即测试的流程将按预期工作,而不是单元或集成测试,后者以更低的开销验证我们的代码是否按预期工作。

我们已经展示了如何使用 Cypress 来检查、交互和断言 UI。我们还展示了 Cypress 的默认等待/重试功能在编写健壮测试时的巨大优势。

我们利用 Cypress 的 HTTP 请求拦截库来模拟 HTTP 请求,使测试更加可预测和快速。

最后,我们探讨了如何使用 Cypress 设置视觉回归测试。

在下一章中,我们将探讨如何将 Vue.js 应用程序部署到网页上。

第十四章:14. 将您的代码部署到网络

概述

在本章结束时,您将能够解释 CI/CD 工作流程的好处以及它是如何与发布周期、发布节奏和开发工作流程相结合的。为此,您将能够阐述 Vue.js 开发与生产构建之间的差异以及所做出的权衡。为了测试和部署 Vue.js 应用程序,您将配置 GitLab CI/CD,包括管道、作业和步骤。您将熟悉 Netlify、AWS S3 和 AWS CloudFront,以及它们的关键相似之处和差异。

在本章中,我们将探讨如何将 Vue.js 应用程序部署到万维网,以及如何自动化此部署过程以轻松和有信心地频繁发布。

简介

在本章中,我们将探讨如何利用 CI/CD 工具和技术,以高信心和高频率将应用程序交付到生产环境中。

在前面的章节中,我们看到了如何构建和测试复杂的 Vue.js 应用程序。本章是关于利用所有技术,包括测试和自动化它们,以最小风险和时间开销将软件交付到生产环境中。

CI/CD 作为敏捷软件开发过程的一部分的好处

持续集成CI)是每天多次集成代码的实践。为了支持这一点,需要一个支持单个存储库中多个工作状态(分支)的现代 版本控制系统VCS),如 Git,以便允许开发者独立工作代码,同时仍然允许他们安全地协作和集成他们的更改。

为了增强版本控制系统(VCS)、存储库(如 GitLab 或 GitHub)周围的托管和协作工具的能力,已经创建了这些工具,并允许开发者通过网页 用户界面UI)更有效地查看和管理代码更改。

作为这些托管平台及其提供的协作工具的一部分,或作为补充,自动检查对于在集成前后保持代码质量的高信心至关重要。采用 CI 方法通常意味着包括额外的代码质量步骤,例如单元或集成测试、覆盖率检查,以及每次任何新代码集成到主线分支(集成更改的分支)时构建工件。

团队遵循的用于代码协作和 CI 的 Git 规范被称为 Git 工作流程,通常简称为 Git flow

Git flow 将决定分支命名规范,以及如何以及何时集成更改。例如,一个团队可能会决定分支应以工单编号开头,后跟一个简短的短划线小写描述,例如 WRK-2334-fix-ie-11-scroll

作为 Git flow 的一部分,决定并遵守的其他约定示例包括提交消息的长度和标题、应该通过或允许失败的自动检查,以及合并变更请求所需的审阅者数量,在 GitHub 和 GitLab 的术语中,分别称为拉取请求或合并请求。

Git 流程分为两大类:基于主干的开发和(功能)基于分支的开发。我们将首先介绍基于分支的开发,因为其局限性已经变得非常明显,大多数项目倾向于使用基于主干的开发。

在基于分支的 Git 工作流程中,多个工作分支被保存在仓库中。基于分支的流程可以用来保持与环境状态一致的分支。

例如,以下图表显示了三个分支——生产预发布开发生产不包含来自预发布开发的任何变更。预发布领先于生产,但除了生产上的变更外,与开发没有共同变更。开发领先于预发布生产:它在与预发布相同的提交上从生产分支出来,但它与预发布没有共享任何进一步的提交:

图 14.1:基于分支的 Git 提交/分支树示例有三个环境分支

图 14.1:具有三个环境分支的基于分支的 Git 提交/分支树示例

基于分支的工作流程也可以用来跟踪进入发布线的变更。这在项目需要维护应用程序或库的两个版本,但需要对两个版本都应用错误修复或安全补丁的情况下很有用。

在以下示例中,我们有一个与环境分支类似的分支示例。版本 1.0.0 包含一些在 1.0.1 和 1.1.0 中不存在的变化,但不共享任何新的代码。版本 1.0.1 和 1.1.0 同时从 1.0.0 分支出来,但它们没有共享进一步的变更:

图 14.2:基于分支的 Git 提交/分支树示例有三个发布分支

图 14.2:具有三个发布分支的基于分支的 Git 提交/分支树示例

在基于主干分支的 Git 工作流程中,团队中的每个成员都会从一个单一分支创建新的分支,通常是“master”分支。这个过程通常被称为“从分支分支”:

图 14.3:一个基于主干分支的 Git 提交/分支树示例,有两个功能分支从主分支分支出来

图 14.3:一个基于主干分支的 Git 提交/分支树示例,有两个功能分支从主分支分支出来

基于主干分支的工作流程的一个极端情况是只有一个单一的分支供所有人提交。

注意

在基于仓库的环境下,"发布分支"的替代方案是使用 Git 标签来跟踪发布快照。这提供了与维护分支相同的优势,即减少了分支噪音,并且由于标签一旦创建就不能更改,因此具有不可变性的额外好处。

持续交付(CD)是团队能够将每个良好的构建部署到生产环境的能力。

持续交付(CD)的一个先决条件是持续集成(CI),因为持续集成(CI)为构建的质量提供了一些初始的信心。作为持续交付(CD)的一部分,除了持续集成(CI)之外,还需要新的系统、工具和实践。

参考以下图表,了解与持续集成(CI)和持续交付(CD)相关的工具和实践:

图 14.4:持续集成(CI)和持续交付(CD)实践之间的关系

图 14.4:持续集成(CI)和持续交付(CD)实践之间的关系

采用持续交付(CD)所需的额外成分是对应用程序将继续按预期(对于最终用户)工作以及新缺陷没有无意中引入的高度信心。这意味着在能够部署之前,需要在持续集成(CI)检查期间或之后进行额外的端到端测试步骤来验证构建。

这些端到端测试可以手动进行,也可以自动化。在一个理想的持续交付(CD)设置中,后者(自动化端到端测试)是首选的,因为它意味着部署不包括人工交互。如果端到端测试通过,构建可以自动部署。

为了促进持续交付(CD),用于部署软件的系统必须重新思考。作为 CD 的一部分,部署不能是一个冗长的手动过程。这导致公司采用云原生技术,如 Docker,以及基础设施即代码工具,如 HashiCorp 的 Terraform

向持续交付(CD)实践转变的强调导致了 GitOpsChatOps 等想法的诞生。

在 GitOps 和 ChatOps 中,部署和运营任务是由开发者和利益相关者每天交互的工具驱动的。

在 GitOps 中,可以通过 GitHub/GitLab(或另一个 Git 托管提供商)、直接使用 GitHub Actions 或 GitLab CI/CD,或者通过具有紧密集成和报告功能的持续集成/持续交付(CI/CD)软件(如 CircleCI 或 Jenkins)来进行部署。

在 ChatOps 的情况下,使用对话界面来部署和操作软件。某些 ChatOps 的变体可以被认为是 GitOps 的子集,例如,与 deploy <service-name> <environment> 等工具交互,这些工具会将服务部署到相关环境。请注意,聊天界面非常类似于开发者可能习惯的命令行界面,但其他利益相关者可能需要一些时间来适应。

我们现在已经探讨了持续集成(CI)和持续交付(CD)的方法;接下来我们将讨论使用持续集成(CI)和持续交付(CD)的优势:

图 14.5:持续集成(CI)和持续交付(CD)的优势

图 14.5:持续集成(CI)和持续交付(CD)的优势

这两种实践也会对团队的心态和表现产生影响。能够在一天内看到您的更改集成,并在一周内将其部署到生产环境中,这意味着贡献者可以立即看到他们的工作产生了影响。

CI/CD 还有助于推广敏捷原则,其中更改是迭代地应用和部署的。这与项目长期的时间表形成对比,其中估计的不准确会累积并可能导致重大延误。

为生产构建

将应用程序部署到生产环境始于创建一个可以部署的工件。在 Vue.js 的情况下,我们正在构建一个客户端应用程序,这意味着我们的构建工件将包含 HTML、JavaScript 和 CSS 文件。

Vue CLI 内置了一个 build 命令。这个 build 命令将把我们的 Vue.js 单文件组件(.vue 文件)编译成渲染函数(Vue 运行时可以使用这些函数来渲染我们的应用程序),并将它们输出到 JavaScript 中。

作为构建过程的一部分,Vue CLI 将会处理 JavaScript、Vue 单文件组件以及相互导入的模块,并将它们 打包。打包意味着相互依赖的相关代码块将被输出为一个单一的 JavaScript 文件。

由于我们使用了 Vue CLI,Vue.js 库本身也可以被精简。Vue.js 运行时包可以包含一个 运行时编译器,它可以将字符串模板转换为客户端上的渲染函数。由于我们在构建时使用 Vue CLI 编译为渲染函数,因此 Vue.js 的这部分不需要包含在我们的 JavaScript 中。

Vue CLI 的构建步骤还包括一个 死代码消除 步骤。这意味着它可以分析正在生成的代码,如果其中任何部分显然从未使用过——例如,一个如 if (false) { /* do something */ } 这样的语句——那么它将不会出现在构建输出中。

默认情况下,当我们调用 vue service build 时,Vue CLI 会为生产环境构建,这在 Vue CLI 项目中是通过 build 脚本来实现的,可以使用 npm run buildyarn build 来运行。

在一个示例 Vue CLI 项目中,我们将看到类似以下的内容:

![图 14.6:在新的 Vue CLI 项目中 "npm run build" 的输出img/B15218_14_06.jpg

图 14.6:在新的 Vue CLI 项目中 "npm run build" 的输出

dist 文件夹现在可以使用静态托管解决方案(如 Netlify 或 AWS S3 和 CloudFront)进行部署。

我们现在已经看到了如何使用 Vue CLI 和 npm run build 命令来构建用于生产的 Vue.js 应用程序。

接下来,我们将看到如何使用 GitLab CI/CD 来测试我们的代码(在部署之前)。

使用 GitLab CI/CD 测试您的代码

GitLab 有一个内置的 CI/CD 工具,称为 GitLab CI/CD。

为了使用 GitLab CI/CD,您需要一个 GitLab 账户。

要与托管在 GitLab 上的 Git 仓库交互,您还需要将您机器上的 SSH 密钥与您的 GitLab 账户关联。

注意

在 GitLab 文档中添加 SSH 密钥的说明可以在 docs.gitlab.com/ee/gitlab-basics/create-your-ssh-keys.html 找到。

一旦您创建了账户,您可以使用 项目 页面右上角的 新建项目 按钮创建一个新的存储库,如图所示 截图:

图 14.7:带有新建项目按钮的 GitLab "项目"页面

图 14.7:带有新建项目按钮的 GitLab "项目"页面

如果您点击 新建项目 按钮,您将被带到 新建项目 页面,在那里您可以使用默认的 空白项目 选项卡通过给它一个名称和 slug 来创建项目,如图所示 截图:

图 14.8:选择空白项目的 GitLab 新项目页面

图 14.8:选择空白项目的 GitLab 新项目页面

一旦您点击 创建项目,GitLab 项目 页面将以空状态出现,显示如何克隆项目的说明。您应该运行克隆存储库所需的命令,这可能是以下等效命令(您应该在您的机器上运行):

注意

如果您没有将 SSH 密钥与您的账户关联,在此阶段 GitLab 应该会显示一个带有您可点击链接的警告,以设置 SSH 密钥。

git clone <repository-url>

在您的机器上,您应该打开已克隆存储库的目录。为了添加 GitLab CI/CD,我们需要在项目的根目录中添加一个 .gitlab-ci.yml 文件。一个示例 .gitlab-ci.yml 文件,它将一个 build 作业添加到管道的 build 阶段,用于安装依赖项(使用 npm ci),运行生产构建(npm run build),并缓存输出工件,定义如下。

作业名称是通过在 YAML 文件中设置顶级键来定义的——在这种情况下,build:

在 YAML 语法中,我们将增加缩进来表示 build 键指向一个对象。

build job 对象中,我们将定义用于运行作业的 Docker 镜像,使用 image: node:lts。这意味着我们希望这个作业在 Node.js 长期支持 (LTS) 镜像上运行,该镜像将是 Node.js 12,直到 2020 年 10 月 20 日,届时它将指向 Node.js 14 镜像。

注意

您可以在 nodejs.org/en/about/releases/ 访问最新的 Node.js LTS 发布计划。

我们可以在作业中定义的另一个属性是阶段。GitLab CI/CD 管道默认有三个阶段:构建测试部署。当团队的工作流程不适合这三个类别时(例如,如果需要部署到多个环境),可以使用自定义阶段来替换这些阶段。请参阅文档 (docs.gitlab.com/ee/ci/yaml/#stages)。

注意

stages用于定义作业可以使用的阶段,并且它是全局定义的。

stages的指定允许灵活的多阶段管道。阶段中元素的顺序定义了作业执行的顺序:

a) 同一阶段的作业是并行运行的。

b) 下一个阶段的作业将在前一个阶段的作业成功完成后运行。

我们的管道目前只有一个阶段和一个作业,所以大部分前面的内容对我们不适用。

我们设置的最后一个属性是script,它定义了在作业运行时应运行的步骤,以及artifacts,它配置了工件存储。在我们的例子中,我们将运行npm ci来安装所有依赖项,然后运行npm run build,这将运行生产 Vue.js CLI 构建。我们的工件被设置为保留一周,并包含dist文件夹(Vue CLI build输出存储的地方)。

完整来说,我们有以下内容:

build:
  image: node:lts
  stage: build
  script:
    - npm ci
    - npm run build
  artifacts:
    expire_in: 1 week
    paths:
      - dist

一旦我们将这个.gitlab-ci.yml文件推送到包含 Vue CLI 项目的仓库中,我们将在仓库视图中看到以下内容,其中有一个步骤的管道正在最新提交上运行:

图 14.9:GitLab 仓库视图,显示在最新提交上运行的构建作业

图 14.9:GitLab 仓库视图,显示在最新提交上运行的构建作业

如果我们点击Pipeline图标(蓝色进行中指示器),我们将获得管道视图。在管道视图中,Build代表状态管道(我们将其设置为build)并且它代表作业名称(我们将其定义为build)。在作业完成之前,我们会看到相同的进行中指示器,如下所示:

图 14.10:GitLab CI 管道视图,显示正在运行的构建作业

图 14.10:GitLab CI 管道视图,显示正在运行的构建作业

作业完成后,我们会看到一个成功图标(绿色勾号)。我们可以在作业运行时或完成后(无论它是否失败或成功)点击此图标或作业名称来访问作业视图。当作业完成时,我们还会看到一个重试图标,这可以用来重试失败的管道步骤。以下截图显示了作业成功运行:

图 14.11:GitLab CI 管道视图,显示构建作业通过

图 14.11:GitLab CI 管道视图,显示构建作业通过

点击作业后,我们会看到作业视图,它显示了作业中所有步骤的详细分解。从准备 docker_machine 执行器步骤开始,该步骤加载 Node.js Docker 镜像,我们看到运行脚本以及缓存和工件恢复的步骤,如下所示:

图 14.12:GitLab CI 作业视图,显示成功的构建作业

图 14.12:GitLab CI 作业视图,显示成功的构建作业

如果我们想在 GitLab CI/CD 运行中添加test步骤,我们需要在一个支持单元测试的项目中。这可以通过使用 Vue CLI 来实现,它是通过vue add @vue/unit-jest安装的。安装和添加单元测试在第十二章单元测试中进行了详细说明。

我们需要在.gitlab-ci.yml文件中添加一个新的作业;我们将称之为test,使用node:lts镜像,并将作业分配给test状态。在作业中,我们运行npm ci,然后是npm run test:unit(这是由unit-jest CLI 插件添加的npm脚本):

# rest of .gitlab-ci.yml
test:
  image: node:lts
  stage: test
  script:
    - npm ci
    - npm run test:unit

一旦我们推送这个新的.gitlab-ci.yml文件,我们将在主存储库页面上看到以下视图:

图 14.13:存储库视图,GitLab CI/CD 正在运行包含新测试步骤的管道

图 14.13:存储库视图,GitLab CI/CD 正在运行包含新测试步骤的管道

我们可以点击进入管道视图。GitLab CI/CD 使用管道的原因是,在某个阶段的失败步骤将意味着后续阶段的步骤将不会运行。例如,如果我们得到一个失败的build作业,test阶段的作业将不会运行。以下截图很好地解释了这一点:

图 14.14:GitLab CI/CD 管道视图,失败的构建作业阻止测试作业/阶段运行

图 14.14:GitLab CI/CD 管道视图,失败的构建作业阻止测试作业/阶段运行

如果我们再次提交另一个提交或重试构建步骤(如果失败不是由更改引起的)并再次导航到管道视图,我们将看到以下内容:

图 14.15:GitLab CI/CD 管道视图,测试作业正在运行构建阶段的作业全部成功后

图 14.15:构建阶段作业全部成功后,GitLab CI/CD 管道视图中的测试作业正在运行

一旦测试作业成功,我们将看到以下管道:

图 14.16:GitLab CI/CD 管道视图,构建和测试阶段的作业全部成功

图 14.16:GitLab CI/CD 管道视图,构建和测试阶段的作业全部成功

我们现在已添加了一个包含buildtest阶段的 GitLab CI/CD 管道,该管道将验证在每次向 GitLab 存储库推送时,代码仍然按预期集成。

练习 14.01:向您的 GitLab CI/CD 管道添加 Lint 步骤

Linting 是一种获取自动化格式化和代码风格检查的方法。将其集成到 CI 中可以确保所有合并到主线分支的代码都遵循团队的代码风格指南。它还减少了代码风格审查评论的数量,这些评论可能会很嘈杂,并可能分散对更改请求的基本问题的关注。

要访问此练习的代码文件,请参阅packt.live/2IQDFW0

  1. 为了添加代码检查,我们需要确保我们的 package.json 文件中包含 lint 脚本。如果它缺失,我们需要添加它并将其设置为 vue-cli-service lint

    {
      "// other": "properties",
      "scripts": {
        "// other": "scripts",
        "lint": "vue-cli-service lint",
        "// other": "scripts"
      },
      "// more": "properties"
    }
    
  2. 为了在 GitLab CI/CD 上运行代码检查,我们需要添加一个新的 lint 作业,该作业将在 GitLab CI/CD 管道的 test 阶段运行在 Node.js LTS Docker 映像中。我们将在 .gitlab-ci.yml 中这样做:

    lint:
      image: node:lts
      stage: test
    
  3. 为了让 lint 作业按照 package.json 中的设置运行 lint 脚本,我们需要在 .gitlab-ci.yml 文件中添加一个 script 部分。首先需要运行 npm ci 来安装依赖项,然后运行 npm run lint 来执行代码检查:

    lint:
      image: node:lts
      stage: test
      script:
        - npm ci
        - npm run lint
    
  4. 最后,我们需要使用以下命令提交和推送代码到 GitLab:

    git add .
    git commit -m "add linting"
    git push
    

    一旦代码被推送,我们就可以通过 GitLab CI/CD UI 看到管道运行,如下所示。注意,在 test 阶段的全部作业都是并行运行的:

    图 14.17:GitLab CI/CD 管道视图,所有作业均成功,包括并行运行的 "test" 和 "lint"

    包括并行运行的 "test" 和 "lint"

    img/B15218_14_17.jpg

图 14.17:GitLab CI/CD 管道视图,所有作业均成功,包括并行运行的 "test" 和 "lint"

我们现在已经看到了如何使用 GitLab CI/CD 在每次提交时运行构建和测试。

接下来,我们将看到如何将 Vue.js 应用程序部署到 Netlify。

部署到 Netlify

Netlify 是一家专注于静态托管和相关支持服务的托管提供商,以便拥有一个使用静态托管的全交互网站。这包括诸如 Netlify Functions(无服务器函数)、Netlify Forms(无后端表单提交系统)和 Netlify Identity(身份/认证提供商)等服务。

以下部分需要您拥有一个 Netlify 账户,这是免费的。

将网站部署到 Netlify 的最简单方法是使用拖放界面。您可以在登录视图的首页底部找到它:app.netlify.com。它看起来如下所示:

图 14.18:Netlify 的拖放部署区域位于 App 首页底部

App 首页底部

img/B15218_14_18.jpg

图 14.18:Netlify 的拖放部署区域位于 App 首页底部

因此,我们可以选择一个已经运行过 npm run build 命令并可以通过简单地将 dist 文件夹拖动到拖放部署区域来部署的项目,如下面的截图所示:

图 14.19:将 dist 文件夹拖放到 Netlify 拖放部署区域

img/B15218_14_19.jpg

图 14.19:将 dist 文件夹拖放到 Netlify 拖放部署区域

一旦上传成功,Netlify 会将您重定向到您的新网站管理页面。它看起来如下所示:

图 14.20:Netlify 新应用页面用于拖放网站

img/B15218_14_20.jpg

图 14.20:Netlify 新应用页面用于拖放网站

我们可以点击网站链接,然后我们会看到默认的 Vue CLI 首页模板,如下所示:

![图 14.21:Netlify 新应用显示问候信息]

![图片 B15218_14_21.jpg]

图 14.21:Netlify 新应用显示问候信息

我们已经看到了如何使用拖放界面手动将站点部署到 Netlify。

接下来,我们将看到如何从 GitLab 将我们的站点部署到 Netlify。

在 Netlify 应用主页上,我们需要点击显示在以下截图中的“从 Git 创建新站点”按钮:

![图 14.22:Netlify 主页,带有从 Git 创建新站点的按钮]

![图片 B15218_14_22.jpg]

图 14.22:Netlify 主页,带有从 Git 创建新站点的按钮

我们将看到一个页面,要求我们选择要连接的 Git 提供商。在这个例子中,我们将使用GitLab。以下截图显示了屏幕将如何显示:

![图 14.23:Netlify – 创建新站点 | 连接到 Git 提供商]

![图片 B15218_14_23.jpg]

图 14.23:Netlify – 创建新站点 | 连接到 Git 提供商

点击GitLab后,我们将收到 GitLab 的 OAuth 授权挑战,我们需要通过点击以下截图所示的“授权”按钮来接受:

![图 14.24:GitLab OAuth 授权模态框]

![图片 B15218_14_24.jpg]

图 14.24:GitLab OAuth 授权模态框

然后,我们将被重定向到 Netlify,并被要求选择要部署的仓库,如下所示:

![图 14.25:选择要部署的 GitLab 仓库]

![图片 B15218_14_25.jpg]

图 14.25:选择要部署的 GitLab 仓库

我们选择我们想要部署的仓库,并遇到一个配置页面。由于我们现在是在 Netlify 的构建服务器上构建,我们需要配置 Netlify 以构建应用程序并部署正确的文件夹。

我们将构建命令填写为npm run build,因为这是我们构建脚本。发布目录是dist

然后,我们可以点击“部署站点”按钮,这将启动部署过程,如下所示:

![图 14.26:Netlify 构建配置选项卡,已填写 npm run build]

分别为构建命令和发布目录的 dist

![图片 B15218_14_26.jpg]

图 14.26:Netlify 构建配置选项卡,已填写 npm run build 和 dist 作为构建命令和发布目录

然后,我们将被重定向到新创建的应用程序页面,如下所示:

![图 14.27:新的 Netlify 应用]

![图片 B15218_14_27.jpg]

图 14.27:新的 Netlify 应用

我们已经看到了如何使用手动上传方法将应用程序部署到 Netlify,以及如何使用 GitLab 作为 Git 托管提供商。

练习 14.02:从 GitHub 将站点部署到 Netlify

我们已经看到了如何从 GitLab 将站点部署到 Netlify,但它与从 GitHub 部署有何不同?答案是它们非常相似;唯一的显著区别是“连接到 Git 提供商”选项卡中的第一步:

  1. 我们将首先点击主页上的“从 Git 创建新站点”按钮,如下所示:![图 14.28:Netlify 仪表板上的从 Git 创建新站点]

    ![图片 B15218_14_28.jpg]

    图 14.28:Netlify 控制台上的 Git 新站点

  2. 然后,我们将选择 GitHub 作为 Git 托管提供商,如下截图所示:![图 14.29:持续部署 图片

    图 14.29:持续部署

  3. 当我们遇到 GitHub OAuth 授权挑战,如下截图所示,我们授权 Netlify:![图 14.30:GitHub 授权挑战 图片

    图 14.30:GitHub 授权挑战

  4. 我们从仓库列表中选择我们想要部署的 Vue CLI 项目,如下所示:

  5. ![图 14.31:选择正确的仓库 图片

    图 14.31:选择正确的仓库

  6. 在部署选项选项卡上,我们选择 master 作为要部署的分支。

  7. 我们将构建命令设置为 npm run build

  8. 我们将发布目录设置为 dist

  9. 完成的部署选项如下所示:![图 14.32:Netlify 构建配置选项卡已填写 npm run build 和 dist 分别代表构建命令和发布目录 图片

    图 14.32:Netlify 构建配置选项卡已填写 npm run build 和 dist,分别代表构建命令和发布目录

  10. 我们点击 部署站点 以开始部署过程。

我们现在已经看到了如何使用手动上传方法以及使用 GitLab 或 GitHub 作为 Git 托管提供商将应用程序部署到 Netlify。

接下来,我们将了解如何使用 Amazon Web Services 简单存储服务AWS S3)和 AWS CloudFront 部署 Vue.js 应用程序。

使用 S3 和 CloudFront 部署到 AWS

Amazon S3 是一种静态存储服务,可以用作静态文件的托管,例如由 Vue CLI 的 build 脚本生成的文件。

CloudFront 是 AWS 的 内容分发网络CDN)服务。CDN 可以通过从 边缘位置提供静态内容来提高 Web 应用程序的性能。这些服务器位于世界各地,并且更有可能位于比 服务器(实际提供内容的服务器)更靠近最终用户的地方。如果 CDN 的边缘服务器没有缓存资源,它们将从源请求资源,但会为后续请求提供服务。

以下步骤的一个先决条件是 AWS 账户:

  1. 我们首先创建并配置一个 S3 存储桶。

    我们首先前往 S3 产品页面。它将类似于以下截图:

    ![图 14.33:从 AWS 服务列表中选择 S3 图片

    图 14.33:从 AWS 服务列表中选择 S3

  2. 在 S3 控制台主页上,我们可以点击 创建存储桶 按钮,这将带我们到存储桶创建页面,如下所示:![图 14.34:AWS S3 控制台上的创建存储桶按钮 图片

    图 14.34:AWS S3 控制台上的创建存储桶按钮

  3. 首先,我们给我们的存储桶命名。为了本例的目的,让我们称它为vue-workshop,如下所示:图 14.35:在存储桶创建页面输入存储桶名称

    图 14.35:在存储桶创建页面输入存储桶名称

  4. 我们还需要将 S3 存储桶设置为公开。这是通过取消选择阻止所有公开访问复选框来完成的。一旦这样做,我们必须检查确认复选框,如下所示:图 14.36:将 S3 存储桶设置为公开并确认警告

    图 14.36:将 S3 存储桶设置为公开并确认警告

  5. 一旦完成,我们将被重定向到存储桶列表页面。我们想要点击进入我们新的存储桶。然后,我们需要访问属性标签,以找到静态网站托管选项:

  6. 图 14.37:S3 存储桶属性标签中的静态网站托管选项

    图 14.37:S3 存储桶属性标签中的静态网站托管选项

  7. 我们可以填写静态网站托管S3 属性,选择使用此存储桶托管网站,并将索引文档和错误文档设置为index.html。记下端点URL 是个好主意,因为我们需要配置 CloudFront,如下所示:图 14.38:填写静态网站托管 S3 属性

    图 14.38:填写静态网站托管 S3 属性

  8. 我们现在可以回到 S3 存储桶页面的概览标签,点击上传,并将文件从我们的dist文件夹之一拖放到以下截图所示的位置:

  9. 图 14.39:通过拖放将文件添加到 vue-workshop S3 存储桶

    图 14.39:通过拖放将文件添加到 vue-workshop S3 存储桶

  10. 一旦文件被拖放到概览页面,我们点击下一步,并确保在页面的管理公开权限部分选择授予此对象(s)公开读取访问权限,以确保文件权限设置为公开。完成此操作后,我们可以通过点击下一步上传,在审查上传的文件后,不更改默认值完成上传,如下所示:

  11. 图 14.40:设置上传到 S3 存储桶的文件权限为公开

    图 14.40:设置上传到 S3 存储桶的文件权限为公开

  12. 我们现在应该已经配置了 S3 存储桶以托管静态内容,通过访问网站端点(在属性 | 静态网站托管中可用),我们看到以下 Vue.js 应用程序(这是我们上传的):图 14.41:从我们的 AWS S3 存储桶提供的 Vue.js 应用程序

    图 14.41:从我们的 AWS S3 存储桶提供的 Vue.js 应用程序

    注意,S3 只能通过 HTTP 提供网站服务,并且无法直接从 S3 存储桶配置域名。除了性能和健壮性之外,能够设置自定义域名和 HTTPS 支持也是将 AWS CloudFront 设置为网站 CDN 的其他原因。

  13. 我们将首先导航到 CloudFront 控制台并点击“创建分布”按钮,如下所示:

  14. 图 14.42:从 AWS 服务列表中选择 CloudFront

    img/B15218_14_42.jpg

    图 14.42:从 AWS 服务列表中选择 CloudFront

  15. 当提示我们想要创建哪种类型的分布时,我们将通过点击相关的“开始”按钮选择Web,如下截图所示:

  16. 图 14.43:选择创建 Web CloudFront 分布

    img/B15218_14_43.jpg

    图 14.43:选择创建 Web CloudFront 分布

  17. “源域名”应该是 S3 存储桶网站端点域名——换句话说,就是之前我们用来访问它的 URL 的域名。对于位于us-east-1区域的example存储桶,它看起来像example.s3-website.us-west-1.amazonaws.com。以下截图显示了这一点:图 14.44:在 CloudFront 中输入网站端点域名

    img/B15218_14_44.jpg

    图 14.44:在 CloudFront 分布的“源域名”字段中输入网站端点域名

  18. 在设置分布时,选择“默认缓存行为”部分的“查看器协议策略”字段的“将 HTTP 重定向到 HTTPS”选项是个好主意,如下所示:

  19. 图 14.45:为查看器协议策略字段选择将 HTTP 重定向到 HTTPS

    img/B15218_14_45.jpg

图 14.45:为查看器协议策略字段选择将 HTTP 重定向到 HTTPS

现在我们已经准备好点击“创建分布”按钮并等待更改传播。

注意

由于 CloudFront 分布更改正在部署到世界各地的服务器上,因此它们需要一段时间才能传播。

一旦控制台显示其状态为“已部署”,我们就可以打开 CloudFront 分布的域名。

我们已经看到了如何设置 S3 和 CloudFront 来托管静态网站。现在我们将看到如何使用 AWS CLI 将本地目录同步到 S3 存储桶。

下一个部分的前提条件是有一个使用AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_DEFAULT_REGION环境变量注入 AWS 凭证的 shell 实例。访问密钥和秘密密钥需要从“账户”下拉菜单中的“我的安全凭证”下的“访问密钥”生成。它还需要 AWS CLI 版本 2。

如果我们是在 Vue CLI 项目中,我们可以使用 AWS S3 CLI 命令将 dist 文件夹(可以使用 npm run build 构建)部署到我们的 vue-workshop 存储桶。我们想要更新一个 s3 资源,以便我们的命令以 aws s3 开始。我们想要执行的命令是同步文件,所以我们将使用 aws s3 sync 命令。我们将同步 ./distvue-workshop S3 存储桶,使用 AWS URI 语法,即 s3://vue-workshop。我们还想要确保我们上传的文件,就像存储桶配置一样,允许 public-read。完整的命令如下:

aws s3 sync ./dist s3://vue-workshop --acl=public-read

练习 14.03:从 GitLab CI/CD 部署到 S3

S3 是一种非常经济高效且性能出色的解决方案,用于大规模存储静态文件。在这个练习中,我们将探讨如何集成 GitLab CI/CD 和 AWS S3 来部署 Vue.js 应用程序。这将自动化 Vue.js 应用的部署。部署将在每次向 GitLab 推送时运行,无需任何手动干预。

要访问此练习的代码文件,请参阅 packt.live/3kJ1HPD

为了从 GitLab CI/CD 部署到 S3 存储桶,我们首先需要设置凭证管理:

  1. 按照以下步骤导航到 GitLab 的 CI/CD 设置部分:图 14.46:设置菜单中的 CI/CD

    图 14.46:14_46.jpg

    图 14.46:设置菜单中的 CI/CD

  2. 我们将想要添加变量,所以让我们展开该部分。你将看到如下截图所示的消息:图 14.47:GitLab CI/CD 设置的变量部分展开

    图 14.47:14_47.jpg

    图 14.47:GitLab CI/CD 设置的变量部分展开

  3. 接下来,我们将使用 UI 添加 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY(由于它们是敏感的 API 密钥,所以未显示其值),如下所示:

  4. 图 14.48:输入 AWS_ACCESS_KEY_ID 环境变量

    图 14.48:14_48.jpg

    图 14.48:输入 AWS_ACCESS_KEY_ID 环境变量

  5. 然后,我们可以使用 UI 添加默认的 AWS_REGION 变量。这不是那么敏感,所以其值在以下截图中显示:

  6. 图 14.49:输入 AWS_DEFAULT_REGION 环境变量

    图 14.49:14_49.jpg

    图 14.49:输入 AWS_DEFAULT_REGION 环境变量

  7. 现在我们已经在 GitLab CI/CD 上设置了环境变量,我们可以开始更新我们的 .gitlab-ci.yml 文件。首先,我们想在 build 步骤之后开始缓存 dist 目录。为了做到这一点,我们需要在 build 作业中添加一个 cache 属性:

    build:
      # other properties
      cache:
        key: $CI_COMMIT_REF_SLUG
        paths:
          - dist
      # other properties
    # other jobs
    
  8. 我们现在可以添加我们的 deploy 作业,它将是 deploy 阶段的一部分。为了访问 AWS CLI,我们将使用 Python 映像(python:latest)并在 before_script 步骤中使用 pip(Python 包管理器)安装 AWS CLI。一旦我们安装了 AWS CLI,我们将在 script 步骤中使用我们用于从本地机器部署的 aws s3 sync 命令来运行部署:

    # other jobs
    deploy:
      image: python:latest
      stage: deploy
      cache:
        key: $CI_COMMIT_REF_SLUG
        paths:
          - dist
      before_script:
        - pip install awscli
      script:
        - aws s3 sync ./dist s3://vue-workshop --acl=public-read
    

    注意

    由于 Vue CLI 的 build 命令通过指纹文件内容内置了缓存清除功能,我们不需要使缓存失效。指纹化意味着如果文件内容发生变化,其名称/URL 将相应地更改。当请求此新文件时,它将从未缓存的 URL 加载,因此将获取文件的最新版本。

    一旦将此配置更新推送到 GitLab 仓库,我们可以看到管道运行了三个阶段,所有阶段都通过了,如下所示:

    ![图 14.50:通过构建、测试和部署作业 图片

图 14.50:通过构建、测试和部署作业

我们已经看到了如何使用 AWS CLI 和 GitLab CI/CD 配置和部署 Vue.js 应用程序到 S3 和 CloudFront。

活动 14.01:将 GitLab CI/CD 添加到图书搜索应用程序并部署到 Amazon S3 和 CloudFront

现在,让我们使用 GitLab CI/CD 将一个完全构建的 图书搜索 Vue.js 应用程序部署到 S3/CloudFront,该应用程序从 Google Books API 加载数据。我们将从在本地运行生产构建并检查输出开始。然后,我们将切换到在 GitLab CI 上运行构建和代码质量步骤(linting)。最后,我们将设置 S3 存储桶和 CloudFront 分发,并将它们与 GitLab CI/CD 集成,以便在每次向仓库推送时部署。

此活动的起始代码可以在 Chapter14/Activity14.01_initial 中找到;我们从一个 Chapter14/Activity14.01_solution 开始:

  1. 首先,我们想要在本地运行生产构建。我们可以使用用于构建所有 Vue CLI 项目的常规命令。我们还将检查相关的资产(JavaScript、CSS 和 HTML)是否正确生成。

    我们预计 dist 文件夹将包含以下类似的结构:

    ![图 14.51:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令) 在 Vue CLI 生产构建运行之后 图片

    图 14.51:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令生成)

  2. 为了运行 GitLab CI/CD,我们需要一个 .gitlab-ci.yml 文件。我们将在 .gitlab-ci.yml 中添加一个作业,该作业在 build 阶段运行 Node.js LTS Docker 容器中的包安装,然后是生产构建。我们还将确保缓存生产构建的输出。

    一旦我们使用 git add .gitlab-ci.yml 并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含正在运行状态的 build 作业:

    ![图 14.52:构建作业正在运行的 GitLab CI/CD 管道 图片

    图 14.52:构建作业正在运行的 GitLab CI/CD 管道

    另一方面,以下截图表示 build 作业完成并处于 passed 状态时的 GitLab CI/CD 管道:

    ![图 14.53:构建作业通过后的 GitLab CI/CD 管道 图片

    图 14.53:GitLab CI/CD 流水线中 build 作业通过

  3. 接下来,我们希望在 GitLab CI/CD 的test阶段添加一个代码质量作业(通过更新.gitlab-ci.yml)。我们将这个作业命名为lint,它将运行依赖项的安装以及通过 Vue CLI 进行代码检查。

    一旦我们使用git add .gitlab-ci.yml提交并推送更改,我们应该看到以下 GitLab CI/CD 流水线运行,其中包含正在运行状态的lint作业:

    图 14.54:GitLab CI/CD 流水线中 lint 作业运行

    图 14.54:运行中的 GitLab CI/CD 流水线及 lint 作业

    以下截图显示了 GitLab CI/CD 流水线,其中lint作业已成功完成:

    图 14.55:GitLab CI/CD 流水线中 lint 作业通过

    图 14.55:GitLab CI/CD 流水线中 lint 作业通过

  4. 为了部署我们的应用程序,我们需要在 S3 控制台中创建一个启用public accessvue-workshop-book-search S3 存储桶。

    S3 存储桶创建页面应如下所示:

    图 14.56:S3 存储桶创建页面,输入 vue-workshop-book-    search 作为存储桶名称输入

    图 14.56:S3 存储桶创建页面,输入 vue-workshop-book-search 作为存储桶名称

    以下截图显示了 S3 存储桶创建页面上的公共访问和免责声明信息:

    图 14.57:启用公共访问并添加变量(值已隐藏)的 S3 存储桶创建页面    并接受相关免责声明

    图 14.57:S3 存储桶创建页面,启用公共访问并接受相关免责声明

  5. 为了通过 Web 访问 S3 存储桶内容,我们还需要配置其网络托管。我们可以通过 S3 控制台配置网络托管属性。

    应该按照以下配置,将索引和错误页面设置为index html

    图 14.58:S3 存储桶属性页面,启用网络托管并配置索引和错误页面为 index.html

    图 14.58:S3 存储桶属性页面,启用网络托管并配置索引和错误页面为 index.html

  6. 为了让 GitLab CI/CD 能够在 S3 上创建和更新文件,我们需要将相关的 AWS 密钥添加到我们的 GitLab 仓库 CI/CD 设置中。这些密钥可以在 AWS 管理控制台的Username下拉菜单 | My Security Credentials | Access keys(访问密钥 ID 和秘密访问密钥) | Create New Access Key(或选择一个密钥进行重用)中找到。以下截图显示了这些详细信息:

  7. 图 14.59:GitLab CI/CD 设置页面,已添加所需的 AWS 环境变量    variables added (with values masked)

    图 14.59:GitLab CI/CD 设置页面,已添加所需的 AWS 环境变量(值已隐藏)

  8. 接下来,我们希望在 GitLab CI/CD 的 deploy 阶段添加一个 deploy 作业(通过更新 .gitlab-ci.yml)。我们将作业命名为 deploy;它需要下载 awscli pip 包(Python 包管理器),这意味着最有意义的 Docker 镜像是 python:latestdeploy 作业将从缓存中加载构建好的生产构建,使用 pip 安装 awscli,并运行 aws s3 sync <build_directory> s3://<s3-bucket-name> --acl=public-read

    一旦我们使用 git add .gitlab-ci.yml 并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态的 deploy 作业:

    ![图 14.60:GitLab CI/CD 管道中部署作业正在运行 img/B15218_14_60.jpg

    图 14.60:GitLab CI/CD 管道中部署作业正在运行

    以下截图显示了成功完成的 deploy 作业的 GitLab CI/CD 管道:

    ![图 14.61:GitLab CI/CD 管道中部署作业已通过 img/B15218_14_61.jpg

    图 14.61:GitLab CI/CD 管道中部署作业已通过

    一旦管道完成,我们的应用程序应可通过以下截图所示的 web S3 端点访问:

    ![图 14.62:通过 S3 网端点 URL 访问的图书搜索 img/B15218_14_62.jpg

    图 14.62:通过 S3 网端点 URL 访问的图书搜索

  9. 最后,我们将创建一个充当 web S3 端点 CDN 的 CloudFront 分发。我们需要将 origin 设置为我们的 S3 存储桶网端点的源,并确保我们已经启用了 Redirect HTTP to HTTPS,如下面的截图所示:

  10. ![图 14.63:显示源和设置行为的 CloudFront 分发创建页面 源和设置行为 img/B15218_14_63.jpg

图 14.63:显示源和设置行为的 CloudFront 分发创建页面

一旦 CloudFront 分发部署,我们的应用程序应可通过以下截图所示的 CloudFront 分发域名访问:

![图 14.64:通过 CloudFront 域访问的图书搜索显示 "harry potter" 搜索的结果对于 "harry potter" 搜索的结果img/B15218_14_64.jpg

图 14.64:通过 CloudFront 域访问的图书搜索显示 "harry potter" 搜索的结果

注意

此活动的解决方案可以通过此链接找到。

摘要

在本章中,我们探讨了如何将 CI 和 CD 实践引入 Vue.js 项目,以便安全高效地部署到生产环境。我们看到了 CI 和 CD 在敏捷交付过程中的好处。我们使用 GitLab 的 CI/CD 功能在每次提交时运行测试、代码检查和构建。我们看到了如何通过将 Netlify 连接到我们的托管提供商来利用 Netlify 托管静态网站。最后,我们探讨了如何设置和部署到 AWS S3 和 CloudFront。

在这本书的整个过程中,你已经学会了如何构建、测试和部署一个适用于你的团队和最终用户的可扩展且性能良好的 Vue.js 应用程序。

附录

1. 开始你的第一个 Vue 项目

活动一.01:使用 Vue.js 构建动态购物清单应用

解决方案:

要访问此活动的代码文件,请参阅packt.live/35Tkzau

  1. 通过运行vue create new-activity-app命令使用 Vue CLI 创建一个新的 Vue 项目。通过命令提示符手动选择dart-sassbabeleslint功能。

  2. 使用占位符“按 Enter 键添加新项目”创建一个输入字段,并将其v-model绑定到名为input的数据对象上,同时设置ref属性值为input。通过使用@keyup.enter并引用addItem方法,将Enter键绑定到addItem方法,该方法将在下一步创建:

    <template>
      <div class="container">
        <h2>Shopping list</h2>
        <div class="user-input">
          <input
            placeholder="Press enter to add new item"
            v-model="input"
            @keyup.enter="addItem"
            ref="input"
          />
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          input: '',
        }
      },
    }
    </script>
    <style lang="scss">
    @import 'styles/global';
    $color-green: #4fc08d;
    $color-grey: #2c3e50;
    .container {
      max-width: 600px;
      margin: 80px auto;
    }
    // Type
    .h2 {
      font-size: 21px;
    }
    .user-input {
      display: flex;
      align-items: center;
      padding-bottom: 20px;
      input {
        width: 100%;
        padding: 10px 6px;
        margin-right: 10px;
      }
    }
    </style>
    
  3. addItem上引入一个绑定点击事件的按钮,并在methods对象中包含相应的addItem()方法。在addItem()方法中,将数据属性input的字符串推入shoppingList,并添加一个检查以确保input属性存在。可选地,为你的按钮添加一些样式:

    <template>
      <div class="container">
        <h2>Shopping list</h2>
        <div class="user-input">
          <input
            placeholder="Press enter to add new item"
            v-model="input"
            @keyup.enter="addItem"
            ref="input"
          /><button @click="addItem">Add item</button>
        </div>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          input: '',
          shoppingList: [],
        }
      },
      methods: {
        addItem() {
          // Don't allow adding to the list if empty
          if (!this.input) return
          this.shoppingList.push(this.input)
          // Clear the input after adding
          this.input = ''
          // Focus the input element again for quick typing!
          this.$refs.input.focus()
        },
      },
    }
    </script>
    <style lang="scss">
    ...
    // Buttons
    button {
      appearance: none;
      padding: 10px;
      font-weight: bold;
      border-radius: 10px;
      border: none;
      background: $color-grey;
      color: white;
      white-space: nowrap;
      + button {
        margin-left: 10px;
      }
    }
    </style>
    
  4. 在 DOM 中输出购物清单项。当你点击“添加项目”按钮时,它应该被添加到shoppingList并显示:

    <template>
      <div class="container">
        ...
        <ul v-if="shoppingList">
          <li v-for="(item, i) in shoppingList" :key="i" class="item"
             ><span>{{ item }}</span>
            </li>
        </ul>
      </div>
    </template>
    <style lang="scss">
    .item {
      display: flex;
      align-items: center;
    }
    ul {
      display: block;
      margin: 0 auto;
      padding: 30px;
      border: 1px solid rgba(0, 0, 0, 0.25);
      > li {
        color: $color-grey;
        margin-bottom: 4px;
      }
    }
    </style>
    

    以下截图显示了购物清单:

    ![图 1.44:购物清单应根据用户输入显示 图片 B15218_01_44.jpg

    图 1.44:购物清单应根据用户输入显示

  5. 为了满足从列表中删除项目的最后要求,创建一个名为deleteItem的新方法,并允许传入一个名为i的参数。如果有参数传递到该方法中,过滤出该数组项并更新shoppingList属性;如果没有参数传递到该方法中,则用空数组替换数据属性:

    ...
    <script>
    export default {
      data() {
        return {
          input: '',
          shoppingList: [],
        }
      },
      methods: {
        addItem() {
          // Don't allow adding to the list if empty
          if (!this.input) return
          this.shoppingList.push(this.input)
          // Clear the input after adding
          this.input = ''
          // Focus the input element again for quick typing!
          this.$refs.input.focus()
        },
        deleteItem(i) {
          this.shoppingList = i
            ? this.shoppingList.filter((item, x) => x !== i)
            : []
        },
      },
    }
    </script>
    
  6. 创建一个“删除所有”按钮元素,并使用点击事件@click将其绑定到deleteItem方法:

        <button class="button--delete" @click="deleteItem()">      Delete all</button>
    ...
    <style lang="scss">
    ...
    .button--delete {
      display: block;
      margin: 0 auto;
      background: red;
    }
    </style>
    
  7. 在列表循环中添加一个“删除”按钮,通过传递v-for属性i来删除单个购物清单项:

    <template>
      <div class="container">
        ...
        <ul v-if="shoppingList">
          <li v-for="(item, i) in shoppingList" :key="i" class="item"
            ><span>{{ item }}</span>
            <button class="button--remove" 
              @click="deleteItem(i)">Remove</button>
          </li>
        </ul>
        <br />
        <button class="button--delete" @click="deleteItem()">      Delete all</button>
      </div>
    </template>
    ...
    <style lang="scss">
    ...
    .button--remove {
      background: none;
      color: red;
      text-transform: uppercase;
      font-size: 11px;
      align-self: flex-end;
    }
    </style>
    

    图 1.45 显示了添加项目之前的购物清单所有详细信息:

    ![图 1.45:最终输出 图片 B15218_01_45.jpg

图 1.45:最终输出

以下截图显示了添加项目到购物清单后的输出:

![图 1.46:添加项目到购物清单后的最终输出图片 B15218_01_46.jpg

图 1.46:添加项目到购物清单后的最终输出

在这个活动中,你通过使用SFC的所有基本功能来测试你的 Vue 知识,例如表达式、循环、双向绑定和事件处理。你构建了一个购物清单应用,允许用户使用 Vue 方法添加和删除单个列表项,或者通过单击一次清除整个列表。

2. 数据处理

活动二.01:使用 Contentful API 创建博客列表

解决方案:

执行以下步骤以完成活动。

注意

要访问此活动的代码文件,请参阅packt.live/33ao1f5

  1. 使用 Vue CLI 的 vue create activity 命令创建一个新的 Vue 项目,并选择以下预设:Babel、SCSS 预处理器(您可以选择任一预处理器),以及 prettier 格式化器。

  2. 添加 contentful 依赖项:

    yarn add contentful
    
  3. App.vue 中,移除默认内容并将 contentful 导入到组件中:

    <template>
      <div id=»app»>
      </div>
    </template>
    <script>
    import { createClient } from 'contentful'
    const client = createClient({
      space: ‹hpr0uushokd4›,
      accessToken: ‹jwEHepvQx-kMtO7_2ldjhE4WMAsiDp3t1xxBT8aDp7U›,
    })
    </script>
    <style lang="scss">
    #app {
      font-family: ‹Avenir›, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
      margin: 60px auto 0;
      max-width: 800px;
    }
    </style>
    
  4. 在创建生命周期中向 getPeoplegetBlogPosts 添加 async 方法,并将调用响应分别分配给模板中的 authorsposts 数据属性:

    <template>
      <div id=»app»>
        <pre>{{ authors }}</pre>
        <pre>{{ posts }}</pre>
      </div>
    </template>
    <script>
    import { createClient } from 'contentful'
    const client = createClient({
      space: ‹hpr0uushokd4›,
      accessToken: ‹jwEHepvQx-kMtO7_2ldjhE4WMAsiDp3t1xxBT8aDp7U›,
    })
    export default {
      name: ‹app›,
      data() {
        return {
          authors: [],
          posts: {},
        }
      },
      async created() {
        this.authors = await this.getPeople()
        this.posts = await this.getBlogPosts()
      },
      methods: {
        async getPeople() {
          const entries = await client.getEntries({ content_type:         'person' })
          return entries.items
        },
        async getBlogPosts() {
          const entries = await client.getEntries({
            content_type: ‹blogPost›,
            order: ‹-fields.publishDate›,
          })
          return entries.items
        },
      },
    }
    </script>
    
  5. 使用 posts 对象遍历文章,并输出 publishDatetitledescriptionimage

    
      <div class=»articles»>
          <hr />
          <h2>Articles</h2>
          <section v-if=»posts» class=»articles-list»>
            <article v-for=»(post, i) in posts» :key=»i»>
              <img
                class=»thumbnail»
                :src=»
                  post.fields.heroImage.fields.file.url +                 '?fit=scale&w=350&h=196'
                «
              />
    
     class=»article-text»>
    
    <div class="date">{{
                  new Date(post.fields.publishDate).toDateString()
                }}</div>
                <h4>{{ post.fields.title }}</h4>
                <p>{{ post.fields.description }}</p>
              </div>
            </article>
          </section>
        </div>
    
  6. articles-list 添加一些 scss 样式:

    .articles-list {
      article {
        display: flex;
        text-align: left;
        padding-bottom: 15px;
        .article-text {
          padding: 15px 0;
        }
        .thumbnail {
          margin-right: 30px;
        }
        .date {
          font-size: 12px;
          font-weight: bold;
          text-transform: uppercase;
        }
      }
    }
    
  7. 使用计算属性输出作者信息:

    <template>
       ...
    
        <div v-if=»name» class=»author»>
          <h2
            >{{ name }} <br />
            <small v-if=»title» >{{ title }}</small></h2
          >
          <p v-if=»bio» >{{ bio }}</p>
        </div>
        ...
    </template>
    ...  
    computed: {
        name() {
          return this.authors[0] && this.authors[0].fields.name
        },
        title() {
          return this.authors[0] && this.authors[0].fields.title
        },
        bio() {
          return this.authors[0] && this.authors[0].fields.shortBio
        },
    },
    ...
    

    以下截图显示了作者信息以及他们的博客文章列表:

    ![图 2.16:使用 Contentful 博客文章的预期结果

    ![img/B15218_02_16.jpg]

图 2.16:使用 Contentful 博客文章的预期结果

在此活动中,您使用 Vue SFC 的基本功能构建了一个博客,列出了来自 API 源的文章,使用 async 方法从 API 获取远程数据,并使用计算属性组织深层嵌套的对象结构。

3. Vue CLI

活动 3.01:使用 Vue-UI 和 Vuetify 组件库构建 Vue 应用程序

解决方案:

执行以下步骤以完成活动。

注意

要访问此活动的代码文件,请参阅 packt.live/35WaCJG

  1. 打开命令行,运行 vue create activity-app

  2. 通过按一次 向下箭头键 并按 Enter 键选择最后一个选项,手动选择功能

    ? Please pick a preset: (Use arrow keys)
      default (babel, eslint)
     > Manually select features
    
  3. 选择 BabelCSS 预处理器检查器/格式化器

    ? Check the features needed for your project:
     (*) Babel
     ( ) TypeScript
     ( ) Progressive Web App (PWA) Support
     ( ) Router
     ( ) Vuex
     (*) CSS Pre-processors
    >(*) Linter / Formatter
     ( ) Unit Testing
     ( ) E2E Testing
    
  4. 选择 Sass/SCSS (with dart-sass)

    ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys)
    > Sass/SCSS (with dart-sass)
      Sass/SCSS (with node-sass)
      Less
      Stylus
    
  5. 接下来,我们将选择 Eslint+ Prettier 来格式化代码:

    ? Pick a linter / formatter config: (Use arrow keys)
     ESLint with error prevention only
     ESLint + Airbnb config
     ESLint + Standard config
    > ESLint + Prettier
    
  6. 然后,我们将选择选项 保存时检查提交时检查和修复 以选择额外的检查功能并保存它们:

    ? Pick additional lint features: (Press <space> to select,  <a> to toggle all, <i> to invert selection)
     >(*) Lint on save
     (*) Lint and fix on commit
    
  7. 要将配置放在专用文件中,我们将选择 在专用配置文件中 选项:

    ? Where do you prefer placing config for Babel, PostCSS, ESLint,  etc.? (Use arrow keys)
     > In dedicated config files
     In package.json
    

    Enter 键跳过保存。npm 包将自动安装。您应该在终端看到以下输出:

    yarn install v1.16.0
    info No lockfile found.
     [1/4] Resolving packages...
    
  8. 安装完包后,运行 yarn serve 命令。然后,转到您的浏览器并导航到 http://localhost:8080。您应该看到以下输出:![图 3.43:默认 Vue 项目屏幕

    ![img/B15218_03_43.jpg]

    图 3.43:默认 Vue 项目屏幕

  9. 停止 serve 任务,并在命令行中运行 vue ui

  10. 在 Vue-UI 内,转到项目选择屏幕(位于 http://localhost:8000/project/select)。

  11. 点击 导入 按钮,导航到您新创建的 Vue 项目存储的文件夹。以下截图显示了您的屏幕应该看起来像什么:![图 3.44:Vue-UI 项目管理器

    ![img/B15218_03_44.jpg]

    图 3.44:Vue-UI 项目管理器

  12. 点击大绿色的 导入此文件夹 按钮。

  13. Projects 仪表板导航到 Plugins 选项卡。

  14. 点击 + 添加插件 按钮。你的屏幕应该看起来像下面的截图:图 3.45:Vue-UI 插件管理器,你可以在这里添加、删除、    并修改 Vue 插件

    图 3.45:Vue-UI 插件管理器,你可以在这里添加、删除和修改 Vue 插件

  15. 搜索 vuetify 并安装 vue-cli-plugin-vuetify,然后选择默认配置设置,如图 3.46 所示:图 3.46:安装 Vuetify CLI 时 App.vue 的默认配置

    图 3.46:安装 Vuetify CLI 时 App.vue 的默认配置

  16. 导航到 Tasks 页面并点击 Start Tasks。当应用程序初始化时,点击 Open App 按钮。在 localhost URL 上,你应该看到一个如下所示的 Vuetify 风格的页面:图 3.47:当 Vuetify CLI 插件安装时你在浏览器中看到的内容

    图 3.47:当 Vuetify CLI 插件安装时你在浏览器中看到的内容

  17. 点击 Vuetify 页面布局中的 Select a layout 超链接。

  18. 从以下截图所示的选项中点击 Baseline 主题(或任何其他你感兴趣的主题)的代码链接:图 3.48:Vuetify 网站提供了多个预制的布局

    图 3.48:Vuetify 网站提供了多个预制的布局

  19. 从 Vuetify 仓库复制 baseline.vue 文件的内容,并用此内容替换你的 App.vue 文件内容。你的 localhost:8080 应该会重新加载你复制的内容,浏览器应显示如下:图 3.49:从浏览器中看到的模板最终结果

图 3.49:从浏览器中看到的模板最终结果

到此活动结束时,你看到了如何使用 Vue-UI 准备 Vue.js 项目,选择和组织用于企业级 Vue 应用程序生产的宝贵预设。你安装并使用了 Vuetify 框架,利用 Vuetify 组件创建了一个布局,然后你可以在浏览器中预览它。

4. 嵌套组件(模块化)

活动 4.01:具有可重用组件的本地消息视图

解决方案

执行以下步骤以完成活动。

注意

要访问此活动的代码文件,请参阅 packt.live/36ZxyH8

首先,我们需要一种方法来捕获用户的消息:

  1. 创建一个显示 textareaMessageEditor 组件:

    <template>
      <div>
        <textarea></textarea>
      </div>
    </template>
    
  2. 使用 data 组件方法可以添加一个响应实例属性:

    <script>
    export default {
      data() {
        return {
          message: ''
        }
      }
    }
    </script>
    
  3. textarea 发生 change 时,我们将状态存储在一个我们已在 data 组件方法中设置为 null 的 message 响应实例变量中:

    <template>
      <!-- rest of the template -->
        <textarea
          @change="onChange($event)"
        >
        </textarea>
    </template>
    <script>
    export default {
      // rest of component properties
      methods: {
        onChange(event) {
          this.message = event.target.value
        }
      }
    }
    </script>
    
  4. 一个 Send 操作应该导致 textarea 的最新内容作为 send 事件的负载发出:

    <template>
      <!-- rest of the template -->
        <button @click="$emit('send', message)">Send</button>
      <!-- rest of the template -->
    </template>
    
  5. 要显示 MessageEditor,我们需要导入它,在 components 中注册它,并在 src/App.vuetemplate 部分引用它:

    <template>
      <div id="app">
        <MessageEditor />
      </div>
    </template>
    <script>
    import MessageEditor from './components/MessageEditor.vue'
    export default {
      components: {
        MessageEditor,
      },
    }
    </script>
    
  6. 要显示消息,我们将使用 @send 监听 send 事件,并将每个有效负载添加到一个新的 messages 数组响应式实例变量中:

    <template>
      <!-- rest of template -->
        <MessageEditor @send="onSend($event)" />
      <!-- rest of template -->
    </template>
    <script>
    // rest of script
    export default {
      // other component fields
      data() {
        return { messages: [] }
      },
      methods: {
        onSend(message) {
          this.messages = [...this.messages, message]
        }
      }
    }
    </script>
    
  7. MessageFeed 支持通过 messages 数组作为 prop 传递:

    <template>
    </template>
    <script>
    export default {
      props: {
        messages: {
          type: Array,
          required: true
        }
      }
    }
    </script>
    
  8. 我们将使用 v-for 来遍历 messages 数组:

    <template>
        <div>
        <p v-for="(m, i) in messages" :key="i">
          {{ m }}
        </p>
      </div>
    </template>
    
  9. 要显示我们存储的消息,我们将在 App 中渲染 MessageFeed,并将 messages 应用实例变量绑定为 MessageFeedmessages 属性:

    <template>
      <!-- rest of template -->
        <MessageFeed :messages="messages" />
      <!-- rest of template -->
    </template>
    <script>
    // other imports
    import MessageFeed from './components/MessageFeed.vue'
    export default {
      components: {
        // other components,
        MessageFeed
      }
    }
    </script>
    
  10. MessageEditor 中,我们将重构 send 按钮点击 handler,以便在点击时也将 this.message 设置为 ''

    <template>
      <!-- rest of template -->
        <textarea
          ref="textArea"
          @change="onChange($event)"
        >
        </textarea>
        <button @click="onSendClick()">Send</button>
      <!-- rest of template -->
    </template>
    <script>
    export default {
      // rest of component
      methods: {
        // other methods
        onSendClick() {
          this.$emit('send', this.message)
          this.message = ''
          this.$refs.textArea.value = ''
        }
      }
    }
    </script>
    

    预期输出如下:

    ![图 4.34:带有已发送的 Hello World! 和 Hello JavaScript! 的消息应用]

    ![图片 B15218_04_34.jpg]

图 4.34:带有已发送的 Hello World! 和 Hello JavaScript! 的消息应用

通过这样,我们已经学习了如何使用组件、属性、事件和引用来渲染聊天界面。

5. 全局组件组合

活动 5.01:使用插件和可重用组件构建 Vue.js 应用程序

解决方案

完成此活动的步骤如下:

注意

要访问此活动的代码文件,请参阅 packt.live/35UlWpj

  1. axios 安装到项目中:

    npm install --save axios
    
  2. 要将 axios 注入为 this 组件实例的属性,创建一个 src/plugins/axios.js 插件文件,在 install 时,这意味着组件实例将有一个 axios 属性:

    import axios from 'axios'
    export default {
      install(Vue) {
        Vue.prototype.axios = axios
      }
    }
    
  3. 为了使插件工作,请在 src/main.js 中导入并注册它:

    // other imports
    import axiosPlugin from './plugins/axios.js'
    Vue.use(axiosPlugin)
    // other initialisation code
    
  4. 我们还希望将我们的 API 的 baseUrl 注入到所有组件中。我们将在 src/main.js 文件中创建一个插件来执行此操作:

    const BASE_URL = 'https://jsonplaceholder.typicode.com'
    Vue.use({
      install(Vue) {
        Vue.baseUrl = BASE_URL
        Vue.prototype.baseUrl = BASE_URL
      }
    })
    

    注意

    熟悉 axios 的人知道我们可以将此 URL 注入为 axiosbaseURL

  5. 现在,我们需要从 src/App.vue 中获取所有 todos。在 mounted 生命周期方法中做这件事是个好地方:

    <script>
    export default {
      async mounted() {
        const { data: todos } = await this.axios.get(      `${this.baseUrl}/todos`)
        this.todos = todos
      }
    }
    </script>
    
  6. 要显示 todo 列表,我们将在 src/components/TodoList.vue 中创建一个 TodoList 函数式组件。这将接受一个 todos 属性,遍历项目,并在 todo 作用域插槽中绑定它以延迟渲染我们的 todo

    <template functional>
      <ul>
        <li v-for="todo in props.todos" :key="todo.id">
          <slot name="todo" :todo="todo" />
        </li>
      </ul>
    </template>
    
  7. 我们现在可以使用 TodoList 组件来渲染出我们在 src/App.vue 中已经获取的 todos 属性:

    <template>
      <div id="app">
        <TodoList :todos="todos">
          <template #todo="{ todo }">
          {{ todo.title }}
          </template>
        </TodoList>
      </div>
    </template>
    <script>
    import TodoList from './components/TodoList.vue'
    export default {
      components: {
        TodoList
      },
      // other component methods
      data() {
        return { todos: [] }
      }
    }
    </script>
    

    这将生成以下输出:

    ![图 5.23:正在加载 Todos 和显示标题]

    ![图片 B15218_05_23.jpg]

    ![图 5.23:正在加载 Todos 和显示标题]

    注意

    该数据集的链接,已作为 JSON API 公开,可在 jsonplaceholder.typicode.com/ 找到。

  8. 现在,让我们创建一个 TodoEntry 组件,我们将在这里实现大部分关于待办事项的逻辑。对于组件来说,一个好的做法是让 props 非常具体于组件的角色。在这种情况下,我们将处理的 todo 对象的属性是 idtitlecompleted,因此这些应该是我们的 TodoEntry 组件接收的 props。我们不会将 TodoEntry 制作成函数式组件,因为我们还需要一个组件实例来创建 HTTP 请求:

    <template>
      <div>
        <label>{{ title }}</label>
        <input
          type="checkbox"
          :checked="completed"
        />
      </div>
    </template>
    <script>
    export default {
      props: {
        id: {
          type: Number,
          required: true
        },
        title: {
          type: String,
          required: true
        },
        completed: {
          type: Boolean,
          default: false
        }
      }
    }
    </script>
    
  9. 更新 src/App.vue 以使其消费 TodoEntry 如下(确保绑定 idtitlecompleted):

    <template>
      <div id="app">
        <TodoList :todos="todos">
          <template #todo="{ todo }">
            <TodoEntry
            :id="todo.id"
            :title="todo.title"
            :completed="todo.completed"
          />
        </template>
      </TodoList>
     </div>
    </template>
    <script>
    // other imports
    import TodoEntry from './components/TodoEntry.vue'
    export default {
      components: {
      // other components
        TodoEntry
     },
     // other component methods
    }
    </script>
    

    我们将得到以下输出:

    图 5.24:TodoEntry 渲染从 API 获取的数据

    图 5.24:TodoEntry 渲染从 API 获取的数据

  10. 现在,我们需要添加切换 src/components/TodoEntry.vue 的功能。我们将监听 input 变更事件;在变更时,我们将读取新值,并向 /todos/{todoId} 发送一个包含 completed 设置为新值的 PATCH 请求。我们还将发出一个 Vue.js 中的 completedChange 事件,以便 App 组件可以更新内存中的数据:

    <template>
      <!-- rest of the template -->
        <input       type="checkbox"
          :checked="completed"
          @change="toggleCompletion()"
        />
      <!-- rest of the template -->
    </template>
    <script>
    export default {
      // other component properties
      methods: {
        toggleCompletion() {
          const newCompleted = !this.completed 
          this.$emit('completeChange', newCompleted)
          this.axios.patch(
            `${this.baseUrl}/todos/${this.id}`,
            { completed: newCompleted }
          )
        }
      }
    }
    </script>
    
  11. App.vue 中,当触发 completeChange 事件时,我们需要更新相关的 todo。由于 completeChange 事件不包含我们 todo 的 ID,因此在我们设置 handleCompleteChange 函数以便它监听 completeChange 事件时,我们需要从上下文中读取那个 ID:

    <template>
     <!-- rest of template -->
            <TodoEntry
              :id="todo.id"
              :title="todo.title"
              :completed="todo.completed"
              @completeChange="handleCompleteChange(todo.id, $event)"
            />
     <!-- rest of template -->
    </template>
    <script>
    // imports
    export default {
     // other component properties
      methods: {
        handleCompleteChange(id, newCompleted) {
          this.todos = this.todos.map(
            t => t.id === id
             ? { ...t, completed: newCompleted }
             : t
          )
        }
      }
    }
    </script>
    

    在这个阶段,我们应该看到以下输出:

    图 5.25:使用 JSON placeholder 数据的待办事项应用

图 5.25:使用 JSON placeholder 数据的待办事项应用

通过这样,我们已经学会了如何使用插件和可重用组件来构建一个消费 JSONPlaceholder 数据的 todo 应用。

6. 路由

活动 6.01:创建一个带有动态嵌套路由和布局的消息单页应用

解决方案:

完成以下步骤以完成活动:

注意

要访问此活动的代码文件,请参阅 packt.live/2ISxml7

  1. src/views/ 文件夹中创建一个新的 MessageEditor.vue 文件,作为主要组件,用于与用户交互以编写消息。我们使用 textarea 作为消息输入字段,并将 listener 方法 onChange 绑定到 DOM 事件 change 上,以捕获用户输入的任何消息更改。此外,我们还添加了 ref 以保持对渲染的 HTML textarea 元素的指针记录,以便在稍后阶段修改我们保存的消息。

    除了这个之外,我们还附加了另一个 listener 方法 onSendClick提交按钮click 事件上,以捕获用户发送消息的确认。onChangeonSendClick 的实际逻辑实现显示在 步骤 3 中。

  2. <template> 部分 应该看起来像以下这样:

    <template>
      <div>
        <textarea
          ref="textArea"
          @change="onChange($event)"
        >
        </textarea>
        <button @click="onSendClick()">Submit</button>
      </div>
    </template>
    
  3. script 中,除了之前的代码外,我们还将接收到一个 list,用于在提交新消息后更新,并将更新后的列表发送回父组件:

    <script>
    export default {
      props: {
        list: Array
      },
      data() {
        return {
          message: ''
        }
      },
      methods: {
        onChange(event) {
          this.message = event.target.value
        },
        onSendClick() {
          if (!this.message) return;
          this.list.push(this.message);
          this.$emit('list:update', this.list);
          this.message = ''
          this.$refs.textArea.value = ''
        }
      },
    }
    </script>
    
  4. 我们需要在 ./src/router/index.js 中的路由数组中定义一个父路由作为默认路由,其 path/namemessages

    {
        path: '/',
        name: 'messages',
        component: () => import(/* webpackChunkName: "messages" */       '../views/Messages.vue'),
    }
    

    然后在 children 属性下添加一个新的路由作为嵌套路由,称为 editor

    {
        path: '/',
        name: 'messages',
        component: () => import(/* webpackChunkName: "messages" */       '../views/Messages.vue'),
        children: [{
          path: 'editor',
          name: 'editor',
          component: () => import(/* webpackChunkName: "editor" */         '../views/MessageList.vue'),
          props: true,
        }]
    },
    
  5. 我们创建一个新的视图组件 MessageList.vue,使用 v-for 将消息列表渲染到 router-link 组件中:

    <template>
      <div>
        <h2> Message Feed </h2>
        <div v-for="(m, i) in list" :key="i" >
          <router-link :to="`/message/${i}`">
            {{ i }}
            </router-link>
        </div>
    </div>
    </template>
    <script>
    export default {
      props: {
        list: {
          type: Array,
          default: () => []
        }
      }
    }
    </script>
    
  6. 类似于 步骤 2,将 MessageList.vue 组件注册到 messages 路由的 children 路由数组中:

    {
        path: '/',
        name: 'messages',
        component: () => import(/* webpackChunkName: "messages" */       '../views/Messages.vue'),
        children: [{
          path: 'list',
          name: 'list',
          component: () => import(/* webpackChunkName: "list" */         '../views/MessageList.vue'),
          props: true,
        }, {
          path: 'editor',
          name: 'editor',
          component: () => import(/* webpackChunkName: "editor" */         '../views/MessageEditor.vue'),
          props: true,
        }]
      },
    
  7. 现在,我们的 messages 视图需要一个 UI。我们使用 router-link 定义 Messages.vue 视图,以允许在 editorlist 之间进行导航,并使用 router-view 组件来渲染嵌套视图:

    <template>
      <div>
        <router-link :to="{ name: 'list', params: { list       }}">List</router-link> |
        <router-link :to="{ name: 'editor', params: { list       }}">Editor</router-link>
        <router-view :list.sync="list"/>
      </div>
    </template>
    <script>
    

    当然,我们需要从 props 接收一个 messageslist

    <script>
    export default {
      props: {
        list: Array
      }
    </script>
    

    由于我们没有全局状态或适当的数据库,我们需要在 ./src/router/index.js 中模拟一个全局消息列表:

    const messages = []
    

    然后将它作为默认 props 传递给 messages 路由,如下所示:

    {
        path: '/',
        name: 'messages',
        /* ... */ 
        props: {
          list: messages
        },
    }
    
  8. 为了捕捉用户是否正在离开当前编辑视图,我们将在组件内的 beforeRouteLeave 导航守卫上添加一个 Hook,这将允许我们显示警告并根据用户的决定中止或继续。这是在 MessageEditor.vue 文件中完成的:

    beforeRouteLeave(to, from, next) {
          if (this.$refs.textArea.value !== '') {
            const ans = window.confirm(You have an unsaved message.           Are you sure you want to navigate away?');
            next(!!ans);
          }
          else {
            next();
          }
      }
    
  9. 创建 messageLayout.vue 很简单,包括标题文本、来自 propscontent 和一个 返回按钮

    <template>
      <div class="message">
        <h2>Message content:</h2>
        <main>
          <slot/>
        </main>
        <button @click="goBack">Back</button>
      </div>
    </template>
    

    goBack 逻辑应该是简单的:如果有保存的前一个路由,我们就使用 this.$routes.go(-1) 在导航栈中后退一步。否则,我们将使用 this.$router.push({ name: 'message'})messages 导航路由推送到栈中:

    <script>
    import MessageLayout from '../layouts/messageLayout.vue';
    export default {
      props: {
        content: {
          type: String,
          default: ''
        },
      },
      methods: {
        goBack() {
          if (this.$route.params.from) {
            this.$router.go(-1)
          }
          else {
            this.$router.push({
              name: 'messages'
            })
          }
        }
      }
    }
    </script>
    

    但然后我们仍然需要从跟踪中传递前一个路由,this.$route.params.from,应在路由注册时完成。

  10. 我们在 routes 中添加 message 路由配置,并使用 beforeEnter 组件守卫将 from 前一个导航路由保存并传递到视图的 params 中。

    此外,由于它是一个具有 message/:id 模式的动态路由,我们需要检索消息内容并将其映射到相关的 prop 上:

    {
        path: '/message/:id',
        name: 'message',
        component: () => import(/* webpackChunkName: "message" */       '../views/Message.vue'),
        props:true,
        beforeEnter(to, from, next) {
          if (to.params && to.params.id) {
            const id = to.params.id;
    
            if (messages && messages.length > 0 && id <           messages.length) {
              to.params.content = messages[id];
              }
          }
          to.params.from = from;
          next()
        },
      }
    
  11. 最后,为了从 Message.vueMessages.vue 中拆分 UI 布局,在 ./src/layouts 文件夹中,我们创建了一个 default.vue 布局和一个 messageLayout.vue 布局。

    正如我们在本章所学,在 App.vue 中,我们将使用根据布局变量渲染的组件包裹 router-view。当然,router-view 需要一个同步的 layout 属性,以便根据当前视图动态更改布局:

    <template>
      <div id="app">
        <component :is="layout">
          <router-view :layout.sync="layout"/>
        </component>
      </div>
    </template>
    <script>
    export default {
      data() {
        return {
          layout: () => import(/* webpackChunkName: "defaultLayout"         */ './layouts/default.vue')
        }
      }
    }
    </script>
    
  12. default.vue 中,我们只需为 messages 部分添加标题文本和一个 slot

    <template>
      <div class="default">
        <h1>Messages section</h1>
        <main>
          <slot/>
        </main>
      </div>
    </template>
    
  13. messageLayout.vue 中,我们将从 Message.vue 中提取标题文本和按钮逻辑:

    <template>
      <div class="message">
        <h2>Message content:</h2>
        <main>
          <slot/>
        </main>
        <button @click="goBack">Back</button>
      </div>
    </template>
    <script>
    export default {
      methods: {
        goBack() {
          if (this.$route.params.from) {
            this.$router.go(-1)
          }
          else {
            this.$router.push({
              name: 'messages'
            })
          }
        }
      }
    }
    </script>
    

    最后一步是确保在组件创建时触发 update:layout 事件以更新 Message.vueMessages.vue 的视图布局:

    import MessageLayout from '../layouts/messageLayout.vue';
    export default {
    /*...*/
      created() {
        this.$emit('update:layout', MessageLayout);
      }
    }
    

    Messages.vue 中,这将如下所示:

    <script>
    import DefaultLayout from '../layouts/default';
    export default {
      props: {
        list: Array
      },
      created() {
        this.$emit('update:layout', DefaultLayout);
      }
    }
    </script>
    
  14. 使用以下命令运行应用程序:

    yarn serve
    

    为了确保您已正确完成步骤,您需要访问每个路由并确保内容渲染与相应的图示一致。首先,确保 /list 视图渲染的消息列表如图 6.47 所示:

    ![图 6.47:Messages 应用中的 /list 视图 图片

    图 6.47:Messages 应用中的 /list 视图

  15. 接下来,确保 /editor 视图允许用户发送新消息,如图 6.48 所示:![图 6.48:Messages 应用中的 /editor 视图 图片

    图 6.48:Messages 应用中的 /editor 视图

  16. 接下来,确保通过访问 /message/0 路由来确保 /message/:id 动态路由正常工作。你应该会看到类似于图 6.49 所示的消息内容:![图 6.49:Message 应用中的 /message/0 视图 图片

    图 6.49:Message 应用中的 /message/0 视图

  17. 确保当用户正在编写消息时,如果他们尝试在没有保存消息的情况下离开,将触发一个警告,如图 6.50 所示:![图 6.50:用户尝试在没有保存消息的情况下离开时的 /editor 视图 图片

图 6.50:用户尝试在没有保存消息的情况下离开时的 /editor 视图

注意

由于我们没有全局状态管理,我们的 messages 数据在刷新时不会保存。我们可以在探索应用程序时使用 localStorage 来帮助保存数据。

在此活动中,我们将本章涵盖的几个主题组合在一起,包括设置视图、使用模板和动态路由,以及使用 Hooks 在用户在未保存内容的情况下离开前提示确认警告。这些工具可用于许多常见的 SPA 用例,并将有助于您未来的项目。

7. 动画和过渡

活动七.01:使用过渡和 GSAP 构建 Messages 应用

解决方案:

执行以下步骤以完成活动:

注意

要访问此活动的代码文件,请访问 packt.live/399tZ3Y

  1. 我们将重用第六章中创建的 路由 代码,以便为 Message 应用设置所有路由。

    src/views/MessageEditor.vuetemplate 部分将如下所示:

    <template>
      <div>
        <textarea
          ref="textArea"
          @change="onChange($event)"
        >
        </textarea>
        <button @click="onSendClick()">Submit</button>
      </div>
    </template>
    
  2. 接下来,src/views/MessageEditor.vuescript 部分应包含点击和离开路由的逻辑:

    <script>
    export default {
      props: {
        list: Array
      },
      data() {
        return {
          message: ''
        }
      },
      methods: {
        onChange(event) {
          this.message = event.target.value
        },
        onSendClick() {
          if (!this.message) return;
          this.list.push(this.message);
          this.$emit('list:update', this.list);
          this.message = ''
          this.$refs.textArea.value = ''
        }
      },
      beforeRouteLeave(to, from, next) {
          if (this.$refs.textArea.value !== '') {
            const ans = window.confirm('You have unsaved message.           Are you sure to navigate away?');
            next(ans);
          }
          else {
            next();
          }
      }
    }
    </script>
    
  3. 接下来,我们需要 MessageList.vuetemplate 代码。代码如下:

    <template>
      <div>
        <h2> Message Feed </h2>
        <transition-group
          @appear="enter"
          tag="div"
          move-class="flip"
          :css="false"
        >
          <div v-for="(m, i) in list" :key="m">
            <router-link :to="`/message/${i}`">
              {{ i }}
            </router-link>
          </div>
        </transition-group>
      </div>
    </template>
    
  4. 接下来,我们需要在 MessageList.vue 文件中添加一个 script 部分。要添加 script 部分,代码如下:

    <script>
    import { TimelineMax } from 'gsap';
    export default {
      props: {
        list: {
          type: Array,
          default: () => []
        }
      },
      methods: {
        enter(el, done) {
          const tl = new TimelineMax({
            onComplete: done,
            stagger: 1.2,
            duration: 2,
          });
          tl.fromTo(el, {opacity: 0}, {opacity: 1})
            .to(el, {rotation: -270, duration: 1, ease: "bounce"})
            .to(el, {rotation: -360})
            .to(el, {rotation: -180, opacity: 0})
            .to(el, {rotation: 0, opacity: 1});
        }
      }
    }
    </script>
    
  5. 我们还将在 MessageList.vue 中创建一个 style 部分,并使用以下代码定义 .flip-move 类:

    <style>
    .flip-move {
      transition: transform 1s;
    }
    </style>
    
  6. Message.vue 应该在 p 元素内包含渲染的内容。我们还将定义 content 属性并发出更新信号:

    <template>
      <div>
        <p>{{content}}</p>
        <router-view/>
      </div>
    </template>
    <script>
    import MessageLayout from '../layouts/messageLayout.vue';
    export default {
      props: {
        content: {
          type: String,
          default: ''
        }
      },
      created() {
        this.$emit('update:layout', MessageLayout);
      }
    }
    </script>
    
  7. 确保你的 src/router/index.js 文件与在 第六章,路由 中创建的相同,创建具有动态嵌套路由和布局的消息 SPA,该章节可在 packt.live/2ISxml7 找到:

    import Vue from 'vue'
    import VueRouter from 'vue-router'
    import Messages from '@/views/Messages.vue'
    Vue.use(VueRouter)
    const messages = []
    export const routes = [
      {
        path: '/',
        name: 'messages',
        component: () => import(/* webpackChunkName: "messages" */       '../views/Messages.vue'),
        props: {
          list: messages
        },
        children: [{
          path: 'list',
          name: 'list',
          component: () => import(/* webpackChunkName: "list" */         '../views/MessageList.vue'),
          props: true,
        }, {
          path: 'editor',
          name: 'editor',
          component: () => import(/* webpackChunkName: "list" */         '../views/MessageEditor.vue'),
          props: true,
        }]
      },
      {
        path: '/message/:id',
        name: 'message',
        component: () => import(/* webpackChunkName: "message" */       '../views/Message.vue'),
        props:true,
        beforeEnter(to, from, next) {
          if (to.params && to.params.id) {
            const id = to.params.id;
    
            if (messages && messages.length > 0 && id <           messages.length) {
              to.params.content = messages[id];
            }
          }
          to.params.from = from;
          next()
        },
      }
    ]
    const router = new VueRouter({
      mode: 'history',
      base: process.env.BASE_URL,
      routes
    })
    export default router
    
  8. 现在,我们将使用具有两个属性 name="fade"mode="out-in"transition 组件包裹 App.vue 中的 <template> 部分的 router-view

    <component :is="layout">
          <transition name="fade" mode="out-in">
            <router-view :layout.sync="layout"/>
          </transition>
        </component>
    Create CSS stylings for the related classes, inside App.vue:
    <style>
    .fade-enter-active, .fade-leave-active {
      transition: opacity 2s, transform 3s;
    }
    .fade-enter, .fade-leave-to {
      opacity: 0;
      transform: translateX(-20%);
    }
    </style>
    
  9. src/views/Messages.vue 中,我们将使用 transition 组件包裹 router-view。这次,我们将使用一个自定义的 enter-active-class 过渡类属性,以及 fade 名称:

    <transition name="fade" enter-active-class="zoom-in">
      <router-view :list.sync="list"/>
    </transition>
    
  10. src/views/Messages.vuestyle 部分中添加 zoom-infade-enter 动画效果:

    <style>
    .zoom-in {
      animation-duration: 0.3s;
      animation-fill-mode: both;
      animation-name: zoom;
    }
    .fade-enter-active {
      transition: opacity 2s, transform 3s;
    }
    .fade-enter {
      opacity: 0;
      transform: translateX(-20%);
    }
    </style>
    
  11. src/views/MessageList.vue 中,将 transition-group 作为消息链接列表的包装器,并使用 JavaScript 钩子进行程序化动画。但我们必须指定在页面初始渲染时的过渡,因为列表应该在出现时进行动画。我们将添加 appear 属性并将 enter 绑定到 appear,以及添加 move-class 翻转(将在 style 部分稍后创建的动画):

    <transition-group
      appear
      @appear="enter"
      tag="div"
      move-class="flip"
      :css="false"
    >
      <div v-for="(m, i) in list" :key="m">
        <router-link :to="`/message/${i}`">
          {{ i }}
        </router-link>
      </div>
    </transition-group>
    
  12. 将 GSAP 作为依赖项添加,并在 src/views/MessageList.vue 中的 appear 过渡事件处理程序(钩子)上实现弹跳进入效果:

    <script>
    import { TimelineMax } from 'gsap';
    export default {
      props: {
        list: {
          type: Array,
          default: () => []
        }
      },
      methods: {
        enter(el, done) {
          const tl = new TimelineMax({
            onComplete: done,
            stagger: 1.2,
            duration: 2,
          });
          tl.fromTo(el, {opacity: 0}, {opacity: 1})
            .to(el, {rotation: -270, duration: 1, ease: "bounce"})
            .to(el, {rotation: -360})
            .to(el, {rotation: -180, opacity: 0})
            .to(el, {rotation: 0, opacity: 1});
        }
      }
    }
    </script>
    
  13. 接下来,我们需要创建我们在 HTML 中定义的 flip-move 类。我们将通过添加一个包含我们的新 flip-move 类的 style 部分来完成此操作:

    <style>
    .flip-move {
      transition: transform 1s;
    }
    </style>
    
  14. 使用 yarn serve 命令运行应用程序,你应该在浏览器中的 localhost:8080 看到以下内容:图 7.19:从消息列表视图导航到编辑视图时淡出

图 7.19:从消息列表视图导航到编辑视图时淡出

现在,你应该在从消息列表视图导航到编辑视图时看到淡出效果,如 图 7.19 所示,以及从编辑视图导航到列表视图时的淡出效果,如 图 7.20 所示:

图 7.20:从编辑视图导航到消息列表视图时淡出

图 7.20:从编辑视图导航到消息列表视图时淡出

当消息在动态中时,你应该看到翻转动作期间的弹跳效果,如 图 7.21 所示:

图 7.21:在消息列表视图中显示消息动态时的弹跳效果

图 7.21:在消息列表视图中显示消息动态时的弹跳效果

最后,当点击列表中的特定消息时,它应该渲染如 图 7.22 所示的内容:

图 7.22:单个消息视图

图 7.22:单个消息视图

在这个活动中,我们将几个不同的动画组合起来,并与路由结合以创建自定义页面过渡。我们使用了多种不同的动画类型来展示动画可以提供的多种可能性。

8. Vue.js 状态管理的状态

活动第 8.01 节:将邮箱和电话号码添加到个人资料卡片生成器

解决方案

执行以下步骤以完成活动:

注意

要访问此活动的代码文件,请参阅packt.live/3m1swQE

  1. 我们可以从向src/components/AppProfileForm添加一个新的email输入字段和标签开始,用于Email字段:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2" for="email">Email
          </label>
          <input
            id="email"
            type="email"
            name="email"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    
  2. 然后,我们可以在AppProfileForm中添加一个新的phone输入字段(类型为tel)和标签,用于Phone Number字段:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2" for="phone">Phone         Number</label>
          <input
            id="phone"
            type="tel"
            name="phone"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    

    新字段如下所示:

    图 8.35:包含新邮箱和电话号码字段的示例应用

    图 8.35:包含新邮箱和电话号码字段的示例应用

  3. 然后,我们可以在src/store.js中的初始状态和变异中添加emailphone字段,以便organization在初始化时设置,在profileUpdate时设置,并在profileClear时重置:

    // imports & Vuex setup
    export default new Vuex.Store({
      state: {
        formData: {
          // rest of formData fields
          email: '',
          phone: '',
        }
      },
      mutations: {
        profileUpdate(state, payload) {
          state.formData = {
            // rest of formData fields
            email: payload.email || '',
            phone: payload.phone || '',
          }
        },
        profileClear(state) {
          state.formData = {
            // rest of formData fields
            email: '',
            phone: '',
          }
        }
      }
    })
    
  4. 我们需要在src/components/AppProfileForm.vue组件的本地状态中使用v-model跟踪email,并在data()函数中初始化它:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2" for="email">Email
          </label>
          <input
            id="email"
            type="email"
            name="email"
            v-model="email"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    <script>
    export default {
      // rest of component
      data() {
        return {
          // other data properties
          email: ''
        }
      }
    }
    </script>
    
  5. 我们需要在src/components/AppProfileForm.vue组件的本地状态中使用v-model跟踪phone,并在data()函数中初始化它:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col mt-2">
          <label class="flex text-gray-800 mb-2" for="phone">Phone         Number</label>
          <input
            id="phone"
            type="tel"
            name="phone"
            v-model="phone"
            class="border-2 border-solid border-blue-200 rounded           px-2 py-1"
          />
        </div>
      <!-- rest of template -->
    </template>
    <script>
    export default {
      // rest of component
      data() {
        return {
          // other data properties
          phone: ''
        }
      }
    }
    </script>
    
  6. 为了让变异的负载包含emailphone,我们需要将其添加到$store.commit('profileUpdate')的负载中。我们还想在组件触发profileClear变异时在表单上重置它:

    <script>
    export default {
      // rest of component
      methods: {
        submitForm() {
          this.$store.commit('profileUpdate', {
            // rest of payload
            email: this.email,
            phone: this.phone
          })
        },
        resetProfileForm() {
          // other resets
          this.email = ''
          this.phone = ''
        }
      }
    }
    </script>
    
  7. 为了使email显示,我们需要在src/components/AppProfileDisplay.vue中使用条件段落(在没有设置邮箱时隐藏Email标签)来渲染它:

    <template>
      <!-- rest of template -->
        <p class="mt-2" v-if="formData.email">
          Email: {{ formData.email }}
        </p>
      <!-- rest of template -->
    </template>
    
  8. 为了使phone显示,我们需要在src/components/AppProfileDisplay.vue中使用条件 span(在没有设置电话时隐藏Phone Number标签)来渲染它:

    <template>
      <!-- rest of template -->
        <p class="mt-2" v-if="formData.phone">
          Phone Number: {{ formData.phone }}
        </p>
      <!-- rest of template -->
    </template>
    

    当表单填写并提交时,应用应如下所示:

    图 8.36:包含邮箱和电话号码字段的示例应用

图 8.36:包含邮箱和电话号码字段的示例应用

我们已经看到了如何向 Vuex 管理的应用中添加新字段。接下来,我们将看到如何决定是否将某些内容放入全局或局部状态。

9. 使用 Vuex – 状态、获取器、操作和变异

活动第 9.01 节:创建简单的购物车和价格计算器

解决方案

执行以下步骤以完成活动:

注意

要访问此活动的代码文件,请参阅packt.live/2KpvBvQ

  1. 使用 CLI 创建一个新的具有 Vuex 支持的 Vue 应用。

  2. 将产品和空cart添加到位于store/index.js的存储中。请注意,产品名称和价格是任意的:

      state: {
        products: [
          { name: "Widgets", price: 10 },
          { name: "Doodads", price: 8 },
          { name: "Roundtuits", price: 12 },
          { name: "Fluff", price: 4 },
          { name: "Goobers", price: 7 }
        ],
        cart: [
        ]
    
  3. 创建一个新的 Products 组件 (components/Products.vue),它遍历每个产品并包括每个产品的名称和价格。它还将包括添加或从购物车中删除项目的按钮:

        <h2>Products</h2>
        <table>
          <thead>
            <tr>
              <th>Name</th>
              <th>Price</th>
              <th>&nbsp;</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(product, idx) in products" :key="idx">
              <td>{{ product.name }}</td>
              <td>{{ product.price  }}</td>
              <td>
                <button @click="addToCart(product)">Add to Cart               </button> 
                <button @click="removeFromCart(product)">Remove from               Cart</button>
              </td>
            </tr>
          </tbody>
        </table>
    
  4. 为了在不添加 $store 前缀的情况下使用产品,包括 mapState 并在 Products 组件的 computed 属性中定义其使用:

    import { mapState } from 'vuex';
    export default {
      name: 'Products',
      computed: mapState(['products']),
    
  5. 接下来包括添加和从购物车中删除项目的函数。这只会调用存储中的 mutations:

      methods: {
        addToCart(product) {
          this.$store.commit('addToCart', product);
        },
        removeFromCart(product) {
          this.$store.commit('removeFromCart', product);
        }
      }
    
  6. store/index.js 文件中定义您的 mutations 来处理与购物车的工作。当向购物车添加新项目时,您首先需要查看该项目是否之前已添加,如果是,则简单地增加数量。当从购物车中删除项目时,如果数量达到 0,则应完全删除该项目:

      mutations: {
        addToCart(state, product) {
          let index = state.cart.findIndex(p => p.name ===         product.name);
          if(index !== -1) {
            state.cart[index].quantity++;
          } else {
            state.cart.push({ name: product.name, quantity: 1});
          }
        },
        removeFromCart(state, product) {
          let index = state.cart.findIndex(p => p.name ===         product.name);
          if(index !== -1) {
            state.cart[index].quantity--;
            if(state.cart[index].quantity === 0) state.cart.splice           (index, 1);
          }
        }
    
  7. 定义一个 Cart 组件 (components/Cart.vue),它遍历购物车并显示每个项目的数量:

        <h2>Cart</h2>
        <table>
          <thead>
            <tr>
              <th>Name</th>
              <th>Quantity</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(product, idx) in cart" :key="idx">
              <td>{{ product.name }}</td>
              <td>{{ product.quantity  }}</td>
            </tr>
          </tbody>
        </table>
    
  8. 与前面的组件一样,添加 mapState 并将购物车别名为:

    import { mapState } from 'vuex';
    export default {
      name: 'Cart',
      computed: mapState(['cart'])
    }
    
  9. 定义最终的组件 Checkout (components/Checkout.vue),并显示一个名为 cartTotal 的属性。这将通过在存储中创建的 getter 来定义:

    <h2>Checkout</h2>
    Your total is ${{ cartTotal }}.
    
  10. 在脚本块中映射 getter:

    import { mapGetters } from 'vuex';
    export default {
      name: 'Cart',
      computed: mapGetters(['cartTotal']),
    
  11. 添加一个结账按钮。它应该只在存在总计时显示,并运行名为 checkout 的方法:

        <button v-show="cartTotal > 0" @click="checkout">Checkout       </button>
    
  12. 定义 checkout 以简单地提醒用户:

      methods: {
        checkout() {
            alert('Checkout process!');
        }
      }
    
  13. 在 Vuex 存储中,定义 cartTotal 的 getter。它需要遍历购物车并通过将价格乘以数量来确定总和:

      getters: {
        cartTotal(state) {
          return state.cart.reduce((total, item) => {
            let product = state.products.find(p => p.name ===           item.name);
            return total + (product.price * item.quantity);
          }, 0);
        }
      },
    
  14. 在主 App.vue 组件中使用所有三个组件:

    <template>
      <div id="app">
        <Products />
        <Cart />
        <Checkout />
      </div>
    </template>
    <script>
    import Products from './components/Products.vue'
    import Cart from './components/Cart.vue'
    import Checkout from './components/Checkout.vue'
    export default {
      name: 'app',
      components: {
        Products, Cart, Checkout
      }
    }
    </script>
    

    按照之前的方式启动您的应用程序 (npm run serve) 并在浏览器中打开 URL。最初,您应该看到以下输出,显示一个空购物车:

    图 9.9:购物车的初始显示

    ](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_09_09.jpg)

图 9.9:购物车的初始显示

当您添加和删除项目时,您会看到购物车和总计实时更新:

图 9.10:添加了多个数量项目的购物车

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_09_10.jpg)

图 9.10:添加了多个数量项目的购物车

前面的图显示了产品和它们的价格,以及包含多个不同产品数量的购物车和最终的结账金额。您现在已经构建了一个完整的、尽管简单的、由 Vue 和 Vuex 驱动的电子商务购物车产品。

10. 使用 Vuex – 获取远程数据

活动 10.01:使用 Axios 和 Vuex 进行身份验证

解决方案:

执行以下步骤以完成此活动。

注意

要访问此活动的代码文件,请参阅 packt.live/3kVox6M

  1. 使用 CLI 来构建新的应用程序,并确保启用 Vuex 和 Vue Router。完成后,使用 npm 安装 Axios。现在您已经构建了应用程序的框架,让我们开始构建它。首先,打开 App.vue,这是应用程序的核心组件,并修改它,使整个模板成为视图:

    <template>
      <div id="app">
        <router-view/>
      </div>
    </template>
    
  2. 默认情况下,CLI 会生成两个 viewsHomeAbout。我们将把 About 改成显示猫的视图,但现在打开 Home.vue 并添加登录表单。使用按钮运行一个方法来执行(假的)登录:

    <template>
      <div>
        <h2>Login</h2>
        <form>
          <div>
            <label for="username">Username: </label>
            <input type="text" id="username" v-model="username"           required>
          </div>
          <div>
            <label for="password">Password: </label>
            <input type="password" id="password" v-model="password"           required>
          </div>
          <div>
            <input type="submit" @click.prevent="login" value=          "Log In">
          </div>
        </form>
      </div>
    </template>
    
  3. 添加登录表单的 data 和登录按钮的 handler。这将触发对存储的 dispatch。在成功的登录(它总是会成功)后,使用 $router.replace 来导航到下一页。这样做是为了代替 $router.go,这样用户就不能通过点击后退按钮返回到登录表单:

    <script>
    export default {
      name: 'home',
      data() {
        return {
          username:'',
          password:''
        }
      },
      methods: {
        async login() {
          let response = await this.$store.dispatch('login',
          { username:this.username,
            password:this.password
          });
          if(response) {
            this.$router.replace('cats');
          } else {
            // handle a bad login here..
          }
        }
      }
    }
    </script>
    
  4. 现在让我们在 views/Cats.vue 中构建 Cats 组件。这个组件将简单地遍历存储中的猫,并对存储进行调用以加载它们:

    <template>
      <div>
        <h2>Cats</h2>
        <ul>
        <li v-for="(cat,idx) in cats" :key="idx">
          {{cat.name}} is {{cat.gender}}
        </li>
        </ul>  
      </div>
    </template>
    <script>
    import { mapState } from 'vuex';
    export default {
      created() {
        this.$store.dispatch('loadCats');
      },
      computed: {
        ...mapState(["cats"])
      }
    }
    </script>
    
  5. 现在通过编辑 store/index.js 来构建 Vuex 存储。首先导入 Vuex 并定义两个端点的常量。记住,我们在这里是模拟一个真实的 API,所以端点只是返回静态的 JSON:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    import axios from 'axios';
    const LOGIN_URL = 'https://api.jsonbin.io/b/  5debc045bc5ffd04009563cd';
    const CATS_URL = 'https://api.jsonbin.io/b/  5debc16dcb4ac6042075d594';
    
  6. 存储需要保留两样东西:认证 tokencats。设置 state 并为它们定义 mutations

    export default new Vuex.Store({
      state: {
        token:'',
        cats:[]
      },
      mutations: {
        setCats(state, cats) {
          state.cats = cats;
        },
        setToken(state, t) {
          state.token = t;
        }
      },
    
  7. 现在添加 actions。登录 action 将结果存储为 token,而 cats actiontoken 作为 authorization 标头传递:

      actions: {
        loadCats(context) {
          axios.get(CATS_URL,
            {
              headers: {
                'Authorization': 'bearer '+context.state.token
              }
            })
          .then(res => {
            context.commit('setCats', res.data);
          })
          .catch(error => {
            console.error(error);
          });
        },
        async login(context, credentials) {
          return axios.get(LOGIN_URL, {
            params:{
              username: credentials.username,
              password: credentials.password
            }
          })
          .then(res => {
            context.commit('setToken', res.data.token);
            return true;
          })
          .catch(error => {
            console.error(error);
          });  
        }
      }
    })
    
  8. 应用程序的最后一部分是路由器,它有一个相当有趣的特点。想想 cats 页面。如果用户首先访问该页面会发生什么?没有 token,对端点的调用将无法返回有效数据。(再次,在一个真实的服务器上是这样。)幸运的是,Vue Router 提供了一种非常简单的方式来处理这种情况——路由守卫。在 cats 路由中利用 beforeEnter 来处理这个调用。编辑你的 router/index.js 文件,使其看起来像以下代码:

    import Vue from 'vue'
    import VueRouter from 'vue-router'
    import Home from '../views/Home.vue'
    import store from '../store';
    Vue.use(VueRouter)
    const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/cats',
        name: 'cats',
        component: () => import(/* webpackChunkName: "cats" */ '../views/Cats.vue'),
        beforeEnter: (to, from, next) => {
          if(!store.state.token) {
            next('/');
          }
          next();
        }
      }
    ]
    const router = new VueRouter({
      mode: 'history',
      base: process.env.BASE_URL,
      routes
    })
    export default router
    
  9. 使用 npm run serve 启动应用程序,将 URL 复制到浏览器中,你应该会看到以下输出:![图 10.8:初始登录屏幕 img/B15218_10_08.jpg

图 10.8:初始登录屏幕

登录后,你会看到以下截图所示的数据显示:

![图 10.9:登录成功后成功显示数据img/B15218_10_09.jpg

图 10.9:登录成功后成功显示数据

在这个活动中,你看到了使用 Vuex 和 Axios 时认证系统会是什么样子。虽然后端是假的,但这里使用的代码可以很容易地连接到一个真实的认证系统。

11. 使用 Vuex – 组织更大的存储

活动 11.01:简化 Vuex 存储

解决方案:

执行以下步骤来完成活动。

注意

要访问此活动的初始代码文件,请访问 packt.live/3kaqBHH

  1. 首先创建一个新文件,src/store/state.js,它将存储除了 cat 对象之外的所有状态值:

    export default {
        name:'Lindy',
        job:'tank',
        favoriteColor:'blue',
        favoriteAnimal:'cat'
    }
    
  2. 创建一个新文件,src/store/getters.js,并将 desiredPet 的 getter 移入其中:

    export default {
        desiredPet(state) {
            return state.favoriteColor + ' ' + state.favoriteAnimal;
        }
    }
    
  3. 接下来,创建 src/store/mutations.js 并复制与猫名称无关的 mutations

    export default {
      setName(state, name) {
        state.name = name;
      },
      setJob(state, job) {
        state.job = job;
      },
      setFavoriteColor(state, color) {
        state.color = color;
      },
      setFavoriteAnimal(state, animal) {
        state.animal = animal;
      }
    }
    
  4. 更新存储文件 (src/store/index.js) 以导入新文件:

    import state from './state.js';
    import getters from './getters.js';
    import mutations from './mutations.js';
    
  5. 编辑现有的 statemutationsgetters 块以使用包含的值:

    export default new Vuex.Store({
      state,
      getters, 
      mutations, 
    
  6. 现在将猫相关值移动到存储的 modules 块中。创建一个 stategettersmutations 块,并将所有值移动过来,更新它们以引用状态值,而不是 state.cat

        cat: {
            state: {
              name:'Cracker',
              gender:'male',
              job:'annoyer'  
            },
            getters: {
              petDescription(state) {
                return state.name + ' is a ' + state.gender + 
                ' ' + state.job +  ' cat.';
              }
            },
            mutations: {
              setCatName(state, name) {
                state.name = name;
              },
              setCatGender(state, gender) {
                state.gender = gender;
            },
              setCatJob(state, job) {
                state.job = job;
              }
            }
        }
    
  7. 运行应用程序并确认 App.vue 组件继续按预期工作。

    您的输出将如下所示:

    图 11.4:活动的最终输出

图 11.4:活动的最终输出

现在,Vuex 存储已被修改得更加易于接近、编辑和未来调试。要访问此活动的解决方案,请访问 packt.live/3l4Lg0x

12. 单元测试

活动 12.01:添加带有测试的简单标题搜索页面

解决方案:

执行以下步骤来完成活动:

注意

要访问此活动的代码文件,请参阅 packt.live/2UVF28c

  1. src/components/SearchForm.vue 的新文件中创建一个带有输入和按钮的搜索表单:

    <template>
      <form class="flex flex-row m-auto mb-10">
        <input
          placholder="Search"
          class="bg-white focus:outline-none focus:shadow-outline         border
          border-gray-300 rounded py-2 px-4 flex
          appearance-none leading-normal"
          type="text"
        />
        <button
          type="submit"
          class="flex bg-blue-500 hover:bg-blue-700
          text-white font-semibold font-sm hover:text-white         py-2 px-4 border
          border-blue-500 hover:border-transparent rounded"
        >
          Search
        </button>
      </form>
    </template>
    
  2. 现在,我们将通过导入、注册并在 src/App.vue 中渲染来使表单显示:

    <template>
      <!-- rest of template -->
        <div class="flex flex-col">
          <SearchForm />
          <!-- rest of template -->
        </div>
      <!-- rest of template -->
    </template>
    
  3. 我们现在准备好为搜索表单添加快照测试。在 __tests__/SearchForm.test.js 中,我们应该添加 SearchForm should match expected HTML

    import {render} from '@testing-library/vue'
    import SearchForm from '../src/components/SearchForm.vue'
    test('SearchForm should match expected HTML', () => {
      const {html} = render(SearchForm)
      expect(html()).toMatchSnapshot()
    })
    
  4. 我们希望使用 v-model 跟踪搜索表单输入的内容,以双向绑定 searchTerm 实例变量和输入内容:

    <template>
      <!-- rest of template -->
        <input
          v-model="searchTerm"
          placholder="Search"
          class="bg-white focus:outline-none focus:shadow-outline         border
          border-gray-300 rounded py-2 px-4 flex
          appearance-none leading-normal"
          type="text"
        />
      <!-- rest of template -->
    </template>
    <script>
    export default {
      data() {
        return {
          searchTerm: ''
        }
      }
    }
    </script>
    
  5. 当提交搜索表单时,我们需要使用 this.$router.push() 更新 URL 中的正确参数。这可以通过 q 查询参数存储搜索来完成。

    <template>
      <form
        @submit="onSubmit()"
        class="flex flex-row m-auto mb-10"
      >
      <!-- rest of template -->
      </form>
    </template>
    <script>
    export default {
      // other properties
      methods: {
        onSubmit() {
          this.$router.push({
            path: '/'
            query: {
              q: this.searchTerm
            }
          })
        }
      }
    }
    </script>
    
  6. 我们希望将搜索表单中的 q 查询参数的状态反映在搜索表单输入中。从 this.$route.query 中读取 q 并将其设置为 SearchForm 组件状态中 searchTerm 数据字段的初始值:

    <script>
    export default {
      data() {
        return {
          searchTerm: this.$route.query.q || ''
        }
      },
      // other properties
    }
    </script>
    
  7. 接下来,我们将想要过滤传递给主页上 PostList 的帖子。我们将使用 this.$route.query.q 在一个计算属性中过滤按标题排序的帖子。这个新的计算属性将随后用于替代 src/App.vue 中的 posts

    <template>
      <!-- rest of template -->
          <router-view
            :posts="relevantPosts"
          />
      <!-- rest of template -->
    </template>
    <script>
    export default {
      // other properties
      computed: {
        relevantPosts() {
          const { q } = this.$route.query
          if (!q) {
            return this.posts
          }
          return this.posts.filter(
            p => p.title.toLowerCase().includes(q.toLowerCase())
          )
        }
      }
    }
    </script>
    
  8. 接下来,我们应该添加一个测试,更改搜索查询参数并检查应用程序显示正确的结果。为此,我们可以导入 src/App.vuesrc/store.jssrc/router.js,并使用存储和路由渲染应用程序。然后,我们可以通过使用字段的占位符为 Search 来更新搜索字段的内容。最后,我们可以通过点击具有 test idSearch 的元素来提交表单(这是搜索按钮):

    // imports and other tests
    test('SearchForm filter by keyword on submission',   async () => {
      const {getByPlaceholderText, getByText, queryByText} =     render(App, {
        router,
        store
      })
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).    toBeTruthy()
      await fireEvent.update(getByPlaceholderText('Search'), 'react')
      await fireEvent.click(getByText('Search'))
      expect(queryByText('Vue.js for React developers')).    toBeTruthy()
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeFalsy()
    })
    

    我们现在处于一个通过测试的状态。以下截图显示了这一点:

    图 12.29:路由测试通过

图 12.29:路由测试通过

我们还得到了一个能够按搜索词过滤的应用程序,如下所示:

![图 12.30:搜索“react”过滤与该搜索词相关的帖子]

![img/B15218_12_30.jpg]

图 12.30:搜索“react”过滤与该搜索词相关的帖子

我们已经看到了如何创建和测试具有多个页面、Vuex 和一系列组件的 Vue.js 应用程序。

13. 端到端测试

活动第 13.01 节:添加设置用户电子邮件和测试的功能

解决方案:

执行以下步骤以完成活动:

注意

要访问此活动的代码文件,请参阅packt.live/2IZP4To

  1. 为了跟踪电子邮件,我们将在data()中将它设置为一个响应式状态,并在页面上添加一个电子邮件类型输入,它将使用v-modelemail双向绑定。我们还添加了一个标签和相应的标记。请注意,我们将在电子邮件输入上设置一个data-test-id属性,设置为"email-input"

    <template>
      <div id="app" class="p-10">
        <div class="flex flex-col">
          <!-- rest of template -->
          <div class="flex flex-col mx-auto mb-4">
            <label
              class="flex text-gray-700 text-sm font-bold mb-2"
              for="email"
            >
              Enter your email:
            </label>
            <input
              v-model="email"
              id="email"
              type="email"
              data-test-id="email-input"
              class="flex shadow appearance-none border             rounded py-2 px-3 text-gray-700 leading-tight             focus:outline-none focus:shadow-outline"
              required
            />
          </div>
          <!-- rest of template -->
    </template>
    <script>
    // imports
    export default {
      data() {
        return {
          email: '',
          // other data properties
        }
      },
      // other component properties
    }
    </script>
    
  2. 我们现在将添加一个beforeEach钩子来设置 Cypress 模拟服务器并模拟GET评论(列表)请求。评论列表请求应该被别名为getComments

    describe('Email input', () => {
      beforeEach(() => {
        cy.server()
        cy.route('GET', '**/comments', []).as('getComments')
      })
    })
    
  3. 我们将添加第一个测试,检查将文本输入到电子邮件输入框是否正确工作。我们将进入应用程序,输入电子邮件,并检查我们输入的内容现在是否是输入值:

    describe('Email input', () => {
      // setup
      it('email input should work', () => {
        cy.visit('/')
        cy.get('[data-test-id="email-input"]')
          .type('hugo@example.tld')
          .should('have.value', 'hugo@example.tld')
      })
    })
    

    当使用 Cypress UI 运行时,我们得到以下通过测试:

    ![图 13.24:Cypress 运行“enter-email”测试,带有电子邮件输入测试]

    ![img/B15218_13_24.jpg]

    图 13.24:Cypress 运行“enter-email”测试,带有电子邮件输入测试

  4. email属性的存在是添加评论的先决条件,因此当email为空时(!email),我们将禁用添加新评论按钮。我们将绑定到disabled属性并根据email字段是否填充切换一些类:

    <template>
      <div id="app" class="p-10">
        <div class="flex flex-col">
          <!-- rest of template -->
          <button
            @click="showEditor = !showEditor"
            class="flex mx-auto bg-blue-500 hover:bg-blue-700           text-white font-bold py-2 px-4 rounded"
            data-test-id="new-comment-button"
            :disabled="!email"
            :class="{ 'opacity-50 cursor-not-allowed' : !email }"
          >
            Add a New Comment
          </button>
          <!-- rest of template -->
        </div>
      </div>
    </template>
    
  5. 使用这个新的当电子邮件为空时禁用添加新评论按钮功能,我们应该添加一个新的端到端测试。我们将加载页面,并在初始加载时检查email输入框为空,并且新评论按钮被禁用。然后我们将在电子邮件输入框中输入电子邮件,并检查新评论按钮现在不是禁用的,这意味着它已被启用:

    describe('Email input', () => {
      // setup & other tests
      it('add comment button should be disabled when no email',     () => {
        cy.visit('/')
        cy.get('[data-test-id="email-input"]')
          .should('have.value', '')
        cy.get('[data-test-id="new-comment-button"]')
          .should('be.disabled')
        cy.get('[data-test-id="email-input"]')
          .type('hugo@example.tld')
    
        cy.get('[data-test-id="new-comment-button"]')
          .should('not.be.disabled')
      })
    })
    

    更新后的测试运行输出如下:

    ![图 13.25:Cypress 运行“enter-email”测试,带有禁用的]

    添加评论按钮测试

    ![img/B15218_13_25.jpg]

    图 13.25:Cypress 运行“enter-email”测试,带有禁用的添加评论按钮测试

  6. 现在我们有了捕获电子邮件的方法,我们应该在提交新评论的 POST 调用(即提交新评论时)将其传递给后端 API。为了做到这一点,我们应该修改methods.submitNewCommentemail被硬编码为evan@vuejs.org的位置:

    <script>
    // imports
    export default {
      // other component properties
      methods: {
        submitNewComment() {
          // rest of method
          fetch('https://jsonplaceholder.typicode.com/comments', {
            // other fetch options
            body: JSON.stringify({
              email: this.email,
              body: this.newComment
            })
          }).then(res => res.json())
          // rest of promise chain
        }
      }
    }
    </script>
    
  7. 现在我们正在使用用户输入的电子邮件,我们应该编写一个端到端测试来检查它是否被发送。我们将模拟 POST 请求,将其别名为 newComment,并返回一个任意值。然后我们可以访问页面,填写电子邮件输入,打开评论编辑器,填写内容,并提交。然后我们将等待 newComment 请求,并断言请求体中的内容和电子邮件与我们完成时相同:

    describe('Email input', () => {
      // setup & other tests
      it('when adding comment, it should be created with the     input email', () => {
        cy.route('POST', '**/comments', {
          body: 'My new comment',
          email: 'hugo@example.tld'
        }).as('newComment')
        cy.visit('/')
        cy.get('[data-test-id="email-input"]')
          .type('hugo@example.tld')
        cy.get('[data-test-id="new-comment-button"]')
          .should('not.be.disabled')
          .click()
    
        cy.get('[data-test-id="new-comment-editor"]')
          .type('My new comment')
    
        cy.get('[data-test-id="new-comment-submit"]')
          .should('not.be.disabled')
          .click()
    
        cy.wait('@newComment')
          .its('request.body')
          .should('deep.equal', {
            body: 'My new comment',
            email: 'hugo@example.tld'
          })
      })
    })
    

    当使用 Cypress UI 运行时,我们得到以下测试运行输出:

    图 13.26:Cypress 运行 "enter-email" 测试,包含电子邮件输入测试

图 13.26:Cypress 运行 "enter-email" 测试,包含电子邮件输入测试

我们现在已经看到了如何有效地使用 Cypress 构建和测试(使用端到端测试)Vue.js 应用程序。

14. 将您的代码部署到网络

活动 14.01:将 GitLab CI/CD 添加到图书搜索应用程序并部署到 Amazon S3 和 CloudFront

解决方案

执行以下步骤以完成活动:

注意

要访问此活动的代码文件,请参阅 packt.live/36ZecBT

  1. 首先,我们希望在本地运行一个生产构建。我们可以使用用于构建所有 Vue CLI 项目的常规命令。我们还想检查相关的资产(JavaScript、CSS 和 HTML)是否正确生成。

    生产构建命令是 npm run build,如下截图所示:

    图 14.65:初始 book-search Vue CLI 项目的 npm run build 输出

    图 14.65:初始 book-search Vue CLI 项目的 npm run build 输出

    npm run build 命令构建一个包含以下内容的 dist 目录,如下截图所示。它包含 CSSJavaScriptHTML 资产,以及 sourcemaps.js.map 文件)和 favicon

    图 14.66:使用 tree 命令生成的 dist 文件夹的示例内容    在 Vue CLI 生产构建运行之后

    图 14.66:Vue CLI 生产构建运行后 dist 文件夹的示例内容(使用 tree 命令生成)

  2. 为了运行 GitLab CI/CD,我们需要一个 .gitlab-ci.yml 文件。我们将在 .gitlab-ci.yml 中添加一个作业,在 build 阶段运行 Node.js LTS Docker 容器中的包安装和生产构建。我们还将确保缓存生产构建的输出:

    build:
      image: node:lts
      stage: build
      script:
        - npm ci
        - npm run build
      cache:
        key: $CI_COMMIT_REF_SLUG
        paths:
          - dist
      artifacts:
        expire_in: 1 week
        paths:
          - dist
    

    一旦我们使用 git add .gitlab-ci.yml 并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的 build 作业:

    图 14.67:构建作业正在运行的 GitLab CI/CD 管道

    图 14.67:正在运行的 GitLab CI/CD 管道,构建作业正在运行

    以下截图显示了 GitLab CI/CD 管道,build 作业已成功完成:

    图 14.68:构建作业通过后的 GitLab CI/CD 管道

    图 14.68:GitLab CI/CD 管道,构建任务已通过

  3. 接下来,我们希望在 GitLab CI/CD 的 test 阶段添加一个代码质量任务(通过更新 .gitlab-ci.yml)。我们将该任务命名为 lint,它将运行依赖项的安装以及通过 Vue CLI 进行代码检查:

    # other jobs
    lint:
      image: node:lts
      stage: test
      script:
        - npm ci
        - npm run lint
    

    一旦我们使用 git add .gitlab-ci.yml 并提交和推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的 lint 任务:

    图 14.69:GitLab CI/CD 管道,lint 任务正在运行

    图 14.69:GitLab CI/CD 管道,lint 任务正在运行

    以下截图显示了 GitLab CI/CD 管道,lint 任务成功完成:

    图 14.70:GitLab CI/CD 管道,lint 任务已通过

    图 14.70:GitLab CI/CD 管道,lint 任务已通过

  4. 为了部署我们的应用程序,我们需要使用 S3 控制台创建一个启用公共访问的 vue-workshop-book-search S3 存储桶。

    S3 存储桶创建页面应如图下截图所示:

    图 14.71:S3 存储桶创建页面,输入     输入作为存储桶名称

    图 14.71:S3 存储桶创建页面,输入 vue-workshop-book-search 作为存储桶名称

    图 14.72 显示了 S3 存储桶创建页面,具有公共访问权限和免责声明信息:

    图 14.72:S3 存储桶创建页面,启用公共访问    并接受相关免责声明

    图 14.72:S3 存储桶创建页面,启用公共访问并接受相关免责声明

  5. 要通过网页访问 S3 存储桶内容,我们还需要将其配置为 Web 服务器。我们可以通过 S3 控制台配置 Web 服务器属性。

    应按以下方式配置,将索引和错误页面设置为 index.html

    图 14.73:S3 存储桶属性页面,已启用 Web 服务器并配置了索引和错误页面为 index.html

    图 14.73:S3 存储桶属性页面,已启用 Web 服务器并配置了索引和错误页面为 index.html

  6. 为了让 GitLab CI/CD 能够在 S3 上创建和更新文件,我们需要将相关的 AWS 密钥添加到我们的 GitLab 仓库 CI/CD 设置中。这些密钥可以在 AWS 管理控制台的 用户名 下拉菜单 | 我的安全凭证 | 访问密钥(访问密钥 ID 和秘密访问密钥)| 创建新访问密钥(或选择一个密钥进行重用)。以下截图显示了 CI/CD 设置 页面:图 14.74:GitLab CI/CD 设置页面,变量部分已打开

    图 14.74:GitLab CI/CD 设置页面,变量部分已打开

    一旦点击变量部分的展开按钮,我们添加相关的 AWS 环境变量:AWS_ACCESS_KEY_IDAWS_DEFAULT_REGIONAWS_SECRET_ACCESS_KEY。然后变量部分将如下所示:

    图 14.75:带有所需 AWS 环境变量的 GitLab CI/CD 设置页面    添加了变量(值被屏蔽)

    图 14.75:带有所需 AWS 环境变量(值被屏蔽)的 GitLab CI/CD 设置页面

  7. 接下来,我们希望在 GitLab CI/CD 的deploy阶段添加一个deploy作业(通过更新.gitlab-ci.yml)。我们将作业命名为deploy;它需要下载awscli pip包(Python 包管理器),这意味着最有意义的 Docker 镜像就是python:latestdeploy作业将从缓存中加载构建的生产版本,使用pip安装awscli,并运行aws s3 sync <build_directory> s3://<s3-bucket-name> --acl=public-read

    # other jobs
    deploy:
      image: python:latest
      stage: deploy
      cache:
        key: $CI_COMMIT_REF_SLUG
        paths:
          - dist
      before_script:
        - pip install awscli
      script:
        - aws s3 sync ./dist s3://vue-workshop-book-search       --acl=public-read
    

    一旦我们使用git add .gitlab-ci.yml提交并推送更改,我们应该看到以下 GitLab CI/CD 管道运行,其中包含运行状态下的deploy作业:

    图 14.76:正在运行的 GitLab CI/CD 管道中的 deploy 作业

    图 14.76:正在运行的 GitLab CI/CD 管道中的 deploy 作业

    图 14.77显示了成功完成的deploy作业的 GitLab CI/CD 管道:

    图 14.77:通过通过的作业的 GitLab CI/CD 管道

    图 14.77:通过通过的deploy作业的 GitLab CI/CD 管道

    一旦管道完成,我们的应用程序应该可以通过 S3 网络端点访问,如下面的截图所示:

    图 14.78:通过 S3 网络端点 URL 访问的图书搜索

    图 14.78:通过 S3 网络端点 URL 访问的图书搜索

  8. 最后,我们将创建一个充当 S3 网络端点 CDN 的 CloudFront 分发。我们希望将origin设置为 S3 存储桶网络端点的源,并确保我们已启用将 HTTP 重定向到 HTTPS

  9. 图 14.79:带有源域名的 CloudFront 分发创建页面    域设置为 S3 存储桶

图 14.79:将源域名设置为 S3 存储桶的 CloudFront 分发创建页面

一旦 CloudFront 分发部署完成,我们的应用程序应该可以通过 CloudFront 分发的域名访问,如下面的截图所示:

图 14.80:通过 CloudFront 域名访问的图书搜索,显示 harry potter 查询的结果

图 14.80:通过 CloudFront 域名访问的图书搜索,显示 harry potter 查询的结果

通过使用 GitLab CI/CD,我们已将 CI/CD 添加到现有的 Vue CLI 项目中。然后我们使用 CloudFront 作为我们的 CDN 将其部署到 S3。

posted @ 2025-09-08 13:04  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报