By Nick Middleweek


2016-09-02 23:22:58 8 Comments

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

2 comments

@wOxxOm 2016-09-05 16:11:36

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);
    }
  }
}

@Nick Middleweek 2016-09-06 07:18:46

Nice, thank you. I'll check this out this evening. Good stuff.

@Nick Middleweek 2016-09-06 19:38:28

Hi, thanks again for this reply... I'm trying to get my head around the use of the TreeWalker - you said it's to preserve any event listeners attached to sub-elements of the h1 tag and so it doesn't trigger a new mutation but am I wrong in thinking by changing the value of a text node without a TreeWalker, it also wouldn't trigger a new mutation anyway because of the filters used nor would it interfere with any event handlers - we are just changing the textNode.nodeValue? Cheers

@wOxxOm 2016-09-06 20:56:34

Try to guess which child node is the correct text node without enumerating them recursively: 1) <h1>first <span>second <a>third</a></span></h1> and 2) <h1><span>first</span> second <a>third</a></h1>

@Nick Middleweek 2016-09-07 23:03:24

Thanks for the intro to the TreeWalkers here, nice... I get that it can navigate the text-nodes and check each child-nodes text value but the comment about event-listeners and not triggering another Mutation - I'm assuming that was for another answer somewhere? Without being pedantic, I'm just making sure I fully understand. Cheers.

@wOxxOm 2016-09-08 05:16:04

Not triggering another mutation is an optimization and a precaution that eliminates the need to check whether the node that still matches the main condition was altered moments ago by our code.

@Beau 2016-09-07 00:14:03

I do A/B testing for a living and I use MutationObservers fairly often with good results, but far more often I just do long polling which is actually what most of the 3rd party platforms do under the hood when you use their WYSIWYG (or sometimes even their code editors). A 50 millisecond loop shouldn't slow down the page or cause FOUC.

I generally use a simple pattern like:

var poller = setInterval(function(){
  if(document.querySelector('#question-header') !== null) {
    clearInterval(poller);

    //Do something
  }
}, 50);

You can get any DOM element using a sizzle selector like you might in jQuery with document.querySelector, which is sometimes the only thing you need a library for anyway.

In fact we do this so often at my job that we have a build process and a module library which includes a function called When which does exactly what you're looking for. That particular function checks for jQuery as well as the element, but it would be trivial to modify the library not to rely on jQuery (we rely on jQuery since it's on most of our client's sites and we use it for lots of stuff).

Speaking of 3rd party testing platforms and javascript libraries, depending on the implementation a lot of the platforms out there (like Optimizely, Qubit, and I think Monetate) bundle a version of jQuery (sometime trimmed down) which is available immediately when executing your code, so that's something to look into if you're using a 3rd party platform.

@Nick Middleweek 2016-09-07 19:03:51

Thanks for this @Beau - I've used this pattern before but on occasions it hasn't been bullet-proof. I'll give it another go in this situation and report back, however, I think I'm starting to prefer to use natural browser events even if it does reduce the target audience slightly. (jQuery - We're not merging that in with our snippet, we have that loaded beforehand). Cheers.

@wOxxOm 2016-09-08 05:25:12

FYI 50ms is 3 frames on 60fps used by browsers to paint pages. That's a huge gap (actually 1 frame gap is also noticeable). And there's even a bigger problem: timer callback may be delayed randomly during CPU-intensive complex page load (I've seen 500ms delays).

Related Questions

Sponsored Content

16 Answered Questions

[SOLVED] How do I modify the URL without reloading the page?

22 Answered Questions

[SOLVED] How to make JavaScript execute after page load?

18 Answered Questions

[SOLVED] How to reload a page using JavaScript

  • 2010-09-15 06:03:09
  • Resh
  • 941780 View
  • 786 Score
  • 18 Answer
  • Tags:   javascript reload

19 Answered Questions

[SOLVED] Detect when browser receives file download

27 Answered Questions

[SOLVED] How can I refresh a page with jQuery?

30 Answered Questions

[SOLVED] How to change an element's class with JavaScript?

  • 2008-10-12 20:06:43
  • Nathan Smith
  • 2507853 View
  • 2678 Score
  • 30 Answer
  • Tags:   javascript html dom

10 Answered Questions

11 Answered Questions

[SOLVED] How to change the href for a hyperlink using jQuery

5 Answered Questions

6 Answered Questions

Sponsored Content