From a31efdc169198913efd2c62e1ed5a23739ff2d39 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] close #274 implement the new state management proposal --- src/replay/index.ts | 123 +++++++++++++++---------- src/replay/machine.ts | 175 ++++++++++++++++++++++++------------ src/replay/timer.ts | 26 ++++-- src/types.ts | 1 + typings/replay/index.d.ts | 11 +-- typings/replay/machine.d.ts | 55 +++++++----- typings/replay/timer.d.ts | 9 +- typings/types.d.ts | 3 +- 8 files changed, 259 insertions(+), 144 deletions(-) diff --git a/src/replay/index.ts b/src/replay/index.ts index 571c4896..07d10520 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -2,7 +2,7 @@ import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import * as smoothscroll from 'smoothscroll-polyfill'; import { Timer } from './timer'; -import { createPlayerService } from './machine'; +import { createPlayerService, createSpeedService } from './machine'; import { EventType, IncrementalSource, @@ -56,24 +56,23 @@ export class Replayer { public wrapper: HTMLDivElement; public iframe: HTMLIFrameElement; + public service: ReturnType; + public speedService: ReturnType; public get timer() { return this.service.state.context.timer; } - private config: playerConfig; + public config: playerConfig; private mouse: HTMLDivElement; private emitter: Emitter = mitt(); private nextUserInteractionEvent: eventWithTime | null; - private noramlSpeed: number = -1; // tslint:disable-next-line: variable-name private legacy_missingNodeRetryMap: missingNodeMap = {}; - private service!: ReturnType; - private treeIndex!: TreeIndex; private fragmentParentMap!: Map; @@ -124,6 +123,7 @@ export class Replayer { this.fragmentParentMap.clear(); }); + const timer = new Timer([], config?.speed || defaultConfig.speed); this.service = createPlayerService( { events: events.map((e) => { @@ -132,8 +132,7 @@ export class Replayer { } return e as eventWithTime; }), - timer: new Timer(this.config), - speed: config?.speed || defaultConfig.speed, + timer, timeOffset: 0, baselineTime: 0, lastPlayedEvent: null, @@ -145,25 +144,37 @@ export class Replayer { ); this.service.start(); this.service.subscribe((state) => { - if (!state.changed) { - return; - } - // publish via emitter + this.emitter.emit(ReplayerEvents.StateChange, { + player: state, + }); + }); + this.speedService = createSpeedService({ + normalSpeed: -1, + timer, + }); + this.speedService.start(); + this.speedService.subscribe((state) => { + this.emitter.emit(ReplayerEvents.StateChange, { + speed: state, + }); }); // rebuild first full snapshot as the poster of the player // maybe we can cache it for performance optimization - const { events: contextEvents } = this.service.state.context; - const firstMeta = contextEvents.find((e) => e.type === EventType.Meta); - const firstFullsnapshot = contextEvents.find( + const firstMeta = this.service.state.context.events.find( + (e) => e.type === EventType.Meta, + ); + const firstFullsnapshot = this.service.state.context.events.find( (e) => e.type === EventType.FullSnapshot, ); if (firstMeta) { const { width, height } = firstMeta.data as metaEvent['data']; - this.emitter.emit(ReplayerEvents.Resize, { - width, - height, - }); + setTimeout(() => { + this.emitter.emit(ReplayerEvents.Resize, { + width, + height, + }); + }, 0); } if (firstFullsnapshot) { this.rebuildFullSnapshot( @@ -182,14 +193,15 @@ export class Replayer { this.config[key] = config[key]; }); if (!this.config.skipInactive) { - this.noramlSpeed = -1; + this.backToNormal(); } } public getMetaData(): playerMetaData { - const { events } = this.service.state.context; - const firstEvent = events[0]; - const lastEvent = events[events.length - 1]; + const firstEvent = this.service.state.context.events[0]; + const lastEvent = this.service.state.context.events[ + this.service.state.context.events.length - 1 + ]; return { startTime: firstEvent.timestamp, endTime: lastEvent.timestamp, @@ -216,25 +228,31 @@ export class Replayer { * @param timeOffset number */ public play(timeOffset = 0) { - if (this.service.state.value === 'ended') { - this.service.state.context.lastPlayedEvent = null; - this.service.send({ type: 'REPLAY' }); - } - if (this.service.state.value === 'paused') { - this.service.send({ type: 'RESUME', payload: { timeOffset } }); + if (this.service.state.matches('paused')) { + this.service.send({ type: 'PLAY', payload: { timeOffset } }); } else { + this.service.send({ type: 'PAUSE' }); this.service.send({ type: 'PLAY', payload: { timeOffset } }); } this.emitter.emit(ReplayerEvents.Start); } - public pause() { - this.service.send({ type: 'PAUSE' }); + public pause(timeOffset?: number) { + if (timeOffset === undefined && this.service.state.matches('playing')) { + this.service.send({ type: 'PAUSE' }); + } + if (typeof timeOffset === 'number') { + this.play(timeOffset); + this.service.send({ type: 'PAUSE' }); + } this.emitter.emit(ReplayerEvents.Pause); } public resume(timeOffset = 0) { - this.service.send({ type: 'RESUME', payload: { timeOffset } }); + console.warn( + `The 'resume' will be departed in 1.0. Please use 'play' method which has the same interface.`, + ); + this.play(timeOffset); this.emitter.emit(ReplayerEvents.Resume); } @@ -282,7 +300,6 @@ export class Replayer { } private getCastFn(event: eventWithTime, isSync = false) { - const { events } = this.service.state.context; let castFn: undefined | (() => void); switch (event.type) { case EventType.DomContentLoaded: @@ -314,19 +331,24 @@ export class Replayer { case EventType.IncrementalSnapshot: castFn = () => { this.applyIncremental(event, isSync); + if (isSync) { + // do not check skip in sync + return; + } if (event === this.nextUserInteractionEvent) { this.nextUserInteractionEvent = null; - this.restoreSpeed(); + this.backToNormal(); } if (this.config.skipInactive && !this.nextUserInteractionEvent) { - for (const _event of events) { + for (const _event of this.service.state.context.events) { if (_event.timestamp! <= event.timestamp!) { continue; } if (this.isUserInteraction(_event)) { if ( _event.delay! - event.delay! > - SKIP_TIME_THRESHOLD * this.config.speed + SKIP_TIME_THRESHOLD * + this.speedService.state.context.timer.speed ) { this.nextUserInteractionEvent = _event; } @@ -334,13 +356,12 @@ export class Replayer { } } if (this.nextUserInteractionEvent) { - this.noramlSpeed = this.config.speed; const skipTime = this.nextUserInteractionEvent.delay! - event.delay!; const payload = { speed: Math.min(Math.round(skipTime / SKIP_TIME_INTERVAL), 360), }; - this.setConfig(payload); + this.speedService.send({ type: 'FAST_FORWARD', payload }); this.emitter.emit(ReplayerEvents.SkipStart, payload); } } @@ -353,8 +374,13 @@ export class Replayer { castFn(); } this.service.send({ type: 'CAST_EVENT', payload: { event } }); - if (event === events[events.length - 1]) { - this.restoreSpeed(); + if ( + event === + this.service.state.context.events[ + this.service.state.context.events.length - 1 + ] + ) { + this.backToNormal(); this.service.send('END'); this.emitter.emit(ReplayerEvents.Finish); } @@ -438,7 +464,6 @@ export class Replayer { e: incrementalSnapshotEvent & { timestamp: number }, isSync: boolean, ) { - const { baselineTime } = this.service.state.context; const { data: d } = e; switch (d.source) { case IncrementalSource.Mutation: { @@ -461,7 +486,10 @@ export class Replayer { doAction: () => { this.moveAndHover(d, p.x, p.y, p.id); }, - delay: p.timeOffset + e.timestamp - baselineTime, + delay: + p.timeOffset + + e.timestamp - + this.service.state.context.baselineTime, }; this.timer.addAction(action); }); @@ -863,14 +891,15 @@ export class Replayer { ); } - private restoreSpeed() { - if (this.noramlSpeed === -1) { + private backToNormal() { + this.nextUserInteractionEvent = null; + if (this.speedService.state.matches('normal')) { return; } - const payload = { speed: this.noramlSpeed }; - this.setConfig(payload); - this.emitter.emit(ReplayerEvents.SkipEnd, payload); - this.noramlSpeed = -1; + this.speedService.send({ type: 'BACK_TO_NORMAL' }); + this.emitter.emit(ReplayerEvents.SkipEnd, { + speed: this.speedService.state.context.normalSpeed, + }); } private warnNodeNotFound(d: incrementalData, id: number) { diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 74b944e4..4ba61822 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -1,4 +1,4 @@ -import { createMachine, interpret, assign } from '@xstate/fsm'; +import { createMachine, interpret, assign, StateMachine } from '@xstate/fsm'; import { playerConfig, eventWithTime, @@ -7,13 +7,12 @@ import { EventType, Emitter, } from '../types'; -import { Timer, getDelay } from './timer'; +import { Timer, addDelay } from './timer'; import { needCastInSyncMode } from '../utils'; export type PlayerContext = { events: eventWithTime[]; timer: Timer; - speed: playerConfig['speed']; timeOffset: number; baselineTime: number; lastPlayedEvent: eventWithTime | null; @@ -32,28 +31,17 @@ export type PlayerEvent = }; } | { type: 'PAUSE' } - | { - type: 'RESUME'; - payload: { - timeOffset: number; - }; - } - | { type: 'END' } - | { type: 'REPLAY' } - | { type: 'FAST_FORWARD' } - | { type: 'BACK_TO_NORMAL' } | { type: 'TO_LIVE'; payload: { baselineTime?: number } } | { type: 'ADD_EVENT'; payload: { event: eventWithTime; }; + } + | { + type: 'END'; }; export type PlayerState = - | { - value: 'inited'; - context: PlayerContext; - } | { value: 'playing'; context: PlayerContext; @@ -62,14 +50,6 @@ export type PlayerState = value: 'paused'; context: PlayerContext; } - | { - value: 'ended'; - context: PlayerContext; - } - | { - value: 'skipping'; - context: PlayerContext; - } | { value: 'live'; context: PlayerContext; @@ -106,37 +86,27 @@ export function createPlayerService( { id: 'player', context, - initial: 'inited', + initial: 'paused', states: { - inited: { - on: { - PLAY: { - target: 'playing', - actions: ['recordTimeOffset', 'play'], - }, - TO_LIVE: { - target: 'live', - actions: ['startLive'], - }, - }, - }, playing: { on: { PAUSE: { target: 'paused', actions: ['pause'], }, - END: 'ended', - FAST_FORWARD: 'skipping', CAST_EVENT: { target: 'playing', actions: 'castEvent', }, + END: { + target: 'paused', + actions: ['resetLastPlayedEvent', 'pause'], + }, }, }, paused: { on: { - RESUME: { + PLAY: { target: 'playing', actions: ['recordTimeOffset', 'play'], }, @@ -146,16 +116,6 @@ export function createPlayerService( }, }, }, - skipping: { - on: { - BACK_TO_NORMAL: 'playing', - }, - }, - ended: { - on: { - REPLAY: 'playing', - }, - }, live: { on: { ADD_EVENT: { @@ -173,7 +133,7 @@ export function createPlayerService( if (event.type === 'CAST_EVENT') { return event.payload.event; } - return context.lastPlayedEvent; + return ctx.lastPlayedEvent; }, }), recordTimeOffset: assign((ctx, event) => { @@ -190,13 +150,17 @@ export function createPlayerService( play(ctx) { const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); + for (const event of events) { + // TODO: improve this API + addDelay(event, baselineTime); + } const neededEvents = discardPriorSnapshots(events, baselineTime); const actions = new Array(); for (const event of neededEvents) { if ( lastPlayedEvent && - lastPlayedEvent.timestamp > baselineTime && + lastPlayedEvent.timestamp < baselineTime && (event.timestamp <= lastPlayedEvent.timestamp || event === lastPlayedEvent) ) { @@ -215,7 +179,7 @@ export function createPlayerService( castFn(); emitter.emit(ReplayerEvents.EventCast, event); }, - delay: getDelay(event, baselineTime), + delay: event.delay!, }); } } @@ -226,8 +190,15 @@ export function createPlayerService( pause(ctx) { ctx.timer.clear(); }, + resetLastPlayedEvent: assign((ctx) => { + return { + ...ctx, + lastPlayedEvent: null, + }; + }), startLive: assign({ baselineTime: (ctx, event) => { + ctx.timer.toggleLiveMode(true); ctx.timer.start(); if (event.type === 'TO_LIVE' && event.payload.baselineTime) { return event.payload.baselineTime; @@ -239,6 +210,7 @@ export function createPlayerService( const { baselineTime, timer, events } = ctx; if (machineEvent.type === 'ADD_EVENT') { const { event } = machineEvent.payload; + addDelay(event, baselineTime); events.push(event); const isSync = event.timestamp < baselineTime; const castFn = getCastFn(event, isSync); @@ -250,7 +222,7 @@ export function createPlayerService( castFn(); emitter.emit(ReplayerEvents.EventCast, event); }, - delay: getDelay(event, baselineTime), + delay: event.delay!, }); } } @@ -261,3 +233,96 @@ export function createPlayerService( ); return interpret(playerMachine); } + +export type SpeedContext = { + normalSpeed: playerConfig['speed']; + timer: Timer; +}; + +export type SpeedEvent = + | { + type: 'FAST_FORWARD'; + payload: { speed: playerConfig['speed'] }; + } + | { + type: 'BACK_TO_NORMAL'; + } + | { + type: 'SET_SPEED'; + payload: { speed: playerConfig['speed'] }; + }; + +export type SpeedState = + | { + value: 'normal'; + context: SpeedContext; + } + | { + value: 'skipping'; + context: SpeedContext; + }; + +export function createSpeedService(context: SpeedContext) { + const speedMachine = createMachine( + { + id: 'speed', + context, + initial: 'normal', + states: { + normal: { + on: { + FAST_FORWARD: { + target: 'skipping', + actions: ['recordSpeed', 'setSpeed'], + }, + SET_SPEED: { + target: 'normal', + actions: ['setSpeed'], + }, + }, + }, + skipping: { + on: { + BACK_TO_NORMAL: { + target: 'normal', + actions: ['restoreSpeed'], + }, + SET_SPEED: { + target: 'normal', + actions: ['setSpeed'], + }, + }, + }, + }, + }, + { + actions: { + setSpeed: (ctx, event) => { + if ('payload' in event) { + ctx.timer.setSpeed(event.payload.speed); + } + }, + recordSpeed: assign({ + normalSpeed: (ctx) => ctx.timer.speed, + }), + restoreSpeed: (ctx) => { + ctx.timer.setSpeed(ctx.normalSpeed); + }, + }, + }, + ); + + return interpret(speedMachine); +} + +export type PlayerMachineState = StateMachine.State< + PlayerContext, + PlayerEvent, + PlayerState +>; + +export type SpeedMachineState = StateMachine.State< + SpeedContext, + SpeedEvent, + SpeedState +>; diff --git a/src/replay/timer.ts b/src/replay/timer.ts index 38e1d3b5..72e6516a 100644 --- a/src/replay/timer.ts +++ b/src/replay/timer.ts @@ -1,5 +1,4 @@ import { - playerConfig, actionWithDelay, eventWithTime, EventType, @@ -8,14 +7,15 @@ import { export class Timer { public timeOffset: number = 0; + public speed: number; private actions: actionWithDelay[]; - private config: playerConfig; private raf: number; + private liveMode: boolean; - constructor(config: playerConfig, actions: actionWithDelay[] = []) { + constructor(actions: actionWithDelay[] = [], speed: number) { this.actions = actions; - this.config = config; + this.speed = speed; } /** * Add an action after the timer starts. @@ -37,10 +37,10 @@ export class Timer { this.actions.sort((a1, a2) => a1.delay - a2.delay); this.timeOffset = 0; let lastTimestamp = performance.now(); - const { actions, config } = this; + const { actions } = this; const self = this; function check(time: number) { - self.timeOffset += (time - lastTimestamp) * config.speed; + self.timeOffset += (time - lastTimestamp) * self.speed; lastTimestamp = time; while (actions.length) { const action = actions[0]; @@ -51,7 +51,7 @@ export class Timer { break; } } - if (actions.length > 0 || self.config.liveMode) { + if (actions.length > 0 || self.liveMode) { self.raf = requestAnimationFrame(check); } } @@ -65,6 +65,14 @@ export class Timer { this.actions.length = 0; } + public setSpeed(speed: number) { + this.speed = speed; + } + + public toggleLiveMode(mode: boolean) { + this.liveMode = mode; + } + private findActionIndex(action: actionWithDelay): number { let start = 0; let end = this.actions.length - 1; @@ -83,7 +91,7 @@ export class Timer { } // TODO: add speed to mouse move timestamp calculation -export function getDelay(event: eventWithTime, baselineTime: number): number { +export function addDelay(event: eventWithTime, baselineTime: number): number { // Mouse move events was recorded in a throttle function, // so we need to find the real timestamp by traverse the time offsets. if ( @@ -97,5 +105,5 @@ export function getDelay(event: eventWithTime, baselineTime: number): number { return firstTimestamp - baselineTime; } event.delay = event.timestamp - baselineTime; - return event.timestamp - baselineTime; + return event.delay; } diff --git a/src/types.ts b/src/types.ts index 7a44972f..fc0ccaf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -410,4 +410,5 @@ export enum ReplayerEvents { EventCast = 'event-cast', CustomEvent = 'custom-event', Flush = 'flush', + StateChange = 'state-change', } diff --git a/typings/replay/index.d.ts b/typings/replay/index.d.ts index 724060b4..5d0551b9 100644 --- a/typings/replay/index.d.ts +++ b/typings/replay/index.d.ts @@ -1,17 +1,18 @@ import { Timer } from './timer'; +import { createPlayerService, createSpeedService } from './machine'; import { eventWithTime, playerConfig, playerMetaData, Handler } from '../types'; import './styles/style.css'; export declare class Replayer { wrapper: HTMLDivElement; iframe: HTMLIFrameElement; + service: ReturnType; + speedService: ReturnType; get timer(): Timer; - private config; + config: playerConfig; private mouse; private emitter; private nextUserInteractionEvent; - private noramlSpeed; private legacy_missingNodeRetryMap; - private service; private treeIndex; private fragmentParentMap; constructor(events: Array, config?: Partial); @@ -21,7 +22,7 @@ export declare class Replayer { getCurrentTime(): number; getTimeOffset(): number; play(timeOffset?: number): void; - pause(): void; + pause(timeOffset?: number): void; resume(timeOffset?: number): void; startLive(baselineTime?: number): void; addEvent(rawEvent: eventWithTime | string): void; @@ -40,7 +41,7 @@ export declare class Replayer { private moveAndHover; private hoverElements; private isUserInteraction; - private restoreSpeed; + private backToNormal; private warnNodeNotFound; private debugNodeNotFound; } diff --git a/typings/replay/machine.d.ts b/typings/replay/machine.d.ts index 5eaff393..3d82c1ec 100644 --- a/typings/replay/machine.d.ts +++ b/typings/replay/machine.d.ts @@ -1,9 +1,9 @@ +import { StateMachine } from '@xstate/fsm'; import { playerConfig, eventWithTime, Emitter } from '../types'; import { Timer } from './timer'; export declare type PlayerContext = { events: eventWithTime[]; timer: Timer; - speed: playerConfig['speed']; timeOffset: number; baselineTime: number; lastPlayedEvent: eventWithTime | null; @@ -20,19 +20,6 @@ export declare type PlayerEvent = { }; } | { type: 'PAUSE'; -} | { - type: 'RESUME'; - payload: { - timeOffset: number; - }; -} | { - type: 'END'; -} | { - type: 'REPLAY'; -} | { - type: 'FAST_FORWARD'; -} | { - type: 'BACK_TO_NORMAL'; } | { type: 'TO_LIVE'; payload: { @@ -43,22 +30,15 @@ export declare type PlayerEvent = { payload: { event: eventWithTime; }; +} | { + type: 'END'; }; export declare type PlayerState = { - value: 'inited'; - context: PlayerContext; -} | { value: 'playing'; context: PlayerContext; } | { value: 'paused'; context: PlayerContext; -} | { - value: 'ended'; - context: PlayerContext; -} | { - value: 'skipping'; - context: PlayerContext; } | { value: 'live'; context: PlayerContext; @@ -68,5 +48,32 @@ declare type PlayerAssets = { emitter: Emitter; getCastFn(event: eventWithTime, isSync: boolean): () => void; }; -export declare function createPlayerService(context: PlayerContext, { getCastFn, emitter }: PlayerAssets): import("@xstate/fsm").StateMachine.Service; +export declare function createPlayerService(context: PlayerContext, { getCastFn, emitter }: PlayerAssets): StateMachine.Service; +export declare type SpeedContext = { + normalSpeed: playerConfig['speed']; + timer: Timer; +}; +export declare type SpeedEvent = { + type: 'FAST_FORWARD'; + payload: { + speed: playerConfig['speed']; + }; +} | { + type: 'BACK_TO_NORMAL'; +} | { + type: 'SET_SPEED'; + payload: { + speed: playerConfig['speed']; + }; +}; +export declare type SpeedState = { + value: 'normal'; + context: SpeedContext; +} | { + value: 'skipping'; + context: SpeedContext; +}; +export declare function createSpeedService(context: SpeedContext): StateMachine.Service; +export declare type PlayerMachineState = StateMachine.State; +export declare type SpeedMachineState = StateMachine.State; export {}; diff --git a/typings/replay/timer.d.ts b/typings/replay/timer.d.ts index 8bea5759..0d67d3c0 100644 --- a/typings/replay/timer.d.ts +++ b/typings/replay/timer.d.ts @@ -1,14 +1,17 @@ -import { playerConfig, actionWithDelay, eventWithTime } from '../types'; +import { actionWithDelay, eventWithTime } from '../types'; export declare class Timer { timeOffset: number; + speed: number; private actions; - private config; private raf; - constructor(config: playerConfig, actions?: actionWithDelay[]); + private liveMode; + constructor(actions: actionWithDelay[] | undefined, speed: number); addAction(action: actionWithDelay): void; addActions(actions: actionWithDelay[]): void; start(): void; clear(): void; + setSpeed(speed: number): void; + toggleLiveMode(mode: boolean): void; private findActionIndex; } export declare function getDelay(event: eventWithTime, baselineTime: number): number; diff --git a/typings/types.d.ts b/typings/types.d.ts index 05d7f29e..58fad2fd 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -308,6 +308,7 @@ export declare enum ReplayerEvents { MouseInteraction = "mouse-interaction", EventCast = "event-cast", CustomEvent = "custom-event", - Flush = "flush" + Flush = "flush", + StateChange = "state-change" } export {};