javascriptab-testingmutation-observersmutation-eventsoptimizely

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


I do A/B Testing on our site and I do most of my work is in a JS file that is loaded at the top of the page before anything else is rendered but after jQuery has loaded which comes in handy at times.

Taking a very simple example of changing an H1 tag, I would normally inject a style in the head to set the H1 opacity to 0 and then on DOMContentLoaded, I would manipulate the H1 contents and then set the opacity to 1. The reason for this is to avoid a flash of the old content before the change takes place - hiding the whole object is more graceful on the eye.

I've started to look at the MutationObserver API. I've used this before when changing content in an overlay dialog box that the user could open which seems to be quite a cool approach and I'm wondering if anyone has managed to use a MutationObserver to listen to the document as it's first loading/ parsing and make changes to the document before first render and before DOMContentLoaded?

This approach would then let me change the H1 content without having to hide it, change it, then show it.

I've attempted but failed so far and have just ended up reading about the to-be-obselete Mutation Events and wondering if I'm trying to do something that just isn't possible. However we've (not me) have managed to put a robot on Mars so I'm hoping I can solve this.

So is it possible to use MutationObservers to change the HTML content on-the-fly as the page is being loaded/ parsed?

Thanks for any help or any pointers.

Regards, Nick


Solution

  • 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 but supports complex selectors) or getElementsByTagName (extremely fast).

    const FIND = /\bfoo\b/gi;
    const TAG = 'h1'; 
    function onMutation(mutations, observer) {
      let stopped;
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          let el, tag = node.localName;
          if ((tag === TAG || !tag && (el = node.parentNode) && el.localName === TAG)
              && FIND.test(node.textContent)) {
            if (!stopped) stopped = !observer.disconnect();
            replaceText(node, !tag);
          } else if (tag && node.firstElementChild) { // has children
            for (el of node.getElementsByTagName(TAG)) {
              if (FIND.test(el.textContent)) {
                if (!stopped) stopped = !observer.disconnect();
                replaceText(el);
              }
            }
          }
        }
      }
      if (stopped) observer.observe(document, {childList: true, subtree: true});
    }
    
    function replaceText(el, elText) {
      const walker = !elText && document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
      for (let node; (node = elText ? el : walker.nextNode()); elText = false) {
        const old = node.nodeValue;
        const res = old.replace(FIND, 'bar');
        if (old !== res) node.nodeValue = res;
      }
    }
    

    Note if you target older browsers, forEach and filter and ES2015 for (val of array) could be very slow, 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);
        }
      }
    }