Cache addHoverClass as it is quite expensive (#643)

* Add cache and cache purging

Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85

* Add cache and cache purging

Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85

* Cache addHoverClass as it is quite expensive

https://github.com/rrweb-io/rrweb-snapshot/pull/85

* Make cache non-optional

* Make cache required on addHoverClass
This commit is contained in:
Justin Halsall
2021-07-26 16:13:03 +02:00
committed by GitHub
parent bdd869506c
commit 588164aa12
8 changed files with 122 additions and 26 deletions

View File

@@ -6,7 +6,11 @@ import snapshot, {
needMaskingText, needMaskingText,
IGNORED_NODE, IGNORED_NODE,
} from './snapshot'; } from './snapshot';
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; import rebuild, {
buildNodeWithSN,
addHoverClass,
createCache,
} from './rebuild';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';
@@ -16,6 +20,7 @@ export {
rebuild, rebuild,
buildNodeWithSN, buildNodeWithSN,
addHoverClass, addHoverClass,
createCache,
transformAttribute, transformAttribute,
visitSnapshot, visitSnapshot,
cleanupSnapshot, cleanupSnapshot,

View File

@@ -6,6 +6,7 @@ import {
elementNode, elementNode,
idNodeMap, idNodeMap,
INode, INode,
BuildCache,
} from './types'; } from './types';
import { isElement } from './utils'; import { isElement } from './utils';
@@ -64,7 +65,10 @@ function escapeRegExp(str: string) {
const HOVER_SELECTOR = /([^\\]):hover/; const HOVER_SELECTOR = /([^\\]):hover/;
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g'); const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g');
export function addHoverClass(cssText: string): string { export function addHoverClass(cssText: string, cache: BuildCache): string {
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
if (cachedStyle) return cachedStyle;
const ast = parse(cssText, { const ast = parse(cssText, {
silent: true, silent: true,
}); });
@@ -99,10 +103,19 @@ export function addHoverClass(cssText: string): string {
'g', 'g',
); );
return cssText.replace(selectorMatcher, (selector) => { const result = cssText.replace(selectorMatcher, (selector) => {
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover'); const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
return `${selector}, ${newSelector}`; return `${selector}, ${newSelector}`;
}); });
cache?.stylesWithHoverClass.set(cssText, result);
return result;
}
export function createCache(): BuildCache {
const stylesWithHoverClass: Map<string, string> = new Map();
return {
stylesWithHoverClass,
};
} }
function buildNode( function buildNode(
@@ -110,9 +123,10 @@ function buildNode(
options: { options: {
doc: Document; doc: Document;
hackCss: boolean; hackCss: boolean;
cache: BuildCache;
}, },
): Node | null { ): Node | null {
const { doc, hackCss } = options; const { doc, hackCss, cache } = options;
switch (n.type) { switch (n.type) {
case NodeType.Document: case NodeType.Document:
return doc.implementation.createDocument(null, '', null); return doc.implementation.createDocument(null, '', null);
@@ -143,7 +157,7 @@ function buildNode(
const isRemoteOrDynamicCss = const isRemoteOrDynamicCss =
tagName === 'style' && name === '_cssText'; tagName === 'style' && name === '_cssText';
if (isRemoteOrDynamicCss && hackCss) { if (isRemoteOrDynamicCss && hackCss) {
value = addHoverClass(value); value = addHoverClass(value, cache);
} }
if (isTextarea || isRemoteOrDynamicCss) { if (isTextarea || isRemoteOrDynamicCss) {
const child = doc.createTextNode(value); const child = doc.createTextNode(value);
@@ -256,7 +270,9 @@ function buildNode(
return node; return node;
case NodeType.Text: case NodeType.Text:
return doc.createTextNode( return doc.createTextNode(
n.isStyle && hackCss ? addHoverClass(n.textContent) : n.textContent, n.isStyle && hackCss
? addHoverClass(n.textContent, cache)
: n.textContent,
); );
case NodeType.CDATA: case NodeType.CDATA:
return doc.createCDATASection(n.textContent); return doc.createCDATASection(n.textContent);
@@ -275,10 +291,18 @@ export function buildNodeWithSN(
skipChild?: boolean; skipChild?: boolean;
hackCss: boolean; hackCss: boolean;
afterAppend?: (n: INode) => unknown; afterAppend?: (n: INode) => unknown;
cache: BuildCache;
}, },
): INode | null { ): INode | null {
const { doc, map, skipChild = false, hackCss = true, afterAppend } = options; const {
let node = buildNode(n, { doc, hackCss }); doc,
map,
skipChild = false,
hackCss = true,
afterAppend,
cache,
} = options;
let node = buildNode(n, { doc, hackCss, cache });
if (!node) { if (!node) {
return null; return null;
} }
@@ -310,6 +334,7 @@ export function buildNodeWithSN(
skipChild: false, skipChild: false,
hackCss, hackCss,
afterAppend, afterAppend,
cache,
}); });
if (!childNode) { if (!childNode) {
console.warn('Failed to rebuild', childN); console.warn('Failed to rebuild', childN);
@@ -369,9 +394,10 @@ function rebuild(
onVisit?: (node: INode) => unknown; onVisit?: (node: INode) => unknown;
hackCss?: boolean; hackCss?: boolean;
afterAppend?: (n: INode) => unknown; afterAppend?: (n: INode) => unknown;
cache: BuildCache;
}, },
): [Node | null, idNodeMap] { ): [Node | null, idNodeMap] {
const { doc, onVisit, hackCss = true, afterAppend } = options; const { doc, onVisit, hackCss = true, afterAppend, cache } = options;
const idNodeMap: idNodeMap = {}; const idNodeMap: idNodeMap = {};
const node = buildNodeWithSN(n, { const node = buildNodeWithSN(n, {
doc, doc,
@@ -379,6 +405,7 @@ function rebuild(
skipChild: false, skipChild: false,
hackCss, hackCss,
afterAppend, afterAppend,
cache,
}); });
visit(idNodeMap, (visitedNode) => { visit(idNodeMap, (visitedNode) => {
if (onVisit) { if (onVisit) {

View File

@@ -111,3 +111,7 @@ export type MaskTextFn = (text: string) => string;
export type MaskInputFn = (text: string) => string; export type MaskInputFn = (text: string) => string;
export type KeepIframeSrcFn = (src: string) => boolean; export type KeepIframeSrcFn = (src: string) => boolean;
export type BuildCache = {
stylesWithHoverClass: Map<string, string>;
};

View File

@@ -1,53 +1,67 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import 'mocha'; import { Suite } from 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { addHoverClass } from '../src/rebuild'; import { addHoverClass, createCache } from '../src/rebuild';
import { BuildCache } from '../src/types';
function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000;
}
interface ISuite extends Suite {
cache: BuildCache;
}
describe('add hover class to hover selector related rules', function (this: ISuite) {
beforeEach(() => {
this.cache = createCache();
});
describe('add hover class to hover selector related rules', () => {
it('will do nothing to css text without :hover', () => { it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }'; const cssText = 'body { color: white }';
expect(addHoverClass(cssText)).to.equal(cssText); expect(addHoverClass(cssText, this.cache)).to.equal(cssText);
}); });
it('can add hover class to css text', () => { it('can add hover class to css text', () => {
const cssText = '.a:hover { color: white }'; const cssText = '.a:hover { color: white }';
expect(addHoverClass(cssText)).to.equal( expect(addHoverClass(cssText, this.cache)).to.equal(
'.a:hover, .a.\\:hover { color: white }', '.a:hover, .a.\\:hover { color: white }',
); );
}); });
it('can add hover class when there is multi selector', () => { it('can add hover class when there is multi selector', () => {
const cssText = '.a, .b:hover, .c { color: white }'; const cssText = '.a, .b:hover, .c { color: white }';
expect(addHoverClass(cssText)).to.equal( expect(addHoverClass(cssText, this.cache)).to.equal(
'.a, .b:hover, .b.\\:hover, .c { color: white }', '.a, .b:hover, .b.\\:hover, .c { color: white }',
); );
}); });
it('can add hover class when there is a multi selector with the same prefix', () => { it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = '.a:hover, .a:hover::after { color: white }'; const cssText = '.a:hover, .a:hover::after { color: white }';
expect(addHoverClass(cssText)).to.equal( expect(addHoverClass(cssText, this.cache)).to.equal(
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
); );
}); });
it('can add hover class when :hover is not the end of selector', () => { it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'div:hover::after { color: white }'; const cssText = 'div:hover::after { color: white }';
expect(addHoverClass(cssText)).to.equal( expect(addHoverClass(cssText, this.cache)).to.equal(
'div:hover::after, div.\\:hover::after { color: white }', 'div:hover::after, div.\\:hover::after { color: white }',
); );
}); });
it('can add hover class when the selector has multi :hover', () => { it('can add hover class when the selector has multi :hover', () => {
const cssText = 'a:hover b:hover { color: white }'; const cssText = 'a:hover b:hover { color: white }';
expect(addHoverClass(cssText)).to.equal( expect(addHoverClass(cssText, this.cache)).to.equal(
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
); );
}); });
it('will ignore :hover in css value', () => { it('will ignore :hover in css value', () => {
const cssText = '.a::after { content: ":hover" }'; const cssText = '.a::after { content: ":hover" }';
expect(addHoverClass(cssText)).to.equal(cssText); expect(addHoverClass(cssText, this.cache)).to.equal(cssText);
}); });
it('benchmark', () => { it('benchmark', () => {
@@ -56,9 +70,28 @@ describe('add hover class to hover selector related rules', () => {
'utf8', 'utf8',
); );
const start = process.hrtime(); const start = process.hrtime();
addHoverClass(cssText); addHoverClass(cssText, this.cache);
const end = process.hrtime(start); const end = process.hrtime(start);
const duration = end[0] * 1_000 + end[1] / 1_000_000; const duration = getDuration(end);
expect(duration).to.below(100); expect(duration).to.below(100);
}); });
it('should be a lot faster to add a hover class to a previously processed css string', () => {
const factor = 100;
let cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);
const start = process.hrtime();
addHoverClass(cssText, this.cache);
const end = process.hrtime(start);
const cachedStart = process.hrtime();
addHoverClass(cssText, this.cache);
const cachedEnd = process.hrtime(cachedStart);
expect(getDuration(cachedEnd) * factor).to.below(getDuration(end));
});
}); });

View File

@@ -1,5 +1,5 @@
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot'; import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot';
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; import rebuild, { buildNodeWithSN, addHoverClass, createCache } from './rebuild';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, }; export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, createCache, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, };

View File

@@ -1,16 +1,19 @@
import { serializedNodeWithId, idNodeMap, INode } from './types'; import { serializedNodeWithId, idNodeMap, INode, BuildCache } from './types';
export declare function addHoverClass(cssText: string): string; export declare function addHoverClass(cssText: string, cache: BuildCache): string;
export declare function createCache(): BuildCache;
export declare function buildNodeWithSN(n: serializedNodeWithId, options: { export declare function buildNodeWithSN(n: serializedNodeWithId, options: {
doc: Document; doc: Document;
map: idNodeMap; map: idNodeMap;
skipChild?: boolean; skipChild?: boolean;
hackCss: boolean; hackCss: boolean;
afterAppend?: (n: INode) => unknown; afterAppend?: (n: INode) => unknown;
cache: BuildCache;
}): INode | null; }): INode | null;
declare function rebuild(n: serializedNodeWithId, options: { declare function rebuild(n: serializedNodeWithId, options: {
doc: Document; doc: Document;
onVisit?: (node: INode) => unknown; onVisit?: (node: INode) => unknown;
hackCss?: boolean; hackCss?: boolean;
afterAppend?: (n: INode) => unknown; afterAppend?: (n: INode) => unknown;
cache: BuildCache;
}): [Node | null, idNodeMap]; }): [Node | null, idNodeMap];
export default rebuild; export default rebuild;

View File

@@ -90,3 +90,6 @@ export declare type SlimDOMOptions = Partial<{
export declare type MaskTextFn = (text: string) => string; export declare type MaskTextFn = (text: string) => string;
export declare type MaskInputFn = (text: string) => string; export declare type MaskInputFn = (text: string) => string;
export declare type KeepIframeSrcFn = (src: string) => boolean; export declare type KeepIframeSrcFn = (src: string) => boolean;
export declare type BuildCache = {
stylesWithHoverClass: Map<string, string>;
};

View File

@@ -1,4 +1,11 @@
import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot'; import {
rebuild,
buildNodeWithSN,
INode,
NodeType,
BuildCache,
createCache,
} from 'rrweb-snapshot';
import * as mittProxy from 'mitt'; import * as mittProxy from 'mitt';
import { polyfill as smoothscrollPolyfill } from './smoothscroll'; import { polyfill as smoothscrollPolyfill } from './smoothscroll';
import { Timer } from './timer'; import { Timer } from './timer';
@@ -97,6 +104,9 @@ export class Replayer {
// Hold the list of CSSRules for in-memory state restoration // Hold the list of CSSRules for in-memory state restoration
private virtualStyleRulesMap!: VirtualStyleRulesMap; private virtualStyleRulesMap!: VirtualStyleRulesMap;
// The replayer uses the cache to speed up replay and scrubbing.
private cache: BuildCache = createCache();
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map(); private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
private mirror: Mirror = createMirror(); private mirror: Mirror = createMirror();
@@ -376,6 +386,14 @@ export class Replayer {
this.iframe.style.pointerEvents = 'none'; this.iframe.style.pointerEvents = 'none';
} }
/**
* Empties the replayer's cache and reclaims memory.
* The replayer will use this cache to speed up the playback.
*/
public resetCache() {
this.cache = createCache();
}
private setupDom() { private setupDom() {
this.wrapper = document.createElement('div'); this.wrapper = document.createElement('div');
this.wrapper.classList.add('replayer-wrapper'); this.wrapper.classList.add('replayer-wrapper');
@@ -566,6 +584,7 @@ export class Replayer {
afterAppend: (builtNode) => { afterAppend: (builtNode) => {
this.collectIframeAndAttachDocument(collected, builtNode); this.collectIframeAndAttachDocument(collected, builtNode);
}, },
cache: this.cache,
})[1]; })[1];
for (const { mutationInQueue, builtNode } of collected) { for (const { mutationInQueue, builtNode } of collected) {
this.attachDocumentToIframe(mutationInQueue, builtNode); this.attachDocumentToIframe(mutationInQueue, builtNode);
@@ -639,6 +658,7 @@ export class Replayer {
afterAppend: (builtNode) => { afterAppend: (builtNode) => {
this.collectIframeAndAttachDocument(collected, builtNode); this.collectIframeAndAttachDocument(collected, builtNode);
}, },
cache: this.cache,
}); });
for (const { mutationInQueue, builtNode } of collected) { for (const { mutationInQueue, builtNode } of collected) {
this.attachDocumentToIframe(mutationInQueue, builtNode); this.attachDocumentToIframe(mutationInQueue, builtNode);
@@ -1217,6 +1237,7 @@ export class Replayer {
map: this.mirror.map, map: this.mirror.map,
skipChild: true, skipChild: true,
hackCss: true, hackCss: true,
cache: this.cache,
}) as INode; }) as INode;
// legacy data, we should not have -1 siblings any more // legacy data, we should not have -1 siblings any more