Is there a reason to not use querySelector, since it’s a lot more flexible? One reason jQuery became so popular is because the DOM was painful to use. Things like querySelector fix that.
getElement is slightly faster, but not by enough to care IIRC so I use querySelector for consistency and it's flexibility.
> One reason jQuery became so popular is because the DOM was painful
I would say that is the key reason, with everything else being collateral benefits. Assuming you combine element selection, dealing with legacy incompatibilities, and function chaining to reduce boilerplate code, under the same banner of "making the DOM less painful".
My experience is that most developers tend to guess at performance and throw away numbers they disagree with. As a result performance testing is only something product owners care about.
In isolation definitely, but in real world code it might be faster to use querySelector for branchy code if it doesn’t always use an id. As with everything, if it’s not performance-sensitive write the code that’s easier for humans to read, and if it is measure first.
I'm not sure what you're trying to say here, as it's tautologically correct that getElementById can't be used in cases where you want to select on more than just the id.
Do you mean a use case where you have branchy code that produces a selector string that has some id only paths?
Yes. Branchy code which could sometimes use getElementById and other times use querySelector may be faster if it always uses querySelector, even if that call itself is slower. The reason for this is that the JITs sometimes deoptimize on branchy logic with inconsistent property access between branches. They also deoptimize on branchy logic defining intermediate values, but much less often when the value is a consistent type like a string (selector).
This would only be relevant if you're doing something like
var theFunction = condition ? "querySelector" : "getElementById";
...
document[theFunction](...)
it won't apply to
if (condition)
document.querySelector(...)
else
document.getElementById(...)
As from the point of view of the runtime the latter has two call sites, and each one is monomorphic and will very quickly (first layer of the JIT tower generally) become a Structure/Shape/HiddenClass check on `document` followed by a direct call to the host environment's implementation function (or more likely the argument checking and marshaling function before the actual internal implementation).
It is possible that the higher level JITs pay attention to the branch counts on conditions or use other side channels for the deopt, but for host functions it's generally not something that will happen as the JITs see natively implemented functions as largely opaque barriers - they only have a few internal (to the runtime itself) cases where they make any assumptions about the behaviour of host functions.
> As from the point of view of the runtime the latter has two call sites, and each one is monomorphic
I expected that to be the case but I’ve actually measured it and it’s not always. It is, when the object being accessed has a consistent shape/hidden class, as you mention, but a lot of times they don’t. A weird case is native interfaces because while the host functions are opaque and you’d expect they have a stable shape the interfaces themselves are often mutable either for historical reasons or shortcuts taken in newer proposals/implementations. Accessing document.foo isn’t and can’t be monomorphic in many cases, even if it can be treated that way speculatively. But branchy code can throw out all sorts of speculation of that sort. I don’t know which level of the JIT this occurs at, I’m just speaking from having measured it as a user of the APIs.
This isn't me disagreeing, just me being surprised and trying to think of why the optimizer falls off.
JSC at least has flags on the structure that track which ones will bollocks up caching (e.g. the misery that is looking up things in the prototype chain if the object in question has magic properties that don't influence the structure).
One thought I have is if your test case was something like
if (a)
obj.doTheNativeThing()
else
obj.doTheOtherNativeThing()
(or whatever)
and you primed the caches by having a being true/false be a 50/50 split, vs all one way. My thinking (I have not done any of the debugging or logging) is that the branch that isn't taken won't insert any information about the call target. I can see that resulting in the generated code in the optimizing layers of the JITs being something along the lines of
if (a)
call _actualNativeFunction
else
deopt
The deopt terminates the execution flow so then in principle the VM gets to make assumptions about the code state after the whole if/else block, but more importantly the actual size of the code for the function is smaller, and so if you were close to the inlining limit dropping the content of the else branch _could_ result in your test function getting inlined, and then follow on optimizations can happen in the context of the function that you use to run your test with. Even if there aren't magic follow on optimizations removing the intermediate call can itself be a significant perf win.
Testing the performance of engines was super annoying back when I worked on JSC, as you have to try and construct real test cases, but that means competing with your test functions being inlined. JSC (and presumably other engines) have things you can do (outside of the browser context) to explicitly prevent inlining of a function, but then that is also not necessarily realistic. But it's super easy to accidentally make useless test cases, e.g.
function runTest(f) {
let start = new Date;
for (let j = 0; j < 10000; j++)
f()
let end = new Date;
console.log(end - start)
}
function test1() {
...
}
function test2() {
...
}
runTest(test1)
runTest(test2)
In the first run with test1, f (in runTest) is obviously monomorphic, so the JIT happily inlines it (for the sake of the example assume both functions are below the max inlining size). The next run with test2 makes f polymorphic so runTest gets recompiled and doesn't inline. Now if test1 and test2 are both small the overhead of the call can dominate the cpu time taken which means that if you simply force no inlining of the function you may no longer be getting any useful information, which is obviously annoying :D
Also there's a performance cliff when you have a lot of unique ids (or selectors in use from JS).
When you hit the cache querySelector is primarily a getElementById call and then some overhead to match the selector a second time (which chrome should really optimize):
That’s surprising, webkit+blink and I’m guessing gecko all optimize the query selector cases. I assume it’s the cost of the NodeList (because NodeLists are live :-/)
I actually just went and tested and in webkit at least my 100% perfect test case I had querySelector taking 2x longer than getElementById. I tried understanding what the current webkit code does but the selector matching code is now excitingly complex due to the CSS JIT.
Many many years ago I recall querySelector starting out with a check for #someCSSIdentifier and shortcutting to the getElementById path, but maybe my memory is playing tricks on me.
Yup that's what it did, and Chrome still does. After much research and prototyping the CSS JIT didn't improve real world content (especially given the complexity) so it was never added to Chrome.