企图给vuex补充事件触发,小心地实现一个发布订阅,并说服自己这是合理的。
主要内容:
- 跨组件通信:从event bus 到vuex
- 一个比较麻烦的case: 跨组件触发事件
- mutation和action,同步异步,动机拆解
- 封装发布订阅,让vuetool可调试追踪
- 完善代码,抽离逻辑,约定规范
作用:
- 可以方便地用
on
和emit
方式,从一个组件触发另一个组件方法,不需增加状态变量 - 可以在vuetool中追踪到事件触发的行为和时机
- 遵循vuex缘由,不加冗余逻辑,摆脱思想负担
- 不是标准,侧重好用
final code:vuex-event.js
跨组件通信:从event bus 到vuex
为了维护单向数据流的清晰,vue(2.x以上)只支持$emit
和$on
进行父子组件通信。
把一个组件想象成一个模块,其实就是希望组件只维护一个输入(prop)和一个输出(事件$emit
)的状态。但在构筑多个业务组件时候,组件与组件的通信就会因层层传递等变得复杂。
vue官方为跨多层父子组件通信提供了两种方案:
eventHub
的主要思想是通过一个新的vue实例,用它来集中处理组件中的通信:
var eventHub = new Vue()
eventHub.$on('add-todo', this.addTodo)
eventHub.$emit('add-todo', { text: this.newTodoText })
eventHub.$off('add-todo', this.addTodo)
这种方式简单直接,将数据集中到新创建的vue实例中,同时利用vue本身实现好的事件机制,完成了一套数据托管的流程。
为此,官方推荐了
vuex
, 并集成到 vue-tool。vuex采用
store模式
的思想解决数据追踪和调试的问题:
// https://cn.vuejs.org/v2/guide/state-management.html
var store = {
debug: true,
state: {
message: 'Hello!'
},
setMessageAction (newValue) {
if (this.debug) console.log('setMessageAction triggered with', newValue)
this.state.message = newValue
},
clearMessageAction () {
if (this.debug) console.log('clearMessageAction triggered')
this.state.message = ''
}
}
这种模式下,可以把数据集中到store对象中的state进行管理,同时,为了方便对修改state
这个行为进行追踪调试,vuex约定对state数据的修改都不能简单地赋值,而是要经过一个提交方法commit
(类似上面的setMessageAction
),这样在commit
的函数体里面,我们就能增加调试代码,从而对每个修改数据的行为都能追踪定位。
一个比较麻烦的case: 跨组件触发事件
vuex很好地解决了大型跨组件通信问题,但有些情况使用起来会有些小纠结。比如下面:
// button.vue
<button @click="initAllData">
// List.vue
<···v-for="item in list">
methods: {
initAllData () {
this.initData1()
this.initData2()
// dosomething else
}
}
假设button.vue
和List.vue
是分属比较远的两个组件,button想要触发List的事件,且无数据交互,即initAllData
方法严格属于List.vue
, 这种场景在vuex内如何实现?
一种做法是利用一个状态来控制:
// button.vue
<button @click="initAllData">
initAllData () {
this.$store.commit('triggerInitData', true)
}
// List.vue
<···v-for="item in list">
// computed 引入triggerInitData
watch:{
triggerInitData (val) {
if (val) {
this.initAllData()
this.$store.commit('triggerInitData', false)
}
}
}
methods: {
initAllData () {
this.initData1()
this.initData2()
// dosomething else
}
}
但这样的实现总显得有点冗余,
- 首先,引入了无必要的状态变量
triggerInitData
- 加长了整个链路,引进了
watch
这种场景下我们更想要的其实是类似eventHub
的发布订阅模式,因为我们关注的是‘事件’而不是‘数据’。
但如果因此我们就引进一个全局发布订阅的话,缺点也很明显:
- 同时存在vuex和一个‘eventHub’,是不是重复了两套逻辑
- 通过事件订阅的行为又面临不方便追踪调试的局面
针对第一点其实很好解,eventHub将数据操作放到新实例上,通过事件机制完成通信,但没对数据做集中管理;vuex实现了,但vuex的核心在于数据中心,并不关心数据之外的,组件间方法的相互触发。换言之事件绑定-事件触发,以及vuex,更像是两个解决方案,我们应该在数据跨组件通信时候使用vuex,在纯事件类型上探索更好的方法。
于是这个问题现在的焦点在于,如何更好地在组件间触发事件,使之对开发者透明,方便追踪调试,同时不干扰正常的数据流?
mutation和action,同步异步,动机拆解
思考上面问题前,我们先回过头来看vuex的设计, 以及为什么有mutation和action
vuex为了能在每次数据变化前后做跟踪,建立了mutation,约定每次数据的修改都应该通过commit
方法进行,我们可以把commit
理解为类似下面的实现
state: { data: 0 },
mutations: {
setData (state, val) {
state.data = val
}
}
// 组件内调用
this.$store.commit('setData', 123)
// store.commit 类似实现
function commit (evt, val) {
store.mutations[evt](store.state, data)
console.log('检测到commit之后变化': data)
}
查看vuex的api,也可以发现,vuex对插件暴露的接口subscribe
,也是在每个 mutation 完成后调用,这时候我们打开vue-tool, 选择第2个tab:vuex
, 可以看到,vuetool这类工具对每个mutation行为都进行了追踪。
mutation
很大作用是存储数据快照。那action分发的意义又是?( 尤在知乎的回答 )
- mutation 只能返回同步状态,如上述代码,如果
mutations[evt]
是异步函数,commit里面之后获取的data都是无意义的,此时真正的data还未返回 - 我们当然可以在commit里面以
.then
的方式书写调试逻辑,但这样就得约定所有mutation方法以promise方式书写,并且还牺牲了本身是同步状态的函数。 - 更好的做法是新增一个action用来处理异步,确保mutation是同步,不管action什么逻辑,只要最后触发commit,提交mutation就行了,这样数据的变化最终仍会经过mutation追踪, 在诸如
vuetool
工具里呈现.
从vuex的这些设计看来,很关注的一点是数据的可维护性,数据在进行变更时候,应该是可追踪的。结合上一段的问题,如果想在组件间触发事件,那最大的原则是不应该破坏数据在变更时候的可检测性,都应该经过mutation层,在此基础上,事件的行为本身最好也能被追踪记录。
封装发布订阅,让vuetool可调试追踪
确立了需求和原则后,我们终于可以优雅地写代码了,我们整理下小目标:
1. vuex项目内,引进发布订阅
2. 利用mutation, 使"事件触发"这个行为被记录
3. 优化封装代码,约定和确保规范,使通信过程无数据传递,以免漏测数据流
我们简单快速实现下第1点, 在一个vue-cli2搭建起来的项目中,我们直接在main.js
中插入:
import store from './store'
···
store.$events = {}
store.$on = function (evt, fn) {
store.$events['$' + evt] = fn
}
store.$off = function (evt) {
store.$events['$' + evt] = null
}
store.$emit = function (evt, data) {
if (!this.$events['$' + evt]) return
this.$events['$' + evt](data)
}
// 绑定
// this.$store.$on('test', () => {
// console.log('test')
// })
// 调用
// this.$store.$emit('test')
···
这样就有个简单的雏形,也确定了大概的调用方式,接着我们思考第2点,如何让这个事件行为像mutation方法一样能被检测到。
一个最简单的思路是,在$emit
时候,我们也提交一个mutation, 使行为本身能通过mutation被记录。结合vuex的动态加载模块功能,我们尝试一下:
store.registerModule('myEvents', {
mutations: {
setEvent () {}
}
})
store.$events = {}
store.$on = function (evt, fn) {
store.$events['$' + evt] = fn
}
store.$off = function (evt) {
store.$events['$' + evt] = null
}
store.$emit = function (evt, data) {
if (!this.$events['$' + evt]) return
this.$events['$' + evt](data)
this.commit('setEvent', evt) // 将事件evt当成payload提交给mutation
}
现在试下触发一个事件,我们在vuetool可以看到,事件也被记录下来了,并且payload就为触发的事件名:
这样,我们基本实现一套功能了,在继续优化之前,唯独针对第三条小目标思考下,假若我们严格限制$emit
参数的传递,防止不经过vuex的数据出现,那我们应该这样子写:
store.$emit = function (evt) {
if (!this.$events['$' + evt]) return
this.$events['$' + evt]()
this.commit('setEvent', evt) // 将事件evt当成payload提交给mutation
}
假若我们传递的是组件间都公用的数据,是的,我们应当抽取到vuex,并且在维护一个单纯的事件触发。但考虑到实际场景,我们也有可能针对一个开关事件传递一个boolean
, 或者根据操作类别返回一个选择0,1,2
之类。为此,对emit方法的限制,更好地做法是把选择交给开发,并提出约定。
完善代码,抽离逻辑,约定规范
现在我们优化封装下我们的代码,考虑这两点:
- 这套事件机制可以直接打到vuex对象上
- 发布订阅这套逻辑可以抽取出来,在任意其他对象也可以使用
第一步为了main.js的清晰,我们应该把这小段逻辑抽离出来, 新建一个文件 vuex-events.js
, 我们的所有操作都是基于store对象的,所以要把store对象传递进去,main.js中可以这样调整:
import vuexEvent from './vuex-events'
vuexEvent(store)
第二步,我们可以意识到发布订阅的逻辑在很多地方的实现都很一致,实现的最终效果通常为on
, off
, once
, emit
这样的方法,因此,我们也可以把这套逻辑抽离出来,并增加一个mixTo的方法,这样想为某个对象增加发布订阅功能的话,我们都可以采用类似events.mixTo(Object)
的方法,推荐参见events.js的实现。
我们可以简单点实现:
// events
function Events () {}
Events.prototype.events = {}
Events.prototype.on = function (evt, callback) {
if (!callback || !evt) return this
this.events[evt] = this.events[evt] || []
this.events[evt].push(callback)
return this
}
Events.prototype.once = function (evt, callback) {
let that = this
let cb = function () {
that.off(evt, cb)
callback(arguments)
}
return this.on(evt, cb)
}
Events.prototype.off = function (evt, callback) {
if (!evt) {
return this
}
let events = this.events[evt]
if (!callback) {
delete this[evt]
} else {
for (let i = events.length; i--;) {
if (events[i] === callback) {
events.splice(i, 1)
return this
}
}
}
}
Events.prototype.trigger = function (evt, ...arg) {
let events = this.events[evt]
if (!evt || !events) return this
let len = events.length
for (let i = 0; i < len; i++) {
events[i](...arg)
}
}
Events.prototype.emit = Events.prototype.trigger
Events.mixTo = function (receiver) {
var proto = Events.prototype
if (isFunction(receiver)) {
for (var key in proto) {
if (proto.hasOwnProperty(key)) {
receiver.prototype[key] = proto[key]
}
}
} else {
for (var key in proto) {
if (proto.hasOwnProperty(key)) {
receiver[key] = proto[key]
}
}
}
}
function isFunction (func) {
return Object.prototype.toString.call(func) === '[object Function]'
}
export default Events
然后vuex-event中引用
// vuex-events.js
import events from './events'
export default function (store) {
events.mixTo(store)
store.registerModule('myEvents', {
mutations: {
setEvent () {
}
}
})
console.log(store)
store.$emit = function (evt, ...arg) {
if (!this.events[evt]) return
this.trigger(evt, ...arg)
this.commit('setEvent', evt)
}
}
最终的代码:vuex-event.js。 时刻记得我们是为了解决在vuex中的跨组件触发事件问题,避免手写过多代码,但对于共享的数据,始终应该抽离到vuex state中,可以理解为我们在为应对组件间纯事件通信做一种尝试。