utils/order-websocket.js 源码解析和页面调用示例
这篇文章专门解析 utils/order-websocket.js 的源码。
上一篇 order-websocket-guide.md 偏接入说明,这一篇偏源码阅读。你可以先看页面怎么调用,再看这个封装内部是怎么完成连接、订阅、收消息、通知页面刷新的。
一、这个文件解决了什么问题
小程序里很多页面都需要知道订单有没有变化,比如:
- 订单列表要刷新
- 订单详情要刷新
- 回收员首页要刷新
- 消息页面要刷新
如果每个页面都自己写 WebSocket,就会很乱,也容易重复连接。
所以项目里把 WebSocket 统一封装到了:
text
utils/order-websocket.js
页面只需要调用:
js
addOrderUpdateListener()
收到后端推送后,页面自己决定刷新什么数据。
二、页面调用方式
1. 列表页调用
列表页通常不关心具体是哪一笔订单变了。只要收到推送,就重新加载列表。
js
import {addOrderUpdateListener} from '@/utils/order-websocket'
import {onShow, onHide, onUnload} from '@dcloudio/uni-app'
let removeOrderUpdateListener = null
onShow(() => {
getOrderList()
removeOrderUpdateListener?.()
removeOrderUpdateListener = addOrderUpdateListener(() => {
currentPage.value = 1
getOrderList()
})
})
onHide(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
onUnload(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
2. 详情页调用
详情页要判断后端推来的订单 ID 是否等于当前页面订单 ID。
如果不是当前订单,就不刷新。
js
import {addOrderUpdateListener} from '@/utils/order-websocket'
import {onShow, onHide, onUnload} from '@dcloudio/uni-app'
const id = ref('')
let removeOrderUpdateListener = null
onShow(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = addOrderUpdateListener((orderId) => {
if (String(orderId) !== String(id.value)) return
getOrderDetail(id.value)
uni.showToast({
title: '订单状态已更新',
icon: 'none'
})
})
})
onHide(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
onUnload(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
3. 消息页调用
消息页收到推送后,重新拉第一页消息。
js
import {addOrderUpdateListener} from '@/utils/order-websocket'
import {onShow, onHide, onUnload} from '@dcloudio/uni-app'
let removeOrderUpdateListener = null
onShow(() => {
loadMessages(true)
removeOrderUpdateListener?.()
removeOrderUpdateListener = addOrderUpdateListener(() => {
loadMessages(true)
})
})
onHide(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
onUnload(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
三、源码结构总览
utils/order-websocket.js 可以分成几块看:
- 配置和状态变量
- 生成 WebSocket 地址
- 发送 STOMP 消息
- 订阅后端队列
- 解析后端消息
- 通知页面刷新
- 断线自动重连
- 暴露给页面使用的方法
页面真正会用到的只有两个方法:
js
addOrderUpdateListener()
disconnectOrderWebSocket()
普通页面基本只用 addOrderUpdateListener()。
disconnectOrderWebSocket() 一般用于退出登录、切换账号时彻底断开连接。
四、配置和状态变量解析
js
const ORDER_WS_PATH = '/ws'
const ORDER_DESTINATION = '/user/queue/order-updates'
const RECONNECT_DELAY = 5000
这三个是最重要的配置:
ORDER_WS_PATH:后端 WebSocket 路径ORDER_DESTINATION:前端订阅地址RECONNECT_DELAY:断线后多久重连
js
let socketTask = null
let connected = false
let connecting = false
let manualClosed = false
let reconnectTimer = null
let subscriptionTimer = null
let urlIndex = 0
let socketMode = 'native'
const listeners = new Set()
这些是连接状态:
socketTask:当前 WebSocket 连接对象connected:是否已经连接成功connecting:是否正在连接中manualClosed:是否是主动关闭reconnectTimer:重连定时器subscriptionTimer:延迟订阅定时器urlIndex:当前尝试第几个连接地址socketMode:当前连接模式listeners:页面注册进来的刷新方法集合
五、生成 WebSocket 地址
源码里的 getGatewayWsBaseUrl() 会从当前环境接口地址推导 WebSocket 地址。
比如接口地址是:
text
http://192.168.0.114:9003/api/wxapp
它会变成:
text
ws://192.168.0.114:9003/api/wxapp
最后再拼上 /ws:
text
ws://192.168.0.114:9003/api/wxapp/ws
如果以后上线后接口是 HTTPS:
text
https://xxx.com/api/wxapp
前端会自动变成:
text
wss://xxx.com/api/wxapp/ws
HTTPS 对应的是 WSS,这样上线后才能正常在小程序真机里使用。
六、为什么有两个连接地址
源码里的 getWsCandidates() 返回两个地址:
js
[
{ url: wsBaseUrl + ORDER_WS_PATH, mode: 'native' },
{ url: `${wsBaseUrl}${ORDER_WS_PATH}/000/${createSockJsSessionId()}/websocket`, mode: 'sockjs' }
]
原因是后端可能有两种 WebSocket 方式:
- 原生 WebSocket
- SockJS WebSocket
前端会先试原生地址。
如果失败,下一次重连会换 SockJS 地址。
这样做是为了兼容后端不同配置。
七、发送 STOMP 消息
WebSocket 只是连接通道。
当前后端用的是 STOMP,所以连接成功后,前端还要发送:
text
CONNECT
然后再发送:
text
SUBSCRIBE
如果只建立 WebSocket 连接,不发送这些内容,后端不会知道前端要订阅哪个消息。
八、后端推送后前端怎么处理
后端推送消息后,前端会进入:
js
socketTask.onMessage((event) => {
...
})
里面会做几件事:
- 先判断是不是 SockJS 包装消息
- 如果是连接确认消息,就订阅队列
- 如果是心跳消息,就忽略
- 如果是普通消息,就解析出订单 ID
- 通知所有页面刷新
真正通知页面的是:
js
notifyOrderUpdate(orderId)
它会遍历 listeners,把订单 ID 传给每个页面注册的方法。
九、页面为什么要取消监听
页面每次显示时都会注册监听。
如果不取消,页面进出多次后,一个页面可能注册很多次。
结果就是:后端只推一次消息,页面却刷新很多次。
所以页面要写:
js
onHide(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
onUnload(() => {
removeOrderUpdateListener?.()
removeOrderUpdateListener = null
})
十、完整源码
下面是当前项目里的 utils/order-websocket.js 完整源码。
js
import store from '@/store'
// 后端暴露的 WebSocket 地址会从当前环境 baseUrl 推导出来:
// http://192.168.0.114:9003/api/wxapp -> ws://192.168.0.114:9003/api/wxapp/ws
const ORDER_WS_PATH = '/ws'
// 后端使用 convertAndSendToUser(userId, "/queue/order-updates", orderId)
// 前端订阅用户队列时,一般订阅 /user/queue/order-updates。
const ORDER_DESTINATION = '/user/queue/order-updates'
// WebSocket 断开后,等待 5 秒再自动重连,避免网络抖动时疯狂重试。
const RECONNECT_DELAY = 5000
// 当前 WebSocket 连接对象。连接成功、发送消息、关闭连接都要通过它操作。
let socketTask = null
// 标记 WebSocket 是否已经打开。打开后才能发送 STOMP 帧。
let connected = false
// 标记 WebSocket 是否正在连接中。防止页面多次进入时重复发起连接。
let connecting = false
// 标记是否是前端主动关闭。主动关闭时不自动重连,异常断开才自动重连。
let manualClosed = false
// 自动重连的定时器。保存下来是为了避免重复创建多个重连定时器。
let reconnectTimer = null
// 延迟订阅的定时器。连接刚打开后会稍等一下再订阅,兼容后端 CONNECTED 返回较慢的情况。
let subscriptionTimer = null
// 当前尝试连接的地址序号。0 走原生 /ws,1 走 SockJS /ws/000/session/websocket,失败后轮流尝试。
let urlIndex = 0
// 当前连接模式:native 表示原生 WebSocket,sockjs 表示后端启用了 SockJS。
let socketMode = 'native'
// 页面注册进来的刷新回调集合。收到订单 ID 后,会逐个通知这些页面刷新自己的数据。
const listeners = new Set()
/**
* 根据当前环境的接口地址,生成 WebSocket 的网关基础地址。
*
* 例子:
* 接口 baseUrl: http://192.168.0.114:9003/api/wxapp
* WebSocket base: ws://192.168.0.114:9003/api/wxapp
*
* 注意:这里不直连后端服务,仍然走网关。
*/
function getGatewayWsBaseUrl() {
const env = store.getters.curEnv || {}
// WebSocket 也要走网关,不直连后端服务。
// 当前小程序接口 baseUrl 是:http://网关地址/api/wxapp
// 所以这里推导出来的 WebSocket 地址是:ws://网关地址/api/wxapp/ws
const baseUrl = String(env.baseUrl || '').replace(/\/api\/wxapp\/?$/, '')
if (!baseUrl) return ''
return baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + '/api/wxapp'
}
/**
* 生成 SockJS 连接需要的 session-id。
*
* 如果后端配置了 .withSockJS(),真实 WebSocket 地址不是 /ws,
* 而是类似 /ws/000/随机session/websocket。
*/
function createSockJsSessionId() {
return `${Date.now()}${Math.random().toString(36).slice(2, 8)}`
}
/**
* 返回可以尝试连接的 WebSocket 地址列表。
*
* 第一个是原生 WebSocket 地址。
* 第二个是 SockJS WebSocket 传输地址。
*
* 如果第一个连接失败,代码会自动切到第二个重连。
*/
function getWsCandidates() {
const wsBaseUrl = getGatewayWsBaseUrl()
if (!wsBaseUrl) return []
return [
// 原生 WebSocket 地址:后端如果没有 withSockJS,一般就是这个。
{ url: wsBaseUrl + ORDER_WS_PATH, mode: 'native' },
// SockJS WebSocket 传输地址:后端如果配置了 .withSockJS(),小程序需要走这个格式。
// 000 是 SockJS server-id,后面的 session-id 每次连接生成一个即可。
{ url: `${wsBaseUrl}${ORDER_WS_PATH}/000/${createSockJsSessionId()}/websocket`, mode: 'sockjs' }
]
}
/**
* 发送 STOMP 文本帧。
*
* WebSocket 只是传输通道,后端这里用 STOMP 协议做订阅。
* 所以前端不能只建立连接,还要发送 CONNECT / SUBSCRIBE 这种 STOMP 命令。
*/
// 小程序不能直接使用浏览器 WebSocket 对象,这里用 uni.connectSocket 的 socketTask 发送 STOMP 文本帧。
function sendFrame(frame) {
if (!socketTask || !connected) return
const data = socketMode === 'sockjs'
// SockJS websocket transport 发送时需要把消息包成 JSON 数组。
? JSON.stringify([frame + '\0'])
// 原生 STOMP over WebSocket 直接发送 STOMP 文本帧。
: frame + '\0'
socketTask.send({
data,
fail: (error) => {
console.warn('[order-websocket] send fail:', error)
}
})
}
/**
* 发送 STOMP CONNECT 命令。
*
* 连接 WebSocket 成功后,先告诉后端:
* "我要开始使用 STOMP 协议通信了"。
*
* 后端说不需要 token,所以这里没有带 Authorization。
*/
function connectStomp() {
// 后端不需要 token,所以 CONNECT 里不带 Authorization。
sendFrame([
'CONNECT',
'accept-version:1.2',
'heart-beat:0,0',
'',
''
].join('\n'))
}
/**
* 订阅订单更新队列。
*
* 后端取消订单后,会通过:
* convertAndSendToUser(userId, "/queue/order-updates", orderId)
* 把订单 ID 推给当前用户。
*
* 前端订阅 /user/queue/order-updates 后,就能收到这个订单 ID。
*/
function subscribeOrderUpdates() {
// 订阅订单更新队列。后端取消订单后,会把订单 ID 推到这个队列。
sendFrame([
'SUBSCRIBE',
'id:order-updates',
`destination:${ORDER_DESTINATION}`,
'',
''
].join('\n'))
}
/**
* 从 STOMP 消息里取出 body。
*
* STOMP 消息大概长这样:
* MESSAGE
* destination:/user/queue/order-updates
*
* 订单ID
*
* 空行后面的内容就是 body,也就是后端发送的订单 ID。
*/
function parseStompBody(data) {
// STOMP 消息格式是:命令 + headers + 空行 + body + \0。
// 这里取空行后面的 body,body 就是后端发送的订单 ID。
const text = String(data || '').replace(/\0+$/g, '')
const blankIndex = text.indexOf('\n\n')
return blankIndex >= 0 ? text.slice(blankIndex + 2).trim() : text.trim()
}
/**
* 处理 SockJS 包装过的消息。
*
* 原生 WebSocket 收到的就是 STOMP 文本。
* SockJS 收到的可能是:
* - o:连接打开
* - h:心跳
* - a["真正的STOMP消息"]:真正的消息包在数组里
*/
function unwrapSocketMessage(data) {
const text = String(data || '')
if (socketMode !== 'sockjs') return text
// SockJS 服务端会先发 o 表示连接打开,h 表示心跳。
if (text === 'o' || text === 'h') return text
if (!text.startsWith('a')) return text
try {
const messages = JSON.parse(text.slice(1))
return Array.isArray(messages) ? String(messages[0] || '') : ''
} catch {
return text
}
}
/**
* 把后端推来的消息内容统一转成订单 ID。
*
* 现在后端说发送的是 orderId 字符串。
* 这里也兼容 JSON,防止以后后端改成:
* { "orderId": "xxx" }
*/
function normalizeOrderId(body) {
// 后端目前说发送 orderId;这里额外兼容 JSON 对象,避免以后后端改成 {orderId: "..."} 后前端失效。
if (!body) return ''
try {
const parsed = JSON.parse(body)
if (parsed && typeof parsed === 'object') {
return String(parsed.orderId || parsed.id || parsed.bizId || parsed.data || '')
}
return String(parsed || '')
} catch {
return String(body || '').trim()
}
}
/**
* 通知所有页面:某个订单更新了。
*
* 页面通过 addOrderUpdateListener 注册自己的刷新逻辑。
* 比如:
* - 回收员首页刷新待接单列表
* - 订单列表刷新当前状态列表
* - 订单详情页重新拉详情
*/
function notifyOrderUpdate(orderId) {
// 一个 WebSocket 连接可以服务多个页面;页面只需要注册自己的刷新回调。
if (!orderId) return
listeners.forEach((listener) => {
try {
listener(orderId)
} catch (error) {
console.warn('[order-websocket] listener error:', error)
}
})
}
/**
* 安排自动重连。
*
* 网络断开、服务重启、网关断开时会走这里。
* 如果是用户/页面主动关闭,就不会重连。
*/
function scheduleReconnect() {
// 非手动关闭时自动重连,避免锁屏、网络切换后收不到后续取消订单消息。
if (manualClosed || reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connectOrderWebSocket()
}, RECONNECT_DELAY)
}
/**
* 建立订单 WebSocket 连接。
*
* 整体流程:
* 1. 根据环境生成网关 WebSocket 地址
* 2. 用 uni.connectSocket 建立连接
* 3. 连接成功后发送 STOMP CONNECT
* 4. 再订阅 /user/queue/order-updates
* 5. 收到 MESSAGE 后解析订单 ID,并通知页面刷新
*
* 这里会防止重复连接:如果已经连接中或已连接,就直接返回。
*/
export function connectOrderWebSocket() {
if (socketTask || connecting || connected) return
const candidates = getWsCandidates()
if (!candidates.length) return
const candidate = candidates[urlIndex % candidates.length]
const url = candidate.url
socketMode = candidate.mode
manualClosed = false
connecting = true
socketTask = uni.connectSocket({
url,
complete: () => {}
})
socketTask.onOpen(() => {
// WebSocket 通道打开了,但还没完成 STOMP 连接。
connecting = false
connected = true
if (socketMode === 'native') {
connectStomp()
// 兜底订阅:有些后端 CONNECTED 返回慢,先延迟订阅一次;收到 CONNECTED 后还会再订阅一次。
subscriptionTimer = setTimeout(subscribeOrderUpdates, 300)
}
})
socketTask.onMessage((event) => {
// 所有服务端消息都会先到这里,再判断是 CONNECTED、MESSAGE、心跳还是 SockJS 包装消息。
const text = unwrapSocketMessage(event.data)
if (socketMode === 'sockjs' && text === 'o') {
connectStomp()
subscriptionTimer = setTimeout(subscribeOrderUpdates, 300)
return
}
if (text === 'h') return
if (text.startsWith('CONNECTED')) {
if (subscriptionTimer) clearTimeout(subscriptionTimer)
subscribeOrderUpdates()
return
}
if (!text.startsWith('MESSAGE')) return
const orderId = normalizeOrderId(parseStompBody(text))
notifyOrderUpdate(orderId)
})
socketTask.onClose(() => {
// 连接关闭后切换到下一个候选地址,下一次重连会尝试另一个格式。
socketTask = null
connected = false
connecting = false
urlIndex++
if (subscriptionTimer) {
clearTimeout(subscriptionTimer)
subscriptionTimer = null
}
scheduleReconnect()
})
socketTask.onError((error) => {
// 握手失败、网关不支持 Upgrade、后端路径不对等问题都会到这里。
console.warn('[order-websocket] socket error:', error)
socketTask = null
connected = false
connecting = false
urlIndex++
scheduleReconnect()
})
}
/**
* 页面注册订单更新监听。
*
* 页面用法:
* const remove = addOrderUpdateListener((orderId) => {
* // 收到订单取消/更新后刷新页面数据
* })
*
* 页面隐藏或卸载时要调用 remove(),否则再次进入页面会注册多次。
*/
export function addOrderUpdateListener(listener) {
// 页面调用这个方法注册"收到订单取消/更新消息后做什么"。
// 返回值是取消监听函数,页面隐藏或卸载时调用,防止重复刷新。
if (typeof listener !== 'function') return () => {}
listeners.add(listener)
connectOrderWebSocket()
return () => {
listeners.delete(listener)
}
}
/**
* 手动断开订单 WebSocket。
*
* 一般页面不直接调用这个方法。
* 页面只需要调用 addOrderUpdateListener 返回的 remove 函数。
*
* 这个方法适合在退出登录、切换账号时调用,彻底关闭连接并清空重连状态。
*/
export function disconnectOrderWebSocket() {
manualClosed = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (subscriptionTimer) {
clearTimeout(subscriptionTimer)
subscriptionTimer = null
}
if (socketTask) {
socketTask.close()
}
socketTask = null
connected = false
connecting = false
urlIndex = 0
socketMode = 'native'
}
十一、接入时最容易错的地方
1. 页面没有取消监听
一定要在 onHide 和 onUnload 里取消。
否则页面多进几次后,会重复刷新。
2. 详情页没有判断订单 ID
详情页建议这样判断:
js
if (String(orderId) !== String(id.value)) return
不判断的话,别的订单变化也会刷新当前详情页。
3. 后端发错用户
后端必须发给当前登录用户对应的 userId。
如果 userId 不一致,前端连接正常也收不到消息。
4. 订阅地址不一致
后端:
java
convertAndSendToUser(userId, "/queue/order-updates", orderId)
前端:
js
const ORDER_DESTINATION = '/user/queue/order-updates'
这两个要对应上。
5. 上线后地址协议不对
上线后如果接口是 HTTPS,WebSocket 要走 WSS。
当前封装已经处理了:
js
return baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + '/api/wxapp'
所以以后服务器换成 HTTPS 后,会自动变成 WSS。
十二、以后扩展其他消息怎么办
如果以后不只是订单消息,还要接收聊天消息、系统通知、任务消息,有两种做法。
第一种:后端还是都推到一个队列里。
那前端可以让后端发送 JSON:
json
{
"type": "ORDER_UPDATED",
"orderId": "208xxx"
}
然后前端根据 type 判断刷新哪个页面。
第二种:后端给不同业务不同队列。
比如:
text
/user/queue/order-updates
/user/queue/message-updates
/user/queue/task-updates
那前端封装里就要增加多个订阅地址。
当前项目只处理订单更新,所以一个订阅地址够用。