• Project address: github.com/ralliejs/ra…
  • File address: rallie.js.cool/
  • Series of articles:
    • Birth of a Microfront-end library -0 | Rallie: Another possibility for a microfront-end

preface

In the last article, we mentioned that@rallie/coreFollow the following code architecture

Socket is the module in Rallie that is responsible for communication between applications. In this issue, we will look at its implementation.

The code in the article only gives the core implementation, and will omit some logic to deal with boundary cases and some type declarations. The complete implementation can refer to the source code in the project

Socket

Socket English original meaning is “Socket, Socket”, familiar with network protocol friends should not be unfamiliar with Socket, but we here Socket is not the real network communication handle, just borrow the name, to say that we want to achieve is an event and state communication link in the communication jack. It will be used like this:


const stores = {} // State center
const eventEmitter = new EventEmitter() // Event center

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

/* ** State communication */
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)
})

/* ** event communication */
/ / radio
socket1.onBroadcast({
  logText(text) {
    console.log(text)
  }
})
const broadcaster = socket2.createBroadcaster()
broadcaster.logText('Hello Rallie') // Trigger the logText event

/ / a unicast
socket1.onUnicast({
  getText() {
    return 'Hello Rallie'}})const unicaster = socket2.createUnicaster()
console.log(unicaster.getText()) // Trigger a unicast event
Copy the code

The Socket class we want to implement should look like this

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
}
Copy the code

Event communication

EventEmitter

EventEmitter is familiar enough that some interviewers will ask you to write an EventEmitter by hand when looking at the publish-subscribe model. Rallie’s EventEmitter implements both broadcast and unicast events.

radio

Broadcasting is a one-to-many relationship, that is, an event can have more than one listener. The general implementation of broadcast event mechanism is to maintain an event subscription pool, the listener event pushes the listener function into the subscription pool, the trigger event traverses the subscription pool, executes the listener function one by one, cancels the listener function to remove from the event pool. The downside of this implementation, however, is that if no event callback has been put into the subscription pool by the time the event is triggered, it will be ignored. Therefore, in addition to maintaining the subscription pool, we maintain an additional parameter pool. When an event is triggered, we first check whether there is a listening callback in the subscription pool. If not, we push the parameters of this triggered event into the parameter pool.

class EventEmitter {
  / /... Omit some code
  private broadcastEvents = {}

