While driving my kids to school this morning, I had an interesting thought. Is it possible for a web component to recognize, and respond, when its inner DOM contents have changed? Turns out of course it is, and the answer isn't really depenedant on web components, but is a baked-in part of the web platform, the MutationObserver. Here's what I built as a way to test it out.
The Initial Web Component
I began with a simple web component that had the following simple feature - count the number of images inside it and report. So we can start with this HTML:
<img-counter>
<p>
<img src="https://placehold.co/60x40">
</p>
<div>
<img src="https://placehold.co/40x40">
</div>
<img src="https://placehold.co/90x90">
</img-counter>
And build a simple component:
class ImgCounter extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
let imgs = this.querySelectorAll('img');
this.innerHTML += `<p>There are <strong>${imgs.length}</strong> images in me.</p>`;
}
}
if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);
It just uses querySelectorAll
to count the img
node inside it. For my initial HTML, this reports 3 of course.
I then added a simple button to my HTML:
<button id="testAdd">Add Img</button>
And a bit of code:
document.querySelector('#testAdd').addEventListener('click', () => {
document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;
});
When run, it will add a new image, but obviously, the counter won't update. Here's a CodePen of this initial version:
See the Pen Img Counter WC 1 by Raymond Camden (@cfjedimaster) on CodePen.
Enter - the MutationObserver
The MDN docs on MutationObserver are pretty good, as always. I won't repeat what's written there but the basics are:
- Define what you want to observe under a DOM element - which includes the subtree, childList, and attributes
- Write your callback
- Define the observer based on the callback
- Tie the observer to the DOM root you want to watch
So my thinking was...
- Move out my 'count images and update display' to a function
- Add a mutation observer and when things change, re-run the new function
My first attempt was rather naive, but here it is in code form, not CodePen, for reasons that will be clear soon:
class ImgCounter extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.renderCount();
const mutationObserver = (mutationList, observer) => {
this.renderCount();
};
const observer = new MutationObserver(mutationObserver);
observer.observe(this, {
childList: true, subtree: true
});
}
renderCount() {
let imgs = this.querySelectorAll('img');
this.innerHTML += `<div><p>There are <strong>${imgs.length}</strong> images in me.</p></div>`;
}
}
So the MutationObserver callback is sent information about what changed, and in my simple little mind, I figured, I don't care. If something changes, just rerun the count to count images.
Look at that code and see if you can figure out the issue. If you can, leave me a comment below.
So yes, this "worked", but this is what happened:
- I clicked the button to add a new image
- The mutation observer fired and was like, cool, new shit to do, run
renderCount
renderCount
got the images and updated the HTML to reflect the new count- Hey guess what,
renderCount
changed the DOM tree, let's run the observer again - Repeat until the heat death of the universe
I had to tweak things a bit, but here's the final version, and I'll explain what I did:
class ImgCounter extends HTMLElement {
#myObserver;
constructor() {
super();
}
connectedCallback() {
// create the div we'll use to monitor images:
this.innerHTML += '<div id="imgcountertext"></div>';
this.renderCount();
const mutationObserver = (mutationList, observer) => {
for(const m of mutationList) {
if(m.target === this) {
this.renderCount();
}
}
};
this.myObserver = new MutationObserver(mutationObserver);
this.myObserver.observe(this, {
childList: true, subtree: true
});
}
disconnectedCallback() {
this.myObserver.disconnect();
}
renderCount() {
let imgs = this.querySelectorAll('img');
this.querySelector('#imgcountertext').innerHTML = `There are <strong>${imgs.length}</strong> images in me.`;
}
}
if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);
document.querySelector('#testAdd').addEventListener('click', () => {
document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;
});
I initially had said I didn't care about what was in the list of items changed in the mutation observer, but I noticed that the target
value was different when I specifically added my image count report. To help with this, I'm now using a div
tag with an ID and renderCount
modifies that.
When a new image (or anything) is added directly inside the component, my target value is img-counter
, or this
, which means I can run renderCount
on it. When renderCount
runs, the target of the mutation is its own div.
Also, I noticed that the MutationObserver talks specifically called out the disconnect
method as a way of ending the DOM observation. That feels pretty important, and web components make it easy with the disconnectedCallback
method.
All in all, it works well now (as far as I know ;), and you can test it yourself below:
See the Pen Img Counter WC 2 by Raymond Camden (@cfjedimaster) on CodePen.
Remember, MutationObserver can absolutely be used outside of web components. Also note that if you only want to respond to an attribute change in a web component, that's really easy as it's baked into the spec. As always, let me know what you think, and I've got a strong feeling that someone going to show me a better way of doing this, and I'd be happy to see it!