1

Responding to HTML Changes in a Web Component

 1 month ago
source link: https://www.raymondcamden.com/2024/03/13/responding-to-html-changes-in-a-web-component
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Responding to HTML Changes in a Web Component

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:

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:

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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK