博客 / 詳情

返回

Taro 源碼揭秘:5.高手都在用的發佈訂閲機制 Events 在 Taro 中是如何實現的?

1. 前言

大家好,我是若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。

截至目前(2024-08-18),taro 4.0 正式版已經發布,目前最新是 4.0.4,官方4.0正式版本的介紹文章暫未發佈。官方之前發過Taro 4.0 Beta 發佈:支持開發鴻蒙應用、小程序編譯模式、Vite 編譯等。

計劃寫一個 Taro 源碼揭秘系列,博客地址:https://ruochuan12.github.io/taro 可以加入書籤,持續關注若川。

  • [x] 1. 揭開整個架構的入口 CLI => taro init 初始化項目的秘密
  • [x] 2. 揭開整個架構的插件系統的秘密
  • [x] 3. 每次創建新的 taro 項目(taro init)的背後原理是什麼
  • [x] 4. 每次 npm run dev:weapp 開發小程序,build 編譯打包是如何實現的?
  • [x] 5. 高手都在用的發佈訂閲機制 Events 在 Taro 中是如何實現的?
  • [x] 6. 為什麼通過 Taro.xxx 能調用各個小程序平台的 API,如何設計實現的?
  • [x] 7. Taro.request 和請求響應攔截器是如何實現的
  • [x] 8. Taro 是如何使用 webpack 打包構建小程序的?
  • [x] 9. Taro 是如何生成 webpack 配置進行構建小程序的?
  • [x] 10. Taro 到底是怎樣轉換成小程序文件的?
  • [ ] 等等

前面 4 篇文章都是講述編譯相關的,CLI、插件機制、初始化項目、編譯構建流程。第 5 篇我們來講些相對簡單的,Taro 是如何實現發佈訂閲機制 Events 的。

學完本文,你將學到:

1. 瞭解發佈訂閲機制
2. 瞭解 taro 一些 npm 包的作用,尋找到 Events 源碼
3. Taro 源碼中發佈訂閲機制 Events 是如何實現的
等等

2. Taro 消息機制

Taro 消息機制文檔上,Taro 提供了消息機制 Events,用來實現組件間通信。我們來學習下如何實現的。

Taro 提供了 Taro.Events 來實現消息機制,使用時需要實例化它,如下

import Taro, { Events } from '@tarojs/taro'

const events = new Events()

// 監聽一個事件,接受參數
events.on('eventName', (arg) => {
  // doSth
})

// 監聽同個事件,同時綁定多個 handler
events.on('eventName', handler1)
events.on('eventName', handler2)
events.on('eventName', handler3)

// 觸發一個事件,傳參
events.trigger('eventName', arg)

// 觸發事件,傳入多個參數
events.trigger('eventName', arg1, arg2, ...)

// 取消監聽一個事件
events.off('eventName')

// 取消監聽一個事件某個 handler
events.off('eventName', handler1)

// 取消監聽所有事件
events.off()

同時 Taro 還提供了一個全局消息中心 Taro.eventCenter 以供使用,它是 Taro.Events 的實例

import Taro from '@tarojs/taro'

Taro.eventCenter.on
Taro.eventCenter.trigger
Taro.eventCenter.off

Vue2 中也有類似的事件 events api $on$off$once$emit,不過 Vue3 移除了。
vue2 events

也有一些 npm 包,如:mitt、tiny-emitter

源碼共讀也有一期第8期 | mitt、tiny-emitter 發佈訂閲

3. 根據文檔使用實現 Events

文檔中,主要有如下幾個需求點:

  • 監聽同個事件,同時綁定多個 handler
  • 觸發事件,傳入多個參數
  • 取消監聽一個事件某個 handler
  • 取消監聽所有事件

我們可以先自行實現一個符合要求的 Events 類,然後再去 Taro 源碼中尋找實現,最後可以對比各自的實現優缺點。

3.1 初步實現 Events

class Events {
    constructor(){
        this.callbacks = [];
    }
    on(eventName, callback){
        this.callbacks.push({
            eventName,
            callback,
        });
    }
    off(){}
    trigger(){}
}

我們用 callbacks 數組來存儲事件,push 方法用來添加事件,支持多個同名的 eventName

3.2 off 方法實現

