天天看點

小程式中釋出訂閱事件的一次優化

項目背景

普通的釋出訂閱方法在這裡就不進行解釋了,相信百度一下有一堆。

在我們自己的小程式中,很早之前就使用了釋出訂閱模式來管理城市和登入态的切換,但是在小程式中會存在非常一些問題

  1. 頁面登出後訂閱事件不會銷毀
  2. 使用my.reLaunch或my.switchTab跳轉會清空頁面棧,重新進入帶有訂閱事件的頁面緩存清單會再push一次訂閱事件,造成一次釋出多次訂閱的bug
  3. 想要手動銷毀訂閱事件必須在注冊訂閱事件時使用具名函數,然後在onUnload中銷毀

舉個最簡單的例子,我們在A頁面的切換了城市,B頁面接收到城市切換後觸發回調

// A頁面
click() {
	app.broadcast.fire('cityChange', cityId)
}           

複制

// B頁面
onLoad() {
  app.broadcast.on('cityChange', this.cb)
},
// 訂閱回調
cb() {
	// ...do something
},
// 登出
onUnload() {
	app.broadcast.off(this.cb)
}           

複制

為了解決上述問題,對釋出訂閱做了改造,實作以下效果

  1. 訂閱事件可以使用匿名函數
  2. 頁面登出自動銷毀訂閱事件

實作一個簡單的釋出訂閱

// broadcast.js
class Emitter{
  constructor() {
    // 存儲所有訂閱的事件
    this.eventMap = new Map()
  }
  on(name, callback) {
    // 初始化
    if(!this.eventMap.has(name)) {
      this.eventMap.set(name, [])
    }
    let callbackList = this.eventMap.get(name)
    callbackList.push(callback)
  }
  fire(name, data) {
    const callbackList = this.eventMap.get(name)
    if(Array.isArray(callbackList)) {
      callbackList.forEach(cb => {
        typeof cb === 'function' && cb(data)
      })
    }
  }
  off(name, callback) {
    if(this.eventMap.has(name)) {
      let callbackList = this.eventMap.get(name).filter(item => item !== callback)
      this.eventMap.set(name, callbackList)
    }
  }
}

const $event = new Emitter()
export.default = $event           

複制

注意,在支付寶小程式内一定要将這個$event挂載在app上,不然在分包内使用釋出訂閱會存在問題,是以後面的demo我們都使用app.broadcast

實作訂閱時使用匿名函數

首先我們想得到的目标是可以使用匿名函數,并且能手動銷毀。

因為使用的是匿名函數,頁面銷毀時無法通過循環判斷匿名函數是否相等來銷毀,是以為了找到對應的匿名函數并且銷毀掉,我們在訂閱的時候直接return出關閉的方法,調用方式如下

onLoad() {
  this.offCb = app.broadcast.on('cityChange', () => {
  	//...do something
  })
},
onUnload() {
	this.offCb()
}           

複制

是以我們改造一下on的代碼,return出銷毀事件

on(name, callback) {
  if(!this.eventMap.has(name)) {
    this.eventMap.set(name, [])
  }
  let callbackList = this.eventMap.get(name)
  callbackList.push(callback)
  // 傳回一個關閉的函數,callback === callback
  return () => this.off(name, callback)
}           

複制

完成了這一步,但是我們還需要在頁面解除安裝的生命周期裡手動銷毀,這也太麻煩了吧,而且我們小程式裡多處用了這個釋出訂閱,改動量太多,而且後續開發也需要開發者們自己銷毀。是以我們接着改造,讓頁面銷毀時自動銷毀該頁面的所有訂閱事件

實作頁面解除安裝自動銷毀

想要自動銷毀頁面的訂閱事件,那麼必須知道目前頁面有多少個訂閱事件,并且頁面解除安裝時一一銷毀。

根據如上話述我們理想中擷取到的資料如下

{
	'pages/index/index': [this.offCbA, this.offCbB, ...]
}           

複制

根據這個資料,可以想到每次訂閱的時候,我們把頁面和訂閱事件return出的銷毀事件關聯起來,這時就可以做一層簡單的攔截,統一處理

// 重新建立一個執行個體對訂閱方法做一層攔截,得到如上資料
class Broadcast{
  on(name, callback) {
    const stopHandle = $event.on(name, callback)
    // 存儲解除安裝方法到對應執行個體上
    markListenHandle(stopHandle)
    return stopHandle
  }
  fire(name, callback) {
    return $event.fire(name, callback)
  }
  off(name, callback) {
    return $event.off(name, callback)
  }
}
export.default = new Broadcast()           

複制

接下來讓我們關聯頁面與銷毀事件

第一步先擷取頁面路由

function markListenHandle(stopHandle) {
  let currentPage
  // 支付寶路由可能擷取失敗,是以需要做一層catch
  try{
    const routers = getCurrentPages() || []
    currentPage = Array.isArray(routers) && routers[routers.length - 1] || ''
  }catch(e) {
    console.log(e)
  }
  如果擷取失敗了,也不去自動銷毀訂閱,不影響主流程
  if(!currentPage) {
    return 
  }
}           

複制

第二步關聯頁面與銷毀事件

// 存儲執行個體對應的銷毀方法
const currentPageMap = new Map()
function markListenHandle(stopHandle) {
  let currentPage
  // 支付寶路由可能擷取失敗,是以需要做一層catch
  try{
    const routers = getCurrentPages() || []
    currentPage = Array.isArray(routers) && routers[routers.length - 1] || ''
  }catch(e) {
    console.log(e)
  }
  如果擷取失敗了,也不去自動銷毀訂閱,不影響主流程
  if(!currentPage) {
    return 
  }
  const list = currentPageMap.get(currentPage) || currentPageMap.set(currentPage, []).get(currentPage)
  list.push(stopHandle)
}           

複制

最後一步,劫持頁面解除安裝生命周期,頁面解除安裝時自動銷毀目前頁面下所有訂閱事件

// 存儲執行個體對應的解除安裝方法
const currentPageMap = new Map()
// 存儲執行個體頁面
const markOnUnmounted = new Set()
function markListenHandle(stopHandle) {
  let currentPage
  try{
    const routers = getCurrentPages() || []
    currentPage = Array.isArray(routers) && routers[routers.length - 1] || ''
  }catch(e) {
    console.log(e)
  }
  if(!currentPage) {
    return 
  }
  const list = currentPageMap.get(currentPage) || currentPageMap.set(currentPage, []).get(currentPage)
  list.push(stopHandle)
  if(!markOnUnmounted.has(currentPage)) {
    markOnUnmounted.add(currentPage)
    // 劫持頁面上的onUnload方法
    const onUnload = currentPage.onUnload
    // 重寫onUnload
    currentPage.onUnload = function() {
      onUnload.apply(this, arguments)
      // 清空目前頁面所有的on
      const stopHandleList = currentPageMap.get(currentPage)
      stopHandleList.forEach(val => val())
      markOnUnmounted.delete(currentPage)
      currentPage = null
    }
  }
}           

複制

好啦,完成了,然後我們就可以在頁面上愉快的使用匿名函數,并且不用關心他的銷毀

onLoad() {
	app.broadcast.on('cityChange', () => {
  	// ...do something
  })
}           

複制