shareWroker封装webSocket实践方案

1,045 阅读6分钟

开发背景

前段时间接到一个需求,要实现PC网页与手机APP实时通信,在PC调用打电话的功能。开始我们只是在网页中使用webSocket并定时心跳检测,保证不断连。之后发现服务器性能跟不上,socket连接太多了,尤其用户打开另外一个浏览器页签运行同样会连接socket,所以考虑所有页签使用同一个socket。

技术介绍

首先简单介绍一下两项技术:

SharedWorker

SharedWorker 是 Web Workers API 的一部分,它允许在浏览器后台运行 JavaScript 代码,与主线程并行处理任务。与 Worker 不同的是,SharedWorker 可以被多个浏览上下文(如窗口、标签页或 iframe)共享。这意味着,一旦一个 SharedWorker 被创建,多个页面可以连接到它,并通过消息传递来共享数据和功能。

以下是 SharedWorker 的一些主要特性和使用场景:

主要特性

  1. 共享性:多个浏览上下文可以连接到同一个 SharedWorker,并与其进行通信。
  2. 通信SharedWorker 通过 MessagePort 对象与页面进行通信。每个页面与 SharedWorker 之间的连接都通过一个唯一的 MessagePort 对象进行。
  3. 数据持久化:由于 SharedWorker 可以在多个页面之间共享,因此它可以存储和共享数据,这些数据在页面之间保持一致。
  4. 同步限制:虽然 SharedWorker 在后台运行,但它仍然受到与主线程相同的同源策略和全局环境的限制。此外,SharedWorker 不能直接访问 DOM 或执行与页面相关的某些操作。
  5. 生命周期SharedWorker 的生命周期并不完全由创建它的页面控制。即使所有页面都关闭了与 SharedWorker 的连接,只要至少有一个 SharedWorker 的引用存在,它就不会被终止。

使用场景

  1. 多页面应用:当需要在多个页面之间共享数据或功能时,SharedWorker 是一个很好的选择。例如,聊天应用可以使用 SharedWorker 来处理聊天消息的接收和发送,以便在所有打开的聊天窗口之间同步消息。
  2. 后台处理:对于需要长时间运行或占用大量资源的任务,可以使用 SharedWorker 在后台进行处理,以避免阻塞主线程并改善用户体验。
  3. 跨页面通信SharedWorker 可以作为多个页面之间的通信桥梁,允许它们在不直接交互的情况下交换数据和事件。

webSocket

WebSocket 大家应该很熟悉了, 它是一种网络通信协议,允许服务器与客户端之间建立持久的双向通信连接。常用于实时聊天、数据推送和在线游戏等场景。

代码实现

本项目是使用的vue2.x 框架, 代码如下:

var ports = []
var ws
let closed = false // 连接是否已关闭
let opened = false // 连接是否已打开

