一个微前端库的诞生-1 | 实现状态和事件通信模块

3,139 阅读9分钟

前言

上一篇文章中,我们提到了@rallie/core遵循如下的代码架构 截屏2021-12-24 上午11.51.05.png

其中的Socket就是Rallie中负责应用间通信的模块,本期我们就一起来看一下它的实现。

文章中的代码只给出核心实现,会省略一些简单的处理边界情况的逻辑以及大部分类型声明,完整实现可以参考项目中的源码

Socket

Socket英文原义是“插座,插孔”,熟悉网络协议的朋友应该对socket不陌生,但我们这里的socket不是真实的网络通信的句柄,只是借用这个名字,来表示我们要实现的是一个事件和状态通信链路中的通信插孔。它将被这样使用:


const stores = {} // 状态中心
const eventEmitter = new EventEmitter() // 事件中心

const socket1 = new Socket(eventEmitter, stores)
const socket2 = new Socket(eventEmitter, stores)

/*
** 状态通信
*/
socket1.initState('count', { value: 0 })
console.log(socket2.getState('count', count => count.value)) // 0
socket2.setState('count', 'add count', (count) => count.value++)
socket2.watchState('count', (count) => count.value).do((newCount, oldCount) => {
  console.log(newCount, oldCount)
})

/*
** 事件通信
*/
// 广播
socket1.onBroadcast({
  logText(text) {
    console.log(text)
  }
})
const broadcaster = socket2.createBroadcaster()
broadcaster.logText('Hello Rallie') // 触发logText事件

// 单播
socket1.onUnicast({
  getText() {
    return 'Hello Rallie'
  }
})
const unicaster = socket2.createUnicaster()
console.log(unicaster.getText()) // 触发单播事件

我们要实现的Socket类应该长这样

class Socket {
  constructor(private eventEmitter, private stores) {
    this.eventEmitter = eventEmitter
    this.stores = stores
  }

  public initState
  public getState
  public setState
  public watchState
  
  public onBroadcast
  public onUnicast
  public createBroadcaster
  public createUnicaster
}

事件通信

EventEmitter

EventEmitter相信大家已经非常熟悉,一些面试官要考察发布-订阅模式,就会让你手写一个EventEmitter。Rallie的EventEmitter实现了广播和单播两种类型的事件。

广播

广播是一对多的关系,即一个事件可以有多个监听函数。一般的广播事件机制的实现就是维护一个事件订阅池,监听事件就把监听函数推入这个订阅池中,触发事件就遍历订阅池,将监听函数依次执行一遍,取消监听就把监听函数从事件池中移除即可。但是这样实现有一个弊端,就是如果在触发事件时,还没有事件回调被放入订阅池,则这次触发事件就会被忽略。因此我们除了维护订阅池,再额外维护一个参数池,当触发事件时,先检查订阅池里是否有监听回调,如果没有,就将本次触发事件的参数推入参数池中,等订阅池中有监听函数时,就将参数池中的参数传给监听函数并执行。

class EventEmitter {
  // ...省略部分代码
  private broadcastEvents = {}

  // 监听广播事件
  public addBroadcastEventListener (event, callback) {
    this.broadcastEvents[event] = this.broadcastEvents[event] || {
      listeners: [], // 事件订阅池
      emitedArgs: [], // 事件参数池
    }
    const { listeners, emitedArgs } = this.broadcastEvents[event]
    listeners.push(callback)
    if (emitedArgs.length > 0) { // 如果事件参数池不为空,说明事件在被监听前已经被触发过了,那么就立即遍历参数池,执行订阅函数
      emitedArgs.forEach((args) => {
        this.emitBroadcast(event, ...args)
      })
      this.broadcastEvents[event].emitedArgs = [] // 清空参数池
    }
  }

  // 触发广播事件
  public emitBroadcast (event, ...args) {
    this.broadcastEvents[event] = this.broadcastEvents[event] || {
      listeners: [],
      emitedArgs: []
    }
    const { listeners, emitedArgs } = this.broadcastEvents[event]
    if (listeners.length > 0) {
      listeners.forEach((callback) => { // 如果事件订阅池不为空,则直接遍历订阅池,执行订阅函数
        callback(...args)
      })
    } else {
      emitedArgs.push(args) // 如果事件订阅池为空,则将事件参数推入参数池中,等有订阅函数被添加的时候,再执行订阅函数
    }
  }

  // 取消监听广播事件
  public removeBroadcastEventListener (event, callback) {
    const registedcallbacks = this.broadcastEvents[event]?.listeners
    if (registedcallbacks) {
      let targetIndex = -1
      for (let i = 0; i < registedcallbacks.length; i++) { // 在订阅池中查找要取消订阅的监听函数
        if (registedcallbacks[i] === callback) {
          targetIndex = i
          break
        }
      }
      if (targetIndex !== -1) {
        registedcallbacks.splice(targetIndex, 1) // 删除监听函数
      }
    }
  }
}

