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:
10
.changeset/serious-ants-juggle.md
Normal file
10
.changeset/serious-ants-juggle.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -131,7 +131,8 @@ export class RRDocument extends BaseRRDocumentImpl(RRNode) {
|
||||
}
|
||||
|
||||
destroyTree() {
|
||||
this.childNodes = [];
|
||||
this.firstChild = null;
|
||||
this.lastChild = null;
|
||||
this.mirror.reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user