off(eventName, callback){
    this.callbacks = this.callbacks.filter((item) => {
        if(typeof eventName === 'string'){
            if(typeof callback === 'function'){
                return !(item.eventName === eventName && item.callback === callback);
            }
            return item.eventName !== eventName;
        }
        return false;
    });
}

off 方法用來取消監聽事件,如果傳入 eventName 參數,則取消監聽該事件,如果還傳入了特定的 handler,則只取消監聽這個 handler。否則取消所有事件。

3.3 trigger 方法實現

trigger(eventName, ...args){
    this.callbacks.forEach((item) => {
        if(item.eventName === eventName){
            item.callback(...args);
        }
    });
}

trigger 傳入 eventName 和參數,遍歷所有事件,如果 eventName 匹配,則執行 handler

Taro events 自行實現所有代碼 demo,可打開調試運行

4. 在茫茫源碼中尋找 class Events 實現

文檔示例:

import Taro, { Events } from '@tarojs/taro'

const events = new Events()

Taro.eventCenter.on
Taro.eventCenter.trigger
Taro.eventCenter.off

@tarojs/taro 對應的源碼路徑是 taro/packages/taro

4.1 @tarojs/taro 暴露者開發者的 Taro 核心 API

暴露給應用開發者的 Taro 核心 API。包含以下小程序端入口文件 index.js 等。
const { hooks } = require('@tarojs/runtime')
const taro = require('@tarojs/api').default

if (hooks.isExist('initNativeApi')) {
  hooks.call('initNativeApi', taro)
}

module.exports = taro
module.exports.default = module.exports

@tarojs/api 對應的源碼路徑是 taro/packages/taro-api

4.2 @tarojs/api 所有端的公有 API

暴露給 @tarojs/taro 的所有端的公有 API。@tarojs/api 會跨 node/瀏覽器/小程序/React Native 使用,不得使用/包含平台特有特性。

入口文件:packages/taro-api/src/index.ts

// packages/taro-api/src/index.ts
/* eslint-disable camelcase */
import { Current, eventCenter, Events, getCurrentInstance, nextTick, options } from '@tarojs/runtime'

// 省略若干代碼

const Taro: Record<string, unknown> = {
  // 省略若干代碼
  Current, getCurrentInstance, options, nextTick, eventCenter, Events,
}

// 省略若干代碼

export default Taro

這個文件代碼不多,省略了一部分。默認導出Taro,其中 eventCenter,Events是從 @tarojs/runtime 引入的。

@tarojs/runtime 對應的源碼路徑是 taro/packages/taro-runtime

4.3 @tarojs/runtime Taro 運行時

Taro 運行時。在小程序端連接框架(DSL)渲染機制到小程序渲染機制,連接小程序路由和生命週期到框架對應的生命週期。在 H5/RN 端連接小程序生命週期規範到框架生命週期。

Events Taro 消息機制。

// packages/taro-runtime/src/index.ts
export * from './emitter/emitter'
// packages/taro-runtime/src/emitter/emitter.ts
import { Events, hooks } from '@tarojs/shared'

const eventCenter = hooks.call('getEventCenter', Events)!

export type EventsType = typeof Events
export { eventCenter, Events }

@tarojs/shared 對應的源碼路徑是 taro/packages/shared

4.4 @tarojs/shared 內部使用的 utils

Taro 內部使用的 utils。包含了常用的類型判斷、錯誤斷言、組件類型/聲明/參數等。@tarojs/shared 會跨 node/瀏覽器/小程序/React Native 使用,不得使用平台特有特性。

引入此包的必須採用 ES6 引用單個模塊語法,且打包配置 external 不得包括此包。

// packages/shared/src/index.ts
// Events 導出的位置
export * from './event-emitter'
// hooks 導出的位置
export * from './runtime-hooks'

5. class Events 的具體實現

終於在 packages/shared/src/event-emitter.ts 找到了 class Events 的實現代碼。

// packages/shared/src/event-emitter.ts
type EventName = string | symbol
type EventCallbacks = Record<EventName, Record<'next' | 'tail', unknown>>

export class Events {
  protected callbacks?: EventCallbacks
  static eventSplitter = ',' // Note: Harmony ACE API 8 開發板不支持使用正則 split 字符串 /\s+/

