likes
comments
collection
share

BroadcastChannel在我业务场景的使用

作者站长头像
站长
· 阅读数 43

简介

因为编辑器的collab协同功能需要每个浏览器Tab都要有一个唯一的agentuserid浏览器指纹。那么接下来就分享一下我是如何在我的项目里结合BroadcastChannel实现这个agentUserId

BroadcastChannel

Broadcast Channel API 可以实现同  下浏览器不同窗口,Tab 页,frame 或者 iframe 下的 浏览器上下文 (通常是同一个网站下不同的页面) 之间的简单通讯。 BroadcastChannel在我业务场景的使用

BroadcastChannel与 window.postMessage

  • BroadcastChannel与window.postMessage都是跨页面通信的
  • BroadcastChannel只允许同源下的不同窗口通信、window.postMessage则可以安全地实现跨源通信
  • BroadcastChannel由于受到同源条件的限制因此比window.postMessage更加安全。一般情况下推荐使用BroadcastChannel除非需要做到非同源之间的通信

需求分析

数据结构:一个用户userId对应多个agentUserId(代表打开多个Tab浏览器窗口)

  1. 同一个用户下的agentUserId列表需要实现可复用,因为数据也会在mongo里进行存储,所以尽量复用减少mongo的数量存储量(localstoage存储)
  2. 用户关闭tab即要回收agentuserid,等有新开的tabs将空闲的agentuserid分配给新打开的agentuserid
  3. 中途不可以变更,直至页面生命周期完全结束(因为collab协同插件不允许中途更换clientID)

功能实现图如下: BroadcastChannel在我业务场景的使用

其他方案尝试

  1. 考虑使用sessionStorage虽然符合整个页面的生命周期,但并不满足因为他没办法实现agentuserid的重复利用
  2. 考虑使用Node中间层来管理agentuserid的分配,当用户下线offline进行回收agentuserid。(当在网络环境不好的情况下,用户断线又重新上线时客户端ws的disconnect立即执行了,但是nest端的handleDiasconnect并没有立即执行而是等待几分钟之后才执行。导致agentuserid没有及时回收。就算及时回收也可能导致agentuserid分配到的不是之前,导致协同插件中途报错)

经过如上方案考量决定使用BroadcastChannel通信,将管理agentuserid的生成逻辑完全由客户端来实现。再次我们需要解决一些问题:

  1. 当两个页面同时刷新如何解决BroadcastChannel的并发问题,此时双方都没有反馈导致分配到同一个agentuserid。采用二次确认的方式来实现
  2. 减少因为其他因素导致BroadcastChannel注册时机不稳定问题。例如将注册时机放在http请求之后。为什么呢? 请求响应的时长会受到一些外在因素影响例如网络环境。如果把BroadcastChannel注册时机放在http请求之后会增加BroadcastChannel的并发的可能性

开始干活

TabsBroadcaster类与TaskItem类的封装

第一步: 定义一个TabsBroadcaster类并且在初始化的时候创建BroadcastChannel实例,以及监听BroadcastChannel的message事件。该TabsBroadcaster类的参数有name通道名称、timeout超时时间多少ms未响应表示超时

class TabsBroadcaster {
    private channel: BroadcastChannel;
    constructor(name: string, timeout = 2000) {
      this.name = name;
      this._messageId = 0;
      // 浏览器断线之后,照常通信
      this.channel = new BroadcastChannel(name);
      this.channel.onmessage = (event) => this.receive(event);
    }
    private receive(event: MessageEvent) {
        ...
    }
}

第二步: 声明一个TaskItem类并且在内TabsBroadcaster类中定义一个_messages属性用于保存每一个task任务。每一个task代表BroadcastChannel的生命周期danswer的生命周期。

class TabsBroadcaster {
    ...
    private _messages: Array<TaskItem>;
    constructor(name: string, timeout = 2000) {
        ...
        this._messages = []; 
    }
}

class TaskItem {
  public _resolve!: (value: unknown) => void;
  public _reject!: (reason?: any) => void;
  private _interval;
  public id: string;
  constructor(id: string, timeout: number) {
    this.id = id;
    this._interval = setTimeout(
      () => this.resolve({ active: false, reason: 'timeout, no one uses it' }),
      timeout,
    );
  }
  
  // 超时会触发resolve事件
  public resolve(value: unknown) {
    clearTimeout(this._interval);
    this._resolve(value);
  }
  
  // 当有tabs使用了询问的id则触发reject
  public reject(reason?: any) {
    this._reject(reason);
  }
}

第三步:TabsBroadcaster类中定义setFilter方法,在receive方法中用于判断是否为当前的窗口的agentUserId

  private _filter?: (item: any) => boolean;
  
  public setFilter(filter: (item: any) => boolean) {
    this._filter = filter;
  }