  // Listen for broadcast events
  public addBroadcastEventListener (event, callback) {
    this.broadcastEvents[event] = this.broadcastEvents[event] || {
      listeners: [].// Event subscription pool
      emitedArgs: [].// Event parameter pool
    }
    const { listeners, emitedArgs } = this.broadcastEvents[event]
    listeners.push(callback)
    if (emitedArgs.length > 0) { // If the event parameter pool is not empty, the event was triggered before it was listened on
      emitedArgs.forEach((args) = > {
        this.emitBroadcast(event, ... args) })this.broadcastEvents[event].emitedArgs = [] // Clear the parameter pool}}// Triggers a broadcast eventpublic emitBroadcast (event, ... args) {this.broadcastEvents[event] = this.broadcastEvents[event] || {
      listeners: [].emitedArgs: []}const { listeners, emitedArgs } = this.broadcastEvents[event]
    if (listeners.length > 0) {
      listeners.forEach((callback) = > { // If the event subscription pool is not empty, the subscription function is executed by traversing the subscription pool directlycallback(... args) }) }else {
      emitedArgs.push(args) // If the event subscription pool is empty, the event parameters will be pushed to the parameter pool, and the subscription function will be executed when a subscription function is added}}// Cancel listening on broadcast events
  public removeBroadcastEventListener (event, callback) {
    const registedcallbacks = this.broadcastEvents[event]? .listenersif (registedcallbacks) {
      let targetIndex = -1
      for (let i = 0; i < registedcallbacks.length; i++) { // Find the listener to unsubscribe from the subscription pool
        if (registedcallbacks[i] === callback) {
          targetIndex = i
          break}}if(targetIndex ! = = -1) {
        registedcallbacks.splice(targetIndex, 1) // Delete the listener function}}}}Copy the code

This allows us to implement a broadcast event transceiver that fires events regardless of whether the event has been subscribed

const event = new EventEmitter()

event.emitBroadcast('print'.'Hello World') / / trigger first
const callback = (text) = > {
  console.log(text)
}
event.addBroadcastEventListener('print', callback) // After listening, will print Hello World
event.removeBroadcastEventListener('print', callback) // Cancel the listener
Copy the code

unicast

Unicast is one-to-one, that is, an event has only one listener function, which means that the listener function can have a return value, and the return value of the listener function can be obtained when the event is triggered. It is for this reason that unicast should not support triggering before listening.

class EventEmitter {
    private unicastEvents = {}
    // Listen for unicast events
    public addUnicastEventListener (event, callback) {
      if (!this.unicastEvents[event]) {
        this.unicastEvents[event] = callback
      }
    }
    // Trigger a unicast eventpublic emitUnicast (event, ... args) {const callback = this.unicastEvents[event]
      if (callback) {
        returncallback(... args) } }// Cancel listening on unicast events
    public removeUnicastEventListener (event) {
      if (this.unicastEvents[event]) {
        delete this.unicastEvents[event]
      }
    }
Copy the code

We can use unicast events like this:

const event = new EventEmitter()
const callback = () = > {
  return 'Hello World'
}
// Listen on events
event.addUnicastEventListener('getText', callback)
// Trigger the event
const text = event.emitUnicast('getText')
console.log(text) // Hello World
// Cancel the listener
event.removeUnicastEventListener('getText', callback)
Copy the code

The Proxy package

Now that we’ve implemented the EventEmitter module, we still use strings as event names when we listen to and fire events, which is not typescript friendly, so we add another layer of wrapping to the event module in sockets. When you listen for an event, you pass in an object whose key is the event name and value is the listener function. When triggering an event, we create a Proxy object that fires events using eventEmitter in its GET method. An implementation of the encapsulated broadcast is given below. Unicast implementations are similar.

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

class Socket {
  / /... Omit some code
  private eventEmitter

  // Listen on events
  public onBroadcast<T extends Record<string, CallbackType>> (events: T) {
    Object.entries(events).forEach(([eventName, handler]) = > {
      // Listen on events
      this.eventEmitter.addBroadcastEventListener(eventName, handler)
    })
    return (eventName? :string) = > { // Returns the method to cancel listening
      const cancelListening = (name) = > {
        this.eventEmitter.removeUnicastEventListener(name, events[name])
      }
      if (eventName) {
        events[eventName] && cancelListening(eventName)
      } else {
        Object.keys(events).forEach((name) = > {
          cancelListening(name)
        })
      }
    }
  }

  // Create event trigger
  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}}}})Copy the code

This way we can fire events as if we were calling methods directly on the object, and is typescript-friendly

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')
Copy the code

State of the communication

@vue/reactivity

@vue/ reActivity is an independent module for handling responses in VUe3. It is not bound to the Vue framework and can be used independently. This module has no official documentation, you can go to the source code to check the use of the way, you can also refer to the boss summary of a good in-depth understanding of Vue3 Reactivity API.

We focus on reactive, Readonly, and Effect apis

  • reactive: Receives a common object as a parameter and returns the proxy after the parameter is upgraded to a responsive object
  • readonlyUsage: withreactive, but the returned proxy does not allow write
  • effect: takes two arguments. The first argument is the dependency collection function, which collects the reactive objects used in the function as dependencies at execution time. The second parameter is some configuration options. We use two options:
    • Lazy: The default value isfalsewhenlazyfortrue“, the dependent function will not be executed immediately and must be executed manually
    • Scheduler: A side effect function that relies on updates

For example 🌰 :

const count = reactive({ value: 0 })
const runnner = effect(() = > { // Rely on the collection function
  return count.value
}, {
  lazy: true.scheduler: () = > { // Side effect function
    console.log('count changed')}})console.log(runner()) // Execute the dependency collection function, printing 0
count.value++ // count changed
count.value++ // count changed
Copy the code

Initialization and read/write states

With the basics in hand, we can start implementing our state management module

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

class Socket {
  / /... Omit some code
  private stores

  // Initialization state
  public initState(namespace, value, isPrivate = false) {
    if (!this.stores[namespace]) {
      this.stores[namespace] = {
        state: reactive(value), / / state values
        owner: isPrivate ? this : null.// Initialize the socket instance in the state. If the socket instance is in the public state, the owner is null
        watchers: [] // The watcher instance, described below}}}// Get the status
  public getState(namespace, getter?) {
    if (this.stores[namespace]) {
      const owner = this.stores[namespace].owner
      if (owner === this || owner === null) { // Cannot change the private state of other socket initializations
        const state = readonly(this.stores[namespace]. State) // getState returns the read-only state getter? Getter (state) : state}}} public setState(namespace, action: string, setter) {
    if (this.stores[namespace] && action) { // To change the state, there must be a string (action) describing the operation.
      setter(this.stores[namespace].state)
    }
  }
}
Copy the code

The initialization of the state and the logic of reading and writing are simple. When designing Rallie’s state management, we made a compromise between standardization and ease of use, only allowing state modification through setState method, and providing a string describing the operation when modifying the state. If the state is declared as private, other socket instances are not allowed to directly modify the state. This provides certain specifications without the boilerplate code of Redux, and Rallie will later add DevTools to improve the development experience.

To observe the state of

Then we implement the watchState method

class Watcher {
  private namespacePrivate Stores public oldWatchingStates private Stores public oldWatchingStates Public Handler public stopEffect public oldWatchingStates 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 // Specify the side effect function
    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 {
  / /... Omit some code
  private stores

  public watchState(namespace, getter) {
    if (this.stores[namespace]) {
      const state = readonly(this.stores[namespace].state)
      const watcher = new Watcher(namespace, this.stores)
      const clone = (val: any) => isPrimitive(val) ? val : JSON.parse(JSON.stringify(val))
      const runner = effect(() => getter(state), {
        lazy: true.scheduler: () = > {
          constwatchingState = getter(state) watcher.handler? .(watchingState, watcher.oldWatchingStates)// Execute the side effect function
          watcher.oldWatchingStates = clone(watchingState)
        }
      })
      watcher.oldWatchingStates = clone(runner())
      watcher.stopEffect = () = > runner.effect.stop()
      return watcher
    }
  }
}
Copy the code

We collect the dependencies through the @vue/reactivity effect method and specify the listener function through the returned watcher, which allows us to achieve the effect similar to watch in VUE through chaining calls

const unwatch = socket
    .watchState('counter', counter.value) // Specify dependencies
    .do((newCount, oldCount) = > { // Specify the listening callback
      console.log(newCount, oldCount)
    })
socket.setState('counter'.'add count'.(counter) = > counter.value++)
unwatch() // Stop listening
Copy the code

You can also treat the listener function as a dependency collection function to achieve a watchEffect effect similar to Vue

const watcher = socket.watchState('counter'.(counter) = > {
  console.log(counter.value)
})
socket.setState('counter'.'add count'.(counter) = > counter.value++)
watcher.unwatch() // Stop listening
Copy the code

conclusion

This is the implementation of Socket module in @rallie/core. When you actually use Rallie, you don’t actually use Socket object, but in Rallie, events and methods of APP are broadcast and unicast of Socket respectively. App state is the state of the socket. So we’ve actually implemented the “service” functionality that the app provides to the outside world. In the next article I’ll continue with the lifecycle of the app and the implementation of dependencies and associations