作者注:[2016.11 更新]这篇文章是基于一个非常旧的 vuex api 版本而写的,代码来自于2015年12月。
但是,它仍能针对下面几个问题深入探讨:
- vuex 为什么重要
- vuex 如何工作
- vuex 如何使你的应用更容易维护
vuex 是 vue.js 作者开发的一个原型库,它帮助你创建更大、维护性更强的应用,类似于 Facebook 的 flux 库(以及由社区维护的 redux 库)。
这篇文章不直接跳到 vuex 教你如何使用它,而是从背后的故事开始说起,逐步解释它为什么是优雅的替代方法,以及将如何帮助你。
译者注:a git repo of vuex-tutorial use vue2.0
你想要创建什么应用?
一个拥有按钮
和计数器
的简单应用,点击按钮计数器加1。这听起来非常容易理解和完成。
我们假设这个应用有两个组件:
- 按钮 (它是事件的来源)
- 计数器 (它必须按照事件来反映更新)
这两个组件不知道彼此的存在,也不能相互通信。即使是在最小的 web 应用中,这也是一种非常常见的模式。在更大点儿的应用中,十几个组件相互通信,并时刻关注对方的变化。不相信我?这里是一个基础的 TODOlist 应用的交互清单:
这篇文章的目标
我们将讨论解决同一个问题的3种方法:
- 组件之间使用事件广播来通信
- 使用一个共享的状态对象通信
- 使用 vuex 通信
读完这篇文章,希望你能理解:
- 在你的项目中使用 vuex 的一个基本工作流程
- 它解决了哪些问题
- 相对其他方法,为什么它是更好的(尽管有些冗长和严格)
准备工作
我们将使用3种不同的方法来解决同一个问题。在这之前,需要做一些共同的准备工作。如果你打算跟着我做,我建议你为这个教程创建一个 git repo,这一小节结束后提交一次代码,然后为不同的方法创建不同的分支。
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 的脚手架页面了,下面来为我们要做的事来修改一些文件。
首先,在文件 src/components/IncrementButton.vue
中创建 IncrementButton
组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <template> <button @click.prevent="activate">+1</button> </template> <script> export default { methods: { activate () { console.log('+1 Pressed') } } } </script> <style> </style> |
下一步,在文件 src/components/CounterDisplay.vue
中创建 CounterDisplay
组件来展示计数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <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 23 | <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:事件广播
来修改组件的代码。
首先在 IncrementButton.vue
中,在按钮被点击时使用 $dispatch
给父组件发送一个消息。
1 2 3 4 5 6 7 8 | export default { methods: { activate () { // Send an event upwards to be picked up by App this.$dispatch('button-pressed') } } } |
在 App.vue
中监听来自子组件的这个消息事件,然后广播一个新的事件 increment
给所有的子组件:
1 2 3 4 5 6 7 8 9 10 11 12 | export default { components: { Counter, Increment }, events: { 'button-pressed': function () { // Send a message to all children this.$broadcast('increment') } } } |
在 CounterDisplay.vue
中,监听 increment
事件,并增加状态数据中的变量:
1 2 3 4 5 6 7 8 9 10 11 12 | export default { data () { return { count: 0 } }, events: { increment () { this.count++ } } } |
这个方法的缺点:
这个方法基本没有什么技术上的错误。此外,在一个文件里实现整个应用的逻辑,专门使用 goto 来跳转也没有错。这只与可维护性有关,这里会讲一下为什么这个方法在可维护性上是糟糕的。
- 对于每一个操作,父组件都需要将事件分发给正确的组件;
- 在大型应用中,可能很难理解事件是从哪儿来的;
- 业务逻辑没有明确的位置。
this.count++
是在CounterDisplay
中,但业务逻辑可能到处都是,这会导致难以维护。
让我来举例说明一下这个方法会怎样导致bug:
- 你雇了两个实习生: Alice 和 Bob。你告诉 Alice 你需要为另外一个组件实现另一个计数器,告诉 Bob 写一个重置按钮;
- Alice 写了一个新的组件
FormattedCounterDisplay
,它能够监听增量,并增加自己的状态数据。Alice 开心的提交了代码; - Bob 写了一个新的
Reset
组件,它向应用发出一个reset
事件,并重新分发它。他在CounterDisplay
中将 count 重置为0,但是他没有意识到 Alice 的组件也订阅了这个变化; - 你的用户点击
+1
按钮后看到应用工作正常。但是当他点击重置
按钮,只有一个计数器被重置了。这看起来是一个非常简单的例子,仅仅为了说明状态和业务逻辑绑在一起可能会导致错误。
方法2: 共享状态
撤销方法1中的改动,创建一个新文件 src/store.js
:
1 2 3 4 5 | export default { state: { counter: 0 } } |
首先修改 CounterDisplay.vue
:
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> |
这里我们做了一些有趣的事情:
- 获取到一个 store 对象,它仅仅是一个对象常量,但是在不同的文件中定义的;
- 在本地数据中,我们创建了一个叫
sharedState
的数据,它映射到store.state
; - vue 使用
store.state
作为当前组件的一部分数据,这意味着store.state
有任何变化,vue 都会自动更新sharedState
。
到目前为止它还不能工作,现在我们来修改 IncrementButton.vue
:
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 } } } |
- 在这里,我们引入
store
,并像之前的例子一样监听了数据的状态变化; - 当
activate
方法被调用时,指向store.state
的sharedState
的计数器 counter 增加; - 监听了计数器的所有组件和计算属性都会被更新。
它为什么比方法1更好
我们来回顾一下两个实习生 Alice 和 Bob 的问题:
- Alice 写的用来监听共享数据的
FormattedComponentDisplay
组件将会始终显示最新的 counter 数据; - Bob 的重置按钮组件将共享数据的 counter 置为0,这将同时影响
CounterDisplay
和 Alice 写的FormattedCounterDisplay
; - 重置按钮符合预期。
为什么这样仍然不够好
- 在 Alice 和 Bob 的实习期内,他们使用不同的格式写了许多计数器、重置按钮,以及增量按钮,它们更新的是同一份共享的数据,生活很美好;
- 一旦他们回到学校,你需要维护他们的代码;
- 新任经理 Carol 进来之后说:“我不想看到计数器的数字超过100”
你现在该做什么?
- 你去十几个组件的代码里找到所有更新数据的地方吗?这让人沮丧;
- 你找到显示数据的地方然后添加一个
filter/formatter
来格式化数据吗?这同样让人沮丧; - 这里就是这个问题,业务逻辑分散在应用的各个角落,原则上一个很简单的问题,但是维护和调试起来却特别痛苦。
稍好一点儿的方法
现在来重构你的代码,重写 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
更容易,于是一切变得难于调试。
然后,你制定大量严格的规则和代码审查,以确保没有人在 store.js
中不使用函数的情况下修改状态数据。如果这都不起作用,那你可以告诉hr结束他的实习了。
方法3:vuex
回滚方法2里的修改,原则上 vuex 的工作原理与方法2有些相似。给你看一张稍稍有些可怕的图:
首先来创建 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 |
现在来看看这段代码做了什么:
- 获取 Vuex 模块,然后使用
Vue.use
安装这个插件; store
不再是一个普通的 JSON 对象,而是Vuex.Store
的一个实例;- 在
state
中创建一个计数器counter
,设置为0; - 创建一个新的变异对象,包含
INCREMENT
方法:获取一个状态数据,然后改变它。
看看这段代码里有哪些有趣的东东:
- 所有通过
require('../store.js')
或import store from '../store.js'
引入的store
将使用同一个 store 实例; - 我们不会修改
store.state.counter
,但是我们有一份state
的拷贝用来做修改,这在接下来会很重要。
现在我们已经改好了 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 的计算属性来给 counter 同步 store 中的数据。
Vue 足够聪明来计算出基于 store.state.counter
的计算属性 counter
,无论 store 何时被更新,它将更新所有的关联项。That’s it!
如果你刷新这个页面,你将看到计数器依然正确工作。下面将逐步解释发生了什么:
- vue 的事件处理函数是
activate
,这个方法调用了store.dispatch('INCREMENT')
; - 在这里,
INCREMENT
是一个动作的名称。它表示 “这是 state 应该做出的那种改变”。我们还可以传递额外的其他参数给分发函数; - vue 指明了分发事件时应该调用哪个函数。现在我们只有一个,但是我们可以为大型应用定制的更复杂;
- 这个函数接收状态数据的拷贝,并对它进行更新。vue 保留一份旧数据的拷贝用于后续的高级功能;
- 当状态更新之后,vue 自动更新所有组件;
- 这些使得你的代码可测试性更强,如果你做了这些的话。
这里是比办法2更好的原因
假如在开发过程中所有状态的拷贝都被保存下来,vue 开发者建立起所谓的“时间旅行调试器”是非常有可能的。除了一个听起来超酷的超级英雄的名字,它将允许你在应用中撤销行为、改变逻辑,以及开发的更快。
只要状态改变,你就可以构建中间件。例如,你可以创建一个 logger 来记录用户执行的所有操作。如果他们发现了一个bug,你可以获取到用户日志,重新播放所有的行为,并正确的重现他们的bug。
通过强制你在一个地方(store)进行所有的动作,这是一个很好的参考,你团队中的每一个人都可以使用你应用中所有修改状态数据的方法。
还有很长的路要走
这里仅仅接触到了 vuex 表面可以做的事情,它自身仍然是一个早期版本,我相信这将成为未来许多年里最成熟的模式之一。
你可以去网上找到关于如何组织 store 以及 vuex 文档的更多信息。你可能需要花一些时间来理解所有的概念,甚至可能需要一些尝试和错误才能找出正确的方法。
结语:处理实习生的代码
你将应用移植到 vue.js,你的实习生仍旧可以找到方法在自己的组件中重写 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,将会抛出一个错误。请注意这会减慢你的应用运行的时间,这个配置可以在生产环境移除,相关示例请查文档。