Speed up snapshotting of many new dom nodes (#903)

* Speed up snapshotting of many new dom nodes

By avoiding reflow we shave about 15-25% off our snapshotting time

* Improve newlyAddedElement docs

* Optimize needMaskingText by using el.closest and less recursion

* Serve all rrweb dist files

* Split serializeNode into smaller functions

Makes it easier to profile

* Slow down cpu enhance tracing on fast machines

* Increase timeout

* Perf: only loop through ancestors when they have something to compare to

* Perf: `hasNode` is cheaper than `getMeta`

* Perf: If parents where already checked, no need to do it again

* Perf: reverse for loops are faster

Because they only do the .lenght check once. In this case I don't think we'll see much performance gains if any

* Clean up code

* Perf: check ancestors once with isBlocked

* guessing this might fixes canvas test

* Update packages/rrweb/src/record/observers/canvas/webgl.ts

Co-authored-by: yz-yu <yanzhen@smartx.com>

* Fix #904 (#906)

Properly remove crossorigin attribute

* Bump minimist from 1.2.5 to 1.2.6 (#902)

Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

Co-authored-by: yz-yu <yanzhen@smartx.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent ef0ff2fe3b
commit 65338aaf11
22 changed files with 815 additions and 372 deletions

View File

@@ -213,6 +213,19 @@ if (process.env.BROWSER_ONLY) {
configs = [];
// browser record + replay, unminified (for profiling and performance testing)
configs.push({
input: './src/index.ts',
plugins: getPlugins(),
output: [
{
name: 'rrweb',
format: 'iife',
file: pkg.unpkg,
},
],
});
for (const c of browserOnlyBaseConfigs) {
configs.push({
input: c.input,

View File

@@ -308,6 +308,7 @@ export default class MutationBuffer {
this.iframeManager.attachIframe(iframe, childSn, this.mirror);
this.shadowDomManager.observeAttachShadow(iframe);
},
newlyAddedElement: true,
});
if (sn) {
adds.push({
@@ -432,7 +433,10 @@ export default class MutationBuffer {
switch (m.type) {
case 'characterData': {
const value = m.target.textContent;
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
if (
!isBlocked(m.target, this.blockClass, false) &&
value !== m.oldValue
) {
this.texts.push({
value:
needMaskingText(
@@ -461,7 +465,10 @@ export default class MutationBuffer {
maskInputFn: this.maskInputFn,
});
}
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
if (
isBlocked(m.target, this.blockClass, false) ||
value === m.oldValue
) {
return;
}
let item: attributeCursor | undefined = this.attributes.find(
@@ -518,6 +525,11 @@ export default class MutationBuffer {
break;
}
case 'childList': {
/**
* Parent is blocked, ignore all child mutations
*/
if (isBlocked(m.target, this.blockClass, true)) return;
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = this.mirror.getId(n);
@@ -525,7 +537,7 @@ export default class MutationBuffer {
? this.mirror.getId(m.target.host)
: this.mirror.getId(m.target);
if (
isBlocked(m.target, this.blockClass) ||
isBlocked(m.target, this.blockClass, false) ||
isIgnored(n, this.mirror) ||
!isSerialized(n, this.mirror)
) {
@@ -571,19 +583,17 @@ export default class MutationBuffer {
}
};
/**
* Make sure you check if `n`'s parent is blocked before calling this function
* */
private genAdds = (n: Node, target?: Node) => {
// parent was blocked, so we can ignore this node
if (target && isBlocked(target, this.blockClass)) {
return;
}
if (this.mirror.getMeta(n)) {
if (this.mirror.hasNode(n)) {
if (isIgnored(n, this.mirror)) {
return;
}
this.movedSet.add(n);
let targetId: number | null = null;
if (target && this.mirror.getMeta(target)) {
if (target && this.mirror.hasNode(target)) {
targetId = this.mirror.getId(target);
}
if (targetId && targetId !== -1) {
@@ -596,8 +606,8 @@ export default class MutationBuffer {
// if this node is blocked `serializeNode` will turn it into a placeholder element
// but we have to remove it's children otherwise they will be added as placeholders too
if (!isBlocked(n, this.blockClass))
(n ).childNodes.forEach((childN) => this.genAdds(childN));
if (!isBlocked(n, this.blockClass, false))
n.childNodes.forEach((childN) => this.genAdds(childN));
};
}
@@ -616,6 +626,15 @@ function isParentRemoved(
removes: removedNodeMutation[],
n: Node,
mirror: Mirror,
): boolean {
if (removes.length === 0) return false;
return _isParentRemoved(removes, n, mirror);
}
function _isParentRemoved(
removes: removedNodeMutation[],
n: Node,
mirror: Mirror,
): boolean {
const { parentNode } = n;
if (!parentNode) {
@@ -625,10 +644,15 @@ function isParentRemoved(
if (removes.some((r) => r.id === parentId)) {
return true;
}
return isParentRemoved(removes, parentNode, mirror);
return _isParentRemoved(removes, parentNode, mirror);
}
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
if (set.size === 0) return false;
return _isAncestorInSet(set, n);
}
function _isAncestorInSet(set: Set<Node>, n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
@@ -636,5 +660,5 @@ function isAncestorInSet(set: Set<Node>, n: Node): boolean {
if (set.has(parentNode)) {
return true;
}
return isAncestorInSet(set, parentNode);
return _isAncestorInSet(set, parentNode);
}

View File

@@ -221,7 +221,7 @@ function initMouseInteractionObserver({
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target, blockClass)) {
if (isBlocked(target, blockClass, true)) {
return;
}
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
@@ -267,7 +267,7 @@ export function initScrollObserver({
>): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
if (!target || isBlocked(target as Node, blockClass, true)) {
return;
}
const id = mirror.getId(target as Node);
@@ -344,7 +344,7 @@ function initInputObserver({
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
isBlocked(target as Node, blockClass)
isBlocked(target as Node, blockClass, true)
) {
return;
}
@@ -549,8 +549,8 @@ function initStyleSheetObserver(
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
unmodifiedFunctions[typeKey] = {
insertRule: (type ).prototype.insertRule,
deleteRule: (type ).prototype.deleteRule,
insertRule: type.prototype.insertRule,
deleteRule: type.prototype.deleteRule,
};
type.prototype.insertRule = function (rule: string, index?: number) {
@@ -653,7 +653,7 @@ function initMediaInteractionObserver({
const handler = (type: MediaInteractions) =>
throttle((event: Event) => {
const target = getEventTarget(event);
if (!target || isBlocked(target as Node, blockClass)) {
if (!target || isBlocked(target as Node, blockClass, true)) {
return;
}
const { currentTime, volume, muted } = target as HTMLMediaElement;

View File

@@ -36,7 +36,7 @@ export default function initCanvas2DMutationObserver(
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked(this.canvas, blockClass)) {
if (!isBlocked(this.canvas, blockClass, true)) {
// Using setTimeout as toDataURL can be heavy
// and we'd rather not block the main thread
setTimeout(() => {

View File

@@ -17,9 +17,8 @@ export default function initCanvasContextObserver(
contextType: string,
...args: Array<unknown>
) {
if (!isBlocked(this, blockClass)) {
if (!('__context' in this))
(this ).__context = contextType;
if (!isBlocked(this, blockClass, true)) {
if (!('__context' in this)) this.__context = contextType;
}
return original.apply(this, [contextType, ...args]);
};

View File

@@ -31,8 +31,7 @@ function patchGLPrototype(
return function (this: typeof prototype, ...args: Array<unknown>) {
const result = original.apply(this, args);
saveWebGLVar(result, win, prototype);
if (!isBlocked(this.canvas , blockClass)) {
const id = mirror.getId(this.canvas );
if (!isBlocked(this.canvas, blockClass, true)) {
const recordArgs = serializeArgs([...args], win, prototype);
const mutation: canvasMutationWithType = {
@@ -41,7 +40,7 @@ function patchGLPrototype(
args: recordArgs,
};
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
cb(this.canvas , mutation);
cb(this.canvas, mutation);
}
return result;

View File

@@ -10,7 +10,7 @@ import type {
textMutation,
} from './types';
import type { IMirror, Mirror } from 'rrweb-snapshot';
import { isShadowRoot, IGNORED_NODE } from 'rrweb-snapshot';
import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot';
import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom';
export function on(
@@ -180,32 +180,34 @@ export function getWindowWidth(): number {
);
}
export function isBlocked(node: Node | null, blockClass: blockClass): boolean {
/**
* Checks if the given element set to be blocked by rrweb
* @param node - node to check
* @param blockClass - class name to check
* @param ignoreParents - whether to search through parent nodes for the block class
* @returns true/false if the node was blocked or not
*/
export function isBlocked(
node: Node | null,
blockClass: blockClass,
checkAncestors: boolean,
): boolean {
if (!node) {
return false;
}
if (node.nodeType === node.ELEMENT_NODE) {
let needBlock = false;
if (typeof blockClass === 'string') {
if ((node as HTMLElement).closest !== undefined) {
return (node as HTMLElement).closest('.' + blockClass) !== null;
} else {
needBlock = (node as HTMLElement).classList.contains(blockClass);
}
} else {
(node as HTMLElement).classList.forEach((className) => {
if (blockClass.test(className)) {
needBlock = true;
}
});
}
return needBlock || isBlocked(node.parentNode, blockClass);
const el: HTMLElement | null =
node.nodeType === node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
if (!el) return false;
if (typeof blockClass === 'string') {
if (el.classList.contains(blockClass)) return true;
if (checkAncestors && el.closest('.' + blockClass) !== null) return true;
} else {
if (classMatchesRegex(el, blockClass, checkAncestors)) return true;
}
if (node.nodeType === node.TEXT_NODE) {
// check parent node since text node do not have class name
return isBlocked(node.parentNode, blockClass);
}
return isBlocked(node.parentNode, blockClass);
return false;
}
export function isSerialized(n: Node, mirror: Mirror): boolean {

View File

@@ -1131,6 +1131,126 @@ exports[`record is safe to checkout during async callbacks 1`] = `
]"
`;
exports[`record should record scroll position 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"p\\",
\\"attributes\\": {
\\"style\\": \\"overflow: auto; height: Npx; width: Npx;\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"testtesttesttesttesttesttesttesttesttest\\",
\\"id\\": 10
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 3,
\\"id\\": 9,
\\"x\\": 10,
\\"y\\": 10
}
}
]"
`;
exports[`record without CSSGroupingRule support captures nested stylesheet rules 1`] = `
"[
{

View File

@@ -1,15 +1,43 @@
// tslint:disable:no-console no-any
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from 'puppeteer';
import type { eventWithTime, recordOptions } from '../../src/types';
import { startServer, launchPuppeteer, replaceLast, ISuite } from '../utils';
import { startServer, launchPuppeteer, ISuite, getServerURL } from '../utils';
const suites: Array<
{
title: string;
eval: string;
times?: number; // defaults to 5
} & ({ html: string } | { url: string })
> = [
// {
// title: 'benchmarking external website',
// url: 'http://localhost:5050',
// eval: 'document.querySelector("button").click()',
// times: 10,
// },
{
title: 'create 1000x10 DOM nodes',
html: 'benchmark-dom-mutation.html',
eval: 'window.workload()',
times: 10,
},
{
title: 'create 1000x10x2 DOM nodes and remove a bunch of them',
html: 'benchmark-dom-mutation-add-and-remove.html',
eval: 'window.workload()',
times: 10,
},
];
function avg(v: number[]): number {
return v.reduce((prev, cur) => prev + cur, 0) / v.length;
}
describe('benchmark: mutation observer', () => {
let code: ISuite['code'];
jest.setTimeout(240000);
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
@@ -20,9 +48,6 @@ describe('benchmark: mutation observer', () => {
dumpio: true,
headless: true,
});
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
@@ -36,30 +61,19 @@ describe('benchmark: mutation observer', () => {
const getHtml = (fileName: string): string => {
const filePath = path.resolve(__dirname, `../html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return replaceLast(
html,
'</body>',
`
<script>
${code}
</script>
</body>
`,
);
return fs.readFileSync(filePath, 'utf8');
};
const suites: {
title: string;
html: string;
times?: number; // default to 5
}[] = [
{
title: 'create 1000x10 DOM nodes',
html: 'benchmark-dom-mutation.html',
times: 10,
},
];
const addRecordingScript = async (page: Page) => {
// const scriptUrl = `${getServerURL(server)}/rrweb-1.1.3.js`;
const scriptUrl = `${getServerURL(server)}/rrweb.js`;
await page.evaluate((url) => {
const scriptEl = document.createElement('script');
scriptEl.src = url;
document.head.append(scriptEl);
}, scriptUrl);
await page.waitForFunction('window.rrweb');
};
for (const suite of suites) {
it(suite.title, async () => {
@@ -68,12 +82,19 @@ describe('benchmark: mutation observer', () => {
console.log(`${message.type().toUpperCase()} ${message.text()}`),
);
const times = suite.times ?? 5;
const durations: number[] = [];
for (let i = 0; i < times; i++) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, suite.html));
const duration = (await page.evaluate(() => {
const loadPage = async () => {
if ('html' in suite) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, suite.html));
} else {
await page.goto(suite.url);
}
await addRecordingScript(page);
};
const getDuration = async (): Promise<number> => {
return (await page.evaluate((triggerWorkloadScript) => {
return new Promise((resolve, reject) => {
let start = 0;
let lastEvent: eventWithTime | null;
@@ -94,14 +115,55 @@ describe('benchmark: mutation observer', () => {
const record = (window as any).rrweb.record;
record(options);
(window as any).workload();
start = Date.now();
setTimeout(() => {
eval(triggerWorkloadScript);
requestAnimationFrame(() => {
record.addCustomEvent('FTAG', {});
}, 0);
});
});
})) as number;
}, suite.eval)) as number;
};
// generate profile.json file
const profileFilename = `profile-${new Date().toISOString()}.json`;
const tempDirectory = path.resolve(path.join(__dirname, '../../temp'));
fs.mkdirSync(tempDirectory, { recursive: true });
const profilePath = path.resolve(tempDirectory, profileFilename);
const client = await page.target().createCDPSession();
await client.send('Emulation.setCPUThrottlingRate', { rate: 6 });
await page.tracing.start({
path: profilePath,
screenshots: true,
categories: [
'-*',
'devtools.timeline',
'v8.execute',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'toplevel',
'blink.console',
'blink.user_timing',
'latencyInfo',
'disabled-by-default-devtools.timeline.stack',
'disabled-by-default-v8.cpu_profiler',
'disabled-by-default-v8.cpu_profiler.hires',
],
});
await loadPage();
await getDuration();
await page.waitForTimeout(1000);
await page.tracing.stop();
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
// calculate durations
const times = suite.times ?? 5;
const durations: number[] = [];
for (let i = 0; i < times; i++) {
await loadPage();
const duration = await getDuration();
durations.push(duration);
}
@@ -112,6 +174,7 @@ describe('benchmark: mutation observer', () => {
durations: durations.join(', '),
},
]);
console.log('profile: ', profilePath);
});
}
});

View File

@@ -0,0 +1,46 @@
<html>
<body></body>
<script>
function add() {
const branches = 1000;
const depth = 10;
const frag = document.createDocumentFragment();
for (let b = 0; b < branches; b++) {
const node = document.createElement('div');
let d = 0;
node.setAttribute('branch', b.toString());
node.setAttribute('depth', d.toString());
let current = node;
while (d < depth - 1) {
d++;
const child = document.createElement('div');
child.setAttribute('branch', b.toString());
child.setAttribute('depth', d.toString());
current.appendChild(child);
current = child;
}
frag.appendChild(node);
}
document.body.appendChild(frag);
}
function remove() {
// const divs = Array.from(document.querySelectorAll('div'));
// const half = divs.length / 2;
// while (divs.length > half) {
// const i = (divs.length * Math.random()) | 0;
// divs[i].remove();
// divs.splice(i, 1);
// }
document.querySelectorAll('div').forEach((node) => {
node.parentNode.removeChild(node);
});
}
window.workload = () => {
add();
remove();
add();
};
</script>
</html>

View File

@@ -192,6 +192,23 @@ describe('record', function (this: ISuite) {
assertSnapshot(ctx.events);
});
it('should record scroll position', async () => {
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
const p = document.createElement('p');
p.innerText = 'testtesttesttesttesttesttesttesttesttest';
p.setAttribute('style', 'overflow: auto; height: 1px; width: 1px;');
document.body.appendChild(p);
p.scrollTop = 10;
p.scrollLeft = 10;
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('can add custom event', async () => {
await ctx.page.evaluate(() => {
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;

View File

@@ -56,7 +56,11 @@ export const startServer = (defaultPort: number = 3030) =>
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
const pathname = path.join(__dirname, sanitizePath);
let pathname = path.join(__dirname, sanitizePath);
if (/^\/rrweb.*\.js.*/.test(sanitizePath)) {
pathname = path.join(__dirname, `../dist`, sanitizePath);
}
try {
const data = fs.readFileSync(pathname);

View File

@@ -10,7 +10,7 @@ export declare function patch(source: {
}, name: string, replacement: (...args: any[]) => any): () => void;
export declare function getWindowHeight(): number;
export declare function getWindowWidth(): number;
export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean;
export declare function isBlocked(node: Node | null, blockClass: blockClass, checkAncestors: boolean): boolean;
export declare function isSerialized(n: Node, mirror: Mirror): boolean;
export declare function isIgnored(n: Node, mirror: Mirror): boolean;
export declare function isAncestorRemoved(target: Node, mirror: Mirror): boolean;