【译】vuex基础:指引与解析

56216630_p0

重要提醒:vuex 的 api 在我写这篇文章的时候已经有了大幅的改动提升。现在 vuex 更加完善地与vue集成在一起。我也应用了新的 api 到原来的概念,写了一篇 新的教程在 vuex 的官方文档中。

这篇文章还会让你非常深入的了解——为什么 vuex 如此重要,它是如何工作以及它如何让你的应用变得更好,更易于维护

Vuex 是一个由 vue 作者开发的,仍在发展中的原型库,目的在于在于帮助你按照因为Facebook的flux库(以及一系列的相似的社区实现,比如Redux)而流行起来的开发模式,来建立更加可维护的大型应用。

与直接跳进到 vuex 中并告诉你如何使用它的文章不同,在这篇文章中我会对为什么它是一个有利的替代方案,以及它到底如何帮助到你背后的理由,给出一个合理的解释。

我们建立了什么

这是一个简单的应用,它有一个按钮和计数器。按下按钮就会增加计数器的值。而且这能让我们轻松的理解那些基本概念

这个应用中有两个组件:

  1. 一个按钮(一个事件源)
  2. 一个计数器(反映基于原始事件的更新)

这两个组件既无法相互发现到对方也无法相互之间进行沟通。即使是在最细小的网页应用中这也是非常常见的一种情况。而在大型应用中,数以十计的组件之间都会相互沟通联系并且需要保证相互之间都是可以被检测到。你不信?这里有些交互甚至在一个基础的 todo list 也存在

这篇文章的目的

我们将会探究4种解决这个问题的方法

  1. 使用事件广播沟通进行组件间的沟通
  2. 使用一个共享状态对象
  3. 使用vuex

通过阅读这篇文章,希望你应该能明白

  1. 一个非常基础的,如何在你的项目中使用 vuex的工作流程
  2. 它解决了什么样的问题
  3. 为什么这可能是比其他方法更好的方法,尽管这有些繁琐和严格

建立一个起始点

我们将会用三种不同的方法解决同一个问题,但是在这之前,我们需要一个共同的起点。如果你计划跟着下去,我建议创建一个这个教程的 git 仓库,在这个章节之后添加一个 commit 以及添加不同方法的分支。

1
2
3
4
5
6
$ npm install -g vue-cli
$ vue init webpack vuex-tutorial
$ cd vuex-tutorial
$ npm install
$ npm install --save vuex
$ npm run dev

现在,你应该看见基础的vue手脚架页面。让我们创建并且更新一些文件来到达我们想要的。

首先,我们创建一个IncrementButton 组件在 src/components/IncrementButton.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<button @click.prevent="activate">+1</button>
</template>
<script>
export default {
methods: {
activate () {
console.log('+1 Pressed');
}
}
}
</script>
<style>
</style>

下一步,我们创建实际上显示那个计数器的 CounterDisplay 组件。在 src/components/COunterDisplay.vue 写一个新的基础 vue 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
Count is {{ count }}
</template>
<script>
export default {
data () {
return {
count: 0
}
}
}
</script>
<style>
</style>

用下面的内容替换掉 App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div id="app">
<h3>Increment:</h3>
<increment></increment>
<h3>Counter:</h3>
<counter></counter>
</div>
</template>
<script>
import Counter from './components/CounterDisplay.vue'
import Increment from './components/IncrementButton.vue'
export default {
components: {
Counter,
Increment
}
}
</script>
<style>
</style>

现在,如果你再一次运行 npm run dev,然后在你的浏览器中打开这个页面,你就应该看见一个按钮和一个计数器。点击这个按钮就会在控制台显示一条信息,没有其他的东西。所以现在,我们得到了我们的起点,让我们继续吧。

解决方案1:事件广播

Basic App

让我们来修改组件中的脚本部分。首先,在 IncrementButton.vue 我们使用 $dispatch 来向父元素发送这个按钮被点击了的信息。

1
2
3
4
5
6
7
8
9
export default {
methods: {
activate () {
// Send an event upwards to be picked up by App
this.$dispatch('button-pressed')
}
}
}

App.vue 中我们监听这个来自 button 子组件的事件并且向所有子组件广播一个新的increment事件来通知计数器

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
components: {
Counter,
Increment
},
events: {
'button-pressed': function () {
// Send a message to all children
this.$broadcast('increment')
}
}
}

CounterDisplay.vue 中我们监听 increment 事件,然后增加这个状态里的 count 的值

1
2
3
4
5
6
7
8
9
10
11
12
export default {
data () {
return {
count: 0
}
},
events: {
increment () {
this.count ++
}
}
}

这个方法的一些问题之处

基本上,在这个方法当中并没有什么技术性错误。重复一次,当你吧你整个应用在一个文件中并且所有的应用逻辑都只写在该去的地方的时候,这里面并没有任何技术性的错误。这完全是可维护性的问题。这里有些理由关于为什么这个方法对于可维护性来说是糟糕的。

  1. 对于每一个行为,父组件都需要接收并‘调度’事件去正确的组件中
  2. 在大型应用中难以判断事件可能来自于哪里
  3. 没有一个清晰的地方放置业务逻辑。现在我们把 this.conut++ 写在 CounterDisplay 中,但是实际上,业务逻辑可以被放在任何可以使得程序更加难以理解的地方。

让我给一个关于这个方法如何导致一个 bug 的例子

  1. 你聘用了两个实习生:爱丽丝和鲍勃 。你告诉爱丽丝你想要在另一个组件中设置另一个计数器。同时告诉鲍勃写一个重置按钮。
  2. 爱丽丝写了一个新组件 FormattedCounterDisplay 来订阅 increment 事件去更新这个组件的状态。爱丽丝开心的提交并推送了它。
  3. 鲍勃写了一个新的“Reset”组件来发送 reset 事件给 App ,让 App 重新调度这个事件。接着他在 CounterDisplay 设置了 reset 来让计数归零,但是他没有察觉到爱丽丝的组件也订阅了它。
  4. 你的用户按按下“+1”可以看到这个应用正常工作。但是当这个用户按下 “reset”按钮,只有一个计数器被重置.

可能这看起来是个非常简单的例子,但是这个例子正正说明了尝试用事件将分散的状态和业务逻辑联系在一起可能导致错误

解决方案2 共享对象

让我们吧我们在解决方案1里所做的还原。 这次我们新建一个文件 src/store.js

1
2
3
4
5
export default {
state: {
counter: 0
}
}

首先,我们在 CounterDisplay 做第一个改动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
Count is {{ sharedState.counter }}
</template>
<script>
import store from '../store'
export default {
data () {
return {
sharedState: store.state
}
}
}
</script>

在这里面,我们做了一些相当有趣的事情:

  1. 我们得到的 store 对象其实只是一个常量对象,只不过它定义在了不同的文件里。
  2. 在我们的本地 data 中我们新建了一个叫 sharedState 的新参数映射到 store.state
  3. 由于这是 data 的一部 分,vue 使得 store.state 变成可响应的。这意味着 vue 将会在 store.state 有任何变动时,自动更新 sharedState

不过到目前为止,这还不能正常运作。但是现在,我们需要修改 IncrementButton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import store from '../store'
export default {
data () {
return {
sharedState: store.state
}
},
methods: {
activate () {
this.sharedState.counter += 1
}
}
}
  1. 在这里,我们引入 store 并且订阅它到响应式的状态中,就像前一个例子那样。
  2. actuvate 方法被调用,它会去指向 store.statesharedState ,令其中的 counter 增加
  3. 所有订阅了 counter 的组件和计算属性,现在都会立即更新了。

为什么这个方法比解决方案1要更好?

让我们重新看一遍实习生爱丽丝和鲍勃的问题

  1. 爱丽丝写了 FormattedComponentDisplay 去订阅那个共享状态的 计数器,因此将会一直显示最新的的计数器的值
  2. 鲍勃的 ResetButton 组件将共享状态的计数器设置为 0。这将会同时影响 CounterDisplay 和爱丽丝写的 FormattedCounterDisplay
  3. 用户发现重置按钮能按照预期工作了

为什么这还不够好?

  1. 在他们过去的一段实习生日子里,爱丽丝和鲍勃写了许多不同规范的计数器展现,重置按钮以及增量按钮,统统都更新到同一个共享的计数器中,美好的生活。
  2. 一旦他们回到校园,你不得不维护他们的代码。
  3. 赞美圣光,新的经理到来,并且这样说到‘我不希望这个计数器超过100 ’

现在该怎么办?

  1. 你是否要跑到数以十计的组件中,把所有更新这个计数器的地方找出来? 这可真他妈金属。
  2. 你是不是要去到所有展示的地方添加一个过滤器/格式化到那里?同样太金属了。

一个略微好那么一丢丢的方案

现在,你开始重构所有的原始代码。你像这样重写你的store.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var store = {
state: {
counter: 0
},
increment: function () {
if (store.state.counter < 100) {
store.state.counter += 1;
}
},
reset: function () {
store.state.counter = 0;
}
}
export default store

自从你显示的调用 increment 并且将你所有的业务逻辑都写在这个 store 当中之后,你的代码变得整洁了许多。然而好景不长,一个新的实习生,他不理解这个概念背后的一切缘由,还发现直接把 store.state.counter 简单地写到这个应用的不知道什么角落更加便捷。结果是这让 debug 变得非常困难。

你接着制定了许多严格的规则,指导方案,代码审查来确保没有任何人,可以不使用 store.js 里的方法直接改写这个状态。而当你发现这依然没有什么卵用的时候。你就会跑去和人力撕逼,再不干掉这个实习生项目我就要爆炸了

解决方案3 Vuex

把方案2里所有的改动还原。实际上,vuex 的工作原理顺着与方案2类似的方向前进。这里有一个看起来有些吓人的图表

Vuex

