querySelectorAll from an element probably doesn't do what you think it does
Modern browsers have APIs called querySelector
and querySelectorAll
. They
find one or more elements matching a CSS selector. I'm assuming basic
familiarity with CSS selectors: how you select elements, classes and ids. If
you haven't used them, the Mozilla Developer Network has an excellent
introduction.
Imagine the following HTML page:
<!DOCTYPE html> <html> <body> <img id="outside"> <div id="my-id"> <img id="inside"> <div class="lonely"></div> <div class="outer"> <div class="inner"></div> </div> </div> </body> </html>
document.querySelectorAll("div")
returns a NodeList
of all of the <div>
elements on the page. document.querySelector("div.lonely")
returns that
single lonely div.
document
supports both querySelector
and
querySelectorAll
, letting you find elements in the entire
document. Elements themselves also support both querySelector
and
querySelectorAll
, letting you query for elements that are
descendants of that element. For example, the following expression will find
images that are descendants of #my-id
:
document.querySelector("#my-id").querySelectorAll("img")
In the sample HTML page above, it will find <img id="inside">
but not <img
id="outside">
.
With that in mind, what do these two expressions do?
document.querySelectorAll("#my-id div div"); document.querySelector("#my-id").querySelectorAll("div div");
You might reasonably expect them to be equivalent. After all, one asks for
div
elements inside div
elements inside #my-id
, and the other asks for
div
elements inside div
elements that are descendants of
#my-id
. However, when you look at this JSbin, you'll see that they
produce very different results:
document.querySelectorAll("#my-id div div").length === 1; document.querySelector("#my-id").querySelectorAll("div div").length === 3;
What is going on here?
It turns out that element.querySelectorAll
doesn't match elements
starting from element
. Instead, it matches elements matching the query that
are also descendants of element
. Therefore, we're seeing three div
elements: div.lonely
, div.outer
, div.inner
. We're seeing them because
they both match the div div
selector and are all descendants of #my-id
.
The trick to remembering this is that CSS selectors are absolute. They are not
relative to any particular element, not even the element you're calling
querySelectorAll
on.
This even works with elements outside the element you're calling
querySelectorAll
on. For example, this selector:
document.querySelector("#my-id").querySelector("div div div")
... matches div.inner
in this snippet (JSbin):
<!DOCTYPE html> <html> <body> <div> <div id="my-id"> <div class="inner"></div> </div> </div> </body> </html>
I think this API is surprising, and the front-end engineers I've asked seem to agree with me. This is, however, not a bug. It's how the spec defines it to work, and browsers consistently implement it that way. Safari. John Resig commented how he and others felt this behavior was quite confusing back when the spec came out.
If you can't easily rewrite the selector to be absolute like we did above,
there are two alternatives: the :scope
CSS pseudo-selector, and
query
/queryAll
.
The :scope
pseudo-selector matches against the current scope. The
name comes from the CSS scoping, which limits the scope
of styles to part of the document. The element we're calling
querySelectorAll
on also counts as a scope, so this expression only
matches div.inner
:
document.querySelector("#my-id").querySelectorAll(":scope div div");
Unfortunately, browser support for scoped CSS and the :scope
pseudo-selector is extremely limited. Only recent versions of Firefox support
it by default. Blink-based browsers like Chrome and Opera require the
well-hidden experimental features flag to be turned on. Safari has a buggy
implementation. Internet Explorer doesn't support it at all.
The other alternative is element.query
/queryAll
. These are alternative
methods to querySelector
and querySelectorAll
that exist on DOM parent
nodes. They also take selectors, except these selectors are interpreted
relative to the element being queried from. Unfortunately, these methods are
even more obscure: they are not referenced on MDN or caniuse.com
, and are
missing from the current DOM4 working draft, dated 18
June 2015. They were still present in an older version, dated 4
February 2014, as well as in the WHATWG Living Document version
of the spec. They have also been implemented by at least two polyfills:
In conclusion, the DOM spec doesn't always necessarily do the most obvious thing. It's important to know pitfalls like these, because they're difficult to discover from just the behavior. Fortunately, you can often rewrite your selector so that it isn't a problem. If you can't, there's always a polyfill to give you the modern API you want. Alternatively, libraries like jQuery can also help you get a consistent, friendly interface for querying the DOM.