这样我们就实现了一个触发事件时可以不用关心事件是否已经被订阅的广播事件收发器

const event = new EventEmitter()

event.emitBroadcast('print', 'Hello World') // 先触发
const callback = (text) => {
  console.log(text)
}
event.addBroadcastEventListener('print', callback) // 后监听, 会打印 Hello World
event.removeBroadcastEventListener('print', callback) // 取消监听

单播

单播是一对一的关系,即一个事件只有一个监听函数,也就意味着这个监听函数可以有返回值,在触发事件时可以获取监听函数的返回值,也正是因为这个原因,单播不应该支持先触发,后监听。

class EventEmitter {
    private unicastEvents = {}
    // 监听单播事件
    public addUnicastEventListener (event, callback) {
      if (!this.unicastEvents[event]) {
        this.unicastEvents[event] = callback
      }
    }
    // 触发单播事件
    public emitUnicast (event, ...args) {
      const callback = this.unicastEvents[event]
      if (callback) {
        return callback(...args)
      }
    }
    // 取消监听单播事件
    public removeUnicastEventListener (event) {
      if (this.unicastEvents[event]) {
        delete this.unicastEvents[event]
      }
    }

我们可以像这样使用单播事件:

const event = new EventEmitter()
const callback = () => {
  return 'Hello World'
}
// 监听事件
event.addUnicastEventListener('getText', callback)
// 触发事件
const text = event.emitUnicast('getText')
console.log(text) // Hello World
// 取消监听
event.removeUnicastEventListener('getText', callback)

Proxy封装

现在我们已经实现了EventEmitter模块, 但是现在监听和触发事件时,我们还是用字符串作为事件名,这样对于typescript不够友好,因此,我们在Socket中,对事件模块再添加一层包装。监听事件时传入一个对象,键是事件名,值是监听函数。触发事件时,我们则创建一个Proxy对象,在该对象的get方法中用eventEmitter触发事件。下面给出封装后的广播的实现,单播的实现是类似的。

type CallbackType = (...args: any[]) => any

class Socket {
  // ...省略部分代码
  private eventEmitter

  // 监听事件
  public onBroadcast<T extends Record<string, CallbackType>> (events: T) {
    Object.entries(events).forEach(([eventName, handler]) => {
      // 监听事件
      this.eventEmitter.addBroadcastEventListener(eventName, handler)
    })
    return (eventName?: string) => { // 返回取消监听的方法
      const cancelListening = (name) => {
        this.eventEmitter.removeUnicastEventListener(name, events[name])
      }
      if (eventName) {
        events[eventName] && cancelListening(eventName)
      } else {
        Object.keys(events).forEach((name) => {
          cancelListening(name)
        })
      }
    }
  }

  // 创建事件触发器
  public createBroadcaster<T extends Record<string, CallbackType>> () {
    return new Proxy<T>(({} as any), {
      get: (target, eventName) => {
        return (...args: any[]) => {
          return this.eventEmitter.emitBroadcast(eventName as string, ...args)
        }
      },
      set: () => {
        return false
      }
    })
  }
}

这样我们就可以像直接调用对象的方法一样触发事件,且对typescript友好

interface  BroadcastEvents {
  print: (text: string) => void
}

const stop = socket1.onBroadcast<BroadcastEvents>({
  print(text) {
    console.log(text)
  }
})

const broadcaster = socket2.createBroadcaster<BroadcastEvents>()
broadcaster.print('Hello World')

状态通信

@vue/reactivity

@vue/reactivity是vue3中处理响应式的独立模块,与Vue框架不绑定,可以单独使用。这个模块没有官方文档,可以自己去源码中查使用方式,也可以参考大佬总结好的深入理解 Vue3 Reactivity API

我们主要关注reactivereadonlyeffect这三个api

  • reactive:接收一个普通对象作为参数,返回该参数被升级为响应式对象后的proxy
  • readonly:用法同reactive,只是返回的proxy不允许写入
  • effect:接收两个参数,第一个参数是依赖收集函数,会在执行时将函数内用到的响应式对象收集为依赖。第二个参数是一些配置选项,我们用到的有两个选项:
    • lazy:默认为false,当lazytrue时,不会立即执行依赖函数,需要手动执行
    • scheduler:依赖更新时的副作用函数

举个例子🌰 :

const count = reactive({ value: 0 })
const runnner = effect(() => { // 依赖收集函数
  return count.value
}, {
  lazy: true,
  scheduler: () => { // 副作用函数
    console.log('count changed')
  }
})
console.log(runner()) // 执行依赖收集函数,打印0
count.value++ // count changed
count.value++ // count changed

初始化和读写状态

掌握上面的基础知识,我们就可以开始实现我们的状态管理模块了

import { reactive, readonly } from '@vue/reactivity'

class Socket {
  // ...省略部分代码
  private stores

