【websocket合集2】websocket.js源码解析和页面调用示例

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 可以分成几块看:

  1. 配置和状态变量
  2. 生成 WebSocket 地址
  3. 发送 STOMP 消息
  4. 订阅后端队列
  5. 解析后端消息
  6. 通知页面刷新
  7. 断线自动重连
  8. 暴露给页面使用的方法

页面真正会用到的只有两个方法:

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 方式:

  1. 原生 WebSocket
  2. SockJS WebSocket

前端会先试原生地址。

如果失败,下一次重连会换 SockJS 地址。

这样做是为了兼容后端不同配置。

七、发送 STOMP 消息

WebSocket 只是连接通道。

当前后端用的是 STOMP,所以连接成功后,前端还要发送:

text 复制代码
CONNECT

然后再发送:

text 复制代码
SUBSCRIBE

如果只建立 WebSocket 连接,不发送这些内容,后端不会知道前端要订阅哪个消息。

八、后端推送后前端怎么处理

后端推送消息后,前端会进入:

js 复制代码
socketTask.onMessage((event) => {
  ...
})

里面会做几件事:

  1. 先判断是不是 SockJS 包装消息
  2. 如果是连接确认消息,就订阅队列
  3. 如果是心跳消息,就忽略
  4. 如果是普通消息,就解析出订单 ID
  5. 通知所有页面刷新

真正通知页面的是:

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. 页面没有取消监听

一定要在 onHideonUnload 里取消。

否则页面多进几次后,会重复刷新。

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

那前端封装里就要增加多个订阅地址。

当前项目只处理订单更新,所以一个订阅地址够用。

相关推荐
游戏开发爱好者811 小时前
带签名时间戳接口的重放与压力测试实战,用动态值在发送前现算签名
网络协议·计算机网络·网络安全·ios·adb·https·压力测试
vipbic11 小时前
一个前端的 9 天重构:我是怎么用 Codex 重做航栈的
前端·javascript·后端
TechWayfarer11 小时前
批量查IP归属地,在线API、脚本、离线库怎么选?三种方案实测对比
网络·python·网络协议·tcp/ip
小小龙学IT12 小时前
Boost.Beast 深度实战:基于 Asio 的开源 C++ HTTP/WebSocket 协议库
c++·websocket·http
kyriewen13 小时前
我受够了复制报错去问 AI,花一下午给控制台做了个调试助手
前端·javascript·ai编程
慧都小妮子14 小时前
前端实现复杂公式计算:SpreadJS 在京东物流 Udata 平台的落地实践
javascript·数据分析·excel·数据可视化·spreadjs·前端表格控件
JJJennie77714 小时前
Gemini 3.7 Flash 上线,Google 想让 AI 多干活
开发语言·javascript·ecmascript
ryan_99615 小时前
MCP Transport 完整指南:stdio 与 Streamable HTTP 到底怎样传消息
网络·网络协议·http