self.onconnect = (e) => {
  const port = e.ports[0]
  ports.push(port)
  // 发送消息给连接的页面
  port.postMessage(
    JSON.stringify({
      type: 10,
      data: `SharedWorker连接成功,连接数:${ports.length}`
    })
  )
  port.onmessage = (e) => {
    // 处理从连接的页面接收到的消息
    // console.log('处理从连接的页面接收到的消息e', e)
    const d = e.data
    // type=0 连接WebSocket
    // type=1 初始化WebSocket
    // type=2 发送数据
    // type=3 关闭连接
    // type=4 从shareWorker移除当前连接的页面
    // type=10 输出日志
    if (d.type == 0) {
      // WebSocket如果未进行连接则需要建立一个新的连接
      // WebSocket连接如果已关闭需重新连接
      if (!ws || closed) {
        closed = false
        const wsBaseUrl = d.data.wsBaseUrl
        try {
          postAllMessage({
            type: 10,
            data: 'WebSocket不存在,即将创建'
          })
          ws = new WebSocket(wsBaseUrl)
          postAllMessage({
            type: 10,
            data: 'WebSocket创建连接成功:' + wsBaseUrl
          })

          ws.onopen = function () {
            opened = true
            postAllMessage({
              type: 10,
              data: 'WebSocket连接打开'
            })
            postAllMessage({
              type: 1,
              success: true,
              method: 'onopen'
            })
          }

          ws.onclose = function (e) {
            // console.log('onclose', e)
            closed = true
            opened = false
            postAllMessage({
              type: 10,
              data: `WebSocket连接关闭:${JSON.stringify(e)}`
            })
            postAllMessage({
              type: 1,
              success: true,
              method: 'onclose',
              data: e
            })
          }
          ws.onmessage = (e) => {
            // console.log('onmessage', e)
            const data = e.data
            postAllMessage({
              type: 10,
              data: `WebSocket获取到数据:${JSON.stringify(e.data)}`
            })
            postAllMessage({
              type: 1,
              success: true,
              method: 'onmessage',
              data: data
            })
          }
          ws.onerror = function (e) {
            // console.log('onerror', e)
            opened = false
            postAllMessage({
              type: 10,
              data: `WebSocket连接错误:${JSON.stringify(e)}`
            })
            postAllMessage({
              type: 1,
              success: true,
              method: 'onerror',
              data: e
            })
          }
        } catch (e) {
          postAllMessage({
            type: 10,
            data: 'WebSocket创建连接失败:' + wsBaseUrl + '\n错误信息:' + e
          })
        }
      } else {
        if (opened) {
          postAllMessage({
            type: 10,
            data: 'WebSocket连接打开,沿用已有WebSocket'
          })
          postAllMessage({
            type: 1,
            success: true,
            method: 'onopen'
          })
        } else {
          postAllMessage({
            type: 1,
            success: true,
            method: 'onclose',
            data: 'WebSocket连接关闭,WebSocket'
          })
        }
        postAllMessage({
          type: 10,
          data: '沿用已有WebSocket连接成功'
        })
      }
    } else if (d.type == 2) {
      ws.send(d.data)
      postAllMessage({
        type: 10,
        data: `WebSocket发送数据:${d.data}`
      })
    } else if (d.type == 3) {
      if (ports.length == 1) {
        ws.close()
        postAllMessage({
          type: 10,
          data: 'WebSocket关闭连接成功'
        })
      } else {
        postAllMessage({
          type: 10,
          data: `当前标签页有${ports.length}个,不会关闭WebSocket`
        })
      }
    } else if (d.type == 4) {
      const index = ports.indexOf(port)
      ports.splice(index, 1)
      postAllMessage({
        type: 10,
        data: `从ShareWorker移除已关闭的页面`
      })
    }
  }

  function postAllMessage(msg) {
    // console.log('SharedWorker连接数', ports.length)
    // console.log('给每个页面发送消息', msg)
    // 广播消息给所有连接的页面
    for (let i = 0; i < ports.length; i++) {
      ports[i].postMessage(
        JSON.stringify({
          type: 10,
          data: 'postAllMessage'
        })
      )
      const message = JSON.stringify(msg)
      // console.log('消息转成字符串', message)
      ports[i].postMessage(message)
    }
  }
}

此文件放在根目录的 public 目录下。 具体每个步骤已在注释中说明。简而言之就是将 socket 封装进 shareWorker 内部,从内部进行连接,发送,接收请求,然后再转发给每个页面。

在页面中使用:

<script>
// 页面关闭前关闭 socket 或移除关闭页面
window.onbeforeunload = function () {
  // console.log('关闭webSocket')
  window.$sharedWorker.port.postMessage({
    type: 3
  })
  window.$sharedWorker.port.postMessage({
    type: 4
  })
}

