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 只是入口,动画组件也只是出口。真正决定系统稳定性的,是中间这层调度器:它要处理异步时序、多状态协作、优先级、资源生命周期和异常恢复。

以前写项目时,很多逻辑能跑起来就先跑起来。但复盘时再看,会发现这些“现场能跑”的经验,其实可以整理成很有价值的工程方法。

posted @ 2026-06-22 14:56  万物皆object  阅读(16)  评论(0)    收藏  举报