Skip to content

Commit

Permalink
fix(utils): greatly improve the speed of querySelectorAll (#3423)
Browse files Browse the repository at this point in the history
* fix(utils): greatly improve the speed of our querySelectorAll code

* fixes

* finalize

* tests

* fix ie11

* const...

* changes

* tie map to cache

* fix test

* remove test

* fixes

* revert rule descriptions
  • Loading branch information
straker authored Jun 8, 2022
1 parent 7e01b00 commit 1cae5ea
Show file tree
Hide file tree
Showing 9 changed files with 782 additions and 186 deletions.
10 changes: 10 additions & 0 deletions lib/core/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import v2Reporter from './reporters/v2';

import * as commons from '../commons';
import * as utils from './utils';
import {
cacheNodeSelectors,
getNodesMatchingExpression
} from './utils/selector-cache';
import { convertSelector } from './utils/matches';

axe.constants = constants;
axe.log = log;
Expand All @@ -70,6 +75,11 @@ axe._thisWillBeDeletedDoNotUse.base = {
axe._thisWillBeDeletedDoNotUse.public = {
reporters
};
axe._thisWillBeDeletedDoNotUse.utils =
axe._thisWillBeDeletedDoNotUse.utils || {};
axe._thisWillBeDeletedDoNotUse.utils.cacheNodeSelectors = cacheNodeSelectors;
axe._thisWillBeDeletedDoNotUse.utils.getNodesMatchingExpression = getNodesMatchingExpression;
axe._thisWillBeDeletedDoNotUse.utils.convertSelector = convertSelector;

axe.imports = imports;

Expand Down
26 changes: 22 additions & 4 deletions lib/core/utils/get-flattened-tree.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import isShadowRoot from './is-shadow-root';
import VirtualNode from '../base/virtual-node/virtual-node';
import cache from '../base/cache';
import { cacheNodeSelectors } from './selector-cache';

/**
* This implemnts the flatten-tree algorithm specified:
Expand Down Expand Up @@ -40,6 +41,20 @@ function getSlotChildren(node) {
return retVal;
}

/**
* Create a virtual node
* @param {Node} node the current node
* @param {VirtualNode} parent the parent VirtualNode
* @param {String} shadowId, optional ID of the shadow DOM that is the closest shadow ancestor of the node
* @return {VirtualNode}
*/
function createNode(node, parent, shadowId) {
const vNode = new VirtualNode(node, parent, shadowId);
cacheNodeSelectors(vNode, cache.get('selectorMap'));

return vNode;
}

/**
* Recursvely returns an array of the virtual DOM nodes at this level
* excluding comment nodes and the shadow DOM nodes <content> and <slot>
Expand Down Expand Up @@ -71,7 +86,7 @@ function flattenTree(node, shadowId, parent) {

// generate an ID for this shadow root and overwrite the current
// closure shadowId with this value so that it cascades down the tree
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
shadowId =
'a' +
Math.random()
Expand Down Expand Up @@ -106,7 +121,7 @@ function flattenTree(node, shadowId, parent) {
if (false && styl.display !== 'contents') {
// intentionally commented out
// has a box
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
retVal.children = realArray.reduce((res, child) => {
return reduceShadowDOM(res, child, retVal);
}, []);
Expand All @@ -119,7 +134,7 @@ function flattenTree(node, shadowId, parent) {
}
} else {
if (node.nodeType === 1) {
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
realArray = Array.from(node.childNodes);
retVal.children = realArray.reduce((res, child) => {
return reduceShadowDOM(res, child, retVal);
Expand All @@ -128,7 +143,7 @@ function flattenTree(node, shadowId, parent) {
return [retVal];
} else if (node.nodeType === 3) {
// text
return [new VirtualNode(node, parent)];
return [createNode(node, parent)];
}
return undefined;
}
Expand All @@ -145,12 +160,15 @@ function flattenTree(node, shadowId, parent) {
*/
function getFlattenedTree(node = document.documentElement, shadowId) {
hasShadowRoot = false;
const selectorMap = {};
cache.set('nodeMap', new WeakMap());
cache.set('selectorMap', selectorMap);

// specifically pass `null` to the parent to designate the top
// node of the tree. if parent === undefined then we know
// we are in a disconnected tree
const tree = flattenTree(node, shadowId, null);
tree[0]._selectorMap = selectorMap;

// allow rules and checks to know if there is a shadow root attached
// to the current tree
Expand Down
5 changes: 5 additions & 0 deletions lib/core/utils/matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ function convertAttributes(atts) {
return {
key: attributeKey,
value: attributeValue,
type: typeof att.value === 'undefined' ? 'attrExist' : 'attrValue',
test: test
};
});
Expand Down Expand Up @@ -224,6 +225,10 @@ export function convertSelector(selector) {
* @returns {Boolean}
*/
function optimizedMatchesExpression(vNode, expressions, index, matchAnyParent) {
if (!vNode) {
return false;
}

const isArray = Array.isArray(expressions);
const expression = isArray ? expressions[index] : expressions;
let matches = matchExpression(vNode, expression);
Expand Down
12 changes: 12 additions & 0 deletions lib/core/utils/query-selector-all-filter.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { matchesExpression, convertSelector } from './matches';
import { getNodesMatchingExpression } from './selector-cache';

function createLocalVariables(
vNodes,
Expand Down Expand Up @@ -124,6 +125,17 @@ function matchExpressions(domTree, expressions, filter) {
function querySelectorAllFilter(domTree, selector, filter) {
domTree = Array.isArray(domTree) ? domTree : [domTree];
const expressions = convertSelector(selector);

// see if the passed in node is the root node of the tree and can
// find nodes using the cache rather than looping through the
// the entire tree
const nodes = getNodesMatchingExpression(domTree, expressions, filter);
if (nodes) {
return nodes;
}

// if the selector cache is not set up or if not passed the
// top level node we default back to parsing the whole tree
return matchExpressions(domTree, expressions, filter);
}

Expand Down
206 changes: 206 additions & 0 deletions lib/core/utils/selector-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { matchesExpression } from './matches';
import tokenList from './token-list';

// since attribute names can't contain whitespace, this will be
// a reserved list for ids so we can perform virtual id lookups
const idsKey = ' [idsMap]';

/**
* Get nodes from the selector cache that match the selector.
* @param {VirtualTree[]} domTree flattened tree collection to search
* @param {Object} expressions
* @param {Function} filter function (optional)
* @return {Mixed} Array of nodes that match the selector or undefined if the selector map is not setup
*/
export function getNodesMatchingExpression(domTree, expressions, filter) {
// check to see if the domTree is the root and has the selector
// map. if not we just return and let our QSA code do the finding
const selectorMap = domTree[0]._selectorMap;
if (!selectorMap) {
return;
}

const shadowId = domTree[0].shadowId;

// if the selector uses a global selector with a combinator
// (e.g. A *, A > *) it's actually faster to use our QSA code than
// getting all nodes and using matchesExpression
for (let i = 0; i < expressions.length; i++) {
if (
expressions[i].length > 1 &&
expressions[i].some(expression => isGlobalSelector(expression))
) {
return;
}
}

// it turned out to be more performant to use a Set to generate a
// unique list of nodes rather than an array and array.includes
// (~3 seconds total on a benchmark site)
const nodeSet = new Set();

expressions.forEach(expression => {
const matchingNodes = findMatchingNodes(expression, selectorMap, shadowId);
matchingNodes?.nodes?.forEach(node => {
// for complex selectors we need to verify that the node
// actually matches the entire selector since we only have
// nodes that partially match the last part of the selector
if (
matchingNodes.isComplexSelector &&
!matchesExpression(node, expression)
) {
return;
}

nodeSet.add(node);
});
});

// Sets in ie11 do not work with Array.from without a polyfill
//(missing `.entries`), but do have forEach
let matchedNodes = [];
nodeSet.forEach(node => matchedNodes.push(node));

if (filter) {
matchedNodes = matchedNodes.filter(filter);
}

return matchedNodes.sort((a, b) => a.nodeIndex - b.nodeIndex);
}

/**
* Add nodes to the passed in Set that match just a part of the selector in order to speed up traversing the entire tree.
* @param {Object} expression Selector Expression
* @param {Object} selectorMap Selector map cache
* @param {String} shadowId ShadowID of the root node
*/
function findMatchingNodes(expression, selectorMap, shadowId) {
// use the last part of the expression to find nodes as it's more
// specific. e.g. for `body h1` use `h1` and not `body`
const exp = expression[expression.length - 1];
let nodes = null;

// a complex selector is one that will require using
// matchesExpression to determine if it matches. these include
// pseudo selectors (:not), combinators (A > B), and any
// attribute value ([class=foo]).
let isComplexSelector =
expression.length > 1 || !!exp.pseudos || !!exp.classes;

if (isGlobalSelector(exp)) {
nodes = selectorMap['*'];
} else {
if (exp.id) {
// a selector must match all parts, otherwise we can just exit
// early
if (!selectorMap[idsKey] || !selectorMap[idsKey][exp.id]?.length) {
return;
}

// when using id selector (#one) we only find nodes that
// match the shadowId of the root
nodes = selectorMap[idsKey][exp.id].filter(
node => node.shadowId === shadowId
);
}

if (exp.tag && exp.tag !== '*') {
if (!selectorMap[exp.tag]?.length) {
return;
}

const cachedNodes = selectorMap[exp.tag];
nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes;
}

if (exp.classes) {
if (!selectorMap['[class]']?.length) {
return;
}

const cachedNodes = selectorMap['[class]'];
nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes;
}

if (exp.attributes) {
for (let i = 0; i < exp.attributes.length; i++) {
const attr = exp.attributes[i];

// an attribute selector that looks for a specific value is
// a complex selector
if (attr.type === 'attrValue') {
isComplexSelector = true;
}

if (!selectorMap[`[${attr.key}]`]?.length) {
return;
}

const cachedNodes = selectorMap[`[${attr.key}]`];
nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes;
}
}
}

return { nodes, isComplexSelector };
}

/**
* Non-tag selectors use `*` for the tag name so a global selector won't have any other properties of the expression. Pseudo selectors that use `*` (e.g. `*:not([class])`) will still be considered a global selector since we don't cache anything for pseudo selectors and will rely on filtering with matchesExpression.
* @param {Object} expression Selector Expression
* @returns {Boolean}
*/
function isGlobalSelector(expression) {
return (
expression.tag === '*' &&
!expression.attributes &&
!expression.id &&
!expression.classes
);
}

/**
* Find all nodes in A that are also in B.
* @param {Mixed[]} a
* @param {Mixed[]} b
* @returns {Mixed[]}
*/
function getSharedValues(a, b) {
return a.filter(node => b.includes(node));
}

/**
* Save a selector and vNode to the selectorMap.
* @param {String} key
* @param {VirtualNode} vNode
* @param {Object} map
*/
function cacheSelector(key, vNode, map) {
map[key] = map[key] || [];
map[key].push(vNode);
}

/**
* Cache selector information about a VirtalNode.
* @param {VirtualNode} vNode
*/
export function cacheNodeSelectors(vNode, selectorMap) {
if (vNode.props.nodeType !== 1) {
return;
}

cacheSelector(vNode.props.nodeName, vNode, selectorMap);
cacheSelector('*', vNode, selectorMap);

vNode.attrNames.forEach(attrName => {
// element ids are the only values we'll match
if (attrName === 'id') {
selectorMap[idsKey] = selectorMap[idsKey] || {};
tokenList(vNode.attr(attrName)).forEach(value => {
cacheSelector(value, vNode, selectorMap[idsKey]);
});
}

cacheSelector(`[${attrName}]`, vNode, selectorMap);
});
}
5 changes: 5 additions & 0 deletions test/core/utils/flattened-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ describe('axe.utils.getFlattenedTree', function() {
assert.equal(vNode.children[1].children[0].props.nodeName, 's');
});

it('should add selectorMap to root element', function() {
var tree = axe.utils.getFlattenedTree();
assert.exists(tree[0]._selectorMap);
});

if (shadowSupport.v0) {
describe('shadow DOM v0', function() {
beforeEach(function() {
Expand Down
18 changes: 18 additions & 0 deletions test/core/utils/matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ describe('utils.matches', function() {
var matches = axe.utils.matches;
var fixture = document.querySelector('#fixture');
var queryFixture = axe.testUtils.queryFixture;
var convertSelector = axe._thisWillBeDeletedDoNotUse.utils.convertSelector;

afterEach(function() {
fixture.innerHTML = '';
Expand Down Expand Up @@ -322,4 +323,21 @@ describe('utils.matches', function() {
});
});
});

describe('convertSelector', function() {
it('should set type to attrExist for attribute selector', function() {
var expression = convertSelector('[disabled]');
assert.equal(expression[0][0].attributes[0].type, 'attrExist');
});

it('should set type to attrValue for attribute value selector', function() {
var expression = convertSelector('[aria-pressed="true"]');
assert.equal(expression[0][0].attributes[0].type, 'attrValue');
});

it('should set type to attrValue for empty attribute value selector', function() {
var expression = convertSelector('[aria-pressed=""]');
assert.equal(expression[0][0].attributes[0].type, 'attrValue');
});
});
});
Loading

0 comments on commit 1cae5ea

Please sign in to comment.