feat(selectors): speed up text selector (#5387)
- Do not check children when parent does not contain the text we look for. - Minor caching improvements in evaluator. This gives up to 5X performance boost on text-heavy pages.
This commit is contained in:
parent
716bd4211d
commit
6a98241ac5
|
|
@ -156,24 +156,39 @@ export class InjectedScript {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createTextEngine(shadow: boolean): SelectorEngine {
|
private _createTextEngine(shadow: boolean): SelectorEngine {
|
||||||
|
const queryList = (root: SelectorRoot, selector: string, single: boolean): Element[] => {
|
||||||
|
const { matcher, strict } = createTextMatcher(selector);
|
||||||
|
const result: Element[] = [];
|
||||||
|
let lastDidNotMatchSelf: Element | null = null;
|
||||||
|
|
||||||
|
const checkElement = (element: Element) => {
|
||||||
|
// TODO: replace contains() with something shadow-dom-aware?
|
||||||
|
if (!strict && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
|
||||||
|
return false;
|
||||||
|
const matches = elementMatchesText(this._evaluator, element, matcher);
|
||||||
|
if (matches === 'none')
|
||||||
|
lastDidNotMatchSelf = element;
|
||||||
|
if (matches === 'self')
|
||||||
|
result.push(element);
|
||||||
|
return single && result.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.nodeType === Node.ELEMENT_NODE && checkElement(root as Element))
|
||||||
|
return result;
|
||||||
|
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
|
||||||
|
for (const element of elements) {
|
||||||
|
if (checkElement(element))
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query: (root: SelectorRoot, selector: string): Element | undefined => {
|
query: (root: SelectorRoot, selector: string): Element | undefined => {
|
||||||
const matcher = createTextMatcher(selector);
|
return queryList(root, selector, true)[0];
|
||||||
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
|
|
||||||
return root as Element;
|
|
||||||
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
|
|
||||||
for (const element of elements) {
|
|
||||||
if (elementMatchesText(this._evaluator, element, matcher))
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
||||||
const matcher = createTextMatcher(selector);
|
return queryList(root, selector, false);
|
||||||
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
|
|
||||||
const result = elements.filter(e => elementMatchesText(this._evaluator, e, matcher));
|
|
||||||
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
|
|
||||||
result.unshift(root as Element);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -823,11 +838,11 @@ function unescape(s: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Matcher = (text: string) => boolean;
|
type Matcher = (text: string) => boolean;
|
||||||
function createTextMatcher(selector: string): Matcher {
|
function createTextMatcher(selector: string): { matcher: Matcher, strict: boolean } {
|
||||||
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
|
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
|
||||||
const lastSlash = selector.lastIndexOf('/');
|
const lastSlash = selector.lastIndexOf('/');
|
||||||
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
|
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
|
||||||
return text => re.test(text);
|
return { matcher: text => re.test(text), strict: true };
|
||||||
}
|
}
|
||||||
let strict = false;
|
let strict = false;
|
||||||
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
|
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
|
||||||
|
|
@ -841,12 +856,13 @@ function createTextMatcher(selector: string): Matcher {
|
||||||
selector = selector.trim().replace(/\s+/g, ' ');
|
selector = selector.trim().replace(/\s+/g, ' ');
|
||||||
if (!strict)
|
if (!strict)
|
||||||
selector = selector.toLowerCase();
|
selector = selector.toLowerCase();
|
||||||
return text => {
|
const matcher = (text: string) => {
|
||||||
text = text.trim().replace(/\s+/g, ' ');
|
text = text.trim().replace(/\s+/g, ' ');
|
||||||
if (!strict)
|
if (!strict)
|
||||||
return text.toLowerCase().includes(selector);
|
return text.toLowerCase().includes(selector);
|
||||||
return text === selector;
|
return text === selector;
|
||||||
};
|
};
|
||||||
|
return { matcher, strict };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InjectedScript;
|
export default InjectedScript;
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
this.begin();
|
this.begin();
|
||||||
try {
|
try {
|
||||||
return this._cached<boolean>(this._cacheMatches, element, [selector, context], () => {
|
return this._cached<boolean>(this._cacheMatches, element, [selector, context.scope, context.pierceShadow], () => {
|
||||||
if (Array.isArray(selector))
|
if (Array.isArray(selector))
|
||||||
return this._matchesEngine(isEngine, element, selector, context);
|
return this._matchesEngine(isEngine, element, selector, context);
|
||||||
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
||||||
|
|
@ -135,7 +135,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
this.begin();
|
this.begin();
|
||||||
try {
|
try {
|
||||||
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
|
return this._cached<Element[]>(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => {
|
||||||
if (Array.isArray(selector))
|
if (Array.isArray(selector))
|
||||||
return this._queryEngine(isEngine, context, selector);
|
return this._queryEngine(isEngine, context, selector);
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
||||||
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context], () => {
|
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow], () => {
|
||||||
const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is');
|
const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is');
|
||||||
if (!isPossiblyScopeClause && element === context.scope)
|
if (!isPossiblyScopeClause && element === context.scope)
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -192,7 +192,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
if (!simple.functions.length)
|
if (!simple.functions.length)
|
||||||
return this._queryCSS(context, simple.css || '*');
|
return this._queryCSS(context, simple.css || '*');
|
||||||
|
|
||||||
return this._cached<Element[]>(this._cacheQuerySimple, simple, [context], () => {
|
return this._cached<Element[]>(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow], () => {
|
||||||
let css = simple.css;
|
let css = simple.css;
|
||||||
const funcs = simple.functions;
|
const funcs = simple.functions;
|
||||||
if (css === '*' && funcs.length)
|
if (css === '*' && funcs.length)
|
||||||
|
|
@ -229,7 +229,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
return true;
|
return true;
|
||||||
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context], () => {
|
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow], () => {
|
||||||
const { selector: simple, combinator } = complex.simples[index];
|
const { selector: simple, combinator } = complex.simples[index];
|
||||||
if (combinator === '>') {
|
if (combinator === '>') {
|
||||||
const parent = parentElementOrShadowHostInContext(element, context);
|
const parent = parentElementOrShadowHostInContext(element, context);
|
||||||
|
|
@ -303,13 +303,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
||||||
return this._cached<boolean>(this._cacheCallMatches, element, [engine, args, context.scope, context.pierceShadow], () => {
|
return this._cached<boolean>(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, ...args], () => {
|
||||||
return engine.matches!(element, args, context, this);
|
return engine.matches!(element, args, context, this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
|
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
|
||||||
return this._cached<Element[]>(this._cacheCallQuery, args, [engine, context.scope, context.pierceShadow], () => {
|
return this._cached<Element[]>(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, ...args], () => {
|
||||||
return engine.query!(context, args, this);
|
return engine.query!(context, args, this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
}
|
}
|
||||||
|
|
||||||
_queryCSS(context: QueryContext, css: string): Element[] {
|
_queryCSS(context: QueryContext, css: string): Element[] {
|
||||||
return this._cached<Element[]>(this._cacheQueryCSS, css, [context], () => {
|
return this._cached<Element[]>(this._cacheQueryCSS, css, [context.scope, context.pierceShadow], () => {
|
||||||
let result: Element[] = [];
|
let result: Element[] = [];
|
||||||
function query(root: Element | ShadowRoot | Document) {
|
function query(root: Element | ShadowRoot | Document) {
|
||||||
result = result.concat([...root.querySelectorAll(css)]);
|
result = result.concat([...root.querySelectorAll(css)]);
|
||||||
|
|
@ -428,7 +428,7 @@ const textEngine: SelectorEngine = {
|
||||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||||
throw new Error(`"text" engine expects a single string`);
|
throw new Error(`"text" engine expects a single string`);
|
||||||
const matcher = textMatcher(args[0], true);
|
const matcher = textMatcher(args[0], true);
|
||||||
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
|
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -437,7 +437,7 @@ const textIsEngine: SelectorEngine = {
|
||||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||||
throw new Error(`"text-is" engine expects a single string`);
|
throw new Error(`"text-is" engine expects a single string`);
|
||||||
const matcher = textMatcher(args[0], false);
|
const matcher = textMatcher(args[0], false);
|
||||||
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
|
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -447,7 +447,7 @@ const textMatchesEngine: SelectorEngine = {
|
||||||
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
|
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
|
||||||
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
|
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
|
||||||
const matcher = (s: string) => re.test(s);
|
const matcher = (s: string) => re.test(s);
|
||||||
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
|
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -499,18 +499,18 @@ export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | Sh
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): boolean {
|
export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): 'none' | 'self' | 'selfAndChildren' {
|
||||||
if (shouldSkipForTextMatching(element))
|
if (shouldSkipForTextMatching(element))
|
||||||
return false;
|
return 'none';
|
||||||
if (!matcher(elementText(evaluator, element)))
|
if (!matcher(elementText(evaluator, element)))
|
||||||
return false;
|
return 'none';
|
||||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||||
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
|
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
|
||||||
return false;
|
return 'selfAndChildren';
|
||||||
}
|
}
|
||||||
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
|
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
|
||||||
return false;
|
return 'selfAndChildren';
|
||||||
return true;
|
return 'self';
|
||||||
}
|
}
|
||||||
|
|
||||||
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
|
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue