4. 回放时钟与倍速控制实现
回放时钟的核心职责与设计边界
回放时钟不是简单的“定时器 + 索引自增”。它要同时满足三件事:事件顺序严格不变、可变速但不丢事件、可逐事件步进。因此时钟需要维护两个独立量:回放时间轴位置(当前已推进到的事件时间戳)与真实墙钟消耗(用于计算等待时长)。倍速只改变“回放时间 / 墙钟时间”的换算比例,不改变事件在回放流中的相对顺序。
本案例采用事件驱动模型:回放流是已按时间戳排序的事件数组,每个事件带 ts(毫秒时间戳)。时钟推进时,先取下一个事件,计算它与上一个事件的时间差 dt = ts[i] - ts[i-1],再按倍速换算成墙钟等待 wait = dt / speed。原速时 speed=1,倍速时 speed=2/4/8,慢放时 speed=0.25/0.5。逐事件步进则跳过等待,直接推进一个事件。
时钟实现:状态机与推进逻辑
时钟用一个小型状态机管理三种模式: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 做下限截断,否则会改变事件的时间语义。