resolve #4 Improve timer's performance by storing all callbacks in an array (#5)

This commit is contained in:
edwardwu
2026-04-01 12:00:00 +08:00
committed by YUyz
parent 92faf502d0
commit 05530551df
3 changed files with 102 additions and 52 deletions

View File

@@ -1,6 +1,6 @@
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
import * as mittProxy from 'mitt'; import * as mittProxy from 'mitt';
import { later, clear } from './timer'; import Timer from './timer';
import { import {
EventType, EventType,
incrementalData, incrementalData,
@@ -14,6 +14,7 @@ import {
missingNodeMap, missingNodeMap,
addedNodeMutation, addedNodeMutation,
missingNode, missingNode,
actionWithDelay,
} from '../types'; } from '../types';
import { mirror } from '../utils'; import { mirror } from '../utils';
import './styles/style.css'; import './styles/style.css';
@@ -36,13 +37,14 @@ export class Replayer {
private iframe: HTMLIFrameElement; private iframe: HTMLIFrameElement;
private mouse: HTMLDivElement; private mouse: HTMLDivElement;
private timerIds: number[] = [];
private emitter: mitt.Emitter = mitt(); private emitter: mitt.Emitter = mitt();
private baselineTime: number = 0; private baselineTime: number = 0;
// record last played event timestamp when paused // record last played event timestamp when paused
private lastPlayedEvent: eventWithTime; private lastPlayedEvent: eventWithTime;
private timer: Timer;
constructor(events: eventWithTime[], config?: Partial<playerConfig>) { constructor(events: eventWithTime[], config?: Partial<playerConfig>) {
if (events.length < 2) { if (events.length < 2) {
throw new Error('Replayer need at least 2 events.'); throw new Error('Replayer need at least 2 events.');
@@ -50,6 +52,7 @@ export class Replayer {
this.events = events; this.events = events;
this.handleResize = this.handleResize.bind(this); this.handleResize = this.handleResize.bind(this);
this.timer = new Timer(this.config);
this.setConfig(Object.assign({}, config)); this.setConfig(Object.assign({}, config));
this.setupDom(); this.setupDom();
this.emitter.on('resize', this.handleResize as mitt.Handler); this.emitter.on('resize', this.handleResize as mitt.Handler);
@@ -84,22 +87,27 @@ export class Replayer {
*/ */
public play(timeOffset = 0) { public play(timeOffset = 0) {
this.baselineTime = this.events[0].timestamp + timeOffset; this.baselineTime = this.events[0].timestamp + timeOffset;
const actions = new Array<actionWithDelay>();
for (const event of this.events) { for (const event of this.events) {
const isSync = event.timestamp < this.baselineTime; const isSync = event.timestamp < this.baselineTime;
const castFn = this.getCastFn(event, isSync); const castFn = this.getCastFn(event, isSync);
if (isSync) { if (isSync) {
castFn(); castFn();
} else { } else {
this.later(castFn, this.getDelay(event)); actions.push({ doAction: castFn, delay: this.getDelay(event) });
} }
} }
this.timer.addActions(actions);
this.timer.start();
} }
public pause() { public pause() {
this.timerIds.forEach(clear); this.timer.clear();
} }
public resume(timeOffset = 0) { public resume(timeOffset = 0) {
this.timer.clear();
const actions = new Array<actionWithDelay>();
for (const event of this.events) { for (const event of this.events) {
if ( if (
event.timestamp < this.lastPlayedEvent.timestamp || event.timestamp < this.lastPlayedEvent.timestamp ||
@@ -109,8 +117,13 @@ export class Replayer {
} }
const delayToBaseline = this.getDelay(event); const delayToBaseline = this.getDelay(event);
const castFn = this.getCastFn(event); const castFn = this.getCastFn(event);
this.later(castFn, delayToBaseline - timeOffset); actions.push({
doAction: castFn,
delay: delayToBaseline - timeOffset,
});
} }
this.timer.addActions(actions);
this.timer.start();
} }
private setupDom() { private setupDom() {
@@ -131,11 +144,6 @@ export class Replayer {
this.iframe.height = `${dimension.height}px`; this.iframe.height = `${dimension.height}px`;
} }
private later(cb: () => void, delayMs: number) {
const id = later(cb, delayMs, this.config);
this.timerIds.push(id);
}
// TODO: add speed to mouse move timestamp calculation // TODO: add speed to mouse move timestamp calculation
private getDelay(event: eventWithTime): number { private getDelay(event: eventWithTime): number {
// Mouse move events was recorded in a throttle function, // Mouse move events was recorded in a throttle function,
@@ -147,13 +155,14 @@ export class Replayer {
const firstOffset = event.data.positions[0].timeOffset; const firstOffset = event.data.positions[0].timeOffset;
// timeOffset is a negative offset to event.timestamp // timeOffset is a negative offset to event.timestamp
const firstTimestamp = event.timestamp + firstOffset; const firstTimestamp = event.timestamp + firstOffset;
const delay = firstTimestamp - this.baselineTime
event.data.positions = event.data.positions.map(p => { event.data.positions = event.data.positions.map(p => {
return { return {
...p, ...p,
timeOffset: p.timeOffset - firstOffset, timeOffset: p.timeOffset - firstOffset + delay,
}; };
}); });
return firstTimestamp - this.baselineTime; return delay;
} }
return event.timestamp - this.baselineTime; return event.timestamp - this.baselineTime;
} }
@@ -294,14 +303,18 @@ export class Replayer {
// skip mouse move in sync mode // skip mouse move in sync mode
if (!isSync) { if (!isSync) {
d.positions.forEach(p => { d.positions.forEach(p => {
this.later(() => { const action = {
this.mouse.style.left = `${p.x}px`; doAction: () => {
this.mouse.style.top = `${p.y}px`; this.mouse.style.left = `${p.x}px`;
const target = mirror.getNode(p.id); this.mouse.style.top = `${p.y}px`;
if (target) { const target = mirror.getNode(p.id);
this.hoverElements((target as Node) as Element); if (target) {
} this.hoverElements((target as Node) as Element);
}, p.timeOffset); }
},
delay: p.timeOffset,
};
this.timer.addAction(action);
}); });
} }
break; break;

View File

@@ -1,38 +1,70 @@
import { playerConfig } from '../types'; import { playerConfig, actionWithDelay } from '../types';
const FRAME_MS = 16; export default class Timer {
let _id = 1; private actions: actionWithDelay[];
const timerMap: Map<number, boolean> = new Map(); private config: playerConfig;
export function later( constructor(config: playerConfig, actions: actionWithDelay[] = []) {
cb: () => void, this.actions = actions;
delayMs: number, this.config = config;
config: playerConfig, }
): number { /**
const now = performance.now(); * Add an action after the timer starts.
let lastStep = now; * @param action
const id = _id++; */
timerMap.set(id, true); public addAction(action: actionWithDelay) {
const index = this.findActionIndex(action);
function check(step: number) { this.actions.splice(index, 0, action);
if (!timerMap.has(id)) { }
return; /**
} * Add all actions before the timer starts
const stepDiff = step - lastStep; * @param actions
lastStep = step; */
delayMs -= config.speed * stepDiff; public addActions(actions: actionWithDelay[]) {
if (delayMs < FRAME_MS) { this.actions.push(...actions);
cb();
clear(id);
} else {
requestAnimationFrame(check);
}
} }
requestAnimationFrame(check); public start() {
return id; this.actions.sort((a1, a2) => a1.delay - a2.delay);
} let delayed = 0;
const start = performance.now();
const { actions, config } = this;
function check(time: number) {
delayed = time - start;
while (actions.length) {
const action = actions[0];
const delayNeeded = action.delay / config.speed;
if (delayed >= delayNeeded) {
actions.shift();
action.doAction();
} else {
break;
}
}
if (actions.length > 0) {
requestAnimationFrame(check);
}
}
requestAnimationFrame(check);
}
export function clear(id: number) { public clear() {
timerMap.delete(id); this.actions = [];
}
private findActionIndex(action: actionWithDelay): number {
let start = 0;
let end = this.actions.length - 1;
while (start <= end) {
let mid = Math.floor((start + end) / 2);
if (this.actions[mid].delay < action.delay) {
start = mid + 1;
} else if (this.actions[mid].delay > action.delay) {
end = mid - 1;
} else {
return mid;
}
}
return start;
}
} }

View File

@@ -239,3 +239,8 @@ export type missingNode = {
export type missingNodeMap = { export type missingNodeMap = {
[id: number]: missingNode; [id: number]: missingNode;
}; };
export type actionWithDelay = {
doAction: () => void;
delay: number;
};