企圖給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中,可以理解為我們在為應對元件間純事件通訊做一種嘗試。