How to change the HTML content as it’s loading on the page

The docs on MDN have a generic incomplete example and don’t showcase the common pitfalls.
Mutation summary library provides a human-friendly wrapper, but like all wrappers it adds overhead.
See Performance of MutationObserver to detect nodes in entire DOM.

Create and start the observer.

Let’s use a recursive document-wide MutationObserver that reports all added/removed nodes.

var observer = new MutationObserver(onMutation);
observer.observe(document, {
  childList: true, // report added/removed nodes
  subtree: true,   // observe any descendant elements
});

Naive enumeration of added nodes.

Slows down loading of enormously big/complex pages, see Performance.
Sometimes misses the H1 elements coalesced in parent container, see the next section.

function onMutation(mutations) {
  mutations.forEach(mutation, m => {
    [...m.addedNodes]
      .filter(node =>
        node.localName === 'h1' && /foo/.test(node.textContent))
      .forEach(h1 => {
        h1.innerHTML = h1.innerHTML.replace(/foo/, 'bar');
      });
  });
}

Efficient enumeration of added nodes.

Now the hard part. Nodes in a mutation record may be containers while a page is being loaded (like the entire site header block with all its elements reported as just one added node): the specification doesn’t require each added node to be listed individually, so we’ll have to look inside each element using querySelectorAll (extremely slow) or getElementsByTagName (extremely fast).

function onMutation(mutations) {
  for (var i = 0, len = mutations.length; i < len; i++) {
    var added = mutations[i].addedNodes;
    for (var j = 0, node; (node = added[j]); j++) {
      if (node.localName === 'h1') {
        if (/foo/.test(node.textContent)) {
          replaceText(node);
        }
      } else if (node.firstElementChild) {
        for (const h1 of node.getElementsByTagName('h1')) {
          if (/foo/.test(h1.textContent)) {
            replaceText(h1);
          }
        }
      }
    }
  }
}

function replaceText(el) {
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  for (let node; (node = walker.nextNode());) {
    const text = node.nodeValue;
    const newText = text.replace(/foo/, 'bar');
    if (text !== newText) {
      node.nodeValue = newText;
    }
  }
}

Why the two ugly vanilla for loops? Because forEach and filter and ES2015 for (val of array) could be very slow in some browsers, see Performance of MutationObserver to detect nodes in entire DOM.

Why the TreeWalker? To preserve any event listeners attached to sub-elements. To change only the Text nodes: they don’t have child nodes, and changing them doesn’t trigger a new mutation because we’ve used childList: true, not characterData: true.

Processing relatively rare elements via live HTMLCollection without enumerating mutations.

So we look for an element that is supposed to be used rarely like H1 tag, or IFRAME, etc. In this case we can simplify and speed up the observer callback with an automatically updated HTMLCollection returned by getElementsByTagName.

const h1s = document.getElementsByTagName('h1');

function onMutation(mutations) {
  if (mutations.length === 1) {
    // optimize the most frequent scenario: one element is added/removed
    const added = mutations[0].addedNodes[0];
    if (!added || (added.localName !== 'h1' && !added.firstElementChild)) {
      // so nothing was added or non-H1 with no child elements
      return;
    }
  }
  // H1 is supposed to be used rarely so there'll be just a few elements
  for (var i = 0, h1; (h1 = h1s[i]); i++) {
    if (/foo/.test(h1.textContent)) {
      // reusing replaceText from the above fragment of code 
      replaceText(h1);
    }
  }
}

Leave a Comment