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