  constructor (opts?) {
    this.callbacks = opts?.callbacks ?? {}
  }
  // 省略這幾個方法具體實現,拆分到下方講述
  on(){}
  once(){}
  off(){}
  trigger(){}
}

eventSplitter 事件分割符 ,
callbacks 對象存儲事件名和回調函數。

5.1 on 事件監聽

on (eventName: EventName, callback: (...args: any[]) => void, context?: any): this {
    let event: EventName | undefined, tail, _eventName: EventName[]
    // 如果沒傳 callback 函數,則直接返回 this
    if (!callback) {
        return this
    }
    // 支持 symbol 事件名寫法
    // 也支持 事件名1,事件名2,事件名3 的寫法
    if (typeof eventName === 'symbol') {
        _eventName = [eventName]
    } else {
        // 事件名1,事件名2,事件名3 分割成數組
        _eventName = eventName.split(Events.eventSplitter)
    }
    this.callbacks ||= {}
    const calls = this.callbacks
    // 遍歷事件名數組
    while ((event = _eventName.shift())) {
        const list = calls[event]
        const node: any = list ? list.tail : {}
        node.next = tail = {}
        node.context = context
        node.callback = callback
        calls[event] = {
            tail,
            next: list ? list.next : node
        }
    }
    // return this 支持鏈式調用
    return this
}

這裏可能有點抽象,我們舉個例子調試下:

我們直接找到打包後的代碼,路徑:taro/packages/shared/dist/event-emitter.js,註釋// export { Events };,追加如下代碼:

function fn1(){}
function fn2(){}
function fn3(){}
function fn4(){}
const events = new Events()
.on('eventName1', fn1)
.on('eventName1', fn2)
.on('eventName1', fn3)
.on('eventName2', fn4);

console.log(events.callbacks);

打開終端,新建 JavaScript Debug Terminal 調試,運行:

node packages/shared/dist/event-emitter.js

調試截圖如下:

調試 events

複製監視的 events.callbacks 的值,它的對象存儲如下結構:

{
  eventName1: {
    tail: {
    },
    next: {
      next: {
        next: {
          next: {
          },
          context: undefined,
          callback: function fn3(){},
        },
        context: undefined,
        callback: function fn2(){},
      },
      context: undefined,
      callback: function fn1(){},
    },
  },
  eventName2: {
    tail: {
    },
    next: {
      next: {
      },
      context: undefined,
      callback: function fn4(){},
    },
  },
}

events callbacks 結構圖

也就是鏈表形式。同名的事件名,會追加到鏈表的 next 節點。所以同名的事件名,可以觸發多個 callback 函數。

5.2 once 事件監聽只執行一次

once (events: EventName, callback: (...r: any[]) => void, context?: any): this {
    const wrapper = (...args: any[]) => {
        callback.apply(this, args)
        this.off(events, wrapper, context)
    }

    // 執行一次後,調用 off 方法移除事件
    this.on(events, wrapper, context)

    return this
}

執行一遍後,調用 off 方法移除事件。

5.3 off 事件移除

off (events?: EventName, callback?: (...args: any[]) => void, context?: any) {
    let event: EventName | undefined, calls: EventCallbacks | undefined, _events: EventName[]
    if (!(calls = this.callbacks)) {
        return this
    }
    if (!(events || callback || context)) {
        delete this.callbacks
        return this
    }
    // 如果是 symbol 事件名,組成數組
    if (typeof events === 'symbol') {
        _events = [events]
    } else {
        // 事件名1,事件名2,事件名3 分割成數組
        // 沒有傳事件名,則移除所有事件
        _events = events ? events.split(Events.eventSplitter) : Object.keys(calls)
    }
    // 遍歷事件名數組
    while ((event = _events.shift())) {
        let node: any = calls[event]
        // 刪除事件對象
        delete calls[event]
        if (!node || !(callback || context)) {
            // 刪除,進行下一次循環
            continue
        }
        const tail = node.tail
        while ((node = node.next) !== tail) {
            const cb = node.callback
            const ctx = node.context
            if ((callback && cb !== callback) || (context && ctx !== context)) {
                this.on(event, cb, ctx)
            }
        }
    }
    return this
}

5.4 trigger 事件觸發

