小程序中发布订阅事件的一次优化

我心飞翔 分类:微信小程序

项目背景

普通的发布订阅方法在这里就不进行解释了,相信百度一下有一堆。
在我们自己的小程序中,很早之前就使用了发布订阅模式来管理城市和登录态的切换,但是在小程序中会存在非常一些问题

  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
  })
}
 

全部代码链接:github.com/chenerhong/…

参考文献

github.com/tangdaohai/…
github.com/tangdaohai/…

作者:掘金-小程序中发布订阅事件的一次优化

回复

我来回复
  • 暂无回复内容