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 {
|
||||
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 {
|
||||
query: (root: SelectorRoot, selector: string): Element | undefined => {
|
||||
const matcher = createTextMatcher(selector);
|
||||
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;
|
||||
}
|
||||
return queryList(root, selector, true)[0];
|
||||
},
|
||||
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
||||
const matcher = createTextMatcher(selector);
|
||||
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;
|
||||
return queryList(root, selector, false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -823,11 +838,11 @@ function unescape(s: string): string {
|
|||
}
|
||||
|
||||
type Matcher = (text: string) => boolean;
|
||||
function createTextMatcher(selector: string): Matcher {
|
||||
function createTextMatcher(selector: string): { matcher: Matcher, strict: boolean } {
|
||||
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
|
||||
const lastSlash = selector.lastIndexOf('/');
|
||||
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;
|
||||
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, ' ');
|
||||
if (!strict)
|
||||
selector = selector.toLowerCase();
|
||||
return text => {
|
||||
const matcher = (text: string) => {
|
||||
text = text.trim().replace(/\s+/g, ' ');
|
||||
if (!strict)
|
||||
return text.toLowerCase().includes(selector);
|
||||
return text === selector;
|
||||
};
|
||||
return { matcher, strict };
|
||||
}
|
||||
|
||||
export default InjectedScript;
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
const selector = this._checkSelector(s);
|
||||
this.begin();
|
||||
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))
|
||||
return this._matchesEngine(isEngine, element, 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);
|
||||
this.begin();
|
||||
try {
|
||||
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
|
||||
return this._cached<Element[]>(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => {
|
||||
if (Array.isArray(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 {
|
||||
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');
|
||||
if (!isPossiblyScopeClause && element === context.scope)
|
||||
return false;
|
||||
|
|
@ -192,7 +192,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
if (!simple.functions.length)
|
||||
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;
|
||||
const funcs = simple.functions;
|
||||
if (css === '*' && funcs.length)
|
||||
|
|
@ -229,7 +229,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
||||
if (index < 0)
|
||||
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];
|
||||
if (combinator === '>') {
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
}
|
||||
|
||||
_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[] = [];
|
||||
function query(root: Element | ShadowRoot | Document) {
|
||||
result = result.concat([...root.querySelectorAll(css)]);
|
||||
|
|
@ -428,7 +428,7 @@ const textEngine: SelectorEngine = {
|
|||
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||
throw new Error(`"text" engine expects a single string`);
|
||||
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')
|
||||
throw new Error(`"text-is" engine expects a single string`);
|
||||
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`);
|
||||
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
|
||||
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;
|
||||
}
|
||||
|
||||
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))
|
||||
return false;
|
||||
return 'none';
|
||||
if (!matcher(elementText(evaluator, element)))
|
||||
return false;
|
||||
return 'none';
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
|
||||
return false;
|
||||
return 'selfAndChildren';
|
||||
}
|
||||
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
|
||||
return false;
|
||||
return true;
|
||||
return 'selfAndChildren';
|
||||
return 'self';
|
||||
}
|
||||
|
||||
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
|
|
|
|||
Loading…
Reference in a new issue