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

@@ -0,0 +1,10 @@
---
'rrdom': major
'rrdom-nodejs': major
'rrweb': patch
---
Refactor: Improve performance by 80% in a super large benchmark case.
1. Refactor: change the data structure of childNodes from array to linked list
2. Improve the performance of the "contains" function. New algorithm will reduce the complexity from O(n) to O(logn)

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 childNodes(): IRRNode[] {
const childNodes: IRRNode[] = [];
let childIterator: IRRNode | null = this.firstChild;
while (childIterator) {
childNodes.push(childIterator);
childIterator = childIterator.nextSibling;
}
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;
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);
});
});
}
});