Vuex-快速启动指南-全-
Vuex 快速启动指南(全)
原文:
zh.annas-archive.org/md5/0354878341f6e7099611b12a720882f8译者:飞龙
前言
Vuex 是构建客户端 Web 应用的集中式状态管理架构。它利用 Vue.js 的反应性系统,完美地集成到 Vue.js 应用中。
在这本书中,你将了解为什么集中式状态管理很重要,它是如何工作的,以及如何使用 Vuex 赋予你的 Vue.js 应用更多功能。最后,你将了解 Vuex 插件系统,并学习如何使用它来丰富你的 Vuex 应用。
本书面向对象
本书针对希望理解和在应用中使用 Vuex 集中式状态管理架构的 Vue.js 开发者。
本书涵盖内容
第一章,用 Flux、Vue 和 Vuex 重新思考用户界面,介绍了 Flux 架构的概念以及 Vuex 实现中的细微差别。
第二章,使用 Vuex 实现 Flux 架构,讲解了 Vuex 的核心概念,并通过一些小型可执行示例,我们学习了如何使用它们。
第三章,设置开发和测试环境,展示了如何使用 webpack 和 npm 为开发 Vue/Vuex 应用准备我们的环境。
第四章,使用 Vuex 状态管理编写 EveryNote 应用,解释了如何开发一个笔记应用,使用我们刚刚学到的 Vuex 的所有概念。此外,我们还使用 karma 测试应用的所有组件。
第五章,调试 Vuex 应用,指出即使测试了每个组件,有时调试也是必要的。我们将了解如何使用 Chrome 开发者工具和 vue-devtools 来调试我们的应用。
第六章,使用 Vuex 插件系统,通过一些有用的 Vuex 插件丰富了 EveryNote 应用,包括从头开始开发的两个自定义插件。
为了充分利用本书
在这本书中,我们假设读者对 Vue.js 框架有良好的了解,对 JavaScript 有良好的了解,并对 EcmaScript 6 有基本了解。
此外,为了运行示例,读者需要安装 Node.js,并对node 包管理器(npm)有非常基本的了解。
最后,为了使用这本书的 Git 仓库,用户需要安装 Git,并对 Git 命令有基本了解。
下载示例代码文件
你可以从你的账户在 www.packtpub.com 下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在 www.packtpub.com 登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并按照屏幕上的说明操作。
文件下载完成后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Vuex-Quick-Start-Guide。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为 github.com/PacktPublishing/。请查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/VuexQuickStartGuide。
代码实战
访问以下链接查看代码运行的视频:
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
html, body, #map {
height: 100%;
margin: 0;
padding: 0
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都如下所示:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
总体反馈:请将邮件发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packtpub.com.
第一章:使用 Flux、Vue 和 Vuex 重新思考用户界面
我在 2007 年底开始了我的第一份工作,作为 Java EE 程序员。我仍然记得我的朋友 Giuseppe 说,“你不喜欢 JavaScript,对吧?”而我回答,“不,我不喜欢。每次我写 JavaScript 时,它都不在所有版本的 Internet Explorer 中工作...更不用说 Firefox 了!”他只是回答,“看看 jQuery。”今天,我喜欢称自己为 JavaScript 程序员。
从那时起,Web 开发已经发生了很大的变化。许多 JavaScript 框架变得流行,然后又因为新框架的出现而衰落。你可能认为学习新框架不值得,因为它们最终会失去人气。然而,在我看来,这并不正确。每个框架都为 Web 开发增添了有用的功能,这些功能我们仍在使用。例如,jQuery 利用了非常简单的 JavaScript,我们开始将客户端逻辑移动到浏览器,而不是在服务器端渲染一切。
今天,我们编写的是具有 Web 用户界面的复杂应用程序,即渐进式 Web 应用程序。这种复杂性需要纪律和最佳实践。幸运的是,像 Facebook、Google 和其他大公司这样的公司已经引入了框架和指南来帮助 Web 程序员。你可能听说过 Google 的Material Design或 Facebook 的Flux。
在本章中,我们将关注以下内容:
-
模型-视图-控制器(MVC)问题,以及使用 Facebook Flux 架构来解决这些问题
-
Flux 基础
-
什么是 Vuex
-
Flux 和 Vuex 之间的架构差异
要理解这本书,你需要对 Vue.js 和 JavaScript 有很好的了解,对 ECMAScript 6 有基本理解,以及对 webpack 有非常基本的了解。无论如何,这里使用的几乎所有概念,包括 Vuex,都得到了解释。
在解释 Flux 概念之后,这本书将帮助你理解 Vuex 如何实现这些概念,如何使用 Vue.js 和 Vuex 构建专业 Web 应用程序,以及最后如何扩展 Vuex 功能。
MVC 问题和 Flux 解决方案
每当我们谈论具有用户界面的应用程序时,MVC 模式就会出现。但 MVC 模式是什么?它是一种将组件分为三部分的架构模式:一个模型、一个视图和一个控制器。你可以在以下图中看到描述 MVC 的经典图示:

图 1.0:经典 MVC 图示
大多数现代的渐进式 Web 应用程序框架都使用 MVC 模式。实际上,如果你看看以下图中显示的 Vue.js 单文件组件,你可以清楚地看到 MVC 模式的三个部分:

图 1.1:Vue.js 单文件组件
template和style部分代表视图部分,script部分提供控制器,控制器中的data部分是模型。
但是,当我们需要从另一个组件的模型中获取一些数据时会发生什么?此外,在一般情况下,我们如何将页面的所有组件相互连接?
显然,直接从其他组件访问组件的模型不是一个好主意。以下截图显示了暴露模型时的依赖关系:

图 1.2:MVC 地狱
Vue.js 提供了一种在父组件和子组件之间通信的好方法:你可以使用 Props 从父组件传递值到子组件,你也可以从子组件 emit 数据到其父组件。以下图展示了这个概念的一个视觉表示:

图 1.3:Vue.js 父子通信
然而,当多个组件共享一个公共状态时,这种通信方式就不够了。以下是一些可能出现的问题:
-
多个视图可能共享同一块状态
-
来自不同视图的用户操作可能需要改变同一块状态
一些框架提供了一个名为 EventBus 的组件;实际上,Vue 实例本身就是一个 EventBus。它有两个方法:Vue.$emit(event, [eventData]) 和 Vue.$on(event, callback([eventData]))。以下是如何创建一个全局事件总线的一个示例:
// EventBus.js
import Vue from 'vue';
export const EventBus = new Vue();
// HelloWorldEmitter.js
import { EventBus } from './EventBus.js';
EventBus.$emit('an-event', 'Hello world');
// HelloWorldReceiver.js
import { EventBus } from './EventBus.js';
EventBus.$on('an-event', eventData => {
console.log(eventData);
});
即使有全局事件总线,使组件通信也不是一件容易的事。如果一个注册了事件的组件在事件触发后加载,它将错过这个事件。如果这个组件在后来加载的模块中,这种情况很可能会发生在渐进式 Web 应用程序中,其中模块是按需加载的。
例如,假设用户想要将一个产品添加到购物车列表中。她点击了 添加到购物车 按钮,这个按钮很可能是位于 CartList 组件中,并且她期望屏幕上看到的那个产品被保存在购物车中。CartList 组件如何找出应该添加到其列表中的产品是什么?
嗯,看起来 Facebook 程序员也面临了类似的问题,为了解决这些问题,他们设计了他们称之为 Flux 的东西:用于构建用户界面的应用程序架构。
受到 Flux 和 Elm 架构的启发,Vue.js 的作者埃文·尤创建了 Vuex。你可能已经知道 Redux。在这种情况下,你会发现 Vuex 和 Redux 很相似,而且埃文·尤通过实现 Vuex 而不是强迫每个程序员在 Vue.js 应用程序中集成 Redux,为我们节省了时间。此外,Vuex 是围绕 Vue.js 设计的,以提供两个框架之间最佳集成。
但 Vuex 是什么?这就是下一节的主题。
什么是 Vuex?
埃文·尤定义 Vuex 为:
"*Vuex 是一个用于 Vue.js 应用程序的状态管理模式 + 库。它作为应用程序中所有组件的集中存储,并确保状态只能以可预测的方式被突变。"
在不了解 Flux 的情况下,这个定义听起来有点模糊。实际上,Vuex 是利用 Vue 的响应式系统实现的 Flux,使用单个集中式 store,并确保状态只能以可预测的方式被修改。
在专注于 Vuex 本身之前,我们将了解 Flux 的基本原理以及 Vuex 是如何从这些概念中汲取灵感的。
理解 Flux 的基本原理
Flux 是管理应用程序中数据流的一种模式,它是 Facebook 用于构建其 Web 应用程序的应用程序架构。以下图表显示了 Flux 的结构和数据流:

图 1.4:Flux 的结构和数据流
如前图所示,Flux 分为四个部分,并且数据流仅有一个方向。在接下来的章节中,我们将看到数据是如何通过以下部分流动的:
-
Actions
-
Dispatchers
-
Stores
-
视图
虽然了解 Flux 的工作原理很重要,但 Vuex 有自己的 Flux 架构实现,这与 Flux 不同,将在以下章节中详细解释。
Actions
Actions 定义了应用程序的内部 API。它们代表可以做什么,但不是如何做。状态变更的逻辑包含在 stores 中。一个 action 只是一个具有类型和一些数据的简单对象。
Actions 应该对读者有意义,并且应该避免实现细节。例如,remove-product-from-cart比将其拆分为update-server-cart、refresh-cart-list和update-money-total更好。
一个 action 被分发到所有 store,并且可以导致多个 store 更新。因此,分发一个 action 将导致一个或多个 store 执行相应的 action 处理器。
例如,当用户点击从购物车中移除按钮时,会分发一个remove-product-from-cart action:
{type: 'remove-product-from-cart', productID: '21'}
在 Vuex 中,动作系统略有不同,它将 Flux actions 分为两个概念:
-
Actions
-
Mutations
Actions 代表应用程序的行为,即应用程序必须做的事情。一个 action 的结果通常包括一个或多个被提交的 mutations。提交一个 mutation 意味着执行其关联的处理器。在 action 内部直接更改 Vuex 状态是不可能的;相反,actions 提交 mutations。
在 actions 内部,你必须处理异步代码,因为 mutations 必须是同步的。
另一方面,mutations 可以并且确实会修改应用程序状态。它们代表直接连接到应用程序状态的直接应用程序逻辑。mutations 应该是简单的,因为复杂的行为应该由 actions 处理。
由于 Vuex 中只有一个 store,因此 actions 是通过 store 分发的,action 与其处理器之间存在直接连接。另一方面,在 Flux 中,每个 store 都知道在响应 action 时应该做什么。
你将在接下来的章节中了解到 Vuex 动作/变异系统。现在,你只需要理解动作背后的概念,以及 Vuex 以略不同于 Flux 的方式实现动作。
分发器
每个应用程序只有一个分发器,它接收动作并将它们分发给商店。每个商店都接收每个动作。这是一个简单的动作分发机制,它可以通过以特定顺序向商店分发动作来处理商店之间的依赖关系。
例如:
-
用户点击了添加到购物车按钮
-
视图捕获这个事件并分发一个
添加到购物车动作 -
每个商店都接收这个动作
由于 Vuex 与 Flux 不同,因为分发器位于商店内部,所以你应该记住的是,应用程序中的每个变化都是从分发一个动作开始的。
商店
商店包含应用程序状态和逻辑。商店只能通过动作进行修改,不暴露任何设置方法。在 Flux 中可以有多个商店,每个商店代表应用程序中的一个领域。在 Vuex 中,只有一个商店,其状态被称为单一状态树。Vuex 不是唯一强制使用单个商店的框架:Redux 明确指出每个 Redux 应用程序都有一个商店。你可能认为单个商店可能会破坏模块化。我们将在稍后看到 Vuex 中模块化是如何工作的。
在切换到 Flux 架构之前,Facebook 聊天一直经历一个错误,即未读消息的数量不正确。他们不是有两个列表——一个是已读消息,另一个是未读消息——而是从其他组件的事件中推导出未读消息的数量。确实,有一个显式的状态,其中存储了所有信息会更好。将状态视为应用程序快照:你可以在应用程序页面关闭之前保存它,当应用程序再次打开时恢复它,这样用户会发现应用程序处于离开时的相同状态。
关于商店有三个重要概念:
-
商店只能通过动作进行修改
-
一旦商店被修改,它会通知视图它已经发生了变化
-
商店表示显式数据,而不是从事件中推导数据
这里是一个商店对之前示例中分发的添加到购物车动作做出反应的例子:
-
商店接收了
添加到购物车动作 -
它决定这是相关的,并通过将当前产品添加到购物车产品列表来执行动作的逻辑
-
它更新其数据,然后通知视图它已经发生了变化
视图
视图或视图控制器显示商店中的数据。这就是 Vue.js 等框架插入的地方。
在商店中渲染数据
在 Facebook 介绍 Flux 的视频中,软件工程师陈静谈到了他们在开发 Facebook Chat 时遇到的一些问题以及他们学到的经验教训。他们学到的其中一个有趣的教训是关于渲染的:他们不想重新渲染聊天中的所有消息,而是想通过仅更新聊天视图中的新消息来优化它。如果你是一个经验丰富的程序员,你可能认为这是“过早优化”。确实如此!将整个视图模型传递给视图,而不是只传递旧模型和新模型之间的差异,要简单得多。
假设一个程序员想要向一个视图添加一个新功能:如果视图模型每次修改时都由视图渲染,他们只需向模型添加一些属性,并在视图中添加一些代码来显示这些新属性。他们不需要担心更新/渲染逻辑。
但是,关于性能呢?仅仅因为未读消息的数量发生了变化,就重新渲染整个页面,这不是很糟糕吗?在这里,Vue.js 来帮助我们。程序员只需更新视图模型,Vue.js 就会理解发生了什么变化,并且只会重新渲染实际发生变化的文档对象模型(DOM)部分。以下图表概述了这一概念:

图 1.5:Vue.js 更新 DOM 节点
经验教训是这样的:花时间设计明确的、有意义的模型,让 Vue.js 负责性能和渲染逻辑。
DOM用于渲染网页。更多信息请见www.w3schools.com/js/js_htmldom.asp。
存储和私有组件模型
由于视图显示来自存储的数据,你可能会认为视图模型只是存储的一部分。实际上,每个组件都可以有一个私有的模型,可以保存仅在该组件内部需要的值。没有必要将每个值都放入存储中。存储应只包含与应用程序相关的数据。
例如,假设你想要从列表中选择一些照片并分享它们。照片列表组件的视图模型将包含所选照片的列表,当用户点击分享按钮时,视图控制器只需分发一个名为share-photos的动作,并将所选照片列表作为action对象中的数据。不需要将所选照片列表放入存储中。
总结 Flux 架构
以下是将 Flux 架构总结在一张图中的内容:

