浏览器多标签页共享SSE

8 阅读1分钟

开发项目中,用到sse链接,当新开多个标签页(>=6)的时候会耗尽当前域名的TCP链接,导致其他请求无法发送。而且也会浪费服务器资源

解决方法:

  • http2,支持多路复用,一个TCP可以发起多个http请求
  • 使用子域名 example.com,example.a.com
  • 从代码层面解决问题,浏览器多标签页共享SSE

今天尝试从代码层面解决,需要解决两个问题

  1. 只能有一个标签页A发起连接
  2. A需要广播消息给其他标签页

只能有一个标签页A发起连接

navigator.locks 浏览器原生支持锁,函数返回一个promise,当promise兑现后释放锁,其他排队的回调才会被调用

private releaseLock: (() => void) | null = null

private attemptToBeLeader() {
  navigator.locks.request(this.url, async () => {
    this.isLeader = true // 标识是否是连接sse的主页面
    this.launch() // 发起请求

    await new Promise<void>(resolve => {
      this.releaseLock = resolve // 手动调用this.releaseLock可以释放锁
    })

    this.isLeader = false
  })
}

释放锁后下一个回调会自动执行,重新发起请求

确保只有一个标签页连接SSE服务器,当连接sse的标签页关闭/刷新(会释放锁),下一个排队的标签自动发起新的sse

广播消息

BroadcastChannel,消息发布订阅,同名的BroadcastChannel,当一个postmessage,其他会收到onmessage

constructor(
  public readonly url: string
) {
  if (!navigator.locks || !window.BroadcastChannel) {
    throw new Error('SharedPersistentConnection is not supported in this browser')
  }

  this.channel = new BroadcastChannel(this.url)

  this.channel.onmessage = (ev: MessageEvent<EventSourceMessage>) => {
    this.config.onmessage?.(ev.data)
  }

  this.state = ConnectionState.Connecting
  this.attemptToBeLeader()
}
// 发起请求
private launch() {
    fetchEventSource(this.url, {
      onmessage: (event) => {
      // 广播
        this.channel.postMessage(event)
      },
      onopen: (response) => {
        this.state = ConnectionState.Open
        return Promise.resolve()
      },
      onclose: () => {
        this.close()
      },
      onerror: (err) => {
        // 错误处理
      }
    })
}

整体流程

graph TD
    A[用户打开 Tab 1] --> B[尝试获取连接锁]
    C[用户打开 Tab 2] --> D[等待连接锁]
    E[用户打开 Tab 3] --> F[等待连接锁]
    
    B -->|获得锁| G[Tab 1 成为领导者]
    D -->|等待| H[广播通道监听]
    F -->|等待| H
    
    G --> I[建立实际 SSE 连接]
    I --> J[SSE 服务器]
    J -->|推送数据| K[领导者 Tab 1]
    K -->|通过 BroadcastChannel 广播| H
    H --> L[Tab 1 接收数据]
    H --> M[Tab 2 接收数据]
    H --> N[Tab 3 接收数据]

完整代码:Github