Vue2-设计模式最佳实践-全-

Vue2 设计模式最佳实践(全)

原文:zh.annas-archive.org/md5/6E739FB94554764B9B3B763043E30DA8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Vue.js 是一个允许您创建高性能 Web 应用程序的 JavaScript 框架。它比竞争对手拥有更小的学习曲线,并且具有详细的文档,其中包含各种示例和用例。Vue.js 应用程序可以使用 Vue CLI 创建,也可以通过script标签将其包含在页面中,因此在没有构建系统的项目中使用非常简单。

与其他现代 Web 框架/库类似,Vue.js 是组件驱动的,这意味着您可以创建具有自己视图/数据逻辑的自包含单元。这使得我们的应用程序可以随着规模的扩大而扩展,因为任何更改都可以从彼此中封装起来。

在扩展应用程序时,状态管理是前端领域的热门话题,Vue.js 通过Vuex库解决了这个问题。这使我们能够在应用程序中定义操作并相应地行事,为我们提供可预测的状态,可用于构建用户界面和其他交互的基础。

本书探讨了所有这些内容,还提供了如何将这些原则应用于新旧 Vue.js 应用程序的示例。

这本书适合谁

这本书适合有兴趣使用 JavaScript 创建 Web 和移动应用程序的人。具有 HTML5/ES2015 的先验经验将有所帮助,因为本书中使用了现代 JavaScript 概念。您可能希望利用这些知识在个人项目或工作角色中创建交互式基于 Web 的体验。

本书涵盖的内容

第一章,Vue.js 原理和比较,向读者介绍了 Vue.js,并激励他们将其用作创建 Web 应用程序的框架。读者将了解 Vue.js 与其他流行框架(如 React 和 Angular)之间的区别。

第二章,Vue 项目的正确创建,探讨了创建 Vue 项目的适当方式。这包括使用 Webpack、Babel/TypeScript(用于 ES6)、.vue文件、linting 等。

第三章《使用 Vue 编写干净、精简的代码》深入研究了 Vue.js 实例和不同的保留属性,如datacomputedwatch,以及创建 getter 和 setter。本章特别考虑了何时使用计算属性和何时使用观察属性。它还概述了为什么模板应该特别精简以便更容易维护。

第四章《Vue.js 指令》介绍了开发人员在编写响应式 Vue 应用程序时可以访问一组强大指令,如v-forv-ifv-model等。本章详细介绍了每个指令,以及最佳实践和模式。此外,读者还将介绍使用事件绑定的简写语法。

第五章《使用 Vue.js 组件进行安全通信》更深入地研究了 Vue 组件,包括组件通信。我们将研究传递属性,以及验证属性类型和考虑不同类型的属性和数据流。

第六章《创建更好的 UI》着重介绍了 Vue 的常见 UI 模式。它再次介绍了如何使用v-model从用户那里获取输入,以及绑定到文本、复选框、单选按钮等输入的方法。还介绍了下拉框和动态输入。最后,本章涵盖了表单提交和各种修饰符,如惰性绑定、数字类型转换和字符串修剪。

第七章《HTTP 和 WebSocket 通信》介绍了将 HTTP 整合到 Vue.js 应用程序的最佳实践。它介绍了Axios以及发送 HTTP 请求的各种方法(即根实例/组件/导航守卫)。本章还介绍了使用socket.io,以及创建用于实时集成的基本 Node/Express API。

第八章《Vue 路由模式》描述了路由是任何 SPA 的重要组成部分。本章重点介绍了 Vue 路由,并探讨了如何在多个页面之间进行路由。它涵盖了从匹配路径和组件到使用导航参数、正则表达式等动态匹配的所有内容。

第九章,使用 Vuex 进行状态管理,演示了使用 Vuex 进行状态管理。首先介绍了 Flux 架构和单向数据流。然后,它介绍了 Vuex,这是 Vue 的状态管理系统。本章还讨论了在应用程序中实现这一点,以及常见的陷阱和使用模式。它还介绍了Vue-devtools来捕获操作和 Vue 实例数据。

第十章,测试 Vue.js 应用程序,展示了测试是任何项目的重要部分,无论是哪种框架或语言。本章将讨论如何测试我们的应用程序以及如何编写可测试的代码。然后,我们将使用 Jasmine 和 Karma 测试我们的应用程序,以及在测试 mutations 时测试我们的 Vuex 代码。

第十一章,优化,概述了部署 Vue 应用程序和任何潜在的性能优化。然后,它介绍了将应用程序转换为渐进式 Web 应用程序PWA)并添加ServiceWorkers、离线支持等。它还探讨了减少整体捆绑大小的方法,以及使用 SVG 获得性能优势。

第十二章,使用 Nuxt 进行服务器端渲染,展示了使用 Nuxt 创建服务器端渲染的 Vue 应用程序。该项目将使用 Vue CLI 创建,并且我们将从配置到路由、中间件和测试 Nuxt,一直到部署都会进行讨论。

第十三章,模式,帮助读者避免编写 Vue.js 应用程序时的常见反模式。提出了一份样式指南,以及关键问题,如使用$parent$ref耦合问题、内联表达式等。

为了充分利用本书

  1. 您应该已经了解并掌握 JavaScript(ES2015+)、HTML5 和 CSS3。

  2. 本书不需要具备 Vue.js 的经验,尽管有其他 JavaScript 框架的经验将有助于比较和类似功能。

下载示例代码文件

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

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

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

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

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

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Vue.js-2-Design-Patterns-and-Best-Practices

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

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/Vuejs2DesignPatternsandBestPractices_ColorImages.pdf.

使用的约定

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

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

代码块设置如下:

// my-module.js
export default function add(x, y) {
 return x + y
}

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

$ npm install
$ npm run dev

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

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一章:Vue.js 的原则和比较

在本章中,我们将探讨为什么 Vue 是一个重要的 Web 开发框架,以及如何设置我们的开发环境。如果我们打算在下一个项目中使用 Vue,重要的是我们意识到这样做的影响、时间投资和学习曲线。您将考虑 Vue 与其他前端开发项目的对比,以及使用 Vue 创建您的第一个应用程序。

总之,我们将考虑以下几点:

  • 下载书籍先决条件

  • 了解 Vue 在前端框架中的定位

  • 为什么您应该考虑将 Vue 作为下一个项目的框架

  • 调查 Vue 的灵活性及其在移动开发中的作用

先决条件

尽管你可以在没有 Node 的情况下开发 Vue 应用程序,但我们将在本书中始终使用 Node.js 来管理依赖关系并与 Vue 命令行界面 (CLI) 交互。这使我们能够更快地启动项目,并且可以默认使用 ECMAScript 2015,从而为我们提供更好的开发体验。让我们快速回顾一下如何设置开发环境。

Windows

在 Windows 上安装 Node 就像访问nodejs.org并下载最新版本一样简单。确保在按照安装步骤时选择 Add to PATH,这样我们就可以在终端中访问 node 命令。

完成后,通过输入node -vnpm -v来检查 Node 安装是否成功。如果你得到两个版本号(即每个版本一个),那么你就可以继续进行本书的其余部分了!

Mac

在 Mac 上安装 Node 需要比简单地从 Node 网站下载安装程序更多的工作。虽然可以使用来自nodejs.org的安装程序,但由于需要sudo,不建议这样做。

如果我们这样做,我们将不得不在所有的npm命令前加上sudo,这可能会使我们的系统容易受到潜在的脚本攻击,并且不方便。相反,我们可以通过 Homebrew 软件包管理器安装 Node,然后我们可以与npm交互,而不必担心必须以sudo身份运行事务。

使用 Homebrew 安装 Node 的另一个好处是它会自动添加到我们的 PATH 中。这意味着我们将能够在不必费力地调整我们的环境文件的情况下输入 node 命令。

通过 Homebrew 安装 Node

获取 Homebrew 的最快方法是访问brew.sh并获取安装脚本。它应该看起来有点像这样:

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

只需将其粘贴到您的终端中,它将下载 Homebrew 软件包管理器到您的 Mac 上。然后我们可以使用 brew install node 在系统上安装 Node 而不必担心任何问题。

完成后,通过输入node -vnpm -v来检查您的 Node 安装是否正常工作。如果您得到两个版本号(即每个版本一个),那么您就可以继续阅读本书的其余部分了!

为了管理不同的 Node 版本,我们还可以安装Node Version ManagerNVM)。但请注意,目前仅 Mac 支持此功能,不支持 Windows。要安装 NVM,我们可以使用 Homebrew,就像这样:

--use Brew to install the NVM
brew install nvm

--File directory
mkdir ~/.nvm

--Install latest version
nvm install --lts

--Ensure latest version is used
nvm use node

--Remember details across sessions
nano ~/.bash_profile

--Execute in every session
export NVM_DIR="$HOME/.nvm"
 . "$(brew --prefix nvm)/nvm.sh"

编辑器

可以使用各种编辑器,如 Visual Studio Code,Sublime Text,Atom 和 WebStorm。我推荐使用 Visual Studio Code (code.visualstudio.com),因为它有频繁的发布周期和丰富的 Vue 扩展,可以用来改善我们的工作流程。

浏览器

我们将使用 Google Chrome 来运行我们的项目,因为它有一个名为 Vue devtools 的扩展,对我们的开发工作流程至关重要。如果您不使用 Google Chrome,请确保您的浏览器具有可供使用的相同 Vue devtools 扩展。

安装 Vue devtools

前往 Google Chrome 扩展商店并下载 Vue.js devtools (goo.gl/Sc3YU1)。安装后,您将可以在开发者工具中访问 Vue 面板。在下面的示例中,我们能够看到 Vue 实例中的数据对象:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-     
  scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Vue.js</title>
</head>
<body>
  <div id="app"></div>
  <script src="http://unpkg.com/vue"></script>
  <script>
   Vue.config.devtools = true
   new Vue({
     el: '#app',
     data: {
       name: 'Vue.js Devtools',
       browser: 'Google Chrome'
     },
     template: `
      <div>
        <h1> I'm using {{name}} with {{browser}}</h1>
      </div>
     `
   });
  </script>
</body>
</html>

然后,我们转到浏览器并打开开发工具,我们可以看到 Vue 已被检测到,并且我们的消息已经输出到屏幕上:

我们将在整本书中使用它来更深入地了解我们的应用程序。请注意,开发者工具只会在本地服务器上提供 Vue 项目时才能识别您的 Vue 项目。

Vue CLI

为了充分利用 Vue 的所有功能,我们将使用 Vue CLI。这使我们能够使用各种起始模板创建项目,并具有适当的捆绑/转译配置。在确保安装了 Node 的情况下,将以下内容输入到您的终端中:

$ npm install vue-cli -g

这为我们未来的章节做好了准备,使用起始模板显著增强了我们的工作流程。

Vue.js 的比较

本书旨在概述如何使用常见的开发模式、最佳实践和要避免的反模式来最佳地构建 Vue 应用程序。

我们的旅程始于看看 Vue 与其他常见项目的比较,如果你通过 GitHub 星标来衡量你的框架,Vue 显然是未来的赢家。根据bestof.js.org,2017 年它的每日星标数目为 114,而 React 为 76,Angular 为 32。

谈论现代 Web 开发技术时,对框架的讨论是一个有趣的话题。很少能找到一个真正客观的比较...但没关系!重点不在于哪个框架或库最好,而在于什么对你的团队、项目目标、消费者以及其他数百个变量最好。作为一个 Vue 开发者,你可能是一个想要用简单、易于使用的 API 构建响应式 Web 应用的人。

正是这种适应性强、易于使用的 API 使得 Vue 很愉快地使用,也许 Vue 最强大的地方之一就是简单而专注的文档。它的门槛非常低:只需从 CDN 添加一个脚本文件,初始化一个新的 Vue 实例...然后你就可以开始了!当然,Vue 比这更复杂得多,但与一些完整的框架如 Angular 相比,你可能会原谅自己认为它是如此简单。

Vue 使用模板、声明式绑定和基于组件的架构来分离关注点,使项目更易于维护。当考虑在企业内部使用哪个框架时,这变得尤为重要。通常情况下,这就是 Angular 等项目的闪光之处,因为它能够在整个项目中强制执行标准。

我们已经确定它很容易使用,但是与竞争对手相比,Vue 还很年轻...我们怎么知道这不全是炒作呢?有人在生产中使用它吗?当然有!GitLab 最近写了一篇关于他们为什么选择 Vue.js 的文章(about.gitlab.com/2016/10/20/why-we-chose-vue/),他们列举的主要优点是易用性、代码量少和假设少。其他公司如 Laravel、任天堂、Sainsbury's 和阿里巴巴都在追随这条路线,甚至像 Rever Shine 这样的公司也将他们的 Web 客户端从 Angular 2.x 重写为 Vue 2.x(medium.com/reverdev/why-we-moved-from-angular-2-to-vue-js-and-why-we-didnt-choose-react-ef807d9f4163)。

不仅是公开面向的 Web 应用程序正在利用 Vue.js——NativeScript Sidekick(www.nativescript.org/blog/announcing-the-nativescript-sidekick-public-preview)是一个专注于改进 NativeScript 开发体验的项目,它是使用 Electron 和 Vue.js 构建的。

如果我们从 Sacha Greif(twitter.com/SachaGreif)和 Michael Rambeau(michaelrambeau.com/)的 JavaScript 调查中获得一些见解,我们可以看到惊人的89%的人在之前使用过 Vue,并希望再次使用它。其他库如 React 的满意度率也达到了92%,但是 Angular 2 及以后的版本并没有得到太多的喜爱,只有65%的人希望再次使用它。

作为前端开发人员,我们还有哪些其他选择?它们与 Vue 相比如何?让我们从 React 开始。

React

React 是由 Facebook 开发和维护的 JavaScript 库,与 Vue 最为接近,因为它们的目标非常相似。与 Vue 一样,React 是基于组件的,并利用了虚拟 DOM 的概念。这允许对 DOM 节点进行高性能渲染,因为使用了不同的算法来确定 DOM 的哪些部分发生了变化,以及如何在变化时最好地渲染/更新它们。

在模板方面,React 使用 JSX 来渲染屏幕上的项目。它采用了更冗长的方式来创建 DOM 元素,使用React.createElement并简化如下:

这是没有 JSX 的样子:

React.createElement</span>(  MyButton,  {color:  'red',  shadowSize: 5},  'Click Me' )

以下是 JSX 的样子。如你所见,两者看起来非常不同:

<MyButton color="red" shadowSize={5}>
 Click  Me </MyButton>

对于新手开发者来说,与 Vue 的简单 HTML 模板相比,这增加了一些学习负担,但也正是这一点赋予了 React 其声明性的力量。它有一个使用setState()的状态管理系统,但也与 Redux 和 MobX 等第三方状态容器兼容。Vue 也具有类似的功能,使用Vuex库,我们将在本书的后面部分更详细地讨论这一点。

使用 React 的一个常见最近关注点是 BSD +专利许可协议,如果你是企业的一部分,这是需要牢记的事情。由于这个许可证,Apache 最近宣布,不会有任何 Apache 软件产品使用 React。另一个例子是Wordpress.com宣布他们不再在 Gutenberg 项目中使用 React(ma.tt/2017/09/on-react-and-wordpress/)。这并不一定意味着你不应该在你的项目中使用 React,但还是值得指出的。

