July 11, 2020

What Is A Mutation Observer?

Observing Mutations

I don't know why, but saying this sounds like coming from a mad scientist.

According to MDN:

The MutationObserver interface provides the ability to watch for changes being made to the DOM tree.

If something is expected to be added to the DOM but not yet targetable, you can wait for the change/mutation to occur. Once the mutation has been observed you can safely continue to manipulate this further.

One example is when a third-party plugin is dynamically adding elements mixed with content not originally in the source code. If you know where your target element is going to be, you can instead target its parent first to watch any new changes within. Here is our example source code with nothing but empty div elements.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>MutationObserver</title>
  </head>
  <body>
    <div class="pokemon" name="bulbasaur"></div>
    <div class="pokemon" name="squirtle"></div>
    <div class="pokemon" name="charmander"></div>
    <div class="pokemon" name="pikachu"></div>
    <div class="pokemon" name="ditto"></div>
    <div class="pokemon" name="eevee"></div>
    <div class="pokemon" name="mewtwo"></div>
    <div class="pokemon" name="jigglypuff"></div>
    <div class="pokemon" name="snorlax"></div>
    <div class="pokemon" name="gastly"></div>
    <script src="bundle.js" charset="utf-8"></script>
  </body>
</html>

We're going to pretend a little and say this third party script (called pokemon.js) will go through each div.pokemon and will render new children. I added a timeout in this script to make sure that there is a significant delay to simulate a real life scenario. Here's what that third party script looks like.

async function renderPokemon(el, requiresProxy) {
  const pokemonName = el.getAttribute('name');
  let url = 'https://pokeapi.co/api/v2/pokemon/' + pokemonName;
  
  requiresProxy = requiresProxy || false;
  requiresProxy ? url = 'https://cors-anywhere.herokuapp.com/' + url : url = url;

  const resp = await fetch(url);
  const json = await resp.json();

  setTimeout(function addInitialData () {
    const name = document.createElement('h1');
    const abilityTitle = document.createElement('h2');
    const abilityList = document.createElement('ul');

    name.textContent = pokemonName.charAt(0).toUpperCase() + pokemonName.slice(1);
    name.class = 'name';

    abilityTitle.textContent = 'Abilities';

    json.abilities.forEach(a => {
      const li = document.createElement('li');

      const span = document.createElement('span');
      span.textContent = a.ability.name.charAt(0).toUpperCase() + a.ability.name.slice(1) + ':';
      span.setAttribute('ability', a.ability.url);

      li.insertAdjacentElement('beforeend', span);
      abilityList.insertAdjacentElement('beforeend', li);
    });

    el.insertAdjacentElement('beforeend', name);
    el.insertAdjacentElement('beforeend', abilityTitle);
    el.insertAdjacentElement('beforeend', abilityList);

  }, Math.random() * 2000);

}

const pokemon = document.querySelectorAll(`.pokemon`);
if (pokemon) {
  pokemon.forEach(p => {
    renderPokemon(p); // only one param is required, add true if you need a proxy
  });
}

Here's a screenshot of what that looks like when executed.

Fetched Pokemon, Missing Ability Info

As you can see, everything was successful except the data for "Abilities" are missing for each character. Let's see what's going on in this fetched endpoint.

{
  "abilities": [
    {
      "ability": {
        "name": "limber",
        "url": "https://pokeapi.co/api/v2/ability/7/"
      },
      "is_hidden": false,
      "slot": 1
    },
    {
      "ability": {
        "name": "imposter",
        "url": "https://pokeapi.co/api/v2/ability/150/"
      },
      "is_hidden": true,
      "slot": 3
    }
  ],
  ...

The data for abilities is available somewhere else. This wouldn't be an issue, but we can't immediately manipulate the DOM to offer this because the elements for "Abilities" are rendered at a later time. And we can't directly improve this script to do this for us because it comes from a third party. So a mutation observer is recommended.

Here's a simple setup in our own index.js file.

const observer = new MutationObserver (mutations => {
  mutations.forEach(mutation => {
    if (mutation.addedNodes.length) {
      console.log('Node Added', mutation.addedNodes[0]);
      // if the node added has a span[ability]
      // fetch ability info
      // add data to span
    }
    if (mutation.removedNodes.length) {
      console.log('Node Removed', mutation.removedNodes[0]);
    }
  });
});

const pokemon = document.querySelectorAll('.pokemon');
if (pokemon) {
  // loop through each pokemon and observe any changes
  // if new child appended check for span[ability], add ability info when ready
  // disconnect observer when finished
}

We construct a new mutation observer (called observer). For each target element (.pokemon), we use the observe method to tell this new mutation observer we want to look out for new children by setting childList: true. Every time a new child element is appended to its parent a mutation will trigger. Inside this new observer, we can start doing what we need to.

Here in index.js we write a new function to fetch planet info once, which is very similar to renderPokemon.

async function fetchAbilityInfo(p, requiresProxy) {
  let url = p.getAttribute('ability');

  requiresProxy = requiresProxy || false;
  requiresProxy ? url = 'https://cors-anywhere.herokuapp.com/' + url : url = url;

  const resp = await fetch(url);
  const json = await resp.json();

  for (let i = 0; i < json.flavor_text_entries.length; i++) {
    let obj = json.flavor_text_entries[i];
    if (obj.language.name == 'en') {
      p.textContent = p.textContent + ' ' + obj.flavor_text;
      break;
    }
  }
};

Now we can invoke this function, loop through pokemon to observe any new elements.

const pokemon = document.querySelectorAll('.pokemon');
if (pokemon) {
  pokemon.forEach(p => {
    observer.observe(p, {
      childList: true
    });
  });

  setTimeout(function disconnect() {
    console.log('disconnecting mutation observer');
    observer.disconnect();
  }, 10000);
}

Be sure to disconnect the mutation observer when you're finished.

Within the observer's conditional, here's how we can manipulate the element when planetInfo is available.

if (mutation.addedNodes.length) {
  console.log('Node Added', mutation.addedNodes[0]);
  if (mutation.addedNodes[0].querySelector('span')) {
    if (mutation.addedNodes[0].querySelector('span').hasAttribute('ability')) {
      mutation.addedNodes[0].querySelectorAll('span').forEach(e => {
        fetchAbilityInfo(e);
        console.log('Node Updated', e);
      });
    }
  }
}

Here's the final result!

Fetched Pokemon & Ability Info

This is just scratching the surface. Mutation observers are capable of a lot more than this. If you want to follow along, I have the code available on Github.

Copyright © 2020. Jake Birkes