图 1.6:Flux 数据流解释
使用 Flux 的好处
以下是一些 Facebook 在将 Flux 引入其 Web 应用程序后获得的一些好处:
-
比经典的 MVC 模式有更好的可扩展性
-
数据流易于理解
-
单元测试更简单、更有效
-
由于动作代表了应用程序的行为,因此以行为驱动的开发非常适合使用 Flux 架构编写应用程序。
通过将 Vuex 框架添加到您的Vue.js应用程序中,您将体验到同样的好处。此外,Vuex,就像 Redux 一样,以几种不同的方式简化了这种架构,例如每个应用程序使用一个存储库,并且为了使用存储库来分发动作,从过程中移除了分发器。
摘要
在本章中,我们探讨了为什么 Facebook 工程师设计了 Flux 架构。我们关注了 Flux 的基本原理,并了解到 Vuex 与 Flux 略有不同。现在我们可以用一句话来概括 Flux:Flux 是一个具有单向数据流的可预测状态管理系统。
在第二章《使用 Vuex 实现 Flux 架构》中,您将学习 Vuex 的核心概念,以及如何在您的应用程序中使用 Vuex。
第二章:使用 Vuex 实现 Flux 架构
在我们心中明确了 Flux 概念之后,我们现在将探索 Vuex 框架,了解它是如何工作的,并通过一些示例,看看您如何在 Vue 应用程序中使用 Vuex。
本章将涵盖以下主题:
-
Vuex 快速浏览
-
将用于运行示例的样板代码
-
Vue.js 反应性系统解释
-
理解 Vuex 的核心概念
-
在开发时启用严格模式以防止意外直接修改状态
-
使用 Vuex 处理表单时的限制
-
简单计数器:一个非常简单的示例中包含的所有 Vuex 概念
第一部分向您介绍了 Vuex,重点介绍了框架背后的概念。
在第二部分,您将看到一个最小的 HTML 代码,用于运行本章的示例。
在第三部分,Vue 反应性系统被详细审查。这很有用,因为 Vuex 利用这个反应性系统无缝地将其本身插入到 Vue 应用程序的架构中。
在第四部分,所有 Vuex 核心概念都得到了彻底的审查,通过代码片段的帮助,您将看到 Vuex 既有强大的功能又易于使用。
第五和第六部分将解释在使用 Vuex 在您的应用程序中时需要记住的一些概念。
最后,在本章的最后部分,一个简单的示例将向您展示如何将 Vuex 的大多数概念组合在一个单独的 HTML 文件中,帮助您理解整体情况。
一旦您阅读了本章,您将对 Vuex 框架有一个清晰的理解,并且您将准备好开始使用它。
技术要求
您需要在系统上安装 Node.js。最后,为了使用本书的 Git 仓库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Vuex-Quick-Start-Guide/tree/master/chapter-2
查看以下视频以查看代码的实际运行情况:
Vuex 快速浏览
在第一章《用 Flux、Vue 和 Vuex 重新思考用户界面》中,我们将 Vuex 定义为 一个用于 Vue.js 应用的状态管理模式 + 库。它作为应用程序中所有组件的集中式存储,有规则确保状态只能以可预测的方式变异。
虽然我认为,在阅读了第一章《用 Flux、Vue 和 Vuex 重新思考用户界面》之后,Rethinking User Interfaces with Flux, Vue and 的定义应该对您来说已经足够清晰,但它仍然有点模糊。让我们列出前述句子中包含的三个概念:
-
集中式存储
-
状态只能以可预测的方式进行变异的事实
-
Vue 反应性系统
如果您回顾一下第一章中“Flux 架构总结”部分的图 1.6,在用 Flux、Vue 和 Vuex 重新思考用户界面,您会看到 Flux 架构有一个分发器,它会将动作分发到每个存储中。只有一个存储意味着分发器可以在存储内部,并且您可以使用集中式存储来分发动作。在 Vuex 中,我们有一个单一存储,其状态被称为单一状态树。
在 Flux 和 Vuex 中的一个基本规则是,状态只能因为动作而更改。没有任何组件、类或代码应该修改状态。只有与动作相关联的代码实际上可以更改状态值。Vuex 通过只能由动作执行的突变来实现这一点。在这方面,Vuex 与 Flux 不同。在 Flux 中,动作只是包含要执行的动作信息的简单数据对象。在 Vuex 中,动作可以执行最终提交一个或多个将更改状态的突变的代码。您将在本章后面阅读有关突变和动作的内容。
最后,在动作已分发并且状态已更新后,必须通知应用程序的视图哪些内容已更改。这是通过利用 Vue 响应性系统来完成的,这是下一节的主题。
示例的样板代码
在接下来的几页中,您将获得一些示例。为了执行这些示例,您需要创建一个如下所示的 HTML 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vuex condensed example</title>
</head>
<body>
<div id="app"></div>
<script src="img/vuex.min.js"></script>
<script src="img/vue.min.js"></script>
<script>
Vue.use(Vuex);
// Add code from examples here
</script>
</body>
</html>
通过复制<script>标签内的示例代码,您可以运行它并查看结果。几乎每个示例代码都可以在本书的 Git 仓库中找到,位于/chapter-2/文件夹下。
Vue.js 响应性系统解释
Vue 的一个强大功能是其响应性系统。它是一种不侵入的方式来检测组件模型的变化。组件模型只是一个普通的 JavaScript 对象。当它发生变化时,Vue 会检测这些变化并更新相应的视图。在 Vuex 中,单一的状态树是响应的,就像 Vue 组件的data部分一样。
理解响应性系统的工作原理对于避免一些常见的错误非常重要。
有两种方法可以检测 JavaScript 对象内部的一个值是否已更改:
-
通过使用在 ECMAScript 2015(第 6 版,ECMA-262)中定义的
Proxy功能 -
通过使用在 ECMAScript 2011(第 5.1 版,ECMA-262)中定义的
Object.defineProperty
由于兼容性原因,Vue 决定使用Object.defineProperty,这意味着存在一些限制。
当您创建一个组件时,Vue 会遍历data部分的所有属性,并使用Object.defineProperty将它们转换为getter/setter方法。因此,Vue 只能检测到在组件的data部分中定义的属性的变化。让我们看一个例子:
// Bugged example of counter
Vue.use(Vuex);
const CounterComponent = {
template: `
<div>
<p>I will count from 1 to {{end}}.</p>
<button @click="beginCounting">Begin!</button>
<p>{{counter}}</p>
</div>
`,
created() {
this.counter = 0;
},
data() {
return {
end: 3,
// you should add counter property here
};
},
methods: {
beginCounting() {
this.counter = 0;
const increaseCounter = () => {
this.counter++;
if (this.counter < this.end) {
setTimeout(increaseCounter, 1000);
}
};
increaseCounter();
},
},
};
new Vue({
el: '#app',
template: '<counter></counter>',
components: {
counter: CounterComponent,
},
});
在这个例子中,counter属性没有在组件的data部分中声明。这阻止 Vue 检测到counter已被更改,因此,当用户点击按钮“开始!”时,他们将看不到计数器的增加。
这可以通过在data部分添加counter并从created()方法中移除它来轻松修复。看看以下代码:
data() {
return {
end: 3,
counter: 0
};
},
你可以在本书的 Git 仓库中找到前述示例的代码,位于chapter-2/counterTo3/counter.html文件中。
当使用数组时,Vue 无法检测以下更改:
-
直接使用索引设置值——例如,
this.items[indexOfItem] = newItem -
改变数组长度——例如,
this.items.length = newLength
为了避免这些问题,你可以创建一个新的数组并将其分配给相应的数据属性,或者使用数组方法,例如push()或splice()。以下是一些更新 Vue 观察到的数组的不同方法:
// Replacing the array
this.items[1] = updatedItem;
this.items = this.items.slice();
// Using splice to change element at index 1
this.items.splice(1,1,updatedItem);
// Adding a new item
this.items.push(newItem);
我们现在明白,每次我们正确地更改组件模型或单个状态树内部的某个东西时,Vue 都会检测到并相应地更新视图。但性能如何?每次修改都更新视图不是不好吗?实际上,Vue 利用 JavaScript 事件循环的工作原理来排队所有视图的更新。为了理解这个概念,让我们关注以下示例:
console.log('start');
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timeout'), 0);
console.log('end')
预期输出(可能因不同浏览器而异)如下:
start
end
promise
timeout
首先,JavaScript 虚拟机执行打印start和end的同步代码,然后执行执行期间排队的所有任务,打印promise和timeout。
如果用户浏览器上有Promise,Vue 将使用Promise;否则,它会尝试找到最佳调度函数,如果没有找到其他支持的调度函数,则回退到setTimeout。今天,Promise几乎在所有浏览器、移动设备和桌面设备上都得到了支持。
理解 Vuex 的核心概念
现在是时候介绍 Vuex 架构了,它由五个核心概念组成:
-
单一状态树
-
获取器
-
变更
-
动作
-
模块
每个概念都将进行详细讨论,并提供一些代码片段以帮助使其清晰。一旦你阅读了以下页面,你将对 Vuex 架构有一个清晰的理解。
理解 Vuex 存储
Vuex 使用单一状态树实现 Flux 存储。在这方面,它与 Flux 不同,因为在 Flux 中可能有多个存储。你可能认为单个存储/状态对模块化来说不是很好。稍后我们将看到如何将单一状态树拆分为模块。
只有一个存储有一些好处:
-
它在所有组件中可用
-
由于所有应用程序状态都在那里,因此更容易调试
-
你可以编写无干扰的插件来监视状态并执行操作,例如持久化状态以供以后检索
单一状态树包含所有应用级别的数据——它代表了应用领域模型。
在组件内部访问单一状态树
现在我们通过一个示例来看看如何在 Vue 组件中使用这个单一状态树。假设我们想在聊天会话中显示未读消息的数量。在应用的某个地方,这个数字会被更新,而 NumUnreadMessages 组件会显示这个数字。以下是一个组件可能被编码的示例:
const NumUnreadMessages = {
template: `<div>Unread: {{ unreadCounter }}</div>`,
computed: {
unreadCounter() {
return this.$store.state.unreadCounter;
},
},
};
如你所见,这很简单——你只需要使用 this.$store.state 来访问应用状态。为了在 Vue components 中使用 this.$store,你需要将存储添加到 Vue 应用中:
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
unreadCounter: 1,
},
});
const NumUnreadMessages = {
template: `<div>Unread: {{ unreadCounter }}</div>`,
computed: {
unreadCounter() {
return this.$store.state.unreadCounter;
},
},
};
const app = new Vue({
el: '#app',
store,
components: {NumUnreadMessages},
template: `
<div class="app">
<num-unread-messages></num-unread-messages>
</div>
`,
});
你可以在本书的 Git 仓库中找到这个示例的代码,在文件 chapter-2/unread-messages/unread.html 中。
mapState 辅助函数
每次想要访问状态时创建一个计算属性可能会很繁琐且冗长,尤其是如果组件需要多个状态属性。
幸运的是,Vuex 提供了一个方便的工具,称为 mapState:
const NumUnreadMessages = {
// ...
computed: Vuex.mapState({
unreadCounter: state => state.unreadCounter,
})
}
在前面的代码中,计算属性 unreadCounter 被映射到 this.$store.state.unreadCounter。由于 mapState 没有很好地记录,我将解释你可以使用它的所有方法。
- 你可以使用函数,如下面的代码所示:
// Using functions with mapState
const NumUnreadMessages = {
data() {
return {label:' unread messages'};
},
computed: mapState({
unreadCounter: state => state.unreadCounter,
unreadCounterPlusLabel(state) {
// Here you can use *this* keyword to access
// the local state of the component.
return state.unreadCounter + this.label;
}
})
}
unreadCounter 是一个箭头函数,而 unreadCounterAlias 是一个普通函数。如果你想访问组件的局部状态,你必须使用一个函数而不是箭头函数;否则,你无法在箭头函数内部使用 this 关键字。
- 你可以使用
strings,如下面的代码片段所示:
// Using strings
computed: mapState({
// Equivalent to unreadCounter: state => state.unreadCounter
unreadCounter: 'unreadCounter'
})
- 最后,如果
state属性的名称和computed属性的名称相同,有一个更简洁的方法可以使用:
// Using string array
computed: mapState([
// map this.unreadCounter to store.state.unreadCounter
'unreadCounter'
])
在这种情况下,你只需要将一个字符串数组传递给 mapState,其中每个字符串都是你想要映射的 state 属性的名称。
你可能会想知道如何将本地计算属性与来自 mapState 的属性混合。以下是一个使用 ECMAScript 6 对象扩展运算符的示例:
computed: {
localComputed () {
// returning localProperty declared into data section
return this.localProperty;
},
...mapState([
'unreadCounter'
])
}
ES6 对象扩展运算符 ... 的使用在程序员中还不是特别普遍,尤其是在与对象一起使用时。如果你对这个运算符感到陌生,请看以下示例:
const obj = {b:'b', c:'c'};
console.log({a:'a', ...obj , d:'d'});
// prints {a: "a", b: "b", c: "c", d: "d"}
组件的局部状态
即使存在全局单一状态树,这并不意味着组件不能有局部状态。全局状态是应用级别的,不应该被组件的私有状态所污染。例如,组件的文本部分可能只会在组件内部使用,因此它们不应该被放入应用状态中。
使用 getters 计算派生状态
有时两个或多个组件需要基于状态内部的值派生状态。你可以在每个组件内部计算派生状态,但这意味着代码的重复,这是不可接受的。为了避免这种情况,你可以创建一个外部函数或实用类来计算派生状态,这比重复代码要好。然而,Vuex 提供了 getter 函数,这样我们就可以在应用程序存储中编写派生状态代码,避免所有这些不必要的步骤。
例如,假设应用状态包含一个消息列表,并且当新消息添加到该列表时,它会被标记为未读。然后我们可以编写一个getter函数,返回所有未读消息:
const store = new Vuex.Store({
state: {
messages: [
{ id: 1, text: 'First message', read: true },
{ id: 2, text: 'Second message', read: false },
],
},
getters: {
unreadMessages(state) {
return state.messages.filter(message => !message.read);
},
},
});
getter函数也接收所有getters作为第二个参数:
getters: {
// ...
unreadCounter(state, getters) => {
return getters.unreadMessages.length;
}
}
我们现在可以使用getter函数更新未读消息的示例:
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
messages: [
{ id: 1, text: 'First message', read: true },
{ id: 2, text: 'Second message', read: false },
],
},
getters: {
unreadMessages(state) {
return state.messages.filter(message => !message.read);
},
unreadCounter(state, getters) {
return getters.unreadMessages.length;
},
},
});
const NumUnreadMessages = {
template: `<div>Unread: {{ unreadCounter }}</div>`,
computed: {
unreadCounter() {
return this.$store.getters.unreadCounter;
},
},
};
new Vue({
el: '#app',
store,
components: { NumUnreadMessages },
template: `
<div class="app">
<num-unread-messages></num-unread-messages>
</div>
`,
});
你可以在本书的 Git 仓库中找到以下示例的代码,位于名为chapter-2/unread-messages/unread-with-getters.html的文件中。
Getter函数也可以接收参数,这使得它们在执行有关状态的查询时非常有用。为了接收参数,getter函数必须返回一个接收参数的函数。看看以下示例:
getters: {
// ...
getMessageById(state) {
return (id) => {
return state.messages.find(msg => msg.id === id);
}
}
}
至于状态,有一个mapState辅助函数。对于getters,有一个mapGetters辅助函数。
mapGetters辅助函数
mapGetters辅助函数简单地将存储getters映射到本地计算属性:
const NumUnreadMessages = {
template: `<div>Unread: {{ unreadCounter }}</div>`,
computed: Vuex.mapGetters(['unreadCounter']),
};
关于mapState,我们可以使用一个数组来列出所有我们想要映射到相应计算属性的getters属性。
如果计算属性的名称与getter名称不同,你可以使用一个对象而不是一个数组:
...mapGetters({
// map `this.numUnread` to `store.getters.unreadCounter`
numUnread: 'unreadCounter'
你可以在本书的 Git 仓库中找到这个更新示例的代码,位于名为chapter-2/unread-messages/unread-with-getters-and-mapgetters.html的文件中。
使用变更更改应用程序状态
到目前为止,我们只看到了如何检索应用状态。现在是时候介绍变更了。使用变更是你唯一可以更改状态的方法。如果你还记得,Flux 只允许动作变更状态。在 Vuex 中,动作被分为动作和变更。我们将在稍后介绍动作——在这里,我们将专注于变更以及我们如何使用 Vuex 来更改状态。
为了更改状态,你需要提交一个变更。变更类似于事件:你声明变更,这是一种事件类型,并将变更链接到一段代码,这就像事件处理器。从这个角度来看,Vuex 可以被视为EventBus模式的演变,该模式在第一章中讨论,用 Flux、Vue 和 Vuex 重新思考用户界面,作为 MVC 组件之间通信的可能解决方案。
变更(Mutations)是在提供给Vuex.Store(...)函数的config对象的mutations部分声明的:
const store = new Vuex.Store({
state: {
messages: []
},
mutations: {
addNewMessage (state, msgText) {
// mutating the state
state.messages.push({text: msgText, read: false});
}
}
})
提交一个变更
你不能直接调用突变处理函数。相反,你可以使用store.commit(mutationName, payload)提交突变。例如:
store.commit('addNewMessage', 'A message');
这将在应用程序状态中的messages数组中添加一条消息。
payload参数可以是原始类型或具有突变所需所有属性的对象。
你也可以使用对象风格的提交,如下例所示:
store.commit({
type: 'addNewMessage',
content: 'A message'
});
由于突变会改变响应式状态,因此建议遵循以下最佳实践:
-
初始化状态的所有属性,以便它们代表应用程序的初始状态
-
确保当你修改或添加新属性到状态时,
Vue将检测到修改,如本章反应性部分所述。
以下是一个完整的示例,其中用户可以添加消息并查看消息列表:
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
messages: [],
},
mutations: {
addNewMessage(state, msgText) {
state.messages.push({ text: msgText, read: false });
},
},
});
const MessageList = {
template: `<ul>
<li v-for="message in messages">{{message.text}}</li>
</ul>
`,
computed: Vuex.mapState(['messages']),
};
const MessageEditor = {
template: `<div>
<input type="text" v-model="message">
<button @click="addMessage">Add message</button>
</div>
`,
data() {
return {
message: '',
};
},
methods: {
addMessage() {
this.$store.commit('addNewMessage', this.message);
},
},
};
new Vue({
el: '#app',
store,
components: { MessageList, MessageEditor },
template: `
<div class="app">
<message-editor></message-editor>
<message-list></message-list>
</div>
`,
});
使用常量字符串枚举突变类型
每次你想提交突变时,都需要编写你想要执行突变类型的类型。在代码中分散字符串被认为是不良实践,并具有以下缺点:
-
这是有风险的:在输入突变类型时可能会出现拼写错误或错误的字母大小写。
-
重命名类型很困难,因为你必须搜索代码中该字符串的所有出现
-
不清楚突变来自哪个模块
由于这些原因,最好使用常量字符串来定义所有突变类型。使用大写字母可以防止错误的字母大小写,而使用常量变量可以让大多数编辑器突出显示出现。此外,编辑器通常提供一种重命名变量的方式,例如字符串常量,这使得重命名突变变得容易。
突变类型应该在包含应用程序所有可能突变(或如果应用程序分为模块,则为模块的所有可能突变)的文件中定义。这样,更容易理解应用程序状态可能受到的所有变化。实际上,如果你记得,Vuex 承诺状态将以可预测的方式改变。假设一个新程序员参与你的项目。你可以坐在他们旁边,开始解释应用程序的功能。你很快会发现,他们可以在没有你的帮助下理解用户执行操作时发生的事情。实际上,他们只需要跟随从在Vue组件内部触发的动作到相应的状态突变的流程。
以下是一个说明如何使用常量突变类型的示例:
// mutation-types.js
export const ADD_NEW_MESSAGE = 'addNewMessage';
// store.js
import Vuex from 'vuex';
import { ADD_NEW_MESSAGE } from './mutation-types';
const store = new Vuex.Store({
state: { ... },
mutations: {
[ADD_NEW_MESSAGE] (state, msgText) {
state.messages.push({text: msgText, read: false});
}
}
});
突变必须是同步的
使用 Vuex 时的重要规则是突变处理函数必须是同步的。不幸的是,这不能通过 JavaScript 语言强制执行,因此 Vuex 不能确保你遵循此规则。这意味着这是一项最佳实践,在编写应用程序代码时必须遵循。
以下是一个违反此规则的示例:
// Violation of rule "mutations handlers must be synchronous"
mutations: {
updateBookDetailsById(state, partialBookData) {
state.currentBook = partialBookData;
const {bookId} = partialBookData;
api.getBookDetailsById(bookId, (bookDetails) => {
state.currentBook = bookDetails;
});
}
}
在这个例子中,首先使用书籍的一些部分数据设置state.currentBook,然后,当服务器提供请求的书籍详细信息时,state.currentBook状态会使用所有书籍的详细信息进行更新。
尽快显示书籍数据是一个好主意。我们不希望用户在服务器提供所有请求的信息之前看到空白页面。但是异步性必须在其他地方处理——更确切地说,在action内部。
但在前面的例子中可能会发生什么?在最佳情况下,书籍的部分数据会被显示,然后几秒钟后,所有书籍的详细信息都会显示出来。但是api.getBookDetailsById(...)可能需要比预期更长的时间,甚至可能失败。在这些最后的情况下,结果将是一个不一致的应用程序状态。如果state.currentBook在服务器提供书籍详细信息之前被用户修改了怎么办?
为了避免这些问题,当提交突变时,应用程序状态必须以同步方式从一个定义良好的状态移动到另一个定义良好的状态。
mapMutations 辅助函数
对于状态和获取器,有一个辅助函数可以节省我们一些按键。
可以使用mapMutations辅助函数重构用户可以添加消息的示例,如下所示:
const MessageEditor = {
template: `<div>
<input type="text" v-model="message">
<button @click="addNewMessage(message)">Add message</button>
</div>
`,
data() {
return {
message: '',
};
},
methods: Vuex.mapMutations([
// Payload is also supported
'addNewMessage'
]),
};
在这种情况下,mapMutations(...)创建了一个addNewMessage方法,当执行时,它会调用this.$store.commit(ADD_NEW_MESSAGE, payload)。
你可以在chapter-2/add-message文件夹中找到前面例子的代码,包括和不包括mapMutations。
在动作中提交突变
如前所述,Vuex 将 Flux 动作分为突变和动作。突变必须是同步的,因此动作是编写异步代码的地方。想法是突变是定义良好的状态修改,动作提交突变以更改应用程序状态。例如,一个动作可以请求从服务器获取一些数据,当服务器响应时,使用它刚刚获得的数据提交一个突变。
简而言之,动作可以通过提交突变来改变状态,并且可以执行异步操作。
动作声明
让我们看看如何在Vuex存储中声明动作:
const store = new Vuex.Store({
state: {
messages: [],
},
mutations: {
addNewMessage(state, msgText) {
state.messages.push({ text: msgText, read: false });
},
},
actions: {
addMessage(context, msgText) {
API.addMessage(msgText).then(() => {
context.commit('addNewMessage', msgText);
});
},
},
});
与用于突变的类似方式,要声明一个动作,你需要在提供给Vuex存储的config对象的actions部分中编写动作方法。
动作接收一个context对象和动作有效负载。context对象包含一个commit(...)方法和state属性,它是应用程序状态。
在前面的例子中,addMessage(...)动作将消息文本发送到一个假设的服务器,并在服务器响应后,提交相应的突变。
分发一个动作
如果你记得,Flux 有一个单一的分发器,将动作分发到每个存储。Vuex 也是这样,只是有一个单一的存储,它也是一个分发器。这意味着可以使用存储来分发动作,如下面的代码所示:
store.dispatch('addNewMessage', 'A message');
在以下代码中,我将 添加消息 示例更新为使用 addMessage 动作而不是直接使用 addNewMessage 变异:
Vue.use(Vuex);
// Server API mock
const API = {
addMessage: () => Promise.resolve()
};
const store = new Vuex.Store({
state: {
messages: [],
},
mutations: {
addNewMessage(state, msgText) {
state.messages.push({ text: msgText, read: false });
},
},
actions: {
addMessage(context, msgText) {
API.addMessage(msgText).then(() => {
context.commit('addNewMessage', msgText);
});
},
},
});
const MessageList = {
// ...
};
const MessageEditor = {
template: `<div>
<input type="text" v-model="message">
<button @click="addMessage">Add message</button>
</div>
`,
data() {
return {
message: '',
};
},
methods: {
addMessage() {
this.$store.dispatch('addMessage', this.message);
},
},
};
new Vue({
// ...
});
您现在可能正在想,肯定有一些辅助函数可以派发动作...事实上,确实有!
mapActions 辅助函数
与其他辅助函数类似,mapActions 辅助函数可以在 Vue 组件的 methods 部分中使用。语法与其他辅助函数相同:
const MessageEditor = {
template: `<div>
<input type="text" v-model="message">
<button @click="addMessage(message)">
Add message
</button>
</div>
`,
data() {
return {
message: '',
};
},
methods: Vuex.mapActions(['addMessage']),
// Or you can use the Object syntax
// methods: Vuex.mapActions({ addMessage: 'addMessage' }),
};
使用模块实现更好的可扩展性
Vuex 的单一状态树可以被分割成模块。当应用程序变得更大,并且您想要将应用程序分割成功能组时,这很有用。这样做还可以让您在页面加载时只加载核心功能,并允许您稍后加载其他功能。这样,您可以大大减少加载时间,尤其是在连接速度慢或应用程序在低端手机上运行时。JavaScript 虚拟机解析所有 JavaScript 代码需要时间,因此提供一个包含所有应用程序代码的单个大型文件将需要几秒钟来解析,给移动用户留下应用程序运行缓慢且沉重的印象。
在 Vue/Vuex 中结合使用的一个好工具是 webpack,它允许您将应用程序分割成包。一个 webpack 包可以包含一个或多个 Vuex 模块,一个模块可以在另一个模块之后加载。您可以在 webpack.js.org/ 找到更多关于 webpack 的信息。
应用程序状态树可以被分割成模块,每个模块又可以分割成子模块。让我们看看如何编码实现这一点:
const subModule = {
state: { ... },
mutations: { ... },
actions: { ... }
};
const moduleA = {
state: { ... },
mutations: { ... },
actions: { ... },
getters: { ... },
modules: {
sub: subModule
}
};
const moduleB = {
state: { ... },
mutations: { ... },
actions: { ... }
};
const store = new Vuex.Store({
modules: {
a: moduleA,
b: moduleB
}
});
store.state.a // -> `moduleA`'s state
store.state.a.sub // -> `subModule`'s state
store.state.b // -> `moduleB`'s state
如您所见,传递给 new Vuex.store({}) 的对象只是您应用的根模块,在根模块或任何其他模块内部,您可以声明其他模块。
模块本地状态
传递给 mutations、actions 和 getters 的状态对象是本地模块状态。这样,子模块不需要知道它位于另一个模块内部。
但如果一个子模块想要访问父模块呢?rootState 是在 actions 和 getters 中提供的,这样你就可以从 rootState 导航到所需的模块。让我们看看怎么做:
const moduleA = {
// ...
actions: {
incrementIfOddOnRootSum ({ state, commit, rootState }) {
if ((state.count + rootState.count) % 2 === 1) {
commit('increment');
}
}
},
getters: {
sumWithRootCount (state, getters, rootState) {
return state.count + rootState.count;
}
}
}
重要的是要注意,在动作内部,rootState 是传递给动作的参数 context 的一个属性,而在获取器内部,rootState 作为第三个参数传递。
带有命名空间的模块
不同模块之间可能存在名称冲突。为了避免这种情况,并创建可重用的模块,你可以将模块的 namespaced 属性设置为 true。以下是一个 namespaced 模块的示例:
modules: {
auth: {
namespaced: true,
state: { ... }, // Already nested, not affected by namespace
mutations: {
setLogged() {...} // -> commit('auth.setLogged')
},
actions: {
login(){...} // -> dispatch('auth/login')
},
getters: {
logged() {...} // -> getters['auth/logged']
}
};
当 namespaced 属性设置为 true 时,模块内部的代码不会改变。改变的是想要使用另一个 namespace 模块的代码。看看以下示例:
modules: {
foo: {
namespaced: true,
getters: {
someGetter (state, getters, rootState, rootGetters) {
getters.someOtherGetter // -> 'foo/someOtherGetter'
rootGetters.someOtherGetter // -> 'someOtherGetter'
},
someOtherGetter: state => { ... }
},
actions: {
someAction ({dispatch, commit, getters, rootGetters}) {
getters.someGetter // -> 'foo/someGetter'
rootGetters.someGetter // -> 'someGetter'
dispatch('someOtherAction'); //->'foo/someOtherAction'
// -> 'someOtherAction'
dispatch('someOtherAction', null, { root: true });
commit('someMutation') // -> 'foo/someMutation'
// -> 'someMutation'
commit('someMutation', null, { root: true })
},
someOtherAction (ctx, payload) { ... }
}
}
}
要提交另一个模块的突变或分发动作,你需要将 { root: true } 作为第三个参数添加。还有一个 rootGetters 参数,它被提供给 actions 处理器或 getters 函数。
最后,当使用 Vuex 辅助函数时,你需要按照以下方式指定命名空间:
computed: {
...mapState('some/nested/module', {
a: state => state.a,
b: state => state.b
})
},
methods: {
...mapActions('some/nested/module', [
'foo',
'bar'
])
}
或者,你可以使用 createNamespacedHelpers(nameSpace) 函数创建命名空间辅助函数,该函数返回一个对象中的所有辅助函数。这些辅助函数绑定为你提供的第一个参数的命名空间。以下是如何使用 createNamespacedHelpers 的示例:
const { mapState, mapActions } =
Vuex.createNamespacedHelpers('some/nested/module');
export default {
computed: {
// look up in `some/nested/module`
...mapState({
a: state => state.a,
b: state => state.b
})
},
methods: {
// look up in `some/nested/module`
...mapActions([
'foo',
'bar'
])
}
};
在本章末尾,你将找到一个将 namespace 参数设置为 true 的两个模块的示例。
动态模块注册
使用 store.registerModule(...) 方法在 Vuex store 创建后注册模块是可能的。以下是如何注册模块的示例:
// register a module `myModule`
store.registerModule('myModule', {
// ...
})
// register a nested module `nested/myModule`
store.registerModule(['nested', 'myModule'], {
// ...
})
这在模块异步加载时特别有用。
// Loading module asynchronously
// index.js
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
currentView: 'initial'
},
});
Vue.component('initial', {
template: '<div>initial</div>',
});
import ('./loaded-later-moudle.js').then((module) => {
module.default(store);
});
new Vue({
el: '#app',
template: '<component :is="$store.state.currentView"/>',
store,
});
以下为 loaded-later-moudle.js 文件代码,该文件在 index.js 中加载:
// loaded-later-moudle.js
export default function moduleFactory(store) {
Vue.component('later', {
template: '<div>later</div>',
});
store.registerModule('loadedLater', {});
setTimeout(() => {
store.state.currentView = 'later';
}, 500);
}
在前面的示例中,根模块定义了一个 currentView 属性,该属性指向 initial 组件。使用动态导入语法,我们导入 loaded-later-moudle.js 文件,并在它加载后执行替换 currentView 值的模块代码,导致显示 later 组件。
以下示例在支持动态导入语法的浏览器中有效,或者你可以使用 webpack 来构建它。
你也可以使用 store.unregisterModule(moduleName) 方法注销动态加载的模块。静态加载的模块不能被注销。
模块重用
与 Vue 组件 类似,为了重用模块,状态声明需要是一个返回状态的函数,而不是一个普通对象。否则,状态对象将在所有模块用户之间共享。这种情况可能发生在以下两种情况中:
-
在多个 store 使用同一模块的情况下
-
在同一 store 中同一模块被注册超过一次
第一种情况不太可能发生,因为 Vuex 使用单个 store。此外,尽管每个 Vue 实例只能注册一个 store,但你总是可以创建多个 store。
如果模块是通用模块并且依赖于某些参数,第二种情况很可能会发生。与类可以有构造函数参数一样,可以使用带有参数的工厂方法创建模块。例如,假设你有两个相似的 RESTful API,并且创建了一个通用 API 模块,以便该模块可以用于这两个 API。在这种情况下,你将使用该模块的两个实例,每个 API 一个实例。
以下是如何创建一个 可重用 模块的示例:
const ReusableModule = {
state () {
return {
foo: 'bar'
}
},
// mutations, actions, getters...
};
在开发时启用严格模式
当 Vuex 处于严格模式时,如果单状态树在变异处理器外部被变异,它将抛出错误。这在开发时很有用,可以防止意外修改状态。要启用严格模式,只需将 strict: true 添加到存储配置对象中:
const store = new Vuex.Store({
// ...
strict: true
});
在生产环境中不应使用严格模式。严格模式在状态树上运行同步的深度观察者以检测不适当的变异,这可能会减慢应用程序的速度。为了避免每次创建生产包时都更改严格模式为 false,您应该使用在创建生产包时将严格值设置为 false 的构建工具。例如,您可以使用以下片段与 webpack 一起使用:
const store = new Vuex.Store({
// ...
strict: process.env.NODE_ENV !== 'production'
})
在 第三章,使用 Vuex 状态管理实现笔记应用中,您将了解到如何使用 webpack 启用/禁用严格模式。
使用 Vuex 时的表单处理限制
使用 Vue 的 v-model 功能与 Vuex 状态直接修改状态,这是被禁止的。
看看以下示例:
<input v-model="$store.state.message">
在这个例子中,$store.state 通过 v-model 直接变异,如果启用了严格模式,将导致抛出错误。
解决这个问题不止一种方法,我将向您展示我认为更好的方法:您可以使用一个可变的计算属性,当读取时访问 state 属性,并在设置时提交一个变异:
<input v-model="message">
// ...
computed: {
message: {
get () {
return this.$store.state.obj.message;
},
set (value) {
this.$store.commit('updateMessage', value);
}
}
}
使用可变的计算属性还允许您在提交相应的变异之前添加一些验证。
以下是一个提交变异的可能代码:
// ...
mutations: {
updateMessage (state, message) {
state.obj.message = message;
}
}
简单计数器示例
以下是一个非常简单的计数器示例,它在一个自包含的 HTML 文件中总结了 Vuex 的核心概念:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Simple counter example</title>
</head>
<body>
<div id="app"></div>
<script src="img/vuex.min.js"></script>
<script src="img/vue.min.js"></script>
<script>
Vue.use(Vuex);
// Sequential module
const sequential = {
namespaced: true,
state() {
return {
count: 1,
};
},
mutations: {
increment: state => state.count++,
decrement: state => state.count--
},
actions: {
increment: ({ commit }) => commit('increment'),
decrement: ({ commit }) => commit('decrement'),
},
getters: {
name: () => 'Sequential',
currentCount: state => state.count,
},
};
// FizzBuzz module that extends sequential module
// and redefine some functions.
const fizzBuzz = Object.assign({}, sequential, {
getters: {
name: () => 'FizzBuzz',
currentCount: state => {
const { count } = state;
let msg = '';
if (count % 3 === 0) msg += "Fizz";
if (count % 5 === 0) msg += "Buzz";
return `${count} ${msg}`;
},
},
});
// Application store with the two modules
const store = new Vuex.Store({
modules: {
sequential,
fizzBuzz,
},
});
// HTML template to show the result
const template = `
<div>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<span>{{name}} value: {{currentCount}}</span>
</div>
`;
// counter component
const counter = {
template,
computed: Vuex.mapGetters('sequential', [
'name',
'currentCount',
]),
methods: Vuex.mapActions('sequential', [
'increment',
'decrement',
]),
};
// fizzBuzzCounter component
const fizzBuzzCounter = {
template,
computed: Vuex.mapGetters('fizzBuzz', [
'name',
'currentCount',
]),
methods: Vuex.mapActions('fizzBuzz', [
'increment',
'decrement',
]),
};
// Vue instance with the store and the components
new Vue({
el: '#app',
store,
template: '<div><counter></counter>' +
'<fizzBuzzCounter></fizzBuzzCounter></div>',
components: {
counter,
fizzBuzzCounter,
},
});
</script>
</body>
</html>
上述示例显示了两个计数器。第一个只是增加或减少当前值。第二个,FizzBuzz,当计数器能被 3 整除时显示 Fizz,当能被 5 整除时显示 Buzz,当能同时被 3 和 5 整除时显示 FizzBuzz,如下面的截图所示:

