rewrite mutation observer handler with lazy child list calculation

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent 487f1d0c9a
commit aa3e2f02ed
6 changed files with 112 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rrweb",
"version": "0.4.0",
"version": "0.4.1",
"description": "record and replay the web",
"main": "dist/index.js",
"module": "dist/module.js",
@@ -40,6 +40,6 @@
},
"dependencies": {
"mitt": "^1.1.3",
"rrweb-snapshot": "^0.5.2"
"rrweb-snapshot": "0.5.3"
}
}

View File

@@ -15,7 +15,7 @@ export default [
file: './dist/record/module.js',
},
{
name: 'record1',
name: 'record',
format: 'iife',
file: './dist/record/browser.js',
},

View File

@@ -9,8 +9,6 @@ import {
} from '../utils';
import {
mutationCallBack,
textMutation,
attributeMutation,
removedNodeMutation,
addedNodeMutation,
observerParam,
@@ -24,14 +22,40 @@ import {
inputValue,
inputCallback,
hookResetter,
textCursor,
attributeCursor,
} from '../types';
/**
* Mutation observer will merge several mutations into an array and pass
* it to the callback function, this may make tracing added nodes hard.
* For example, if we append an element el_1 into body, and then append
* another element el_2 into el_1, these two mutations may be passed to the
* callback function together when the two operations were done.
* Generally we need trace child nodes of newly added node, but in this
* case if we count el_2 as el_1's child node in the first mutation record,
* then we will count el_2 again in the secoond mutation record which was
* duplicated.
* To avoid of duplicate counting added nodes, we will use a Set to store
* added nodes and its child nodes during iterate mutation records. Then
* collect added nodes from the Set which will has no duplicate copy. But
* this also cause newly added node will not be serialized with id ASAP,
* which means all the id related calculation should be lazy too.
* @param cb mutationCallBack
*/
function initMutationObserver(cb: mutationCallBack): MutationObserver {
const observer = new MutationObserver(mutations => {
const texts: textMutation[] = [];
const attributes: attributeMutation[] = [];
const texts: textCursor[] = [];
const attributes: attributeCursor[] = [];
const removes: removedNodeMutation[] = [];
const adds: addedNodeMutation[] = [];
const dropped: Node[] = [];
const addsSet = new Set<Node>();
const genAdds = (n: Node) => {
addsSet.add(n);
n.childNodes.forEach(childN => genAdds(childN));
};
mutations.forEach(mutation => {
const {
type,
@@ -40,17 +64,14 @@ function initMutationObserver(cb: mutationCallBack): MutationObserver {
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,
node: target,
});
}
break;
@@ -60,12 +81,12 @@ function initMutationObserver(cb: mutationCallBack): MutationObserver {
if (value === oldValue) {
return;
}
let item: attributeMutation | undefined = attributes.find(
a => a.id === id,
let item: attributeCursor | undefined = attributes.find(
a => a.node === target,
);
if (!item) {
item = {
id,
node: target,
attributes: {},
};
attributes.push(item);
@@ -74,23 +95,25 @@ function initMutationObserver(cb: mutationCallBack): MutationObserver {
item.attributes[attributeName!] = value;
}
case 'childList': {
addedNodes.forEach(n => {
adds.push({
parentId: id,
previousId: !previousSibling
? previousSibling
: mirror.getId(previousSibling as INode),
nextId: !nextSibling
? nextSibling
: mirror.getId(nextSibling as INode),
node: serializeNodeWithId(n, document, mirror.map)!,
});
});
addedNodes.forEach(n => genAdds(n));
removedNodes.forEach(n => {
removes.push({
parentId: id,
id: mirror.getId(n as INode),
});
// removed node has not been serialized yet, just remove it from the Set
if (addsSet.has(n)) {
addsSet.delete(n);
dropped.push(n);
} else if (addsSet.has(target) && !mirror.getId(n as INode)) {
/**
* If target was newly added and removed child node was
* not serialized, it means the child node has been removed
* before callback fired, so we can ignore it.
* TODO: verify this
*/
} else {
removes.push({
parentId: mirror.getId(target as INode),
id: mirror.getId(n as INode),
});
}
mirror.removeNodeFromMap(n as INode);
});
break;
@@ -99,10 +122,40 @@ function initMutationObserver(cb: mutationCallBack): MutationObserver {
break;
}
});
Array.from(addsSet).forEach(n => {
if (n.parentNode && dropped.indexOf(n.parentNode) < 0) {
adds.push({
parentId: mirror.getId(n.parentNode as INode),
previousId: !n.previousSibling
? n.previousSibling
: mirror.getId(n.previousSibling as INode),
nextId: !n.nextSibling
? n.nextSibling
: mirror.getId(n.nextSibling as INode),
node: serializeNodeWithId(n, document, mirror.map, true)!,
});
} else {
dropped.push(n);
}
});
cb({
texts,
attributes,
removes,
texts: texts.map(text => ({
id: mirror.getId(text.node as INode),
value: text.value,
})),
attributes: attributes.map(attribute => ({
id: mirror.getId(attribute.node as INode),
attributes: attribute.attributes,
})),
removes: removes.map(remove => {
if (remove.parentNode) {
remove.parentId = mirror.getId(remove.parentNode as INode);
delete remove.parentNode;
}
return remove;
}),
adds,
});
});

View File

@@ -180,6 +180,7 @@ export class Replayer {
mutation.node,
this.iframe.contentDocument!,
mirror.map,
true,
) as Node;
const parent = (mirror.getNode(mutation.parentId) as Node) as Element;
if (mutation.nextId) {
@@ -213,8 +214,13 @@ export class Replayer {
});
d.removes.forEach(mutation => {
const target = (mirror.getNode(mutation.id) as Node) as Element;
const parent = (mirror.getNode(mutation.parentId) as Node) as Element;
parent.removeChild(target);
const parent = (mirror.getNode(
mutation.parentId!,
) as Node) as Element;
// target may be removed with its parents before
if (parent) {
parent.removeChild(target);
}
delete mirror.map[mutation.id];
});
break;

View File

@@ -110,11 +110,21 @@ export type observerParam = {
inputCb: inputCallback;
};
export type textCursor = {
node: Node;
value: string | null;
};
export type textMutation = {
id: number;
value: string | null;
};
export type attributeCursor = {
node: Node;
attributes: {
[key: string]: string | null;
};
};
export type attributeMutation = {
id: number;
attributes: {
@@ -123,7 +133,8 @@ export type attributeMutation = {
};
export type removedNodeMutation = {
parentId: number;
parentId?: number;
parentNode?: Node;
id: number;
};

View File

@@ -4,6 +4,7 @@ import {
listenerHandler,
hookResetter,
} from './types';
import { INode } from 'rrweb-snapshot';
export function on(
type: string,
@@ -26,6 +27,11 @@ export const mirror: Mirror = {
removeNodeFromMap(n) {
const id = n.__sn && n.__sn.id;
delete mirror.map[id];
if (n.childNodes) {
n.childNodes.forEach(child =>
mirror.removeNodeFromMap((child as Node) as INode),
);
}
},
};