企圖為vuex新增發布訂閱:事件繫結和事件觸發

愛學習的kimmy發表於2019-03-16

企圖給vuex補充事件觸發,小心地實現一個釋出訂閱,並說服自己這是合理的。

主要內容:

  • 跨元件通訊:從event bus 到vuex
  • 一個比較麻煩的case: 跨元件觸發事件
  • mutation和action,同步非同步,動機拆解
  • 封裝釋出訂閱,讓vuetool可除錯追蹤
  • 完善程式碼,抽離邏輯,約定規範

作用:

  • 可以方便地用onemit方式,從一個元件觸發另一個元件方法,不需增加狀態變數
  • 可以在vuetool中追蹤到事件觸發的行為和時機
  • 遵循vuex緣由,不加冗餘邏輯,擺脫思想負擔
  • 不是標準,側重好用

final code:vuex-event.js

跨元件通訊:從event bus 到vuex

為了維護單向資料流的清晰,vue(2.x以上)只支援$emit$on進行父子元件通訊。
把一個元件想象成一個模組,其實就是希望元件只維護一個輸入(prop)和一個輸出(事件$emit)的狀態。但在構築多個業務元件時候,元件與元件的通訊就會因層層傳遞等變得複雜。
vue官方為跨多層父子元件通訊提供了兩種方案:

  1. 簡單版本:eventHub
  2. 大型應用:vuex

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本身實現好的事件機制,完成了一套資料託管的流程。

一個狀態管理
缺點是,這個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.vueList.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
    }
}
複製程式碼

但這樣的實現總顯得有點冗餘,

  1. 首先,引入了無必要的狀態變數triggerInitData
  2. 加長了整個鏈路,引進了watch

這種場景下我們更想要的其實是類似eventHub的釋出訂閱模式,因為我們關注的是‘事件’而不是‘資料’。
但如果因此我們就引進一個全域性釋出訂閱的話,缺點也很明顯:

  1. 同時存在vuex和一個‘eventHub’,是不是重複了兩套邏輯
  2. 通過事件訂閱的行為又面臨不方便追蹤除錯的局面

針對第一點其實很好解,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行為都進行了追蹤。

企圖為vuex新增發布訂閱:事件繫結和事件觸發
所以,mutation很大作用是儲存資料快照。
那action分發的意義又是?( 尤在知乎的回答 )

  1. mutation 只能返回同步狀態,如上述程式碼,如果mutations[evt]是非同步函式,commit裡面之後獲取的data都是無意義的,此時真正的data還未返回
  2. 我們當然可以在commit裡面以.then的方式書寫除錯邏輯,但這樣就得約定所有mutation方法以promise方式書寫,並且還犧牲了本身是同步狀態的函式。
  3. 更好的做法是新增一個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就為觸發的事件名:

企圖為vuex新增發布訂閱:事件繫結和事件觸發
這樣,我們基本實現一套功能了,在繼續優化之前,唯獨針對第三條小目標思考下,假若我們嚴格限制$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方法的限制,更好地做法是把選擇交給開發,並提出約定。

完善程式碼,抽離邏輯,約定規範

現在我們優化封裝下我們的程式碼,考慮這兩點:

  1. 這套事件機制可以直接打到vuex物件上
  2. 釋出訂閱這套邏輯可以抽取出來,在任意其他物件也可以使用

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

相關文章