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
2023-02-14 21:54:30 +11:00
committed by GitHub
parent 282c8fa415
commit 3cc4323094
8 changed files with 362 additions and 351 deletions

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);
});
});
}
});