一些担心的开发者选择使用替代方案,如 Preact,但最近更多地选择了 Vue.js,因为它满足了 React 开发者在开发应用程序时寻求的许多目标。为此,微软(dev.office.com/fabric#/components)、苹果(developer.apple.com/documentation)和其他无数公司都发布了使用 React 的产品-你可以从中得出什么结论。

Angular

Angular 是由 Google 开发和维护的一种有主见的 JavaScript 框架。在撰写本文时,它目前接近第 5 版,并提供了一种基于结构化标准的网页开发方法。它使用 TypeScript 来强制执行类型安全和 ECMAScript 2015>支持。

与 Angular 相比,Vue 似乎强制执行一组更小的约束,并允许开发人员有更多的选择。Angular 的核心竞争力之一是在任何地方都使用 TypeScript。大多数从 Angular.js 过来的开发人员在 Angular 2 宣布时第一次听说 TypeScript,我注意到有相当多的反对意见,因为需要“学习一门新语言”。事实上,JavaScript 就是 TypeScript,增加工具(自动完成、重构、类型安全等)的价值是不容忽视的。

特别是在处理企业项目时,随着项目复杂性和团队规模的增加,入门挑战变得更加困难。有了 TypeScript,我们能够更好地推理代码之间的关系。这种结构化的开发体验是 Angular 的主要优势。这就是为什么 Angular 团队选择 TypeScript 作为主要开发工具的原因。TypeScript 的好处不仅限于 Angular;我们将在本书后面看看如何将 Vue 与 TypeScript 集成以获得同样的好处。

使用 Angular 作为开发框架有什么缺点吗?不一定。当与 Vue 进行比较时,入门体验是非常不同的。

移动开发

在开发移动应用程序时,像 Angular 和 React 这样的项目是开发面向移动的应用程序的绝佳选择。NativeScript、React Native 和 Ionic Framework 项目的成功大大提升了这些框架的流行度。例如,Ionic Framework 目前在 GitHub 上的星标比 Angular 还要多!

Vue 在这一领域取得了一定的成就,例如 NativeScript Vue、Weex 和 Quasar Framework 等项目。所有列出的项目都相对较新,但只需要一个项目真正地提升 Vue 在生产中的流行度。以 NativeScript Vue 为例,只需要 43 行代码就可以创建一个连接到 REST API 并在屏幕上显示结果的跨平台移动应用程序。如果你想自己尝试一下,可以运行:

# Install the NativeScript CLI
npm install nativescript -g

# New NativeScript Vue project
tns create NSVue --template nativescript-vue-template

# Change directory
cd NSVue

# Run on iOS
tns run ios

然后,我们可以将以下内容放在app/app.js中:

const Vue = require('nativescript-vue/dist/index');
const http = require('http');
Vue.prototype.$http = http;

new Vue({
    template: `
    <page>
        <action-bar class="action-bar" title="Posts"></action-bar>
        <stack-layout>
            <list-view :items="posts">
                <template scope="post">
                    <stack-layout class="list">
                        <label :text="post.title"></label>
                        <label :text="post.body"></label>
                    </stack-layout>
                </template>
            </list-view>
        </stack-layout>
    </page>
    `,    
    data: {
        posts: []
    },
    created(args) {
        this.getPosts();
    },
    methods: {
        getPosts() {
            this.$http
                .getJSON(`https://jsonplaceholder.typicode.com/posts`)
                .then(response => {
                    this.posts = response.map(
                        post => {
                            return {
                                title: post.title,
                                body: post.body
                            }
                        }
                    )
                });
        }
    }
}).$start();

如果我们运行我们的代码,就可以看到一个帖子列表。你会注意到我们的 Vue 代码是声明式的,而且我们可以用更少的代码获得更大框架的功能:

服务器端渲染(SSR)

服务器端渲染允许我们将前端 JavaScript 应用程序渲染为服务器上的静态 HTML。这很重要,因为它可以显著加快我们的应用程序,因为浏览器只需解析关键的 HTML/CSS。最大化性能是现代 Web 应用程序的关键组成部分,随着渐进式 Web 应用程序和 AMP 等项目的预期不断增长。React、Angular 和 Vue 都能够使用各种不同的模式进行服务器端渲染。

让我们看看如何实现一个简单的服务器端渲染的 Vue 应用程序:

# Create a new folder named vue-ssr:
$ mkdir vue-ssr
$ cd vue-ssr

# Create a new file named server.js
$ touch server.js

# Install dependencies
$ npm install vue vue-server-renderer express

server.js中,我们可以创建一个新的 Vue 实例,并使用 Vue 渲染器将我们实例的内容输出为 HTML:

const Vue = require("vue");
const server = require("express")();
const renderer = require("vue-server-renderer").createRenderer();

server.get("*", (req, res) => {
  const app = new Vue({
    data: {
      date: new Date()
    },
    template: `
    <div>
    The visited time: {{ date }}
    </div>`
  });

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end("Internal Server Error");
      return;
    }
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `);
  });
});

server.listen(8080);

要运行应用程序,请在终端中输入以下内容:

$ node server.js

然后我们可以在浏览器中打开http://localhost:8080,我们会在屏幕上看到当前的日期和时间。这只是一个简单的例子,但我们能够看到我们的应用程序是使用vue-server-renderer渲染的。请注意,我们没有定义一个目标元素来在我们的 Vue 实例中渲染内容;这是由renderer.renderToString函数处理的。

您还会注意到我们在 DOM 节点上有data-server-rendered="true"属性,这在客户端渲染的 Vue 应用程序中是不存在的。这使我们能够用服务器端实例来滋养我们的客户端实例,这是我们将在后面关于 Nuxt 的章节中更详细地讨论的内容(nuxtjs.org/)。

结论

企业中的 Web 框架选择总是取决于项目、团队和组织优先事项的目标。没有一个框架是正确的选择,因为最佳选择取决于上下文。每个框架或库都有其独特的优点、缺点和优先事项。如果您的优先事项是快速创建并扩展 Web 应用程序,Vue 可以与其他市场解决方案竞争。

Vue 功能丰富、声明式且高度可读。尽管它是一个简单的框架,但 Vue 的声明性质使我们能够快速启动而不必担心过于复杂的模式。

总结

在本章中,我们看了如何设置开发环境以及 Vue 在整个行业中的应用。我们了解到 Vue 是一个简单但强大的前端开发框架。此外,我们考虑了与其他流行项目(如 Angular 和 React)相比,Vue 的表现如何。我们还看了 Vue 如何与其他技术(如 NativeScript)配合使用,创建跨平台原生移动应用程序。最后,我们以高层次调查了 SSR,并为接下来的章节做好了铺垫。希望到现在你已经相信学习 Vue 是值得的,并且期待着利用它所提供的一切!

在下一章中,我们将看一下 Vue CLI 以及如何利用诸如 Webpack 之类的工具来创建我们的 Vue 项目。此外,我们还将探讨如何利用静态类型和 TypeScript 以及在 Vue 中使用 RxJS 的响应式可观察模式。

第二章:正确创建 Vue 项目

在本章中,我们将看看如何创建可维护的 Vue 项目,并充分利用许多可用的工具和模式。如果您目前在开发工作流程中没有充分利用这些功能,您可能会发现本章讨论的大部分概念也适用于其他技术。

在本章中,我们将研究以下内容:

  • Vue devtools

  • Visual Studio Code 扩展

  • TypeScript 集成

  • 使用 RxJS 进行响应式编程

Visual Studio Code 扩展

我们的开发环境是应用程序开发的重要部分。在使用 Visual Studio Code 创建 Vue 应用程序时,建议安装以下扩展:

  • Vetur

  • Vue 2 Snippets

让我们更详细地看看这两个。

Vetur

Vetur 由 Vue 语言服务器提供支持,为我们提供语法高亮、Emmet(用于增加 HTML/CSS 工作流程)、代码片段、linting、IntelliSense 等功能。这极大地改善了我们的开发体验,并得到了广泛的支持,在 GitHub 上有超过 1,000 个星标。要安装该扩展,点击 Visual Studio Code 中的扩展图标,然后输入Vetur;从这里,您可以选择安装,它将自动在您的项目中使用:

安装 Vetur

这样我们就可以访问到诸如scaffold之类的代码片段,它为我们在 Vue 组件中生成了一个新的空模板、脚本和样式对象:

搭建一个新的 Vue 项目

Vue 2 Snippets

代码片段是应用程序开发的重要部分;与 Emmet 类似,它们允许我们快速搭建应用程序中常见的模式。我们还将安装另一个名为 Vue 2 Snippets 的 Visual Studio Code 扩展,该扩展为我们提供了各种常用的代码片段。

这样可以节省大量时间,否则我们将不得不花费时间编写相同的样板代码。接下来的例子,虽然它很简单,但我们可以得到代码片段的描述,然后按下Tab键,它就会扩展为我们预定义的代码:

充分利用 Vue 代码片段

Vue CLI

Vue 命令行界面CLI)允许我们快速使用各种不同的模板选项搭建新的 Vue 项目。目前,可用的模板选项包括 Webpack、Browserify 和渐进式 Web 应用程序功能等技术。

当然,我们可以创建自己的 Vue 应用程序,并手动添加诸如 Webpack 之类的工具,但这会在技术上增加负担,因为我们必须学习、构建和维护我们的配置。Vue CLI 可以为我们做到这一点,同时保持一组官方模板,但不限制我们修改生成的 Webpack 配置。所有这些都允许我们生成新的无偏见的 Vue 项目。

要开始使用 Vue CLI,让我们确保已经安装了它:

npm install vue-cli -g

然后,我们可以使用 Vue init命令使用 Webpack 模板搭建一个新的 Vue 项目:

vue init webpack-simple my-vue-project

输入上述命令后,我们应该在终端上看到以下内容:

使用 Vue init 创建项目

如果我们分解这个,实质上我们正在基于 webpack-simple 模板初始化一个新的 Vue 项目,名为 my-vue-project。然后我们会进入一个向导过程,询问我们关于项目的更多元数据。这些元数据和配置将根据您选择的模板而有所不同。

让我们来研究模板创建的文件和文件夹:

文件/文件夹 描述
src/ 这个文件夹包含我们项目的所有代码。我们将在 src 中花费大部分时间。
.bablrc 这是我们的 Babel 配置文件,允许我们编写 ES2015 并进行适当的转译。
index.html 这是我们的默认 HTML 文件。
package.json 这个文件包含我们的依赖和其他项目特定的元数据。
webpack.config.js 这是我们的 Webpack 配置文件,允许我们使用.vue文件、Babel 等。

注意我们不再使用.js文件,现在我们的src目录里有.vue文件。Vue 文件被称为单文件组件,它包含模板、脚本和样式标签,允许我们将所有内容限定在这个组件内部。

这是可能的,因为我们有一个自定义的“loader”。这是如何工作的?在看这个之前,让我们快速了解一下现代 JavaScript 构建系统。

JavaScript 模块

为了创建可重用的模块化代码,我们的目标应该是在大多数情况下每个功能有一个文件。这使我们能够避免可怕的“意大利面代码”反模式,其中我们有强耦合和关注点分离很少。继续以意大利面为主题,解决这个问题的方法是采用更小、松散耦合、分布式模块的“意大利饺子代码”模式,这样更容易处理。JavaScript 模块是什么样子的?

在 ECMAScript2015 中,模块只是使用export关键字的文件,并允许其他模块导入该功能:

// my-module.js
export default function add(x, y) {
 return x + y
}

然后我们可以从另一个模块中import add

// my-other-module.js
import { add } from './my-other-module'

add(1, 2) // 3

由于浏览器尚未完全跟上模块导入的步伐,我们经常使用工具来辅助打包过程。在这个领域常见的项目有 Babel、Bublé、Webpack 和 Browserify。当我们使用 Webpack 模板创建一个新项目时,它使用 Vue-loader 将我们的 Vue 组件转换为标准的 JavaScript 模块。

Vue-loader

在标准的webpack-simple模板中的./webpack-config.js内,我们有一个模块对象,允许我们设置我们的加载器;这告诉 Webpack 我们想要在项目中使用.vue文件:

module: {
 rules: [{
  test: /\.vue$/,
  loader: 'vue-loader',
  options: {
   loaders: {}
  // other vue-loader options go here
 }
}]

为了使其工作,Webpack 对与.vue匹配的任何内容运行正则表达式,然后将其传递给我们的vue-loader,以转换为普通的 JavaScript 模块。在这个简单的例子中,我们正在加载扩展名为.vue的文件,但vue-loader可以进一步定制,您可能希望进一步了解这一点(goo.gl/4snNfD)。我们当然可以自己进行这种配置,但希望您能看到使用 Vue CLI 生成我们的 Webpack 项目的好处。

在没有 Webpack 的情况下加载模块

尽管 Webpack 在更多方面帮助我们,但我们目前可以在浏览器中本地加载 JavaScript 模块。在写作时,它的性能往往不如打包工具(但这可能会随着时间的推移而改变)。

为了演示这一点,让我们转到终端,并在基于简单模板的项目中创建一个简单的计数器。这个模板有效地启动了一个新的 Vue 项目,没有任何打包工具:

# New Vue Project
vue init simple vue-modules

# Navigate to Directory
cd vue-modules

# Create App and Counter file
touch app.js
touch counter.js

然后我们可以编辑我们的index.html,添加来自type="module"的脚本文件,这使我们可以使用之前概述的导出/导入语法:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
 <title>Vue.js Modules - Counter</title>
 <script src="https://unpkg.com/vue"></script>
</head>
<body>
 <div id="app">
 </div>
 <script type="module" src="counter.js"></script>
 <script type="module" src="app/app.js"></script>
</body>
</html>

警告:确保您的浏览器是最新的,以便上述代码可以成功运行。

然后,在我们的counter.js中,我们可以导出一个新的default对象,它充当一个新的 Vue 实例。它充当一个简单的计数器,允许我们增加或减少一个值:

export default {
 template: `
  <div>
   <h1>Counter: {{counter}}</h1>
   <button @click="increment">Increment</button>
   <button @click="decrement">Decrement</button>
  </div>`,
 data() {
  return {
   counter: 1
  };
 },
 methods: {
  increment() {
   this.counter++;
  },
 decrement() {
   this.counter--;
  }
 }
};

然后我们可以在app.js中导入counter.js文件,从而演示我们可以导入/导出模块的方式。为了在我们的根 Vue 实例中显示计数器,我们将计数器注册为此实例中的一个组件,并将模板设置为<counter></counter>,这是我们组件的名称:

import Counter from './counter.js';

const app = new Vue({
 el: '#app',
 components: {
  Counter
 },
 template: `<counter></counter>`
});

我们将在本书的后续部分更详细地讨论这一点,但在这一点上,你需要知道的是它实际上充当另一个 Vue 实例。当我们在我们的实例中注册组件时,我们只能从该实例中访问这个组件。

太棒了!这是我们模块导入/导出的结果:

Vue.js 模块

在下一节中,我们将深入了解调试 Vue 应用程序以及 Vue devtools 在其中的作用。

VueJS devtools

能够准确调试我们的应用程序是开发工作流程的重要部分。在上一章中,我们安装了 VueJS devtools,并将在本节中更详细地使用它。让我们创建一个示例项目:

# New project
vue init webpack-simple vue-devtools

# Change directory
cd vue-devtools

# Install dependencies
npm install

# Run application
npm run dev

然后,我们可以打开开发者控制台并导航到 Vue 选项卡。当我们从组件列表中选择 App 时,我们可以看到该组件的数据对象和其他信息。默认情况下,我们有一个msg变量,然后我们将其绑定到我们的模板中,并且我们可以在开发者工具中看到这一点:

检查 Vue 实例

不过,这两种方式都是可以的 - 我们可以使用$vm0.$data来访问 Vue 实例中的对象,将其范围限定为msg。要在控制台中查看这一点,选择<Root>然后选择<App>将在控制台中显示msg。我们可以更改这个值,由于 Vue 正在监视这个值,它将自动在屏幕上更改。

从控制台编辑 Vue 实例数据

注意我们的消息已更改为"Hello Vue Devtools!";如果我们有多个 Vue 实例,将会有其他带有$vm1$vm2等前缀版本的 Vue。在本书后面当我们开始使用Vuex时,我们会经常使用这个。接下来,让我们看看如何将 TypeScript 集成到我们的 Vue 项目中。这对于具有 Angular 背景或任何希望利用静态类型的人都很有用。

TypeScript 和 Vue

您可能以前使用过 TypeScript,或者您可能对如何在 Vue 项目中利用 TypeScript 提供的额外工具感到好奇。为什么要使用 TypeScript?高等人最近的一项研究发现,TypeScript/静态类型工具将提交的错误减少了 15%(goo.gl/XUTPf4)。

如果您以前使用过 Angular,这种语法应该让您感到非常亲切,因为我们将使用装饰器和 ES2015 类。让我们研究一下如何将 TypeScript 添加到使用 Vue CLI 构建的项目中:

# Create a new Vue project
vue init webpack-simple typescript-vue

# Change directory
cd typescript-vue

# Install dependencies
npm install

您应该在终端上获得以下输出:

如果我们按照说明导航到我们的项目目录并运行 npm install,然后我们需要安装 TypeScript 加载器并编辑我们的 Webpack 配置。这允许我们在项目内加载.ts文件,并且因为我们使用了webpack-simple模板,所以只需安装加载器并进行一些更改即可。同时,我们还可以将 TypeScript 安装到项目中:

# Install TypeScript and the TypeScript Loader
npm install typescript ts-loader --save-dev  

然后我们需要对我们的 Webpack 配置进行一些更改,以添加新的加载器。热模块替换默认启用,因此加载后无需刷新即可看到任何更改。

记得从命令行运行项目,并输入 npm dev

我们需要将入口点更改为main.ts(并随后重命名它),并定义ts-loader并删除babel-loader以执行此操作,并编辑webpack.config.js文件,粘贴以下内容:

var path = require('path');
var webpack = require('webpack');

module.exports = {
 entry: './src/main.ts',
 output: {
 path: path.resolve(__dirname, './dist'),
 publicPath: '/dist/',
 filename: 'build.js'
 },
 module: {
 rules: [
 {
 test: /\.vue$/,
 loader: 'vue-loader',
 options: {
 loaders: {}
 }
 },
 {
 test: /\.tsx?$/,
 loader: 'ts-loader',
 exclude: /node_modules/,
 options: {
 appendTsSuffixTo: [/\.vue$/]
 }
 },
 {
 test: /\.(png|jpg|gif|svg)$/,
 loader: 'file-loader',
 options: {
 name: '[name].[ext]?[hash]'
 }
 }
 ]
 },
 resolve: {
 extensions: ['.ts', '.js', '.vue'],
 alias: {
 vue$: 'vue/dist/vue.esm.js'
 }
 },
 devServer: {
 historyApiFallback: true,
 noInfo: true
 },
 performance: {
 hints: false
 },
 devtool: '#eval-source-map'
};

if (process.env.NODE_ENV === 'production') {
 module.exports.devtool = '#source-map';
 // http://vue-loader.vuejs.org/en/workflow/production.html
 module.exports.plugins = (module.exports.plugins || []).concat([
 new webpack.DefinePlugin({
 'process.env': {
 NODE_ENV: '"production"'
 }
 }),
 new webpack.optimize.UglifyJsPlugin({
 sourceMap: true,
 compress: {
 warnings: false
 }
 }),
 new webpack.LoaderOptionsPlugin({
 minimize: true
 })
 ]);
}

之后,我们可以在项目根目录内创建一个tsconfig.json,负责适当配置我们的 TypeScript 设置:

{
 "compilerOptions": {
 "lib": ["dom", "es5", "es2015"],
 "module": "es2015",
 "target": "es5",
 "moduleResolution": "node",
 "experimentalDecorators": true,
 "sourceMap": true,
 "allowSyntheticDefaultImports": true,
 "strict": true,
 "noImplicitReturns": true
 },
 "include": ["./src/**/*"]
}

现在我们的项目中已经设置了 TypeScript,但要真正利用它在我们的 Vue 应用程序中,我们还应该使用vue-class-component。这使我们能够利用组件属性的静态类型,并将组件定义为本机 JavaScript 类:

# Install TypeScript helpers
npm install vue-class-component --save-dev

然后,我们可以通过首先将其指定为具有lang="ts"属性的脚本来定义我们的App.vue文件。然后像往常一样导入 Vue,但除此之外,我们还从vue-class-component导入Component,以便在此文件中用作装饰器。这允许我们将其指定为新的 Vue 组件,并且使用 Component 装饰器,我们可以定义属性、模板等。

在我们的 Component 装饰器内部,我们正在指定一个模板,其中包含一个输入框和按钮。此示例允许我们看到如何绑定到类属性,以及从我们的类中调用方法。以下代码应替换已在App.vue文件中的代码:

<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';

@Component({
 template: `
 <div>
   <input type="text" v-model="name" />
   <button @click="sayHello(name)">Say Hello!</button>
</div>
`
})
export default class App extends Vue {
 name: string = 'Paul';

 sayHello(name: string): void {
   alert(`Hello ${name}`)
 }
}
</script>

运行上述代码后,您应该会得到类似以下的结果:

生命周期钩子

生命周期钩子,如created()mounted()destroyed()等,可以在类中定义为函数。

  • created()

这允许在将组件添加到 DOM 之前执行操作。使用此钩子允许访问数据和事件。

  • mounted()

Mounted 在组件呈现之前和呈现后都可以访问。它提供了与 DOM 和组件进行交互的完全访问权限。

  • destroyed()

附加到组件的所有内容都已被销毁。它允许在组件从 DOM 中移除时进行清理。

它们将被识别并且与预期的方式一样起作用,而不需要 TypeScript。在使用createdmounted钩子时的示例:

// Omitted
export default class App extends Vue {
 name: string = 'Paul';

 created() {
 console.log(`Created: Hello ${this.name}`)
 }

 mounted() {
 console.log(`Mounted: Hello ${this.name}`);
 }
}

现在,如果我们转到控制台,我们可以看到“Hello”的消息与 Paul 的名称一起输出:

属性

我们已经看到了如何创建类并使用 Component 装饰器;现在让我们看看如何在我们的类中使用vue-property-decorator定义“props”:

# Install Vue Property Decorator
npm install vue-property-decorator --save-dev

这取决于vue-class-component,因此每次安装vue-property-decorator时,您都需要确保也安装了vue-class-component。然后,我们可以使用@Prop装饰器定义一个Component属性:

<script lang="ts">
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';

// Omitted
@Component({
})
export default class App extends Vue {
@Prop({ default: 'Paul Halliday' }) name: string;
}
</script>

请注意,我们现在从'vue-property-decorator'而不是vue-class-component中导入Component。这是因为它将其作为模块导出供我们导入。然后,我们使用名称键和'Paul Halliday'default值定义了一个新的组件属性;在使用 TypeScript 之前,它看起来应该是这样的:

export default {
 props: {
 name: {
  type: String,
  default: 'Paul Halliday'
  }
 }
}

计算属性

计算属性允许传递多个表达式,Vue 实例上的这些属性需要使用类的 getter/setter。因此,如果我们想要获取我们名字的反转版本,我们可以简单地传递以下内容:

@Component({
 template: `
   <div>
     <input type="text" v-model="name" />
     <button @click="sayHello(name)">Say Hello!</button>
     <p>{{nameReversed}}</p>
   </div>
 `
})
export default class App extends Vue {
 @Prop({ default: 'Paul Halliday' }) name: string;

 // Computed values
 get nameReversed() {
  return this.name.split("").reverse().join("");
 }

 sayHello(name: string): void {
  alert(`Hello ${name}`)
 }
}

否则,这将等同于:

export default {
 computed: {
  nameReversed() {
   return this.name.split("").reverse().join("");
  }
 }
}

其他装饰器也可以使用,比如@Watch@Inject@Model@Provide。每个装饰器都允许一致的实现,并为原始 Vue 实例提供类似的 API。在下一节中,我们将看看如何使用 RxJS 增强 Vue 应用程序的响应性。

RxJS 和 Vue

如果你来自 Angular 背景,你很可能会对 RxJS 的基本概念感到非常熟悉。这意味着我们通常处理诸如 Observables、Subjects 和各种操作符之类的东西。如果你以前没有使用过它们,不用担心——我们将调查 RxJS 是什么,以及为什么我们想要在 Vue 中使用它。

RxJS 是什么?

如果我们查看 RxJS 文档,我们会看到以下定义:“ReactiveX 是一个通过使用可观察序列来组合异步和基于事件的程序的库”(reactivex.io/intro.html)。乍一看,这并不是一个让我们在项目中感到舒适的描述。

RxJS 帮助我们在应用程序中使用响应式编程原则,通常被称为更具声明性而不是命令性的风格。当我们谈论命令式编程风格时,我们通常是在告诉计算机如何执行特定任务的确切步骤。声明式风格允许我们更多地关注预期的结果而不是实现。

在 JavaScript 中,我们可以通过以下方式创建一个event流:

document.addEventListener('click', event => {
 console.log(event);
 }); 

这样我们就可以观察文档上的任何鼠标点击。我们可以捕获诸如点击坐标、目标、事件类型等内容。显然,这是一个异步的可观察数据流。我们不知道什么时候有人会点击屏幕,也不在乎。我们所做的就是观察,并在事件发生时执行特定的操作。

我们可以使用 RxJS 将这些原则应用到我们的现代应用程序中,其中一切都是一个流。我们可以有一个可观察的数据流,从 Facebook 动态到文档点击事件,定时器,任何东西!一切都可以是一个流。

与 Vue 集成

要将 RxJS 与 Vue 集成,我们需要创建一个新的 Vue 项目,并安装 RxJS 和 Vue-Rx。使用 Vue-Rx 插件的一个很棒的地方是它得到了 Vue.js 团队的官方支持,这让我们有信心它将在长期内得到支持。

让我们使用 Vue CLI 创建一个新项目,并集成 RxJS:

# New Vue project
vue init webpack-simple vue-rxjs

# Change directory
cd vue-rxjs

# Install dependencies
npm install

# Install rxjs and vue-rx
npm install rxjs vue-rx

# Run project
npm run dev

我们现在需要告诉 Vue 我们想要使用VueRx插件。这可以通过Vue.use()来完成,并且不是特定于这个实现。每当我们想要向我们的 Vue 实例添加新的插件时,调用Vue.use()会内部调用插件的install()方法,扩展全局范围的新功能。要编辑的文件将是我们的main.js文件,位于src/main.js。我们将在本书的后面章节更详细地讨论插件:

import Vue from "vue";
import App from "./App.vue";
import VueRx from "vue-rx";
import Rx from "rxjs";

// Use the VueRx plugin with the entire RxJS library
Vue.use(VueRx, Rx);

new Vue({
 el: "#app",
 render: h => h(App)
});

注意到前面的实现中有什么问题吗?嗯,在应用性能和减少捆绑包大小的利益上,我们应该只导入我们需要的内容。这样就变成了:

import Vue from "vue";
import App from "./App.vue";
import VueRx from "vue-rx";

// Import only the necessary modules
import { Observable } from "rxjs/Observable";
import { Subject } from "rxjs/Subject"; 

// Use only Observable and Subject. Add more if needed.
Vue.use(VueRx, {
Observable,
Subject
});

new Vue({
el: "#app",
render: h => h(App)
});

然后,我们可以在 Vue 应用程序内创建一个Observable数据流。让我们转到App.vue,并从 RxJS 导入必要的模块:

// Required to create an Observable stream
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';

然后,我们可以创建一个新的数据Observable;在这个例子中,我们将使用一个简单的人员数组:

// New Observable stream of string array values
const people$ = Observable.of(['Paul', 'Katie', 'Bob']);

这样一来,我们就可以在订阅对象内订阅这个Observable。如果您以前使用过 Angular,这样可以让我们访问Observable(并处理必要的取消订阅),类似于 Async 管道:

export default {
  data () {
    return {
      msg: 'Welcome to Your Vue.js App'
    }
  },
  /**
   * Bind to Observable using the subscriptions object.
   * Allows us to then access the values of people$ inside of our template.
   * Similar to the Async pipe within Angular
   **/
  subscriptions: {
    people$
  }
}

除此之外,如果我们想要为每个组件创建一个新的Observable实例,我们可以将我们的订阅声明为一个函数:

 subscriptions() {
   const people$ = Observable.of(['Paul', 'Katie', 'Bob'])
   return {
     people$
   }
 }

最后,我们可以在我们的模板中显示Observable的结果。我们可以使用v-for指令来遍历数组并在屏幕上显示结果。这是如何工作的?使用以下示例,v-for语法使用item in items语法,可以在我们的上下文中被认为是person in people$。这允许我们访问我们的people$ Observable(或任何其他数组)中的每个项目与插值绑定:

<template>
  <div id="app"> 
    <ul>
      <li
        v-for="(person,index) in people$":key="index"> {{person}}
      </li>
    </ul>
  </div>
</template>

正如您在浏览器中所看到的,我们的三个项目现在已经出现在列表项中的屏幕上:

遍历 RxJS Observables

总结

在本章中,我们看了如何利用 Vue CLI 来搭建新的 Vue 项目,并配置适当的打包配置和 ES2015 支持。我们看到这不仅给了我们额外的能力,而且在长远来看也节省了大量时间。我们不必记住如何创建 Webpack 或 Babel 配置,因为这一切都由起始模板为我们处理;但即使如此,如果我们想添加额外的配置选项,我们也可以。

然后我们看了如何使用 Webpack 和ts-loader来实现 TypeScript,以及利用属性装饰器来利用常见的 TypeScript 和 Vue 模式。这使我们能够利用核心工具,并帮助减少代码中的错误。

最后,我们还在我们的应用程序中实现了 RxJS 和 Vue-Rx,以利用 Observable 模式。如果您有兴趣在项目中使用 RxJS,这是未来集成的良好起点。

在下一章中,我们将更深入地了解 Vue.js 实例和不同的保留属性,比如 data、computed、watch,以及创建 getter 和 setter。本章特别考虑了何时应该使用 computed 来使用或监视属性。

在本节中,我们将通过查看 Vue 如何处理来了解 Vue.js 实例在更低级别上是如何工作的。我们还将查看实例上的各种属性,如 data、computed、watch,以及在使用每个属性时的最佳实践。此外,我们将查看 Vue 实例中可用的各种生命周期钩子,允许我们在应用程序的各个阶段调用特定函数。最后,我们将调查文档对象模型DOM)以及 Vue 为了增强性能而实现虚拟 DOM。

在本章结束时,您将:

  • 更好地理解this关键字在 JavaScript 中的工作原理

  • 了解 Vue 如何在 Vue 实例中代理this关键字

  • 使用数据属性创建响应式绑定

  • 使用计算属性基于我们的数据模型创建声明性函数

  • 使用 watched 属性访问异步数据,并在计算属性的基础上构建

  • 使用生命周期钩子在 Vue 生命周期的特定阶段激活功能

  • 调查 DOM 和虚拟 DOM,了解 Vue 如何将数据渲染到屏幕上

首先,让我们来看看这在 JavaScript 中是如何工作的,以及这与我们 Vue 实例中的上下文有什么关系。

代理

到目前为止,你可能已经与 Vue 应用程序互动,并且想过:this是如何工作的呢?在了解 Vue.js 如何处理this之前,让我们先看看它在 JavaScript 中是如何工作的。

在 JavaScript 中'this'的工作原理

在 JavaScript 中,this有不同的上下文,从全局窗口上下文到 eval、newable 和函数上下文。由于默认上下文与全局作用域相关,因此这是我们的 window 对象:

/**
 * Outputting the value of this to the console in the global context returns the Window object
 */
console.log(this);

/**
 * When referencing global Window objects, we don't need to refer to them with this, but if we do, we get the same behavior
 */
alert('Alert one');
this.alert('Alert two');

在作用域中,this 的上下文会发生变化。这意味着,如果我们有一个Student对象,其中包含特定的值,比如firstNamelastNamegrades等等,那么this的上下文将与对象本身相关:

/**
 * The context of this changes when we enter another lexical scope, take our Student object example:
 */
const Student = {
 firstName: 'Paul',
 lastName: 'Halliday',
 grades: [50, 95, 70, 65, 35],
 getFullName() {
  return `${this.firstName} ${this.lastName}` 
 },
 getGrades() {
  return this.grades.reduce((accumulator, grade) => accumulator + grade);
 },
 toString() {
  return `Student ${this.getFullName()} scored ${this.getGrades()}/500`;
 }
}

当我们用console.log(Student.toString())运行上述代码时,我们得到了这个结果:Student Paul Halliday scored 315/500,因为此时this的上下文现在是对象本身,而不是全局窗口作用域。

如果我们想要在文档中显示这个,我们可以这样做:

let res = document.createTextNode(Student.toString());
let heading = document.createElement('h1');
heading.appendChild(res);
document.body.appendChild(heading);

请注意,通过上述代码,我们再次不需要使用this,因为它在全局上下文中是不需要的。

现在我们对this的基本工作原理有了一定的了解,我们可以研究 Vue 如何代理我们实例中的this,以便更轻松地与各种属性进行交互。

Vue 如何处理'this'

到目前为止,你可能已经注意到我们能够使用this语法引用数据、方法和其他对象中的值,但我们实例的实际结构是this.data.propertyNamethis.methods.methodName;所有这些都是由于 Vue 代理了我们实例的属性。

让我们来看一个非常简单的 Vue 应用程序,它只有一个实例。我们有一个data对象,其中有一个message变量和一个名为showAlert的方法;Vue 是如何知道如何通过this.message访问我们的警报文本的呢?

<template>
 <button @click="showAlert">
 Show Alert</button>
</template>

<script>
export default {
 data() {
  return {
   message: 'Hello World!',
  };
 },
 methods: {
  showAlert() {
   alert(this.message);
  },
 },
};
</script>

Vue 将实例属性代理到顶层对象,使我们能够通过this访问这些属性。如果我们将实例记录到控制台(借助 Vue.js 开发工具),我们会得到以下结果:

控制台输出

在前面的截图中要查看的关键属性是messageshowAlert,它们都在我们的 Vue 实例上定义,但在初始化时被代理到根对象实例。

数据属性

当我们向数据对象添加一个变量时,实质上是创建了一个响应式属性,它会在任何更改时更新视图。这意味着,如果我们有一个数据对象,其中有一个名为firstName的属性,那么每次该值更改时,该属性都会在屏幕上重新渲染:

<!DOCTYPE html>
<html>
<head>
 <title>Vue Data</title>
 <script src="https://unpkg.com/vue"></script>
</head>
<body>
 <div id="app">
  <h1>Name: {{ firstName }}</h1>
  <input type="text" v-model="firstName">
 </div>

 <script>
 const app = new Vue({
  el: '#app',
  data: {
   firstName: 'Paul'
  }
 });
 </script>
</body>
</html>

这种响应性不会扩展到在数据对象之外创建的对象添加到我们的 Vue 实例之后。如果我们有另一个例子,但这次包括将另一个属性(如fullName)附加到实例本身:

<body>
 <div id="app">
  <h1>Name: {{ firstName }}</h1>
  <h1>Name: {{ name }}</h1>
  <input type="text" v-model="firstName">
 </div>

 <script>
 const app = new Vue({
  el: '#app',
  data: {
   firstName: 'Paul'
  }
 });
 app.fullName = 'Paul Halliday';
 </script>
</body>

即使这个项目在根实例上(与我们的firstName变量一样),fullName也不是响应式的,不会在任何更改时重新渲染。这是因为当 Vue 实例初始化时,它会遍历每个属性并为每个数据属性添加 getter 和 setter,因此,如果我们在初始化后添加一个新属性,它就缺少了这个并且不是响应式的。

Vue 如何实现响应式属性?目前,它使用Object.defineProperty为实例内的项目定义自定义 getter/setter。让我们在具有标准get/set功能的对象上创建自己的属性:

 const user = {};
 let fullName = 'Paul Halliday';

 Object.defineProperty(user, 'fullName', {
  configurable: true,
  enumerable: true,
  get() {
   return fullName;
  },
  set(v) {
   fullName = v;
  }
 });

 console.log(user.fullName); // > Paul Halliday
 user.fullName = "John Doe";
 console.log(user.fullName); // > John Doe

由于观察者是使用自定义属性的 setter/getter 设置的,因此在初始化后仅仅添加一个属性到实例中并不会实现响应性。这可能会在 Vue 3 中发生变化,因为它将使用更新的 ES2015+ Proxy API(但可能缺乏对旧版浏览器的支持)。

我们的 Vue 实例不仅仅是一个数据属性!让我们使用计算属性来创建基于数据模型内项目的响应式派生值。

计算属性

在本节中,我们将查看 Vue 实例中的计算属性。这使我们能够创建基于数据模型内项目的小型声明性函数,返回一个基于数据模型内项目的单一值。为什么这很重要?如果我们将所有逻辑都放在模板内,我们的团队成员和未来的自己都需要更多的工作来理解我们的应用程序在做什么。

因此,我们可以使用计算属性来大大简化我们的模板,并创建我们可以引用的变量,而不是逻辑本身。它不仅仅是一个抽象;计算属性是被缓存的,并且只有在依赖项发生变化时才会重新计算。

让我们创建一个简单的项目来看看它的实际效果:

# Create a new Vue.js project
$ vue init webpack-simple computed 

# Change directory
$ cd computed

# Install dependencies
$ npm install

# Run application
$ npm run dev

插值是强大的;例如,在我们的 Vue.js 模板中,我们可以使用 reverse() 方法来取一个字符串(例如,firstName)并将其反转:

<h1>{{  firstName.split('').reverse().join('') }}</h1>

现在我们将展示一个反转后的 firstName,所以 Paul 将变成 luaP。这样做的问题在于,在模板中保留逻辑并不是非常实际的。如果我们想要反转多个字段,我们必须在每个属性上添加另一个 split()reverse()join()。为了使这更具声明性,我们可以使用计算属性。

App.vue 中,我们可以添加一个新的计算对象,其中包含一个名为 reversedName 的函数;这个函数包含了我们反转字符串的逻辑,并允许我们将其抽象成一个包含逻辑的函数,否则会污染模板:

<template>
 <h1>Name: {{ reversedName }}</h1>
</template>

<script>
export default {
 data() {
  return {
   firstName: 'Paul'
  }
 },
 computed: {
  reversedName() {
   return this.firstName.split('').reverse().join('')
  }
 }
}
</script>

然后我们可以在 Vue.js devtools 中看到关于我们计算属性的更多信息:

使用 devtools 显示数据

在我们的简单例子中,重要的是要意识到,虽然我们可以将这个方法,但是有理由将其保留为一个计算属性。计算属性是被缓存的,除非它们的依赖项发生了变化,它们不会被重新渲染,这在我们有一个更大的数据驱动应用程序时尤其重要。

监视属性

计算属性并不总是解决我们的响应式数据问题的答案,有时我们需要创建自己的自定义监视属性。计算属性只能是同步的,必须是纯的(例如,没有观察到的副作用),并且返回一个值;这与监视属性形成了鲜明对比,后者通常用于处理异步数据。

一个被监视的属性允许我们在数据改变时反应性地执行一个函数。这意味着我们可以在数据对象中的每个项目改变时调用一个函数,并且我们将可以访问到这个改变的值作为参数。让我们通过一个简单的例子来看一下:

注意:Axios 是一个需要添加到项目中的库。要这样做,前往 github.com/axios/axios 并按照提供的安装步骤进行操作。

<template>
 <div>
  <input type="number" v-model="id" />
  <p>Name: {{user.name}}</p>
  <p>Email: {{user.email}}</p>
  <p>Id: {{user.id}}</p>
 </div>
</template>

<script>
import axios from 'axios';

export default {
 data() {
  return {
   id: '',
   user: {}
  }
 },
 methods: {
  getDataForUser() { 
   axios.get(`https://jsonplaceholder.typicode.com/users/${this.id}`)
 .then(res => this.user = res.data);
  }
 },
 watch: {
  id() {
   this.getDataForUser();
  }
 }
}
</script>

在这个例子中,每当我们的文本框使用新的id(1-10)更改时,我们会得到关于特定用户的信息,如下所示:

这实际上是在监视id上的任何更改,并调用getDataForUser方法,检索有关此用户的新数据。

尽管观察属性确实具有很大的威力,但计算属性在性能和易用性方面的好处不容忽视;因此,在可能的情况下,应优先选择计算属性而不是观察属性。

生命周期钩子

我们可以访问各种生命周期钩子,在创建 Vue 实例的过程中的特定时刻触发。这些钩子从beforeCreate到实例mounteddestroyed等等。

如下图所示,创建新的 Vue 实例会在实例生命周期的不同阶段触发函数。

我们将在本节中看看如何激活这些钩子:

Vue.js 实例生命周期钩子

利用生命周期钩子(vuejs.org/v2/guide/instance.html)可以以与 Vue 实例上的任何其他属性类似的方式完成。让我们看看如何与每个钩子交互,从顶部开始;我将基于标准的webpack-simple模板创建另一个项目:

// App.vue
<template>
</template>

<script>
export default {
 data () {
   return {
    msg: 'Welcome to Your Vue.js App'
   }
 },
 beforeCreate() {
  console.log('beforeCreate'); 
 },
 created() {
  console.log('created');
 }
}
</script>

请注意,我们只是将这些函数添加到我们的实例中,而没有任何额外的导入或语法。然后我们在控制台中得到两个不同的日志声明,一个是在创建实例之前,另一个是在创建之后。我们实例的下一个阶段是beforeMountedmounted钩子;如果我们添加这些,我们将能够再次在控制台上看到一条消息:

beforeMount() {
 console.log('beforeMount');
},
mounted() {
 console.log('mounted');
}

如果我们修改模板,使其具有更新我们的数据属性的按钮,我们将能够触发beforeUpdatedupdated钩子:

<template>
 <div>
  <h1>{{msg}}</h1>
  <button @click="msg = 'Updated Hook'">Update Message</button>
 </div>
</template>

<script>
export default {
 data () {
   return {
    msg: 'Welcome to Your Vue.js App'
   }
 },
 beforeCreate() {
  console.log('beforeCreate'); 
 },
 created() {
  console.log('created');
 },
 beforeMount() {
  console.log('beforeMount');
 },
 mounted() {
  console.log('mounted');
 },
 beforeUpdated() {
  console.log('beforeUpdated'); 
 },
 updated() {
  console.log('updated');
 }
}
</script>

每当我们选择Update Message按钮时,我们的beforeUpdatedupdated钩子都会触发。这使我们能够在生命周期的这个阶段执行操作,只剩下beforeDestroydestroyed尚未涵盖。让我们向我们的实例添加一个按钮和一个调用$destroy的方法,从而触发适当的生命周期钩子:

<template>
  <div>
    <h1>{{msg}}</h1>
    <button @click="msg = 'Updated Hook'">Update Message
    </button>
    <button @click="remove">Destroy instance</button>
  </div>
</template>

然后我们可以将remove方法添加到我们的实例中,以及允许我们捕获适当的钩子的函数:

methods: {
  remove(){
   this.$destroy();
  }
},
// Other hooks
  beforeDestroy(){
  console.log("Before destroy");
},
  destroyed(){
  console.log("Destroyed");
}

当我们选择destroy实例按钮时,beforeDestroydestroy生命周期钩子将触发。这使我们能够在销毁实例时清理任何订阅或执行任何其他操作。在实际场景中,生命周期控制应该交给数据驱动的指令,比如v-ifv-for。我们将在下一章更详细地讨论这些指令。

Vue.js 和虚拟 DOM

在性能改进方面,让我们考虑为什么 Vue.js 广泛使用虚拟 DOM 来在屏幕上呈现我们的元素。在看虚拟 DOM 之前,我们需要对 DOM 的实际含义有一个基本的理解。

DOM

DOM 用于描述 HTML 或 XML 页面的结构。它创建了一个类似树状的结构,使我们能够在 JavaScript 中进行创建、读取、更新和删除节点以及遍历树等许多功能。让我们考虑以下 HTML 页面:

<!DOCTYPE html>
<html lang="en">
<head>
 <title>DOM Example</title>
</head>
<body>
 <div&gt;
  <p>I love JavaScript!</p>
  <p>Here's a list of my favourite frameworks:</p>
  <ul>
   <li>Vue.js</li>
   <li>Angular</li>
   <li>React</li>
  </ul>
 </div>

 <script src="app.js"></script>
</body>
</html>

我们可以查看 HTML,看到我们有一个div,两个p,一个ulli标签。浏览器解析这个 HTML 并生成 DOM 树,高层次上看起来类似于这样:

然后我们可以与 DOM 交互,通过document.getElementsByTagName()TagName获取这些元素的访问权限,返回一个 HTML 集合。如果我们想要映射这些集合对象,我们可以使用Array.from创建一个这些元素的数组。以下是一个例子:

const paragraphs = Array.from(document.getElementsByTagName('p'));
const listItems = Array.from(document.getElementsByTagName('li'));

paragraphs.map(p => console.log(p.innerHTML));
listItems.map(li => console.log(li.innerHTML));

这样应该会在我们的数组中将每个项目的innerHTML记录到控制台中,从而显示我们如何访问 DOM 中的项目:

虚拟 DOM

更新 DOM 节点的计算成本很高,取决于应用程序的大小,这可能会大大降低应用程序的性能。虚拟 DOM 采用了 DOM 的概念,并为我们提供了一个抽象,允许使用差异算法来更新 DOM 节点。为了充分利用这一点,这些节点不再使用 document 前缀访问,而是通常表示为 JavaScript 对象。

这使得 Vue 能够准确地确定什么发生了变化,并且只重新渲染与之前不同的实际 DOM 中的项目。

总结

在本章中,我们更多地了解了 Vue 实例以及如何利用各种属性类型,如数据、监视器、计算值等。我们了解了 JavaScript 中this的工作原理,以及在 Vue 实例中使用它时的区别。此外,我们还调查了 DOM 以及 Vue 为什么使用虚拟 DOM 来创建高性能应用程序。

总之,数据属性允许我们在模板中使用响应式属性,计算属性允许我们将模板和过滤逻辑分离成可在模板中访问的高性能属性,而监视属性则允许我们处理异步操作的复杂性。

在下一章中,我们将深入研究 Vue 指令,比如v-ifv-modelv-for,以及它们如何用于创建强大的响应式应用程序。

第三章:使用 Vue 编写干净、精简的代码

在本节中,我们将调查 Vue.js 实例在更低级别上的工作方式,看看 Vue 是如何处理 this 的。我们还将查看实例上的各种属性,如数据、计算、观察,以及在使用每个属性时的最佳实践。此外,我们将查看 Vue 实例中可用的各种生命周期钩子,允许我们在应用程序的各个阶段调用特定函数。最后,我们将调查文档对象模型DOM)以及为什么 Vue 实现了虚拟 DOM 以提高性能。

在本章结束时,您将:

  • 更好地理解 JavaScript 中this关键字的工作原理

  • 了解 Vue 如何在 Vue 实例中代理this关键字

  • 使用数据属性创建响应式绑定

  • 使用计算属性基于我们的数据模型创建声明性函数

  • 使用观察属性访问异步数据,并在计算属性的基础上构建

  • 使用生命周期钩子在 Vue 生命周期的特定阶段激活功能

  • 调查 DOM 和虚拟 DOM,以了解 Vue 如何将数据渲染到屏幕上

首先,让我们来看看在 JavaScript 中 this 是如何工作的,以及它如何与我们的 Vue 实例中的上下文相关联。

代理

到目前为止,您可能已经与 Vue 应用程序进行了交互,并想过:this是如何工作的?在查看 Vue.js 如何处理this之前,让我们先看看它在 JavaScript 中是如何工作的。

了解 JavaScript 中的'this'工作原理

在 JavaScript 中,this具有不同的上下文,从全局窗口上下文到 eval、可实例化和函数上下文。由于默认上下文与全局范围相关,因此 this 是我们的窗口对象:

/**
 * Outputting the value of this to the console in the global context returns the Window object
 */
console.log(this);

/**
 * When referencing global Window objects, we don't need to refer to them with this, but if we do, we get the same behavior
 */
alert('Alert one');
this.alert('Alert two');

this 的上下文会根据我们所处的范围而改变。这意味着,如果我们有一个Student对象,具有特定的值,比如firstNamelastNamegrades等,那么this的上下文将与对象本身相关联:

/**
 * The context of this changes when we enter another lexical scope, take our Student object example:
 */
const Student = {
 firstName: 'Paul',
 lastName: 'Halliday',
 grades: [50, 95, 70, 65, 35],
 getFullName() {
  return `${this.firstName} ${this.lastName}` 
 },
 getGrades() {
  return this.grades.reduce((accumulator, grade) => accumulator + grade);
 },
 toString() {
  return `Student ${this.getFullName()} scored ${this.getGrades()}/500`;
 }
}

当我们运行上述代码并使用console.log(Student.toString())时,我们会得到这个结果:Student Paul Halliday scored 315/500,因为此时 this 的上下文现在是对象本身,而不是全局窗口范围。

如果我们想要在文档中显示 this,我们可以这样做:

let res = document.createTextNode(Student.toString());
let heading = document.createElement('h1');
heading.appendChild(res);
document.body.appendChild(heading);

请注意,在上述代码中,我们再次不必使用this,因为它在全局上下文中不需要。

现在我们对this的基本工作原理有了一定的了解,我们可以调查 Vue 如何在实例内部代理this,以便更轻松地与各种属性进行交互。

Vue 如何处理'this'

到目前为止,您可能已经注意到我们能够使用this语法引用数据、方法和其他对象中的值,但实例的实际结构是this.data.propertyNamethis.methods.methodName;所有这些都是由 Vue 代理我们的实例属性实现的。

让我们来看一个非常简单的 Vue 应用程序,它只有一个实例。我们有一个data对象,其中有一个message变量和一个名为showAlert的方法;Vue 如何知道如何通过this.message访问我们的警报文本?

<template>
 <button @click="showAlert">
 Show Alert</button>
</template>

<script>
export default {
 data() {
  return {
   message: 'Hello World!',
  };
 },
 methods: {
  showAlert() {
   alert(this.message);
  },
 },
};
</script>

Vue 将实例属性代理到顶层对象,使我们可以通过 this 访问这些属性。如果我们将实例记录到控制台(借助 Vue.js 开发工具),我们会得到以下结果:

控制台记录

在前面的屏幕截图中要查看的关键属性是messageshowAlert,它们都在我们的 Vue 实例上定义,但在初始化时代理到根对象实例。

数据属性

当我们向数据对象添加变量时,实质上是创建了一个在任何更改时更新视图的响应式属性。这意味着,如果我们有一个名为firstName的属性的数据对象,每次该值更改时,该属性都会在屏幕上重新渲染:

<!DOCTYPE html>
<html>
<head>
 <title>Vue Data</title>
 <script src="https://unpkg.com/vue"></script>
</head>
<body>
 <div id="app">
  <h1>Name: {{ firstName }}</h1>
  <input type="text" v-model="firstName">
 </div>

 <script>
 const app = new Vue({
  el: '#app',
  data: {
   firstName: 'Paul'
  }
 });
 </script>
</body>
</html>

这种响应性不会扩展到在创建 Vue 实例后添加到实例之外的数据对象。如果我们有另一个示例,但这次包括向实例本身添加另一个属性,例如fullName

<body>
 <div id="app">
  <h1>Name: {{ firstName }}</h1>
  <h1>Name: {{ name }}</h1>
  <input type="text" v-model="firstName">
 </div>

 <script>
 const app = new Vue({
  el: '#app',
  data: {
   firstName: 'Paul'
  }
 });
 app.fullName = 'Paul Halliday';
 </script>
</body>

尽管此项位于根实例上(与我们的firstName变量相同),但fullName不是响应式的,不会在任何更改时重新渲染。这是因为当 Vue 实例初始化时,它会遍历每个属性,并为每个数据属性添加 getter 和 setter,因此,如果在初始化后添加新属性,则缺少此属性并且不是响应式的。

Vue 如何实现响应式属性?目前,它使用Object.defineProperty为实例内部的项目定义自定义的 getter/setter。让我们在具有标准get/set功能的对象上创建自己的属性:

 const user = {};
 let fullName = 'Paul Halliday';

 Object.defineProperty(user, 'fullName', {
  configurable: true,
  enumerable: true,
  get() {
   return fullName;
  },
  set(v) {
   fullName = v;
  }
 });

 console.log(user.fullName); // > Paul Halliday
 user.fullName = "John Doe";
 console.log(user.fullName); // > John Doe

由于观察者是使用自定义属性 setter/getter 设置的,所以在初始化之后仅仅添加一个属性到实例中并不会导致响应性。这在 Vue 3 中可能会发生变化,因为它将使用更新的 ES2015+ Proxy API(但可能不支持旧版浏览器)。

我们的 Vue 实例不仅仅是一个数据属性!让我们使用计算属性来创建基于数据模型中的项目的反应性派生值。

计算属性

在这一部分,我们将看看 Vue 实例中的计算属性。这允许我们创建小的、声明性的函数,根据数据模型中的项目返回一个单一的值。为什么这很重要呢?嗯,如果我们把所有的逻辑都放在模板里,我们的团队成员和未来的自己都需要做更多的工作来理解我们的应用程序在做什么。

因此,我们可以使用计算属性来大大简化我们的模板,并创建我们可以引用的变量,而不是逻辑本身。它不仅仅是一个抽象;计算属性是被缓存的,除非依赖项发生变化,否则不会重新计算。

让我们创建一个简单的项目来看看它的运作方式:

# Create a new Vue.js project
$ vue init webpack-simple computed 

# Change directory
$ cd computed

# Install dependencies
$ npm install

# Run application
$ npm run dev

插值是强大的;例如,在我们的 Vue.js 模板中,我们可以使用reverse()方法来取一个字符串(例如firstName)并将其反转:

<h1>{{  firstName.split('').reverse().join('') }}</h1>

现在我们将展示我们的firstName的反转版本,所以 Paul 会变成 luaP。这样做的问题是,在模板内部保持逻辑并不是很实际。如果我们想要反转多个字段,我们就必须在每个属性上添加另一个split()reverse()join()。为了使这更具声明性,我们可以使用计算属性。

App.vue中,我们可以添加一个包含名为reversedName的函数的新计算对象;这个函数采用我们反转字符串的逻辑,并允许我们将其抽象成一个包含逻辑的函数,否则会污染模板:

<template>
 <h1>Name: {{ reversedName }}</h1>
</template>

<script>
export default {
 data() {
  return {
   firstName: 'Paul'
  }
 },
 computed: {
  reversedName() {
   return this.firstName.split('').reverse().join('')
  }
 }
}
</script>