图 2.2:FizzBuzz 计数器
在这个例子中,我创建了两个模块和两个组件,这些组件使用这些模块:一个用于 sequential 计数器,另一个用于 fizzBuzz 计数器。
之后,我创建了一个新的 Vue 实例,并将两个模块和组件添加到其中。这个示例的目的是向您展示如何使用 namespaced 模块,同时也作为一个完整但简单的 Vue 和 Vuex 结合的示例。
您可以在本书的 GitHub 仓库中的 chapter-2/fizzbuzz-counter 文件夹内找到示例源代码。
概述
在本章中,我们学习了 Vuex 框架。我们了解了其核心概念,并看到了 Vuex 如何集成到 Vue 应用程序中。最后,一个简单的计数器示例帮助我们全面了解。
现在是时候从简单的示例过渡到使用 Vuex 进行实际的应用程序开发。这就是第三章的主题,使用 Vuex 状态管理实现笔记应用,开发一个类似于 Google Keep 或 Evernote 的记笔记应用程序。
第三章:设置开发和测试环境
当我开始使用 Vue 时,我发现将 Vue 与 webpack 集成很困难。我还遇到了配置 Karma 以使用 webpack 进行测试的麻烦,更不用说弄清楚如何测试单个文件组件了!
由于这个原因,在接下来的几页中,您将指导配置一个既适合 Vue/Vuex 开发也适合测试的环境。我想您会发现下一页非常有用。
在本章中,您将:
-
使用 npm 设置开发环境。
-
安装、配置和使用 webpack。
-
安装、配置和使用 vue-loader。
-
使用 Karma + Jasmine 设置测试环境。
技术要求
您需要在系统上安装 Node.js。最后,为了使用本书的 Git 仓库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Vuex-Quick-Start-Guide/tree/master/chapter-3
查看以下视频,看看代码的实际效果:
设置开发环境
Vue 提供了一个名为 vue-cli 的工具,用于构建 Vue.js 项目。它还支持我们将要使用的 Web 打包工具 webpack。要安装 vue-cli,您只需在控制台中输入 npm install -g vue-cli 即可。
虽然使用 vue-cli 是启动新项目的最快方式,但在接下来的段落中,我将解释如何从头开始设置 Vue 项目。
我们将使用 npm(Node 包管理器)设置 Vue/Vuex 项目,解释每个步骤,并仅安装最小依赖项集,而不是 vue-cli,它会安装大量的 npm 包以提供通用项目配置。
通过使用本书的 GitHub 仓库并检查第一次提交,您可以观察我是如何设置 EveryNote 应用开发环境的。
使用 npm 为 Vue/Vuex 准备项目
要使用 npm,您需要安装 Node.js。您可以在 nodejs.org/ 上找到有关如何安装 Node.js 的信息。
创建 Vue/Vuex 项目的第一步是创建一个目录,并使用 npm 初始化它。打开控制台,输入以下命令:
mkdir notes-app
cd notes-app
npm init
npm init 命令会向您提出一些问题。每个问题都有一个默认值,通常是一个不错的选择。您可以为每个问题直接按 Enter。之后,它将创建一个包含您提供的值的 package.json 文件。我们将使用此文件来保存项目依赖项并创建一些对应用开发有用的命令。
让我们先安装 webpack 及其相关工具。Webpack 是一个模块打包器,它将帮助我们创建生产捆绑包,以及处理 Vue 单文件组件。如果您从未使用过 webpack,您应该谷歌搜索它以熟悉其核心原则。从现在开始,我将假设您已经具备 webpack 的基本知识。在控制台中输入以下命令:
npm install --save-dev webpack
npm install --save-dev webpack-cli
npm install --save-dev webpack-dev-server
npm install --save-dev html-webpack-plugin
npm install --save-dev clean-webpack-plugin
已创建一个名为node_modules的目录,您可以在该目录中找到我们刚刚安装的包的源代码。如果您使用Git,则应将node_modules放入.gitignore文件中。--save-dev选项将我们刚刚安装的五个包名写入package.json文件。这样,每次您输入npm install时,如果需要,所有保存的包都将被下载。
让我们看看这些包的作用:
-
webpack:将所有源文件打包到一个文件夹中,这个文件夹将包含相应的生产文件 -
webpack-dev-server:启动一个开发 HTTP 服务器,并帮助我们使用浏览器编写代码和调试 -
html-webpack-plugin:将帮助我们创建一个index.html文件,该文件将加载 webpack 捆绑包文件 -
clean-webpack-plugin:在捆绑项目时,webpack 会创建一个分发文件夹,该插件会删除这个文件夹
通过输入npm install,所有保存的包都将重新安装到node_modules文件夹中。这样,要初始化一个项目,您只需获取代码(例如使用git clone)并输入npm install,项目就准备好使用了。
让我们现在创建一个名为webpack.config.js的文件来配置 webpack。初始文件将如下所示:
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const webpack = require('webpack');
const config = {
entry: {
app: './src/main.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist/'),
},
plugins: [
new HtmlWebpackPlugin({ template: 'src/index.html' }),
new CleanWebpackPlugin(['dist']),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
}),
]
};
module.exports = config;
在前面的配置中,我们假设我们有一个名为src的文件夹,里面有一个index.html和一个main.js。
您可能也注意到了我使用了webpack.DefinePlugin:此插件允许您定义可以在项目中使用的常量。在这种情况下,我们定义process.env.NODE_ENV以区分开发环境和生产环境。
现在我们可以创建src文件夹:
mkdir src
完成此操作后,在src文件夹中创建index.html文件:
<!-- src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Packt: Vuex condensed</title>
</head>
<body>
Hello world
</body>
</html>
现在编写main.js文件,如下所示:
// src/main.js
console.log('Hello world');
我们现在可以使用 webpack 来构建项目。我们可以使用npx节点命令,该命令执行 npm 包的二进制文件,这样我们就不需要写出 webpack 可执行文件的完整路径:
npx webpack --config webpack.config.js
将创建一个名为dist的文件夹,您将在其中找到app.bundle.js和index.html文件。如果您打开 HTML 文件,您可能会注意到在body标签的末尾添加了以下行:
<script type="text/javascript" src="img/app.bundle.js"></script>
那是加载 webpack 捆绑包的html标签。我们将在稍后看到如何告诉 webpack 使用 Vue 单文件组件。
由于在开发过程中,逐个构建每个捆绑文件并在 HTTP 服务器中加载它们以查看更改并不方便,我们将使用webpack-dev-server:
npx webpack-dev-server --config webpack.config.js
如果你打开浏览器访问 http://localhost:8080/,你将看到一个包含“Hello world”字样的白色页面。如果你打开浏览器开发工具,你将看到相同的句子在浏览器控制台中打印出来。
让我们在 package.json 文件中添加一些运行 webpack 和 webpack-dev-server 的命令,这样我们就可以通过输入 npm run build 和 npm start 来运行它们:
{
"name": "notes-app",
"version": "1.0.0",
...
"scripts": {
"start": "webpack-dev-server --mode development",
"build": "cross-env NODE_ENV=production webpack --mode production"
},
...
"devDependencies": {
"clean-webpack-plugin": "⁰.1.17",
"html-webpack-plugin": "².30.1",
"webpack": "³.10.0",
"webpack-dev-server": "².11.0"
}
}
自从 webpack 4.0 以来,你需要指定你是在运行用于生产环境还是开发环境的 webpack。--mode 参数让你指定你正在构建哪个环境。最后,--config webpack.config.js 可以省略。
你可能已经注意到,我在 build 部分添加了 cross-env NODE_ENV=production。这是因为,当构建用于生产的应用程序时,我们需要将 NODE_ENV 环境变量设置为生产值。这样,我们可以使用以下代码来确定我们是否正在构建生产代码:
const debug = process.env.NODE_ENV !== 'production';
要使用 cross-env,你需要通过输入以下命令来安装它:
npm install --save-dev cross-env
最后,让我们安装 Vue 和 Vuex,并使用它们来检查一切是否配置正确。
在控制台中执行以下命令:
npm install --save vue vuex
按照以下方式编辑 main.js 文件:
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const store = new Vuex.Store({});
new Vue({
el: '#app',
store,
template:'<div>Hello Vue(x) World!</div>'
});
按照以下方式更新 index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Packt: Vuex condensed</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
现在输入 npm start,浏览器就会显示我们非常第一个 Vuex 应用程序:Hello Vue(x) World!
使用 vue-loader 为单文件组件
Vue.js 提供了一个 webpack 加载器,vue-loader,用于将单文件组件转换为 JavaScript 模块。要安装 vue-loader 和相关工具,请在控制台中输入以下命令:
npm install --save-dev vue-loader
npm install --save-dev vue-template-compiler
npm install --save-dev vue-style-loader
npm install --save-dev css-loader
npm install --save-dev file-loader
file-loader 用于导入外部文件,例如图片。其他包用于告诉 webpack 如何构建 .vue 文件内的所有部分。
让我们更新 webpack.config.js 以使用具有 .vue 文件扩展名的单文件组件:
// ...
const config = {
// ...
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
},
{
test: /\.(png|jpg|jpeg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
resolve: {
alias: {
vue$: 'vue/dist/vue.esm.js',
},
},
// ...
};
// ...
在配置文件中的 rules 部分内,我们告诉 webpack 当源文件中导入文件时使用哪个加载器。在上面的代码中,我们配置了 webpack 为每个 .vue 文件使用 vue-loader,为 .css 文件使用 css-loader 和 vue-style-loader,以及为图片使用 file-loader。
为了测试一切是否已正确配置,我们将创建一个 app.vue 文件:
// src/app.vue
<template>
<div class="app">App <span class="version">v{{version}}</span></div>
</template>
<script>
export default {
computed: {
version() {
return this.$store.state.version;
}
}
};
</script>
<style>
.app {
font-family: "Times New Roman", Times, serif;
background-image: url("./background.jpg");
}
</style>
你需要一个 background.jpg 文件来构建前面的文件。只需将任何图片放入 src 文件夹中,并将其重命名为 background.jpg 即可。
此文件使用 Vuex.Store,Vue 单文件组件的三个部分——<template>、<script> 和 <style>——以及一个作为背景的图片。这样,我们将测试 vue-loader 及其相关包 Vuex.Store 和 file-loader 对背景图片的支持。
让我们现在更新 main.js 以使用 app.vue:
// src/main.js
import Vue from 'vue';
import Vuex from 'vuex';
import app from './app.vue';
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
version: '1.0.0'
}
});
new Vue({
el: '#app',
store,
template:'<app></app>',
components: {app}
});
重新启动 webpack-dev-server (npm start) 并打开 URL http://localhost:8080/,你将看到以下截图类似的内容:

图 3.1:使用 app.vue
配置测试环境
如果你正在为单文件组件配置 webpack,你可能会觉得有点棘手;第一次配置测试环境肯定是有难度的。
我们将使用 Karma 作为测试运行器,Jasmine 作为测试/断言框架,以及 Chrome 作为将运行所有测试的浏览器。
首先,让我们安装所有需要的工具:
npm install --save-dev karma karma-webpack
npm install --save-dev karma-chrome-launcher
npm install --save-dev jasmine-core karma-jasmine
然后,我们需要创建一个karma.conf.js文件,如下所示:
// Using webpack configuration
var webpackConfig = require('./webpack.config.js');
delete webpackConfig.entry; // No entry for tests
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
files: [
'test/**/*.spec.js'
],
exclude: [
],
preprocessors: {
'test/**/*.spec.js': ['webpack']
},
reporters: ['progress'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
concurrency: Infinity,
webpack: webpackConfig,
// avoid walls of useless text
webpackMiddleware: {
noInfo: true
}
})
};
上述配置将在test文件夹内运行所有以.spec.js结尾的文件。此外,我们还告诉 Karma 使用 webpack 预处理文件。
让我们在notes-app内部创建一个测试文件夹:
mkdir test
最后,我们将创建一个简单的测试,用于加载.vue文件并进行测试。
在test文件夹内部创建一个名为test-setup的文件夹,并将其放入一个dummy.vue文件中:
// test/test-setup/dummy.vue
<template>
<div class="app">{{msg}}</div>
</template>
<script>
export default {
data() {
return {msg: 'A message'};
}
};
</script>
创建一个名为dummy.vue.spec.js的测试文件:
// test/test-setup/dummy.vue.spec.js
import Vue from 'vue';
import Test from './dummy.vue';
describe('dummy.vue', function () {
it('should have correct message', function () {
expect(Test.data().msg).toBe('A message');
});
it('should render correct message', function () {
const vm = new Vue({
template: '<div><test></test></div>',
components: {
'test': Test
}
}).$mount();
expect(vm.$el.querySelector('.app').textContent)
.toBe('A message');
})
});
按照以下方式更新package.json:
"scripts": {
"test": "karma start",
"start": "webpack-dev-server --config webpack.config.js",
"build": "webpack --config webpack.config.js"
},
接下来,执行npm test来运行我们刚刚创建的测试。你应该在控制台看到以下类似的内容:
Chrome ... : Executed 2 of 2 SUCCESS (0.006 secs / 0 secs)
现在,我们已经准备好开始开发 EveryNote 网络应用程序了,这是下一章的主题。
摘要
在本章中,我们介绍了设置测试和开发环境所需的所有步骤,准备好使用 Vuex 和 Vue 单文件组件开始编码。此外,我们还添加了一些测试文件以确保一切配置正确。
第四章:使用 Vuex 状态管理编写 EveryNote 应用程序代码
在本章中,我们将从头开始开发一个名为 EveryNote 的记事应用程序。在章节的第一部分,我们将分析和设计应用程序,并为项目准备文件夹结构。
之后,我们将通过测试和代码逐步构建应用程序。这个应用程序将在编写本章的同时开发,提供一个现实世界的 Vuex 开发示例。
您可以通过克隆 https://github.com/PacktPublishing/-Vuex-Condensed Git 仓库来下载应用程序。本章的每个部分都有一个相应的 Git 标签,可以用来下载为该部分编写的代码。
在阅读本章时,您将学习以下内容:
-
利用 Vuex 功能设计和开发应用程序
-
在 Vue 组件中使用 Vuex
-
有效测试 Vue/Vuex 组件
-
使用操作来处理异步操作
技术要求
您需要在系统上安装 Node.js。最后,为了使用本书的 Git 仓库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Vuex-Quick-Start-Guide/tree/master/chapter-4
查看以下视频以查看代码的实际运行情况:
设计 EveryNote 网络应用程序
设计应用程序的一种方法是通过创建用户界面的原型。这样,您可以向利益相关者展示您的原型,讨论它们,相应地更新您的原型,并将它们重新提交给利益相关者。这可以在您开始开发之前完成。
EveryNote 应用程序将看起来像以下原型:

