跳转到内容

使用BroadcastChannel建立较为可靠的连接

如需安装所有解决方案,请参考全量解决方案安装

使用BroadcastChannel,同源的不同标签页之间可以相互通信,有的时候,一些操作需要在一个标签页面执行,然后广播给其他标签页。

比如: 和服务器建立WebSocket通信,只能一个标签页和服务器建立通信,这个时候就需要多个标签页之间协调出一个主标签页建立连接,将消息广播给其他标签页。

此方案基于BroadcastChannel:

  • 相对可靠的。
  • 动态更新tab标签页列表。
  • 主节点选举。
  • 事件监听。

实现流程如下

  1. 标签初始化:标签页面打开,连接BroadcastChannel时,先广播消息(id为当前时间+随机数)。
  2. 已存在标签回复:已经存在的标签页面广播通道收到广播消息,进行回复。
  3. ID 收集:收到回复后,将该标签页添加到id列表(friendChannelIdSet)中。
  4. ID列表确认:一定时间(300ms)后,根据收到的最新的id列表(friendChannelIdSet),可以知道都有那些标签页。
  5. 主节点选举:根据id列表信息(friendChannelIdSet),可以知道自己是否可以为主节点。
  6. 主节点职责
    • 定时发送主节点心跳广播信息。
    • 并将id列表信息保存为旧节点信息列表(_oldFrendChannelIdList)。
    • 清空id列表信息(friendChannelIdSet)。
    • 根据旧列表重新评估主状态;必要恢复成普通节点。
  7. 心跳监听
    • 回复主节点心跳。
    • 将id列表信息保存为旧节点信息列表(_oldFrendChannelIdList)。
    • 清空当前 ID 列表。
    • 将主节点添加到id列表中。
  8. 处理回复:所有监听此广播通道的标签页,收到主节点广播信息的回复信息后,将其对应id添加到id列表信息中(friendChannelIdSet
  9. 申请为主节点:其他监听此广播通道的非主节点标签页,每次收到主节点心跳广播信息,会设置一个超时时间,如果超时了没收到主节点信息,则认为主节点不在了:
    • 广播申请为主节点。
    • 一定时间内,如果收到其他标签页的拒绝信息,则不成为主节点,如果没收到拒绝信息,则成为主节点。
  10. 选举协商:收到其他标签页申请为主节点的消息后,根据自己的id,回复其是否可以为主节点。

单独安装此javascript类

shell
npm install @havue/bc-connect --save
shell
yarn add @havue/bc-connect
shell
pnpm install @havue/bc-connect

使用

引入

ts
import { BroadcastChannelManager } from 'havue'
// or
import { BroadcastChannelManager } from '@havue/solutions'
// or
import { BroadcastChannelManager } from '@havue/bc-connect'

示例

可复制当前页面地址,通过其他标签页打开,进行通信。

其他标签页实例id 列表
当前标签页实例 id:
当前标签页实例 类型:
发送消息:
目标实例id:
点我看代码
vue
<template>
  <div class="brdCnl-box">
    <div class="left-box">
      <div class="title">其他标签页实例id 列表</div>
      <div class="friend-item" v-for="friend in friendList" :key="friend">{{ friend }}</div>
    </div>
    <div class="right-box">
      <div class="current-info">当前标签页实例 id: {{ currentBcId }}</div>
      <div class="current-info">当前标签页实例 类型: {{ currentBcNodeType }}</div>
      <div class="options-box">
        <div class="form-item"><span class="label">发送消息:</span><input v-model="message" /></div>
        <button class="borad-btn" @click="handleBroadMessage">广播</button>
        <div class="form-item"><span class="label">目标实例id:</span><input v-model="targetId" /></div>
        <button class="sigle-btn" @click="handleBroadOneMessage">单发</button>
      </div>
      <div class="message-box">
        <div class="message-item" v-for="message in recieveMessageList" :key="message.id">
          <div class="id">id:{{ message.id }}</div>
          <div class="data">data: {{ message.data }}</div>
        </div>
      </div>
    </div>
  </div>
</template>
ts
<script setup lang="ts">
import type { BcConnectSendMessageType } from '@havue/bc-connect'
import { ref, onMounted, onBeforeUnmount } from 'vue'
// import { BroadcastChannelManager, BcConnectEventTypeEnum } from '@havue/bc-connect'
import { BroadcastChannelManager, BcConnectEventTypeEnum } from '@havue/solutions'

let bcManager: BroadcastChannelManager | null = null

const currentBcId = ref()
const currentBcNodeType = ref()

const friendList = ref<Array<number>>([])
const message = ref()
const targetId = ref()

const recieveMessageList = ref<Array<BcConnectSendMessageType>>([])

function handleBroadMessage() {
  if (!message.value) {
    alert('请输入消息')
    return
  }
  bcManager?.send('test-message', message.value)
}

function handleBroadOneMessage() {
  if (!message.value) {
    alert('请输入消息')
    return
  }
  if (!targetId.value) {
    alert('请输入目标id')
    return
  }
  bcManager?.sendToTarget('test-message', Number(targetId.value), message.value)
}

onMounted(() => {
  if (!bcManager) {
    bcManager = new BroadcastChannelManager('bc_test')
  }

  currentBcId.value = bcManager.id
  friendList.value = bcManager.friendList
  bcManager.connect()
  bcManager.on('test-message', (info) => {
    recieveMessageList.value.push(info)
  })
  bcManager.on(BcConnectEventTypeEnum.Friend_List_Update, (info) => {
    friendList.value = info.data || []
  })
  bcManager.on(BcConnectEventTypeEnum.Node_Type_Change, (info) => {
    currentBcNodeType.value = info.data
  })
})

onBeforeUnmount(() => {
  bcManager?.close()
  bcManager?.destroy()
  bcManager = null
})
</script>
scss
<style lang="scss" scoped>
.brdCnl-box {
  box-sizing: border-box;
  display: flex;
  width: 100%;
  height: 600px;
  color: black;
  background-color: #4d797c;

  .left-box {
    width: 30%;
    overflow: auto;

    .title {
      font-size: 18px;
      font-weight: bold;
    }

    .friend-item {
      padding: 6px 10px;
      margin: 5px;
      background-color: rgb(100 148 237);
    }
  }

  .right-box {
    display: flex;
    flex: 1;
    flex-direction: column;
    padding: 5px 10px;
    background-color: #4e7c4d;

    .current-info {
      padding: 3px 5px;
      background-color: #a8b9be;
    }

    .message-box {
      flex: 1;
      margin-top: 10px;
      overflow: auto;
      background-color: antiquewhite;

      .message-item {
        padding: 5px;
        margin: 5px;
        background-color: #8cbba9;

        .id {
          font-weight: bold;
        }

        .message {
          color: #44b15c;
        }
      }
    }

    .form-item {
      display: flex;
      align-items: center;
      margin: 5px 20px;

      .label {
        display: inline-block;
        width: 100px;
        text-align: right;
      }

      input {
        flex: 1;
        padding: 5px;
        font-size: 16px;
        color: #fff;
        background-color: black;
      }
    }

    button {
      width: 100%;
      color: black;
      background-color: #a8b9be;
    }
  }
}
</style>
源码
ts
// #region typedefine
/** 事件数据类型 */
export type BcConnectSendMessageType = {
  /** 事件类型 */
  type: string
  /** 数据 */
  data: any
  /** 发送事件的实例id */
  id: number
  /** 消息是发送给目标实例id的 */
  targetId?: number
}

/** 事件类型 */
export enum BcConnectEventTypeEnum {
  /** 初始广播 */
  Broadcast = 'Hello world',
  /** 回复初始广播 */
  Broadcast_Reply = 'I can hear you',
  /** 主节点心跳 */
  Main_Node_Hearbeat = '苍天还在,别立黄天',
  /** 回复主节点心跳 */
  Res_Main_Node_Hearbeat = '苍天在上,受我一拜',
  /** 长时间未收到主节点心跳,我想当主节点,你们同意吗 */
  Req_Be_Main_Node = '苍天已死,黄天当立',
  /** 排资论辈,我应是主节点,不同意 */
  Res_Be_Main_Node = '我是黄天,尔等退下',
  /** 当前BC节点类型更改 */
  Node_Type_Change = 'node type change',
  /** 其他标签页BC节点id列表更新 */
  Friend_List_Update = 'friend list update'
}

/** 当前webview BroadcastChannel节点类型 */
export enum BcConnectNodeTypeEnum {
  Main = 'main',
  Normal = 'normal'
}
// #endregion typedefine

// 消息超时时间
const MessageTimeout = 300

/**
 * 使用BroadcastChannel与其他标签页进行通信
 */
export class BroadcastChannelManager {
  /** 通道名称 */
  private _bcName: string
  /** BroadcastChannel实例 */
  private _broadcastChannel: BroadcastChannel | undefined = undefined
  /** 事件map */
  private _eventMap: Map<string, Array<(_: BcConnectSendMessageType) => void>>
  /** 主节点发送心跳的interval */
  private _mainNodeMsgInterval: number | null = null
  /** 认为主节点掉线的timeout */
  private _mainNodeMsgTimeoutTimer: number | null = null
  /** 更新友方列表的timeout */
  private _updateFriendListTimer: number | null = null
  /** 当前实例id */
  public id: number = Date.now() + Math.random()
  /** 记录的友方id数组 */
  private _oldFrendChannelIdList: Array<number> = []
  /** 正在更新的右方id数组 */
  private _friendChannelIdSet: Set<number> = new Set()
  /** 当前节点类型 */
  private _nodeType: BcConnectNodeTypeEnum | undefined = undefined

  /** 是否开启调试模式,会在控制台打印相关信息 */
  private _debug: boolean = false

  constructor(name: string, debug: boolean = false) {
    this._debug = debug
    this._bcName = name
    this._eventMap = new Map()
    this._debug && console.log('BC:id:', this.id)
  }

  get nodeType() {
    return this._nodeType
  }

  get friendList() {
    return [...this._oldFrendChannelIdList]
  }

  public connect() {
    this._debug && console.log('BC:bc connect')
    this.close()
    this._broadcastChannel = new BroadcastChannel(this._bcName)
    this._bindBroadcastChannelEvent()
    this._updateFriendList()
  }

  public close() {
    this._debug && console.log('BC:bc close')
    this._broadcastChannel && this._broadcastChannel.close()
    this._broadcastChannel = undefined
    this._updateFriendListTimer && clearTimeout(this._updateFriendListTimer)
    this._updateFriendListTimer = null
    this._mainNodeMsgInterval && clearInterval(this._mainNodeMsgInterval)
    this._mainNodeMsgInterval = null
    this._mainNodeMsgTimeoutTimer && clearTimeout(this._mainNodeMsgTimeoutTimer)
    this._mainNodeMsgTimeoutTimer = null
    this._oldFrendChannelIdList = []
    this._friendChannelIdSet.clear()
    this._nodeType = undefined
  }

  /**
   * 切换节点类型
   * @param {BcConnectNodeTypeEnum} type
   * @returns
   */
  private _setNodeType(type: BcConnectNodeTypeEnum) {
    if (this._nodeType === type) {
      return
    }
    this._nodeType = type
    this.emit(BcConnectEventTypeEnum.Node_Type_Change, {
      type: BcConnectEventTypeEnum.Node_Type_Change,
      data: type,
      id: this.id
    })
  }

  /** 更新友方列表 */
  private _updateFriendList() {
    // 广播告知己方存在
    this.send(BcConnectEventTypeEnum.Broadcast)

    this._updateFriendListTimer && clearTimeout(this._updateFriendListTimer)

    this._updateFriendListTimer = setTimeout(() => {
      this._oldFrendChannelIdList = this._getNewFriendList()
      this._debug && console.log('BC:connect:updateFriendChannelIdList:', this._oldFrendChannelIdList)
      this.emit(BcConnectEventTypeEnum.Friend_List_Update, {
        type: BcConnectEventTypeEnum.Friend_List_Update,
        data: [...this._oldFrendChannelIdList],
        id: this.id
      })
      this._updataNodeType()
    }, MessageTimeout)
  }
  /** 绑定事件 */
  private _bindBroadcastChannelEvent() {
    this._broadcastChannel &&
      (this._broadcastChannel.onmessage = (event) => {
        const { type, targetId } = event.data
        if (targetId && targetId !== this.id) {
          return
        }
        this.emit(type, event.data)
      })

    // 收到世界呼唤
    this.on(BcConnectEventTypeEnum.Broadcast, (data) => {
      const { id } = data
      if (!this._friendChannelIdSet.has(id)) {
        this._friendChannelIdSet.add(id)
      }

      this.sendToTarget(BcConnectEventTypeEnum.Broadcast_Reply, id)
    })

    // 收到友方存在
    this.on(BcConnectEventTypeEnum.Broadcast_Reply, (data) => {
      const { id } = data
      this._addFriend(id)
    })

    // 收到其他申请为主节点
    this.on(BcConnectEventTypeEnum.Req_Be_Main_Node, (data) => {
      const { id } = data
      if (id > this.id) {
        this.sendToTarget(BcConnectEventTypeEnum.Res_Be_Main_Node, id)
      }
    })

    // 收到其他节点回复主节点心跳
    this.on(BcConnectEventTypeEnum.Res_Main_Node_Hearbeat, (data) => {
      this._addFriend(data.id)
    })

    this._bindNodeEvent()
  }

  /** 监听节点类型切换事件 */
  private _bindNodeEvent() {
    const onMainNodeHearbeat = (data: BcConnectSendMessageType) => {
      this._timeoutToBeMainNode()
      this._catchOldFriend()
      this._addFriend(data.id)
      this.send(BcConnectEventTypeEnum.Res_Main_Node_Hearbeat)
    }

    this.on(BcConnectEventTypeEnum.Node_Type_Change, (info) => {
      const { data } = info
      this._mainNodeMsgInterval && clearInterval(this._mainNodeMsgInterval)
      this._debug && console.log('BC:代理类型切换:', info.data)
      if (data === BcConnectNodeTypeEnum.Main) {
        // 定时发送主节点心跳
        this._mainNodeMsgInterval = setInterval(() => {
          this._catchOldFriend()
          this.send(BcConnectEventTypeEnum.Main_Node_Hearbeat)
        }, MessageTimeout)
      } else if (data === BcConnectNodeTypeEnum.Normal) {
        this._timeoutToBeMainNode()
      }
      // 收到主节点心跳, 重新更新友方列表
      this.on(BcConnectEventTypeEnum.Main_Node_Hearbeat, onMainNodeHearbeat)
    })
  }

  /** 获取最新的友方列表 */
  private _getNewFriendList() {
    return [...this._friendChannelIdSet].sort((a, b) => a - b)
  }
  /**
   * 更新当前节点类型
   */
  private _updataNodeType() {
    this._mainNodeMsgInterval && clearInterval(this._mainNodeMsgInterval)
    if (this._oldFrendChannelIdList.length === 0 || Math.min(...this._oldFrendChannelIdList) > this.id) {
      if (this._nodeType === BcConnectNodeTypeEnum.Main) {
        return
      }
      this._setNodeType(BcConnectNodeTypeEnum.Main)
    } else {
      if (this._nodeType === BcConnectNodeTypeEnum.Normal) {
        return
      }
      this._setNodeType(BcConnectNodeTypeEnum.Normal)
    }
  }

  private _timeoutToBeMainNode() {
    this._mainNodeMsgTimeoutTimer && clearTimeout(this._mainNodeMsgTimeoutTimer)
    // 超时未收到心跳,认为主节点掉线,申请为主节点
    this._mainNodeMsgTimeoutTimer = setTimeout(() => {
      this._req_beMainNode()
    }, MessageTimeout * 3)
  }

  /**
   * 保持记录的活跃的友方id列表
   * 清空正在记录的友方id列表
   */
  private _catchOldFriend() {
    const newFriendList = this._getNewFriendList()
    if (this._oldFrendChannelIdList.join() !== newFriendList.join()) {
      this._debug && console.log('BC:updateFriendChannelIdList:', newFriendList)
      this.emit(BcConnectEventTypeEnum.Friend_List_Update, {
        type: BcConnectEventTypeEnum.Friend_List_Update,
        data: [...newFriendList],
        id: this.id
      })
      this._oldFrendChannelIdList = [...newFriendList]
    }

    if (this._nodeType === BcConnectNodeTypeEnum.Main && Math.min(...this._oldFrendChannelIdList) < this.id) {
      // 有更小的id,不再为主节点
      this._setNodeType(BcConnectNodeTypeEnum.Normal)
    }

    this._friendChannelIdSet.clear()
  }

  /**
   * 申请成为主节点
   */
  private _req_beMainNode() {
    this._debug && console.log('BC:req_beMainNode')

    // 向所有id友方节点发送申请
    this.send(BcConnectEventTypeEnum.Req_Be_Main_Node)

    // 如果长时间未回复,认为自己可以当主节点
    const timer = setTimeout(() => {
      this._setNodeType(BcConnectNodeTypeEnum.Main)
    }, MessageTimeout)

    // 收到拒绝回复,清空timeout
    const handleRes_beMainNode = () => {
      clearTimeout(timer)
      this.off(BcConnectEventTypeEnum.Res_Be_Main_Node, handleRes_beMainNode)
    }

    this.on(BcConnectEventTypeEnum.Res_Be_Main_Node, handleRes_beMainNode)
  }

  /**
   * 添加友方
   * @param id 节点id
   */
  public _addFriend(id: number) {
    if (!this._friendChannelIdSet.has(id)) {
      this._friendChannelIdSet.add(id)
    }
  }

  /**
   * 广播消息
   * @param type 消息类型
   * @param data 数据
   */
  public send(type: string, data?: any) {
    this._broadcastChannel?.postMessage({
      type,
      data,
      id: this.id
    })
  }

  /**
   * 给特定id的节点发送消息
   * @param type 消息类型
   * @param targetId 目标节点id
   * @param data 数据
   */
  public sendToTarget(type: string, targetId: number, data?: any) {
    this._broadcastChannel?.postMessage({
      type,
      data,
      id: this.id,
      targetId
    })
  }

  /**
   * 注册事件
   * @param { string } event 事件类型
   * @param callback 回调
   * @returns void
   */
  public on(event: string, callback: (_: BcConnectSendMessageType) => void) {
    const callbacks = this._eventMap.get(event)
    if (!callbacks) {
      this._eventMap.set(event, [callback])
      return
    }

    if (callbacks.includes(callback)) {
      return
    }
    callbacks.push(callback)
  }

  /**
   * 注销事件
   * @param { string } event 事件类型
   * @param callback 事件回调
   * @returns
   */
  public off(event: string, callback?: (_: BcConnectSendMessageType) => void) {
    const callbacks = this._eventMap.get(event)
    if (!callbacks) {
      return
    }

    if (!callback) {
      callbacks.length = 0
      return
    }

    const index = callbacks.indexOf(callback)
    callbacks.splice(index, 1)
  }

  /**
   * 触发事件
   * @param { string } event 事件类型
   * @param data 数据
   */
  public emit(event: string, data: BcConnectSendMessageType) {
    const callbacks = this._eventMap.get(event) || []

    callbacks.forEach((cb) => {
      cb(data)
    })
  }

  /**
   * 销毁
   */
  public destroy() {
    this._bcName = ''
    this._eventMap.clear()
    this._broadcastChannel?.close()
    this._broadcastChannel = undefined
    this._oldFrendChannelIdList = []
    this._friendChannelIdSet.clear()
    this.id = -1
    this._nodeType = undefined

    this._mainNodeMsgInterval && clearInterval(this._mainNodeMsgInterval)
    this._mainNodeMsgInterval = null
    this._mainNodeMsgTimeoutTimer && clearInterval(this._mainNodeMsgTimeoutTimer)
    this._mainNodeMsgTimeoutTimer = null
    this._debug && console.log('BC:destroy')
  }
}

BroadcastChannelManager类介绍

构造函数

属性说明参数类型
constructor构造函数, 参数为通道名称(name: string) => void

实例属性

属性说明(参数)类型
id实例idnumber
nodeType当前节点类型NodeTypeEnum | null
friendList存储的其他标签页通道实例id列表number[]
connect建立通信,监听通道消息() => void
close断开通信,不再监听通道消息() => void
destroy销毁实例() => void
send广播消息,其他实例会触发type事件(type: string, data?:any) => void
sendToTarget给特定id的实例广播消息(type: string, targetId: number, data?:any) => void
on监听特定事件(event: string, callback: (_: SendMessageType) => void) => void
off取消监听事件, 不传callback会清空事件(event: string, callback?: (_: SendMessageType) => void) => void
emit触发事件(event: string, data: SendMessageType) => void

类型定义

ts
/** 事件数据类型 */
export type BcConnectSendMessageType = {
  /** 事件类型 */
  type: string
  /** 数据 */
  data: any
  /** 发送事件的实例id */
  id: number
  /** 消息是发送给目标实例id的 */
  targetId?: number
}

/** 事件类型 */
export enum BcConnectEventTypeEnum {
  /** 初始广播 */
  Broadcast = 'Hello world',
  /** 回复初始广播 */
  Broadcast_Reply = 'I can hear you',
  /** 主节点心跳 */
  Main_Node_Hearbeat = '苍天还在,别立黄天',
  /** 回复主节点心跳 */
  Res_Main_Node_Hearbeat = '苍天在上,受我一拜',
  /** 长时间未收到主节点心跳,我想当主节点,你们同意吗 */
  Req_Be_Main_Node = '苍天已死,黄天当立',
  /** 排资论辈,我应是主节点,不同意 */
  Res_Be_Main_Node = '我是黄天,尔等退下',
  /** 当前BC节点类型更改 */
  Node_Type_Change = 'node type change',
  /** 其他标签页BC节点id列表更新 */
  Friend_List_Update = 'friend list update'
}

/** 当前webview BroadcastChannel节点类型 */
export enum BcConnectNodeTypeEnum {
  Main = 'main',
  Normal = 'normal'
}