trigger (events: EventName, ...args: any[]) {
    let event: EventName | undefined, node, calls: EventCallbacks | undefined, _events: EventName[]
    // callbacks 對象不存在,則直接返回 this
    if (!(calls = this.callbacks)) {
        return this
    }
    // 如果是 symbol 事件名,組成數組 [events]
    if (typeof events === 'symbol') {
        _events = [events]
    } else {
        // 事件名1,事件名2,事件名3 分割成數組
        _events = events.split(Events.eventSplitter)
    }
    // 遍歷事件名數組
    // 遍歷鏈表,依次執行回調函數
    while ((event = _events.shift())) {
        if ((node = calls[event])) {
            const tail = node.tail
            while ((node = node.next) !== tail) {
                node.callback.apply(node.context || this, args)
            }
        }
    }
    return this
}

遍歷鏈表,依次執行回調函數。
tail 結尾作為判斷,到末尾了,終止遍歷。

接着我們來學習 eventCenter 全局消息中心的實現。

6. eventCenter 全局消息中心

// packages/taro-runtime/src/emitter/emitter.ts
import { Events, hooks } from '@tarojs/shared'
const eventCenter = hooks.call('getEventCenter', Events)!

7. hooks

根據上文的信息,我們可以找到 hooks 對象的代碼位置是 packages/shared/src/runtime-hooks.ts

// packages/shared/src/runtime-hooks.ts
import { Events } from './event-emitter'
import { isFunction } from './is'

import type { Shortcuts } from './template'

// Note: @tarojs/runtime 不依賴 @tarojs/taro, 所以不能改為從 @tarojs/taro 引入 (可能導致循環依賴)
type TFunc = (...args: any[]) => any

// hook 類型
export enum HOOK_TYPE {
  SINGLE,
  MULTI,
  WATERFALL
}

// hook 對象
interface Hook {
  type: HOOK_TYPE
  initial?: TFunc | null
}

// Node 對象
interface Node {
  next: Node
  context?: any
  callback?: TFunc
}

// TaroHook 函數
export function TaroHook (type: HOOK_TYPE, initial?: TFunc): Hook {
  return {
    type,
    initial: initial || null
  }
}

這段代碼聲明瞭一些 TS 接口和類型等,TaroHook 函數返回一個 Hook 對象。

// packages/shared/src/runtime-hooks.ts
type ITaroHooks = {
  /** 小程序端 App、Page 構造對象的生命週期方法名稱 */
  getMiniLifecycle: (defaultConfig: MiniLifecycle) => MiniLifecycle
  // 省略若干代碼
  /** 解決支付寶小程序分包時全局作用域不一致的問題 */
  getEventCenter: (EventsClass: typeof Events) => Events
  // 省略若干代碼
  initNativeApi: (taro: Record<string, any>) => void
}

export const hooks = new TaroHooks<ITaroHooks>({
  getMiniLifecycle: TaroHook(HOOK_TYPE.SINGLE, defaultConfig => defaultConfig),
  // 省略若干代碼
  getEventCenter: TaroHook(HOOK_TYPE.SINGLE, Events => new Events()),
  // 省略若干代碼
  initNativeApi: TaroHook(HOOK_TYPE.MULTI),
})

hooks 對象是 TaroHooks 的實例對象。TaroHooks 繼承自 Events

我們來看 TaroHooks 的具體實現

8. class TaroHooks 的具體實現

// packages/shared/src/runtime-hooks.ts
export class TaroHooks<T extends Record<string, TFunc> = any> extends Events {
  hooks: Record<keyof T, Hook>

  constructor (hooks: Record<keyof T, Hook>, opts?) {
    super(opts)
    // 初始化 hooks 對象
    /**
     *
    {
        getMiniLifecycle,
        getEventCenter: {
            type: 0,
            initial: (Events) => new Events(),
        },
        initNativeApi,
        省略了一些其他hooks
    }

     *
     */
    this.hooks = hooks
    // 遍歷 hooks 對象,
    // 如果 initial 是函數,調用 this.on 方法,監聽事件
    // getEventCenter 的 type 是 SINGLE, initial 是函數 (Events) => new Events()
    for (const hookName in hooks) {
      const { initial } = hooks[hookName]
      if (isFunction(initial)) {
        this.on(hookName, initial)
      }
    }
  }