然后我们可以在 Vue.js devtools 中看到关于我们计算属性的更多信息:

使用 devtools 显示数据

在我们简单的例子中,重要的是要意识到,虽然我们可以把这个方法做成一个方法,但是有理由让它作为一个计算属性。计算属性是被缓存的,除非它们的依赖项发生变化,否则它们不会被重新渲染,这在我们有一个更大的数据驱动应用程序时尤为重要。

观察属性

计算属性并不总是解决我们的响应式数据问题的答案,有时我们需要创建自己的自定义监视属性。计算属性只能是同步的,必须是纯的(例如,没有观察到的副作用),并返回一个值;这与监视属性形成鲜明对比,后者通常用于处理异步数据。

监视属性允许我们在数据更改时反应性地执行函数。这意味着我们可以在数据对象中的每个项目更改时调用函数,并且我们将可以访问此更改后的值作为参数。让我们通过一个简单的例子来看一下:

注意:Axios是一个需要添加到项目中的库。要这样做,请前往github.com/axios/axios并按照提供的安装步骤进行操作。

<template>
 <div>
  <input type="number" v-model="id" />
  <p>Name: {{user.name}}</p>
  <p>Email: {{user.email}}</p>
  <p>Id: {{user.id}}</p>
 </div>
</template>

<script>
import axios from 'axios';

export default {
 data() {
  return {
   id: '',
   user: {}
  }
 },
 methods: {
  getDataForUser() { 
   axios.get(`https://jsonplaceholder.typicode.com/users/${this.id}`)
 .then(res => this.user = res.data);
  }
 },
 watch: {
  id() {
   this.getDataForUser();
  }
 }
}
</script>

在这个例子中,每当我们的文本框以新的id(1-10)更改时,我们就会获得有关该特定用户的信息,如下所示:

这实际上是在监视id上的任何更改,并调用getDataForUser方法,检索有关此用户的新数据。

尽管监视属性确实具有很大的能力,但计算属性在性能和易用性方面的优势不容小觑;因此,在可能的情况下,应优先考虑计算属性而不是监视属性。

生命周期钩子

我们可以在创建 Vue 实例期间的特定时间点触发各种生命周期钩子。这些钩子从beforeCreate之前的创建到mounted之后,destroyed等等。

如下图所示,创建一个新的 Vue 实例会在实例生命周期的不同阶段触发函数。

我们将看看如何在本节中激活这些钩子:

Vue.js 实例生命周期钩子

利用生命周期钩子(vuejs.org/v2/guide/instance.html)可以以与 Vue 实例上的任何其他属性类似的方式进行。让我们看看如何与每个钩子进行交互,从顶部开始;我将基于标准的webpack-simple模板创建另一个项目:

// App.vue
<template>
</template>

<script>
export default {
 data () {
   return {
    msg: 'Welcome to Your Vue.js App'
   }
 },
 beforeCreate() {
  console.log('beforeCreate'); 
 },
 created() {
  console.log('created');
 }
}
</script>

请注意,我们只是简单地将这些函数添加到我们的实例中,而没有任何额外的导入或语法。然后我们在控制台中得到两个不同的日志声明,一个是在创建实例之前,另一个是在创建实例之后。我们实例的下一个阶段是beforeMountedmounted钩子;如果我们添加这些,我们将能够再次在控制台上看到一条消息:

beforeMount() {
 console.log('beforeMount');
},
mounted() {
 console.log('mounted');
}

如果我们修改我们的模板,使其有一个按钮来更新我们的数据属性,我们将能够触发beforeUpdatedupdated钩子:

<template>
 <div>
  <h1>{{msg}}</h1>
  <button @click="msg = 'Updated Hook'">Update Message</button>
 </div>
</template>

<script>
export default {
 data () {
   return {
    msg: 'Welcome to Your Vue.js App'
   }
 },
 beforeCreate() {
  console.log('beforeCreate'); 
 },
 created() {
  console.log('created');
 },
 beforeMount() {
  console.log('beforeMount');
 },
 mounted() {
  console.log('mounted');
 },
 beforeUpdated() {
  console.log('beforeUpdated'); 
 },
 updated() {
  console.log('updated');
 }
}
</script>

每当我们选择Update Message按钮时,我们的beforeUpdatedupdated钩子都会触发。这使我们能够在生命周期的这个阶段执行一个操作,只留下beforeDestroydestroyed尚未覆盖。让我们向我们的实例添加一个按钮和一个调用$destroy的方法,从而触发适当的生命周期钩子:

<template>
  <div>
    <h1>{{msg}}</h1>
    <button @click="msg = 'Updated Hook'">Update Message
    </button>
    <button @click="remove">Destroy instance</button>
  </div>
</template>

然后我们可以向我们的实例添加remove方法,以及允许我们捕获适当钩子的函数:

methods: {
  remove(){
   this.$destroy();
  }
},
// Other hooks
  beforeDestroy(){
  console.log("Before destroy");
},
  destroyed(){
  console.log("Destroyed");
}

当我们选择destroy实例按钮时,beforeDestroydestroy生命周期钩子将触发。这使我们能够在销毁实例时清理任何订阅或执行任何其他操作。在现实世界的场景中,生命周期控制应该交给数据驱动的指令,比如v-ifv-for。我们将在下一章更详细地讨论这些指令。

Vue.js 和虚拟 DOM

在性能改进方面,让我们考虑为什么 Vue.js 广泛使用虚拟 DOM 来在屏幕上渲染我们的元素。在看虚拟 DOM 之前,我们需要对 DOM 的实际含义有一个基础的理解。

DOM

DOM 用于描述 HTML 或 XML 页面的结构。它创建了一个类似树状的结构,使我们能够在 JavaScript 中进行从创建、读取、更新和删除节点到遍历树等许多功能。让我们考虑以下 HTML 页面:

<!DOCTYPE html>
<html lang="en">
<head>
 <title>DOM Example</title>
</head>
<body>
 <div>
  <p>I love JavaScript!</p>
  <p>Here's a list of my favourite frameworks:</p>
  <ul>
   <li>Vue.js</li>
   <li>Angular</li>
   <li>React</li>
  </ul>
 </div>

 <script src="app.js"></script>
</body>
</html>

我们能够查看 HTML 并看到我们有一个div,两个p,一个ulli标签。浏览器解析这个 HTML 并生成 DOM 树,从高层次上看,它看起来类似于这样:

然后,我们可以通过document.getElementsByTagName()与 DOM 交互,返回一个 HTML 集合。如果我们想要映射这些集合对象,我们可以使用Array.from创建一个这些元素的数组。以下是一个例子:

const paragraphs = Array.from(document.getElementsByTagName('p'));
const listItems = Array.from(document.getElementsByTagName('li'));

paragraphs.map(p => console.log(p.innerHTML));
listItems.map(li => console.log(li.innerHTML));

这样应该会在我们的数组中记录每个项目的innerHTML到控制台,从而展示我们如何访问 DOM 中的项目:

虚拟 DOM

更新 DOM 节点在计算上是昂贵的,取决于应用程序的大小,这可能会大大降低应用程序的性能。虚拟 DOM 采用 DOM 的概念,并为我们提供了一个抽象,允许使用差异算法来更新 DOM 节点。为了充分利用这一点,这些节点不再使用 document 前缀访问,而是通常表示为 JavaScript 对象。

这使得 Vue 能够准确地确定什么发生了变化,并且只重新渲染实际 DOM 中与之前不同的项目。

总结

在本章中,我们更多地了解了 Vue 实例以及如何利用各种属性类型,如数据、watchers、计算值等。我们了解了在 JavaScript 中this的工作原理,以及在 Vue 实例中使用它时的区别。此外,我们调查了 DOM 以及为什么 Vue 使用虚拟 DOM 来创建高性能的应用程序。

总之,数据属性允许我们在模板中使用响应式属性,计算属性允许我们将模板和过滤逻辑分离成高性能的属性,可以在模板中访问,而 watched 属性允许我们处理异步操作的复杂性。

在下一章中,我们将深入了解 Vue 指令,比如v-ifv-modelv-for,以及它们如何用于创建强大的响应式应用程序。

第四章:Vue.js 指令

在编写 Vue 应用程序时,我们可以访问各种强大的指令,这些指令允许我们塑造内容在屏幕上的呈现方式。这使我们能够通过对 HTML 模板进行添加来打造高度交互式的用户体验。本章将详细介绍这些指令,以及任何缩写和模式,使我们能够改进我们的工作流程。

在本章结束时,您将学会:

  • 使用属性绑定来有条件地改变元素行为

  • 研究了使用v-model的双向绑定

  • 使用v-ifv-elsev-if-else有条件地显示信息

  • 使用v-for在集合中对项目进行迭代

  • 监听事件(如键盘/输入)使用v-on

  • 使用事件修饰符来改变指令的绑定

  • 使用过滤器来改变绑定的视图数据

  • 看了一下我们如何可以使用简写语法来节省时间并更具有声明性

模型

任何业务应用程序最常见的需求之一就是文本输入。Vue 通过v-model指令来满足我们的需求。它允许我们在表单输入事件上创建反应式的双向数据绑定,使得处理表单变得更加容易。这是对获取表单值和输入事件的一种方便的抽象。为了探索这一点,我们可以创建一个新的 Vue 项目:

# Create a new Vue project
$ vue init webpack-simple vue-model

# Navigate to directory
$ cd vue-model

# Install dependencies
$ npm install

# Run application
$ npm run dev

我们可以转到我们的根App.vue文件,从模板中删除所有内容,而是添加一个包含labelform输入的新div

<template>
 <div id="app">
  <label>Name:</label>
  <input type="text">
 </div>
</template>

这使我们能够向输入元素添加文本,即提示用户输入他们的姓名。我想捕获这个值并在姓名元素下方显示出来以进行演示。为了做到这一点,我们需要在输入元素中添加v-model指令;这将允许我们捕获用户输入事件并将值放入一个变量中。我们将这个变量称为name,并随后将其添加到我们 Vue 实例中的data对象中。现在值已经被捕获为一个变量,我们可以在模板中使用插值绑定来显示这个值:

<template>
  <div id="app">
    <label>Name:</label>
    <input type="text" v-model="name">
    <p>{{name}}</p>
  </div>
</template>

<script>
export default {
  data () {
    return {
     name: ''
    }
  }
}
</script>

结果可以在以下截图中看到:

在使用v-model时,我们不仅限于处理文本输入,还可以在选择时捕获单选按钮或复选框。以下示例展示了这一点:

 <input type="checkbox" v-model="checked">
 <span>Am I checked? {{checked ? 'Yes' : 'No' }}</span>

然后在我们的浏览器中显示如下:

v-model的好处是,它非常适应各种表单控件,让我们对 HTML 模板具有声明性的控制权。

使用 v-for 进行迭代

如果我们有想要重复一定次数的内容,我们可以使用v-for。这通常用于使用数据集填充模板。例如,假设我们有一个杂货清单,并且我们想要在屏幕上显示这个清单;我们可以使用v-for来做到这一点。我们可以创建一个新项目来看看它的运行情况:

# Create a new Vue project
$ vue init webpack-simple vue-for

# Navigate to directory
$ cd vue-for

# Install dependencies
$ npm install

# Run application
$ npm run dev

首先,让我们创建一个包含杂货清单的数组,我们可以在屏幕上显示。每个项目都有idnamequantity

<script>
export default {
  name: 'app',
  data () {
    return {
      groceries: [
        {
          id: 1,
          name: 'Pizza',
          quantity: 1
        },
        {
          id: 2,
          name: 'Hot Sauce',
          quantity: 5
        },
        {
          id: 3,
          name: 'Salad',
          quantity: 1
        },
        {
          id: 4,
          name: 'Water',
          quantity: 1
        },
        {
          id: 4,
          name: 'Yoghurt',
          quantity: 1
        }
      ]
    }
  }
}
</script>

<style>
#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 {
  list-style-type: none;
  padding: 0;
}

li {
  display: block;
}

</style>

然后,我们可以遍历我们的杂货清单中的每个项目,并修改 DOM 以在屏幕上显示它们:

<template>
  <div id="app">
    <h1>Shopping List</h1>
    <ul>
      <li v-for="item in groceries" v-bind:key="item.id">
        {{item.name}}
      </li>
    </ul>
  </div>
</template>

请注意,我们在li元素上有一个v-bind:key="item.id"。这使得 Vue 在随时间变化的迭代中更好地工作,并且应尽可能添加一个键:

绑定

在这一部分,我们将看看如何在 Vue 应用程序中动态切换 CSS 类。我们将首先调查v-bind指令,看看如何将其应用于classstyle属性。这对于根据特定业务逻辑有条件地应用样式非常有用。让我们为此示例创建一个新的 Vue 项目:

# Create a new Vue project
$ vue init webpack-simple vue-bind

# Navigate to directory
$ cd vue-bind

# Install dependencies
$ npm install

# Run application
$ npm run dev

在我们的项目中,我们可以创建代表应用程序不同状态的复选框。我们将从一个名为red的开始。正如您可能推断的那样,通过检查这个复选框,我们可以将特定的文本颜色变为red,然后通过取消选中它将其变为黑色。

App.vue中创建一个名为reddata对象,其值为false

<script>
export default {
 data () {
  return {
   red: false
  }
 }
}
</script>

这代表了我们复选框的值,我们将能够使用v-model指令来设置它:

<template>
 <div id="app">
  <h1>Vue Bindings</h1>

  <input type="checkbox" v-model="red" >
  <span>Red</span>
 </div>
</template>

此时,我们可以为我们的颜色创建一个新的 CSS 类:

<style>
.red {
 color: red;
}
</style>

正如您在浏览器中所看到的,如果我们打开开发工具,可以看到文本的颜色当前设置为blue

最后,为了根据red变量的上下文添加/删除类,我们需要在我们的h1上添加v-bind:class指令,如下所示:

<h1 v-bind:class="{ 'red': red }">Vue Bindings</h1>

现在在我们的浏览器中,我们可以看到我们有能力勾选框来将文本设置为red,就像这样:

添加次要属性

如果我们还想要向我们的类绑定添加另一个属性,我们需要在data对象中添加另一个属性(比如strikeThrough):

data () {
 return {
  red: false,
  strikeThrough: false
 }
}

然后我们可以添加另一个checkbox

<input type="checkbox" v-model="strikeThrough">
<span>Strike Through</span>

使用适当的style

<style>
.red {
 color: red;
}

.strike-through {
 text-decoration: line-through;
}
</style>

最后,我们需要调整我们的绑定以添加额外的类,就像这样:

<h1 v-bind:class="{ 'red': red, 'strike-through': strikeThrough }">Vue Bindings</h1>

这是勾选两个框的结果:

样式绑定

我们可能想要向我们的标题添加各种样式,因此,我们可以使用v-bind:style。通过在我们的data对象中创建一个名为headingStyles的新对象,我们可以看到这个功能的实际效果:

data () {
 return {
  headingStyles: {
   color: 'blue',
   fontSize: '20px',
   textAlign: 'center'
  }
 }
}

每当我们添加本应为 kebab-case 的 CSS 类(例如text-align)时,它们现在在我们的 JavaScript 中变为 camel-case(textAlign)。

让我们在模板中为我们的标题添加样式:

<h1 v-bind:style="headingStyles">Vue Bindings</h1>

每当编译器看到v-bind:时,"内的内容都被视为 JavaScript,具有隐式的this

我们还可以将其拆分为一个单独的对象,例如添加layoutStyles

data () {
 return {
  headingStyles: {
   color: 'blue',
   fontSize: '20px',
  },
  layoutStyles: {
   textAlign: 'center',
   padding: '10px'
  }
 }
}

所以我们现在需要在template中的数组中添加styles,就像在<h1>标签中使用v-bind一样:

<template>
 <h1 v-bind:style="[headingStyles, layoutStyles]">Vue Bindings</h1>
</template>

现在你可以在屏幕上看到我们的样式结果。请注意,数组中的任何后续项目都将优先采用首先声明的样式。

DOM 事件和 v-on

我们可以使用v-on在 Vue 中处理 DOM 事件。通过监听 DOM 事件,我们能够对用户输入做出反应,从按键事件(比如点击Enter按钮)到按钮点击事件等等。

让我们创建一个试验项目来尝试在我们自己的项目中使用这个功能:

# Create a new Vue project
$ vue init webpack-simple vue-on

# Navigate to directory
$ cd vue-on

# Install dependencies
$ npm install

# Run application
$ npm run dev

假设有一个input框,当我们点击添加按钮或按下Enter键时,输入将被添加到数组中:

<template>
 <div id="app">
  <ul>
   <li v-for="(p, index) in person" :key="index">
    {{p}}
   </li>
  </ul>
  <input type="text" v-model="person" v-on:keyup.enter="addPerson" />
  <button v-on:click="addPerson">Add {{ person}} </button>
 </div>
</template>

<script>
export default {
 name: 'app',
 data () {
  return {
   person: '',
   people: []
  }
 },
 methods: {
  addPerson() {
   this.people = this.people.concat(
    {id: this.people.length, name: this.person}
   );
  this.person = '';
  }
 }
}
</script>

在将其推入之前,您必须复制对象。

这里到底发生了什么?我们使用v-model指令捕获了用户输入的值,然后我们监听了keyup.enterv-on:click事件,两者都调用了addPerson函数,随后将person添加到数组中。之后,使用v-for指令,我们能够将这个人员列表输出到页面上:

按键修饰符

我们不仅仅局限于使用enter修饰符,我们还可以使用各种简写修饰符,例如使用@符号和缩短v-on:event.name v-on:,用@符号替换它。其他缩写方法包括:

  • @v-on:相同

  • @keyup.13@keyup.enter相同

  • @key*可以排队,例如@keyup.ctrl.alt.delete

其他修饰符可以在下表中看到:

名称 描述 代码示例
.enter 每当按下Enter键时。 <input v-on:keyup.enter="myFunction" />
.tab 每当按下Tab键时。 <input v-on:keyup.tab="myFunction" />
.delete 每当按下DeleteBackspace键时。 <input v-on:keyup.delete="myFunction" />
.esc 每当按下Esc键时。 <input v-on:keyup.esc="myFunction" />
.up 每当按下上箭头键时。 <input v-on:keyup.up="myFunction" />
.down 每当按下下箭头键时。 <input v-on:keyup.down="myFunction" />
.left 每当按下左箭头键时。 <input v-on:keyup.left="myFunction" />
.right 每当按下右箭头键时。 <input v-on:keyup.right="myFunction" />

事件修饰符

通常在 JavaScript 中处理事件时,我们会修改事件本身的功能。这意味着我们需要在处理程序中添加event.preventDefault()event.stopPropagation()。Vue 通过在模板中使用事件修饰符来处理这些调用,帮助我们抽象化这些调用。

这最好通过一个form示例来展示。让我们以前面的人员示例为例,并修改为包含一个form元素:

<template>
  <div id="app">
    <ul>
      <li v-for="p in people" v-bind:key="p.id" >
        {{p}}
      </li>
    </ul>

    <form v-on:submit="addPerson">
      <input type="text" v-model="person" />
      <button>Add {{ person}} </button>
    </form>
  </div>
</template>

如果您尝试运行此示例,您会注意到当我们点击“添加”按钮时,页面会刷新。这是因为这是form提交事件的默认行为。由于我们此时没有向服务器 POST 数据,因此我们需要在我们的submit事件中添加.prevent修饰符:

 <form v-on:submit.prevent="addPerson">
  <input type="text" v-model="person" />
  <button>Add {{ person}} </button>
 </form>

现在当我们选择我们的按钮时,addPerson函数被调用而不刷新页面。

有条件地显示 DOM 元素

在创建业务应用程序时,会有许多时候,您只想在某个条件为truefalse时显示特定的元素。这可能包括用户的年龄,用户是否已登录,是否为管理员或您能想到的任何其他业务逻辑片段。

对于这一点,我们有各种条件指令,如v-showv-ifv-elsev-else-if,它们都以类似但不同的方式起作用。让我们通过创建一个新的示例项目来更详细地了解这一点:

# Create a new Vue project
$ vue init webpack-simple vue-conditionals

# Navigate to directory
$ cd vue-conditionals

# Install dependencies
$ npm install

# Run application
$ npm run dev

v-show

如果我们想要隐藏元素但仍然在 DOM 中拥有它们(有效地display:none),我们可以使用v-show

<template>
<div id="app">
 <article v-show="admin">
  <header>Protected Content</header>
 <section class="main">
  <h1>If you can see this, you're an admin!</h1>
 </section>
</article>

 <button @click="admin = !admin">Flip permissions</button>
</div>
</template>

<script>
export default{
name: 'app',
 data (){
  return{
   admin: true
    }
  }
}
</script>

例如,如果我们有一个数据变量,可以确定某人是否是管理员,我们可以使用v-show只向适当的用户显示受保护的内容:

请注意,在前面的图中,当admin设置为false时,display: none样式被添加到元素中。乍一看,这似乎就是我们想要的,我们的项目已经消失了!在某些情况下,这是正确的,但在其他情况下,使用v-if可能更好。

v-show不会从 DOM 中移除元素,这意味着一切都会被初始加载,如果没有被使用,就会被隐藏起来。我们的页面将不得不渲染这些内容,如果使用不当可能会导致性能问题;因此在使用v-show之前要问这个问题:

我需要再次显示这个组件吗?如果是,会经常显示吗?

如果对这个问题的答案是,那么在这种情况下v-show可能更好。否则,如果对这个问题的答案是,那么在这种用例中v-if可能更好。

v-if

如果我们想有条件地从 DOM 中移除元素,我们可以使用v-if。让我们用v-if替换之前的v-show指令:

 <article v-if="admin">
  <header>Protected Content</header>
  <section class="main">
   <h1>If you can see this, you're an admin!</h1>
  </section>
 </article>

请注意,现在当我们查看 DOM 时,元素已完全被移除:

v-else

在显示或隐藏元素时的常见模式是显示不同的内容。虽然我们可以多次使用v-ifv-show,但我们也可以使用v-else指令,它可以直接在显示或隐藏元素之后使用。

让我们更详细地了解一下这一点:

<article v-if="admin">
  <header>Protected Content</header>
  <section class="main">
    <h1>If you can see this, you're an admin!</h1>
  </section>
</article>
<article v-else>
  <header>You're not an admin!</header>
  <section class="main">
    <h1>Perhaps you shouldn't be here.</h1>
  </section>
</article>

通过在第二个<article>中添加v-else指令,我们告诉 Vue 我们希望在第一个条件隐藏时显示这个 DOM 元素。由于这种工作方式,我们不必向v-else传递一个值,因为 Vue 明确地在前面的元素中寻找结构指令。

重要的是要意识到,如果在v-ifv-else指令之间有一个元素,这种方法是行不通的,比如这样:

<article v-if="admin">
  <header>Protected Content</header>
  <section class="main">
    <h1>If you can see this, you're an admin!</h1>
  </section>
</article>
<h1>The v-else will be ignored.</h1>
<article v-else>
  <header>You're not an admin!</header>
  <section class="main">
    <h1>Perhaps you shouldn't be here.</h1>
  </section>
</article>

v-else-if

虽然v-else在标准的IF NOT A then B场景中运行良好,但您可能希望测试多个值并显示不同的模板。类似于v-else,我们可以使用v-else-if来改变应用程序的行为。在这个例子中,我们将通过使用 ES2015 引入的生成器来玩耍。

要使用生成器,我们需要安装babel-polyfill包;这也允许我们更好地处理asyncawait等内容:

$ npm install babel-polyfill --save-dev

安装完成后,我们可以修改我们的 Webpack 配置(webpack.config.js)将其包含在我们的入口文件中:

module.exports = {
 entry: ['babel-polyfill', './src/main.js'],
 output: {
  path: path.resolve(__dirname, './dist'),
  publicPath: '/dist/',
  filename: 'build.js',
 },
 // Omitted

如果我们没有安装适当的 polyfill,我们将无法在项目中使用生成器功能。让我们创建一个名为returnRole()的新方法,当调用时给我们三个用户中的一个“角色”:

export default {
 name: 'app',
 data() {
  return {
   role: '',
  }
 },
  methods: {
   *returnRole() {
    yield 'guest';
    yield 'user';
    yield 'admin';
  }
 }
};

如果您以前从未见过生成器函数,您可能会想知道我们在函数名前面加上的星号(*)以及yield关键字是什么。这实质上允许我们通过捕获它的实例来逐步执行函数。例如,让我们创建一个返回迭代器的数据值,我们可以在其上调用next()

 data() {
  return {
   role: '',
   roleList: this.returnRole()
  }
 },
 methods: {
  getRole() {
   /**
    * Calling this.roleList.next() gives us an Iterator object with the interface of:
    * { value: string, done: boolean}
    * We can therefore check to see whether this was the >last< yielded value with done, or get the result by calling .value
    */

    this.role = this.roleList.next().value;
 },

因此,我们可以制作一个模板,利用v-if-else来根据用户角色显示不同的消息:

<template>
 <div id="app">
  <article v-if="role === 'admin'">
   <header>You're an admin!</header>
   <section class="main">
    <h1>If you can see this, you're an admin!</h1>
   </section>
  </article>
  <article v-else-if="role === 'user'">
   <header>You're a user!</header>
   <section class="main">
    <h1>Enjoy your stay!</h1>
   </section>
  </article>
 <article v-else-if="role === 'guest'">
  <header>You're a guest!</header>
  <section class="main">
   <h1>Maybe you should make an account.</h1>
  </section>
 </article>
 <h1 v-else>You have no role!</h1>
 <button @click="getRole()">Switch Role</button>
 </div>
</template>

屏幕上显示的消息取决于用户角色。如果用户没有角色,我们使用v-else来显示一条消息,说明“您没有角色!”。这个例子展示了我们如何利用结构指令根据应用程序状态真正改变 DOM。

过滤器

在本节中,我们将研究过滤器;您可能在诸如 Angular(管道)之类的框架中遇到过过滤器。也许我们想创建一个允许我们以可读格式(DD/MM/YYYY)格式化日期的过滤器。让我们创建一个探索项目来进一步研究这个问题:

# Create a new Vue project
$ vue init webpack-simple vue-filters

# Navigate to directory
$ cd vue-filters

# Install dependencies
$ npm install

# Run application
$ npm run dev

如果我们有一些测试人员,并使用v-for指令在屏幕上显示它们,我们将得到以下结果:

要获得前面截图中显示的结果,我们通过v-for指令显示我们的测试人员与适当的数据,我们需要添加以下代码:

<template>
 <div id="app">
  <ul>
   <li v-for="person in people" v-bind:key="person.id">
    {{person.name}} {{person.dob}}
   </li>
  </ul>
 </div>
</template>

<script>
export default {
 name: 'app',
 data() {
  return {
   people: [
    {
     id: 1,
     name: 'Paul',
     dob: new Date(2000, 5, 29),
    },
    {
     id: 2,
     name: 'Terry',
     dob: new Date(1994, 10, 25),
    },
    {
     id: 3,
     name: 'Alex',
     dob: new Date(1973, 4, 15),
    },
    {
     id: 4,
     name: 'Deborah',
     dob: new Date(1954, 2, 5),
    },
   ],
  };
 },
};
</script>

我们可以自己做日期转换的工作,但在可能的情况下,值得寻找是否有可信赖的第三方组件可以做同样的事情。我们将使用 moment (momentjs.com) 来实现这一点。

让我们为我们的项目安装 moment

$ npm install moment --save

然后我们可以将其添加到我们的 App.vue 中:

<script>
import moment from 'moment';

export default {
 // Omitted
}
</script>

本地注册的过滤器

然后我们有一个选择:将过滤器添加到此 Vue 实例的本地,或者将其全局添加到整个项目中。我们首先看看如何在本地添加它:

首先,我们将创建一个函数,该函数接受一个值,并使用 moment 返回格式化的日期:

const convertDateToString = value => moment(String(value)).format('MM/DD/YYYY');

然后我们可以在我们的 Vue 实例中添加一个 filters 对象,并通过一个 key 来引用它,比如 date。当我们在模板中调用 date 过滤器时,值将传递给这个过滤器,而我们将在屏幕上显示转换后的日期。这可以通过使用 | 键来实现,如下面的代码所示:

 <ul>
  <li v-for="person in people" v-bind:key="person.id">
   {{person.name}} {{person.dob | date}}
  </li>
 </ul>

最后,要将其添加到本地 Vue 实例中,我们可以添加一个引用我们函数的 filters 对象:

export default {
 filters: {
  date: convertDateToString,
 },

这样的结果显示了预期的日期:

全局注册的过滤器

如果我们想在其他地方使用这个过滤器,我们可以将这个函数抽象成自己的文件,并再次引用我们的过滤器,或者,我们可以在应用程序中全局注册 date 过滤器。让我们将我们的 convertDateToString 函数抽象成自己的文件,放在 src/filters/date/date.filter.js 中:

import moment from 'moment';

export const convertDateToString = value =>
 moment(String(value)).format('MM/DD/YYYY');

之后,我们可以在我们的 main.js 中定义过滤器的接口:Vue.filter('filterName', filterFunction())。由于我们已经将函数抽象成了自己的文件,我们可以导入它并像这样定义它:

import Vue from 'vue';
import App from './App.vue';
import { convertDateToString } from './filters/date/date.filter';

Vue.filter('date', convertDateToString);

new Vue({
 el: '#app',
 render: h => h(App),
});

如果您再次检查我们的应用程序,您会看到我们得到了与之前相同的结果。因此,重要的是要考虑过滤器在项目中的使用位置和次数。如果您在特定组件/实例上使用它(一次),那么应该将它放在本地;否则,将其放在全局。

总结

在本章中,我们看了很多 Vue 指令及其用法。这使我们有能力以声明方式改变模板在屏幕上的显示方式,包括捕获用户输入、挂接事件、过滤视图数据等等。每当您想在 Vue.js 应用程序中实现指令时,都应该将本章用作参考。

基于组件的架构是一个重要的概念,它使我们能够构建从个人到企业的可扩展项目。在下一章中,我们将看看如何创建这些可重用的组件,以封装项目中的功能部分。

第五章:与 Vue.js 组件进行安全通信

在现代 Web 应用程序中,注意到组件驱动的架构并不需要花费太多精力。在短时间内,开发需求发生了变化,Web 从一个简单的文档查看器发展为承载具有显着庞大代码库的复杂应用程序。因此,能够创建可重用的组件使我们作为前端开发人员的生活变得更加轻松,因为我们可以将核心功能封装到单一块中,减少总体复杂性,实现更好的关注点分离,协作和可扩展性。

在本章中,我们将把前面的概念应用到我们的 Vue 应用程序中。在本章结束时,您将实现:

  • 创建自己的 Vue 组件的能力

  • 对单文件组件的更深入理解

  • 创建特定于每个组件的样式的能力

  • 能够在本地和全局注册组件,并理解选择其中一个的原因

  • 使用 props 在父子组件之间进行通信的能力

  • 使用全局事件总线在整个应用程序中进行通信的能力

  • 使用插槽使您的组件更加灵活

让我们从您的第一个 Vue 组件开始。

您的第一个 Vue 组件

事实证明,我们一直在 Vue 应用程序中使用组件!使用webpack-simple模板,我们支持单文件组件SFC),它本质上只是一个带有.vue扩展名的模板、脚本和样式标签:

# Create a new Vue project
$ vue init webpack-simple vue-component-1

# Navigate to directory
$ cd vue-component-1

# Install dependencies
$ npm install

# Run application
$ npm run dev

由于我们正在使用 Visual Studio Code 的 Vetur 扩展,我们可以输入scaffold并按Tab键,然后创建一个可以在项目中使用的 SFC。如果我们用一个空组件覆盖App.vue,根据我们当前的定义,它将如下所示:

就是这样!有点。我们仍然需要向我们的组件添加一些功能,并且如果我们要创建一个新文件(即不使用默认的App.vue组件),则需要在某个地方注册它以供使用。让我们通过在src/components/FancyButton.vue下创建一个新文件来看看这个过程:

<template>
 <button>
  {{buttonText}}
 </button>
</template>

<script>
export default {
 data() {
  return {
   buttonText: 'Hello World!'
  }
 }
}
</script>

<style>
 button {
  border: 1px solid black;
  padding: 10px;
 }
</style>

我们的FancyButton组件只是一个说'Hello World!'的按钮,并带有一点点样式。立即,我们需要考虑可以做些什么来使其更具可扩展性:

  • 允许在此组件上输入以更改按钮文本

  • 当我们为button元素设置样式(甚至如果我们添加了类),我们需要一种方法来阻止样式泄漏到应用程序的其他部分

  • 注册此组件,以便可以在整个应用程序中全局使用

  • 注册此组件,以便可以在组件内部本地使用

  • 还有更多!

让我们从最简单的开始,注册组件,以便在我们的应用程序中使用。

全局注册组件

我们可以使用以下接口创建组件并全局注册它们:Vue.component(name: string, options: Object<VueInstance>)。虽然不是必需的,但在命名我们的组件时,遵循 W3C 自定义元素规范设置的命名约定很重要(www.w3.org/TR/custom-elements/#valid-custom-element-name),即全部小写并且必须包含连字符。

在我们的main.js文件中,让我们首先从适当的路径导入FancyButton组件,然后注册它:

import FancyButton from './components/FancyButton.vue';

之后,我们可以使用Vue.component注册组件,可以在main.js中看到加粗的结果代码如下:

import Vue from 'vue';
import App from './App.vue';
import FancyButton from './components/FancyButton.vue';

Vue.component('fancy-button', FancyButton);

new Vue({
  el: '#app',
  render: h => h(App)
});

塔达!我们的组件现在已经全局注册了。现在...我们如何在App.vue组件内部使用它呢?好吧,记得我们指定的标签吗?我们只需将其添加到template中,如下所示:

<template>
 <fancy-button/>
</template>

这是我们辛苦工作的结果(放大到 500%):

作用域样式

太棒了!如果我们添加另一个按钮元素会发生什么?因为我们直接用 CSS 为button元素设置了样式:

<template>
  <div>
    <fancy-button></fancy-button>
    <button>I'm another button!</button>
  </div>
</template>

如果我们转到浏览器,我们可以看到我们创建的每个按钮:

哦哦!这个其他按钮不是fancy-button,那么为什么它会得到样式?幸运的是,阻止样式泄漏到组件外部很简单,我们只需要在style标签中添加scoped属性:

<style scoped>
 button {
 border: 1px solid black;
 padding: 10px;
 }
</style>

scoped属性不是 Vue 默认的一部分,这来自我们的 Webpack vue-loader。您会注意到,在添加此属性后,按钮样式仅适用于我们的fancy-button组件。如果我们看一下以下截图中两个按钮之间的区别,我们可以看到一个只是一个按钮,另一个是使用随机生成的数据属性为按钮设置样式。这可以阻止浏览器在这种情况下将样式应用于两个按钮元素。

在 Vue 中使用作用域 CSS 时,请记住组件内创建的规则不会在整个应用程序中全局访问:

在本地注册组件

我们也可以在应用程序内部局部注册我们的组件。这可以通过将其添加到我们的 Vue 实例中来实现,例如,让我们将main.js中的全局注册注释掉,然后导航到App.vue

// Vue.component('fancy-button', FancyButton);

在将任何代码添加到我们的应用程序组件之前,请注意,我们的按钮现在已经消失,因为我们不再全局注册它。要在本地注册这个,我们需要首先导入组件,类似于之前的操作,然后将其添加到实例中的component对象中:

<template>
 <div>
 <fancy-button></fancy-button>
 <button>I'm another button!</button>
 </div>
</template>

<script>
import FancyButton from './components/FancyButton.vue';

export default {
 components: {
 FancyButton
 }
}
</script>

<style>

</style>

我们的按钮现在再次出现在屏幕上。在决定注册组件的位置时,考虑它们在整个项目中可能需要被多频繁使用。

组件通信

现在我们有了创建可重用组件的能力,这使我们能够在项目中封装功能。为了使这些组件可用,我们需要让它们能够相互通信。我们首先要看的是组件属性的单向通信(称为“props”)。

组件通信的目的是保持我们的功能分布、松散耦合,并从而使我们的应用程序更容易扩展。为了实现松散耦合,您不应尝试在子组件中引用父组件的数据,而应仅使用props传递。让我们看看如何在我们的FancyButton上创建一个改变button文本的属性:

<template>
 <button>
  {{buttonText}}
 </button>
</template>

<script>
export default {
 props: ['buttonText'],
}
</script>

<style scoped>
 button {
 border: 1px solid black;
 padding: 10px;
 }
</style>

请注意,我们能够在模板中绑定到buttonText值,因为我们创建了一个包含每个组件属性的字符串或对象值的props数组。设置这个可以通过连字符形式作为组件本身的属性,这是必需的,因为 HTML 是不区分大小写的:

<template>
 <fancy-button button-text="I'm set using props!"></fancy-button>
</template>

这给我们带来了以下结果:

配置属性值

我们还可以通过将属性值设置为对象来进一步配置它们。这使我们能够定义默认值、类型、验证器等。让我们用我们的buttonText属性来做这个:

export default {
 props: {
  buttonText: {
   type: String,
   default: "Fancy Button!",
   required: true,
   validator: value => value.length > 3
  }
 },
}

首先,我们确保只能将 String 类型传递到此属性中。我们还可以检查其他类型,例如:

  • 数组

  • 布尔值

  • 函数

  • 数字

  • 对象

  • 字符串

  • 符号

根据 Web 组件的良好实践,向 props 发送原始值是一种良好的实践。

在底层,这是针对属性运行instanceof运算符,因此它也可以针对构造函数类型运行检查,如下面的屏幕截图所示:

与此同时,我们还可以使用数组语法检查多种类型:

export default {
 props: {
  buttonText: {
   type: [String, Number, Cat],
  }
 },
}

接下来,我们将默认文本设置为FancyButton!,这意味着默认情况下,如果未设置该属性,它将具有该值。我们还将required设置为true,这意味着每次创建FancyButton时,都必须包含buttonText属性。

目前这是一个术语上的矛盾(即默认值和必需性),但有时您可能希望在属性不是必需的情况下设置默认值。最后,我们将为此添加一个验证函数,以指定每次设置此属性时,它的字符串长度必须大于三。

我们如何知道属性验证失败了?在开发模式下,我们可以检查开发控制台,应该会有相应的错误。例如,如果我们忘记在组件上添加buttonText属性:

自定义事件

我们取得了很大的进展。我们现在有一个可以接受输入、可以全局或局部注册、具有作用域样式、验证等功能的组件。现在我们需要让它具有向其父组件发送事件的能力,以便在FancyButton按钮被点击时进行通信,这是通过编辑$emit事件的代码来实现的:

<template>
 <button 
  @click.prevent="clicked">
  {{buttonText}}
 </button>
</template>

<script>
export default {
 props: {
  buttonText: {
   type: String,
   default: () => {
     return "Fancy Button!" 
   },
   required: true,
   validator: value => value.length > 3
  }
 },
 methods: {
  clicked() {
   this.$emit('buttonClicked');
  }
 }
}
</script>

在我们的示例中,我们将clicked函数附加到按钮的点击事件上,这意味着每当它被选中时,我们就会发出buttonClicked事件。然后我们可以在App.vue文件中监听此事件,将我们的元素添加到 DOM 中:

<template>
  <fancy-button 
   @buttonClicked="eventListener()" 
   button-text="Click 
   me!">
  </fancy-button>
</template>

<script>
import FancyButton from './components/FancyButton.vue';

export default {
  components: {
    'fancy-button': FancyButton
  },
  methods: {
    eventListener() {
      console.log("The button was clicked from the child component!");
    }
  }
}
</script>

<style>

</style>

请注意,此时我们正在使用@buttonClicked="eventListener()"。这使用v-on事件在事件被触发时调用eventListener()函数,随后将消息记录到控制台。我们现在已经演示了在两个组件之间发送和接收事件的能力。

发送事件值

为了使事件系统更加强大,我们还可以将值传递给我们的另一个组件。让我们在FancyButton组件中添加一个输入框(也许我们需要重新命名它或考虑将输入分离成自己的组件!):

<template>
 <div>
  <input type="text" v-model="message">
  <button 
  @click.prevent="clicked()">
   {{buttonText}}
  </button>
 </div>
</template>

<script>
export default {
 data() {
  return {
   message: ''
  };
 },
 // Omitted
}

接下来要做的是在我们的$emit调用中传递消息值。我们可以在clicked方法中这样做:

 methods: {
  clicked() {
   this.$emit('buttonClicked', this.message);
  }
 }

此时,我们可以将事件作为eventListener函数的参数来捕获:

<template>
 <fancy-button @buttonClicked="eventListener($event)" button-text="Click me!"></fancy-button>
</template>

此时要做的最后一件事也是匹配函数的预期参数:

 eventListener(message) {
  console.log(`The button was clicked from the child component with this message: ${message}`);
 }

然后我们应该在控制台中看到以下内容:

我们现在有能力在父子组件之间真正发送事件,以及我们可能想要发送的任何数据。

事件总线

当我们想要创建一个应用程序范围的事件系统(即,不仅限于父子组件),我们可以创建所谓的事件总线。这允许我们通过一个单一的 Vue 实例“管道”所有事件,从而实现超出父子组件通信的可能。除此之外,对于那些不想使用第三方库如Vuex,或者处理不多动作的小型项目来说,这也是有用的。让我们创建一个新的示例项目来演示它:

# Create a new Vue project
$ vue init webpack-simple vue-event-bus

# Navigate to directory
$ cd vue-event-bus

# Install dependencies
$ npm install

# Run application
$ npm run dev

首先,在src文件夹中创建一个EventsBus.js。从这里,我们可以导出一个新的 Vue 实例,我们可以像以前一样使用$emit来发出事件:

import Vue from 'vue';

export default new Vue();

接下来,我们可以创建两个组件,ShoppingInputShoppingList。这将允许我们输入新项目,并在购物清单上显示输入项目的列表,从我们的ShoppingInput组件开始:

<template>
 <div>
  <input v-model="itemName">
  <button @click="addShoppingItem()">Add Shopping Item</button>
 </div>
</template>

<script>
import EventBus from '../EventBus';

export default {
 data() {
  return {
   itemName: ''
  }
 },
 methods: {
  addShoppingItem() {
   if(this.itemName.length > 0) {
    EventBus.$emit('addShoppingItem', this.itemName)
    this.itemName = "";
   }
  }
 },
}
</script>

这个组件的关键是,我们现在导入EventBus并使用$emit,而不是使用this,将我们的应用程序事件系统从基于组件变为基于应用程序。然后,我们可以使用$on来监视任何组件中的更改(以及随后的值)。让我们用下一个组件ShoppingList来看一下:

<template>
 <div>
  <ul>
   <li v-for="item in shoppingList" :key="item">
    {{item}}
   </li>
  </ul>
 </div>
</template>

<script>
import EventBus from '../EventBus';
export default {
 props: ['shoppingList'],
 created() {
  EventBus.$on('addShoppingItem', (item) => {
   console.log(`There was an item added! ${item}`);
  })
 }
}
</script>

看看我们的ShoppingList组件,我们可以看到$on的使用,这允许我们监听名为addShoppingItem的事件(与我们发出的相同事件名称,或者您想要监听的任何其他事件)。这将返回该项,然后我们可以将其记录到控制台或在此时执行任何其他操作。

我们可以将所有这些放在我们的App.vue中:

<template>
 <div>
  <shopping-input/>
  <shopping-list :shoppingList="shoppingList"/>
 </div>
</template>

<script>
import ShoppingInput from './components/ShoppingInput';
import ShoppingList from './components/ShoppingList';
import EventBus from './EventBus';

export default {
 components: {
  ShoppingInput,
  ShoppingList
 },
 data() {
  return {
   shoppingList: []
  }
 },
 created() {
  EventBus.$on('addShoppingItem', (itemName) => {
   this.shoppingList.push(itemName);
  })
 },
}

我们定义了两个组件,并在创建的生命周期钩子内监听addShoppingItem事件。就像以前一样,我们得到了itemName,然后我们可以将其添加到我们的数组中。我们可以将数组传递给另一个组件作为 prop,比如ShoppingList,以在屏幕上呈现。

最后,如果我们想要停止监听事件(完全或每个事件),我们可以使用$off。在App.vue内,让我们创建一个新的按钮来进一步展示这一点:

<button @click="stopListening()">Stop listening</button>

然后我们可以这样创建stopListening方法:

methods: {
 stopListening() {
  EventBus.$off('addShoppingItem')
 }
},

如果我们想要停止监听所有事件,我们可以简单地使用:

EventBus.$off();

到目前为止,我们已经创建了一个事件系统,可以让我们与任何组件进行通信,而不受父/子关系的影响。我们可以通过EventBus发送事件并监听事件,从而更灵活地处理组件数据。

插槽

当我们组合组件时,我们应该考虑它们将如何被我们自己和团队使用。使用插槽允许我们动态地向组件添加具有不同行为的元素。让我们通过创建一个新的示例项目来看看它的作用:

# Create a new Vue project
$ vue init webpack-simple vue-slots

# Navigate to directory
$ cd vue-slots

# Install dependencies
$ npm install

# Run application
$ npm run dev

然后,我们可以继续创建一个名为Messagesrc/components/Message.vue)的新组件。我们可以为这个组件添加一些特定的内容(比如下面的h1),以及一个slot标签,我们可以用它来从其他地方注入内容:

<template>
 <div>
   <h1>I'm part of the Message component!</h1>
   <slot></slot>
 </div>
</template>

<script>
export default {}
</script>

如果我们在App.vue内注册了我们的组件,并将其放置在我们的模板内,我们就可以像这样在component标签内添加内容:

<template>
 <div id="app">
   <message>
     <h2>What are you doing today?</h2>
   </message>
   <message>
     <h2>Learning about Slots in Vue.</h2>
   </message>
 </div>
</template>

<script>
import Message from './components/Message';

export default {
 components: {
  Message
 }
}
</script>

此时,message标签内的所有内容都被放置在Message组件内的slot中:

注意,每次声明Message组件时,我们都会看到"I'm part of the Message component!",这表明即使我们向这个空间注入内容,我们仍然可以每次显示特定于组件的模板信息。

默认值

虽然我们可以向插槽中添加内容,但我们可能希望添加默认内容,以便在我们没有自己添加任何内容时显示。这意味着我们不必每次都添加内容,如果需要的话,我们可以在特定情况下覆盖它。

我们如何向我们的插槽添加默认行为?这很简单!我们只需要在slot标签之间添加我们的元素,就像这样:

<template>
 <div>
  <h1>I'm part of the Message component!</h1>
  <slot>
   <h2>I'm a default heading that appears <em>only</em> when no slots 
   have been passed into this component</h2>
   </slot>
 </div>
</template>

因此,如果我们添加另一个message元素,但这次没有任何标记,我们会得到以下结果:

<template>
 <div id="app">
  <message>
   <h2>What are you doing today?</h2>
  </message>
  <message>
   <h2>Learning about Slots in Vue.</h2>
  </message>
  <message></message>
 </div>
</template>

现在,如果我们转到浏览器,我们可以看到我们的消息如预期般显示:

命名插槽

我们还可以通过命名插槽进一步进行。假设我们的message组件希望同时有datemessageText输入,其中一个是插槽,另一个是组件的属性。我们使用这个的情况可能是,也许我们想以不同的方式显示日期,添加不同的信息,或者根本不显示它。

我们的消息组件变成了:

<template>
 <div>
  <slot name="date"></slot>
  <h1>{{messageText}}</h1>
 </div>
</template>

<script>
export default {
 props: ['messageText']
}
</script>

请注意我们在slot标签上的name="date"属性。这使我们能够在运行时动态地将我们的内容放在正确的位置。然后我们可以构建一个小型的聊天系统来展示这一点,让我们确保在继续之前在我们的项目中安装了moment

$ npm install moment --save

你可能还记得在第四章中使用momentVue.js 指令,我们还将重用之前创建的Date管道。让我们升级我们的App.vue,包含以下内容:

<template>
 <div id="app">

  <input type="text" v-model="message">
  <button @click="sendMessage()">+</button>

  <message v-for="message in messageList" :message-text="message.text" :key="message">
   <h2 slot="date">{{ message.date | date }}</h2>
  </message>
 </div>
</template>

<script>
import moment from 'moment';
import Message from './components/Message';

const convertDateToString = value => moment(String(value)).format('MM/DD/YYYY');

export default {
 data() {
  return {
   message: '',
   messageList: []
  }
 },
 methods: {
  sendMessage() {
   if ( this.message.length > 0 ) {
    this.messageList.push({ date: new Date(), text: this.message });
    this.message = ""
   }
  }
 },
 components: {
  Message
 },
 filters: {
  date: convertDateToString
 }
}
</script>

这里发生了什么?在我们的模板中,我们正在遍历我们的messageList,每次添加新消息时都会创建一个新的消息组件。在组件标签内部,我们期望messageText会出现(因为我们将其作为 prop 传递,并且标记是在 Message 组件内部定义的),但我们还动态添加了日期使用slot

如果我们从 h2 中删除slot="date"会发生什么?日期还会显示吗?不会。这是因为当我们只使用命名插槽时,没有其他地方可以添加插槽。只有当我们将我们的Message组件更改为接受一个未命名插槽时,它才会出现,如下所示:

<template>
 <div>
  <slot name="date"></slot>
  <slot></slot>
  <h1>{{messageText}}</h1>
 </div>
</template>

总结

本章使我们有能力创建可重用的组件,这些组件可以相互通信。我们已经看到了如何可以在整个项目中全局注册组件,或者在特定实例中本地注册组件,从而给我们带来了灵活性和适当的关注点分离。我们已经看到了这种强大的功能,从简单属性的添加到复杂验证和默认值的例子。

在下一章中,我们将研究如何创建更好的 UI。我们将更多地关注指令,比如在表单、动画和验证的上下文中使用v-model

第六章:创建更好的 UI

过渡和动画是在我们的应用程序中创建更好用户体验的好方法。由于有很多不同的选项和用例,它们可以使应用程序的感觉得以或败。我们将在本章中进一步探讨这个概念。

我们还将使用名为Vuelidate的第三方库来进行表单验证。这将允许我们创建随着应用程序规模而扩展的表单。我们还将获得根据表单状态更改 UI 的能力,以及显示有用的验证消息来帮助用户。

最后,我们将看看如何使用render函数和 JSX 来使用 Vue 组合用户界面。虽然这并不适用于每种情况,但在某些情况下,您可能希望充分利用模板中的 JavaScript,并使用功能组件模型创建智能/表现组件。

到本章结束时,您将拥有:

  • 学习了 CSS 动画

  • 创建自己的 CSS 动画

  • 使用Animate.css创建交互式 UI,工作量很小

  • 调查并创建自己的 Vue 过渡

  • 利用Vuelidate在 Vue 中验证表单

  • 使用render函数作为模板驱动 UI 的替代方案

  • 使用 JSX 来组合类似于 React 的 UI

让我们首先了解为什么我们应该关心项目中的动画和过渡。

动画

动画可以用来吸引特定 UI 元素的注意,并通过使其生动起来来改善用户的整体体验。当没有明确的开始状态和结束状态时,应该使用动画。动画可以自动播放,也可以由用户交互触发。

CSS 动画

CSS 动画不仅是强大的工具,而且在项目中使用它们只需要很少的知识就可以轻松维护。

将它们添加到界面中可以是捕获用户注意力的直观方法,它们也可以用于轻松指向用户特定的元素。动画可以定制和自定义,使它们成为各种项目中许多用例的理想选择。

在深入研究 Vue 过渡和其他动画可能性之前,我们应该了解如何进行基本的 CSS3 动画。让我们创建一个更详细地查看这一点的简单项目:

# Create a new Vue project
$ vue init webpack-simple vue-css-animations

# Navigate to directory
$ cd vue-css-animations

# Install dependencies
$ npm install

# Run application
$ npm run dev

App.vue中,我们可以首先创建以下样式:

<style>
button {
 background-color: transparent;
 padding: 5px;
 border: 1px solid black;
}

h1 {
 opacity: 0;
}

@keyframes fade {
 from { opacity: 0; }
 to { opacity: 1; }
}

.animated {
 animation: fade 1s;
 opacity: 1;
}
</style>

如您所见,没有什么特别的。我们使用@keyframes命名为fade来声明 CSS 动画,基本上给 CSS 两个我们希望元素处于的状态-opacity: 1opacity: 0。它并没有说明这些关键帧持续多长时间或是否重复;这一切都在animated类中完成。我们在将类添加到元素时应用fade关键帧为1;与此同时,我们添加opacity: 1以确保在动画结束后它不会消失。

我们可以通过利用v-bind:class根据toggle的值动态添加/删除类来组合这些:

<template>
 <div id="app">
  <h1 v-bind:class="{ animated: toggle }">I fade in!</h1>
  <button @click="toggle = !toggle">Toggle Heading</button>
 </div> 
</template>

<script>
export default {
 data () {
  return {
   toggle: false
  }
 }
}
</script>

很好。现在我们可以根据Boolean值淡入一个标题。但如果我们能做得更好呢?在这种特殊情况下,我们可以使用过渡来实现类似的结果。在更详细地查看过渡之前,让我们看看我们可以在项目中使用 CSS 动画的其他方式。

Animate.css

Animate.css是一种很好的方式,可以轻松地将不同类型的动画实现到项目中。这是由 Daniel Eden 创建的开源 CSS 库(daneden.me/),它为我们提供了"即插即用"的 CSS 动画。

在将其添加到任何项目之前,前往daneden.github.io/animate.css/预览不同的动画样式。有许多不同的动画可供选择,每种都提供不同的默认动画。这些可以进一步定制,我们稍后将在本节中详细讨论。

继续运行以下命令在我们的终端中创建一个游乐项目:

 Create a new Vue project
$ vue init webpack-simple vue-animate-css

# Navigate to directory
$ cd vue-animate-css

# Install dependencies
$ npm install

# Run application
$ npm run dev

设置项目后,继续在所选的编辑器中打开index.html文件。在<head>标签内,添加以下样式表:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css">

这是项目中需要的样式表引用,以使Animate.css在项目中起作用。

使用 Animate.css

现在我们在项目中有了Animate.css,我们可以将App.vue更改为具有以下template

<template>
 <h1 class="animated fadeIn">Hello Vue!</h1>
</template>

在添加任何动画之前,我们首先需要添加 animated 类。接下来,我们可以从Animate.css库中选择任何动画;我们选择了fadeIn作为示例。然后可以将其替换为其他动画,如bounceInLeftshakerubberBand等等!

我们可以将之前的示例转换为基于布尔值的绑定类值,但过渡可能更有趣。

过渡

过渡效果是通过从一个特定状态开始,然后过渡到另一个状态并在中间插值数值来实现的。过渡不能在动画中涉及多个步骤。想象一对窗帘从打开到关闭:第一个状态将是打开的位置,而第二个状态将是关闭的位置。

Vue 有自己的标签来处理过渡,称为<transition><transition-group>。这些标签是可定制的,可以很容易地与 JavaScript 和 CSS 一起使用。实际上,并不一定需要有transition标签来使过渡生效,因为你只需将状态变量绑定到可见属性,但标签通常提供更多控制和潜在更好的结果。

让我们来看看之前的toggle示例,并创建一个使用transition的版本:

<template>
 <div id="app">
  <transition name="fadeIn"
  enter-active-class="animated fadeIn"
  leave-active-class="animated fadeOut">
   <h1 v-if="toggle">I fade in and out!</h1>
  </transition>
  <button @click="toggle = !toggle">Toggle Heading</button>
 </div> 
</template>

<script>
export default {
 data () {
  return {
   toggle: false
  }
 }
}
</script>

让我们更详细地看看各个部分的运作方式。

我们将元素包裹在<transition>标签中,当<h1>进入 DOM 时,它会应用animated fadeInenter-active-class。这是通过v-if指令触发的,因为toggle变量最初设置为false。单击按钮会切换我们的布尔值,触发过渡并应用适当的 CSS 类。

过渡状态

每个进入/离开过渡都会应用最多六个类,这些类由进入场景时的过渡、过程中和离开场景时的过渡组成。第一组(v-enter-*)指的是最初进入然后移出的过渡,而第二组(v-leave-*)指的是结束过渡最初进入然后移出:

名称 描述
v-enter 这是进入的起始状态。在元素插入后的一帧后被移除。
v-enter-active enter-activeenter的活动状态。它在整个活动阶段都是活动的,并且只有在过渡或动画结束后才会被移除。该状态还管理进一步的指令,如延迟、持续时间等。
v-enter-to 这是进入的最后状态,在元素插入后的一帧后添加,与v-enter被移除的时间相同。一旦过渡/动画结束,enter-to就会被移除。
v-leave 这是离开的起始状态。一旦离开过渡被触发,就会在一帧后被移除。
v-leave-active leave-activeleave的活动状态。在整个离开阶段都是活动的,只有在过渡或动画结束时才会被移除。
v-leave-to 离开的最后状态,在离开触发后的一帧后添加,与v-leave同时移除。当过渡/动画结束时,leave-to也会被移除。

每个enterleave过渡都有一个前缀,在表中显示为v的默认值,因为过渡本身没有名称。当将 enter 或 leave 过渡添加到项目中时,理想情况下应该应用适当的命名约定,以充当唯一标识符。如果您计划在项目中使用多个过渡,这可以帮助,并且可以通过简单的赋值操作完成:

<transition name="my-transition">

表单验证

在本书中,我们已经看过了各种不同的捕获用户输入的方式,比如v-model。我们将使用一个名为Vuelidate的第三方库来根据特定规则进行模型验证。让我们通过在终端中运行以下命令来创建一个示例项目:

# Create a new Vue project
$ vue init webpack-simple vue-validation

# Navigate to directory
$ cd vue-validation

# Install dependencies
$ npm install

# Install Vuelidate
$ npm install vuelidate

# Run application
$ npm run dev

什么是 Vuelidate?

Vuelidate是一个开源的轻量级库,帮助我们使用各种验证上下文进行模型验证。验证可以被功能组合,并且它也可以很好地与其他库(如MomentVuex等)配合使用。由于我们已经在项目中使用npm install vuelidate安装了它,现在我们需要在main.js中将其注册为插件。

import Vue from 'vue';
import Vuelidate from 'vuelidate';
import App from './App.vue';

Vue.use(Vuelidate);

new Vue({
  el: '#app',
  validations: {},
  render: h => h(App),
});

将空验证对象添加到我们的主 Vue 实例中,可以在整个项目中引导 Vuelidate 的$v。这样我们就可以使用$v对象来获取关于表单当前状态的信息,跨越所有组件的 Vue 实例。

使用 Vuelidate

让我们创建一个基本表单,允许我们输入firstNamelastNameemailpassword。这将允许我们使用Vuelidate添加验证规则,并在屏幕上可视化它们:

<template>
  <div>
    <form class="form" @submit.prevent="onSubmit">
      <div class="input">
        <label for="email">Email</label>
        <input 
        type="email" 
        id="email" 
        v-model.trim="email">
      </div>
      <div class="input"> 
        <label for="firstName">First Name</label>
        <input 
        type="text"
        id="firstName" 
        v-model.trim="firstName">
      </div>
      <div class="input">
        <label for="lastName">Last Name</label>
        <input 
        type="text" 
        id="lastName" 
        v-model.trim="lastName">
      </div>
      <div class="input">
        <label for="password">Password</label>
        <input 
        type="password" 
        id="password" 
        v-model.trim="password">
      </div>
      <button type="submit">Submit</button>
    </form>
  </div>
</template>
<script>
export default {
  data() {
    return {
      email: '',
      password: '',
      firstName: '',
      lastName: '',
    };
  },
  methods: {
    onSubmit(){
    }
  },
}
</script>

这里涉及很多内容,让我们一步一步来分解:

  1. 我们正在创建一个新的表单,使用@submit.prevent指令,这样当表单提交时页面不会重新加载,这与在表单上调用 submit 并在事件上使用preventDefault是一样的。

  2. 接下来,我们将在每个表单输入元素中添加v-model.trim,以便修剪任何空白并将输入捕获为变量

  3. 我们在数据函数中定义这些变量,以便它们是响应式的

  4. submit按钮被定义为type="submit",这样当点击它时,表单的submit函数就会运行

  5. 我们正在创建一个空白的onSubmit函数,很快就会创建它

现在我们需要添加@input事件,并在每个input元素上调用touch事件,绑定到数据属性v-model,并为字段提供验证,如下所示:

<div class="input">
  <label for="email">Email</label>
  <input 
  type="email" 
  id="email" 
  @input="$v.email.$touch()"
  v-model.trim="email">
</div>
<div class="input"> 
  <label for="firstName">First Name</label>
  <input 
  type="text"
  id="firstName" 
  v-model.trim="firstName"
  @input="$v.firstName.$touch()">
</div>
<div class="input">
  <label for="lastName">Last Name</label>
  <input 
  type="text" 
  id="lastName" 
  v-model.trim="lastName"
  @input="$v.lastName.$touch()">
</div>
<div class="input">
  <label for="password">Password</label>
  <input 
  type="password" 
  id="password" 
  v-model.trim="password"
  @input="$v.password.$touch()">
</div>

然后,通过从Vuelidate导入它们并添加与表单元素对应的validations对象,将验证添加到我们的 Vue 实例中。

Vuelidate将使用相同的名称与我们的data变量绑定,如下所示:

import { required, email } from 'vuelidate/lib/validators';

export default {
 // Omitted
  validations: {
    email: {
      required,
      email,
    },
    firstName: {
      required,
    },
    lastName: {
      required,
    },
    password: {
      required,
    }
  },
}

我们只需导入所需的电子邮件验证器并将其应用于每个模型项。这基本上确保了我们所有的项目都是必需的,并且电子邮件输入与电子邮件正则表达式匹配。然后,我们可以通过添加以下内容来可视化表单和每个字段的当前状态:

 <div class="validators">
  <pre>{{$v}}</pre>
 </div>

然后,我们可以添加一些样式来显示右侧的验证和左侧的表单:

<style>
.form {
 display: inline-block;
 text-align: center;
 width: 49%;
}
.validators {
 display: inline-block;
 width: 49%;
 text-align: center;
 vertical-align: top;
}
.input {
 padding: 5px;
}
</style>

如果一切都按计划进行,我们应该会得到以下结果:

显示表单错误

我们可以使用$invalid布尔值来显示消息或更改表单字段的外观和感觉。让我们首先添加一个名为error的新类,它在输入字段周围添加了red border

<style>
input:focus {
  outline: none;
}
.error {
  border: 1px solid red;
}
</style>

然后,我们可以在字段无效且已触摸时有条件地应用此类,使用v-bind:class

<div class="input">
  <label for="email">Email</label>
  <input 
  :class="{ error: $v.email.$error }"
  type="email" 
  id="email" 
  @input="$v.email.$touch()"
  v-model.trim="email">
</div>
<div class="input"> 
  <label for="firstName">First Name</label>
  <input 
  :class="{ error: $v.firstName.$error }"
  type="text"
  id="firstName" 
  v-model.trim="firstName"
  @input="$v.firstName.$touch()">
</div>
<div class="input">
  <label for="lastName">Last Name</label>
  <input 
  :class="{ error: $v.lastName.$error}"
  type="text" 
  id="lastName" 
  v-model.trim="lastName"
  @input="$v.lastName.$touch()">
</div>
<div class="input">
  <label for="password">Password</label>
  <input 
  :class="{ error: $v.password.$error }"
  type="password" 
  id="password" 
  v-model.trim="password"
  @input="$v.password.$touch()">
</div>

这样,每当字段无效或有效时,我们就会得到以下结果:

随后,如果是这种情况,我们可以显示错误消息。这可以通过多种方式来完成,具体取决于您想要显示的消息类型。让我们以email输入为例,当email字段具有无效的电子邮件地址时显示错误消息:

<div class="input">
  <label for="email">Email</label>
  <input 
  :class="{ error: $v.email.$error }"
  type="email" 
  id="email" 
  @input="$v.email.$touch()"
  v-model.trim="email">

  <p class="error-message" v-if="!$v.email.email">Please enter a valid email address</p>
</div>

// Omitted
<style>
.error-message {
 color: red;
}
</style>

从我们的$v对象的表示中,我们可以看到当字段具有有效的电子邮件地址时,电子邮件布尔值为 true,如果不是,则为 false。虽然这检查电子邮件是否正确,但它并不检查字段是否为空。让我们添加另一个基于required验证器的检查这一点的错误消息:

 <p class="error-message" v-if="!$v.email.email">Please enter a valid email address.</p>
 <p class="error-message" v-if="!$v.email.required">Email must not be empty.</p>

如果我们愿意,甚至可以更进一步,创建自己的包装组件,用于呈现每个字段的各种错误消息。让我们填写剩下的错误消息,以及检查表单元素是否已被触摸(即$dirty):

<div class="input">
  <label for="email">Email</label>
  <input 
  :class="{ error: $v.email.$error }"
  type="email" 
  id="email" 
  @input="$v.email.$touch()"
  v-model.trim="email">

  <div v-if="$v.email.$dirty">
    <p class="error-message" v-if="!$v.email.email">Please enter a 
    valid email address.</p>
    <p class="error-message" v-if="!$v.email.required">Email must not 
    be empty.</p>
  </div>

</div>
<div class="input"> 
  <label for="firstName">First Name</label>
  <input 
  :class="{ error: $v.firstName.$error }"
  type="text"
  id="firstName" 
  v-model.trim="firstName"
  @input="$v.firstName.$touch()">

  <div v-if="$v.firstName.$dirty">
    <p class="error-message" v-if="!$v.firstName.required">First Name 
  must not be empty.</p>
  </div>
</div>
<div class="input">
  <label for="lastName">Last Name</label>
  <input 
  :class="{ error: $v.lastName.$error}"
  type="text" 
  id="lastName" 
  v-model.trim="lastName"
  @input="$v.lastName.$touch()">

  <div v-if="$v.lastName.$dirty">
    <p class="error-message" v-if="!$v.lastName.required">Last Name 
   must not be empty.</p>
  </div>
</div>
<div class="input">
  <label for="password">Password</label>
  <input 
  :class="{ error: $v.password.$error }"
  type="password" 
  id="password" 
  v-model.trim="password"
  @input="$v.password.$touch()">

  <div v-if="$v.password.$dirty">
    <p class="error-message" v-if="!$v.password.required">Password must 
  not be empty.</p>
  </div>
</div>

密码验证

在创建用户帐户时,密码往往会被输入两次,并符合最小长度。让我们添加另一个字段和一些更多的验证规则来强制执行这一点:

import { required, email, minLength, sameAs } from 'vuelidate/lib/validators';

export default {
 // Omitted
  data() {
    return {
      email: '',
      password: '',
      repeatPassword: '',
      firstName: '',
      lastName: '',
    };
  },
  validations: {
    email: {
      required,
      email,
    },
    firstName: {
      required,
    },
    lastName: {
      required,
    },
    password: {
      required,
      minLength: minLength(6),
    },
    repeatPassword: {
      required,
      minLength: minLength(6),
      sameAsPassword: sameAs('password'),
    },
  },
}

我们已经完成了以下工作:

  1. repeatPassword字段添加到我们的数据对象中,以便它可以保存重复的密码

  2. Vuelidate导入了minLengthsameAs验证器

  3. password验证器的minLength添加为6个字符

  4. 添加了sameAs验证器来强制repeatPassword应遵循与password相同的验证规则

现在我们已经有了适当的密码验证,我们可以添加新字段并显示任何错误消息:

<div class="input">
 <label for="email">Email</label>
 <input 
 :class="{ error: $v.email.$error }"
 type="email" 
 id="email" 
 @input="$v.email.$touch()"
 v-model.trim="email">

 <div v-if="$v.email.$dirty">
 <p class="error-message" v-if="!$v.email.email">Please enter a valid email address.</p>
 <p class="error-message" v-if="!$v.email.required">Email must not be empty.</p>
 </div>

</div>
<div class="input"> 
 <label for="firstName">First Name</label>
 <input 
 :class="{ error: $v.firstName.$error }"
 type="text"
 id="firstName" 
 v-model.trim="firstName"
 @input="$v.firstName.$touch()">

 <div v-if="$v.firstName.$dirty">
 <p class="error-message" v-if="!$v.firstName.required">First Name must not be empty.</p>
 </div>
</div>
<div class="input">
 <label for="lastName">Last Name</label>
 <input 
 :class="{ error: $v.lastName.$error}"
 type="text" 
 id="lastName" 
 v-model.trim="lastName"
 @input="$v.lastName.$touch()">

 <div v-if="$v.lastName.$dirty">
 <p class="error-message" v-if="!$v.lastName.required">Last Name must not be empty.</p>
 </div>
</div>
<div class="input">
 <label for="password">Password</label>
 <input 
 :class="{ error: $v.password.$error }"
 type="password" 
 id="password" 
 v-model.trim="password"
 @input="$v.password.$touch()">

 <div v-if="$v.password.$dirty">
 <p class="error-message" v-if="!$v.password.required">Password must not be empty.</p>
 </div>
</div>
<div class="input">
 <label for="repeatPassword">Repeat Password</label>
 <input 
 :class="{ error: $v.repeatPassword.$error }"
 type="password" 
 id="repeatPassword" 
 v-model.trim="repeatPassword"
 @input="$v.repeatPassword.$touch()">

 <div v-if="$v.repeatPassword.$dirty">
 <p class="error-message" v-if="!$v.repeatPassword.sameAsPassword">Passwords must be identical.</p>

 <p class="error-message" v-if="!$v.repeatPassword.required">Password must not be empty.</p>
 </div>
</div>

表单提交

接下来,如果表单无效,我们可以禁用我们的“提交”按钮:

<button :disabled="$v.$invalid" type="submit">Submit</button>

我们还可以在 JavaScript 中使用this.$v.$invalid来获取此值。以下是一个示例,演示了如何检查表单是否无效,然后根据我们的表单元素创建用户对象:

methods: {
  onSubmit() {
    if(!this.$v.$invalid) {
      const user = { 
        email: this.email,
        firstName: this.firstName,
        lastName: this.lastName,
        password: this.password,
        repeatPassword: this.repeatPassword
      }

      // Submit the object to an API of sorts
    }
  },
},

如果您希望以这种方式使用您的数据,您可能更喜欢设置您的数据对象如下:

data() {
  return {
    user: {
      email: '',
      password: '',
      repeatPassword: '',
      firstName: '',
      lastName: '',
    }
  };
},

我们现在已经创建了一个具有适当验证的表单!

渲染/功能组件

我们将改变方向,从验证和动画转向考虑使用功能组件和渲染函数来提高应用程序性能。您可能也会听到它们被称为“呈现组件”,因为它们是无状态的,只接收数据作为输入属性。

到目前为止,我们只声明了组件的标记,使用了template标签,但也可以使用render函数(如src/main.js中所示):

import Vue from 'vue'
import App from './App.vue'

new Vue({
  el: '#app',
  render: h => h(App)
})

h来自超文本,它允许我们用 JavaScript 创建/描述 DOM 节点。在render函数中,我们只是渲染App组件,将来我们会更详细地看这个。Vue 创建了一个虚拟 DOM,使得处理实际 DOM 变得更简单(以及在处理大量元素时提高性能)。

渲染元素

我们可以用以下对象替换我们的App.vue组件,该对象接受一个render对象和hyperscript,而不是使用template

<script>
export default {
 render(h) {
  return h('h1', 'Hello render!')
 }
}
</script>

然后渲染一个带有文本节点'Hello render!'的新h1标签,这就是所谓的VNode虚拟节点),复数形式为VNodes虚拟 DOM 节点),它描述了整个树。现在让我们看看如何在ul中显示一个项目列表:

  render(h){
    h('ul', [
      h('li', 'Evan You'),
      h('li', 'Edd Yerburgh'),
      h('li', 'Paul Halliday')
    ])
 }

