ESC

Type to search the knowledge base.

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.

intermediate5 min read
  • 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.contains beats className.split. SVG className is not a string.
  • Native getElementsByClassName is a live HTMLCollection. querySelectorAll is a static NodeList. 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:

  1. Tokenize the query with whitespace. Drop empties. If nothing remains, return [].
  2. Walk element children of the root, depth-first, left to right. Skip the root itself (this matches Element.getElementsByClassName).
  3. An element matches when every token is in its classList.
  4. 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 Element variant.
  • OR instead of AND when several tokens are present.
  • for...in over children. You will pick up length and extra enumerable junk. Use a numeric index or for...of.
  • Mutating the tree while walking and skipping siblings. Collect first, or walk a snapshot of children if 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

Related guides