  private tapOneOrMany<K extends Extract<keyof T, string>> (hookName: K, callback: T[K] | T[K][]) {
    // 列表,變量
    const list = isFunction(callback) ? [callback] : callback
    list.forEach(cb => this.on(hookName, cb))
  }
  //   省略tap、call方法,拆開到下方講述
  isExist (hookName: string) {
    return Boolean(this.callbacks?.[hookName])
  }
}

8.1 tap 方法 - 監聽事件

tap<K extends Extract<keyof T, string>> (hookName: K, callback: T[K] | T[K][]) {
    const hooks = this.hooks
    const { type, initial } = hooks[hookName]
    if (type === HOOK_TYPE.SINGLE) {
      // 單個類型的hook,則取消監聽事件,重新監聽事件
      this.off(hookName)
      this.on(hookName, isFunction(callback) ? callback : callback[callback.length - 1])
    } else {
      // 不是,則取消監聽指定回調函數的事件,重新監聽一個或多個事件
      initial && this.off(hookName, initial)
      this.tapOneOrMany(hookName, callback)
    }
  }

tap 方法是監聽事件,hook類型是SINGLE類型時,直接取消,重新監聽。不是SINGLE類型時,則取消監聽指定回調函數的事件,重新監聽一個或多個事件。

8.2 call 方法 - 觸發事件

call<K extends Extract<keyof T, string>> (hookName: K, ...rest: Parameters<T[K]>): ReturnType<T[K]> | undefined {
    // 獲取 hooks 對象
    // call('getEventCenter', Events);
    const hook = this.hooks[hookName]
    if (!hook) return

    const { type } = hook

    // Events 對象 中的事件名稱、回調函數等對象
    const calls = this.callbacks
    if (!calls) return

    const list = calls[hookName] as { tail: Node, next: Node }

    if (list) {
      const tail = list.tail
      let node: Node = list.next
      let args = rest
      let res

      // 遍歷鏈表,依次執行回調函數
      //  判斷條件,節點不等於列表的末尾
      while (node !== tail) {
        res = node.callback?.apply(node.context || this, args)
        // 如果是 waterfall,則 args 是 [res]
        if (type === HOOK_TYPE.WATERFALL) {
          const params: any = [res]
          args = params
        }
        node = node.next
      }
      return res
    }
  }

因為 TaroHooks 是繼承自 Events,所以 call 實現和 Eventstrigger 觸發事件類似。
都是遍歷鏈表,依次執行回調函數。還有一個判斷,就是 typeHOOK_TYPE.WATERFALL 的時候,將返回值作為參數傳給下一個回調。

換句話説

// 調用 call 方法,觸發事件
import { Events, hooks } from '@tarojs/shared'
const eventCenter = hooks.call('getEventCenter', Events)!
/**
 *
    getEventCenter: {
        type: 0,
        initial: (Events) => new Events(),
    }
 *
 */

簡化之後,其實就是:

const events = new Events();
events.on('getEventCenter', (Events) => new Events())
events.trigger('getEventCenter', Events));

const eventCenter = new Events();

單從 getEventCenter 函數來看,好像有些多此一舉,為啥要這樣寫呢?可能是為了修復多個平台的bug。暫時不知道好處是啥,知道的讀者朋友也可以反饋告知。

9. 總結

我們通過文檔Taro 消息機制,瞭解到 Taro 提供了EventsTaro.eventCenter 對象,用於發佈訂閲。我們根據文檔也實現了。

我們在茫茫源碼中,尋找 class Events 的實現,依次在 @tarojs/taro => @tarojs/api => @tarojs/runtime => @tarojs/shared 層層查找,我們終於在 packages/shared/src/event-emitter.ts 找到了 class Events 的實現代碼。

class Events 用鏈表存儲事件,實現了 on、once、off、trigger 等方法,用於發佈訂閲。

Taro.eventCenter 對象其實也是 class Events 的實例,只不過繞了一圈,用 TaroHooks 實例 hooks.call('getEventCenter', Events) 來獲取。


如果看完有收穫,歡迎點贊、評論、分享、收藏支持。你的支持和肯定,是我寫作的動力。也歡迎提建議和交流討論

作者:常以若川為名混跡於江湖。所知甚少,唯善學。若川的博客,github blog,可以點個 star 鼓勵下持續創作。

最後可以持續關注我@若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.