export default {
async mounted() {
    window.$sharedWorker = new SharedWorker('./wroker.js', 'workerWs')
    this.handleConnectWebSocket()
},
methods: {
    /**
     * 连接WebSocket
     */
    handleConnectWebSocket() {
      const that = this
      that.loadingConnect = true
      createWebSocket()

      const timeout = 1000 * 9
      const heartCheck = {
        sendTimeoutObj: null,
        serverTimeoutObj: null,
        // 重置心跳发送
        reset: function () {
          clearTimeout(this.sendTimeoutObj)
          clearTimeout(this.serverTimeoutObj)
        },
        // 发送心跳
        start: function () {
          // 定时发送心跳
          this.sendTimeoutObj = setTimeout(() => {
            window.$sharedWorker.port.postMessage({
              type: 2,
              data: message
            })
            // 正常来说,当发送完心跳包后,服务端会响应即在onmessage中做出响应,并清除此心跳包发送新的心跳包,
            // 如果没有做出响应的,则达到超时时间主动关闭websocket,开始重连
            this.serverTimeoutObj = setTimeout(() => {
              // console.log('主动关闭Socket')
              window.$sharedWorker.port.postMessage({
                type: 3
              })
            }, timeout)
          }, timeout)
        }
      }

      // 创建webSocket
      function createWebSocket() {
        // 连接次数超过5次,则不再连接(主要为了服务端出错后导致前端不断进行连接的问题)
        if (that.connectFrequency >= 5) {
          return
        }
        const code = localStorage.getItem('invitationCode')
        try {
          window.$sharedWorker.port.postMessage({
            type: 0,
            data: {
              wsBaseUrl: `${process.env.VUE_APP__WEB_SOCKET_BASE_API}/web/${code}`
            }
          })
          init()
        } catch (e) {
          console.log(e)
        }
      }

      // 与WebScket发送第一次消息,建立通道
      var message = JSON.stringify({
        id: that.webid,
        src: 'web', // 设备类型
        'User-Agent': navigator.userAgent
      })

      // 初始化webSocket
      function init() {
        window.$sharedWorker.port.onmessage = (e) => {
          // console.log('端口接收消息1:' + e.data)
          const d = JSON.parse(e.data)
          // webSocket打开
          if (d.type == 1 && d.success) {
            if (d.method == 'onopen') {
              onopen(d)
              window.$sharedWorker.port.postMessage({
                type: 2,
                data: message
              })
            }
            // webSocket连接关闭后
            if (d.method == 'onclose') {
              onclose(d.data)
            }
            if (d.method == 'onmessage') {
              onmessage(d)
            }
            if (d.method == 'onerror') {
              onerror(d.data)
            }
          }
        }
        // webSocket打开
        var onopen = () => {
          that.loadingConnect = false
          tryHideFullScreenLoading()
          that.$store.commit('call/setConnectedApp', true)
          heartCheck.reset()
          heartCheck.start()
        }
        // webSocket连接关闭后
        var onclose = (e) => {
          // console.log('连接已关闭,请检查网络设置', e)
          that.loadingConnect = false
          tryHideFullScreenLoading()
          that.$store.commit('call/setConnectedApp', false)
          // 重新建立连接
          reconnect()
        }
        // webSocket接收消息
        var onmessage = (res) => {
          heartCheck.reset()
          heartCheck.start()
          // console.log('webSocket接收消息', res.data)
          const data = JSON.parse(res.data)
          // 不是数组不作处理,服务端有可能发送数字心跳,也可能为null
          if (!Array.isArray(data)) {
            return
          }
          // 只保留在线的设备
          that.list = data.filter((i) => i.channelStatus == 1)
          // console.log('最新设备列表', that.list, that)

          // 设备状态判断
          let status
          // 只要有一个设备Socket连接状态为1,则说明是有设备是在连接的
          if (that.list.length == 0) {
            status = false
          } else {
            status = true
          }
          that.$store.commit('call/setChannelStatus', status)

          // 每10秒,或者通话状态改变都会不断获取服务端设备列表消息,执行以下代码
          // 循环每条消息判断通话状态
          const deviceList = that.list
          for (let index = 0; index < deviceList.length; index++) {
            const item = deviceList[index]
            // 服务端状态与本地状态相同可以直接返回,不再继续往下执行
            if (item.operate == that.call.operate) {
              return
            }

            if (item.operate == 2) {
              // 通话中
              localStorage.setItem('duringShow', true)
              that.visibleDialog = false
              that.$store.commit('call/setVisible', true)

              // 通话时长计算
              that.onCallTimeStart = Date.now()
              // 通话开始时间存入浏览器缓存,以防止刷新页面后通话开始时间丢失,导致通话时长无法计算
              localStorage.setItem('onCallTimeStart', that.onCallTimeStart)
              that.onCallTimeDuration = 0
              // 有定时器说明是刷新页面后, 通过浏览器缓存拿到通话开始时间,执行的定时器
              // 如果收到了通话消息,则清掉通过浏览器缓存执行的定时器,防止发生通话时长错误问题
              if (that.s) {
                clearInterval(that.s)
                that.s = null
              }
              // 每一秒拿最新时间与通话开始时间相比,得到通话持续时长
              that.s = setInterval(() => {
                that.onCallTimeDuration = Date.now() - that.onCallTimeStart
              }, 1000)

              // 将服务端状态与本地同步为(通话中)
              const tempObj = { ...item }
              // web已经挂断,不进行重新赋值,防止通话(空闲)状态成为(通话中)
              if (isAppHangUp === false) {
                tempObj.operate = 1
              } else {
                tempObj.operate = 2
              }
              that.call = tempObj
              break
            } else if (item.operate == 0) {
              // 挂断
              if (item.phoneMac === that.call.phoneMac) {
                // 如果服务端设备MAC码与本地拨打设备相同,才可以判断它当前的设备状态
                // console.log('android挂断电话')
                // 通话挂断后重新请求获取通话数据
                that.getCallInfo()
                if (that.call.operate == 1 && isAppHangUp === false) {
                  // 如果当前通话空闲,并且是web点击的挂断
                  // 则不做处理(web可能已经点击了挂断)
                  isAppHangUp = true
                  // console.log('web可能已经点击了挂断')
                  // 本地设备MAC码清空,防止再次进入挂断逻辑
                  that.call.phoneMac = ''
                  setTimeout(() => {
                    // 触发挂断事件
                    that.$bus.$emit('hangUp', 2)
                  }, 1500)
                } else {
                  // 通话状态为挂断, 并且是app点击的挂断
                  // 先将状态改为(挂断)
                  that.call.operate = 0
                  // console.log('app点击了挂断')
                  // 清理通话时长定时器
                  clearInterval(that.s)
                  that.s = null
                  // 最后的 通话时长计算
                  that.onCallTimeDuration = Date.now() - that.onCallTimeStart

                  // 通话开始时间清0
                  that.onCallTimeStart = 0
                  // 清理浏览器缓存的通话开始时间
                  localStorage.removeItem('onCallTimeStart')
                  localStorage.removeItem('duringShow')
                  // 本地设备MAC码清空,防止再次进入挂断逻辑
                  that.call.phoneMac = ''
                  setTimeout(() => {
                    // 1.5秒后本地通话状态改为(空闲)
                    that.call.operate = 1
                    // 清空通话时长
                    that.onCallTimeDuration = 0
                    // 触发挂断事件
                    that.$bus.$emit('hangUp', 2)
                  }, 1500)
                }
                break
              }
            }
          }
        }
        // webSocket连接错误
        var onerror = (e) => {
          // console.log('webSocket连接错误', e)
          that.loadingConnect = false
          tryHideFullScreenLoading()
          that.$store.commit('call/setConnectedApp', false)
          that.connectFrequency++ // 连接失败次数
        }
      }

      let isConnected = false
      let reconnectTimeout = null

      // 重连
      function reconnect() {
        // 当前正在操作连接的时候就不进行连接,防止出现重复连接的情况
        if (isConnected) return
        isConnected = true
        reconnectTimeout && clearTimeout(reconnectTimeout)
        reconnectTimeout = setTimeout(() => {
          heartCheck.reset()
          isConnected = false
          createWebSocket()
        }, 1000)
      }
    },
}

这部分牵扯了大部分的业务代码,其实重点关注 window.$sharedWorker.port 发送和接收消息即可。

踩过的坑

刚开始想采用 BroadcastChannel 直接共享 webSocket 的, 实际用下来是无法发送 webSocket 实例对象的,所以这个方案放弃。