我们再一次新建一个 src/store.js ,但是这次我们的代码是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Vuex from 'vuex'
import Vue from 'vue'
Vue.use(Vuex)
var store = new Vuex.Store({
state: {
counter: 0
},
mutations: {
INCREMENT (state) {
state.counter ++
}
}
})
export default store

让我们来看看代码中发生了什么事情:

  1. 我们得到 Vuex 模块 并且让 Vue 去激活,使用这个插件
  2. 我们的 store 不再是一个普通的 json 对象,而是一个 Vuex.Store 的实例
  3. 我们再一次在状态中创建了一个 counter ,设置为 0。
  4. 我们有一个新的 mutations 对象,他拥有一个 INCREMET 需要输入一个状态,并且改变那个状态。

有一些有趣的事情在这段代码里发生:

  1. 所有通过 require(./store.js) 或者 import store form '../store.js' 的方式都可以使用同一个 store 实例
  2. 我们永远不会编辑 store.state.conuter,但是我们得到一个这个状态的的副本用于我们的更新与修改。这在接下来会变得十分重要

现在我们可以盖上这个 store ,让我们前往 IncrementButton.vue

1
2
3
4
5
6
7
8
9
import store from '../store'
export default {
methods: {
activate () {
store.dispatch('INCREMENT')
}
}
}

这个组件中甚至没有任何数据。但是我们调用了 store.dispatch('INCREMENT') 来响应点击事件。我们不久以后还会回到这里。

现在,先让我们更新 CounterDisplay.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
Count is {{ counter }}
</template>
<script>
import store from '../store'
export default {
computed: {
counter () {
return store.state.counter
}
}
}
</script>

这里正是事情开始变得有趣的地方。我们不在订阅共享状态。相反的,我们使用 vue’s 的计算属性去获取那个 store 中的 counter 属性。

Vue 有足够的聪明可以推算出 这个 conuter 计算属性依赖于 store.state.counter 。所以无论在哪里这个 store 被更新,它都会去更新所有相关的对象。而这就是其中一个

如果你刷新这个页面,你将会看见这个计数器很好地工作着。让我一步步告诉你发生了什么:

  1. Vue 的事件处理方法叫 activate 。这个方法被我们用于调用 store.dispatch('INCREMENT')
  2. 在这里面,INCREMENT 是一个 action 的名字。action 代表了一个 ’应当用作于这个状态的改动的一种类型‘。我们可以通过包含有额外的参数的调度函数,来向 action 传递额外的参数。
  3. Vue 能识别出是什么变动在触发这个调度。现在我们只是有了一个,但是我们可以让这变得更加复杂,来为大型的应用量身定制。
  4. 这些变动会临时复制这个状态的并且更新他。Vue 保持一个旧的状态副本,这个稍后会被用于一些高级的特性当中
  5. 如果你做得都是诸如这样的事情,这让你的代码变得更加可测试的,

为什么说这个方法比方案2好太多了

  1. 如果所有的状态的副本在整个还开发过程中都得到保存,vue 开发人员可能可以建立一个类似于“时间旅行的除错工具”。除了听起来像是一个非常酷的超级英雄的名字,它还允许你“撤销”你的应用中的 action ,改变这当中的业务逻辑,并让开发变得更加迅速。
  2. 你可以写一个中间件在任何状态改变时被调用。举个例子。你可以写一个日志工具来打印出所有被用户执行的 action 。如果他们找到一个 bug 你可以就获得这个 bug 的日志。重演所有那些 action 并且准确的重现他们的 bug
  3. 通过迫使你将所有的 action 写在一个地方,这份文件变成一个很好的参考来让任何你团队中的人知道,有哪些方法去可以改变你的应用的状态。

Still a long way to go.

这仅仅触及了 vuex 能力的表面。而 vuex 也仍然是一个非常早期的版本,我也非常相信它会成长为这些年来最有影响的开发范式之一。

你可以在 vuex 的文档中找到更多关于如何组织你的 stores 的信息和更多其他这资料。
这可能会花费你的一些时间去消化所有的这些概念。甚至是一些尝试与犯错去去找到正确的平衡与方法。

终章:死于实习生的代码

即使你将你的应用移植到 vue ,你实习生依然发现他可以在组件中直接写入 store.state.counter 作为一条偷懒的窍门。然而你已经受够了,这是最后一条稻草。
你可以先人一步在你的 store.js 加上一行代码

1
2
3
4
5
6
7
8
9
10
11
var store = new Vuex.Store({
state: {
counter: 0
},
mutations: {
INCREMENT (state) {
state.counter ++
}
},
strict: true // Vuex's patent pending anti-intern device
})

现在,无论任何人任何时候直接改写这个store,就会抛出一个错误。要注意的是,这会稍稍影响你的应用效率。不过你可以在生产环境中安全的移除它。请参阅文档和示例来做到这一点。


居然被我对着谷歌翻译翻译完了=。=
原文地址在这里
Vuex basics: Tutorial and explanation