JavaScript - MutationObserver

MutationObserver provides developers with a way to react to changes in a DOM.

Prevent image loading with MutationObserver

Edit: this is not actually reliable due to the way that various browsers will try to pre-emptively fetch resources as they parse an HTML document, as confirmed by Jake Archibald of the Chrome dev team.

It turns out that if you place a MutationObserver in the <head> (or presumably even in the <body> before any <img> elements), and have it start observing right away it will be able to remove the src before the browser downloads the image, thus allowing for lazy loading and other optimizations.

I put this in <head> and normal <img> tags in <body>. - No image requests visible in Firefox dev tools - Safari shows requests but 0 bytes transferred - Chrome seems to get 4 kb over the wire before calling it quits

Code language: JavaScript

const observer = new MutationObserver(mutations => {
  mutations.forEach(mutation => {
    Array.from(mutation.addedNodes)
      .filter(node => node.tagName === 'IMG')
      .forEach(img => {
        img.dataset.src = img.src;
        img.src = '';
      });
  });
});
 
observer.observe(document.documentElement, {
  childList: true,
  subtree: true
});

Real-World Uses for MutationObserver

MutationObserver is a lesser known JavaScript feature which allows you to detect when elements in a web page are inserted, changed or removed. It is still relatively new, but it is supported by every modern browser.

The web is full of demos and tutorials of MutationObserver, but it’s pretty hard to find examples of it actually being used in practice. Even a search of Github is almost all libraries and test cases. We’ve had a couple occasions to use it at Eager however, which I now have the opportunity to share.

Client-side Image Optimization

Believe it or not, it’s actually possible to swap the src’s of img tags before the browser begins to load them. We can use that to optimize our images without changing the HTML source of our page. This code uses a FireSize service to handle the actual optimization.

We start by setting up a MutationObserver which will call our checkNode function with any new nodes which are added to the DOM:

Code language: JavaScript

var observer = new MutationObserver(function(mutations){
  for (var i=0; i < mutations.length; i++){
    for (var j=0; j < mutations[i].addedNodes.length; j++){
      checkNode(mutations[i].addedNodes[j]);
    }
  }
});
 
observer.observe(document.documentElement, {
  childList: true,
  subtree: true
});

If we run this code early in the head of the page, it will call our checkNode function with each DOM node as the browser parses the page’s HTML. This gives us the ability to check or mutate these nodes before they’ve ever been rendered.

We can define our checkNode function to decide if this is an image for us to optimize.

Code language: JavaScript

checkNode = function(addedNode) {
  if (addedNode.nodeType === 1 && addedNode.tagName === 'IMG'){
    addedNode.src = optimizeSrc(addedNode.src)
  }
}

Finally, we can define optimizeSrc to switch out our image’s src for an optimized one:

Code language: JavaScript

optimizeSrc = function(src) {
  return "//firesize.com/" + src;
}

For a complete implementation, take a look at our FireSize app source code.

Initializing When An Element Becomes Available on the Page

It’s a common pattern to wait for jQuery.ready or DOMContentLoaded to initialize code which depends on elements on the page. Those events don’t fire until the entire DOM has loaded however, meaning the page will start to be rendered before you have a chance to change or add to its content.

Our pattern from the image optimization solution also works for detecting when any element becomes available, allowing you to initialize code which depends on that element at the exact first moment it’s possible. We can redefine checkNode to instead check if our element matches an arbitrary selector:

Code language: JavaScript

checkNode = function(addedNode) {
  if (addedNode.nodeType === 1){
    if (addedNode.matches('.should-underline')){
      SmartUnderline.init(addedNode);
    }
  }
}