重要的是要意识到,我们只能用超文本渲染一个根节点。这个限制对我们的模板也是一样的,所以我们预期将我们的项目包裹在一个div中,就像这样:

render(h) {
 return h('div', [
  h('ul', [
   h('li', 'Evan You'),
   h('li', 'Edd Yerburgh'),
   h('li', 'Paul Halliday')
  ])
 ])
}

属性

我们还可以向我们渲染的项目传递样式元素和各种其他属性。以下是一个使用style对象来将每个项目的颜色更改为red的示例:

 h('div', [
  h('ul', { style: { color: 'red' } }, [
   h('li', 'Evan You'),
   h('li', 'Edd Yerburgh'),
   h('li', 'Paul Halliday')
  ])
 ])

正如你可以想象的那样,我们可以添加尽可能多的style属性,以及我们期望的额外选项,比如propsdirectiveson(点击处理程序)等。让我们看看如何映射元素以渲染带有props的组件。

组件和 props

让我们在components/ListItem.vue下创建一个ListItem组件,其中有一个 prop,name。我们将在我们的li的位置渲染这个组件,并在包含各种names的数组上进行映射。请注意,我们还向我们的 Vue 实例添加了functional: true选项;这告诉 Vue 这纯粹是一个呈现组件,它不会有任何自己的状态:

<script>
export default {
 props: ['name'],
 functional: true
}
</script>

在我们的render函数中,h通常也被称为createElement,因为我们在 JavaScript 上下文中,我们能够利用数组操作符,如mapfilterreduce等。让我们用map替换静态名称,用动态生成的组件:

import ListItem from './components/ListItem.vue';

export default {
 data() {
  return {
   names: ['Evan You', 'Edd Yerburgh', 'Paul Halliday']
  }
 },
 render(createElement) {
  return createElement('div', [
   createElement('ul',
    this.names.map(name => 
     createElement(ListItem, 
      {props: { name: name } })
     ))
   ])
 }
}

我们需要做的最后一件事是向我们的组件添加一个render函数。作为第二个参数,我们能够访问上下文对象,这使我们能够访问propsoptions。在这个例子中,我们假设name prop 总是存在且不是nullundefined

export default {
 props: ['name'],
 functional: true,
 render(createElement, context) {
  return createElement('li', context.props.name)
 }
}

再次,我们现在有一个包含作为prop传递的项目的元素列表:

JSX

虽然这是一个很好的思考练习,但在大多数情况下,模板更优越。也许有时您想在组件内部使用render函数,在这种情况下,使用 JSX 可能更简单。

让我们通过在终端中运行以下命令将 JSX 的 babel 插件添加到我们的项目中:

**$ npm i -D babel-helper-vue-jsx-merge-props babel-plugin-syntax-jsx babel-plugin-transform-vue-jsx** 

然后我们可以更新我们的.babelrc以使用新的插件:

{
 "presets": [
 ["env", { "modules": false }],
 "stage-3"
 ],
 "plugins": ["transform-vue-jsx"]
}

这使我们能够重写我们的render函数,以利用更简单的语法:

render(h) {
 return (
  <div>
   <ul>
    { this.names.map(name => <ListItem name={name} />) }
   </ul>
  </div>
 )
}

这更具有声明性,而且更容易维护。在底层,它被转译为以前的hyperscript格式与 Babel 一起。

总结

在本章中,我们学习了如何在 Vue 项目中利用 CSS 动画和过渡。这使我们能够使用户体验更流畅,并改善我们应用程序的外观和感觉。

我们还学习了如何使用render方法构建我们的 UI;这涉及使用 HyperScript 创建 VNodes,然后使用 JSX 进行更清晰的抽象。虽然您可能不想在项目中使用 JSX,但如果您来自 React 背景,您可能会觉得更舒适。

第七章:HTTP 和 WebSocket 通信

在本章中,我们将看看如何使用HTTP与服务器端 API 进行接口交互。我们将使用HTTP GETPOSTPUTPATCHDELETE创建一个应用程序,以及创建一个利用Socket.io库的内存实时聊天应用程序,利用 WebSockets。

在本章结束时,您将知道如何:

  • 使用json-server创建模拟数据库 API

  • 使用Axios创建 HTTP 请求

  • 使用 WebSockets 和Socket.io进行客户端之间的实时通信

HTTP

让我们首先创建一个新的 Vue.js 项目,作为我们的游乐场项目。在终端中输入以下内容:

# Create a new Vue project
$ vue init webpack-simple vue-http

# Navigate to directory
$ cd vue-http
# Install dependencies
$ npm install

# Run application
$ npm run dev

在 JavaScript 中有许多创建 HTTP 请求的方法。我们将使用Axios库在项目中使用简化的基于 promise 的方法。让我们通过在终端中输入以下内容来安装它:

# Install Axios to our project
$ npm install axios --save

我们现在有了创建 HTTP 请求的能力;我们只需要一个 API 来指向Axios。让我们创建一个模拟 API。

安装 JSON 服务器

为了创建一个模拟 API,我们可以使用json-server库。这允许我们通过在项目内创建一个db.json文件来快速全局启动。它有效地创建了一个 GET,POST,PUT,PATCH 和 DELETE API,并将数据存储在一个文件中,附加到我们的原始 JSON 文件中。

我们可以通过在终端中运行以下命令来安装它:

# Install the json-server module globally
$ npm install json-server -g

由于我们添加了-g标志,我们将能够在整个终端中全局访问json-server模块。

接下来,我们需要在项目的根目录下创建我们的db.json文件。您可以根据需要对数据集进行创意处理;我们只是简单地有一份我们可能感兴趣的课程列表:

{
  "courses": [
    {
      "id": 1,
      "name": "Vue.js Design Patterns"
    },
    {
      "id": 2,
      "name": "Angular: From Beginner to Advanced"
    },
    {
      "id": 3,
      "name": "Cross Platform Native Applications with Fuse"
    }
  ]
}

然后我们可以通过在终端中运行以下命令来运行我们的数据库:

# Run the database based on our db.json file
$ json-server db.json --watch

如果我们一切顺利,我们应该能够通过http://localhost:3000访问我们的数据库,如下成功消息所示:

太棒了。我们已经准备好了,现在我们可以获取课程列表。

HTTP GET

我们需要做的第一件事是将Axios导入到我们的App.vue组件中。在这种情况下,我们还可以设置一个ROOT_URL,因为我们只会寻找/courses端点:

<script>
import axios from 'axios'
export default {
  data() {
    return {
      ROOT_URL: 'http://localhost:3000/courses',
      courses: []
    }
  }
}
</script>

这样我们就能够钩入created()这样的生命周期钩子,并调用一个从我们的 API 请求课程的方法:

export default {
  data() {
    return {
      ROOT_URL: 'http://localhost:3000/courses',
      courses: []
    }
  },
  created() {
    this.getCourseList();
  },
  methods: {
    getCourseList() {
      axios
        .get(this.ROOT_URL)
        .then(response => {
          this.courses = response.data;
        })
        .catch(error => console.log(error));
    }
  }
}

这里发生了什么?我们调用了getCoursesList函数,该函数向我们的http://localhost:3000/courses端点发出了 HTTPGET请求。然后,它要么将课程数组设置为数据(也就是说,我们的db.json中的所有内容),要么仅仅在出现错误时记录错误。

然后,我们可以使用v-指令在屏幕上显示这个:

<template>
  <div class="course-list">
    <h1>Courses</h1>
    <div v-for="course in courses" v-bind:key="course.id">
      <p>
        {{course.name}}
      </p> 
    </div>
  </div>
</template>

再加上一点样式,我们得到:

<style>
.course-list {
  background-color: rebeccapurple;
  padding: 10px;
  width: 50%;
  text-align: center;
  margin: 0 auto;
  color: white;
}
</style>

让我们继续进行 HTTP POST!

HTTP POST

我们可以在courseName div后面添加一个输入框和button,允许用户向他们的学习列表中输入一个新的课程:

<div>
 <input type="text" v-model="courseName" placeholder="Course name"> 
 <button @click="addCourse(courseName)">Add</button>
</div>

这要求我们将courseName变量添加到我们的data对象中:

data() {
 return {
  ROOT_URL: 'http://localhost:3000/courses/',
  courses: [],
  courseName: '',
 };
},

然后,我们可以创建一个名为addCourse的类似方法,该方法以courseName作为参数:

methods: {
// Omitted
 addCourse(name) {
  axios
   .post(this.ROOT_URL, { name })
   .then(response => {
     this.courses.push(response.data);
     this.courseName = ''; 
   })
   .catch(error => console.log(error));
 }
}

您可能会注意到它与之前的 HTTP 调用非常相似,但这次我们使用的是.post而不是.get,并传递了一个具有name键和值的对象。

发送 POST 请求后,我们使用this.courses.push(response.data)来更新客户端数组,因为虽然服务器端(我们的客户端db.json文件)已更新,但客户端状态没有更新。

HTTP PUT

接下来,我们想要做的是能够更改列表中的项目。也许在提交项目时我们犯了一个错误,因此我们想要编辑它。让我们添加这个功能。

首先,让我们告诉 Vue 跟踪我们何时正在编辑课程。用户编辑课程的意图是每当他们点击课程名称时;然后我们可以将编辑布尔值添加到我们的data对象中:

data() {
 return {
  ROOT_URL: 'http://localhost:3000/courses/',
  courses: [],
  courseName: '',
  editing: false,
 };
},

然后我们的模板可以更改以反映这一点:

<template>
 <div class="course-list">
  <h1>Courses</h1>
  <div v-for="course in courses" v-bind:key="course.id">
   <p @click="setEdit(course)" v-if="!editing">
   {{course.name}}
   </p>
  <div v-else>
   <input type="text" v-model="course.name">
   <button @click="saveCourse(course)">Save</button>
  </div> 
  </div>
  <div v-if="!editing">
  <input type="text" v-model="courseName" placeholder="Course name"> 
  <button @click="addCourse(courseName)">Add</button>
  </div>
 </div>
</template>

这里到底发生了什么?嗯,我们已经将我们的courseName更改为只在我们不编辑时显示(也就是说,我们没有点击课程名称)。相反,使用v-else指令,我们显示一个输入框和button,允许我们保存新的CourseName

此时,我们还隐藏了添加课程按钮,以保持简单。

代码如下所示:

setEdit(course) {
 this.editing = !this.editing;
},
saveCourse(course) {
 this.setEdit();
 axios
 .put(`${this.ROOT_URL}/${course.id}`, { ...course })
 .then(response => {
 console.log(response.data);
 })
 .catch(error => console.log(error));
}

在这里,我们在指向所选课程的端点上使用了我们的axios实例上的.put方法。作为数据参数,我们使用了展开操作符{ ...course }来解构课程变量以与我们的 API 一起使用。

之后,我们只是将结果记录到控制台。当我们将"Vue.js Design Patterns"字符串编辑为简单地说Vue.js时,它看起来是这样的:

耶!我们要看的最后一件事是 DELETE 和从我们的数据库中删除项目。

HTTP DELETE

为了从我们的列表中删除项目,让我们添加一个button,这样当用户进入编辑模式(通过点击一个项目)时,他们可以删除那个特定的课程:

<div v-else>
  <input type="text" v-model="course.name">
  <button @click="saveCourse(course)">Save</button>
  <button @click="removeCourse(course)">Remove</button>
</div> 

我们的removeCourse函数如下:

removeCourse(course) {
  axios
    .delete(`${this.ROOT_URL}/${course.id}`)
    .then(response => {
      this.setEdit();
      this.courses = this.courses.filter(c => c.id != course.id);
    })
    .catch(error => console.error(error));
},

我们调用axios.delete方法,然后过滤我们的courses列表,除了我们删除的课程之外的每个课程。然后更新我们的客户端状态,并使其与数据库一致。

在本章的这一部分中,我们根据我们的 REST API 创建了一个简单的“我想学习的课程”列表。它当然可以被抽象为多个组件,但由于这不是应用程序的核心重点,我们只是在一个组件中完成了所有操作。

接下来,让我们使用 Node 和Socket.io制作一个实时聊天应用程序。

使用 Node 和 Socket.io 制作实时聊天应用程序

在本节中,我们将使用 Node 和Socket.io创建一个实时聊天应用程序。我们将使用 Node.js 和 Express 框架编写少量代码,但它都是您所熟悉和喜爱的 JavaScript。

在您的终端中运行以下命令以创建一个新项目:

# Create a new Vue project
$ vue init webpack-simple vue-chat

# Navigate to directory
$ cd vue-chat

# Install dependencies
$ npm install

# Run application
$ npm run dev

然后我们可以创建一个服务器文件夹,并初始化一个package.json,用于服务器特定的依赖项,如下所示:

# Create a new folder named server
$ mkdir server

# Navigate to directory
$ cd server

# Make a server.js file
$ touch server.js

# Initialise a new package.json
$ npm init -y

# Install dependencies
$ npm install socket.io express --save

什么是 Socket.io?

在我们之前的例子中,如果我们想要从服务器获取新数据,我们需要发出另一个 HTTP 请求,而使用 WebSockets,我们可以简单地拥有一个一致的事件监听器,每当事件被触发时就会做出反应。

为了在我们的聊天应用程序中利用这一点,我们将使用Socket.io。这是一个客户端和服务器端的库,允许我们快速轻松地使用 WebSockets。它允许我们定义和提交事件,我们可以监听并随后执行操作。

服务器设置

然后,我们可以使用 Express 创建一个新的 HTTP 服务器,并通过在server.js中添加以下内容来监听应用程序连接:

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const PORT = 3000;

http.listen(PORT, () => console.log(`Listening on port: ${PORT}`));

io.on('connection', socket => {
  console.log('A user connected.');
});

如果我们在server文件夹内的终端中运行node server.js,我们应该会看到消息“Listening on port: 3000”。这意味着一旦我们在客户端应用程序中实现Socket.io,我们就能够监视每当有人连接到应用程序时。

客户端连接

为了捕获客户端连接,我们需要在 Vue 应用程序中安装Socket.io。我们还将使用另一个名为vue-socket.io的依赖项,在 Vue 应用程序中为我们提供更流畅的实现。

在终端中运行以下命令,确保你在根目录下(即不在server文件夹中):

# Install socket.io-client and vue-socket.io
$ npm install socket.io-client vue-socket.io --save

设置 Vue 和 Socket.io

让我们转到我们的main.js文件,这样我们就可以注册Socket.ioVue-Socket.io插件。你可能还记得如何在之前的章节中做到这一点:

import Vue from 'vue';
import App from './App.vue';
import SocketIo from 'socket.io-client';
import VueSocketIo from 'vue-socket.io';

export const Socket = SocketIo(`http://localhost:3000`);

Vue.use(VueSocketIo, Socket);

new Vue({
  el: '#app',
  render: h => h(App),
});

在上述代码块中,我们导入必要的依赖项,并创建对我们当前运行在端口3000上的 Socket.io 服务器的引用。然后我们使用Vue.use添加 Vue 插件。

如果我们做的一切都正确,我们的客户端和服务器应该在彼此交流。我们应该在终端中看到以下内容:

确定连接状态

现在我们已经添加了 Vue-Socket.io 插件,我们可以在 Vue 实例内部访问 sockets 对象。这使我们能够监听特定事件,并确定用户是否连接或断开 WebSocket 连接。

App.vue中,让我们在屏幕上显示一条消息,如果我们与服务器连接/断开连接:

<template>
  <div>
    <h1 v-if="isConnected">Connected to the server.</h1>
    <h1 v-else>Disconnected from the server.</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isConnected: false,
    };
  },
  sockets: {
    connect() {
      this.isConnected = true;
    },
    disconnect() {
      this.isConnected = false;
    },
  },
};
</script>

除了 sockets 对象之外,这里不应该有太多新的东西。每当我们连接到 socket 时,我们可以在connect()钩子内运行任何代码,disconnect()也是一样。我们只是翻转一个布尔值,以便在屏幕上显示不同的消息,使用v-ifv-else指令。

最初,我们得到了 Connected to the server,因为我们的服务器正在运行。如果我们在终端窗口中使用CTRL + C停止服务器,我们的标题将更改以反映我们不再具有 WebSocket 连接的事实。以下是结果:

创建连接状态栏

让我们用这个概念玩一些游戏。我们可以创建一个 components 文件夹,然后创建一个名为ConnectionStatus.vue的新组件。在这个文件中,我们可以创建一个状态栏,当用户在线或离线时向用户显示:

<template>
  <div>
    <span v-if="isConnected === true" class="bar connected">
      Connected to the server.
    </span>
    <span v-else class="bar disconnected">
      Disconnected from the server.
    </span>
  </div>
</template>

<script>
export default {
  props: ['isConnected'],
};
</script>

<style>
.bar {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
  padding: 5px;
}

.connected {
  background: greenyellow;
  color: black;
}

.disconnected {
  background: red;
  color: white;
}
</style>

虽然我们当前应用程序中只有一个屏幕,但我们可能希望在多个组件中使用这个组件,所以我们可以在main.js中全局注册它:

import App from './App.vue';
import ConnectionStatus from './components/ConnectionStatus.vue';

Vue.component('connection-status', ConnectionStatus);

然后,我们可以编辑我们的 App.vue 模板以使用此组件,并将当前连接状态作为 prop 传递:

<template>
  <div>
    <connection-status :isConnected="isConnected" />
  </div>
</template>

这是我们的结果:

接下来,我们可以创建一个导航栏组件,使我们的用户界面更完整。

导航栏

导航栏组件除了简单显示我们应用程序的名称外,不会有太多用途。您可以更改此功能,以包括其他功能,例如登录/注销、添加新的聊天频道或任何其他特定于聊天的用户操作。

让我们在 components 文件夹中创建一个名为 Navbar.vue 的新组件:

<template>
  <div v-once>
    <nav class="navbar">
      <span>Socket Chat</span>
    </nav>
  </div>
</template>

<script>
export default {};
</script>

<style>
.navbar {
  background-color: blueviolet;
  padding: 10px;
  margin: 0px;
  text-align: center;
  color: white;
}
</style>

您可能会注意到在这个 div 上添加了 v-once 指令。这是我们第一次看到它,但由于这个组件完全是静态的,我们可以告诉 Vue 不要监听任何更改,只渲染一次。

然后,我们必须删除 HTML body 内部的任何默认填充或边距。在根目录中创建一个名为 styles.css 的文件,其中包含这些属性:

body {
 margin: 0px;
 padding: 0px;
}

然后,我们可以像这样将其添加到我们的 index.html 文件中:

<head>
 <meta charset="utf-8">
 <title>vue-chat</title>
 <link rel="stylesheet" href="styles.css">
</head>

接下来,我们需要全局注册此组件。如果您觉得可以的话,请尝试在 main.js 中自行完成。

这要求我们导入 Navbar 并像这样注册它:

import Navbar from './components/Navbar.vue'

Vue.component('navigation-bar', Navbar);

然后我们可以将其添加到我们的 App.vue 文件中:

<template>
  <div>
    <navigation-bar />
    <connection-status :isConnected="isConnected" />
  </div>
</template>

接下来,让我们创建我们的 MessageList 组件来保存消息列表。

消息列表

通过创建一个接受消息数组的 prop 的新组件,我们可以在屏幕上显示消息列表。在 components 文件夹中创建一个名为 MessageList.vue 的新组件:

<template>
 <div>
  <span v-for="message in messages" :key="message.id">
  <strong>{{message.username}}: </strong> {{message.message}}
  </span>
 </div>
</template>

<script>
export default {
 props: ['messages'],
};
</script>

<style scoped>
div {
 overflow: scroll;
 height: 150px;
 margin: 10px auto 10px auto;
 padding: 5px;
 border: 1px solid gray;
}
span {
 display: block;
 padding: 2px;
}
</style>

这个组件非常简单;它只是使用 v-for 指令遍历我们的 messages 数组。我们使用适当的 prop 将消息数组传递给这个组件。

不要将此组件全局注册,让我们在 App.vue 组件内部特别注册它。在这里,我们还可以向 messages 数组添加一些虚拟数据:

import MessageList from './components/MessageList.vue';

export default {
 data() {
  return {
   isConnected: false,
   messages: [
    {
     id: 1,
     username: 'Paul',
     message: 'Hey!',
    },
    {
     id: 2,
     username: 'Evan',
     message: 'How are you?',
    },
   ],
  };
 },
 components: {
 MessageList,
},

然后我们可以将 message-list 组件添加到我们的模板中:

 <div class="container">
  <message-list :messages="messages" />
 </div>

我们根据数据对象中找到的消息数组将消息作为 prop 传递。我们还可以添加以下样式:

<style>
.container {
 width: 300px;
 margin: 0 auto;
}
</style>

这样做将使我们的消息框居中显示在屏幕上,并限制 width 以进行演示。

我们正在取得进展!这是我们的消息框:

接下来呢?嗯,我们仍然需要能够向我们的列表中添加消息的功能。让我们接下来处理这个。

向列表添加消息

在 components 文件夹中创建一个名为MessageForm.vue的新组件。这将用于将消息输入到列表中。

我们可以从以下开始:

<template>
  <form @submit.prevent="sendMessage">
    <div>
      <label for="username">Username:</label>
      <input type="text" name="username" v-model="username">
    </div>
    <div>
      <label for="message">Message:</label>
      <textarea name="message" v-model="message"></textarea>
    </div>
    <button type="submit">Send</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      message: '',
    };
  },
};
</script>

<style>
input,
textarea {
  margin: 5px;
  width: 100%;
}
</style>

这本质上允许我们捕获用户对所选usernamemessage的输入。然后我们可以使用这些信息在sendMessage函数中向我们的Socket.io服务器发送数据。

通过将@submit.prevent添加到我们的表单而不是@submit,我们确保覆盖了提交表单的默认行为;这是必要的,否则我们的页面会重新加载。

让我们去注册我们的表单在App.vue中,即使我们还没有连接任何操作:

import MessageList from './components/MessageList.vue';

export default {
 // Omitted
 components: {
   MessageList,
   MessageForm,
 },
}

然后我们可以将其添加到我们的模板中:

<template>
  <div>
    <navigation-bar />
    <div class="container">
      <message-list :messages="messages" />
      <message-form />
    </div>
    <connection-status :isConnected="isConnected" />
  </div>
</template>

现在我们的应用程序看起来是这样的:

使用 Socket.io 进行服务器端事件

为了发送新消息,我们可以在我们的server.js文件中监听名为chatMessage的事件。

这可以在我们的原始连接事件内完成,确保我们按 socket 逐个 socket 地监听事件:

io.on('connection', socket => {
  console.log('A user connected.');

  socket.on('chatMessage', message => {
    console.log(message);
  })
});

如果我们从客户端发送chatMessage事件,那么它应该随后在我们的终端内记录出这条消息。让我们试一试!

因为我们对server.js文件进行了更改,所以我们需要重新启动 Node 实例。在运行server.js的终端窗口中按下CTRL + C,然后再次运行 node server.js

Nodemon

或者,您可能希望使用一个名为nodemon的模块,在进行任何更改时自动执行此操作。

在您的终端内运行以下命令:

# Install nodemon globally
$ npm install nodemon -g

然后我们可以运行:

# Listen for any changes to our server.js file and restart the server
$ nodemon server.js

太好了!让我们回到我们的MessageForm组件并创建sendMessage函数:

methods: {
 sendMessage() {
   this.socket.emit('chatMessage', {
     username: this.username,
     message: this.message,
   });
 },
},

此时点击发送还没有将消息添加到数组中,但它确实在我们的终端内显示了发送的消息!让我们来看一下:

事实证明,我们不必写太多代码来利用我们的 WebSockets。让我们回到App.vue组件并向我们的 sockets 对象添加一个名为chatMessage的函数。注意这与事件名称相同,这意味着每次触发此事件时我们都可以运行特定的方法:

export default {
// Omitted
 sockets: {
  connect() {
   this.isConnected = true;
  },
  disconnect() {
   this.isConnected = false;
  },
  chatMessage(messages) {
   this.messages = messages;
  },
 },
}

我们的客户端代码现在已经连接并监听chatMessage事件。问题在于我们的服务器端代码目前没有向客户端发送任何内容!让我们通过在 socket 内部发出一个事件来解决这个问题:

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const PORT = 3000;

http.listen(PORT, () => console.log(`Listening on port: ${PORT}`));

const messages = [];

const emitMessages = () => io.emit('chatMessage', messages);

io.on('connection', socket => {
  console.log('A user connected.');

  emitMessages(messages);

  socket.on('chatMessage', message => {
    messages.push(message);

    emitMessages(messages);
  });
});

我们使用一个名为 messages 的数组将消息保存在内存中。每当客户端连接到我们的应用程序时,我们也会向下游发送这些消息(所有先前的消息都将显示)。除此之外,每当数组中添加新消息时,我们也会将其发送给所有客户端。

如果我们打开两个 Chrome 标签,我们应该能够进行自我导向的对话!

然后我们可以在另一个标签页中与自己交谈!

总结

在本章中,我们学习了如何使用Axios库和json-server在 Vue 中创建 HTTP 请求。这使我们能够与第三方 API 进行交互,并增强我们的 Vue 应用程序。

我们还学习了如何使用 WebSockets 和Socket.io创建一个更大的应用程序。这使我们能够与连接到我们的应用程序的其他客户端进行实时通信,从而实现更多的可能性。

我们已经走了很长的路!为了真正利用 Vue,我们需要掌握路由器并了解高级状态管理概念。这将在接下来的章节中讨论!

第八章:Vue 路由模式

路由是任何单页应用程序SPA)的重要组成部分。本章重点介绍了最大化 Vue 路由器,并涵盖了从在页面之间路由用户到参数到最佳配置的一切。

在本章结束时,我们将涵盖以下内容:

  • 在 Vue.js 应用程序中实现路由

  • 使用动态路由匹配创建路由参数

  • 将路由参数作为组件属性传递

单页应用程序

现代 JavaScript 应用程序实现了一种称为 SPA 的模式。在其最简单的形式中,它可以被认为是根据 URL 显示组件的应用程序。由于模板被映射到路由,因此无需重新加载页面,因为它们可以根据用户导航的位置进行注入。

这是路由器的工作。

通过这种方式创建我们的应用程序,我们能够增加感知和实际速度,因为我们的应用程序更加动态。如果我们加入在上一章学到的概念(HTTP),你会发现它们与 SPA 模型紧密相连。

使用路由器

让我们启动一个游乐场项目并安装 vue-router 库。这使我们能够在我们的应用程序内利用路由,并为我们提供现代 SPA 的功能。

在终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vue-router-basics

# Navigate to directory
$ cd vue-router-basics

# Install dependencies
$ npm install

# Install Vue Router
$ npm install vue-router

# Run application
$ npm run dev

由于我们在构建系统中使用 webpack,我们已经用 npm 安装了路由器。然后我们可以在 src/main.js 中初始化路由器:

import Vue from 'vue';
import VueRouter from 'vue-router';

import App from './App.vue';

Vue.use(VueRouter);

new Vue({
  el: '#app',
  render: h => h(App)
});

这实际上将 VueRouter 注册为全局插件。插件只是一个接收 Vueoptions 作为参数的函数,并允许诸如 VueRouter 这样的库向我们的 Vue 应用程序添加功能。

创建路由

然后我们可以在 main.js 文件中定义两个小组件,它们只是有一个模板,显示带有一些文本的 h1

const Hello = { template: `<h1>Hello</h1>` };
const World = { template: `<h1>World</h1>`};

然后,为了在特定的 URL(如 /hello/world)上在屏幕上显示这些组件,我们可以在我们的应用程序内定义路由:

const routes = [
  { path: '/hello', component: Hello },
  { path: '/world', component: World }
];

现在我们已经定义了我们想要在应用程序中使用的组件以及路由,我们需要创建一个新的 VueRouter 实例并传递路由。

尽管我们使用了 Vue.use(VueRouter),但我们仍然需要创建一个新的 VueRouter 实例并初始化我们的路由。这是因为仅仅将 VueRouter 注册为插件,就可以让我们在 Vue 实例中访问路由选项:

const router = new VueRouter({
  routes
});

然后我们需要将router传递给我们的根 Vue 实例:

new Vue({
  el: '#app',
  router,
  render: h => h(App)
});

最后,为了在我们的App.vue组件内显示路由组件,我们需要在template内添加router-view组件:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

如果我们然后导航到/#/hello//#/world,将显示适当的组件:

动态路由

我们还可以根据特定参数动态匹配路由。这可以通过在参数名称前指定带有冒号的路由来实现。以下是使用类似问候组件的示例:

// Components
const Hello = { template: `<h1>Hello</h1>` };
const HelloName = { template: `<h1>Hello {{ $route.params.name}}` }

// Routes
const routes = [
 { path: '/hello', component: Hello },
 { path: '/hello/:name', component: HelloName },
]

如果我们的用户导航到/hello,他们将看到带有文本Helloh1。否则,如果他们导航到/hello/{name}(即 Paul),他们将看到带有文本Hello Paulh1

我们取得了很大的进展,但重要的是要知道,当我们导航到参数化的 URL 时,如果参数发生变化(即从/hello/paul/hello/katie),组件生命周期钩子不会再次触发。我们很快会看到这一点!

路由 props

让我们将我们的/hello/name路由更改为将name参数作为component prop 传递,可以通过在路由中添加props: true标志来实现:

const routes = [
  { path: '/hello', component: Hello },
  { path: '/hello/:name', component: HelloName, props: true},
]

然后我们可以更新我们的组件以接受具有id名称的 prop,并在生命周期钩子中将其记录到控制台中:

const HelloName = {
  props: ['name'],
  template: `<h1>Hello {{ name }}</h1>`,
  created() {
    console.log(`Hello ${this.name}`)
  }
}

如果我们尝试导航到不同的动态路由,我们会看到创建的钩子只触发一次(除非我们刷新页面),即使我们的页面显示了正确的名称:

组件导航守卫

我们如何解决生命周期钩子问题?在这种情况下,我们可以使用所谓的导航守卫。这允许我们钩入路由器的不同生命周期,例如beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave方法。

beforeRouteUpdate

让我们使用beforeRouteUpdate方法来访问有关路由更改的信息:

const HelloName = {
  props: ['name'],
  template: `<h1>Hello {{ name }}</h1>`,
  beforeRouteUpdate(to, from, next) {
    console.log(to);
    console.log(from);
    console.log(`Hello ${to.params.name}`)
  },
}

如果我们在导航到/hello/{name}下的不同路由后检查 JavaScript 控制台,我们将能够看到用户要去哪里以及他们来自哪里。tofrom对象还让我们访问params、查询、完整路径等等。

虽然我们正确地获得了日志声明,但是如果我们尝试在路由之间导航,您会注意到我们的应用程序不会使用参数name prop 进行更新。这是因为在守卫内完成任何计算后,我们没有使用next函数。让我们添加进去:

  beforeRouteUpdate(to, from, next) {
    console.log(to);
    console.log(from);
    console.log(`Hello ${to.params.name}`)
    next();
  },

beforeRouteEnter

我们还可以利用beforeRouteEnter在进入组件路由之前执行操作。这里有一个例子:

 beforeRouteEnter(to, from, next) {
  console.log(`I'm called before entering the route!`)
  next();
 }

我们仍然必须调用next将堆栈传递给下一个路由处理程序。

beforeRouteLeave

我们还可以钩入beforeRouteLeave,以便在我们从一个路由导航离开时执行操作。由于我们已经在这个钩子的上下文中在这个路由上,我们可以访问组件实例。让我们来看一个例子:

 beforeRouteLeave(to, from, next) {
 console.log(`I'm called before leaving the route!`)
 console.log(`I have access to the component instance, here's proof! 
 Name: ${this.name}`);
 next();
 }

再次,在这个实例中,我们必须调用next

全局路由钩子

我们已经了解了组件导航守卫,虽然这些守卫是基于组件的,但您可能希望建立全局钩子来监听导航事件。

beforeEach

我们可以使用router.beforeEach来全局监听应用程序中的路由事件。如果您有身份验证检查或其他应该在每个路由中使用的功能,这是值得使用的。

这是一个简单记录用户要去和来自的路由的示例。以下每个示例都假定路由器存在于类似以下的范围内:

const router = new VueRouter({
  routes
})

router.beforeEach((to, from, next) => {
 console.log(`Route to`, to)
 console.log(`Route from`, from)
 next();
});

再次,我们必须调用next()来触发下一个路由守卫。

beforeResolve

在确认导航之前触发beforeResolve全局路由守卫,但重要的是要知道,这仅在所有特定于组件的守卫和异步组件已解析之后才会发生。

这里有一个例子:

router.beforeResolve((to, from, next) => {
 console.log(`Before resolve:`)
 console.log(`Route to`, to)
 console.log(`Route from`, from)
 next();
});

afterEach

我们还可以钩入全局afterEach函数,允许我们执行操作,但我们无法影响导航,因此只能访问tofrom参数:

router.afterEach((to, from) => {
 console.log(`After each:`)
 console.log(`Route to`, to)
 console.log(`Route from`, from)
});

解析堆栈

现在我们已经熟悉了各种不同的路由生命周期钩子,值得在尝试导航到另一个路由时调查整个解析堆栈:

  1. 触发路由更改:这是任何路由生命周期的第一阶段,也是我们尝试导航到新路由时触发的。例如,从/hello/Paul/hello/Katie。此时尚未触发任何导航守卫。

  2. 触发组件离开守卫:接下来,任何离开守卫都会被触发,例如beforeRouteLeave,在加载的组件上。

  3. 触发全局 beforeEach 守卫:由于可以使用beforeEach创建全局路由中间件,这些函数将在任何路由更新之前被调用。

  4. 触发重用组件中的本地 beforeRouteUpdate 守卫:正如我们之前看到的,每当我们使用不同的参数导航到相同的路由时,生命周期钩子不会被触发两次。相反,我们使用beforeRouteUpdate来触发生命周期更改。

  5. 在组件中触发 beforeRouteEnter:在导航到任何路由之前每次都会调用这个。在这个阶段,组件没有被渲染,因此没有访问this组件实例。

  6. 解析异步路由组件:然后尝试解析项目中的任何异步组件。这里有一个例子:

const MyAsyncComponent = () => ({
component: import ('./LazyComponent.vue'),
loading: LoadingComponent,
error: ErrorComponent,
delay: 150,
timeout: 3000
})
  1. 在成功激活的组件中触发 beforeRouteEnter

现在我们可以访问beforeRouteEnter钩子,并在解析路由之前执行任何操作。

  1. 触发全局 beforeResolve 钩子:在组件内提供守卫和异步路由组件已经被解析后,我们现在可以钩入全局的router.beforeResolve方法,允许我们在这个阶段执行操作。

  2. 导航:所有先前的导航守卫都已触发,用户现在成功导航到了一个路由。

  3. 触发 afterEach 钩子:虽然用户已经被导航到了路由,但事情并没有到此为止。接下来,路由器会触发一个全局的afterEach钩子,该钩子可以访问tofrom参数。由于在这个阶段路由已经被解析,它没有下一个参数,因此不能影响导航。

  4. 触发 DOM 更新:路由已经被解析,Vue 可以适当地触发 DOM 更新。

  5. 在 beforeRouteEnter 中触发 next 中的回调:由于beforeRouteEnter没有访问组件的this上下文,next参数采用一个回调函数,在导航时解析为组件实例。一个例子可以在这里看到:

beforeRouteEnter (to, from, next) {   
 next(comp => {
  // 'comp' inside this closure is equal to the component instance
 }) 

程序化导航

我们不仅限于使用router-link进行模板导航;我们还可以在 JavaScript 中以编程方式将用户导航到不同的路由。在我们的App.vue中,让我们暴露<router-view>并让用户能够选择一个按钮,将他们导航到/hello/hello/:name路由:

<template>
  <div id="app">
    <nav>
      <button @click="navigateToRoute('/hello')">/Hello</button>
      <button 
       @click="navigateToRoute('/hello/Paul')">/Hello/Name</button>
    </nav>
    <router-view></router-view>
  </div>
</template>

然后,我们可以添加一个方法,将新的路由推送到路由堆栈上:

<script>
export default {
  methods: {
    navigateToRoute(routeName) {
      this.$router.push({ path: routeName });
    },
  },
};
</script>

在这一点上,每当我们选择一个按钮,它应该随后将用户导航到适当的路由。$router.push()函数可以采用各种不同的参数,这取决于你如何设置你的路由。以下是一些例子:

// Navigate with string literal
this.$router.push('hello')

// Navigate with object options
this.$router.push({ path: 'hello' })

// Add parameters
this.$router.push({ name: 'hello', params: { name: 'Paul' }})

// Using query parameters /hello?name=paul
this.$router.push({ path: 'hello', query: { name: 'Paul' }})

router.replace

我们还可以用router.replace替换当前的历史堆栈,而不是将导航项推送到堆栈上。这是一个例子:

this.$router.replace({ path: routeName });

router.go

如果我们想要向用户后退或前进导航,我们可以使用router.go;这本质上是window.history API 的一个抽象。让我们看一些例子:

// Navigate forward one record
this.$router.go(1);

// Navigate backward one record
this.$router.go(-1);

// Navigate forward three records
this.$router.go(3);

// Navigate backward three records
this.$router.go(-3);

延迟加载路由

我们还可以延迟加载我们的路由,以利用 webpack 的代码拆分。这使我们比急切加载路由时拥有更好的性能。为了做到这一点,我们可以创建一个小型的试验项目。在终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vue-lazy-loading

# Navigate to directory
$ cd vue-lazy-loading

# Install dependencies
$ npm install

# Install Vue Router
$ npm install vue-router

# Run application
$ npm run dev

让我们首先创建两个组件,命名为Hello.vueWorld.vue,放在src/components目录下:

// Hello.vue
<template>
  <div>
    <h1>Hello</h1>
    <router-link to="/world">Next</router-link>
  </div>
</template>

<script>
export default {};
</script>

现在我们已经创建了Hello.vue组件,让我们创建第二个World.vue

// World.vue
<template>
  <div>
    <h1>World</h1>
    <router-link to="/hello">Back</router-link>
  </div>
</template>

<script>
export default {};
</script>

然后我们可以像通常一样在main.js中初始化我们的路由:

import Vue from 'vue';
import VueRouter from 'vue-router';

Vue.use(VueRouter);

主要区别在于导入组件的方式。这需要使用syntax-dynamic-import Babel 插件。通过在终端中运行以下命令将其安装到您的项目中:

$ npm install --save-dev babel-plugin-syntax-dynamic-import

然后我们可以更新.babelrc以使用新的插件:

{
 "presets": [["env", { "modules": false }], "stage-3"],
 "plugins": ["syntax-dynamic-import"]
}

最后,这使我们能够异步导入我们的组件,就像这样:

const Hello = () => import('./components/Hello');
const World = () => import('./components/World');

然后我们可以定义我们的路由并初始化路由器,这次引用异步导入:

const routes = [
 { path: '/', redirect: '/hello' },
 { path: '/hello', component: Hello },
 { path: '/World', component: World },
];

const router = new VueRouter({
 routes,
});

new Vue({
 el: '#app',
 router,
 render: h => h(App),
});

然后我们可以通过在 Chrome 中查看开发者工具|网络选项卡来查看其结果,同时浏览我们的应用程序:

每个路由都被添加到自己的捆绑文件中,随后我们得到了改进的性能,因为初始捆绑文件要小得多:

一个 SPA 项目

让我们创建一个使用 RESTful API 和我们刚学到的路由概念的项目。通过在终端中运行以下命令来创建一个新项目:

# Create a new Vue project
$ vue init webpack-simple vue-spa

# Navigate to directory
$ cd vue-spa

# Install dependencies
$ npm install

# Install Vue Router and Axios
$ npm install vue-router axios

# Run application
$ npm run dev

启用路由

我们可以首先在应用程序中启用VueRouter插件。为了做到这一点,我们可以在src/router目录下创建一个名为index.js的新文件。我们将使用这个文件来包含所有特定于路由的配置,但根据底层功能将每个路由分别放在不同的文件中。

让我们导入并添加路由插件:

import Vue from 'vue';
import VueRouter from 'vue-router';

Vue.use(VueRouter)

定义路由

为了将路由分离到应用程序中的不同文件中,我们首先可以在src/components/user下创建一个名为user.routes.js的文件。每当我们有一个需要路由的不同功能集时,我们可以创建我们自己的*.routes.js文件,然后将其导入到路由的index.js中。

现在,我们只需导出一个新的空数组:

export const userRoutes = [];

然后我们可以将路由添加到我们的index.js中(即使我们还没有定义任何路由):

import { userRoutes } from '../components/user/user.routes';

const routes = [...userRoutes];

我们正在使用 ES2015+扩展运算符,它允许我们使用数组中的每个对象而不是数组本身。

然后初始化路由,我们可以创建一个新的VueRouter并传递路由,如下所示:

const router = new VueRouter({
  // This is ES2015+ shorthand for routes: routes
  routes,
});

最后,让我们导出路由,以便在我们的主 Vue 实例中使用它:

export default router;

main.js中,让我们导入路由并将其添加到实例中,如下所示:

import Vue from 'vue';
import App from './App.vue';
import router from './router';

new Vue({
 el: '#app',
 router,
 render: h => h(App),
});

创建 UserList 路由

我们应用程序的第一部分将是一个主页,显示来自 API 的用户列表。我们过去曾使用过这个例子,所以你应该熟悉涉及的步骤。让我们在src/components/user下创建一个名为UserList.vue的新组件。

组件将看起来像这样:

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{user.name}}
    </li>
  </ul> 
</template>

<script>
export default {
  data() {
    return {
      users: [
        {
          id: 1,
          name: 'Leanne Graham',
        }
      ],
    };
  },
};
</script>

现在可以随意添加自己的测试数据。我们将很快从 API 请求这些数据。

当我们创建了我们的组件后,我们可以在user.routes.js中添加一个路由,每当激活'/'(或您选择的路径)时显示这个组件:

import UserList from './UserList';

export const userRoutes = [{ path: '/', component: UserList }];

为了显示这个路由,我们需要更新App.vue,以便随后将内容注入到router-view节点中。让我们更新App.vue来处理这个问题:

<template>
 <div>
  <router-view></router-view>
 </div>
</template>

<script>
export default {};
</script>

<style>

</style>

我们的应用程序应该显示一个单一的用户。让我们创建一个 HTTP 实用程序来从 API 获取数据。

从 API 获取数据

src/utils下创建一个名为api.js的新文件。这将用于创建Axios的基本实例,然后我们可以在其上执行 HTTP 请求:

import axios from 'axios';

export const API = axios.create({
 baseURL: `https://jsonplaceholder.typicode.com/`
})

然后我们可以使用beforeRouteEnter导航守卫来在有人导航到'/'路由时获取用户数据:

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{user.name}}
    </li>
  </ul> 
</template>

<script>
import { API } from '../../utils/api';
export default {
  data() {
    return {
      users: [],
    };
  },
  beforeRouteEnter(to, from, next) {
    API.get(`users`)
      .then(response => next(vm => (vm.users = response.data)))
      .catch(error => next(error));
  },
};
</script>

然后我们发现屏幕上显示了用户列表,如下截图所示,每个用户都表示为不同的列表项。下一步是创建一个detail组件,注册详细路由,并找到一种链接到该路由的方法:

创建详细页面

为了创建一个详细页面,我们可以创建UserDetail.vue并按照与上一个组件类似的步骤进行操作:

<template>
  <div class="container">
    <div class="user">
      <div class="user__name">
        <h1>{{userInfo.name}}</h1>
        <p>Person ID {{$route.params.userId}}</p>
        <p>Username: {{userInfo.username}}</p>
        <p>Email: {{userInfo.email}}</p>
      </div>
      <div class="user__address" v-if="userInfo && userInfo.address">
        <h1>Address</h1>
        <p>Street: {{userInfo.address.street}}</p>
        <p>Suite: {{userInfo.address.suite}}</p>
        <p>City: {{userInfo.address.city}}</p>
        <p>Zipcode: {{userInfo.address.zipcode}}</p>
        <p>Lat: {{userInfo.address.geo.lat}} Lng: 
        {{userInfo.address.geo.lng}} </p>
      </div>

      <div class="user__other" >
        <h1>Other</h1>
        <p>Phone: {{userInfo.phone}}</p>
        <p>Website: {{userInfo.website}}</p>
        <p v-if="userInfo && userInfo.company">Company: 
        {{userInfo.company.name}}</p>
      </div>
    </div>
  </div>
</template>

<script>
import { API } from '../../utils/api';

export default {
  data() {
    return {
      userInfo: {},
    };
  },
  beforeRouteEnter(to, from, next) {
    next(vm => 
      API.get(`users/${to.params.userId}`)
        .then(response => (vm.userInfo = response.data))
        .catch(err => console.error(err))
    )
  },
};
</script>

<style>
.container {
 line-height: 2.5em;
 text-align: center;
}
</style>

由于我们的详细页面中永远不应该有多个用户,因此userInfo变量被创建为 JavaScript 对象而不是数组。

然后我们可以将新组件添加到我们的user.routes.js中:

import UserList from './UserList';
import UserDetail from './UserDetail';

export const userRoutes = [
 { path: '/', component: UserList },
 { path: '/:userId', component: UserDetail },
];

为了链接到这个组件,我们可以在我们的UserList组件中添加router-link

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <router-link :to="{ path: `/${user.id}` }">
      {{user.name}}
      </router-link>
    </li>
  </ul> 
</template>

然后我们在浏览器中看一下,我们可以看到只有一个用户列出,下面的信息来自于与该用户相关联的用户详细信息:

子路由

我们还可以从我们的 API 中访问帖子,因此我们可以同时显示帖子信息和用户信息。让我们创建一个名为UserPosts.vue的新组件:

<template>
  <div>
    <ul>
      <li v-for="post in posts" :key="post.id">{{post.title}}</li>
    </ul>
  </div>
</template>

<script>
import { API } from '../../utils/api';
export default {
  data() {
    return {
      posts: [],
    };
  },
  beforeRouteEnter(to, from, next) {
       next(vm =>
          API.get(`posts?userId=${to.params.userId}`)
          .then(response => (vm.posts = response.data))
          .catch(err => console.error(err))
     )
  },
};
</script>

这允许我们根据我们的userId路由参数获取帖子。为了将此组件显示为子视图,我们需要在user.routes.js中注册它:

import UserList from './UserList';
import UserDetail from './UserDetail';
import UserPosts from './UserPosts';

export const userRoutes = [
  { path: '/', component: UserList },
  {
    path: '/:userId',
    component: UserDetail,
    children: [{ path: '/:userId', component: UserPosts }],
  },
];

然后我们可以在我们的UserDetail.vue组件中添加另一个<router-view>标签来显示子路由。模板现在看起来像这样:

<template>
  <div class="container">
    <div class="user">
        // Omitted
    </div>
    <div class="posts">
      <h1>Posts</h1>
      <router-view></router-view>
    </div>
  </div>
</template>

最后,我们还添加了一些样式,将用户信息显示在左侧,帖子显示在右侧:

<style>
.container {
  line-height: 2.5em;
  text-align: center;
}
.user {
  display: inline-block;
  width: 49%;
}
.posts {
  vertical-align: top;
  display: inline-block;
  width: 49%;
}
ul {
  list-style-type: none;
}
</style>

然后我们转到浏览器,我们可以看到数据的显示方式正如我们计划的那样,用户信息显示在左侧,帖子显示在右侧:

哒哒!我们现在已经创建了一个具有多个路由、子路由、参数等的 Vue 应用程序!

摘要

在本节中,我们学习了 Vue Router 以及如何使用它来创建单页面应用程序。因此,我们涵盖了从初始化路由器插件到定义路由、组件、导航守卫等等的所有内容。我们现在有必要的知识来创建超越单一组件的 Vue 应用程序。

现在我们已经扩展了我们的知识,并了解了如何使用 Vue Router,我们可以在下一章中继续处理Vuex中的状态管理。

第九章:使用 Vuex 进行状态管理

在本章中,我们将研究使用Vuex的状态管理模式。Vuex可能并非每个应用程序都需要,但当适合使用它时,了解它的重要性以及如何实现它是非常重要的。

在本章结束时,您将完成以下工作:

  • 了解了Vuex是什么以及为什么应该使用它

  • 创建您的第一个 Vuex 存储

  • 调查了 actions、mutations、getters 和 modules

  • 使用 Vue devtools 逐步执行Vuex变化

什么是 Vuex?

状态管理是现代 Web 应用程序的重要组成部分,随着应用程序的增长,管理这些状态是每个项目都面临的问题。Vuex旨在通过强制使用集中式存储来帮助我们实现更好的状态管理,本质上是应用程序内的单一真相来源。它遵循类似于 Flux 和 Redux 的设计原则,并且还与官方 Vue devtools 集成,为出色的开发体验。

到目前为止,我已经谈到了状态状态管理,但您可能仍然对这对于您的应用程序意味着什么感到困惑。让我们更深入地定义这些术语。

状态管理模式(SMP)

我们可以将状态定义为组件或应用程序中变量/对象的当前值。如果我们将我们的函数视为简单的输入->输出机器,那么这些函数外部存储的值构成了我们应用程序的当前状态。

注意我已经区分了组件级应用级状态。组件级状态可以定义为限定在一个组件内的状态(即我们组件内的数据函数)。应用级状态类似,但通常用于多个组件或服务之间。

随着我们的应用程序不断增长,跨多个组件传递状态变得更加困难。我们在本书的前面看到,我们可以使用事件总线(即全局 Vue 实例)来传递数据,虽然这样可以实现,但最好将我们的状态定义为一个统一的集中存储的一部分。这使我们能够更容易地思考应用程序中的数据,因为我们可以开始定义actionsmutations,这些总是生成状态的新版本,并且管理状态变得更加系统化。

事件总线是一种简单的状态管理方法,依赖于单一视图实例,在小型 Vuex 项目中可能有益,但在大多数情况下,应该使用 Vuex。随着我们的应用变得更大,使用 Vuex 清晰地定义我们的操作和预期的副作用,使我们能够更好地管理和扩展项目。

所有这些是如何结合在一起的一个很好的例子可以在以下截图中看到(vuex.vuejs.org/en/intro.html):

Vuex 状态流

让我们将这个例子分解成一个逐步的过程:

  1. 初始状态在 Vue 组件内呈现。

  2. Vue 组件分派一个Action来从后端 API获取一些数据。

  3. 然后触发一个Commit事件,由Mutation处理。这个Mutation返回一个包含来自后端 API的数据的新版本的状态。

  4. 然后可以在 Vue Devtools中看到这个过程,并且您有能力在应用程序中发生的先前状态的不同版本之间“时间旅行”。

  5. 然后在 Vue 组件内呈现新的状态

我们 Vuex 应用程序的主要组件是存储,它是我们所有组件的单一真相来源。存储可以被读取但不能直接改变;它必须有变异函数来进行任何更改。虽然这种模式一开始可能看起来很奇怪,如果您以前从未使用过状态容器,但这种设计允许我们以一致的方式向我们的应用程序添加新功能。

由于 Vuex 是原生设计用于与 Vue 一起工作,因此存储默认是响应式的。这意味着从存储内部发生的任何更改都可以实时看到,无需任何黑客技巧。

思考状态

作为一个思考练习,让我们首先定义我们应用程序的目标以及任何状态、操作和潜在的变化。您现在不必将以下代码添加到您的应用程序中,所以请随意继续阅读,我们将在最后把它全部整合在一起。

让我们首先将状态视为键/值对的集合:

const state = {
 count: 0 // number
}

对于我们的计数器应用程序,我们只需要一个状态元素——当前计数。这可能有一个默认值为0,类型为数字。因为这很可能是我们应用程序内唯一的状态,所以您可以考虑这个状态在这一点上是应用程序级别的。

接下来,让我们考虑用户可能想要在我们的计数器应用程序中执行的任何动作类型。

然后,这三种动作类型可以被分派到 store,因此我们可以执行以下变化,每次返回一个新的状态版本:

  • 增加:将当前计数加一(0 -> 1)

  • 减少:将当前计数减一(1 -> 0)

  • 重置:将当前计数重置为零(n -> 0)

我们可以想象,此时我们的用户界面将使用正确的绑定版本更新我们的计数。让我们实现这一点,使其成为现实。

使用 Vuex

现在我们已经详细了解了由Vuex驱动的应用程序的组成部分,让我们创建一个游乐项目,以利用这些功能!

在终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vuex-counter

# Navigate to directory
$ cd vuex-counter

# Install dependencies
$ npm install

# Install Vuex
$ npm install vuex

# Run application
$ npm run dev

创建一个新的 store

让我们首先创建一个名为index.js的文件,放在src/store内。这是我们将用来创建新 store 并整合各种组件的文件。

我们可以先导入VueVuex,并告诉 Vue 我们想要使用Vuex插件:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

然后我们可以导出一个包含所有应用程序状态的状态对象的新Vuex.Store。我们导出这个对象,以便在必要时在其他组件中导入状态:

export default new Vuex.Store({
  state: {
    count: 0,
  },
}); 

定义动作类型

然后我们可以在src/store内创建一个名为mutation-types.js的文件,其中包含用户可能在我们应用程序中执行的各种操作:

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

虽然我们不必像这样明确地定义我们的动作,但尽可能使用常量是一个好主意。这使我们能够更好地利用工具和 linting 技术,并且能够一目了然地推断整个应用程序中的动作。

动作

我们可以使用这些动作类型来提交一个新的动作,随后由我们的 mutations 处理。在src/store内创建一个名为actions.js的文件:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    commit(types.INCREMENT);
  },
  types.DECREMENT {
    commit(types.DECREMENT);
  },
  types.RESET {
    commit(types.RESET);
  },
};

在每个方法内部,我们正在解构返回的store对象,只取commit函数。如果我们不这样做,我们将不得不像这样调用commit函数:

export default {
 types.INCREMENT {
  store.commit(types.INCREMENT);
 }
}

如果我们重新查看我们的状态图,我们可以看到在提交一个动作后,该动作会被变化器捕捉到。

变化

变化是存储状态可以改变的唯一方法;这是通过提交/分派一个动作来完成的,就像之前看到的那样。让我们在src/store内创建一个名为mutations.js的新文件,并添加以下内容:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    state.count++;
  },
  types.DECREMENT {
    state.count--;
  },
  types.RESET {
    state.count = 0;
  },
};

您会注意到,我们再次使用我们的动作类型来定义方法名;这是可能的,因为 ES2015+ 中有一个名为计算属性名的新功能。现在,每当一个动作被提交/分发时,改变器将知道如何处理这个动作并返回一个新的状态。

获取器

现在我们可以提交动作,并让这些动作返回状态的新版本。下一步是创建获取器,以便我们可以在整个应用程序中返回状态的切片部分。让我们在 src/store 中创建一个名为 getters.js 的新文件,并添加以下内容:

export default {
  count(state) {
    return state.count;
  },
};

由于我们有一个微不足道的例子,为这个属性使用获取器并不是完全必要的,但是当我们扩展我们的应用程序时,我们将需要使用获取器来过滤状态。把它们想象成状态中的值的计算属性,所以如果我们想要返回这个属性的修改版本给视图层,我们可以这样做:

export default {
  count(state) {
    return state.count > 3 ? 'Above three!' : state.count;
  },
};

组合元素

为了将所有这些整合在一起,我们必须重新访问我们的 store/index.js 文件,并添加适当的 stateactionsgettersmutations

import Vue from 'vue';
import Vuex from 'vuex';

import actions from './actions';
import getters from './getters';
import mutations from './mutations';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    count: 0,
  },
  actions,
  getters,
  mutations,
});

在我们的 App.vue 中,我们可以创建一个 template,它将给我们当前的计数以及一些按钮来 增加减少重置 状态:

<template>
  <div>
    <h1>{{count}}</h1>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">R</button>
  </div>
</template>

每当用户点击按钮时,一个动作将从以下方法中分发:

import * as types from './store/mutation-types';

export default {
  methods: {
    increment() {
      this.$store.dispatch(types.INCREMENT);
    },
    decrement() {
      this.$store.dispatch(types.DECREMENT);
    },
    reset() {
      this.$store.dispatch(types.RESET);
    },
  },
}

我们再次使用常量来提供更好的开发体验。接下来,为了利用我们之前创建的获取器,让我们定义一个 computed 属性:

export default {
  // Omitted
  computed: {
    count() {
      return this.$store.getters.count;
    },
  },
}

然后我们就有了一个显示当前计数并可以增加、减少或重置的应用程序。

负载

如果我们想让用户决定要增加计数的数量怎么办?假设我们有一个文本框,我们可以在其中添加一个数字,并按照这个数字增加计数。如果文本框设置为 0 或为空,我们将增加计数 1

因此,我们的模板将如下所示:

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

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

    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">R</button>
  </div>
</template>

我们将金额值放在我们的本地组件状态上,因为这不一定需要成为主要的 Vuex 存储的一部分。这是一个重要的认识,因为这意味着如果有必要,我们仍然可以拥有本地数据/计算值。我们还可以更新我们的方法,将金额传递给我们的动作/改变器:

export default {
  data() {
    return {
      amount: 0,
    };
  },
  methods: {
    increment() {
      this.$store.dispatch(types.INCREMENT, this.getAmount);
    },
    decrement() {
      this.$store.dispatch(types.DECREMENT, this.getAmount);
    },
    reset() {
      this.$store.dispatch(types.RESET);
    },
  },
  computed: {
    count() {
      return this.$store.getters.count;
    },
    getAmount() {
      return Number(this.amount) || 1;
    },
  },
};

然后我们需要更新actions.js,因为现在它接收state对象和我们的amount作为参数。当我们使用commit时,让我们也将amount传递给 mutation:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    commit(types.INCREMENT, amount);
  },
  types.DECREMENT {
    commit(types.DECREMENT, amount);
  },
  types.RESET {
    commit(types.RESET);
  },
};

因此,我们的 mutation 看起来与以前类似,但这次我们根据数量增加/减少:

export default {
  types.INCREMENT {
    state.count += amount;
  },
  types.DECREMENT {
    state.count -= amount;
  },
  types.RESET {
    state.count = 0;
  },
};

哒哒!现在我们可以根据文本值增加计数:

Vuex 和 Vue devtools

现在我们有了一种一致的通过动作与存储进行交互的方式,我们可以利用 Vue devtools 来查看我们的状态随时间的变化。如果您还没有安装 Vue devtools,请访问第二章,Vue 项目的正确创建,以获取更多关于此的信息。

我们将使用计数器应用程序作为示例,以确保您已经运行了此项目,并在 Chrome(或您的浏览器的等效物)中右键单击检查元素。如果我们转到 Vue 选项卡并选择 Vuex,我们可以看到计数器已加载初始应用程序状态:

从上面的截图中,您可以看到计数状态成员以及任何 getter 的值。让我们点击几次增量按钮,看看会发生什么:

太棒了!我们可以看到 INCREMENT 动作以及状态和 getter 的后续更改,以及有关 mutation 本身的更多信息。让我们看看如何在我们的状态中进行时间旅行:

在上面的截图中,我选择了第一个动作的时间旅行按钮。然后您可以看到我们的状态恢复到计数:1,这也反映在其余的元数据中。然后应用程序会更新以反映状态的更改,因此我们可以逐个步骤地查看每个动作在屏幕上的结果。这不仅有助于调试,而且我们向应用程序添加的任何新状态都将遵循相同的过程,并以这种方式可见。

让我们点击一个动作的提交按钮:

正如您所看到的,这将合并我们点击提交时的所有动作,然后成为我们的基本状态的一部分。因此,计数属性等于您提交到基本状态的动作。

模块和可扩展性

目前,我们的一切都在根状态下。随着我们的应用程序变得更大,利用模块的好处将是一个不错的主意,这样我们就可以适当地将容器分割成不同的部分。让我们通过在store文件夹内创建一个名为modules/count的新文件夹,将我们的计数器状态转换为自己的模块。

然后,我们可以将actions.jsgetters.jsmutations.jsmutation-types.js文件移动到计数模块文件夹中。这样做后,我们可以在文件夹内创建一个index.js文件,该文件仅导出此模块的stateactionsgettersmutations

import actions from './actions';
import getters from './getters';
import mutations from './mutations';

export const countStore = {
  state: {
    count: 0,
  },
  actions,
  getters,
  mutations,
};

export * from './mutation-types';

我还选择从index.js文件中导出 mutation 类型,这样我们就可以在组件内按模块使用这些类型,只需从store/modules/count导入。由于在此文件中导入了多个内容,我给 store 命名为countStore。让我们在store/index.js中定义新模块:

import Vue from 'vue';
import Vuex from 'vuex';
import { countStore } from './modules/count';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    countStore,
  },
});

我们的App.vue稍作修改;我们不再引用 types 对象,而是专门从这个模块引用 types:

import * as fromCount from './store/modules/count';

export default {
  data() {
    return {
      amount: 0,
    };
  },
  methods: {
    increment() {
      this.$store.dispatch(fromCount.INCREMENT, this.getAmount);
    },
    decrement() {
      this.$store.dispatch(fromCount.DECREMENT, this.getAmount);
    },
    reset() {
      this.$store.dispatch(fromCount.RESET);
    },
  },
  computed: {
    count() {
      return this.$store.getters.count;
    },
    getAmount() {
      return Number(this.amount) || 1;
    },
  },
};

然后,我们可以通过使用与我们的计数示例相同的文件/结构来向我们的应用程序添加更多的模块。这使我们能够在应用程序不断增长时进行扩展。

摘要

在本章中,我们利用了Vuex库来实现 Vue 中的一致状态管理。我们定义了什么是状态,以及组件状态和应用程序级状态。我们学会了如何适当地将我们的 actions、getters、mutations 和 store 分割成不同的文件以实现可扩展性,以及如何在组件内调用这些项目。

我们还研究了如何使用Vuex与 Vue devtools 来逐步执行应用程序中发生的 mutations。这使我们能够更好地调试/推理我们在开发应用程序时所做的决定。

在下一章中,我们将学习如何测试我们的 Vue 应用程序以及如何让我们的测试驱动我们的组件设计。

第十章:测试 Vue.js 应用程序

在一个紧迫和加速要求的世界中,为我们的应用程序创建自动化测试变得比以往任何时候都更加重要。一个需要考虑的重要因素,大多数开发人员忽视的是,测试是一种技能,仅仅因为你可能习惯编写解决方案,并不意味着你可以自动编写好的单元测试。随着你在这个领域的经验越来越丰富,你会发现自己更经常地编写测试,并想知道在没有它们的情况下你到底是怎么做的!

在本章结束时,我们将涵盖以下内容:

  • 了解为什么应该考虑使用自动化测试工具和技术

  • 为 Vue 组件编写你的第一个单元测试

  • 编写模拟特定函数的测试

  • 编写依赖于 Vue.js 事件的测试

  • 使用 Wallaby.js 实时查看我们测试的结果

当我们谈论测试我们的 Vue 项目时,根据上下文,我们可能指的是不同的事情。

为什么要测试?

自动化测试工具是有原因的。当我们手动测试我们创建的工作时,你会从经验中知道这是一个漫长(有时复杂)的过程,不允许一致的结果。我们不仅需要手动记住一个特定组件是否工作(或者在某个地方写下结果!),而且它也不具有变化的弹性。

这些年来我听到过一些关于测试的话语:

“但是保罗,如果我为我的应用程序编写测试,将需要三倍的时间!”

“我不知道如何编写测试...”

“那不是我的工作!”

...以及其他各种。

关键是测试和开发一样是一种技能。你可能不会立刻擅长其中一种,但是随着时间、练习和毅力,你应该会发现自己处于一种测试感觉自然和软件开发的正常部分的位置。

单元测试

自动化测试工具取代了我们每次想要验证我们的功能是否按预期工作时所做的手动工作,并给了我们一种运行测试命令逐个测试我们的断言的方法。然后这些结果会以报告的形式呈现给我们(或者在我们的编辑器中实时显示,正如我们后面会看到的),这使我们有能力重构不按预期工作的代码。

通过使用自动化测试工具,与手动测试相比,我们节省了大量的工作量。

单元测试可以被定义为一种只测试一个“单元”(功能的最小可测试部分)的测试类型。然后,我们可以自动化这个过程,随着应用程序变得更大,不断测试我们的功能。在这一点上,您可能希望遵循测试驱动开发/行为驱动开发的实践。

在现代 JavaScript 测试生态系统中,有各种测试套件可用。这些测试套件可以被认为是给我们提供编写断言、运行测试、提供覆盖报告等功能的应用程序。我们将在项目中使用 Jest,因为这是由 Facebook 创建和维护的快速灵活的测试套件。

让我们创建一个新的游乐场项目,以便我们可以熟悉单元测试。我们将使用webpack模板而不是webpack-simple模板,因为这允许我们默认配置测试:

# Create a new Vue project
**$ vue init webpack vue-testing** ? Project name vue-testing
? Project description Various examples of testing Vue.js applications
? Author Paul Halliday <hello@paulhalliday.io>
? Vue build runtime
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Airbnb
? Set up unit tests Yes
? Pick a test runner jest
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been create
d? (recommended) npm

# Navigate to directory
$ cd vue-testing

# Run application
$ npm run dev

让我们首先调查test/unit/specs目录。这是我们在测试 Vue 组件时放置所有单元/集成测试的地方。打开HelloWorld.spec.js,让我们逐行进行:

// Importing Vue and the HelloWorld component
import Vue from 'vue';
import HelloWorld from '@/components/HelloWorld';

// 'describe' is a function used to define the 'suite' of tests (i.e.overall context).
describe('HelloWorld.vue', () => {

  //'it' is a function that allows us to make assertions (i.e. test 
  true/false)
  it('should render correct contents', () => {
    // Create a sub class of Vue based on our HelloWorld component
    const Constructor = Vue.extend(HelloWorld);

    // Mount the component onto a Vue instance
    const vm = new Constructor().$mount();

    // The h1 with the 'hello' class' text should equal 'Welcome to 
   Your Vue.js App'
    expect(vm.$el.querySelector('.hello h1').textContent).toEqual(
      'Welcome to Your Vue.js App',
    );
  });
});

然后,我们可以通过在终端中运行npm run unit来运行这些测试(确保您在项目目录中)。这将告诉我们有多少个测试通过了以及整体测试代码覆盖率。这个指标可以用作确定应用程序在大多数情况下有多健壮的一种方式;但是,它不应该被当作圣经。在下面的截图中,我们可以清楚地看到有多少个测试通过了:

设置 vue-test-utils

为了获得更好的测试体验,建议使用vue-test-utils模块,因为这为我们提供了许多专门用于 Vue 框架的帮助程序和模式。让我们基于webpack-simple模板创建一个新项目,并自己集成 Jest 和vue-test-utils。在您的终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vue-test-jest

# Navigate to directory
$ cd vue-test-jest

# Install dependencies
$ npm install

# Install Jest and vue-test-utils
$ npm install jest vue-test-utils --save-dev

# Run application
$ npm run dev

然后,我们必须在项目中添加一些额外的配置,以便我们可以运行 Jest,我们的测试套件。这可以在项目的package.json中配置。添加以下内容:

{
  "scripts": {
    "test": "jest"
  }
}

这意味着任何时候我们想要运行我们的测试,我们只需在终端中运行npm run test。这将在任何匹配*.spec.js名称的文件上运行 Jest 的本地(项目安装)版本。

