web #dom #javascript #lottie #bodymovin

The MutationObserver Interface

Jan 12 '19 · 4 min read · 1601

Listening for the existence of specified DOM elements using the MutationObserver interface.

Intro

T

here have been a few times that I have needed to wait until an specific element exists/ has finished being added to the DOM before executing code that depends on such element. However, I never really researched into different ways of achieving this, until recently when I needed it for working with Lottie/Bodymovin.js from Airbnb. There is an SO discussion about this too.

Check out this post to learn more about Lottie and Bodymovin

The problem that I had was with needing to change the viewbox attribute of an SVG rendered by Lottie, but having to wait until the SVG had been added to the DOM before being able to manipulate it using JavaScript.

The three methods for listening to the existence of an element in the DOM that I will discuss are:

  1. Polling using setTimeout() (expensive and slow, ✖)
  2. Mutation Events (deprecated, expensive and slow ✖)
  3. MutationObserver (yes please!, ✔)

Polling using setTimeout()

For most things, polling should only be used as a last resort or temporary solution. There is almost always a better solution.

The main issue is performance; poll too frequently and it will block the execution of the rest of your script; too slowly and there will be a delay between when the DOM element has actually been created and when your polling script detects that it has been created.

If you do need to poll, then you need to make sure that your polling function has a polling count limit, so that it does not run forever. See the code below for an example safe polling implementation:

Polling for an element safely

// Polling function
function pollForElement ({elementSelector, interval, pollCount, pollCountLimit}, callback) {
  var element = document.querySelector(elementSelector);

  if (pollCount > pollCountLimit)
    callback( new Error('Reached maximum pollCount before element was found.') ); // poll limit reached
  else if (element)
    callback(null); // Found
  else 
    nextPoll({elementSelector, interval, pollCount, pollCountLimit}, callback); // Not found. Poll again
};

function nextPoll ({elementSelector, interval, pollCount, pollCountLimit}, callback) {
  setTimeout( function () {
    pollCount ? pollCount++ : pollCount = 1;
    pollForElement({elementSelector, interval, pollCount, pollCountLimit}, callback);
  }, interval); 
};

// Options
var pollOptions = {
  elementSelector: '#test',
  interval: 200,
  pollCountLimit: 25
};

// Call the function
pollForElement(pollOptions, function (err) {
  if (err)
    return console.error(err);
  // Do something here when element is found
});

This function is recursive, but can easily be turned into an iterative function by looping up to the pollCountLimit, and breaking early if the specified element is found.

Mutation Events (Deprecated)

You can find the MDN documentation for Mutation Events here, which gives you a list of all the Mutation Events that you can listen for from the old DOM3 specification.

Mutation Events are a native JavaScript mechanism that used to be in the Web Standards for getting notified about changes to the DOM. Although some browsers - like Chrome - still support Mutation Events, it has been deprecated in favor of Mutation Observers. This means you should not use Mutation Events in production code, since support could be dropped at any time. However, for completeness we shall still discuss why it has been deprecated and look at how Mutation Events would be implemented as a comparison to the other methods in this post.

The DOMNodeInserted Mutation Event

Before diving into what the issues are specifically, let's take a look at how you can re-write the polling code using the DOMNodeInserted Mutation Event.

// Listen function
function listenForElement({container, selector}, callback) {
  // Setup the event handler
  function handler (e) {
    if (e.target === container.querySelector(selector)) {
      console.log('Found');
      container.removeEventListener('DOMNodeInserted', handler);
      callback();
    }
  }
  // Start listening
  container.addEventListener('DOMNodeInserted', handler);
};

// Options
var options = {
  container: document.querySelector('#test-container'),
  selector: '#test-element'
};

// Call the function to start listeneing
listenForElement(options, function () {
  // Do something here when element is found
});

The main differences with the polling code are that you are now using event listeners, and you add the listener on the container of the new element rather than the element itself. This reveals one of the first problems with Mutation Events: Event Propagation.

Issues with Mutation Events

Using Event Propagation means that all child nodes and their sub-trees could cause the event to fire, which is why you need a check in the event handler to make sure creation of the correct element fired the event, and not the insertion of a different child node. This could mean that the Mutation Event fires too often.

Furthermore, Event Listeners on a part of the DOM and Event Propagation prevents some User Agent run-time optimisations for that part of the DOM. This makes DOM modifications between 1.5 - 7 times slower.

To read more about the Mutation Event's flawed API design, consult the MDN documentation and the W3C archives.

MutationObserver

The MutationObserver Interface is the new method for watching changes to the DOM, which replaces Mutation Events. See the MDN documentation for more.

Using the MutationObserver Interface

// Function declaration
function waitForElement ({container, selector}, callback) {
  var observer = {
    'config': {
      childList: true,
      subtree: false
    },
    'callback': function (mutationsList, observer) {
      if (container.querySelector(selector)) {
        console.log('Found');
        observer.disconnect();
        callback();
      }
    }
  };
  // Create the the observer and start observing for DOM changes
  (new MutationObserver(observer.callback)).observe(container, observer.config);
};

// Function options
var options = {
  container: document.querySelector('#test-container'),
  selector: '#test'
};

// Call the function here to start observing changes to the DOM
waitForElement(options, function () {
  // Do something here
});

The implementation looks similar to Mutation Events; you start observing on the element container, there is a handler that is executed when an observation is made, and when the element is found we remove the observer in the same way that we removed the DOMNodeInserted event listener.

Looking at the 'config' object inside the 'observer' object above, we can now set whether we want to observe DOM changes on the sub-tree (childNodes of childNodes etc.), or only direct childNodes of a parent. This example hints at the extra features that the MutationObserver Interface affords you, but it can do a lot more. For example, you can configure the observer to only observe certain types of mutation (e.g. just childNodes, attributes etc.). Again, check out some documentation for more ways to make your observer more specialized.