4. 回放时钟与倍速控制实现

回放时钟的核心职责与设计边界

回放时钟不是简单的“定时器 + 索引自增”。它要同时满足三件事:事件顺序严格不变、可变速但不丢事件、可逐事件步进。因此时钟需要维护两个独立量:回放时间轴位置(当前已推进到的事件时间戳)与真实墙钟消耗(用于计算等待时长)。倍速只改变“回放时间 / 墙钟时间”的换算比例,不改变事件在回放流中的相对顺序。

本案例采用事件驱动模型:回放流是已按时间戳排序的事件数组,每个事件带 ts(毫秒时间戳)。时钟推进时,先取下一个事件,计算它与上一个事件的时间差 dt = ts[i] - ts[i-1],再按倍速换算成墙钟等待 wait = dt / speed。原速时 speed=1,倍速时 speed=2/4/8,慢放时 speed=0.25/0.5。逐事件步进则跳过等待,直接推进一个事件。

回放事件流(已排序) E0 快照 ts=1000 E1 成交 ts=1040 E2 快照 ts=1120 E3 撤单 ts=1180 E4 快照 ts=1300 E5 成交 ts=1360 顺序不可变 · 索引只前进 回放时钟核心 cursor:当前事件索引 lastTs:上一事件时间戳 speed:倍速因子 mode:play / step / pause dt = ts[i] − lastTs wait = dt ÷ speed 倍速档位 0.25× 0.5× 1× 2× 4× 8× 慢放观察跳水细节 逐事件步进 step():cursor += 1 跳过 wait,立即渲染 用于精确定位跳水瞬间 渲染当前事件 → 盘口视图

时钟实现:状态机与推进逻辑

时钟用一个小型状态机管理三种模式:pause(暂停,不推进)、play(按倍速自动推进)、step(单次步进后回到 pause)。关键约束是:任何模式下 cursor 只增不减,倍速切换只影响下一次等待时长,不影响已推进位置。下面给出可直接运行的实现。

class ReplayClock {
  constructor(events, { speed = 1, onRender } = {}) {
    if (!Array.isArray(events) || events.length === 0) {
      throw new Error('回放事件流不能为空');
    }
    this.events = events;
    this.speed = speed;
    this.onRender = onRender || (() => {});
    this.cursor = -1;          // 尚未渲染任何事件
    this.lastTs = events[0].ts; // 以首事件时间戳为基准
    this.mode = 'pause';
    this.timer = null;
  }

  setSpeed(speed) {
    if (!(speed > 0)) throw new Error('倍速必须为正数');
    this.speed = speed;
    // 若正在播放,按新倍速重排下一次等待
    if (this.mode === 'play') {
      this._clearTimer();
      this._scheduleNext();
    }
  }

  play() {
    if (this.mode === 'play') return;
    this.mode = 'play';
    this._scheduleNext();
  }

  pause() {
    this.mode = 'pause';
    this._clearTimer();
  }

  step() {
    this._clearTimer();
    this.mode = 'pause';
    this._advance();
  }

  _scheduleNext() {
    if (this.cursor >= this.events.length - 1) {
      this.mode = 'pause';
      return;
    }
    const next = this.events[this.cursor + 1];
    const dt = Math.max(0, next.ts - this.lastTs);
    const wait = dt / this.speed;
    this.timer = setTimeout(() => {
      this._advance();
      if (this.mode === 'play') this._scheduleNext();
    }, wait);
  }

  _advance() {
    if (this.cursor >= this.events.length - 1) return;
    this.cursor += 1;
    const ev = this.events[this.cursor];
    this.lastTs = ev.ts;
    this.onRender(ev, this.cursor);
  }

  _clearTimer() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }
}

使用示例(Node 环境可直接运行,浏览器中把 console.log 换成渲染函数即可):

const events = [
  { ts: 1000, type: 'snapshot', bid: 10.20, ask: 10.21 },
  { ts: 1040, type: 'trade',    price: 10.20, vol: 300 },
  { ts: 1120, type: 'snapshot', bid: 10.18, ask: 10.21 },
  { ts: 1180, type: 'cancel',   side: 'bid', level: 1 },
  { ts: 1300, type: 'snapshot', bid: 10.05, ask: 10.22 },
  { ts: 1360, type: 'trade',    price: 10.05, vol: 800 },
];

const clock = new ReplayClock(events, {
  speed: 0.5,
  onRender: (ev, i) => console.log(`[${i}] ts=${ev.ts} ${ev.type}`),
});

clock.play();
// 约 80ms 后输出 [0] ts=1000 snapshot
// 约 80ms 后输出 [1] ts=1040 trade
// 约 160ms 后输出 [2] ts=1120 snapshot
// ...

倍速与步进的验证要点

  • 顺序不变性:无论 speed 取何值,onRender 收到的索引序列必须是 0,1,2,…,N−1 连续递增。可在回调中记录索引并断言 i === prev + 1。
  • 倍速比例正确:以 1× 与 4× 分别播放同一段事件,4× 的总墙钟耗时约为 1× 的 1/4(允许定时器误差)。
  • 慢放可观察:跳水段(如 ts=1120→1300,dt=180ms)在 0.25× 下等待约 720ms,足以逐帧观察盘口变化。
  • 步进不跳事件:连续调用 step() 应逐个渲染,且不触发定时器。
  • 边界安全:cursor 到达末尾后 play 自动转 pause,step 不再推进,不抛异常。
倍速切换时不要重置 lastTs。若重置,下一次 dt 会从 0 开始计算,导致该事件被立即渲染,破坏节奏。正确做法是保留 lastTs,仅用新 speed 重算等待。
同一时间戳的多个事件(dt=0)在任意倍速下都会连续渲染,这是预期行为。若需要人为拉开观察间隔,应在数据层插入“展示间隔”字段,而不是在时钟里对 dt 做下限截断,否则会改变事件的时间语义。