图 3.2:EveryNote 原型界面
应用程序将具有以下功能:
-
创建新笔记
-
显示所有笔记
-
更新现有笔记
-
删除笔记
-
将笔记保存到
LocalStorage
在基本功能实现后,我们还将添加两个更多功能:
-
在笔记中搜索
-
锁定笔记
在现实世界的应用程序中,您可能需要用户故事来更好地定义预期的行为,从而确定程序员应该编写的代码。这些故事可以接受测试,这种类型的测试称为验收测试。
在这种情况下,EveryNote 的功能简单且定义良好,因此我们可以先选择一个功能并开始开发。
应用程序结构
Vuex 提出了一种通用的应用程序结构,我们将采用。以下是其文件夹结构:
test # test folder
├── test_file.spec.js # a test file
└── ...
src # app main folder
├── index.html
├── main.js
├── api
│ └── ... # abstractions for making API requests
├── components
│ ├── App.vue
│ └── ...
└── store
├──index.js #here we assemble modules and export the store
├── actions.js # root actions
├── mutations.js # root mutations
└── modules
├── module_a.js # a module
└── module_b.js # another module
我们现在将通过向本章开头创建的 notes-app 文件夹中添加一些文件来创建项目框架。
首个要创建的文件是 index.html。对于任何 Vue.js 应用程序,我们需要将 Vue/Vuex 应用的根容器放在 body 中,如下所示:
<!-- src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Packt: Vuex condensed EveryNote</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
第二个文件是main.js。它包含启动应用 Vue.js 部分的代码:
// src/main.js
import Vue from 'vue';
import App from './components/App.vue';
import store from './store';
new Vue({
el: '#app',
store,
render: h => h(App),
});
现在 Vue 应用已经准备好了,我们可以通过在store文件夹内创建index.js来向其中添加 Vuex:
// src/store/index.js
import Vuex from 'vuex';
import Vue from 'vue';
Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production';
const store = new Vuex.Store({
state: {},
strict: debug,
});
export default store;
最后,我们按照以下方式创建EveryNote应用的根 Vue 组件:
// src/components/App.vue
<template>
<div class="app">EveryNote app</div>
</template>
<script>
export default {};
</script>
<style>
.app {
font-family: "Times New Roman", Times, serif;
background-image: url("background.jpeg");
}
</style>
克隆book仓库,并使用git checkout step-0_project-scaffold来查看此步骤的所有项目文件。
现在项目骨架已经准备好了,我们可以开始编写第一个功能了。
开发 EveryNote 应用
在接下来的段落中,我将使用测试驱动开发来开发应用。你不需要了解 TDD 就能理解我将要做什么。你首先会看到一个断言代码应该做什么的测试,然后,紧接着,你会看到实现。
但为什么这本书要使用 TDD(测试驱动开发)呢?
一个原因是,我认为通过阅读测试代码中对代码行为的断言来理解代码的意图比从实现代码中推断其行为要容易。
另一个原因是,在编写组件的同时测试组件比有一个(无聊的)关于测试组件的章节要容易理解。
使用待办事项列表来帮助开发过程
我发现将待办事项列表写入文件对于提醒我需要做什么很有用。我还发现记录需要处理的事情的疑问和简单笔记很有帮助。
这个待办事项列表是一个简单的.txt文件,随着时间的推移会发生变化,并希望当应用完成时它是空的。我还把这个文件放在Git 修订版下。
初始的To-do列表看起来像这样:
To-do:
Show all notes*
Create new notes
Update an existing note
Delete a note
Save notes to LocalStorage
Extra:
Search among notes
Pin a note
Done:
我使用*符号来标记当前正在开发的功能。
显示笔记列表
我将首先显示笔记列表,因为其他功能依赖于它。另一个可能的起始功能是创建新笔记的能力。
为了显示笔记列表,我们需要将该列表添加到应用的Vuex.Store中。然后我们需要一个使用该存储来显示笔记的 Vue 组件。
第一个测试是关于在应用的主存储中定义笔记列表:
// test/store/store.spec.js
import store from '../../src/store';
describe('EveryNote main store', () => {
it('should have a list of notes', () => {
expect(Array.isArray(store.state.noteList)).toBe(true);
});
});
接下来,定义实现:
// src/store/index.js
import ...
// ...
const store = new Vuex.Store({
state: {
noteList: [],
},
strict: debug,
});
...
从现在开始,你将首先看到一个详细说明组件测试的框架,紧接着是一个带有代码实现的框架。你将在本章后面提供关于测试驱动开发的描述。现在,重要的是你要理解 TDD 有一个节奏:一个测试,一段生产代码,一个测试,一段生产代码,等等。
这也被称为红、绿、重构:
-
红:你编写一个小测试,执行它的结果是测试失败——你会在测试控制台中看到红色。
-
绿色:你以最简单的方式使测试通过——你会在测试控制台中看到绿色。在这个步骤中允许复制代码。
-
重构:如果你觉得有必要,你会移除代码重复并提高代码质量。
下一步是创建一个 Vue 组件noteList来显示笔记列表。
测试代码:
// test/components/NoteList.spec.js
import Vue from 'vue';
import Vuex from 'vuex';
import NoteList from '../../src/components/NoteList.vue';
describe('NoteList.vue', () => {
let store;
let noteList;
function newNoteListCmp() {
const Constructor = Vue.extend(NoteList);
return new Constructor({
store,
}).$mount();
}
beforeEach(() => {
Vue.use(Vuex);
noteList = [];
store = new Vuex.Store({
state: { noteList },
});
});
it('should expose store.noteList', () => {
const noteListCmp = newNoteListCmp();
expect(noteListCmp.notes).toBe(noteList);
});
it('should cycle through noteList', () => {
noteList.push({});
noteList.push({});
const noteListCmp = newNoteListCmp();
const contents =
noteListCmp.$el.querySelectorAll('.content');
expect(contents.length).toBe(2);
});
it('should render notes inside noteList', () => {
const title = 'Note title';
const content = 'Note content';
noteList.push({ title, content });
const noteListCmp = newNoteListCmp();
const { $el } = noteListCmp;
const titleEl = $el.querySelector('.title');
const contentEl = $el.querySelector('.content');
expect(titleEl.textContent).toBe(title);
expect(contentEl.textContent).toBe(content);
});
});
应用代码:
// src/components/NoteList.vue
<template>
<div class="container">
<div v-for="note in notes">
<div class="title">{{note.title}}</div>
<div class="content">{{note.content}}</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: mapState({
notes: 'noteList',
}),
};
</script>
<style>
</style>
尽管我把所有的这些测试放在一起以提高可读性,但我并不是先写三个测试,然后写代码。我是先写第一个测试,然后写代码,然后写下一个测试,以此类推。记住红绿重构模式!
为了编写NoteList代码,我需要三个测试:
-
第一个测试检查是否存在一个名为
notes的计算属性,它暴露state.store.NoteList -
第二个测试确保
notes中的每个笔记都在模板部分被渲染 -
最后,最后一个测试确保笔记的标题和内容被渲染
此外,还有一些代码用于设置测试环境,以便模拟商店并创建组件。每个要测试的项目都应该被隔离。这意味着我们不能使用真实的商店,我们需要为每个要测试的组件提供一个模拟的。
有一个框架,vue-test-utils,可以用来测试 Vue 组件。我决定不使用它,以便保持本书中的示例简单。你可能想在编写你的应用程序时使用它。
我们现在可以继续到下一个功能,但在继续之前,我首先想看到一些笔记实际上在浏览器中显示。为了实现这一点,我们可以暂时在商店中添加两个笔记,并将NoteList组件添加到App.vue中。
测试代码:
// src/store/index.js
import Vuex from 'vuex';
import Vue from 'vue';
Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production';
const store = new Vuex.Store({
state: {
noteList: [
{ title: 'title A', content: 'content 1' },
{ title: 'title B', content: 'content 2' },
],
},
strict: debug,
});
export default store;
应用代码:
// src/components/App.vue
<template>
<div class="app">
<div>EveryNote app</div>
<note-list></note-list>
</div>
</template>
<script>
import NoteList from './NoteList.vue';
export default {
components: {
NoteList,
},
};
</script>
<style>
.app {
font-family: "Times New Roman", Times, serif;
background-image: url("background.jpeg");
}
</style>
下面的截图是结果:

图 3.3:笔记列表
目前,结果看起来很丑;在所有主要功能实现后,我将添加一些 CSS 使其看起来更好。
你可以通过输入以下命令来下载此阶段的代码:
git checkout step-1_note-list
创建新笔记
到目前为止,待办事项列表看起来是这样的:
To-do:
Create new notes*
- NoteEditor component
- Update current note mutation
- Add note to noteList mutation
- Add note action
Update an existing note
Delete a note
Save notes to LocalStorage
Extra:
Search among notes
Pin a note
Done:
Show all notes
- Add note list to the store
- Note list vue component
-- Add a temporary note list to the store
接下来我将实现的新功能是创建新笔记的能力。为此功能,我们需要一个NoteEditor组件,一个名为currentNote的商店属性,一个名为addNote的操作,以及两个突变:UPDATE_CURRENT_NOTE和ADD_NOTE。
理念是,当用户在笔记编辑器中编写时,currentNote商店属性会更新。当他点击添加笔记按钮时,会触发addNote操作,导致新笔记被添加到笔记列表中。让我们将currentNote属性添加到应用商店中。
测试代码:
// test/store/store.spec.js
import store from '../../src/store';
describe('EveryNote main store', () => {
it('should have a list of notes', () => {
expect(Array.isArray(store.state.noteList)).toBe(true);
});
it('should have currentNote property', () => {
const { state } = store;
expect(state.currentNote.title).not.toBe(undefined);
expect(state.currentNote.content).not.toBe(undefined);
});
});
应用代码:
// src/store/index.js
//...
const store = new Vuex.Store({
state: {
noteList: [
{ title: 'title A', content: 'content 1' },
{ title: 'title B', content: 'content 2' },
],
currentNote: { title: '', content: '' },
},
mutations,
strict: debug,
});
你可能会想知道为什么我添加了一个测试只是为了验证currentNote字段是否在应用商店中。这里的想法是,我并不是在编写测试来测试应用程序是否正常工作——我是在编写测试来编写生产代码。为了修改应用代码的任何一行,我需要一个测试来证明我正在编写生产代码。
这是罗伯特·C·马丁(Robert C. Martin,又称 Uncle Bob)提出的三个 TDD 规则中的第一个:
- 除非它能让失败的单元测试通过,否则不允许编写任何生产代码
另外两个如下:
-
你不允许再编写任何足以失败的单元测试,编译失败也是失败
-
你不允许再编写任何更多足以通过一个失败的单元测试的生产代码
所以为什么我在向存储中添加两个假笔记并修改 App.vue 以使用 NoteList 组件时没有编写任何测试?因为那是临时代码,不是生产代码。在应用程序完成之前,我将删除这些修改。
现在 currentNote 已经定义,我可以编写一个 UPDATE_CURRENT_NOTE 突变。
测试代码:
// test/store/mutations.spec.js
import { mutations, types } from '../../src/store/mutations';
describe('EveryNote root mutations', () => {
it('should update current note', () => {
const updateCurrentNote
= mutations[types.UPDATE_CURRENT_NOTE];
const state = { currentNote: { title: '', content: '' } };
const newNote = { title: 'title', content: 'some text' };
updateCurrentNote(state, newNote);
expect(state.currentNote).toEqual(newNote);
});
});
应用代码:
// src/store/mutations.js
export const types = {
UPDATE_CURRENT_NOTE: 'UPDATE_CURRENT_NOTE',
};
export const mutations = {
types.UPDATE_CURRENT_NOTE {
state.currentNote = { title, content };
},
};
接下来,创建 NoteEditor 组件。
测试代码:
// test/components/NoteEditor.spec.js
import Vue from 'vue';
import Vuex from 'vuex';
import NoteEditor from '../../src/components/NoteEditor.vue';
import { types, mutations } from '../../src/store/mutations';
import actions from '../../src/store/actions';
const { UPDATE_CURRENT_NOTE } = types;
describe('NoteEditor component', () => {
let store;
let currentNote;
function newNoteEditorCmp() {
const Constructor = Vue.extend(NoteEditor);
store = new Vuex.Store({
state: { currentNote, noteList: [] },
mutations,
actions,
});
return new Constructor({
store,
}).$mount();
}
beforeEach(() => {
Vue.use(Vuex);
currentNote = { title: 'title', content: 'content' };
});
it('should expose currentNote.content as content', () => {
const editorCmp = newNoteEditorCmp();
expect(editorCmp.content).toBe(currentNote.content);
});
it('should expose currentNote.content setter', () => {
const editorCmp = newNoteEditorCmp();
store.commit = jasmine.createSpy('commit spy');
const newContent = 'A new content';
editorCmp.content = newContent;
const expected = {
title: currentNote.title,
content: newContent,
};
expect(store.commit)
.toHaveBeenCalledWith(UPDATE_CURRENT_NOTE, expected);
});
it('should expose currentNote.title as title', () => {
const editorCmp = newNoteEditorCmp();
expect(editorCmp.title).toBe(currentNote.title);
});
it('should expose currentNote.title setter', () => {
const editorCmp = newNoteEditorCmp();
store.commit = jasmine.createSpy('commit spy');
const newTitle = 'A new title';
editorCmp.title = newTitle;
const expected = {
title: newTitle,
content: currentNote.content,
};
expect(store.commit)
.toHaveBeenCalledWith(UPDATE_CURRENT_NOTE, expected);
});
it('should render current note inside the editor', () => {
const editorCmp = newNoteEditorCmp();
const { $el } = editorCmp;
const contentEl = $el.querySelector('.content');
const titleEl = $el.querySelector('.title');
expect(contentEl.value).toBe(currentNote.content);
expect(titleEl.value).toBe(currentNote.title);
});
});
应用代码:
// src/components/NoteEditor.vue
<template>
<div>
<input v-model="title" type="text" class="title"/>
<input v-model="content" type="text" class="content"/>
</div>
</template>
<script>
import { types } from '../store/mutations';
const { UPDATE_CURRENT_NOTE } = types;
export default {
computed: {
content: {
get() {
return this.$store.state.currentNote.content;
},
set(value) {
const newContent = {
title: this.title,
content: value,
};
this.$store.commit(UPDATE_CURRENT_NOTE, newContent);
},
},
title: {
get() {
return this.$store.state.currentNote.title;
},
set(value) {
const newContent = {
title: value,
content: this.content,
};
this.$store.commit(UPDATE_CURRENT_NOTE, newContent);
},
},
},
};
</script>
<style></style>
为了编写 NoteEditor 组件,我测试了计算属性 content 和 title 是否正确链接到 $store.state.currentNote,以及这些属性是否在 template 部分中使用。
与 NoteList 组件的测试一样,test 文件的第一部分只是创建 test 下的组件的一些代码。从现在起,我将避免重复这部分代码。
下一步是创建 addNote 动作和相应的突变,以便我可以在用户按下添加笔记按钮时更新 NoteEditor 来分发此动作。以下是 ADD_NOTE 突变。
测试代码:
// test/store/mutations.spec.js
import { mutations, types } from '../../src/store/mutations';
describe('EveryNote root mutations', () => {
it('should update current note', () => {
// ...
});
it('should add a note to noteList', () => {
const ADD_NOTE = mutations[types.ADD_NOTE];
const state = { noteList: [] };
const newNote = { title: 'title', content: 'some text' };
ADD_NOTE(state, newNote);
expect(state.noteList['0']).toBe(newNote);
});
});
应用代码:
// src/store/mutations.js
export const types = {
UPDATE_CURRENT_NOTE: 'UPDATE_CURRENT_NOTE',
ADD_NOTE: 'ADD_NOTE',
};
export const mutations = {
types.UPDATE_CURRENT_NOTE {
state.currentNote = { title, content };
},
types.ADD_NOTE {
state.noteList.push(aNote);
},
};
以下是 addNote 动作测试:
// test/store/actions.spec.js
import actions from '../../src/store/actions';
import { types } from '../../src/store/mutations';
describe('EveryNote root actions', () => {
it('should have addNote action', () => {
const { addNote } = actions;
const mockContext = {
commit: jasmine.createSpy('commit'),
};
const aNote = {};
addNote(mockContext, aNote);
expect(mockContext.commit)
.toHaveBeenCalledWith(types.ADD_NOTE, aNote);
});
});
以下是应用程序代码:
// src/store/actions
import { types } from './mutations';
export default {
addNote({ commit }, aNote) {
commit(types.ADD_NOTE, aNote);
},
};
最后,我可以更新 NoteEditor 来分发 addNote 动作,并看到笔记列表已更新。首先,让我们更新 NoteEditor。
测试代码:
// test/components/NoteEditor.spec.js
import // ...
const { UPDATE_CURRENT_NOTE } = types;
describe('NoteEditor component', () => {
let store;
let currentNote;
function newNoteEditorCmp() {
// ...
}
// ...
it('should have addNote method', () => {
const editorCmp = newNoteEditorCmp();
spyOn(store, 'dispatch');
editorCmp.addNote();
expect(store.dispatch)
.toHaveBeenCalledWith('addNote', currentNote);
});
it('should not add empty notes', () => {
const editorCmp = newNoteEditorCmp();
spyOn(store, 'dispatch');
currentNote.title = '';
currentNote.content = '';
editorCmp.addNote();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('should reset title and content on addNote', () => {
const editorCmp = newNoteEditorCmp();
editorCmp.addNote();
expect(editorCmp.title).toBe('');
expect(editorCmp.content).toBe('');
});
});
应用代码:
// src/components/NoteEditor.vue
<template>
<div>
<input v-model="title" type="text" class="title"
placeholder="title"/>
<input v-model="content" type="text" class="content"
placeholder="content"/>
<button @click="addNote">Add note</button>
</div>
</template>
<script>
import { types } from '../store/mutations';
const { UPDATE_CURRENT_NOTE } = types;
export default {
computed: {
content: {
// ...
},
title: {
// ...
},
},
methods: {
addNote() {
if (this.title !== '' || this.content !== '') {
const newNote = {
title: this.title,
content: this.content,
};
this.$store.dispatch('addNote', newNote);
}
this.title = '';
this.content = '';
},
},
};
</script>
现在,让我们向存储中添加 actions:
// src/store/index.js
import // ...
import actions from './actions';
// ...
const store = new Vuex.Store({
state: { // ... },
mutations,
actions,
strict: debug,
});
并将 NoteEditor 添加到 App.vue
// src/components/App.vue
<template>
<div class="app">
<div>EveryNote app</div>
<note-editor></note-editor>
<note-list></note-list>
</div>
</template>
<script>
import NoteList from './NoteList.vue';
import NoteEditor from './NoteEditor.vue';
export default {
components: {
NoteList,
NoteEditor,
},
};
</script>
<style>
// ...
</style>
在为组件添加了一些 CSS 并稍微重新设计应用程序之后,它现在看起来像以下图示:

图 3.4:EveryNote 重新设计
你可以通过输入以下内容在此阶段下载代码:
git checkout step-2_create-notes
删除现有笔记
我接下来要实现的功能是删除笔记的能力。以下是被更新的 待办 列表:
To-do:
Delete a note*
- delete action
- delete mutation
- create Note component and use it in NoteList component
Update an existing note
Save notes to LocalStorage
Extra:
Search among notes
Pin a note
Done:
Show all notes
Create new notes
为了让用户能够删除笔记,我需要更新笔记框架以包含一个删除按钮,添加一个 deleteNote 动作,并添加一个 DELETE_NOTE 突变。最后,我将从 NoteList 中提取笔记框架代码并创建一个 Note 组件。让我们创建 DELETE_NOTE 突变。
测试代码:
// test/store/mutations.spec.js
import { mutations, types } from '../../src/store/mutations';
describe('EveryNote root mutations', () => {
// ...
it('should delete a note', () => {
const DELETE_NOTE = mutations[types.DELETE_NOTE];
const aNote = {};
const state = { noteList: [aNote] };
DELETE_NOTE(state, aNote);
expect(state.noteList.length).toBe(0);
});
it('should NOT delete a note if not inside noteList', ()=>{
const DELETE_NOTE = mutations[types.DELETE_NOTE];
const aNote = {};
const state = { noteList: [aNote] };
const anotherNote = {};
DELETE_NOTE(state, anotherNote);
expect(state.noteList.length).toBe(1);
});
});
应用代码:
// src/store/mutations.js
export const types = {
UPDATE_CURRENT_NOTE: 'UPDATE_CURRENT_NOTE',
ADD_NOTE: 'ADD_NOTE',
DELETE_NOTE: 'DELETE_NOTE',
};
export const mutations = {
// ...
types.DELETE_NOTE {
const index = state.noteList.indexOf(aNote);
if (index >= 0) {
state.noteList.splice(index, 1);
}
},
};
然后,让我们将 deleteNote 添加到动作中。
测试代码:
// test/store/actions.spec.js
import actions from '../../src/store/actions';
import { types } from '../../src/store/mutations';
describe('EveryNote root actions', () => {
// ..
it('should have deleteNote action', () => {
const { deleteNote } = actions;
const mockContext = {
commit: jasmine.createSpy('commit'),
};
const aNote = {};
deleteNote(mockContext, aNote);
expect(mockContext.commit)
.toHaveBeenCalledWith(types.DELETE_NOTE, aNote);
});
});
应用代码:
// src/store/actions
import { types } from './mutations';
export default {
// ...
deleteNote({ commit }, aNote) {
commit(types.DELETE_NOTE, aNote);
},
};
现在让我们重构 NoteList 组件以使用一个名为 Note 的新组件:
// src/components/NoteList.vue
<template>
<div class="container">
<note v-for="(note, i) in notes" :note="note" :key="i">
</note>
</div>
</template>
<script>
// ...
</script>
<style scoped>
// ...
然后,我们将笔记渲染的测试从 NoteList 移动到 Note 组件:
// test/components/Note.spec.js
import Vue from 'vue';
import Vuex from 'vuex';
import Note from '../../src/components/Note.vue';
describe('Note.vue', () => {
let note;
let store;
beforeEach(() => {
Vue.use(Vuex);
note = { title: 'title', content: 'content' };
});
function newNoteCmp() {
const Constructor = Vue.extend(Note);
store = new Vuex.Store({
state: {},
});
return new Constructor({
propsData: { note },
store,
}).$mount();
}
it('should render a note', () => {
const { title, content } = note;
const noteCmp = newNoteCmp();
const { $el } = noteCmp;
const titleEl = $el.querySelector('.title');
const contentEl = $el.querySelector('.content');
expect(titleEl.textContent.trim()).toBe(title);
expect(contentEl.textContent.trim()).toBe(content);
});
});
接下来,让我们编写新的 Note 组件:
// src/components/Note.vue
<template>
<div class="note">
<div class="title">{{note.title}}</div>
<div class="content" v-text="note.content"></div>
</div>
</template>
<script>
export default {
props: ['note'],
};
</script>
<style scoped>
/* ... */
</style>
最后,我们可以在 Note 组件中添加一个删除按钮,当点击时将分发 deleteNote 动作:
// test/components/Note.spec.js
import Vue from 'vue';
import Vuex from 'vuex';
import Note from '../../src/components/Note.vue';
describe('Note.vue', () => {
let note;
let store;
beforeEach(() => {
Vue.use(Vuex);
note = { title: 'title', content: 'content' };
});
function newNoteCmp() {
// ...
}
it('should render a note', () => {
// ...
});
it('should emit deleteNote on delete tap', () => {
const noteCmp = newNoteCmp();
spyOn(store, 'dispatch');
noteCmp.onDelete();
expect(store.dispatch)
.toHaveBeenCalledWith('deleteNote', note);
});
});
以下是更新后的 Note.vue 代码,这将使测试通过:
// src/components/Note.vue
<template>
<div class="note">
<div class="title">{{note.title}}</div>
<div class="content" v-text="note.content">
</div>
<div class="icons">
<img class="delete" src="img/delete.svg"
@click="onDelete"/>
</div>
</div>
</template>
<script>
export default {
props: ['note'],
methods: {
onDelete() {
this.$store.dispatch('deleteNote', this.note);
},
},
};
</script>
<style scoped>
// ...
</style>
为了运行前面的代码,你需要一个 delete.svg 文件。你可以在本书的 Git 仓库中找到它,或者你可以使用另一张图片。
你可以通过输入以下内容在此阶段下载代码:
git checkout step-3_delete-notes
更新现有笔记
为了编辑现有的笔记,我们可以重用 NoteEditor 组件。目前,这个组件与主存储的 currentNote 属性相关联,但我们可以使用一个属性来传递要编辑的笔记,从而消除其对主存储的依赖。这种泛化在开发 Vuex 应用程序时很常见,通常会导致两种类型的组件:
-
“哑”组件:这些不改变或处理应用程序状态;它们只是通过属性接收输入并派发事件
-
智能组件:这些作为“哑”组件的容器;它们处理子组件之间的交互,并依赖于 Vuex 元素,如应用程序状态和动作
“哑”组件应该设计成可重用的,而“智能”组件应该设计成与应用程序依赖。
NoteEditor 可以重构为一个“哑”组件,让它的父组件将其链接到应用程序的状态。
我将这些考虑记录在“待办”列表上:
To-do:
Update an existing note*
- re-use NoteEditor with an existing note
Considerations:
- NoteList could be refactored into a dumb component
Save notes to LocalStorage
Extra:
Search among notes
Pin a note
Done:
Show all notes
Create new notes
Delete a note
如你所见,我还在考虑将 NoteList 转换为“哑”组件,以便它可以用来显示不同的笔记列表,例如已固定笔记或匹配特定搜索关键字的笔记。
让我们从重构 NoteEditor 为一个“哑”组件开始:
// test/components/NoteEditor.spec.js
import Vue from 'vue';
import NoteEditor from '../../src/components/NoteEditor.vue';
describe('NoteEditor component', () => {
let note;
function newNoteEditorCmp() {
const Constructor = Vue.extend(NoteEditor);
return new Constructor({
propsData: { note },
}).$mount();
}
beforeEach(() => {
note = { title: 'title', content: 'content' };
});
it('should init title and content to note prop', () => {
const editorCmp = newNoteEditorCmp();
expect(editorCmp.title).not.toBe(undefined);
expect(editorCmp.title).toBe(note.title);
expect(editorCmp.content).not.toBe(undefined);
expect(editorCmp.content).toBe(note.content);
});
it('should have onEditDone method ' +
'that emits the edited note', () => {
const editorCmp = newNoteEditorCmp();
spyOn(editorCmp, '$emit');
const newNote = { title: 'a', content: 'b' };
editorCmp.title = newNote.title;
editorCmp.content = newNote.content;
editorCmp.onEditDone();
expect(editorCmp.$emit)
.toHaveBeenCalledWith('editDone', newNote);
});
it('should not emit empty notes', () => {
note.title = '';
note.content = '';
const editorCmp = newNoteEditorCmp();
spyOn(editorCmp, '$emit');
editorCmp.onEditDone();
expect(editorCmp.$emit).not.toHaveBeenCalled();
});
it('should reset title, content after onEditDone', () => {
const editorCmp = newNoteEditorCmp();
editorCmp.onEditDone();
expect(editorCmp.title).toBe('');
expect(editorCmp.content).toBe('');
});
});
如你所见,测试不再需要使用 Vuex,关于 currentNote 状态属性的测试将移至其容器,该容器将在本组件开发之后进行。
此外,我决定不对 NoteEditor.vue 中的 <template> 部分进行测试,因为视图经常变化,我不想在更改 UI 的某个部分时让测试拖慢我的进度。在我看来,最好将关于 UI 的单元测试数量减少到非常少或没有。记住,TDD 是关于编写代码,而不是测试现有代码。当某个 UI 部分稳定下来且不太可能很快发生变化时,你可以在该部分上编写自动化测试。如果你同意我不测试视图的观点,请记住避免在 Vue 组件的 <template> 部分放置太多逻辑。
新的 NoteEditor 实现如下所示:
// src/components/NoteEditor.vue
<template>
<div class="container">
<div class="centered">
<input v-model="title" type="text" class="title"
placeholder="title"/><br>
<textarea v-model="content" class="content"
rows="3" placeholder="content"></textarea><br>
<div class="buttons">
<button @click="onEditDone" class="done">Done</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['note'],
data() {
return {
title: this.note.title,
content: this.note.content,
};
},
methods: {
onEditDone() {
if (this.title !== '' || this.content !== '') {
this.$emit('editDone', {
title: this.title,
content: this.content,
});
}
this.title = '';
this.content = '';
},
},
};
</script>
<style scoped>
// ...
</style>
我们现在需要一个容器来容纳此组件,即 App.vue 组件,以及将 NoteEditor 连接到 currentNote 状态属性的代码:
// test/components/App.spec.js
import Vue from 'vue';
import Vuex from 'vuex';
import App from '../../src/components/App.vue';
describe('App.vue', () => {
let store;
let noteList;
let currentNote;
function newAppCmp() {
const Constructor = Vue.extend(App);
store = new Vuex.Store({
state: { currentNote, noteList },
});
return new Constructor({
store,
}).$mount();
}
beforeEach(() => {
Vue.use(Vuex);
noteList = [];
currentNote = { title: '', content: '' };
});
it('should update store.currentNote ' +
'on onAddDone event', () => {
const app = newAppCmp();
spyOn(app.$store, 'dispatch');
const aNote = {};
app.onAddDone(aNote);
expect(app.$store.dispatch)
.toHaveBeenCalledWith('addNote', aNote);
});
});
以下是将测试通过的代码:
// src/components/App.vue
<template>
<div class="app">
<div class="header">EveryNote</div>
<div class="body">
<note-editor :note="$store.state.currentNote"
@editDone="onAddDone"/>
<note-list/>
</div>
</div>
</template>
<script>
import NoteList from './NoteList.vue';
import NoteEditor from './NoteEditor.vue';
export default {
components: {
NoteList,
NoteEditor,
},
methods: {
onAddDone(note) {
this.$store.dispatch('addNote', note);
},
},
};
</script>
<style scoped>
// ...
</style>
最后,我们需要 editNote 和 updateNote 动作以及相应的突变。第一个将设置当前正在编辑的笔记,最后一个将持久化我们刚刚编辑的笔记的更改。
测试代码:
// src/store/actions
import { types } from './mutations';
export default {
addNote({ commit }, aNote) {
commit(types.ADD_NOTE, aNote);
},
deleteNote({ commit }, aNote) {
commit(types.DELETE_NOTE, aNote);
},
editNote({ commit }, aNote) {
commit(types.EDIT_NOTE, aNote);
},
updateNote({ commit }, aNote) {
commit(types.UPDATE_NOTE, aNote);
},
};
应用代码:
// src/store/mutations.js
export const types = {
UPDATE_CURRENT_NOTE: 'UPDATE_CURRENT_NOTE',
ADD_NOTE: 'ADD_NOTE',
DELETE_NOTE: 'DELETE_NOTE',
EDIT_NOTE: 'EDIT_NOTE',
UPDATE_NOTE: 'UPDATE_NOTE',
};
export const mutations = {
// ...
types.EDIT_NOTE {
const index = state.noteList.indexOf(aNote);
if (index >= 0) {
state.editIndex = index;
state.editNote = state.noteList[index];
}
},
types.UPDATE_NOTE {
const index = state.editIndex;
if (index >= 0) {
state.editNote = null;
state.noteList.splice(index, 1, aNote);
state.editIndex = -1;
}
},
};
测试与其他我们已完成的行为测试类似,所以我不重复它们。
最后一步是将 Note 组件更新为当用户点击笔记的编辑图标时触发 editNote 动作:
// src/components/Note.ue
<template>
<div class="note">
<div class="title">{{note.title}}</div>
<div class="content"
style="white-space: pre-line;" v-text="note.content">
</div>
<div class="icons">
<img class="edit" src="img/edit.svg"
@click="onEdit"/>
<img class="delete" src="img/delete.svg"
@click="onDelete"/>
</div>
</div>
</template>
<script>
export default {
props: ['note'],
methods: {
onDelete() {
this.$store.dispatch('deleteNote', this.note);
},
onEdit() {
this.$store.dispatch('editNote', this.note);
},
},
};
</script>
<style scoped>
// ...
</style>
之后,我们修改 App 组件,以便在 editNote 被分发时显示要编辑的笔记:
// src/components/App.vue
<template>
<div class="app">
<div class="header">EveryNote</div>
<div class="body">
<note-editor :note="$store.state.currentNote"
@editDone="onAddDone"/>
<note-list/>
</div>
<div class="overlay" v-if="$store.state.editNote">
<note-editor class="note-editor" @editDone="onEditDone"
:note="$store.state.editNote"/>
</div>
</div>
</template>
<script>
import NoteList from './NoteList.vue';
import NoteEditor from './NoteEditor.vue';
export default {
components: {
NoteList,
NoteEditor,
},
methods: {
onAddDone(note) {
this.$store.dispatch('addNote', note);
},
onEditDone(note) {
this.$store.dispatch('updateNote', note);
},
},
};
</script>
<style scoped>
// ...
</style>
你可以通过输入以下内容来下载此阶段的代码:
git checkout step-4_edit-notes
其他功能
以下是更新的 待办 列表,显示了我们已经完成的工作和剩余的工作:
To-do:
Save notes to LocalStorage
Considerations:
- NoteList could be refactored into a dumb component
Extra:
Search among notes
Pin a note
Done:
Show all notes
Create new notes
Delete a note
Update an existing note
我不会在本书中讨论其他功能,因为我认为现在已经很清楚 Vuex 的开发工作以及如何测试 Vuex 应用程序。你可以在本书的代码仓库中找到完整的应用程序代码,仓库地址为 https://github.com/PacktPublishing/-Vuex-Condensed。
当将笔记持久化到 localStorage 时,我们不仅会保存笔记列表,还会使用 Vuex 插件来保存所有应用程序状态。你将在本书的最后一章中了解到这一点,该章节将介绍 vuex-persistedstate 插件,这是一个用于使用 localStorage 持久化 Vuex 应用程序状态的插件。
概述和注意事项
我们实现了所谓的 CRUD 功能,即创建、读取、更新和删除。
在这个阶段,EveryNote 应用看起来如下:

图 3.5:带有 CRUD 操作的 EveryNote 应用
当轻触铅笔图标时,笔记编辑器会打开。以下是在编辑模式下的应用程序截图:

图 3.6:EveryNote 编辑对话框
为了达到这个阶段的 EveryNote 开发,我们使用了 TDD 方法以及增量设计和开发过程。正如我在引言中承诺的,我是在编写这一章的同时开发这个应用的。这意味着我没有遵循最优的开发路径——实际上,我不得不更改一些代码,甚至删除一些测试。这在开发过程中是正常的。无论如何,在设计阶段花更多的时间,以避免架构错误并在开发阶段节省时间。事实上,良好的设计是基础,但在这个阶段也不要过于详细。有时,人们认为 TDD 跳过了设计。这完全错误——事实上,你会为错误付出双倍代价:一次在生产代码中,一次在测试代码中。
与远程服务器同步
在开发 EveryNote 应用时,我决定为每个突变编写一个操作。如果一个操作只提交一个突变,你可能避免编写这个操作,并在组件内部提交相应的突变。像 Google Keep 一样,EveryNote 应用可以通过将笔记持久化到远程服务器来增强功能,这样用户就可以从任何电脑或移动设备上读取他的或她的笔记。在这种情况下,操作就派上用场,因为它们可以执行异步操作以保持应用程序状态与服务器同步。
我不会实现将应用程序状态持久化到远程服务器的代码,但我将向您展示当应用加载时如何从远程服务器获取笔记列表的示例。
策略是当App.vue加载时,我们可以触发一个loadNotesFromServer动作,该动作将保存的状态更新到localStorage中,并包含从服务器获取的笔记列表。
让我们从loadNotesFromServer动作测试开始:
// test/store/actions.spec.js
import actions from '../../src/store/actions';
import { types } from '../../src/store/mutations';
import api from '../../src/api/api-mock';
describe('EveryNote root actions', () => {
// ...
it('should have loadNotesFromServer action', (done) => {
const { loadNotesFromServer } = actions;
const mockContext = {
commit: jasmine.createSpy('commit'),
};
const aNote = {};
spyOn(api, 'fetchAllNotes').and.returnValue(Promise.resolve([aNote]));
loadNotesFromServer(mockContext).then(() => {
expect(mockContext.commit)
.toHaveBeenCalledWith(types.ADD_NOTE, aNote);
done();
});
});
});
动作代码如下:
// src/store/actions.js
import { types } from './mutations';
import api from '../api/api-mock';
export default {
// ...
loadNotesFromServer({ commit }) {
return api.fetchAllNotes().then((notes) => {
notes.forEach(note => commit(types.ADD_NOTE, note));
});
},
};
这是一个简单的实现——在实际情况下,你可能需要将服务器获取的笔记列表与保存在localStorage中的笔记合并。
然后,我们修改App.vue以分发动作:
// src/components/App.vue
<template>
// ..
</template>
<script>
import NoteList from './NoteList.vue';
import NoteEditor from './NoteEditor.vue';
export default {
created() {
this.$store.dispatch('loadNotesFromServer');
},
components: {
NoteList,
NoteEditor,
},
methods: {
// ...
},
};
</script>
<style scoped>
// ...
</style>
最后,我们创建了一个 API 的模拟实现,将两个笔记从存储移动到模拟 API:
// src/api/api-mock.js
export default {
fetchAllNotes() {
return Promise.resolve([
{ title: 'title A', content: 'content 1' },
{ title: 'title B', content: 'content 2' },
]);
},
};
你可以在这一阶段通过输入以下内容下载代码:
git checkout step-5_remote-mock-server
摘要
在本章中,我们开发了EveryNote应用程序,了解了 Vuex 的概念,并探讨了如何在真实应用程序的开发中使用 Vuex。我们还学习了 TDD 的基础知识,并看到了 Vue/Vuex 元素如何进行测试。
但关于调试呢?即使有测试,有时也需要调试。下一章将解释如何使用浏览器开发者工具调试 Web 应用程序,以及如何使用vue-devtools轻松调试 Vue/Vuex 应用程序。
第五章:Vuex 应用程序调试
通过使用测试驱动开发来开发你的应用程序,你可以显著减少调试时间。尽管如此,仍然会有一些代码无法按预期工作或微小的错误隐藏在你的代码中。
幸运的是,浏览器提供了开发者工具来帮助前端开发者调试他们的应用程序,Vue 提供了 vue-devtools。
在下一章中,我们将学习以下内容:
-
使用 vue-devtools
-
使用内置的日志插件
为了理解这一章,你需要具备基本的 Chrome 开发者工具知识。
使用 vue-devtools
Vue.js 提供了 vue-devtools 工具,帮助程序员调试 Vue 应用程序。Vuex 通过增强此工具来跟踪每个提交的突变。
你可以将此实用程序作为 Chrome 或 FireFox 的扩展程序安装,或者你可以导航到 github.com/vuejs/vue-devtools 以获取安装说明。
通过输入 npm start 启动 EveryNote 应用程序,使用已安装 vue-devtools 的 Google Chrome 打开 http://localhost:8080/,然后按 F12。
组件检查器
如果你选择 Chrome 开发者工具中的元素标签页,你会看到如下截图所示的内容:

图 4.1:Chrome 开发者工具,元素标签页
在元素标签页下方,你可以看到 EveryNote 应用程序当前的 DOM 树。选中的 div 是包含第二个笔记的元素。
通过将 DOM 结构映射回我们刚刚编写的 Vue 组件,你可以理解选中的元素是组件 Note 的根元素。难道不是看到 <Note> 而不是详细的 Note DOM 元素结构更好吗?
现在选择 Vue 标签页,你会看到如下截图所示的内容:

图 4.2:Chrome 开发者工具中的 Vue 标签页。
一眼就能看到应用程序的结构,而不是组件的 DOM 元素。通过将鼠标移到组件上,相应的元素将在 HTML 页面中突出显示。如果你按下选择按钮 (
),你可以在 HTML 页面中选择一个元素,并在组件树中突出显示。
通过在“过滤组件”框下方的树中选择一个元素,你也会在props框架中看到它的属性,如下截图所示:

图 4.3:道具框架
如果选中的组件与 Vuex 有绑定,它将出现在同一个框中。接下来的截图是一个示例:

图 4.4:Vuex 组件绑定
这样,就可以轻松地在页面内的 Vue 组件之间移动,并查看它们的状态。
我们将要看到的下一个功能是事件检查器。
事件检查器
观察组件状态很有用,但如果我们还能记录组件之间的交互会更好。实际上,vue-devtools 提供了另外两个功能:事件和 Vuex 变更记录。
在下面的截图中,您可以看到 vue-devtools 的过滤事件部分:

图 4.5:过滤事件部分
例如,我添加了一个笔记,因为这个动作,过滤事件标签页记录了在NoteEditor内部触发了editDone事件。它还显示了事件有效载荷。
最后,我们将探索一个 Vuex 专用标签页。
Vuex 时间旅行
通过按 Vue 按钮进入 Vuex 部分
,您将能够记录所有提交的 Vuex 变更。以下截图显示了此功能:

图 4.6:Vuex 时间旅行标签页
如您所见,应用程序加载后,已添加了两个笔记。这些笔记是由于由loadNotesFromServer动作触发的对假设服务器的虚假调用而添加的。
之后,我点击了第二个笔记的删除按钮。实际上,记录的第三个变更是DELETE_NOTE。您可以看到每个变更的状态和变更有效载荷,甚至可以撤销提交,如下面的截图所示,我正准备撤销DELETE_NOTE变更:

图 4.7:撤销 DELETE_NOTE 变更
由于撤销操作,应用程序状态恢复到之前的变更,应用程序再次显示第二个笔记,如下面的截图所示:

图 4.8:撤销 DELETE_NOTE 变更后的应用程序状态。
当您想要调试一个动作及其对应的变更时,撤销变更的能力非常有用:您可以使用 Chrome 开发者工具中的源代码标签页在动作代码中设置断点,然后执行和回滚变更,直到满足需求。请注意,如果您在提交变更后使用 Chrome 调试器重新启动动作代码,则应用程序状态已经更改,第二次执行会受到新状态的影响。相反,如果您撤销提交,则可以安全地重新执行一段代码,而无需重新加载整个页面。
最后,状态框架上方的两个按钮让您可以从剪贴板导出和导入应用程序状态。
启用 Vuex 内置的日志插件
Vuex 提供了一个内置插件来记录每个变更。它可以按如下方式添加到应用程序存储中:
// src/store/index.js
import createLogger from 'vuex/dist/logger';
// ...
const debug = process.env.NODE_ENV !== 'production';
const plugins = debug ? [createLogger({})] : [];
const store = new Vuex.Store({
state: {
// ...
},
mutations,
actions,
strict: debug,
plugins,
});
对于EveryNote应用程序的结果输出如下:

图 4.8:Vuex 内置日志插件
如您在先前的截图中所见,它不仅记录了变更名称,还记录了前一个和下一个状态。
你可以在这一阶段通过输入以下命令来下载代码:
git checkout step-6_vuex-built-in-logger
摘要
在本章中,我们介绍了 vue-devtools 的功能,并引入了 Vuex 内置的日志插件。但 Vuex 插件究竟是什么呢?
嗯,这就是第五章的主题,使用 Vuex 插件系统;在接下来的几页中,我们将学习 Vuex 插件是什么,以及我们如何编写一个自定义插件。
第六章:使用 Vuex 插件系统
在前面的章节中,我写到了使用 Vuex 插件系统持久化 EveryNote 应用程序状态的可能性。我们还了解了一个内置的记录器插件,用于记录每个突变。但是,Vuex 插件究竟是什么?我们如何编写一个自定义插件?
在以下页面中,您将:
-
了解 Vuex 插件系统
-
向 EveryNote 应用程序添加两个有用的插件
-
编写一个用于跟踪用户与您的应用程序交互的 Google Analytics 插件
-
开发一个撤销/重做插件
技术要求
您需要在系统上安装 Node.js。最后,为了使用本书的 Git 仓库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
查看以下视频以查看代码的实际效果:
理解 Vuex 插件系统
Vuex 插件是一个函数,它接受应用程序存储作为唯一参数,并可以订阅突变。
下面是一个插件示例:
const consolePlugin = (store) => {
store.subscribe((mutation, state) => {
// called after every mutation.
// The mutation comes in the format of { type, payload }.
console.log(mutation, state);
});
};
您可以将插件添加到存储中,如下所示:
const store = new Vuex.Store({
// ...
plugins: [consolePlugin]
});
与组件一样,插件不能直接更改状态;它们必须提交一个突变。
例如,假设我们想显示最后一次提交突变的时间。我们可以编写一个插件如下:
// src/store/plugins.js
// ...
const lastEditDate = (store) => {
store.subscribe((mutation) => {
if (mutation.type !== types.UPDATE_LAST_EDIT_DATE) {
store.commit(types.UPDATE_LAST_EDIT_DATE);
}
});
};
由于我们可以订阅每个突变并接收新状态,我们可以使用 localStorage 持久化应用程序状态。实际上,有一个名为 vuex-persistedstate 的插件正是这样做的。您可以在以下页面了解更多关于此插件的信息。
使用两个插件增强 EveryNote
如果您在 Google 上搜索 Vuex 插件,您可能会找到各种不同目的的插件。我选择了您可能在下一个 Vuex 项目中想要使用的两个插件。这些插件是:
-
vuex-persistedstate
-
vuex-router-sync
我们将使用第一个插件来使用 localStorage 保存 EveryNote 状态,这样每次浏览器页面重新加载时就不会丢失所有笔记。
如果您正在创建一个单页 Web 应用程序,强烈建议您使用第二个插件,即 vuex-router-sync。在这种情况下,您已经使用了 vue-router,vuex-router-sync 将同步当前路由,作为 Vuex 存储状态的一部分。
使用 vuex-persistedstate 保存应用程序状态
当前 EveryNote 应用程序显示两个假笔记,并且每次您重新加载页面时都会丢失新创建的笔记。通过添加 vuex-persistedstate 插件,笔记将使用 localStorage 保存。
要添加 vuex-persistedstate 类型,请输入以下内容:*
npm install --save vuex-persistedstate
之后,我们需要将其添加到插件列表中:
// src/store/index.js
// ...
import createPersistedState from 'vuex-persistedstate';
// ...
plugins.push(createPersistedState());
const store = new Vuex.Store({
state: {
// ...
},
mutations,
actions,
strict: debug,
plugins,
});
最后,我们需要从模拟 API 中删除两个假笔记:
// src/api/api-mock.js
export default {
fetchAllNotes() {
return Promise.resolve([]);
},
};
从现在开始,如果您添加一个笔记然后重新加载页面,那个笔记仍然会存在。
vuex-persistedstate 具有高度的可配置性,可以将应用程序状态持久化到每个同步存储。例如,如果您想当应用程序页面关闭时重置状态,您可以将其配置为使用 sessionStorage,而不是 localStorage。
您可以在以下位置找到更多信息:github.com/robinvdvleuten/vuex-persistedstate。
使用 vuex-router-sync 同步路由数据
如果您在应用程序中使用 vue-router,您可能还希望使用 vuex-router-sync,因为它在插件网站上说明,它 同步 vue-router 的当前 $route 作为 vuex 存储状态的一部分。
要安装此插件,请输入:
npm install vuex-router-sync --save
要将其添加到 EveryNote 应用程序中,您需要将 vue-router 添加到项目中:
npm install vue-router --save
并按如下方式修改 src/store/index.js:
// src/store/index.js
import { sync } from 'vuex-router-sync';
// ...
const store = new Vuex.Store({
state: {
// ...
},
mutations,
actions,
strict: debug,
plugins,
});
sync(store, router);
// ...
但它是如何工作的?每次路由变化时,此插件都会更新 store.state.route 属性。此属性由以下内容组成:
store.state.route.path // current path (string)
store.state.route.params // current params (object)
store.state.route.query // current query (object)
要更新存储属性,它提交一个 route.ROUTE_CHANGED 突变,其中 route 是 vuex-router-sync 插件模块的默认名称。
您可以更改它使用的模块名称如下:
sync(store, router, { moduleName: 'CustomRouteSyncModule' } );
为了以编程方式更改路由,请使用 vue-router;不要修改 store.state.route。
您可以在以下位置找到更多信息:github.com/vuejs/vuex-router-sync。
在以下页面中,我们将开发一个使用 vuex-router-sync 将页面浏览量发送到 Google 服务器的 Google Analytics 插件。
开发一个 Google Analytics 插件
在以下页面中,我将假设您熟悉 Google Analytics,并且已经正确配置了应用程序中的 Google Analytics 跟踪。如果不是这样,您可以 Google 它,了解它是如何工作的,然后回来这里。对基本概念的了解就足够您继续阅读以下页面。
第一步是将此 Google Analytics 跟踪代码片段添加到 index.html 文件中:
<!-- index.html -->
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="img/js?id=GA_TRACKING_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_TRACKING_ID');
</script>
之后,将有一个全局的 gtag(...) 函数可用于向 Google Analytics 服务器发送事件。
通常,应用程序会跟踪页面浏览量和一些事件。要向 Google Analytics 服务器发送事件,只需编写以下内容:
gtag('event', 'MUTATION_NAME');
使用前面的代码,我们可以编写一个插件,为每个突变发送一个 Analytics 事件,如下所示:
// src/store/plugins.js
// ...
export const googleAnalytics = (store) => {
store.subscribe((mutation) => {
gtag('event', mutation.type);
});
};
为了发送页面浏览量,我们可以利用每次位置变化时提交的 vuex-router-syncroute.ROUTE_CHANGED 突变。
我们可以相应地更新 Analytics 插件,如下所示:
// src/store/plugins.js
import analytics from '../gtag';
// ...
export const googleAnalytics = (store) => {
store.subscribe((mutation, state) => {
if (mutation.type === 'route/ROUTE_CHANGED') {
analytics.sendPageView(state.route.path);
} else {
analytics.sendEvent(mutation.type);
}
});
};
其中 analytics 对象类似于:
// src/gtag/index.js
const GA_TRACKING_ID = 'GA_TRACKING_ID';
class GtagAnalytics {
static sendEvent(action) {
gtag('event', action);
}
static sendPageView(pagePath) {
gtag('config', GA_TRACKING_ID, { page_path: pagePath });
}
}
export default GtagAnalytics;
您可能不想发送所有突变作为分析事件;在这种情况下,您可以创建一个包含您想要发送的突变类型的映射,或者一个包含您不希望发送的突变类型的列表。
您可以通过输入以下内容下载带有 Google Analytics 插件的 EveryNote 代码:
git checkout chapter-5/step-7_google-analytics-plugin
开发一个撤销/重做插件
我们刚刚编写的 Google Analytics 插件是 Vuex 插件系统如何被利用来向您的应用程序添加功能的一个好例子,而不需要触及应用程序核心代码。但是,对于更复杂的插件呢?Vuex 插件也适合更复杂的操作吗?嗯,当然,它是!在接下来的几页中,我们将开发一个撤销/重做插件,这仍然是一个简单但非平凡的例子。
我们可以利用这样一个事实:在使用 Vuex 系统时,我们有一个单一集中的状态,并且这个状态只能通过突变来修改。想法是每次状态被修改时都拍摄一个快照。然后,要回到突变历史,只需将当前状态设置为快照即可,这代表了一个突变发生之前的较旧状态。
让我们先创建一个插件,该插件注册了一个名为undoRedo的模块:
store.registerModule(moduleName, {
namespaced: true,
getters: {
canUndo() {}, // Tells if undo can be performed
canRedo() {}, // Tells if redo can be performed
},
state: {
currentPosition: 0, // Position in the history
snapshots: [], // Snapshots taken
},
mutations: {
[UNDO]() {}, // Mutation to undo last mutation
REDO {}, // Mutation to redo last mutation
[UPDATE_CURRENT_POSITION](){},//update currentPosition
[UPDATE_SNAPSHOTS](){}, //update snapshots
},
});
在前面的代码中,我们定义了两个状态属性:
-
currentPosition:这代表当前快照的索引。当提交撤销突变时,我们减少索引;当提交重做突变或其他突变时,索引增加。
-
snapshots:这是一个包含状态快照的数组。
之后,我们需要两个相应的突变来更新这些属性,以及一个UNDO和一个REDO突变,以便让插件客户端撤销或重做修改。
最后,我们提供了两个获取器,canUndo()和canRedo(),以公开插件的撤销/重做状态。
我们现在可以订阅突变,以便每次应用程序状态发生变化时都拍摄一个快照:
const undoRedoPlugin = (store) => {
function takeStateSnapshot(state) {
// ...
}
function restoreStateSnapshot(state, toRestore) {
// ...
}
store.subscribe(({ type }, state) => {
if (mutationsToExclude[type] === undefined) {
const index = state[moduleName].currentPosition + 1;
const snapshots = state[moduleName].snapshots.slice();
snapshots.length = index + 1;
snapshots[index] = takeStateSnapshot(state);
store.commit(currentPositionType, index);
store.commit(updateSnapshotType, snapshots);
}
});
store.registerModule(moduleName, {
// ...
});
};
当然,有一些突变必须排除,例如snapshots或currentPosition属性的突变,以及插件用户可能想要排除的突变,例如如果应用程序中使用了vuex-router-sync,则route/ROUTE_CHANGED。
我们现在可以按照以下方式实现撤销/重做突变:
store.registerModule(moduleName, {
namespaced: true,
getters: {
canUndo({ currentPosition }) {
return currentPosition >= 1;
},
canRedo({ currentPosition, snapshots }) {
return currentPosition < snapshots.length - 1;
},
},
state: {
currentPosition: 0,
snapshots: [takeStateSnapshot(store.state)],
},
mutations: {
UNDO {
if (store.getters[canUndoGetter]) {
state.currentPosition--;
const { snapshots } = state;
const snapShot = snapshots[state.currentPosition];
restoreStateSnapshot(store.state, snapShot);
}
},
REDO {
if (store.getters[canRedoGetter]) {
state.currentPosition++;
const { snapshots } = state;
const snapShot = snapshots[state.currentPosition];
restoreStateSnapshot(store.state, snapShot);
}
},
UPDATE_CURRENT_POSITION {
state.currentPosition = value;
},
UPDATE_SNAPSHOTS {
state.snapshots = value;
},
},
});
如您所见,前面的代码只是关于恢复正确快照的。
在拍摄快照时,不应考虑每个状态属性,并且每个快照必须是状态的副本。以下代码显示了这些概念:
function takeStateSnapshot(state) {
const toClone = {};
Object.keys(state).forEach((key) => {
if (statePropsToExclude[key] === undefined) {
toClone[key] = state[key];
}
});
return JSON.stringify(toClone);
}
function restoreStateSnapshot(state, toRestore) {
const clone = JSON.parse(toRestore);
Object.keys(clone).forEach((key) => {
state[key] = clone[key];
});
}
最后,我们可以提供一个工厂方法来创建和配置撤销/重做插件。以下是完全的插件代码:
// src/store/undo-redo-plugin.js
export default (options) => {
const moduleName = 'undoRedo' || options.moduleName;
const UNDO = 'undo';
const REDO = 'redo';
const UPDATE_CURRENT_POSITION = 'UPDATE_CURRENT_POSITION';
const UPDATE_SNAPSHOTS = 'UPDATE_SNAPSHOTS';
const undoType = `${moduleName}/${UNDO}`;
const redoType = `${moduleName}/${REDO}`;
const currentPositionType =
`${moduleName}/${UPDATE_CURRENT_POSITION}`;
const updateSnapshotType =
`${moduleName}/${UPDATE_SNAPSHOTS}`;
const canUndoGetter = `${moduleName}/canUndo`;
const canRedoGetter = `${moduleName}/canRedo`;
const statePropsToExclude = {
[moduleName]: '',
};
if (options.statePropsToExclude) {
options.statePropsToExclude.forEach((toExclude) => {
statePropsToExclude[toExclude] = '';
});
}
const mutationsToExclude = {
[undoType]: '',
[redoType]: '',
[currentPositionType]: '',
[updateSnapshotType]: '',
};
if (options.mutationsToExclude) {
options.mutationsToExclude.forEach((toExclude) => {
mutationsToExclude[toExclude] = '';
});
}
const undoRedoPlugin = (store) => {
function takeStateSnapshot(state) {
const toClone = {};
Object.keys(state).forEach((key) => {
if (statePropsToExclude[key] === undefined) {
toClone[key] = state[key];
}
});
return JSON.stringify(toClone);
}
function restoreStateSnapshot(state, toRestore) {
const clone = JSON.parse(toRestore);
Object.keys(clone).forEach((key) => {
state[key] = clone[key];
});
}
store.subscribe(({ type }, state) => {
if (mutationsToExclude[type] === undefined) {
const index = state[moduleName].currentPosition + 1;
const snapshots = state[moduleName].snapshots.slice();
snapshots.length = index + 1;
snapshots[index] = takeStateSnapshot(state);
store.commit(currentPositionType, index);
store.commit(updateSnapshotType, snapshots);
}
});
store.registerModule(moduleName, {
namespaced: true,
getters: {
canUndo({ currentPosition }) {
return currentPosition >= 1;
},
canRedo({ currentPosition, snapshots }) {
return currentPosition < snapshots.length - 1;
},
},
state: {
currentPosition: 0,
snapshots: [takeStateSnapshot(store.state)],
},
mutations: {
UNDO {
if (store.getters[canUndoGetter]) {
state.currentPosition--;
const { snapshots } = state;
const snapShot = snapshots[state.currentPosition];
restoreStateSnapshot(store.state, snapShot);
}
},
REDO {
if (store.getters[canRedoGetter]) {
state.currentPosition++;
const { snapshots } = state;
const snapShot = snapshots[state.currentPosition];
restoreStateSnapshot(store.state, snapShot);
}
},
UPDATE_CURRENT_POSITION {
state.currentPosition = value;
},
UPDATE_SNAPSHOTS {
state.snapshots = value;
},
},
});
};
return undoRedoPlugin;
};
您可以通过输入以下内容下载带有撤销/重做插件的 EveryNote 代码:
git checkout chapter-5/step-8_undo-redo-plugin
以这种方式实现的撤销/重做与不与其状态与服务器同步的应用程序配合得很好。通常,仅恢复先前状态是不够的,您还需要执行一个操作来更新服务器数据。例如,如果您撤销了一个删除的笔记,您需要将未删除的笔记数据发送到远程服务器。这意味着真正的撤销/重做功能是与应用程序相关的,并且我们编写的插件需要扩展以处理与远程服务器的同步。
使用承诺处理异步性
在一个真实的撤销/重做插件中,你可能会向服务器发送数据,这是一个异步操作。我们了解到必须在 Vuex 动作内部处理异步性。当你分发一个动作时,store.dispatch('anAction'),dispatch方法返回一个Promise。在接下来的页面中,我将解释如何使用Promise(一个相对较新的 JavaScript 特性)来处理异步操作。
在 JavaScript 中处理异步操作可能会很棘手。我见过一些因为程序员不知道如何处理异步代码而变得极其混乱的代码片段。
等待稍后可用的数据最糟糕的方式是轮询。永远不要这样做:
// Just don't use this way!
let dataFromServer;
// ...
const waitForData = () => {
if(dataFromServer !== undefined) {
doSomethingWith(dataFromServer);
} else {
setTimeout(waitForData, 100);
}
};
setTimeout(waitForData, 100);
上述例子可以使用回调函数重构:
api.getDataFromServer((dataFromServer) => {
// do something with dataFromServer
});
回调函数适用于简单的操作,但当你需要组合多个回调函数时,它们会很快变得难以管理。你有没有听说过“回调地狱”这个短语?
幸运的是,JavaScript 现在提供了Promise,这是一种处理异步操作简单的方法。使用Promise,前面的代码可以重写如下:
api.getDataFromServer().then(function success(dataFromServer){
// do something with dataFromServer
}, function fail(error) {
// Handle the error
});
如果你不太熟悉 promise,请在谷歌上搜索并学习它们。接下来的章节将解释 promise 如何连接或并行执行,在我看来,这仍然不是程序员们很好地理解的。
连接 promise
store.dispatch('action')函数返回一个 promise。这允许程序员在执行另一个操作之前等待一个动作完成。
让我们看看一个在另一个动作完成后分发动作的例子:
store.dispatch('action 1').then(() => {
return store.dispatch('action 2');
}).then(() => {
store.commit('mutation depending on action 1 and 2');
});
Promise的then(callback)方法返回另一个Promise,这个Promise将被回调函数返回的值解决。如果回调函数返回的值本身是一个Promise,它将等待这个第二个Promise完成。好吧,我知道——第一次听到这个概念时,这个概念听起来有点扭曲。我将在下面的例子中解释 promise 的连接:
// Creates a resolved promise with a return value 'A'
const p1 = Promise.resolve('A');
console.log('start');
// Chaining promises
p1
.then(result => result + 'B')
.then(result => asyncEcho(result + 'C')) // wait 1000 ms
.then(result => console.log(result))
console.log('end');
function asyncEcho(echoMsg) {
return new Promise(resolve => {
setTimeout(() => resolve(echoMsg), 1000);
});
}
结果输出如下:
start end
// after 1000 ms
ABC
首先,同步代码被执行,打印出start和end,然后执行 promise 链。在链的中间步骤,一个在 1000 毫秒后解决的 promise 从回调函数中返回。这使得链的最后一个then(...)在执行最后一个回调之前必须等待asyncEcho(...)promise 解决。
直到链式 promise 解决,代码执行会从一个then()移动到另一个。但是当一个 promise 被拒绝时会发生什么呢?让我们看看另一个例子:
// Creates a resolved promise
const p1 = Promise.resolve();
// Chaining promises
p1
.then(() => {console.log(1); return asyncFail();})
.then(() => console.log(2), // success
() => console.log('Fail 2')) // fail
.then(() => {console.log(3); throw 'An error';}, // success
() => console.log('Fail 3')) // fail
.then(() => console.log(4), // success
() =>{console.log('Fail 4');return Promise.reject()})
.catch(()=> console.log('Catch called'));
function asyncFail() {
return new Promise((resolve, reject) => {
setTimeout(reject, 1000);
});
}
结果输出如下:
1
Fail 2
3
Fail 4
Catch called
你得到了正确的输出吗?或者你期望在Fail 2行后看到Fail 3?这是一个常见的错误。只有当回调函数内部发生错误或返回一个被拒绝的 promise 时,链的下一步的失败回调才会执行。在其他所有情况下,执行的是下一个成功回调,即使前一步的失败回调正在执行。
以下图解释了这个概念:

图 2.1:链式调用 promises
promises 的并行执行
现在我们知道了如何链式调用 promises,我们也知道了如何链式调用 Vuex actions,因为store.dispatch(...)方法返回一个 promise。但如果我们想并行执行两个或更多操作并等待所有操作完成呢?Promise对象提供了一个Promise.all([p1, p2, ..., pn])方法,它返回一个 promise,在所有提供的 promises 都解决后解决,或者在提供的任何一个 promise 被拒绝时立即拒绝。让我们看一个例子:
const p1 = asyncEcho('A', 500);
const p2 = asyncEcho('B', 1000);
const e = asyncFail('E1', 100);
Promise.all([p1, p2]).then((values) =>
console.log('OK', values));
Promise.all([p1, p2, e]).then(() => {
console.log('this gets not executed');
}, (error) => {
console.log('Err', error);
});
function asyncEcho(echoMsg, delay) {
return new Promise(resolve => {
setTimeout(() => resolve(echoMsg), delay);
});
}
function asyncFail(error, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(error), delay);
});
}
输出如下:
Err E1 // After 100 ms
OK A, B // After 1000 ms
使用 promises 时的常见错误
最后,让我们看看使用Promise时犯的两个常见错误。
以下代码展示了当Promise构造函数回调被误解时会发生什么:**
console.log(1);
buggyExecuteLater(() => console.log(3));
console.log(2);
function buggyExecuteLater(callback) {
new Promise(() => callback());
}
// Output
// 1
// 3
// 2
传递给new Promise(callback)的回调函数是同步执行的。如果你想在当前 JavaScript 执行完成后立即安排某些事情,请使用Promise.resolve().then(callback)或setTimeout(callback, 0)。
以下代码展示了当程序员忘记返回一个被拒绝的 promise 时会发生什么:
function iMayFail() {
const rand = Math.random();
const successP = Promise.resolve();
const failP = Promise.reject();
return rand < 0.5 ? successP : failP;
}
function buggyToss() {
return iMayFail().then(
() => 'Success', // Success callback
() => 'Fail' // Fail callback
);
}
buggyToss().then(
result => console.log('Resolved ' + result),
result => console.log('Rejected ' + result)
);
// Output is always 'Resolved Fail' or 'Resolved Success'
无论Math.random()返回什么,输出总是 Resolved Fail 或 Resolved Success,因为buggyToss()的fail回调没有抛出任何错误或返回一个被拒绝的 promise。以下是buggyToss()的正确版本:
function correctToss() {
return iMayFail().then(
() => 'Success', // Success callback
() => Promise.reject('Fail') // Fail callback
);
摘要
在本章中,我们探讨了 Vuex 插件系统的工作原理,我们扩展了EveryNote应用,添加了两个有用的插件,并从头开发了两款插件:一款 Google Analytics 插件和一款撤销/重做插件。此外,一般而言,我们看到了 Vuex 插件系统如何被利用来向我们的应用添加通用功能,而无需触及应用的核心代码。
最后我们理解了如何使用 JavaScript Promise特性处理异步操作。


浙公网安备 33010602011771号