  // 初始化状态
  public initState(namespace, value, isPrivate = false) {
    if (!this.stores[namespace]) {
      this.stores[namespace] = {
        state: reactive(value), // 状态值
        owner: isPrivate ? this : null, // 初始化状态的socket实例,如果是公共状态,则owner为null
        watchers: [] // watcher实例,下文会提到
      }
    }
  }

  // 获取状态
  public getState(namespace, getter?) {
    if (this.stores[namespace]) {
      const owner = this.stores[namespace].owner
      if (owner === this || owner === null) { // 不能修改其他socket初始化的私有状态
        const state = readonly(this.stores[namespace].state) // getState得到的状态是只读的
        return getter ? getter(state) : state 
      }
    }
  }

    // 修改状态
  public setState(namespace, action: string, setter) {
    if (this.stores[namespace] && action) { // 修改状态时,必须有描述本次操作的字符串(action)
      setter(this.stores[namespace].state)
    }
  }
}

状态的初始化以及读写的逻辑都比较简单。在设计Rallie的状态管理时,我们在规范性和易用性上取了一个折中,只允许通过setState方法修改状态,且在修改状态时需要提供描述本次操作的字符串,如果把状态声明为私有状态的话,还不允许其他socket实例直接修改状态。这样既提供了一定的规范,又不会像redux一样有太多的样板代码,后续Rallie会添加devtools工具以提升开发体验。

观察状态

然后我们来实现watchState方法。

class Watcher {
  private namespace
  private stores
  public oldWatchingStates // 存储上一次的状态值
  public handler // 状态变化时的副作用函数
  public stopEffect // 停止副作用的函数

  constructor (namespace, stores) {
    this.namespace = namespace
    this.stores = stores
    this.stores[namespace].watchers.push(this)
  }

  public do (handler: (watchingStates: T, oldWatchingStates: T) => void | Promise<void>) {
    this.handler = handler // 指定副作用函数
    return () => this.unwatch()
  }

  public unwatch () {
    this?.stopEffect()
    this.handler = null
    const index = this.stores[this.namespace].watchers.indexOf(this)
    index !== -1 && this.stores[this.namespace].watchers.splice(index, 1)
  }
}

class Socket {
  // ...省略部分代码
  private stores

  public watchState(namespace, getter) {
    if (this.stores[namespace]) {
      const state = readonly(this.stores[namespace].state)
      const watcher = new Watcher(namespace, this.stores)
      let dirty = false // 脏检查标志位
      const runner = effect(() => getter(state), {
        lazy: true,
        scheduler: () => {
          if (!dirty) { // 如果触发了副作用,则把脏检查标志位记为true,在微任务队列中执行监听回调
            dirty = true
            Promise.resolve().then(() => {
              const watchingState = getter(state)
              watcher.handler?.(watchingState, watcher.oldWatchingStates)
              watcher.oldWatchingStates = watchingState
              dirty = false
            })
          }
        }
      })
      watcher.oldWatchingStates = runner()
      watcher.stopEffect = () => runner.effect.stop()
      return watcher
    }
  }
}

我们通过@vue/reactivityeffect方法收集依赖,通过返回的watcher指定监听函数,从而让我们既可以通过链式调用实现类似Vuewatch的效果

const unwatch = socket
    .watchState('counter', counter.value) // 指定依赖
    .do((newCount, oldCount) => { // 指定监听回调
      console.log(newCount, oldCount)
    })
socket.setState('counter', 'add count', (counter) => counter.value++)
unwatch() // 停止监听

也可以把监听函数当作依赖收集函数,实现类似VuewatchEffect的效果

const watcher = socket.watchState('counter', (counter) => {
  console.log(counter.value)
})
socket.setState('counter', 'add count', (counter) => counter.value++)
watcher.unwatch() // 停止监听

另外,我们并非直接在sheduler中执行监听回调,而是在副作用第一次被触发时通过Promise.resolve将监听回调放入微任务队列中,同时将脏检查标志位记为true,防止后续的副作用重复执行监听回调。这样我们就保证了在一轮事件循环中只会触发一次监听回调,从而提升了性能。

socket.initState('counter', { value: 0 })

socket
  .watchState('counter', counter => counter.value)
  .do(val => console.log(`counter的值被修改为${val}`))

socket.setState('counter', 'add counter multiple times', (counter) => {
  counter.value++
  counter.value++
  counter.value++
})

// setState三次修改了counter.value, 但是只会打印一次'counter的值被修改为3'

总结

这就是@rallie/coreSocket模块的实现,在实际使用rallie时,你没有实际用到Socket对象,但是rallie中,app的eventsmethods分别就是socket的广播和单播,app的state就是socket的状态。因此实际上我们已经实现了app对外提供的"服务"功能,下一篇文章我会继续介绍app的生命周期以及依赖和关联功能的实现

下一篇:一个微前端库的诞生-2 | 实现App的管理和调度