change observed mutations into serializable records

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent e2fbecea96
commit d2175eae87
6 changed files with 280 additions and 39 deletions

View File

@@ -1,35 +0,0 @@
import { snapshot } from 'rrweb-snapshot';
import { EventType, event } from './types';
function on(
type: string,
fn: EventListenerOrEventListenerObject,
target = document,
) {
target.addEventListener(type, fn);
}
function createEvent(type: EventType, data: any): event {
return {
type,
data,
timestamp: Date.now(),
};
}
function emit(e: event) {}
function record() {
on('DOMContentLoaded', () => {
emit(
createEvent(EventType.DomContentLoaded, { href: window.location.href }),
);
});
on('load', () => {
emit(createEvent(EventType.Load, null));
const node = snapshot(document);
emit(createEvent(EventType.FullSnapshot, { node }));
});
}
export default record;

70
src/record/index.ts Normal file
View File

@@ -0,0 +1,70 @@
import { snapshot } from 'rrweb-snapshot';
import initObservers from './observer';
import { mirror } from '../utils';
import {
EventType,
event,
eventWithTime,
recordOptions,
IncrementalSource,
} from '../types';
function on(
type: string,
fn: EventListenerOrEventListenerObject,
target = document,
) {
target.addEventListener(type, fn, { capture: true, passive: true });
}
function wrapEvent(e: event): eventWithTime {
return {
...e,
timestamp: Date.now(),
};
}
function record(options: recordOptions) {
try {
const { emit } = options;
// runtime checks for user options
if (!emit) {
throw new Error('emit function is required');
}
on('DOMContentLoaded', () => {
emit(
wrapEvent({
type: EventType.DomContentLoaded,
data: {
href: window.location.href,
},
}),
);
});
on('load', () => {
emit(wrapEvent({ type: EventType.Load, data: {} }));
const [node, idNodeMap] = snapshot(document);
if (!node) {
return console.warn('Failed to snapshot the document');
}
mirror.map = idNodeMap;
emit(wrapEvent({ type: EventType.FullSnapshot, data: { node } }));
initObservers({
mutationCb: m =>
emit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
),
});
});
} catch (error) {
// TODO: handle internal error
}
}
export default record;

107
src/record/observer.ts Normal file
View File

@@ -0,0 +1,107 @@
import { INode } from 'rrweb-snapshot';
import { mirror } from '../utils';
import {
mutationCallBack,
textMutation,
attributeMutation,
removedNodeMutation,
addedNodeMutation,
observerParam,
} from '../types';
function initMutationObserver(cb: mutationCallBack): MutationObserver {
const observer = new MutationObserver(mutations => {
const texts: textMutation[] = [];
const attributes: attributeMutation[] = [];
const removes: removedNodeMutation[] = [];
const adds: addedNodeMutation[] = [];
mutations.forEach(mutation => {
const {
type,
target,
oldValue,
addedNodes,
removedNodes,
attributeName,
nextSibling,
previousSibling,
} = mutation;
const id = mirror.getId(target as INode);
switch (type) {
case 'characterData': {
const value = target.textContent;
if (value !== oldValue) {
texts.push({
id,
value,
});
}
break;
}
case 'attributes': {
const value = (target as HTMLElement).getAttribute(attributeName!);
if (value === oldValue) {
return;
}
let item: attributeMutation | undefined = attributes.find(
a => a.id === id,
);
if (!item) {
item = {
id,
attributes: {},
};
attributes.push(item);
}
// overwrite attribute if the mutations was triggered in same time
item.attributes[attributeName!] = value;
}
case 'childList': {
removedNodes.forEach(n => {
removes.push({
parentId: id,
id: mirror.getId(n as INode),
});
});
addedNodes.forEach(n => {
adds.push({
parentId: id,
previousId: !previousSibling
? previousSibling
: mirror.getId(previousSibling as INode),
nextId: !nextSibling
? nextSibling
: mirror.getId(nextSibling as INode),
id: mirror.getId(n as INode),
});
});
break;
}
default:
break;
}
});
cb({
texts,
attributes,
removes,
adds,
});
});
observer.observe(document, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
});
return observer;
}
export default function initObservers(o: observerParam) {
const mutationObserver = initMutationObserver(o.mutationCb);
return {
mutationObserver,
};
}

View File

@@ -1,3 +1,5 @@
import { serializedNodeWithId, idNodeMap, INode } from 'rrweb-snapshot';
export enum EventType { export enum EventType {
DomContentLoaded, DomContentLoaded,
Load, Load,
@@ -5,8 +7,93 @@ export enum EventType {
IncrementalSnapshot, IncrementalSnapshot,
} }
export type event = { export type domContentLoadedEvent = {
type: EventType; type: EventType.DomContentLoaded;
timestamp: number; data: {
data: any; href: string;
};
};
export type loadedEvent = {
type: EventType.Load;
data: {};
};
export type fullSnapshotEvent = {
type: EventType.FullSnapshot;
data: {
node: serializedNodeWithId;
};
};
export enum IncrementalSource {
Mutation,
}
export type incrementalSnapshotEvent = {
type: EventType.IncrementalSnapshot;
data: incrementalData;
};
export type mutationData = {
source: IncrementalSource.Mutation;
} & mutationCallbackParam;
export type incrementalData = mutationData;
export type event =
| domContentLoadedEvent
| loadedEvent
| fullSnapshotEvent
| incrementalSnapshotEvent;
export type eventWithTime = event & {
timestamp: number;
};
export type recordOptions = {
emit: (e: eventWithTime) => void;
};
export type observerParam = {
mutationCb: mutationCallBack;
};
export type textMutation = {
id: number;
value: string | null;
};
export type attributeMutation = {
id: number;
attributes: {
[key: string]: string | null;
};
};
export type removedNodeMutation = {
parentId: number;
id: number;
};
export type addedNodeMutation = {
parentId: number;
previousId: number | null;
nextId: number | null;
id: number;
};
type mutationCallbackParam = {
texts: textMutation[];
attributes: attributeMutation[];
removes: removedNodeMutation[];
adds: addedNodeMutation[];
};
export type mutationCallBack = (m: mutationCallbackParam) => void;
export type Mirror = {
map: idNodeMap;
getId: (n: INode) => number;
getNode: (id: number) => INode;
}; };

11
src/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Mirror } from './types';
export const mirror: Mirror = {
map: {},
getId(n) {
return n.__sn && n.__sn.id;
},
getNode(id) {
return mirror.map[id];
},
};

View File

@@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true, "removeComments": true,
"preserveConstEnums": true, "preserveConstEnums": true,
"sourceMap": true, "sourceMap": true,