How to change the HTML content as it's loading on the page
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);
}
}
}
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.