improve rrdom performance (#1127)

* add more check to rrdom to make diff algorithm more robust

* fix: selector match in iframe is case-insensitive

add try catch to some fragile points

* test: increase timeout value for Jest

* improve code style

* fix: failed to execute insertBefore on Node in the diff function

this happens when ids of doctype or html element are changed in the virtual dom

also improve the code quality

* refactor diff function to make the code cleaner

* fix: virtual nodes are passed to plugin's onBuild function

* refactor the diff function and adjust the order of diff work.

* call afterAppend hook in a consistent traversal order

* improve the performance of the "contains" function

reduce the complexity from O(n) to O(logn)
a specific benchmark is needed to add further

* add a real events for benchmark

* refactor: change the data structure of childNodes from array to linked list

* remove legacy code in rrweb package

* update unit tests

* update change log
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent b837600e80
commit 8edace971d
8 changed files with 362 additions and 351 deletions

View File

@@ -3,8 +3,8 @@ import { parseCSSText, camelize, toCSSText } from './style';
export interface IRRNode {
parentElement: IRRNode | null;
parentNode: IRRNode | null;
childNodes: IRRNode[];
ownerDocument: IRRDocument;
readonly childNodes: IRRNode[];
readonly ELEMENT_NODE: number;
readonly TEXT_NODE: number;
// corresponding nodeType value of standard HTML Node
@@ -16,8 +16,11 @@ export interface IRRNode {
lastChild: IRRNode | null;
previousSibling: IRRNode | null;
nextSibling: IRRNode | null;
// If the node is a document or a doctype, textContent returns null.
textContent: string | null;
contains(node: IRRNode): boolean;
@@ -127,11 +130,16 @@ type ConstrainedConstructor<T = Record<string, unknown>> = new (
* This is designed as an abstract class so it should never be instantiated.
*/
export class BaseRRNode implements IRRNode {
public childNodes: IRRNode[] = [];
public parentElement: IRRNode | null = null;
public parentNode: IRRNode | null = null;
public textContent: string | null;
public ownerDocument: IRRDocument;
public firstChild: IRRNode | null = null;
public lastChild: IRRNode | null = null;
public previousSibling: IRRNode | null = null;
public nextSibling: IRRNode | null = null;
public textContent: string | null;
public readonly ELEMENT_NODE: number = NodeType.ELEMENT_NODE;
public readonly TEXT_NODE: number = NodeType.TEXT_NODE;
// corresponding nodeType value of standard HTML Node
@@ -144,26 +152,24 @@ export class BaseRRNode implements IRRNode {
//
}
public get firstChild(): IRRNode | null {
return this.childNodes[0] || null;
}
public get lastChild(): IRRNode | null {
return this.childNodes[this.childNodes.length - 1] || null;
}
public get nextSibling(): IRRNode | null {
const parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
const index = siblings.indexOf(this);
return siblings[index + 1] || null;
public get childNodes(): IRRNode[] {
const childNodes: IRRNode[] = [];
let childIterator: IRRNode | null = this.firstChild;
while (childIterator) {
childNodes.push(childIterator);
childIterator = childIterator.nextSibling;
}
return childNodes;
}
public contains(node: IRRNode) {
if (node === this) return true;
for (const child of this.childNodes) {
if (child.contains(node)) return true;
if (!(node instanceof BaseRRNode)) return false;
else if (node.ownerDocument !== this.ownerDocument) return false;
else if (node === this) return true;
while (node.parentNode) {
if (node.parentNode === this) return true;
node = node.parentNode;
}
return false;
}
@@ -202,11 +208,11 @@ export function BaseRRDocumentImpl<
public readonly nodeName: '#document' = '#document';
public readonly compatMode: 'BackCompat' | 'CSS1Compat' = 'CSS1Compat';
public readonly RRNodeType = RRNodeType.Document;
public textContent: string | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(args);
this.textContent = null;
this.ownerDocument = this;
}
@@ -248,8 +254,8 @@ export function BaseRRDocumentImpl<
return this.documentElement;
}
public appendChild(childNode: IRRNode): IRRNode {
const nodeType = childNode.RRNodeType;
public appendChild(newChild: IRRNode): IRRNode {
const nodeType = newChild.RRNodeType;
if (
nodeType === RRNodeType.Element ||
nodeType === RRNodeType.DocumentType
@@ -262,11 +268,10 @@ export function BaseRRDocumentImpl<
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
childNode.ownerDocument = this.ownerDocument;
this.childNodes.push(childNode);
return childNode;
const child = appendChild(this, newChild);
child.parentElement = null;
return child;
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
@@ -283,33 +288,19 @@ export function BaseRRDocumentImpl<
);
}
}
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
const child = insertBefore(this, newChild, refChild);
child.parentElement = null;
return child;
}
public removeChild(node: IRRNode) {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRDocument': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
public removeChild(node: IRRNode): IRRNode {
return removeChild(this, node);
}
public open() {
this.childNodes = [];
this.firstChild = null;
this.lastChild = null;
}
public close() {
@@ -415,7 +406,6 @@ export function BaseRRDocumentTypeImpl<
public readonly name: string;
public readonly publicId: string;
public readonly systemId: string;
public textContent: string | null = null;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
@@ -423,6 +413,7 @@ export function BaseRRDocumentTypeImpl<
this.publicId = publicId;
this.systemId = systemId;
this.nodeName = qualifiedName;
this.textContent = null;
}
toString() {
@@ -459,7 +450,9 @@ export function BaseRRElementImpl<
}
public set textContent(textContent: string) {
this.childNodes = [this.ownerDocument.createTextNode(textContent)];
this.firstChild = null;
this.lastChild = null;
this.appendChild(this.ownerDocument.createTextNode(textContent));
}
public get classList(): ClassList {
@@ -528,37 +521,15 @@ export function BaseRRElementImpl<
}
public appendChild(newChild: IRRNode): IRRNode {
this.childNodes.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
return appendChild(this, newChild);
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = this;
newChild.parentNode = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
return insertBefore(this, newChild, refChild);
}
public removeChild(node: IRRNode): IRRNode {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRElement': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
return removeChild(this, node);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -741,6 +712,65 @@ export type CSSStyleDeclaration = Record<string, string> & {
removeProperty: (name: string) => string;
};
function appendChild(parent: IRRNode, newChild: IRRNode) {
if (parent.lastChild) {
parent.lastChild.nextSibling = newChild;
newChild.previousSibling = parent.lastChild;
} else {
parent.firstChild = newChild;
newChild.previousSibling = null;
}
parent.lastChild = newChild;
newChild.nextSibling = null;
newChild.parentNode = parent;
newChild.parentElement = parent;
newChild.ownerDocument = parent.ownerDocument;
return newChild;
}
function insertBefore(
parent: IRRNode,
newChild: IRRNode,
refChild: IRRNode | null,
) {
if (!refChild) return appendChild(parent, newChild);
if (refChild.parentNode !== parent)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
newChild.previousSibling = refChild.previousSibling;
refChild.previousSibling = newChild;
newChild.nextSibling = refChild;
if (newChild.previousSibling) newChild.previousSibling.nextSibling = newChild;
else parent.firstChild = newChild;
newChild.parentElement = parent;
newChild.parentNode = parent;
newChild.ownerDocument = parent.ownerDocument;
return newChild;
}
function removeChild(parent: IRRNode, child: IRRNode) {
if (child.parentNode !== parent)
throw new Error(
"Failed to execute 'removeChild' on 'RRNode': The RRNode to be removed is not a child of this RRNode.",
);
if (child.previousSibling)
child.previousSibling.nextSibling = child.nextSibling;
else parent.firstChild = child.nextSibling;
if (child.nextSibling)
child.nextSibling.previousSibling = child.previousSibling;
else parent.lastChild = child.previousSibling;
child.previousSibling = null;
child.nextSibling = null;
child.parentElement = null;
child.parentNode = null;
return child;
}
// Enumerate nodeType value of standard HTML Node.
export enum NodeType {
PLACEHOLDER, // This isn't a node type. Enum type value starts from zero but NodeType value starts from 1.

View File

@@ -131,7 +131,8 @@ export class RRDocument extends BaseRRDocumentImpl(RRNode) {
}
destroyTree() {
this.childNodes = [];
this.firstChild = null;
this.lastChild = null;
this.mirror.reset();
}

View File

@@ -1271,7 +1271,7 @@ describe('diff algorithm for rrdom', () => {
expect(rrdom.mirror.getId(rrdom)).toBe(-2);
expect(rrdom.mirror.getId(rrdom.body)).toBe(-6);
rrdom.childNodes = [];
while (rrdom.firstChild) rrdom.removeChild(rrdom.firstChild);
/**
* Rebuild the rrdom and make it looks like this:
* -7 RRDocument

View File

@@ -9,6 +9,7 @@ import {
BaseRRMediaElementImpl,
BaseRRNode,
IRRDocumentType,
IRRNode,
} from '../src/document';
describe('Basic RRDocument implementation', () => {
@@ -34,6 +35,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -42,56 +44,103 @@ describe('Basic RRDocument implementation', () => {
expect(node.toString()).toEqual('RRNode');
});
it('can get first child node', () => {
it('can get and set first child node', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
expect(parentNode.firstChild).toBeNull();
parentNode.childNodes = [childNode1];
parentNode.firstChild = childNode1;
expect(parentNode.firstChild).toBe(childNode1);
parentNode.childNodes = [childNode1, childNode2];
expect(parentNode.firstChild).toBe(childNode1);
parentNode.childNodes = [childNode2, childNode1];
expect(parentNode.firstChild).toBe(childNode2);
parentNode.firstChild = null;
expect(parentNode.firstChild).toBeNull();
});
it('can get last child node', () => {
it('can get and set last child node', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
expect(parentNode.lastChild).toBeNull();
parentNode.childNodes = [childNode1];
expect(parentNode.lastChild).toBe(childNode1);
parentNode.childNodes = [childNode1, childNode2];
expect(parentNode.lastChild).toBe(childNode2);
parentNode.childNodes = [childNode2, childNode1];
parentNode.lastChild = childNode1;
expect(parentNode.lastChild).toBe(childNode1);
parentNode.lastChild = null;
expect(parentNode.lastChild).toBeNull();
});
it('can get nextSibling', () => {
it('can get and set preSibling', () => {
const node1 = new RRNode();
const node2 = new RRNode();
expect(node1.previousSibling).toBeNull();
node1.previousSibling = node2;
expect(node1.previousSibling).toBe(node2);
node1.previousSibling = null;
expect(node1.previousSibling).toBeNull();
});
it('can get and set nextSibling', () => {
const node1 = new RRNode();
const node2 = new RRNode();
expect(node1.nextSibling).toBeNull();
node1.nextSibling = node2;
expect(node1.nextSibling).toBe(node2);
node1.nextSibling = null;
expect(node1.nextSibling).toBeNull();
});
it('can get childNodes', () => {
const parentNode = new RRNode();
expect(parentNode.childNodes).toBeInstanceOf(Array);
expect(parentNode.childNodes.length).toBe(0);
const childNode1 = new RRNode();
parentNode.firstChild = childNode1;
parentNode.lastChild = childNode1;
expect(parentNode.childNodes).toEqual([childNode1]);
const childNode2 = new RRNode();
expect(parentNode.nextSibling).toBeNull();
expect(childNode1.nextSibling).toBeNull();
childNode1.parentNode = parentNode;
parentNode.childNodes = [childNode1];
expect(childNode1.nextSibling).toBeNull();
childNode2.parentNode = parentNode;
parentNode.childNodes = [childNode1, childNode2];
expect(childNode1.nextSibling).toBe(childNode2);
expect(childNode2.nextSibling).toBeNull();
parentNode.lastChild = childNode2;
childNode1.nextSibling = childNode2;
childNode2.previousSibling = childNode1;
expect(parentNode.childNodes).toEqual([childNode1, childNode2]);
const childNode3 = new RRNode();
parentNode.lastChild = childNode3;
childNode2.nextSibling = childNode3;
childNode3.previousSibling = childNode2;
expect(parentNode.childNodes).toEqual([
childNode1,
childNode2,
childNode3,
]);
});
it('should return whether the node contains another node', () => {
const parentNode = new RRNode();
expect(parentNode.contains(parentNode)).toBeTruthy();
expect(parentNode.contains(null as unknown as IRRNode)).toBeFalsy();
expect(parentNode.contains(undefined as unknown as IRRNode)).toBeFalsy();
expect(parentNode.contains({} as unknown as IRRNode)).toBeFalsy();
expect(
parentNode.contains(new RRDocument().createElement('div')),
).toBeFalsy();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
parentNode.childNodes = [childNode1];
parentNode.firstChild = childNode1;
parentNode.lastChild = childNode1;
childNode1.parentNode = parentNode;
expect(parentNode.contains(childNode1)).toBeTruthy();
expect(parentNode.contains(childNode2)).toBeFalsy();
childNode1.childNodes = [childNode2];
parentNode.lastChild = childNode2;
childNode1.nextSibling = childNode2;
childNode2.previousSibling = childNode1;
childNode2.parentNode = childNode1;
expect(parentNode.contains(childNode1)).toBeTruthy();
expect(parentNode.contains(childNode2)).toBeTruthy();
const childNode3 = new RRNode();
expect(parentNode.contains(childNode3)).toBeFalsy();
childNode2.firstChild = childNode3;
childNode2.lastChild = childNode3;
childNode3.parentNode = childNode2;
expect(parentNode.contains(childNode3)).toBeTruthy();
});
it('should not implement appendChild', () => {
@@ -143,6 +192,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -282,7 +332,7 @@ describe('Basic RRDocument implementation', () => {
expect(() =>
node.removeChild(node.createElement('div')),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'removeChild' on 'RRDocument': The RRNode to be removed is not a child of this RRNode."`,
`"Failed to execute 'removeChild' on 'RRNode': The RRNode to be removed is not a child of this RRNode."`,
);
expect(node.removeChild(documentType)).toBe(documentType);
expect(documentType.parentNode).toBeNull();
@@ -369,6 +419,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -404,6 +455,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -686,23 +738,39 @@ describe('Basic RRDocument implementation', () => {
const node = document.createElement('div');
expect(node.childNodes.length).toBe(0);
const child1 = document.createComment('span');
const child1 = document.createElement('span');
expect(node.appendChild(child1)).toBe(child1);
expect(node.childNodes[0]).toEqual(child1);
expect(node.childNodes[0]).toBe(child1);
expect(node.firstChild).toBe(child1);
expect(node.lastChild).toBe(child1);
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBeNull();
expect(child1.parentElement).toBe(node);
expect(child1.parentNode).toBe(node);
expect(child1.ownerDocument).toBe(document);
expect(node.contains(child1)).toBeTruthy();
const child2 = document.createElement('p');
expect(node.appendChild(child2)).toBe(child2);
expect(node.childNodes[1]).toEqual(child2);
expect(node.childNodes[1]).toBe(child2);
expect(node.firstChild).toBe(child1);
expect(node.lastChild).toBe(child2);
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBe(child2);
expect(child2.previousSibling).toBe(child1);
expect(child2.nextSibling).toBeNull();
expect(child2.parentElement).toBe(node);
expect(child2.parentNode).toBe(node);
expect(child2.ownerDocument).toBe(document);
expect(node.contains(child1)).toBeTruthy();
expect(node.contains(child2)).toBeTruthy();
});
it('can insert new child before an existing child', () => {
const node = document.createElement('div');
const child1 = document.createElement('h1');
const child2 = document.createElement('h2');
const child3 = document.createElement('h3');
expect(() =>
node.insertBefore(node, child1),
).toThrowErrorMatchingInlineSnapshot(
@@ -710,42 +778,119 @@ describe('Basic RRDocument implementation', () => {
);
expect(node.insertBefore(child1, null)).toBe(child1);
expect(node.childNodes[0]).toBe(child1);
expect(node.childNodes.length).toBe(1);
expect(node.firstChild).toBe(child1);
expect(node.lastChild).toBe(child1);
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBeNull();
expect(child1.parentNode).toBe(node);
expect(child1.parentElement).toBe(node);
expect(child1.ownerDocument).toBe(document);
expect(node.contains(child1)).toBeTruthy();
expect(node.insertBefore(child2, child1)).toBe(child2);
expect(node.childNodes.length).toBe(2);
expect(node.childNodes[0]).toBe(child2);
expect(node.childNodes[1]).toBe(child1);
expect(node.childNodes).toEqual([child2, child1]);
expect(node.firstChild).toBe(child2);
expect(node.lastChild).toBe(child1);
expect(child1.previousSibling).toBe(child2);
expect(child1.nextSibling).toBeNull();
expect(child2.previousSibling).toBeNull();
expect(child2.nextSibling).toBe(child1);
expect(child2.parentNode).toBe(node);
expect(child2.parentElement).toBe(node);
expect(child2.ownerDocument).toBe(document);
expect(node.contains(child2)).toBeTruthy();
expect(node.contains(child1)).toBeTruthy();
expect(node.insertBefore(child3, child1)).toBe(child3);
expect(node.childNodes).toEqual([child2, child3, child1]);
expect(node.firstChild).toBe(child2);
expect(node.lastChild).toBe(child1);
expect(child1.previousSibling).toBe(child3);
expect(child1.nextSibling).toBeNull();
expect(child3.previousSibling).toBe(child2);
expect(child3.nextSibling).toBe(child1);
expect(child2.previousSibling).toBeNull();
expect(child2.nextSibling).toBe(child3);
expect(child3.parentNode).toBe(node);
expect(child3.parentElement).toBe(node);
expect(child3.ownerDocument).toBe(document);
expect(node.contains(child2)).toBeTruthy();
expect(node.contains(child3)).toBeTruthy();
expect(node.contains(child1)).toBeTruthy();
});
it('can remove an existing child', () => {
const node = document.createElement('div');
const child1 = document.createElement('h1');
const child2 = document.createElement('h2');
const child3 = document.createElement('h3');
node.appendChild(child1);
node.appendChild(child2);
expect(node.childNodes.length).toBe(2);
expect(child1.parentNode).toBe(node);
expect(child2.parentNode).toBe(node);
expect(child1.parentElement).toBe(node);
expect(child2.parentElement).toBe(node);
node.appendChild(child3);
expect(node.childNodes).toEqual([child1, child2, child3]);
expect(() =>
node.removeChild(document.createElement('div')),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'removeChild' on 'RRElement': The RRNode to be removed is not a child of this RRNode."`,
`"Failed to execute 'removeChild' on 'RRNode': The RRNode to be removed is not a child of this RRNode."`,
);
expect(node.removeChild(child1)).toBe(child1);
expect(child1.parentNode).toBeNull();
expect(child1.parentElement).toBeNull();
expect(node.childNodes.length).toBe(1);
// Remove the middle child.
expect(node.removeChild(child2)).toBe(child2);
expect(node.childNodes.length).toBe(0);
expect(node.childNodes).toEqual([child1, child3]);
expect(node.contains(child2)).toBeFalsy();
expect(node.firstChild).toBe(child1);
expect(node.lastChild).toBe(child3);
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBe(child3);
expect(child3.previousSibling).toBe(child1);
expect(child3.nextSibling).toBeNull();
expect(child2.previousSibling).toBeNull();
expect(child2.nextSibling).toBeNull();
expect(child2.parentNode).toBeNull();
expect(child2.parentElement).toBeNull();
// Remove the previous child.
expect(node.removeChild(child1)).toBe(child1);
expect(node.childNodes).toEqual([child3]);
expect(node.contains(child1)).toBeFalsy();
expect(node.firstChild).toBe(child3);
expect(node.lastChild).toBe(child3);
expect(child3.previousSibling).toBeNull();
expect(child3.nextSibling).toBeNull();
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBeNull();
expect(child1.parentNode).toBeNull();
expect(child1.parentElement).toBeNull();
node.insertBefore(child1, child3);
expect(node.childNodes).toEqual([child1, child3]);
// Remove the next child.
expect(node.removeChild(child3)).toBe(child3);
expect(node.childNodes).toEqual([child1]);
expect(node.contains(child3)).toBeFalsy();
expect(node.contains(child1)).toBeTruthy();
expect(node.firstChild).toBe(child1);
expect(node.lastChild).toBe(child1);
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBeNull();
expect(child3.previousSibling).toBeNull();
expect(child3.nextSibling).toBeNull();
expect(child3.parentNode).toBeNull();
expect(child3.parentElement).toBeNull();
// Remove all children.
expect(node.removeChild(child1)).toBe(child1);
expect(node.childNodes).toEqual([]);
expect(node.contains(child1)).toBeFalsy();
expect(node.contains(child2)).toBeFalsy();
expect(node.contains(child3)).toBeFalsy();
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(child1.previousSibling).toBeNull();
expect(child1.nextSibling).toBeNull();
expect(child1.parentNode).toBeNull();
expect(child1.parentElement).toBeNull();
});
});
@@ -768,6 +913,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -803,6 +949,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -838,6 +985,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
@@ -870,6 +1018,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.previousSibling).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();

View File

@@ -1,173 +0,0 @@
import { RRdomTreeNode, AnyObject } from './tree-node';
class RRdomTree {
private readonly symbol = '__rrdom__';
public initialize(object: AnyObject) {
this._node(object);
return object;
}
public hasChildren(object: AnyObject): boolean {
return Boolean(this._node(object).hasChildren);
}
public firstChild(object: AnyObject) {
return this._node(object).firstChild || null;
}
public lastChild(object: AnyObject) {
return this._node(object).lastChild || null;
}
public previousSibling(object: AnyObject) {
return this._node(object).previousSibling || null;
}
public nextSibling(object: AnyObject) {
return this._node(object).nextSibling || null;
}
public parent(object: AnyObject) {
return this._node(object).parent || null;
}
public insertAfter(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const nextNode = this._node(referenceNode.nextSibling);
const newNode = this._node(newObject);
const parentNode = this._node(referenceNode.parent);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
newNode.parent = referenceNode.parent;
newNode.previousSibling = referenceObject;
newNode.nextSibling = referenceNode.nextSibling;
referenceNode.nextSibling = newObject;
if (nextNode) {
nextNode.previousSibling = newObject;
}
if (parentNode && parentNode.lastChild === referenceObject) {
parentNode.lastChild = newObject;
}
if (parentNode) {
parentNode.childrenChanged();
}
return newObject;
}
public insertBefore(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const prevNode = this._node(referenceNode.previousSibling);
const newNode = this._node(newObject);
const parentNode = this._node(referenceNode.parent);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
newNode.parent = referenceNode.parent;
newNode.previousSibling = referenceNode.previousSibling;
newNode.nextSibling = referenceObject;
referenceNode.previousSibling = newObject;
if (prevNode) {
prevNode.nextSibling = newObject;
}
if (parentNode && parentNode.firstChild === referenceObject) {
parentNode.firstChild = newObject;
}
if (parentNode) {
parentNode.childrenChanged();
}
return newObject;
}
public appendChild(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const newNode = this._node(newObject);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
if (referenceNode.hasChildren) {
this.insertAfter(referenceNode.lastChild!, newObject);
} else {
newNode.parent = referenceObject;
referenceNode.firstChild = newObject;
referenceNode.lastChild = newObject;
referenceNode.childrenChanged();
}
return newObject;
}
public remove(removeObject: AnyObject) {
const removeNode = this._node(removeObject);
const parentNode = this._node(removeNode.parent);
const prevNode = this._node(removeNode.previousSibling);
const nextNode = this._node(removeNode.nextSibling);
if (parentNode) {
if (parentNode.firstChild === removeObject) {
parentNode.firstChild = removeNode.nextSibling;
}
if (parentNode.lastChild === removeObject) {
parentNode.lastChild = removeNode.previousSibling;
}
}
if (prevNode) {
prevNode.nextSibling = removeNode.nextSibling;
}
if (nextNode) {
nextNode.previousSibling = removeNode.previousSibling;
}
removeNode.parent = null;
removeNode.previousSibling = null;
removeNode.nextSibling = null;
removeNode.cachedIndex = -1;
removeNode.cachedIndexVersion = NaN;
if (parentNode) {
parentNode.childrenChanged();
}
return removeObject;
}
private _node(object: AnyObject | null): RRdomTreeNode {
if (!object) {
throw new Error('Object is falsy');
}
if (this.symbol in object) {
return object[this.symbol] as RRdomTreeNode;
}
return (object[this.symbol] = new RRdomTreeNode());
}
}

View File

@@ -1,51 +0,0 @@
export type AnyObject = { [key: string]: any; __rrdom__?: RRdomTreeNode };
export class RRdomTreeNode implements AnyObject {
public parent: AnyObject | null = null;
public previousSibling: AnyObject | null = null;
public nextSibling: AnyObject | null = null;
public firstChild: AnyObject | null = null;
public lastChild: AnyObject | null = null;
// This value is incremented anytime a children is added or removed
public childrenVersion = 0;
// The last child object which has a cached index
public childIndexCachedUpTo: AnyObject | null = null;
/**
* This value represents the cached node index, as long as
* cachedIndexVersion matches with the childrenVersion of the parent
*/
public cachedIndex = -1;
public cachedIndexVersion = NaN;
public get isAttached() {
return Boolean(this.parent || this.previousSibling || this.nextSibling);
}
public get hasChildren() {
return Boolean(this.firstChild);
}
public childrenChanged() {
this.childrenVersion = (this.childrenVersion + 1) & 0xffffffff;
this.childIndexCachedUpTo = null;
}
public getCachedIndex(parentNode: AnyObject) {
if (this.cachedIndexVersion !== parentNode.childrenVersion) {
this.cachedIndexVersion = NaN;
// cachedIndex is no longer valid
return -1;
}
return this.cachedIndex;
}
public setCachedIndex(parentNode: AnyObject, index: number) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.cachedIndexVersion = parentNode.childrenVersion;
this.cachedIndex = index;
}
}

View File

@@ -1,12 +1,14 @@
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import type { eventWithTime } from '@rrweb/types';
import type { recordOptions } from '../../src/types';
import { launchPuppeteer, ISuite } from '../utils';
const suites: Array<{
title: string;
eval: string;
eval?: string;
eventURL?: string;
eventsString?: string;
times?: number; // defaults to 5
}> = [
@@ -66,6 +68,12 @@ const suites: Array<{
`,
times: 3,
},
{
title: 'real events recorded on bugs.chromium.org',
eventURL:
'https://raw.githubusercontent.com/rrweb-io/benchmark-events/main/rrdom-benchmark-1.json',
times: 3,
},
];
function avg(v: number[]): number {
@@ -86,9 +94,6 @@ describe('benchmark: replayer fast-forward performance', () => {
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
for (const suite of suites)
suite.eventsString = await generateEvents(suite.eval);
}, 600_000);
afterAll(async () => {
@@ -99,6 +104,13 @@ describe('benchmark: replayer fast-forward performance', () => {
it(
suite.title,
async () => {
if (suite.eval) suite.eventsString = await generateEvents(suite.eval);
else if (suite.eventURL) {
suite.eventsString = await fetchEventsWithCache(
suite.eventURL,
'./temp',
);
} else throw new Error('Invalid suite');
suite.times = suite.times ?? 5;
const durations: number[] = [];
for (let i = 0; i < suite.times; i++) {
@@ -168,4 +180,37 @@ describe('benchmark: replayer fast-forward performance', () => {
await page.close();
return eventsString;
}
/**
* Fetch the recorded events from URL. If the events are already cached, read from the cache.
*/
async function fetchEventsWithCache(
eventURL: string,
cacheFolder: string,
): Promise<string> {
const fileName = eventURL.split('/').pop() || '';
const cachePath = path.resolve(__dirname, cacheFolder, fileName);
if (fs.existsSync(cachePath)) return fs.readFileSync(cachePath, 'utf8');
return new Promise((resolve, reject) => {
https
.get(eventURL, (resp) => {
let data = '';
resp.on('data', (chunk) => {
data += chunk;
});
resp.on('end', () => {
resolve(data);
const folderAbsolutePath = path.resolve(__dirname, cacheFolder);
if (!fs.existsSync(folderAbsolutePath))
fs.mkdirSync(path.resolve(__dirname, cacheFolder), {
recursive: true,
});
fs.writeFileSync(cachePath, data);
});
})
.on('error', (err) => {
reject(err);
});
});
}
});