第四步:TabsBroadcaster类中定义send方法。用于与其他Tabs进行通信。 TaskItem将在此时进行初始化。并且推入到this._messages中,会在后续的receive事件进行消费

  public send(content: any, timeout?: number): Promise<any> {
    const id = uuidv4();
    const task = new TaskItem(id, timeout || this._timeout);
    const promise = new Promise((resolve, reject) => {
      task._reject = reject;
      task._resolve = resolve;
    });
    this._messages.push(task);
    this.channel.postMessage({
      id,
      type: 'request',
      payload: content,
    });
    return promise;
  }

第五步:TabsBroadcaster类中定义answer方法。响应其他tabs的通信

  private answer(messageId: number, content: any) {
    this.channel.postMessage({
      id: messageId,
      type: 'response',
      payload: content,
    });
  }

第六步:TabsBroadcaster类中定义receive方法。接收到其他tabs推送过来的信息。

  1. 此时结合上面定义的this._filter来判断传递过来的agentUserId是否与当前的agentUserid一致。如果是调用this.answer告知请求方这个agentUserId我用啦
  2. 如果响应类型是response,表示接收方给我反馈消息啦,此时通过message.id找到task并且调用task?.reject从而让send方法进入到rejected状态(id已被使用你不能用了),从而不让task进入resolved(超时: 没人再使用当前询问的agentUserid)
  private receive(event: MessageEvent) {
    const data = event.data;
    if (data.type === 'request') {
      if (!this._filter || this._filter(data.payload)) {
        this.answer(data.id, { active: true });
      }
    } else if (data.type === 'response') {
      const task = this._messages.find((msg) => msg.id === data.id);
      // $ task可能不存在(不是这个tabs的相关响应)
      task?.reject(data.payload);
    }
  }
}

第七步: 定义一个findAvailableAgentUser方法,用于查找可用的agentUserid。

  1. 从localStorage获取到userAgentList列表数据
  2. 传入setFilter方法回调判断id是否一致
  3. 结合promise.all循环变量userAgentList列表,有一个状态为resolved结束
let currentAgentUserId: string;
let broadcaster: TabsBroadcaster | undefined;
async function 
(): Promise<IAgentUser | undefined> {
  if (!broadcaster) broadcaster = new TabsBroadcaster('AKclown', 200);
  currentAgentUserId = '';
  broadcaster.setFilter((item) => {
    return item.id === currentAgentUserId;
  });
  const agentUsers: Array<IAgentUser> =
    JSON.parse(localStorage.getItem(`agentUsers`) || '[]') || [];

  const requests: Promise<IAgentUser | undefined>[] = [];
  for (const item of agentUsers) {
    const response = broadcaster.send(item).then(() => {
      if (currentAgentUserId === '') {
        item.time = new Date().getTime();
        // $ 将这个agentUserId分配给当前用户
        currentAgentUserId = item.id;
        localStorage.setItem(`agentUsers`, JSON.stringify(agentUsers));
        return Promise.resolve(item);
      }
    });
    requests.push(response);
  }
  return Promise.any(requests);
}

使用

  useEffect(() => {
    find()
  }, [])
  
  async function find() {
    try {
      ...
      const agentId = await findAvailableAgentUser();
      ...
    } catch (error) {
      // 全部不可用,主动生成一个
      const agentId = uuidv4();
      storeAgentUserId(agentId)
    }
  }

二次确认 (重要)

在使用agentUserid时新增一个二次确认机制解决BroadcastChannel并发带来分配到同一个agentUserid的问题。

在没有加入二次确认之前tab(1)tab(2)都分配到d12.(复现场景: 同时刷新页面、同时打开tab页加载,高频复现) BroadcastChannel在我业务场景的使用 加入二次确认之后,虽然第一次第一次分配到d12,但经过二次确认之后发现d12被占用了又重新分配一个新的 BroadcastChannel在我业务场景的使用

/** 检查agentUser (用于二次确认) */
async function checkAgentUser(agentUser: IAgentUser) {
  if (!broadcaster) broadcaster = new TabsBroadcaster('AKclown', 500);
  let isAvailable = true;
  try {
    await broadcaster.send(agentUser);
    isAvailable = true;
  } catch (error) {
    // 有人在使用,触发reject逻辑
    isAvailable = false;
  }
  return isAvailable;
}

总结

以上就是使用BroadcastChannel来实现浏览器唯一标识的全过程。其实现了BroadcastChannel+TaskItem的优雅的封装写法。通过新增一个二次确认机制解决BroadcastChannel并发带来分配到同一个agentUserid的问题。知道了websocket客户端断开disconnect触发时机nest服务端handleDisConnect的触发时机不一致问题。 PS: 此次代码的demo,到此收工.JPG

转载自:https://juejin.cn/post/7251391372393824315
评论
请登录