Walk the DOM Like getElementsByClassName
DFS or BFS a subtree for class tokens. Live vs static NodeList, classList vs className split, and why querySelectorAll is the modern answer.
- javascript
- dom
- interview
- polyfill
The prompt is “implement getElementsByClassName.” People split className on spaces, recurse with childNodes, and return a plain array. Then three follow-ups land: SVG (className is not a string), live collections (the native API is live), and document order (BFS is the wrong walk).
Native element.getElementsByClassName(names) returns a live HTMLCollection of descendants that have all of the space-separated tokens, in tree order. Tree order is preorder depth-first: parent, then its children left to right, each subtree completed before the next sibling. A queue (BFS) is level order. It will fail a native-order test.
In production you call querySelectorAll. In the interview you walk the tree, match tokens with classList, and say your result is static.
Key takeaways
- Match every class token. Walk descendants only, in document (DFS preorder) order.
classList.containsbeatsclassName.split. SVGclassNameis not a string.- Native
getElementsByClassNameis a liveHTMLCollection.querySelectorAllis a staticNodeList. Your array is static; say that.- BFS is not document order. Use children + DFS (recursion or an explicit stack).
Token match on every element, document order
Procedure:
- Tokenize the query with whitespace. Drop empties. If nothing remains, return
[]. - Walk element children of the root, depth-first, left to right. Skip the root itself (this matches
Element.getElementsByClassName). - An element matches when every token is in its
classList. - Push matches into an array. Return that array (static).
function getElementsByClassName(root, classNames) {
const tokens = String(classNames).trim().split(/\s+/).filter(Boolean);
const out = [];
if (!root || tokens.length === 0) return out;
function match(el) {
return tokens.every((token) => el.classList.contains(token));
}
function walk(node) {
const kids = node.children;
for (let i = 0; i < kids.length; i++) {
const el = kids[i];
if (match(el)) out.push(el);
walk(el);
}
}
walk(root);
return out;
}
<div id="root" class="box">
<p class="box item">a</p>
<span class="item">b</span>
<p class="box item extra">c</p>
</div>
getElementsByClassName(root, 'box item');
// [p.box.item, p.box.item.extra] // both tokens, descendants only, tree order
root itself has box and is not in the result. Native document.getElementsByClassName does include document.documentElement if it matches, because the search is the whole document, not “children of document.” If they pass document, decide: walk document.documentElement and test it first, or walk document.children. Spell the choice.
Multiple tokens mean AND, not a descendant combinator. 'box item' is “has box and item”, not “.box .item”.
Iterative DFS if they fear deep trees:
function getElementsByClassName(root, classNames) {
const tokens = String(classNames).trim().split(/\s+/).filter(Boolean);
const out = [];
if (!root || tokens.length === 0) return out;
const stack = [];
const first = root.children;
for (let i = first.length - 1; i >= 0; i--) stack.push(first[i]);
while (stack.length) {
const el = stack.pop();
if (tokens.every((t) => el.classList.contains(t))) out.push(el);
const kids = el.children;
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
}
return out;
}
Push children right-to-left so you pop left-to-right. That is the usual stack trick for preorder.
Live vs static, classList vs className
| API | Result | Live? | Includes the root? |
|---|---|---|---|
document.getElementsByClassName |
HTMLCollection |
yes | yes, if <html> matches |
element.getElementsByClassName |
HTMLCollection |
yes | no, descendants only |
querySelectorAll('.a.b') |
NodeList |
no (static) | no |
| Interview array walk | Array |
no | no (if you walked children) |
Live means the collection reflects DOM mutations after you got it. This loop never ends if each clone still has the class:
const live = document.getElementsByClassName('n');
for (let i = 0; i < live.length; i++) {
live[i].parentNode.appendChild(live[i].cloneNode(true));
}
A static array you built before the loop is safe. querySelectorAll is safe. If you polyfill by returning a real live collection, you are reimplementing HTMLCollection, not walking a tree once.
className looks like a string on HTML elements. On SVG it is often an SVGAnimatedString (el.className.baseVal). String(el.className).split(/\s+/) becomes junk. el.classList.contains is the token API; use it. See classList and dataset.
el.getAttribute('class') is a string on both HTML and SVG. Splitting that works if you cannot use classList. Trim and split on whitespace. Duplicate tokens in the query ('box box') still mean “has box.”
childNodes includes text and comments. You would filter nodeType === 1. children is only elements. Prefer children.
Why querySelectorAll is the modern answer
function byClass(root, classNames) {
const tokens = String(classNames).trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return [];
const selector = tokens.map((t) => '.' + CSS.escape(t)).join('');
return [...root.querySelectorAll(selector)];
}
.box.item is AND on the same element. .box .item would be a descendant. Forgetting CSS.escape breaks on class names with colons or other CSS specials (a real issue with some CSS-in-JS hashes).
querySelector / querySelectorAll already do tree order, static snapshots, and selector engines that handle more than classes. The walk is assigned because it tests DFS, token sets, and whether you know the live-collection trap, not because the codebase should ship a recursive polyfill.
Do not build the selector by concatenating unescaped class names from user input. That is how you turn a class walk into a selector injection mess.
Footguns
- BFS queue. Level order is not document order.
- Including the start element for the
Elementvariant. - OR instead of AND when several tokens are present.
for...inoverchildren. You will pick uplengthand extra enumerable junk. Use a numeric index orfor...of.- Mutating the tree while walking and skipping siblings. Collect first, or walk a snapshot of
childrenif the callback mutates. Native live collections have the opposite problem (they see the mutation).
If you need to assemble a result list into the document later, build it on a DocumentFragment instead of inserting per match.
Interview answer (out loud)
“I tokenize the class string on whitespace and walk element descendants in DFS preorder, which is document order. An element matches when classList contains every token. I return a static array and I would say native getElementsByClassName is a live HTMLCollection, which is a different contract. I would not split className because SVG className is not a string. In an app I would use querySelectorAll with CSS.escape, not a hand-rolled walk.”
Further reading
- MDN
Document.getElementsByClassName - MDN
Element.classList - DOM spec:
getElementsByClassName - MDN
querySelectorAll
Related on this site
- DOM traversal with querySelector
- classList and dataset
- Creating and updating DOM nodes
- DocumentFragment
- Array.filter polyfill
- Array.map polyfill
Related guides
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.
- Array.forEach PolyfillImplement Array.prototype.forEach: visit present elements, skip holes, ignore the callback return, and honor thisArg without using native forEach.
- Array.includes PolyfillImplement Array.prototype.includes with SameValueZero: NaN is found, +0 equals -0, and fromIndex can walk backwards from the end.