开发背景
前段时间接到一个需求,要实现PC网页与手机APP实时通信,在PC调用打电话的功能。开始我们只是在网页中使用webSocket并定时心跳检测,保证不断连。之后发现服务器性能跟不上,socket连接太多了,尤其用户打开另外一个浏览器页签运行同样会连接socket,所以考虑所有页签使用同一个socket。
技术介绍
首先简单介绍一下两项技术:
SharedWorker
SharedWorker 是 Web Workers API 的一部分,它允许在浏览器后台运行 JavaScript 代码,与主线程并行处理任务。与 Worker 不同的是,SharedWorker 可以被多个浏览上下文(如窗口、标签页或 iframe)共享。这意味着,一旦一个 SharedWorker 被创建,多个页面可以连接到它,并通过消息传递来共享数据和功能。
以下是 SharedWorker 的一些主要特性和使用场景:
主要特性
- 共享性:多个浏览上下文可以连接到同一个
SharedWorker,并与其进行通信。 - 通信:
SharedWorker通过MessagePort对象与页面进行通信。每个页面与SharedWorker之间的连接都通过一个唯一的MessagePort对象进行。 - 数据持久化:由于
SharedWorker可以在多个页面之间共享,因此它可以存储和共享数据,这些数据在页面之间保持一致。 - 同步限制:虽然
SharedWorker在后台运行,但它仍然受到与主线程相同的同源策略和全局环境的限制。此外,SharedWorker不能直接访问 DOM 或执行与页面相关的某些操作。 - 生命周期:
SharedWorker的生命周期并不完全由创建它的页面控制。即使所有页面都关闭了与SharedWorker的连接,只要至少有一个SharedWorker的引用存在,它就不会被终止。
使用场景
- 多页面应用:当需要在多个页面之间共享数据或功能时,
SharedWorker是一个很好的选择。例如,聊天应用可以使用SharedWorker来处理聊天消息的接收和发送,以便在所有打开的聊天窗口之间同步消息。 - 后台处理:对于需要长时间运行或占用大量资源的任务,可以使用
SharedWorker在后台进行处理,以避免阻塞主线程并改善用户体验。 - 跨页面通信:
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 实例对象的,所以这个方案放弃。