接下来,我们需要告诉 Jest 如何处理单文件组件(即*.vue文件)在我们的项目中。这需要vue-jest预处理器。我们还希望在测试中使用 ES2015+语法,因此我们还需要babel-jest预处理器。让我们通过在终端中运行以下命令来安装两者:

npm install --save-dev babel-jest vue-jest

然后我们可以在package.json中定义以下对象:

"jest": {
  "moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/src/$1"
  },
  "moduleFileExtensions": [
    "js",
    "vue"
  ],
  "transform": {
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
    ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
  }
}

这本质上告诉 Jest 如何处理 JavaScript 和 Vue 文件,通过知道要使用哪个预处理器(即babel-jestvue-jest),具体取决于上下文。

如果我们告诉 Babel 只为当前加载的 Node 版本转译功能,我们还可以使我们的测试运行更快。让我们在.babelrc文件中添加一个单独的测试环境:

{
  "presets": [["env", { "modules": false }], "stage-3"],
  "env": {
    "test": {
      "presets": [["env", { "targets": { "node": "current" } }]]
    }
  }
}

现在我们已经添加了适当的配置,让我们开始测试吧!

创建一个 TodoList

现在让我们在src/components文件夹中创建一个TodoList.vue组件。这是我们将要测试的组件,我们将逐步为其添加更多功能:

<template>
  <div>
    <h1>Todo List</h1>
    <ul>
      <li v-for="todo in todos" v-bind:key="todo.id">
        {{todo.id}}. {{todo.name}}</li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
    return {
      todos: [
        { id: 1, name: 'Wash the dishes' },
        { id: 2, name: 'Clean the car' },
        { id: 3, name: 'Learn about Vue.js' },
      ],
    };
  },
};
</script>

<style>
ul,
li {
  list-style: none;
  margin-left: 0;
  padding-left: 0;
}
</style>

正如您所看到的,我们只是一个返回具有不同项目的待办事项数组的简单应用程序。让我们在src/router/index.js中创建一个路由器,以匹配我们的新TodoList组件并将其显示为根:

import Vue from 'vue';
import Router from 'vue-router';
import TodoList from '../components/TodoList';

Vue.use(Router);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'TodoList',
      component: TodoList,
    },
  ],
});

由于我们正在使用vue-router,我们还需要安装它。在终端中运行以下命令:

$ npm install vue-router --save-dev

然后,我们可以将路由器添加到main.js中:

import Vue from 'vue'
import App from './App.vue'
import router from './router';

new Vue({
  el: '#app',
  router,
  render: h => h(App)
})

我现在已经添加了router-view,并决定从App.vue中删除 Vue 标志,这样我们就有了一个更清洁的用户界面。以下是App.vue的模板:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

正如我们在浏览器中看到的那样,它显示了我们的模板,其中包括 TodoList 的名称和我们创建的todo项目:

让我们为这个组件编写一些测试

编写测试

src/components文件夹中,创建一个名为__tests__的新文件夹,然后创建一个名为TodoList.spec.js的文件。Jest 将自动找到此文件夹和后续的测试。

让我们首先从测试工具中导入我们的组件和mount方法:

import { mount } from 'vue-test-utils';
import TodoList from '../TodoList';

mount方法允许我们在隔离中测试我们的TodoList组件,并且使我们能够模拟任何输入 props、事件,甚至输出。接下来,让我们创建一个描述块,用于包含我们的测试套件:

describe('TodoList.vue', () => {

});

现在让我们挂载组件并访问 Vue 实例:

describe('TodoList.vue', () => {
 // Vue instance can be accessed at wrapper.vm
 const wrapper = mount(TodoList);
});

接下来,我们需要定义it块来断言我们测试用例的结果。让我们做出我们的第一个期望-它应该呈现一个待办事项列表:

describe('TodoList.vue', () => {
  const todos = [{ id: 1, name: 'Wash the dishes' }];
  const wrapper = mount(TodoList);

  it('should contain a list of Todo items', () => {
    expect(wrapper.vm.todos).toContainEqual(todos[0]);
  });
});

我们可以通过在终端中运行$ npm run test -- --watchAll来观察测试的变化。或者,我们可以在package.json内创建一个新的脚本来代替这个操作:

"scripts": {
 "test:watch": "jest --watchAll"
}

现在,如果我们在终端内运行npm run test:watch,它将监视文件系统的任何更改。

这是我们的结果:

这很有趣。我们有一个通过的测试!但是,此时我们必须考虑,这个测试是否脆弱?在实际应用中,我们可能不会在运行时默认拥有TodoList中的项目。

我们需要一种方法来在我们的隔离测试中设置属性。这就是设置自己的 Vue 选项的能力派上用场的地方!

Vue 选项

我们可以在 Vue 实例上设置自己的选项。让我们使用vue-test-utils在实例上设置自己的数据,并查看这些数据是否呈现在屏幕上:

describe('TodoList.vue', () => {
  it('should contain a list of Todo items', () => {
    const todos = [{ id: 1, name: 'Wash the dishes' }];
    const wrapper = mount(TodoList, {
      data: { todos },
    });

    // Find the list items on the page
    const liWrapper = wrapper.find('li').text();

    // List items should match the todos item in data
    expect(liWrapper).toBe(todos[0].name);
  });
});

正如我们所看到的,我们现在是根据组件内的数据选项来测试屏幕上呈现的项目。

让我们添加一个TodoItem组件,以便我们可以动态地渲染带有todo属性的组件。然后我们可以根据我们的属性测试这个组件的输出:

<template>
  <li>{{todo.name}}</li>
</template>

<script>
export default {
  props: ['todo'],
};
</script>

然后我们可以将其添加到TodoList组件中:

<template>
  <div>
    <h1>TodoList</h1>
    <ul>
      <TodoItem v-for="todo in todos" v-bind:key="todo.id" 
      :todo="todo">{{todo.name}}</TodoItem>
    </ul>
  </div>
</template>

<script>
import TodoItem from './TodoItem';

export default {
  components: {
    TodoItem,
  },
  // Omitted
}

我们的测试仍然如预期般通过,因为组件在运行时被渲染为li。不过,将其更改为查找组件本身可能是一个更好的主意:

import { mount } from 'vue-test-utils';
import TodoList from '../TodoList';
import TodoItem from '../TodoItem';

describe('TodoList.vue', () => {
  it('should contain a list of Todo items', () => {
    const todos = [{ id: 1, name: 'Wash the dishes' }];
    const wrapper = mount(TodoList, {
      data: { todos },
    });

    // Find the list items on the page
    const liWrapper = wrapper.find(TodoItem).text();

    // List items should match the todos item in data
    expect(liWrapper).toBe(todos[0].name);
  });
});

让我们为我们的TodoItem编写一些测试,并在components/__tests__内创建一个TodoItem.spec.js

import { mount } from 'vue-test-utils';
import TodoItem from '../TodoItem';

describe('TodoItem.vue', () => {
  it('should display name of the todo item', () => {
    const todo = { id: 1, name: 'Wash the dishes' };
    const wrapper = mount(TodoItem, { propsData: { todo } });

    // Find the list items on the page
    const liWrapper = wrapper.find('li').text();

    // List items should match the todos item in data
    expect(liWrapper).toBe(todo.name);
  });
});

因为我们基本上使用相同的逻辑,所以我们的测试是相似的。主要区别在于,我们不是有一个todos列表,而是只有一个todo对象。我们使用propsData来模拟 props,而不是数据,基本上断言我们可以向这个组件添加属性,并且它呈现正确的数据。让我们看一下我们的测试是否通过或失败:

添加新功能

让我们以测试驱动的方式向我们的应用程序添加新功能。我们需要一种方法来向我们的todo列表中添加新项目,所以让我们首先编写我们的测试。在TodoList.spec.js内,我们将添加另一个it断言,应该向我们的todo列表中添加一个项目:

it('should add an item to the todo list', () => {
  const wrapper = mount(TodoList);
  const todos = wrapper.vm.todos;
  const newTodos = wrapper.vm.addTodo('Go to work');
  expect(todos.length).toBeLessThan(newTodos.length);
});

如果我们现在运行我们的测试,我们将得到一个失败的测试,这是预期的!:

让我们尽可能少地修复我们的错误。我们可以在 Vue 实例内添加一个名为addTodo的方法:

export default {
  methods: {
    addTodo(name) {},
  },
  // Omitted
}

现在我们得到了一个新的错误;这次它说无法读取未定义的length属性,基本上是说我们没有newTodos数组:

让我们使我们的addTodo函数返回一个将当前的todos与新 todo 结合在一起的数组:

addTodo(name) {
  return [...this.todos, { name }]
},

运行npm test后,我们得到以下输出:

塔达!测试通过。

嗯。我记得我们所有的todo项目都有适当的id,但看起来情况已经不再是这样了。

理想情况下,我们的服务器端数据库应该为我们处理id号码,但目前,我们可以使用uuid包生成客户端uuid。让我们通过在终端中运行以下命令来安装它:

$ npm install uuid

然后我们可以编写我们的测试用例,断言添加到列表中的每个项目都有一个id属性:

it('should add an id to each todo item', () => {
  const wrapper = mount(TodoList);
  const todos = wrapper.vm.todos;
  const newTodos = wrapper.vm.addTodo('Go to work');

  newTodos.map(item => {
    expect(item.id).toBeTruthy();
  });
});

正如你所看到的,终端输出了我们有一个问题,这是因为显然我们没有id属性:

让我们使用之前安装的uuid包来实现这个目标:

import uuid from 'uuid/v4';

export default {
  methods: {
    addTodo(name) {
      return [...this.todos, { id: uuid(), name }];
    },
  },
  // Omitted
};

然后我们得到了一个通过的测试:

从失败的测试开始对多个原因都是有益的:

  • 它确保我们的测试实际上正在运行,我们不会花时间调试任何东西!

  • 我们知道接下来需要实现什么,因为我们受当前错误消息的驱使

然后我们可以写入最少必要的内容来获得绿色的测试,并继续重构我们的代码,直到我们对解决方案感到满意。在以前的测试中,我们可以写得更少以获得绿色的结果,但为了简洁起见,我选择了更小的例子。

点击事件

太好了!我们的方法有效,但这不是用户将与应用程序交互的方式。让我们看看我们是否可以使我们的测试考虑用户输入表单和随后的按钮:

<form @submit.prevent="addTodo(todoName)">
  <input type="text" v-model="todoName">
  <button type="submit">Submit</button>
</form>

我们还可以对我们的addTodo函数进行小小的改动,确保this.todos被赋予新的todo项目的值:

addTodo(name) {
 this.todos = [...this.todos, { id: uuid(), name }];
 return this.todos;
},

很棒的是,通过进行这种改变,我们可以检查所有以前的用例,并且看到没有任何失败!为自动化测试欢呼!

接下来,让我们创建一个it块,我们可以用来断言每当我们点击提交按钮时,都会添加一个项目:

  it('should add an item to the todo list when the button is clicked', () => {
    const wrapper = mount(TodoList);
  })

接下来,我们可以使用 find 从包装器中获取表单元素,然后触发事件。由于我们正在提交表单,我们将触发提交事件并传递参数给我们的submit函数。然后我们可以断言我们的todo列表应该是1

it('should add an item to the todo list when the button is clicked', () => {
 const wrapper = mount(TodoList);
 wrapper.find('form').trigger('submit', 'Clean the car');

 const todos = wrapper.vm.todos;

 expect(todos.length).toBe(1);
})

我们还可以检查在表单提交时是否调用了适当的方法。让我们使用jest来做到这一点:

it('should call addTodo when form is submitted', () => {
  const wrapper = mount(TodoList);
  const spy = jest.spyOn(wrapper.vm, 'addTodo');

  wrapper.find('form').trigger('submit', 'Clean the car');

  expect(wrapper.vm.addTodo).toHaveBeenCalled();
});

测试事件

我们取得了很大的进展,但如果我们能测试组件之间触发的事件,那不是很棒吗?让我们通过创建一个TodoInput组件来看看这个问题,并将我们的表单抽象到this组件中:

<template>
  <form @submit.prevent="addTodo(todoName)">
    <input type="text" v-model="todoName">
    <button type="submit">Submit</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      todoName: ''
    } 
  },
  methods: {
    addTodo(name) {
      this.$emit('addTodo', name);
    }
  }
}
</script>

现在,我们在this组件中的addTodo方法触发了一个事件。让我们在TodoInput.spec.js文件中测试该事件:

import { mount } from 'vue-test-utils';
import TodoInput from '../TodoInput';

describe('TodoInput.vue', () => {
  it('should fire an event named addTodo with todo name', () => {
    const mock = jest.fn()
    const wrapper = mount(TodoInput);

    wrapper.vm.$on('addTodo', mock)
    wrapper.vm.addTodo('Clean the car');

    expect(mock).toBeCalledWith('Clean the car')
  })
});

在这个方法中,我们介绍了一个新的概念——mock。这允许我们定义自己的行为,并随后确定事件是如何被调用的。

每当addTodo事件被触发时,mock函数就会被调用。这使我们能够看到我们的事件是否被调用,并确保事件可以携带有效负载。

我们还可以确保TodoList处理this事件,但首先确保您已经更新了TodoList以包括TodoInput表单:

<template>
  <div>
    <h1>TodoList</h1>

    <TodoInput @addTodo="addTodo($event)"></TodoInput>

    <ul>
      <TodoItem v-for="todo in todos" v-bind:key="todo.id" :todo="todo">{{todo.name}}</TodoItem>
    </ul>
  </div>
</template>

<script>
import uuid from 'uuid/v4';

import TodoItem from './TodoItem';
import TodoInput from './TodoInput';

export default {
  components: {
    TodoItem,
    TodoInput
  },
  data() {
    return {
      todos: [],
      todoName: ''
    };
  },
  methods: {
    addTodo(name) {
      this.todos = [...this.todos, { id: uuid(), name }];
      return this.todos;
    },
  },
};
</script>
<style>
ul,
li {
  list-style: none;
  margin-left: 0;
  padding-left: 0;
}
</style>

然后,在我们的TodoList.spec.js中,我们可以首先导入TodoInput,然后添加以下内容:

import TodoInput from '../TodoInput';
it('should call addTodo when the addTodo event happens', () => {
  const wrapper = mount(TodoList);

  wrapper.vm.addTodo = jest.fn();
  wrapper.find(TodoInput).vm.$emit('addTodo', 'Clean the car');

  expect(wrapper.vm.addTodo).toBeCalledWith('Clean the car');
})

除此之外,我们还可以确保事件执行其预期功能;所以当我们触发事件时,它会向数组添加一个项目,我们正在测试数组的长度:

it('adds an item to the todolist when the addTodo event happens', () => {
 const wrapper = mount(TodoList);
 wrapper.find(TodoInput).vm.$emit('addTodo', 'Clean the car');
 const todos = wrapper.vm.todos;
 expect(todos.length).toBe(1);
});

使用 Wallaby.js 获得更好的测试体验

我们还可以使用 Wallaby.js 在编辑器中实时查看我们的单元测试结果。这不是一个免费的工具,但在创建面向测试驱动的 Vue 应用程序时,您可能会发现它很有用。让我们首先克隆/下载一个已经设置好 Wallaby 的项目。在您的终端中运行以下命令:

# Clone the repository
$ git clone https://github.com/ChangJoo-Park/vue-wallaby-webpack-template

# Change directory
$ cd vue-wallaby-webpack-template

# Install dependencies
$ npm install

# At the time of writing this package is missing eslint-plugin-node
$ npm install eslint-plugin-node

# Run in browser
$ npm run dev

然后,我们可以在编辑器中打开它,并在编辑器中安装 Wallaby.js 扩展。您可以在wallabyjs.com/download/找到受支持的编辑器列表和说明。

我将在 Visual Studio Code 中安装这个,首先在扩展市场中搜索 Wallaby:

然后,我们可以按下 Mac 上的CMD + SHIFT + =或 Windows 上的CTRL + SHIFT + =,告诉 Wallaby 有关项目的配置文件(wallaby.js)。从下拉菜单中,单击“选择配置文件”,然后键入wallaby.js。这将允许 Wallaby 和 Vue 一起工作。

要启动 Wallaby,我们可以再次打开配置菜单并选择“启动”。然后,我们可以导航到tests/unit/specs/Hello.spec.js文件,并在编辑器的行边距中看到不同的块:

由于一切都是绿色的,我们知道它已经通过了!如果我们改变测试的实现细节会怎么样?让我们故意让一个或多个测试失败:

除了“应该呈现正确内容”块之外,一切都保持不变,可以在左侧看到。这是因为我们现在有一个失败的断言,但更重要的是,我们不必重新运行测试,它们会立即显示在我们的编辑器中。不再需要在不同窗口之间切换以观看我们的测试输出!

摘要

本章让我们了解了如何适当地测试我们的 Vue 组件。我们学会了如何遵循先失败的方法来编写驱动我们开发决策的测试,以及如何利用 Wallaby.js 在编辑器中查看我们测试的结果!

在下一章中,我们将学习如何将我们的 Vue.js 应用与现代渐进式 Web 应用技术相结合,例如服务工作者、应用程序清单等等!

第十一章:优化

如果你多年来一直在编写针对 Web 平台的应用程序,你会看到 Web 经历了多少变化。最初只是一个简单的文档查看器,现在我们必须处理复杂的构建步骤、状态管理模式、持续审查性能和兼容性等等。

值得庆幸的是,JavaScript 的流行和随后的工具意味着有模板和经过验证的技术,我们可以用来优化我们的应用程序和部署。

在本章中,我们将看一下以下主题:

  • 来自 Vue CLI 的vue-pwa模板

  • 渐进式 Web 应用程序的特点

  • 使用 ngrok 在任何设备上查看本地主机应用程序

  • 使用 Firebase 托管部署 Web 应用程序

  • 持续集成及其对大型项目的意义

  • 在每次 Gitcommit上自动运行测试

  • 在每次 Gitcommit上自动部署到 Firebase 托管

渐进式 Web 应用程序(PWA)

PWAs 可以被定义为利用现代 Web 的能力来提供周到、引人入胜和互动体验的应用程序。我的对 PWAs 的定义是包含渐进增强原则的。我们当然可以利用 PWAs 所提供的一切,但我们不必这样做(或者至少不是一次性做完)。

这意味着我们不仅在不断改进我们的应用程序,而且遵循这些原则迫使我们以用户的角度思考,用户可能有不稳定的互联网连接,想要离线优先体验,需要主屏幕可访问的应用程序等等。

再次,Vue CLI 让这个过程对我们来说很容易,因为它提供了一个 PWA 模板。让我们使用适当的模板创建一个新的 Vue 应用程序:

# Create a new Vue project
$ vue init pwa vue-pwa

? Project name vue-pwa
? Project short name: fewer than 12 characters to not be truncated on homescreens (default: same as name) 
? Project description A PWA built with Vue.js
? Author Paul Halliday <hello@paulhalliday.io>
? Vue build runtime
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Airbnb
? Setup unit tests with Karma + Mocha? No
? Setup e2e tests with Nightwatch? No

# Navigate to directory
$ cd vue-pwa

# Install dependencies
$ npm install

# Run application
$ npm run dev

在本章中,我们将看一下这个模板给我们带来的好处,以及我们如何使我们的应用程序和操作更加渐进。

Web 应用程序清单

你可能已经看到使用 Web 应用程序清单的应用程序的好处——如果你曾经在一个要求你在主屏幕上安装的网站上,或者如果你注意到在 Android Chrome 上地址栏的颜色从默认灰色变成不同颜色,那就是一个渐进式应用程序。

让我们转到static/manifest.json并调查内容:

{
  "name": "vue-pwa",
  "short_name": "vue-pwa",
  "icons": [
    {
      "src": "/static/img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/static/img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87"
}

我们有选项来给我们的应用程序nameshort_name;这些将在设备的主屏幕上安装时显示。

icons数组用于为不同大小的图标提供高清体验。start_url定义了在用户主屏幕上安装时启动时要加载的文件,因此指向index.html

我们可以通过显示属性在设备上运行 PWA 时更改应用程序的外观。有各种可用选项,如browserstandaloneminimal-uifullscreen。每个选项都会改变应用程序在设备上的显示方式;(https://developers.google.com/web/fundamentals/web-app-manifest/)

这里有一个浏览器和独立应用的例子:

显示选项-Web 应用清单

我们还可以利用background_color选项来改变 PWA 启动时闪屏背景的颜色,如下截图所示:

如果我们想要改变工具栏的颜色,我们可以使用theme_color选项(随着我们继续前进,我们将看一个例子)。

您可以根据项目的需求自定义您的 Web 应用清单并传递其他选项。您可以在 MDN 上找到有关 Web 应用清单的更多信息developer.mozilla.org/en-US/docs/Web/Manifest

在设备上进行测试

如果我们想要在设备上测试我们的应用程序而不用担心部署,我们可以使用 ngrok 等工具在本地主机和外部世界之间创建隧道。这允许我们在任何具有公共 URL 的设备上查看我们的应用程序,一旦关闭连接,URL 和随后的应用程序就会消失。

通过导航到ngrok.com/download并按照您的平台的安装步骤来下载 ngrok。

Ngrok 也可以通过npm安装,输入:

npm install ngrok -g

由于我们的 Vue 应用程序正在 8080 端口上运行,我们可以启动 ngrok 并告诉它从该端口提供服务。在已安装 ngrok 的终端中运行以下命令:

$ ngrok http 8080

然后我们在终端中得到以下结果:

然后我们可以在任何设备上导航到此 URL,并在屏幕上看到以下结果:

这不是更本地化的体验吗?现在我们默认拥有有色的地址/状态栏。在生产模式下,我们还可以通过ServiceWorker获得更多功能。在深入了解之前,让我们看看如何使用 Firebase 将我们的应用程序部署到更永久的 URL。

Firebase 部署

Firebase 是谷歌的一个平台,允许我们利用实时数据库、远程配置、推送通知等等。对于我们的用例来说,更重要的是静态文件部署的潜力,这是我们将要利用的东西。

该平台有三种不同的可用套餐,每种套餐提供不同级别的服务,第一层是免费的,接下来的两层需要付费。

首先,导航到firebase.google.com,并通过点击“登录”来使用谷歌账号登录,然后点击右上角的“转到控制台”。

然后,通过在 Firebase 仪表板上选择+添加项目来创建一个新的 Firebase 项目,然后选择项目名称和国家。

然后我们将导航到项目概述,我们可以选择将 Firebase 添加到我们的项目以及各种其他选项。我们正在寻找托管,因为我们有兴趣部署我们的静态内容。从左侧菜单中,点击“托管”:

我们将经常在这个屏幕上,因为它允许我们回滚部署以及查看其他使用指标。由于我们还没有进行第一次部署,屏幕看起来会类似于这样:

如果我们点击“开始”,我们将收到一条消息,指出我们需要下载 Firebase 工具。这是一个允许我们在终端内管理 Firebase 项目的 CLI。

通过在终端中运行以下命令来安装 Firebase 工具:

$ npm install firebase-tools -g

然后我们可以按照托管向导的下一步中概述的步骤进行操作,但我们暂时不会使用部署步骤。向导应该看起来像这样:

让我们通过在终端中运行以下命令来登录 Firebase 控制台:

$ firebase login

选择一个谷歌账号并给予适当的权限。然后会出现以下屏幕:

然后,我们可以在我们的vue-pwa项目中初始化一个新的 Firebase 项目。在终端中运行以下命令:

$ firebase init

在这一点上,我们可以使用键盘导航到托管并用空格键选择它。这应该使圆圈变绿,并告诉 Firebase 我们想要在我们的项目中设置托管。

然后,我们必须将我们的本地项目与 Firebase 仪表板中的项目进行匹配。从列表中选择您之前创建的项目:

然后它应该问您与设置相关的问题-像这样回答:

我们现在可以随意部署到 Firebase。我们需要为生产构建我们的项目,以适当生成包含我们应用程序内容的dist文件夹。让我们在终端中运行以下命令:

$ npm run prod

然后,要部署到 Firebase,我们可以运行以下命令:

$ firebase deploy

过了一会儿,您应该会收到一个可导航的 URL,其中包含我们通过 HTTPS 提供的应用程序:

我们的 Firebase 仪表板也已更新以反映我们的部署:

如果我们然后导航到 URL,我们应该按预期获得我们的项目:

此外,因为我们使用生产构建构建了我们的应用程序,我们可以断开与 Wi-Fi 的连接或在开发者工具中勾选离线框。这样做后,我们会发现我们的应用程序仍然按预期运行,因为我们在所有生产构建上都运行了ServiceWorker

持续集成(CI)

有各种 CI 平台可用,例如 Travis、GitLab、Jenkins 等等。每个平台通常都有一个共同的目标,即自动化部署和随之而来的挑战。

当然,我们可以部署我们的站点,运行我们的测试,并继续进行我们不断增加的构建步骤中的其他项目。这不仅是一个繁琐的过程,而且也给了我们许多犯错的机会。此外,这也意味着每个步骤都必须为团队的每个成员进行记录,文档必须保持最新,并且在整个组织中并不是完全可扩展的。

在我们的示例中,我们将使用 Travis CI,我想要解决的第一个目标是自动运行我们的单元测试。为此,我们需要在项目中有一个或多个单元测试。

单元测试

我们在前一章中介绍了如何测试 Vue.js 应用程序,那么每次推送新版本时自动运行测试不是很好吗?让我们快速在项目中设置一些测试,并将其与 Travis 集成:

# Install necessary dependencies
$ npm install jest vue-test-utils babel-jest vue-jest --save-dev

然后我们可以添加一个运行jest的新脚本:

{
  "scripts": {
    "test": "jest"
  }
}

接下来,将jest配置添加到您的package.json中:

"jest": {
  "moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/src/$1"
  },
  "moduleFileExtensions": [
    "js",
    "vue"
  ],
  "transform": {
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
    ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
  }
}

最后,我们可以在.babelrc中更新我们的babel配置:

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": ["transform-runtime"],
  "env": {
    "test": {
      "presets": [["env", { "targets": { "node": "current" } }]],
      "plugins": [ "istanbul" ]
    }
  }
}

然后,在components/__test__/Hello.spec.js中创建一个简单的测试,只需检查我们数据中的msg是否与一个字符串匹配:

import { mount } from 'vue-test-utils';
import Hello from '../Hello';

describe('Hello.vue', () => {
  it('should greet the user', () => {
    const wrapper = mount(Hello);

    expect(wrapper.vm.msg).toEqual('Welcome to Your Vue.js PWA');
  })
})

如预期的那样,我们可以运行npm test来执行我们的测试:

创建一个 Git 存储库

要使用 Travis CI 进行持续集成,我们需要将项目上传到 GitHub。如果您的机器上还没有安装 Git,请从git-scm.com/下载并随后在github.com创建一个 GitHub 账户。

github.com/new为您的项目创建一个新的存储库,或者点击屏幕右上角的+号,然后点击新存储库按钮。

然后,我们可以给我们的存储库命名,并将可见性设置为公共或私有:

一旦我们点击创建存储库按钮,我们将看到多种上传存储库到 GitHub 的方式。唯一的问题是我们还没有把我们的 PWA 项目变成 Git 存储库。

我们可以在 Visual Studio Code 或命令行中执行此操作。在 Visual Studio Code 中,点击新存储库按钮。如果您刚安装了 Git,您可能需要重新启动编辑器才能看到此按钮。这是它在 Visual Studio Code 中的样子。

然后,我们可以用一个简单的消息进行新提交,比如“第一次提交”,然后点击打勾:

然后,我们可以按照内部突出显示的步骤将这些更改推送到 GitHub 上的存储库...或者按照以下图片中给出的命令行推送现有的存储库:

我们对存储库的任何未来更改都将推送到此远程存储库。这很重要,因为当我们创建 Travis 帐户时,它将自动访问我们的所有 GitHub 存储库。

连接到 Travis CI

让我们转到travis-ci.org/,并单击使用 GitHub 登录。在给予 Travis 任何必要的权限后,您应该能够看到与您的帐户关联的存储库列表。我们可以告诉 Travis,我们希望它监视此存储库中的更改,方法是将开关切换为绿色:

配置 Travis

接下来要做的是向我们的项目添加一个适当的.travis.yml配置文件。这将告诉 Travis 每次我们将构建推送到 GitHub 时要做什么。因此,在我们使用 Travis 构建时会发生两个不同的阶段:

  • Travis 在我们的项目内安装任何依赖项

  • Travis 运行构建脚本

我们可以连接到构建过程的各个阶段,例如before_installinstallbefore_scriptscriptbefore_cacheafter_successafter_failurebefore_deploydeployafter_deployafter_script。所有这些都相对容易理解,但如果看起来很多,不用担心,我们只会连接其中的一些阶段。

让我们在项目的根目录添加一个名为.travis.yml的文件,并逐步添加选项。我们可以首先定义项目的语言,由于我们使用的是 Node,接下来也是 Node 环境的版本:

language: node_js
node_js: 
 - "9.3.0"

我选择的node_js版本与我的环境相匹配(可以使用node -v进行检查),但如果您需要针对特定版本的 Node(或多个版本),您可以在这里添加它们。

接下来,让我们添加我们只想在master分支上触发构建:

branches: 
  only:
    - master

然后,我们需要告诉 Travis 从package.json运行什么脚本。因为我们想运行我们的测试,所以我们将运行测试脚本:

script:
  - npm run test

最后,让我们声明我们希望收到每次构建的电子邮件通知:

notifications:
  email:
    recipients:
      - your@email.com
    on_success: always 
    on_failure: always

这给我们以下文件:

language: node_js
node_js: 
  - "9.3.0"

branches: 
  only:
    - master

script:
  - npm run build
  - npm run test

notifications:
  email:
    recipients:
      - your@email.com
    on_success: always 
    on_failure: always

如果我们将这些更改推送到我们的存储库并与原始存储库同步,我们应该能够观看 Travis 控制台运行我们的测试。Travis 可能需要一些时间来启动构建,所以请耐心等待:

如果我们向下滚动日志的底部,您可以看到我们的项目已经为生产和测试构建:

太棒了!我们现在可以运行我们的测试,并在 Travis CI 的构建过程中连接到各个阶段。鉴于我们正在 Travis 上为生产构建我们的项目,我们应该能够自动将此构建部署到 Firebase。

让我们更改我们的Hello.vue组件以显示新消息(并使我们的测试失败):

export default {
  name: 'hello',
  data() {
    return {
      msg: 'Welcome to Your Vue.js PWA! Deployed to Firebase by Travis CI',
    };
  },
};

自动部署到 Firebase

我们可以让 Travis 自动处理我们的部署,但是我们需要一种方法让 Travis 访问我们的部署令牌。我们可以通过在终端中运行以下命令来获取 CI 环境的令牌:

$ firebase login:ci

再次登录您的 Google 帐户后,您应该在终端内获得一个令牌:

 Success! Use this token to login on a CI server:

# Token here

现在保留令牌,因为我们一会儿会用到它。

返回 Travis CI 仪表板,并转到项目的设置。在设置内,我们需要添加一个环境变量,然后我们可以在部署脚本内引用它。

添加FIREBASE_TOKEN环境变量,其值等于我们从终端获得的令牌:

然后,我们可以更新我们的.travis.yml文件,在我们的 CI 环境中安装 firebase 工具,并且如果一切顺利,然后将它们部署到我们的 Firebase 托管环境:

language: node_js
node_js: 
  - "9.3.0"

branches: 
  only:
    - master

before_script: 
  - npm install -g firebase-tools

script:
  - npm run build
  - npm run test

after_success: 
  - firebase deploy --token $FIREBASE_TOKEN

notifications:
  email:
    recipients:
      - your@email.com
    on_success: always 
    on_failure: always

在更改此文件并同步存储库后进行新的提交。然后这应该触发 Travis 上的新构建,我们可以查看日志。

以下是结果:

我们的部署失败因为我们的测试失败。请注意,我们托管在 Firebase 上的应用程序根本没有更改。这是有意的,这就是为什么我们将部署步骤放在after_success内的原因,因为如果我们有失败的测试,我们很可能不希望将此代码推送到生产环境。

让我们修复我们的测试,并将新的commit推送到存储库:

import { mount } from 'vue-test-utils';
import Hello from '../Hello'

describe('Hello.vue', () => {
  it('should greet the user', () => {
    const wrapper = mount(Hello);

    expect(wrapper.vm.msg).toEqual('Welcome to Your Vue.js PWA! Deployed to Firebase by Travis CI');
  })
})

由于所有脚本的退出代码为 0(没有错误),after_success钩子被触发,将我们的项目推送到 Firebase Hosting:

如果我们在适当的 URL 检查我们的应用程序,我们应该看到一个更新的消息:

服务工作者

在使用vue-pwa模板为生产构建我们的应用程序时,它包括ServiceWorker。这本质上是一个在后台运行的脚本,使我们能够利用首次离线方法、推送通知、后台同步等功能。

我们的应用程序现在还会提示用户将应用程序安装到他们的主屏幕上,如下所示:

如果我们与互联网断开连接,我们也会获得首次离线体验,因为应用程序仍然可以继续运行。这是在使用vue-pwa模板时获得的主要好处之一,如果您想了解更多关于ServiceWorker以及如何根据自己的需求自定义它,Google 在developers.google.com/web/fundamentals/primers/service-workers/上有一个很好的入门指南。

摘要

在本章中,我们调查了 Vue CLI 中的 PWA 模板,随后看了一下随着应用程序的不断增长,我们如何可以自动部署和测试我们的应用程序。这些原则使我们能够不断确保我们可以花更多的时间开发功能,而不是花时间维护部署文档和每次遵循基本任务。

在接下来的章节中,我们将介绍 Nuxt,这是一个允许我们使用 Vue 创建服务器端渲染/静态应用程序的框架。Nuxt 还具有一个有趣的基于文件夹的路由结构,这在创建 Vue 应用程序时给了我们很大的力量。

第十二章:使用 Nuxt 进行服务器端渲染

Nuxt 受到一个名为 Next.js 的流行 React 项目的启发,由 Zeit 构建。这两个项目的目标都是创建应用程序,利用最新的思想、工具和技术,提供更好的开发体验。Nuxt 最近进入了 1.x 版本及更高版本,这意味着它应该被认为是稳定的,可以用于生产网站。

在本章中,我们将更详细地了解 Nuxt,如果你觉得它有用,它可能会成为你创建 Vue 应用程序的默认方式。

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

  • 调查 Nuxt 并理解使用它的好处

  • 使用 Nuxt 创建应用程序

  • 使用 Nuxt 中间件

  • 使用布局定义内容

  • 在 Nuxt 中理解路由

  • 使用服务器端渲染构建 Vue 项目

  • 将 Vue 项目构建为静态站点

Nuxt

Nuxt 引入了通用 Vue 应用程序的概念,因为它使我们能够轻松地利用服务器端渲染(SSR)。与此同时,Nuxt 还赋予我们生成静态站点的能力,这意味着内容以 HTML、CSS 和 JS 文件的形式呈现,而不需要来回从服务器传输。

这还不是全部——Nuxt 处理路由生成,并且不会减少 Vue 的任何核心功能。让我们创建一个 Nuxt 项目。

创建一个 Nuxt 项目

我们可以使用 Vue CLI 使用启动模板创建一个新的 Nuxt 项目。这为我们提供了一个简单的 Nuxt 项目,并避免了手动配置的麻烦。我们将创建一个名为“丰盛家常烹饪”的“食谱列表”应用程序,该应用程序使用 REST API 获取类别和食谱名称。在终端中运行以下命令创建一个新的 Nuxt 项目:

# Create a new Nuxt project
$ vue init nuxt-community/starter-template vue-nuxt

# Change directory
$ cd vue-nuxt

# Install dependencies
$ npm install

# Run the project in the browser
$ npm run dev

前面的步骤与创建新的 Vue 项目时所期望的非常相似,相反,我们可以简单地使用 Nuxt 存储库和启动模板来生成一个项目。

如果我们查看我们的package.json,你会发现我们没有生产依赖项的列表;相反,我们只有一个nuxt

"dependencies": {
  "nuxt": "¹.0.0"
}

这很重要,因为这意味着我们不必管理 Vue 的版本或担心其他兼容的包,因为我们只需要更新nuxt的版本。

目录结构

如果我们在编辑器中打开我们的项目,我们会注意到我们比以前的 Vue 应用程序有更多的文件夹。我编制了一张表格,概述了它们的含义:

文件夹 描述
资产 用于存储项目资产,例如未编译的图像、js 和 CSS。 使用 Webpack 加载程序作为模块加载。
组件 用于存储应用程序组件。 这些不会转换为路由。
布局 用于创建应用程序布局,例如默认布局、错误布局或其他自定义布局。
中间件 用于定义自定义应用程序中间件。 这允许我们在不同事件上运行自定义功能,例如在页面之间导航。
页面 用于创建组件(.vue文件),用作应用程序路由。
插件 用于注册应用程序范围的插件(即使用 Vue.use)。
静态 用于存储静态文件;此文件夹中的每个项目都映射到 /* 而不是 /static/*
Store 与 Vuex 存储一起使用。 Nuxt 可以与 Vuex 的标准和模块实现一起使用。

尽管这可能看起来更复杂,但请记住,这有助于我们分离关注点,结构允许 Nuxt 处理诸如自动生成路由之类的事情。

Nuxt 配置

让我们向项目添加一些自定义链接,以便我们可以利用 CSS 库、字体等。 让我们向项目添加 Bulma。

Bulma 是一个 CSS 框架,允许我们使用 Flexbox 构建应用程序,并让我们利用许多预制组件。 我们可以通过转到nuxt.config.js并在head对象内的link对象中添加一个新对象来将其添加到我们的项目中,如下所示:

head: {
  // Omitted
  link: [
    { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
    {
      rel: 'stylesheet',
      href:
    'https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.1/css/bulma.min.css',
    },
  ],
}

如果我们使用开发人员工具来检查 HTML 文档中的头部,您会注意到 Bulma 已添加到我们的项目中。 如果我们转到开发人员工具,我们可以看到它确实在项目中使用 Bulma:

导航

每次我们在页面目录中创建一个新的.vue文件,我们都会为我们的应用程序创建一个新的路由。 这意味着每当我们想要创建一个新的路由时,我们只需创建一个带有路由名称的新文件夹,其余工作由 Nuxt 处理。 鉴于我们的pages文件夹中有默认的index.vue,路由最初看起来像这样:

routes: [
  {
    name: 'index',
    path: '/',
    component: 'pages/index.vue'
  }
]

如果我们然后添加一个带有index.vuecategories文件夹,Nuxt 将生成以下路由:

routes: [
  {
    name: 'index',
    path: '/',
    component: 'pages/index.vue'
  },
  {
    name: 'categories',
    path: '/categories',
    component: 'pages/categories/index.vue'
  }
]

如果我们想利用动态路由参数,比如id,我们可以在categories文件夹内创建一个名为_id.vue的组件。这将自动创建一个带有id参数的路由,允许我们根据用户的选择采取行动:

routes: [
  {
    name: 'index',
    path: '/',
    component: 'pages/index.vue'
  },
  {
    name: 'categories',
    path: '/categories',
    component: 'pages/categories/index.vue'
  },
  {
    name: 'categories-id',
    path: '/categories/id',
    component: 'pages/categories/_id.vue'
  }
]

在路由之间导航

我们如何使用 Nuxt 在路由之间导航?嗯,当然是使用nuxt-link组件!

这类似于在标准 Vue.js 应用程序中导航链接时使用的router-link组件(截至目前为止,它与之相同),但这是用nuxt-link组件包装的,以利用未来的预取等功能。

布局

我们可以在 Nuxt 项目中创建自定义布局。这允许我们更改页面的排列方式,还允许我们添加共同点,比如静态导航栏和页脚。让我们使用 Bulma 创建一个新的导航栏,以便在我们的站点中的多个组件之间导航。

components文件夹中,创建一个名为NavigationBar.vue的新文件,并给它以下标记:

<template>
  <nav class="navbar is-primary" role="navigation" aria-label="main 
  navigation">
    <div class="navbar-brand">
      <nuxt-link class="navbar-item" to="/">Hearty Home Cooking</nuxt-
      link>
    </div>
  </nav>
</template>

<script>
export default {}
</script>

然后,我们需要将这个添加到我们默认布局中layouts/default.vue。我还用适当的 Bulma 类将nuxt标签(也就是我们的主router-view)包起来,以使我们的内容居中:

<template>
  <div>
    <navigation-bar></navigation-bar>
    <section class="section">
      <nuxt class="container"/>
    </section>
  </div>
</template>

<script>
import NavigationBar from '../components/NavigationBar'

export default {
  components: {
    NavigationBar
  }
}
</script>

如果我们然后转到浏览器,我们会看到一个反映我们代码的应用程序:

模拟 REST API

在创建用于显示我们数据的组件之前,让我们用 JSON Server 模拟一个 REST API。为此,我们需要在项目的根目录下创建一个名为db.json的文件,如下所示:

{
  "recipes": [
    { "id": 1, "title": "Blueberry and Chocolate Cake", "categoryId": 1, "image": "https://static.pexels.com/photos/291528/pexels-photo-291528.jpeg" },
    { "id": 2, "title": "Chocolate Cheesecake", "categoryId": 1, "image": "https://images.pexels.com/photos/47013/pexels-photo-47013.jpeg"},
    { "id": 3, "title": "New York and Berry Cheesecake", "categoryId": 1, "image": "https://images.pexels.com/photos/14107/pexels-photo-14107.jpeg"},
    { "id": 4, "title": "Salad with Light Dressing", "categoryId": 2, "image": "https://static.pexels.com/photos/257816/pexels-photo-257816.jpeg"},
    { "id": 5, "title": "Salmon Slices", "categoryId": 2, "image": "https://static.pexels.com/photos/629093/pexels-photo-629093.jpeg" },
    { "id": 6, "title": "Mushroom, Tomato and Sweetcorn Pizza", "categoryId": 3, "image": "https://static.pexels.com/photos/7658/food-pizza-box-chalkboard.jpg" },
    { "id": 7, "title": "Fresh Burger", "categoryId": 4, "image": "https://images.pexels.com/photos/460599/pexels-photo-460599.jpeg" }
  ],
  "categories": [
    { "id": 1, "name": "Dessert", "description": "Delcious desserts that range from creamy New York style cheesecakes to scrumptious blueberry and chocolate cakes."},
    { "id": 2, "name": "Healthy Eating", "description": "Healthy options don't have to be boring with our fresh salmon slices and sweet, crispy salad."},
    { "id": 3, "name": "Pizza", "description": "Pizza is always a popular choice, chef up the perfect meat feast with our recipes!"},
    { "id": 4, "name": "Burgers", "description": "Be the king of the party with our flagship BBQ Burger recipe, or make something lighter with our veggie burgers!"}
  ]
}

接下来,请确保您在终端中运行以下命令,以确保您的机器上安装了 JSON Server:

$ npm install json-server -g

然后,通过在终端中输入以下命令,我们可以在3001端口(或除3000之外的任何端口,因为这是 Nuxt 运行的端口)上运行服务器:

$ json-server --watch db.json --port 3001

这将监视我们数据库的任何更改,并相应地更新 API。然后我们就能够请求localhost:3000/recipes/:idlocalhost:3000/categories/:id。在 Nuxt 中,我们可以使用axiosasyncData来做到这一点;让我们接下来看看。

asyncData

我们可以使用asyncData方法在组件加载之前解析组件的数据,实质上是在服务器端请求数据,然后在加载时将结果与组件实例内的数据对象合并。这使得它成为一个很好的地方来添加异步操作,比如从 REST API 获取数据。

我们将使用axios库来创建 HTTP 请求,因此我们需要确保已经安装了它。在终端中运行以下命令:

$ npm install axios

然后,在pages/index.vue中,当我们的应用程序启动时,我们将获取一个类别列表来展示给用户。让我们在asyncData中做到这一点:

import axios from 'axios'

export default {
  asyncData ({ req, params }) {
    return axios.get(`http://localhost:3001/categories`)
      .then((res) => {
        return {
          categories: res.data
        }
      })
  },
}

类别

由于asyncData与我们的 Vue 实例的数据对象合并,我们可以在视图中访问数据。让我们创建一个category组件,用于显示 API 中每个类别的类别:

<template>
  <div class="card">
    <header class="card-header">
      <p class="card-header-title">
        {{category.name}}
      </p>
    </header>
    <div class="card-content">
      <div class="content">
        {{category.description}}
      </div>
    </div>
    <footer class="card-footer">
      <nuxt-link :to="categoryLink" class="card-footer-
      item">View</nuxt-link>
    </footer>
  </div>
</template>

<script>

export default {
  props: ['category'],
  computed: {
    categoryLink () {
      return `/categories/${this.category.id}`
    }
  }
}
</script>

<style scoped>
div {
  margin: 10px;
}
</style>

在上面的代码中,我们使用 Bulma 来获取类别信息并将其放在卡片上。我们还使用了一个computed属性来生成nuxt-link组件的 prop。这使我们能够根据类别id导航用户到项目列表。然后我们可以将其添加到我们的根pages/index.vue文件中:

<template>
  <div>
    <app-category v-for="category in categories" :key="category.id" 
    :category="category"></app-category>
  </div>
</template>

<script>
import Category from '../components/Category'
import axios from 'axios'

export default {
  asyncData ({ req, params }) {
    return axios.get(`http://localhost:3001/categories`)
      .then((res) => {
        return {
          categories: res.data
        }
      })
  },
  components: {
    'app-category': Category
  }
}
</script>

因此,这就是我们的首页现在的样子:

分类详情

为了将用户导航到category详细页面,我们需要在categories文件夹中创建一个_id.vue文件。这将使我们能够在此页面内访问 ID 参数。这个过程与之前类似,只是现在我们还添加了一个validate函数来检查id参数是否存在:

<script>
import axios from 'axios'

export default {
  validate ({ params }) {
    return !isNaN(+params.id)
  },
  asyncData ({ req, params }) {
    return axios.get(`http://localhost:3001/recipes? 
    categoryId=${params.id}`)
      .then((res) => {
        return {
          recipes: res.data
        }
      })
  },
}
</script>

validate函数确保该路由存在参数,如果不存在,将会将用户导航到错误(404)页面。在本章的后面,我们将学习如何创建自己的错误页面。

现在我们在data对象内有一个基于用户选择的categoryIdrecipes数组。让我们在组件文件夹内创建一个Recipe.vue组件,用于显示食谱信息:

<template>
  <div class="recipe">
    <div class="card">
      <div class="card-image">
        <figure class="image is-4by3">
          <img :src="recipe.image">
        </figure>
      </div>
      <div class="card-content has-text-centered">
        <div class="content">
          {{recipe.title}}
        </div>
      </div>
    </div>
  </div>
</template>

<script>

export default {
  props: ['recipe']
}
</script>

<style>
.recipe {
  padding: 10px; 
  margin: 5px;
}
</style>

我们再次使用 Bulma 进行样式设置,并且能够将一个食谱作为 prop 传递给这个组件。让我们在_id.vue组件内迭代所有的食谱:

<template>
  <div>
    <app-recipe v-for="recipe in recipes" :key="recipe.id" 
    :recipe="recipe"></app-recipe>
  </div>
</template>

<script>
import Recipe from '../../components/Recipe'
import axios from 'axios'

export default {
  validate ({ params }) {
    return !isNaN(+params.id)
  },
  asyncData ({ req, params }) {
    return axios.get(`http://localhost:3001/recipes?
    categoryId=${params.id}`)
      .then((res) => {
        return {
          recipes: res.data
        }
      })
  },
  components: {
    'app-recipe': Recipe
  }
}
</script>

每当我们选择一个类别,现在我们会得到以下页面,显示所选的食谱:

错误页面

如果用户导航到一个不存在的路由或者我们的应用程序出现错误怎么办?嗯,我们当然可以利用 Nuxt 的默认错误页面,或者我们可以创建自己的错误页面。

我们可以通过在layouts文件夹内创建error.vue来实现这一点。让我们继续做这个,并在状态码为404时显示错误消息;如果不是,我们将显示一个通用的错误消息:

<template>
  <div>
    <div class="has-text-centered" v-if="error.statusCode === 404">
      <img src="https://images.pexels.com/photos/127028/pexels-photo-
      127028.jpeg" alt="">
        <h1 class="title">Page not found: 404</h1>
        <h2 class="subtitle">
          <nuxt-link to="/">Back to the home page</nuxt-link>
        </h2>
    </div>
    <div v-else class="has-text-centered">
      <h1 class="title">An error occured.</h1>
      <h2 class="subtitle">
        <nuxt-link to="/">Back to the home page</nuxt-link>
      </h2>
    </div>
  </div>
</template>

<script>

export default {
  props: ['error'],
}
</script>

如果我们然后导航到localhost:3000/e,您将被导航到我们的错误页面。让我们来看看错误页面:

插件

我们需要能够向我们的应用程序添加配方;因为添加新配方将需要一个表单和一些输入以适当验证表单,我们将使用Vuelidate。如果您还记得之前的章节,我们可以使用Vue.use添加Vuelidate和其他插件。在使用 Nuxt 时,该过程类似,但需要额外的步骤。让我们通过在终端中运行以下命令来安装Vuelidate

$ npm install vuelidate

在我们的插件文件夹内,创建一个名为Vuelidate.js的新文件。在这个文件内,我们可以导入VueVuelidate并添加插件:

import Vue from 'vue'
import Vuelidate from 'vuelidate'

Vue.use(Vuelidate)

然后,我们可以更新nuxt.config.js以添加指向我们的Vuelidate文件的插件数组:

plugins: ['~/plugins/Vuelidate']

build对象内,我们还将'vuelidate'添加到供应商包中,以便将其添加到我们的应用程序中:

build: {
 vendor: ['vuelidate'],
 // Omitted
}

添加配方

让我们在pages/Recipes/new.vue下创建一个新的路由;这将生成一个到localhost:3000/recipes/new的路由。我们的实现将是简单的;例如,将食谱步骤作为string可能不是生产的最佳选择,但它允许我们在开发中实现我们的目标。

然后,我们可以使用Vuelidate添加适当的数据对象和验证:

import { required, minLength } from 'vuelidate/lib/validators'

export default {
  data () {
    return {
      title: '',
      image: '',
      steps: '',
      categoryId: 1
    }
  },
  validations: {
    title: {
      required,
      minLength: minLength(4)
    },
    image: {
      required
    },
    steps: {
      required,
      minLength: minLength(30)
    }
  },
}

接下来,我们可以添加适当的模板,其中包括从验证消息到上下文类的所有内容,并在表单有效/无效时启用/禁用submit按钮:

<template>
  <form @submit.prevent="submitRecipe">
    <div class="field">
      <label class="label">Recipe Title</label>
      <input class="input" :class="{ 'is-danger': $v.title.$error}" v-
      model.trim="title" @input="$v.title.$touch()" type="text">
      <p class="help is-danger" v-if="!$v.title.required && 
      $v.title.$dirty">Title is required</p>
      <p class="help is-danger" v-if="!$v.title.minLength && 
      $v.title.$dirty">Title must be at least 4 characters.</p>
    </div>

    <div class="field">
      <label class="label">Recipe Image URL</label>
      <input class="input" :class="{ 'is-danger': $v.image.$error}" v-
      model.trim="image" @input="$v.image.$touch()" type="text">
      <p class="help is-danger" v-if="!$v.image.required && 
      $v.image.$dirty">Image URL is required</p>
    </div>

    <div class="field">
      <label class="label">Steps</label>
      <textarea class="textarea" rows="5" :class="{ 'is-danger': 
      $v.steps.$error}" v-model="steps" @input="$v.steps.$touch()" 
      type="text">
      </textarea>
      <p class="help is-danger" v-if="!$v.steps.required && 
      $v.steps.$dirty">Recipe steps are required.</p>
      <p class="help is-danger" v-if="!$v.steps.minLength && 
      $v.steps.$dirty">Steps must be at least 30 characters.</p>
    </div>

    <div class="field">
      <label class="label">Category</label>
      <div class="control">
        <div class="select">
          <select v-model="categoryId" @input="$v.categoryId.$touch()">
            <option value="1">Dessert</option>
            <option value="2">Healthy Eating</option>
          </select>
        </div>
      </div>
    </div>

    <button :disabled="$v.$invalid" class="button is-
    primary">Add</button>
  </form>
</template>

要提交食谱,我们需要向我们的 API 发出 POST 请求:

import axios from 'axios'

export default {
  // Omitted
  methods: {
    submitRecipe () {
      const recipe = { title: this.title, image: this.image, steps: 
      this.steps, categoryId: Number(this.categoryId) }
      axios.post('http://localhost:3001/recipes', recipe)
    }
  },
}

不要手动导航到http://localhost:3000/recipes/new URL,让我们在导航栏中添加一个项目:

<template>
  <nav class="navbar is-primary" role="navigation" aria-label="main navigation">
    <div class="navbar-brand">
      <nuxt-link class="navbar-item" to="/">Hearty Home Cooking</nuxt-
      link>
    </div>
    <div class="navbar-end">
      <nuxt-link class="navbar-item" to="/recipes/new">+ Add New 
      Recipe</nuxt-
     link>
    </div>
  </nav>
</template>

现在我们的页面是这样的:

虽然我们还没有在应用程序中使用食谱步骤,但我在这个示例中包含了它作为您可能想要自己包含的功能。

转换

在页面之间导航时,Nuxt 使添加过渡效果变得非常简单。让我们通过添加自定义 CSS 来为每个导航操作添加一个transition。将名为transition.css的文件添加到assets文件夹中,然后我们将钩入到不同页面状态中:

.page-enter-active, .page-leave-active {
  transition: all 0.25s;
}

.page-enter, .page-leave-active {
  opacity: 0;
  transform: scale(2);
}

添加文件后,我们需要告诉 Nuxt 我们要将其用作.css文件。将以下代码添加到您的nuxt.config.js中:

 css: ['~/assets/transition.css']

现在,我们可以在任何页面之间导航,每次都会有页面过渡效果。

为生产构建

Nuxt 为我们提供了多种构建项目用于生产的方式,例如服务器渲染(Universal)、静态或单页应用程序(SPA)模式。所有这些都根据用例提供了不同的优缺点。

默认情况下,我们的项目处于服务器渲染(Universal)模式,并且可以通过在终端中运行以下命令来进行生产构建:

$ npm run build

然后我们在项目的.nuxt文件夹内得到一个dist文件夹;其中包含我们应用程序的构建结果,可以部署到托管提供商:

静态

为了以静态模式构建我们的项目,我们可以在终端中运行以下命令:

$ npm run generate

这将构建一个静态站点,然后可以部署到诸如 Firebase 之类的静态托管提供商。终端应该如下所示:

SPA 模式

要以 SPA 模式构建我们的项目,我们需要将以下键值添加到nuxt.config.js中:

mode: 'spa'

然后我们可以再次构建我们的项目,但这次将使用 SPA 模式构建:

$ npm run build 

我们的命令终端现在应该如下所示:

总结

在本章中,我们讨论了如何使用 Nuxt 创建服务器渲染的 Vue 应用程序。我们还讨论了创建新路由有多么容易,以及如何在项目中添加自定义 CSS 库。此外,我们还介绍了如何在页面中添加过渡效果,使在路由之间切换时更加有趣。我们还介绍了如何根据需要构建项目的不同版本,无论是想要一个通用、静态还是 SPA 应用程序。

在最后一章中,我们将讨论 Vue.js 中常见的反模式以及如何避免它们。这对于编写能经受时间考验的一致性软件至关重要。

第十三章:模式

在本章中,我们将看看 Vue.js 中的各种反模式,并在高层次上回顾我们在整本书中学到的概念。我们将看看各种模式和反模式,以及我们如何编写能够在团队和自己的项目中保持一致的代码。

在将任何东西定义为模式反模式之前,重要的是为读者准确定义两者。如果某事被认为是一种模式,这意味着在绝大多数情况下这是一种推荐的做法。相反,如果我将其定义为反模式,那么在绝大多数情况下,这很可能不是一种推荐的做法。关于这一点的更多信息,可以在github.com/pablohpsilva/vuejs-component-style-guide找到模式和指南的良好来源。

软件开发是一个主观的领域,有各种解决同一问题的方式,因此可能有一些被归类为你不同意的意识形态,这是可以的。在一天结束时,每个团队都有自己的风格,但开发人员应该努力坚持能够减少摩擦并加快开发速度的模式。

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

  • Vue 项目中的常见模式和反模式

  • 容器/展示组件

  • 如何编写可测试的 Vue.js 组件

组件

在 Vue 中,组件之间有许多通信方式,例如使用 props、事件和基于 store 的场景。Vue 还给我们提供了$parent$children对象的访问权限,允许我们与父/子组件进行交互。让我们来看看这个,并看看为什么不应该使用它。

通信-反模式

想象一下我们熟悉的TodoList示例,在TodoItem组件中,我们希望能够完成特定的 Todo。如果我们想要将我们的 todos 保留在TodoList中,因此从TodoItem中调用完成方法,我们可以这样做:

export default {
  props: ['todo'],
  methods: {
    completeTodo(todo) {
      this.$parent.$options.methods.completeTodo(todo);
    }
  }
}

出于许多原因,这是一个坏主意,主要是因为我们将这两个组件紧密耦合在一起,并假设父组件上始终会有一个completeTodo方法。

我们可以改变什么来使它变得更好?

我并不是说父/子组件不能通信,但你应该设计组件的方式是灵活的。根据应用程序的结构,使用事件或 Vuex 存储。这里有一个使用事件的例子:

methods: {
  completeTodo(todo) {
    this.$emit('completeTodo', todo)
  }
}

子组件变异道具-反模式

重要的是我们不应该在子组件内修改道具。当传递给组件时,道具应被视为真相的来源,因此在子组件内更改值通常是不好的做法。然而,也有一些特殊情况,可能适当这样做,比如使用.sync修饰符实现双向数据绑定时。

如果我们使用先前的示例并在子组件内更改 todos 道具,我们将在控制台内收到警告:

methods: {
  completeTodo(todo) {
    this.todo = [{id: 1, name: 'Do the dishes.'}];
    this.$emit('completeTodo', todo)
  }
}

我们应该做什么?

如果你想在子组件内使用道具,最好将道具保存为data选项内的新变量。这样可以使您变异道具的新版本,局限于此组件:

export default {
  props: {
    age: {
      type: Number,
    }
  },
  data() {
    return {
      personAge: this.age
    }
  },
}

然后我们可以访问和变异personAge而不必担心任何副作用。另一个例子是创建一个可过滤的搜索框,而不是直接变异道具,可以创建一个执行所需功能的computed属性:

export default {
  props: {
    filter: {
      type: String,
    }
  },
  computed: {
    trimFilter() {
      return this.filter.trim().toLowerCase()
    }
  }
}

变异属性数组

在 JavaScript 中传递数组和对象作为属性时,需要考虑的一个重要因素是它们是按引用传递的。这意味着在子组件内对原始数组的任何更改也会泄漏到父组件中。让我们看看这个例子:

<template>
  <div>
    <h1>Parent Component</h1>
    <ul>
      <li v-for="friend in friendList" :key="friend.id">{{friend.name}}</li>
    </ul>

    <Person :friendList="friendList" />
  </div>
</template>

<script>
import Person from './components/Person';

export default {
  data() {
    return {
      friendList: [{ id: 1, name: 'John Doe' }]
    }
  },
  components: {
    Person
  }
}
</script>

在这里,我们有一个包含数组的组件(App.vue),我们将其显示在屏幕上并传递到子组件中。我们将在子组件内在屏幕上显示相同的数组,但还会给子组件一个按钮,用于向数组中添加新项:

<template>
  <div>
    <h1>Child Component</h1>
    <ul>
      <li v-for="friend in friendList" :key="friend.id">{{friend.name}}</li>
    </ul>
    <button @click="addFriend">Add Friend</button>
  </div> 
</template>

<script>
export default {
  props: {
    friendList: {
      type: Array,
    }
  },
  methods: {
    addFriend() {
      this.friendList.push({ id: 2, name: 'Sarah Doe' })
    }
  }
}
</script>

当我们向朋友列表中添加一个新人时,这是我们的结果:

然后,两个组件都有相同的数组!这不是我们想要的。如果出于某种原因我们想要执行这样的操作,最好保留朋友列表的副本并进行变异,如下所示:

export default {
  props: {
    friendList: {
      type: Array,
    }
  },
  data() {
    return {
      fList: [...this.friendList]
    }
  },
  methods: {
    addFriend() {
      this.fList.push({ id: 2, name: 'Sarah Doe' })
    }
  }
}

使用数据作为对象-反模式

在创建 Vue 组件时,重要的是数据选项是一个返回包含数据的新对象的函数,而不仅仅是一个普通的数据对象。

如果你简单地使用一个不是函数的数据对象,那么组件的所有实例将共享相同的数据。这是不好的,因为你可以想象到,每当数据发生变化时,组件的所有实例都会被更新为相同的数据。重要的是要确保每个组件都能够管理自己的数据,而不是在整个组件之间共享数据。

让我们来看一下问题所在:

data: {
 recipeList: [],
 selectedCategory: 'Desserts'
}

我们可以通过这样做来修复这个问题:

data () {
 return {
  recipeList: [],
  selectedCategory: 'Desserts'
 }
}

通过创建return语句,它允许每个创建的实例都有自己的对象,而不是共享一个对象。这样就可以在没有共享数据冲突的情况下多次使用代码。

接下来,让我们来看一下命名组件的最佳实践。

组件命名 - 反模式

将组件命名为单个单词并不是一个好主意,因为它有可能与原生 HTML 元素发生冲突。假设我们有一个注册表单和一个名为Form.vue的组件;在模板中使用时,什么样的名称才是合适的呢?

嗯,你可能会想象到,拥有一个名为<form>的组件将与<form>冲突,因此最佳实践是使用多于一个单词命名的组件。一个更好的例子可以是signup-formapp-signup-formapp-form,具体取决于个人偏好:

// This would not be an appropriate name as it conflicts with HTML elements.
Vue.component('form', Form)

// This is a better name as it's multi-word and there are less chances to conflict.
Vue.component('signup-form', Form)

模板表达式

通常情况下,当我们在屏幕上显示项目时,我们可能需要计算值并调用函数来改变我们的数据外观。建议不要在模板内部进行这项工作,而是将其移到computed属性中,因为这样更容易维护。

// Bad 
<nuxt-link :to="`/categories/${this.category.id}`" class="card-footer-item">View</nuxt-link>

// Good
<nuxt-link :to="categoryLink" class="card-footer-item">View</nuxt-link>

export default {
  props: ['category'],
  computed: {
    categoryLink () {
      return `/categories/${this.category.id}`
    }
  }
}

这意味着我们的模板中的任何更改都更容易处理,因为输出被映射到一个计算属性。

模式 - 容器/展示组件

组件设计的一个重要部分是确保你的组件是可测试的。你应该把每个组件都看作是应用程序中的一个独立模块,可以根据需要进行切换;因此,它不应该与另一个组件紧密耦合。

最好的方法是确保你的组件在确保轻耦合之后是可测试的,通过组件属性具有明确定义的公共 API,然后使用事件在父/子组件之间进行通信。这也有助于我们进行测试,因为我们能够更容易地模拟组件。

遵循这种模式时常见的模式是容器/展示组件。这意味着我们将所有业务逻辑和状态保留在“容器”中,然后将状态传递给“展示”组件以在屏幕上显示。

展示组件仍然可以通过事件与其他组件通信,如果必要的话,但不应修改或保存外部传入 props 之外的状态。这确保了我们的组件之间有一个共同的数据流,这意味着我们的应用程序更容易理解。

这是一个明确命名的组件—DogContainer.vue

<template>
  <dog-presentational :dogName="dogName" @woof="woof"></dog-presentational>
</template>

<script>
import DogPresentational from './DogPresentational'

export default {
  data() {
    return {
      dogName: 'Coco',
    }
  },
  components: {
    'dog-presentational': DogPresentational
  },
  methods: {
    woof() {
      alert('Woof!');
    }
  },
}
</script>

容器组件已将狗的名字(以及任何其他项目)作为属性传递到展示组件中。容器组件还在此组件上监听名为woof的事件,并在发出时调用woof方法。这是我们的展示组件:

<template>
  <div>
    <h1>Name: {{dogName}}</h1>
    <button @click="woof">Woof</button>
  </div>
</template>

<script>
export default {
  props: ['dogName'],
  methods: {
    woof() {
      this.$emit('woof')
    }
  }
}
</script>

我们的组件关注点现在清晰地分离开来,并且它们之间有一个明确的通信路径。

这种组合可以在以下图中可视化:

组合组件

Prop 验证

虽然我们应该通过 props 在子组件之间进行通信,但在验证属性时要考虑类型、要求、默认值等,是很重要的。在整本书中,我为简洁起见使用了这两种技术的混合,但在生产中,props 应该得到适当的验证。让我们首先看一些属性类型的示例:

export default {
  props: {
    firstName: {
      type: String
    },
    lastName: {
      type: String
    },
    age: {
      type: Number
    },
    friendList: {
      type: Array
    }
  },
}

我们还有其他各种类型可用,例如布尔值、函数或任何其他构造函数(即 Person 类型)。通过准确定义我们期望的类型,这使我们(和我们的团队)更好地理解我们可以在这个组件中期望什么。

同时,我们还可以确保 props 是必需的。这应该在必要时进行,这样我们就可以确保每当组件被使用时,没有缺少必需的 props:

  props: {
    firstName: {
      type: String,
      required: true,
      default: 'John'
    },
    lastName: {
      type: String,
      required: true,
      default: 'Doe'
    }
  }

我们应该始终在可能的情况下为我们的 props 提供默认值,因为这减少了必要的配置,但仍允许开发人员自定义组件。在处理对象和数组时,函数被用作默认参数,以避免实例共享相同的值的问题。

props: {
  firstName: {
    type: String,
    default: 'John'
  },
  lastName: {
    type: String,
    default: 'Doe'
  },
  friendList: {
    type: Array,
    default: () => [{ id: 1, name: 'Paul Halliday'}]
  }
}

我们还可以为我们的属性分配一个自定义的“验证器”函数。假设我们有一个插槽“机器”组件,只有在用户年满 18 岁时才会被渲染:

  props: {
    age: {
      type: Number,
      required: true,
      validator: value => {
        return value >= 18
      }
    },
  }

理解响应性

我们已经讨论了响应性以及它在之前的章节中如何使用,但重新考虑是很重要的。当我们在 Vue 中创建响应式数据对象时,它会使用Object.defineProperty来获取每个属性并添加适当的 getter/setter。这允许 Vue 处理对象的更改并通知观察者,随后更新组件vuejs.org/v2/guide/reactivity.html。可以这样来可视化:

可视化响应性

这意味着在数据选项中定义的任何属性都会自动变为响应式。这里有一个例子:

export default {
  data() {
    return {
      name: 'Vue.js'
    }
  }
}

在这个 Vue 实例中,name属性是响应式的,但如果我们在 Vue 实例之外添加另一个属性,那么它就不会是响应式的。让我们看一个例子:

export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Bananas'},
        { id: 2, name: 'Pizza', quantity: 2},
        { id: 3, name: 'Cheesecake', quantity: 5}
      ] 
    }
  },
}

我们的groceries组件有一个包含各种对象的 items 数组。每个对象都有一个数量,除了香蕉对象,但我们想为其设置数量。在使用v-for时,包含v-bind:key(或简写为:key)是很重要的,因为它作为每个项目的唯一标识符,从而允许重用和重新排序每个节点。虽然key可能是v-for的属性,但请记住它还有其他用例场景。

<template>
  <div>
    <ul>
      <li v-for="(item, index) in items" :key="item.id" @click="addQuantity(index)">
        {{item.name}} {{item.quantity}}
      </li>
    </ul>
  </div> 
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Bananas'},
        { id: 2, name: 'Pizza', quantity: 2},
        { id: 3, name: 'Cheesecake', quantity: 5}
      ] 
    }
  },
  methods: {
    addQuantity(selected) {
      this.items[selected].quantity = 1;
      console.log(this.items);
    }
  }
}

然后,我们转到浏览器,并使用开发工具访问控制台,我们可以看到数量已经被设置为保存我们对象的值。

请注意,当在数据对象中定义包含数量的数量对象时,会有响应式的 getter 和 setter。在事后向 items 添加属性意味着 Vue 不会添加适当的 getter/setter。如果我们想要这样做,我们必须使用Vue.set

methods: {
  addQuantity(selected) {
    const selectedItem = this.items[selected];
    this.$set(selectedItem, 'quantity', 2)
    console.log(this.items);
  }
}

这一次,我们的实例中的每个属性都有 getter/setter:

总结

在本章中,我们看了反模式和模式,并且我们扩展了我们的知识,不仅知道它们是什么,还知道何时适合使用它们以符合最佳实践。不仅如此,我们还在本章中回顾了我们在整本书中学到的许多概念,同时考虑了一些新的想法和技术,可以在未来使用。

回顾之前的章节,我们可以回顾过去,看到我们已经覆盖了多少内容。实践本书中涵盖的技术将使您能够使用 Vue.js 创建可扩展的应用程序,并建立在您所学到的基础上。另一件重要的事情要记住的是,web 开发一直在不断发展,Vue 的实际应用数量不断增长,你也应该如此

接下来呢?尝试新事物!建立新项目,参加 Vue.js 的会议和会议 - 找到新的方式来运用你的技能来教别人。你不仅会对他人产生积极影响,而且会重新确认自己作为开发者的技能。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报