Vue 实时互动大屏:从 WebSocket 消息到霸屏动画的调度状态机设计
这个问题不是“Vue 怎么监听 WebSocket 消息”,也不是“动画组件怎么显示”。真正麻烦的地方在于:消息是实时来的,动画是有播放时长的,现场操作人员还可能暂停、恢复、插队;普通消息、霸屏、点歌、打赏又会进入不同展示通道。
我以前做互动大屏时,最开始的实现很自然:WebSocket 收到消息以后写进全局状态,页面组件 watch 这个消息,然后在组件里判断类型、处理素材、入队、播放动画。
单看每一段代码都不难,但几个状态叠在一起以后,问题就来了。
问题背景
大屏现场有几类消息:
- 普通聊天:进入普通消息流或弹幕流。
- 霸屏消息:需要独占一段时间播放动画。
- 点歌消息:既要展示消息,也可能切换到点歌模块。
- 打赏消息:优先级更高,展示节奏和普通霸屏不一样。
前端同时要处理几件事:
- WebSocket 消息实时到达。
- 霸屏动画有固定播放周期。
- 用户可以暂停霸屏消费,再恢复。
- 某些消息允许插队。
- 页面切换或组件销毁时要清理未完成任务。
如果只用一个 bapinstate 布尔值表示“当前有没有霸屏播放”,它很快就会不够用。
现象
现场压测或真实活动中,容易出现几类现象:
- 两条霸屏消息几乎同时到达时,后一条偶尔被覆盖。
- 暂停期间收到消息,恢复后顺序不稳定。
- 插队消息虽然放到了前面,但正在播放的动画结束后不一定立刻接上。
- WebSocket 重连后,旧消息可能再次进入队列。
- 切换页面后,组件已经销毁,但延迟回调还在尝试提交全局状态。
这些问题很难靠“加一个 if”解决,因为它们不是单点 bug,而是调度模型不清晰。
初始实现的隐患
常见写法是把所有逻辑塞进展示组件:
watch: {
lastmsg(newVal) {
this.consumeMessage(newVal)
}
},
methods: {
consumeMessage(e) {
const msg = JSON.parse(e.data)
const item = normalize(msg)
if (isSpecialMessage(item)) {
if (this.paused) {
this.queue.push(item)
} else if (this.queue.length || this.playing) {
item.jumpQueue ? this.queue.unshift(item) : this.queue.push(item)
} else {
this.playNow(item)
}
} else {
this.$emit('normal-message', item)
}
}
}
这段代码的问题不是“写错了”,而是职责混在一起了:
- 消息解析在组件里。
- 去重在组件里。
- 队列调度在组件里。
- 动画播放时序也在组件里。
- Vuex 状态提交和父子组件事件又混在一起。
当需求继续加,比如暂停、恢复、插队、打赏优先、销毁清理,组件会越来越像一个临时调度中心。
根因
我后来把问题归成一句话:
实时消息系统里,展示组件不能同时承担“消息消费者”和“动画播放器”的角色。
WebSocket 是生产者,动画组件是渲染层,中间应该有一个明确的调度器。调度器负责回答几个问题:
- 这条消息是否有效?
- 是否重复?
- 进入哪个队列?
- 当前能不能播放?
- 播放完成后下一条是谁?
- 暂停和销毁时如何处理未完成任务?
如果没有这一层,就只能靠散落在组件里的布尔值互相配合。
定位过程
定位这类问题时,我不会一上来改代码,而是先补日志,把消息生命周期串起来:
[socket] receive id=1008 type=screen
[scheduler] enqueue id=1008 queue=3
[scheduler] play id=1008 state=playing
[renderer] mounted id=1008
[renderer] finish id=1008
[scheduler] next id=1010 queue=2
如果日志里出现下面几种情况,就说明调度模型有漏洞:
- 同一个 id 多次
enqueue。 state=playing时又出现第二次play。destroyed后还有finish回调。pause后队列长度增长,但resume没有触发消费。finish以后队列有数据,但状态仍停在playing。
日志比猜测可靠得多。尤其是现场互动类项目,问题往往只在消息密集时出现。
重构思路
我会把结构拆成四层:
WebSocket
-> normalizeMessage
-> messageScheduler
-> renderer callback
每层只做一件事:
WebSocket:只负责连接、收消息、断线重连。normalizeMessage:把后端消息转换成前端统一结构。messageScheduler:负责队列、去重、优先级和状态。renderer:只负责把某条消息播放出来,并在结束时通知调度器。
状态也不要只用一个布尔值,而是明确成状态机:
const STATE = {
IDLE: 'idle',
PLAYING: 'playing',
PAUSED: 'paused',
DESTROYED: 'destroyed'
}
核心实现
下面是一个脱敏后的调度器写法,重点不在代码量,而在状态边界清楚:
function createMessageScheduler(options) {
const queue = []
const seen = new Set()
const maxSeenSize = options.maxSeenSize || 300
let state = STATE.IDLE
let current = null
let finishTimer = null
function remember(id) {
if (!id) return false
if (seen.has(id)) return true
seen.add(id)
if (seen.size > maxSeenSize) {
const first = seen.values().next().value
seen.delete(first)
}
return false
}
function enqueue(message) {
if (state === STATE.DESTROYED) return
if (!message || remember(message.id)) return
if (message.jumpQueue) {
queue.unshift(message)
} else {
queue.push(message)
}
consume()
}
function consume() {
if (state !== STATE.IDLE) return
if (!queue.length) return
current = queue.shift()
state = STATE.PLAYING
options.onPlay(current, {
finish,
fail
})
finishTimer = setTimeout(() => {
finish()
}, current.duration || options.defaultDuration || 3000)
}
function finish() {
if (state === STATE.DESTROYED) return
clearTimeout(finishTimer)
finishTimer = null
current = null
state = STATE.IDLE
consume()
}
function fail(error) {
options.onError && options.onError(error, current)
finish()
}
function pause() {
if (state === STATE.DESTROYED) return
if (state === STATE.IDLE || state === STATE.PLAYING) {
state = STATE.PAUSED
}
}
function resume() {
if (state !== STATE.PAUSED) return
state = STATE.IDLE
consume()
}
function destroy() {
state = STATE.DESTROYED
queue.length = 0
current = null
clearTimeout(finishTimer)
finishTimer = null
seen.clear()
}
return {
enqueue,
pause,
resume,
destroy,
getState: () => state,
getQueueSize: () => queue.length
}
}
在 Vue 组件里,展示层就会轻很多:
mounted() {
this.scheduler = createMessageScheduler({
defaultDuration: 2800,
onPlay: (message, control) => {
this.playingMessage = message
this.$nextTick(() => {
this.startAnimation(message, control.finish)
})
},
onError: (error, message) => {
console.warn('message render failed', message && message.id, error)
}
})
},
watch: {
lastmsg(e) {
const message = normalizeMessage(e)
if (message.channel === 'screen') {
this.scheduler.enqueue(message)
} else {
this.$emit('normal-message', message)
}
}
},
beforeDestroy() {
this.scheduler.destroy()
}
消息归一化
我比较建议单独写 normalizeMessage,不要让队列关心后端字段:
function normalizeMessage(event) {
const raw = JSON.parse(event.data)
const data = raw.message || {}
if (raw.code !== 2) {
return {
channel: 'unknown',
raw
}
}
const isScreen =
data.themeId ||
data.source === 'song' ||
data.source === 'reward'
return {
id: data.id,
channel: isScreen ? 'screen' : 'normal',
type: data.source || 'message',
content: data.content || '',
avatar: data.avatar || '',
images: typeof data.url === 'string' ? data.url.split(',').filter(Boolean) : [],
theme: data.theme || null,
duration: Number(data.duration || 3) * 1000,
jumpQueue: Boolean(data.queueOrder),
createdAt: data.createTime || new Date().toISOString(),
raw: data
}
}
这样后端字段变化时,调度器和动画组件都不用跟着改。
方案对比
我比较过三种方案。
第一种是继续用组件内部数组和布尔值。优点是改动小,缺点是后续需求一加就会继续膨胀,问题定位也困难。
第二种是把队列放进 Vuex。优点是全局可见,缺点是播放状态、定时器、动画回调这些东西并不适合全部塞进 Vuex,否则全局状态会变得很重。
第三种是独立调度器加轻量 Vuex。Vuex 只保留当前播放数据和全局开关,调度器管理队列和时序。这个方案最适合互动大屏,因为它把实时消息和动画生命周期分开了。
边界情况
这类调度器必须考虑边界:
- 重复消息:用
id去重,但去重集合要限制大小,避免长时间运行后内存增长。 - 插队消息:只插到等待队列最前面,不强行打断当前动画。
- 暂停状态:暂停只影响后续消费,不建议暂停到一半的动画,除非业务明确需要。
- 动画失败:素材加载失败或组件报错时,要调用
fail,不能让状态卡死。 - 组件销毁:销毁后所有
setTimeout和回调都应该失效。 - WebSocket 重连:调度器不关心重连,只接收归一化后的消息。
验证
我会用下面几组用例验证:
1. 连续推送 20 条霸屏,确认全部按顺序播放。
2. 推送 5 条后暂停,再推送 5 条,恢复后队列继续消费。
3. 正在播放时推送插队消息,确认当前播放不被打断,下一条优先播放插队消息。
4. 同一条消息重复推送 3 次,确认只播放一次。
5. 播放中切换页面,确认没有后续状态提交和定时器报错。
6. 动画组件主动抛错,确认调度器能跳到下一条。
这个验证清单比单纯看页面更重要。互动大屏的问题往往不是“完全不能用”,而是“消息一多、状态一乱,就偶发出错”。
小结
我表达的不是“写一个队列”,而是:当前端从展示页面变成实时互动系统时,必须认真设计消息消费模型。
WebSocket 只是入口,动画组件也只是出口。真正决定系统稳定性的,是中间这层调度器:它要处理异步时序、多状态协作、优先级、资源生命周期和异常恢复。
以前写项目时,很多逻辑能跑起来就先跑起来。但复盘时再看,会发现这些“现场能跑”的经验,其实可以整理成很有价值的工程方法。

浙公网安备 33010602011771号