BroadcastChannel在我业务场景的使用
简介
因为编辑器的collab协同功能需要每个浏览器Tab都要有一个唯一的agentuserid即浏览器指纹。那么接下来就分享一下我是如何在我的项目里结合BroadcastChannel实现这个agentUserId。
BroadcastChannel
Broadcast Channel API 可以实现同 源 下浏览器不同窗口,Tab 页,frame 或者 iframe 下的 浏览器上下文 (通常是同一个网站下不同的页面) 之间的简单通讯。

BroadcastChannel与 window.postMessage
- BroadcastChannel与window.postMessage都是跨页面通信的
- BroadcastChannel只允许
同源下的不同窗口通信、window.postMessage则可以安全地实现跨源通信 - BroadcastChannel由于受到
同源条件的限制因此比window.postMessage更加安全。一般情况下推荐使用BroadcastChannel除非需要做到非同源之间的通信
需求分析
数据结构:一个用户userId对应多个agentUserId(代表打开多个Tab浏览器窗口)
- 同一个用户下的agentUserId列表需要实现可复用,因为数据也会在mongo里进行存储,所以尽量复用减少mongo的数量存储量(localstoage存储)
- 用户关闭tab即要回收agentuserid,等有新开的tabs将空闲的agentuserid分配给新打开的agentuserid
- 中途不可以变更,直至页面生命周期完全结束(因为collab协同插件不允许中途更换clientID)
功能实现图如下:

其他方案尝试
- 考虑使用sessionStorage虽然符合整个页面的生命周期,但并不满足因为他没办法实现agentuserid的重复利用
- 考虑使用Node中间层来管理agentuserid的分配,当用户下线offline进行回收agentuserid。(当在网络环境不好的情况下,用户断线又重新上线时客户端ws的disconnect立即执行了,但是nest端的handleDiasconnect并没有立即执行而是等待几分钟之后才执行。导致agentuserid没有及时回收。就算及时回收也可能导致agentuserid分配到的不是之前,导致协同插件中途报错)
经过如上方案考量决定使用BroadcastChannel通信,将管理agentuserid的生成逻辑完全由客户端来实现。再次我们需要解决一些问题:
- 当两个页面
同时刷新如何解决BroadcastChannel的并发问题,此时双方都没有反馈导致分配到同一个agentuserid。采用二次确认的方式来实现 - 减少因为其他因素导致
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的生命周期d和answer的生命周期。
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推送过来的信息。
- 此时结合上面定义的
this._filter来判断传递过来的agentUserId是否与当前的agentUserid一致。如果是调用this.answer告知请求方这个agentUserId我用啦 - 如果响应类型是
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。
- 从localStorage获取到userAgentList列表数据
- 传入
setFilter方法回调判断id是否一致 - 结合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页加载,高频复现)
加入二次确认之后,虽然第一次第一次分配到d12,但经过二次确认之后发现d12被占用了又重新分配一个新的

/** 检查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