JavaScript Mutation Observer API: Monitoring DOM Changes
Master the Mutation Observer API to efficiently detect and respond to DOM mutations. Learn to track attribute changes, node additions/removals, and text updates.
The Mutation Observer API provides a way to watch for changes being made to the DOM tree. It's designed to replace the older Mutation Events feature and offers better performance and more flexibility for monitoring DOM mutations.
Understanding Mutation Observer
Mutation Observer allows you to observe changes to elements, their attributes, text content, and child nodes without impacting performance like the deprecated mutation events.
Basic Mutation Observer Setup
// Create a MutationObserver instance
const observer = new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
console.log('Mutation detected:', mutation);
console.log('Type:', mutation.type);
console.log('Target:', mutation.target);
if (mutation.type === 'childList') {
console.log('Added nodes:', mutation.addedNodes);
console.log('Removed nodes:', mutation.removedNodes);
} else if (mutation.type === 'attributes') {
console.log('Attribute name:', mutation.attributeName);
console.log('Old value:', mutation.oldValue);
} else if (mutation.type === 'characterData') {
console.log('Old text:', mutation.oldValue);
}
});
});
// Configuration options
const config = {
childList: true, // Observe child node additions/removals
attributes: true, // Observe attribute changes
characterData: true, // Observe text content changes
subtree: true, // Observe all descendants
attributeOldValue: true, // Record previous attribute values
characterDataOldValue: true, // Record previous text content
attributeFilter: ['class', 'id', 'style'], // Only observe specific attributes
};
// Start observing
const targetNode = document.getElementById('target');
observer.observe(targetNode, config);
// Stop observing
observer.disconnect();
// Get pending mutations and clear the queue
const records = observer.takeRecords();
console.log('Pending mutations:', records);
Mutation Types and Records
// Detailed mutation handling
class MutationHandler {
constructor() {
this.observer = new MutationObserver(this.handleMutations.bind(this));
}
handleMutations(mutations) {
mutations.forEach((mutation) => {
switch (mutation.type) {
case 'childList':
this.handleChildListMutation(mutation);
break;
case 'attributes':
this.handleAttributeMutation(mutation);
break;
case 'characterData':
this.handleCharacterDataMutation(mutation);
break;
}
});
}
handleChildListMutation(mutation) {
// Handle added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('Element added:', node.tagName);
this.onElementAdded(node);
} else if (node.nodeType === Node.TEXT_NODE) {
console.log('Text node added:', node.textContent);
}
});
// Handle removed nodes
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('Element removed:', node.tagName);
this.onElementRemoved(node);
}
});
// Check position changes
if (mutation.previousSibling) {
console.log('Previous sibling:', mutation.previousSibling);
}
if (mutation.nextSibling) {
console.log('Next sibling:', mutation.nextSibling);
}
}
handleAttributeMutation(mutation) {
const { target, attributeName, oldValue } = mutation;
const newValue = target.getAttribute(attributeName);
console.log(`Attribute '${attributeName}' changed`);
console.log(`Old value: ${oldValue}`);
console.log(`New value: ${newValue}`);
// Handle specific attributes
switch (attributeName) {
case 'class':
this.onClassChange(target, oldValue, newValue);
break;
case 'style':
this.onStyleChange(target, oldValue, newValue);
break;
case 'data-state':
this.onStateChange(target, oldValue, newValue);
break;
}
}
handleCharacterDataMutation(mutation) {
const { target, oldValue } = mutation;
const newValue = target.textContent;
console.log('Text content changed');
console.log(`Old: "${oldValue}"`);
console.log(`New: "${newValue}"`);
this.onTextChange(target, oldValue, newValue);
}
// Custom handlers
onElementAdded(element) {
// Initialize new elements
if (element.classList.contains('lazy-load')) {
this.initLazyLoading(element);
}
}
onElementRemoved(element) {
// Cleanup for removed elements
this.cleanup(element);
}
onClassChange(element, oldClasses, newClasses) {
const oldList = oldClasses ? oldClasses.split(' ') : [];
const newList = newClasses ? newClasses.split(' ') : [];
const added = newList.filter((cls) => !oldList.includes(cls));
const removed = oldList.filter((cls) => !newList.includes(cls));
console.log('Classes added:', added);
console.log('Classes removed:', removed);
}
onStyleChange(element, oldStyle, newStyle) {
// Parse style changes
const oldStyles = this.parseInlineStyle(oldStyle);
const newStyles = this.parseInlineStyle(newStyle);
Object.keys(newStyles).forEach((prop) => {
if (oldStyles[prop] !== newStyles[prop]) {
console.log(`Style ${prop}: ${oldStyles[prop]} → ${newStyles[prop]}`);
}
});
}
onStateChange(element, oldState, newState) {
console.log(`State transition: ${oldState} → ${newState}`);
}
onTextChange(node, oldText, newText) {
// Handle text updates
}
parseInlineStyle(styleStr) {
const styles = {};
if (!styleStr) return styles;
styleStr.split(';').forEach((rule) => {
const [property, value] = rule.split(':').map((s) => s.trim());
if (property && value) {
styles[property] = value;
}
});
return styles;
}
observe(target, config) {
this.observer.observe(target, config);
}
disconnect() {
this.observer.disconnect();
}
initLazyLoading(element) {
console.log('Initializing lazy loading for:', element);
}
cleanup(element) {
console.log('Cleaning up:', element);
}
}
Practical Applications
Dynamic Content Loading Detection
// Auto-initialize components when added to DOM
class ComponentAutoInitializer {
constructor() {
this.componentMap = new Map([
['data-carousel', this.initCarousel],
['data-tooltip', this.initTooltip],
['data-modal', this.initModal],
['data-tabs', this.initTabs],
]);
this.initialized = new WeakSet();
this.observer = null;
this.init();
}
init() {
// Initialize existing components
this.initializeAll(document.body);
// Watch for new components
this.observer = new MutationObserver((mutations) => {
const addedElements = new Set();
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
addedElements.add(node);
// Check descendants
const descendants = node.querySelectorAll('*');
descendants.forEach((desc) => addedElements.add(desc));
}
});
}
});
// Initialize new components
addedElements.forEach((element) => {
this.initializeElement(element);
});
});
this.observer.observe(document.body, {
childList: true,
subtree: true,
});
}
initializeAll(root) {
this.componentMap.forEach((initFn, attribute) => {
const elements = root.querySelectorAll(`[${attribute}]`);
elements.forEach((element) => {
this.initializeElement(element);
});
});
}
initializeElement(element) {
// Skip if already initialized
if (this.initialized.has(element)) return;
// Check each component type
this.componentMap.forEach((initFn, attribute) => {
if (element.hasAttribute(attribute)) {
console.log(`Initializing ${attribute} component:`, element);
initFn.call(this, element);
this.initialized.add(element);
}
});
}
initCarousel(element) {
const options = JSON.parse(element.dataset.carousel || '{}');
// Initialize carousel library
console.log('Carousel initialized with options:', options);
}
initTooltip(element) {
const text = element.dataset.tooltip;
// Initialize tooltip
console.log('Tooltip initialized:', text);
}
initModal(element) {
const modalId = element.dataset.modal;
// Initialize modal
console.log('Modal initialized:', modalId);
}
initTabs(element) {
const tabsConfig = element.dataset.tabs;
// Initialize tabs
console.log('Tabs initialized:', tabsConfig);
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
// Form validation observer
class FormValidationObserver {
constructor(form) {
this.form = form;
this.fields = new Map();
this.observer = null;
this.init();
}
init() {
// Set up initial field tracking
this.form.querySelectorAll('input, textarea, select').forEach((field) => {
this.trackField(field);
});
// Watch for form changes
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Handle added fields
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (this.isFormField(node)) {
this.trackField(node);
}
// Check descendants
node
.querySelectorAll('input, textarea, select')
.forEach((field) => {
this.trackField(field);
});
}
});
// Handle removed fields
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (this.isFormField(node)) {
this.untrackField(node);
}
// Check descendants
node
.querySelectorAll('input, textarea, select')
.forEach((field) => {
this.untrackField(field);
});
}
});
} else if (mutation.type === 'attributes') {
// Handle attribute changes on form fields
if (this.isFormField(mutation.target)) {
this.handleFieldAttributeChange(mutation);
}
}
});
});
this.observer.observe(this.form, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'required',
'pattern',
'min',
'max',
'minlength',
'maxlength',
'disabled',
],
});
}
isFormField(element) {
return element.matches('input, textarea, select');
}
trackField(field) {
if (this.fields.has(field)) return;
const validation = {
rules: this.extractValidationRules(field),
isValid: true,
errors: [],
};
this.fields.set(field, validation);
// Add event listeners
field.addEventListener('input', () => this.validateField(field));
field.addEventListener('blur', () => this.validateField(field));
console.log('Tracking field:', field.name, validation.rules);
}
untrackField(field) {
this.fields.delete(field);
console.log('Untracking field:', field.name);
}
extractValidationRules(field) {
const rules = {};
if (field.required) rules.required = true;
if (field.pattern) rules.pattern = field.pattern;
if (field.minLength >= 0) rules.minLength = field.minLength;
if (field.maxLength >= 0) rules.maxLength = field.maxLength;
if (field.min) rules.min = field.min;
if (field.max) rules.max = field.max;
if (field.type === 'email') rules.email = true;
if (field.type === 'url') rules.url = true;
return rules;
}
handleFieldAttributeChange(mutation) {
const field = mutation.target;
const validation = this.fields.get(field);
if (!validation) return;
// Update validation rules
validation.rules = this.extractValidationRules(field);
console.log(
`Validation rules updated for ${field.name}:`,
validation.rules
);
// Revalidate with new rules
this.validateField(field);
}
validateField(field) {
const validation = this.fields.get(field);
if (!validation) return;
validation.errors = [];
validation.isValid = true;
const value = field.value;
const rules = validation.rules;
// Apply validation rules
if (rules.required && !value) {
validation.errors.push('This field is required');
}
if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) {
validation.errors.push('Invalid format');
}
if (value && rules.minLength && value.length < rules.minLength) {
validation.errors.push(`Minimum length is ${rules.minLength}`);
}
if (value && rules.maxLength && value.length > rules.maxLength) {
validation.errors.push(`Maximum length is ${rules.maxLength}`);
}
validation.isValid = validation.errors.length === 0;
// Update UI
this.updateFieldUI(field, validation);
}
updateFieldUI(field, validation) {
field.classList.toggle('is-valid', validation.isValid);
field.classList.toggle('is-invalid', !validation.isValid);
// Update error message
const errorElement = field.parentElement.querySelector('.error-message');
if (errorElement) {
errorElement.textContent = validation.errors.join(', ');
}
}
isFormValid() {
let isValid = true;
this.fields.forEach((validation, field) => {
this.validateField(field);
if (!validation.isValid) {
isValid = false;
}
});
return isValid;
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
Virtual DOM Diffing
// Simple virtual DOM implementation with MutationObserver
class VirtualDOM {
constructor(container) {
this.container = container;
this.vdom = null;
this.observer = null;
this.ignoreNextMutation = false;
this.setupObserver();
}
setupObserver() {
this.observer = new MutationObserver((mutations) => {
if (this.ignoreNextMutation) {
this.ignoreNextMutation = false;
return;
}
console.warn('External DOM mutation detected:', mutations);
// Could reconcile external changes or warn about them
});
this.observer.observe(this.container, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
});
}
createElement(type, props, ...children) {
return {
type,
props: props || {},
children: children.flat(),
};
}
render(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
return document.createTextNode(vnode);
}
const element = document.createElement(vnode.type);
// Set properties
Object.entries(vnode.props).forEach(([key, value]) => {
if (key.startsWith('on')) {
const event = key.substring(2).toLowerCase();
element.addEventListener(event, value);
} else if (key === 'className') {
element.className = value;
} else if (key === 'style' && typeof value === 'object') {
Object.assign(element.style, value);
} else {
element.setAttribute(key, value);
}
});
// Render children
vnode.children.forEach((child) => {
element.appendChild(this.render(child));
});
return element;
}
diff(oldVNode, newVNode) {
// No old node - create new
if (!oldVNode) {
return { type: 'CREATE', newVNode };
}
// No new node - remove old
if (!newVNode) {
return { type: 'REMOVE' };
}
// Different types - replace
if (typeof oldVNode !== typeof newVNode) {
return { type: 'REPLACE', newVNode };
}
// Text nodes
if (typeof newVNode === 'string' || typeof newVNode === 'number') {
if (oldVNode !== newVNode) {
return { type: 'TEXT', newVNode };
}
return null;
}
// Different element types
if (oldVNode.type !== newVNode.type) {
return { type: 'REPLACE', newVNode };
}
// Same type - check props and children
const propPatches = this.diffProps(oldVNode.props, newVNode.props);
const childPatches = this.diffChildren(
oldVNode.children,
newVNode.children
);
if (propPatches || childPatches.length > 0) {
return { type: 'UPDATE', propPatches, childPatches };
}
return null;
}
diffProps(oldProps, newProps) {
const patches = {};
let hasPatches = false;
// Check for changed/added props
Object.keys(newProps).forEach((key) => {
if (oldProps[key] !== newProps[key]) {
patches[key] = newProps[key];
hasPatches = true;
}
});
// Check for removed props
Object.keys(oldProps).forEach((key) => {
if (!(key in newProps)) {
patches[key] = undefined;
hasPatches = true;
}
});
return hasPatches ? patches : null;
}
diffChildren(oldChildren, newChildren) {
const patches = [];
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
patches.push(this.diff(oldChildren[i], newChildren[i]));
}
return patches;
}
patch(element, patches, vnode) {
if (!patches) return;
this.ignoreNextMutation = true;
switch (patches.type) {
case 'CREATE':
element.appendChild(this.render(patches.newVNode));
break;
case 'REMOVE':
element.remove();
break;
case 'REPLACE':
const newElement = this.render(patches.newVNode);
element.parentNode.replaceChild(newElement, element);
break;
case 'TEXT':
element.textContent = patches.newVNode;
break;
case 'UPDATE':
// Update props
if (patches.propPatches) {
this.patchProps(element, patches.propPatches);
}
// Update children
if (patches.childPatches) {
patches.childPatches.forEach((childPatch, i) => {
if (childPatch) {
this.patch(element.childNodes[i], childPatch, vnode.children[i]);
}
});
}
break;
}
}
patchProps(element, propPatches) {
Object.entries(propPatches).forEach(([key, value]) => {
if (value === undefined) {
element.removeAttribute(key);
} else if (key.startsWith('on')) {
// Handle event listeners
const event = key.substring(2).toLowerCase();
// Note: This is simplified - real implementation would track old listeners
} else if (key === 'className') {
element.className = value;
} else if (key === 'style' && typeof value === 'object') {
Object.assign(element.style, value);
} else {
element.setAttribute(key, value);
}
});
}
update(newVNode) {
const patches = this.diff(this.vdom, newVNode);
if (patches) {
this.patch(this.container, patches, newVNode);
}
this.vdom = newVNode;
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
Content Security Monitoring
// Monitor for XSS attempts and unsafe content
class ContentSecurityMonitor {
constructor(options = {}) {
this.options = {
checkScripts: true,
checkIframes: true,
checkLinks: true,
checkStyles: true,
whitelist: [],
...options,
};
this.observer = null;
this.violations = [];
this.init();
}
init() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.checkElement(node);
// Check all descendants
node.querySelectorAll('*').forEach((child) => {
this.checkElement(child);
});
} else if (node.nodeType === Node.TEXT_NODE) {
this.checkTextNode(node);
}
});
} else if (mutation.type === 'attributes') {
this.checkAttribute(mutation);
}
});
});
this.observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true,
attributeOldValue: true,
});
}
checkElement(element) {
const tagName = element.tagName.toLowerCase();
// Check for script elements
if (this.options.checkScripts && tagName === 'script') {
if (!this.isWhitelisted(element.src)) {
this.reportViolation('script', element);
element.remove();
}
}
// Check for iframes
if (this.options.checkIframes && tagName === 'iframe') {
if (!this.isWhitelisted(element.src)) {
this.reportViolation('iframe', element);
element.remove();
}
}
// Check for suspicious links
if (this.options.checkLinks && tagName === 'a') {
const href = element.getAttribute('href');
if (href && this.isSuspiciousLink(href)) {
this.reportViolation('suspicious-link', element);
element.removeAttribute('href');
}
}
// Check for inline styles
if (this.options.checkStyles && element.style.cssText) {
if (this.containsSuspiciousStyles(element.style.cssText)) {
this.reportViolation('suspicious-style', element);
element.removeAttribute('style');
}
}
// Check event handlers
Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith('on')) {
this.reportViolation('inline-event-handler', element, attr.name);
element.removeAttribute(attr.name);
}
});
}
checkAttribute(mutation) {
const { target, attributeName, oldValue } = mutation;
const newValue = target.getAttribute(attributeName);
// Check for javascript: URLs
if (attributeName === 'href' || attributeName === 'src') {
if (newValue && this.isSuspiciousLink(newValue)) {
this.reportViolation('suspicious-attribute', target, attributeName);
target.removeAttribute(attributeName);
return;
}
}
// Check for event handlers
if (attributeName.startsWith('on')) {
this.reportViolation('inline-event-handler', target, attributeName);
target.removeAttribute(attributeName);
return;
}
// Check style attribute
if (attributeName === 'style' && this.options.checkStyles) {
if (this.containsSuspiciousStyles(newValue)) {
this.reportViolation('suspicious-style-attribute', target);
target.removeAttribute('style');
}
}
}
checkTextNode(node) {
const text = node.textContent;
// Check for script tags in text
if (/<script[\s>]/i.test(text)) {
this.reportViolation('script-in-text', node.parentElement);
node.textContent = text.replace(
/<script[\s\S]*?<\/script>/gi,
'[REMOVED]'
);
}
}
isWhitelisted(url) {
if (!url) return false;
return this.options.whitelist.some((pattern) => {
if (pattern instanceof RegExp) {
return pattern.test(url);
}
return url.startsWith(pattern);
});
}
isSuspiciousLink(url) {
const suspicious = [/^javascript:/i, /^data:.*script/i, /^vbscript:/i];
return suspicious.some((pattern) => pattern.test(url));
}
containsSuspiciousStyles(styles) {
const suspicious = [
/expression\s*\(/i,
/javascript:/i,
/@import/i,
/behavior:/i,
];
return suspicious.some((pattern) => pattern.test(styles));
}
reportViolation(type, element, details = '') {
const violation = {
type,
timestamp: Date.now(),
element: element.outerHTML.substring(0, 200),
tagName: element.tagName,
details,
location: this.getElementPath(element),
};
this.violations.push(violation);
console.warn('Security violation detected:', violation);
// Dispatch event
window.dispatchEvent(
new CustomEvent('security-violation', {
detail: violation,
})
);
}
getElementPath(element) {
const path = [];
let current = element;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
} else if (current.className) {
selector += `.${current.className.split(' ').join('.')}`;
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ');
}
getViolations() {
return [...this.violations];
}
clearViolations() {
this.violations = [];
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
Live Collection Updates
// Keep live collections synchronized with DOM changes
class LiveCollectionManager {
constructor() {
this.collections = new Map();
this.observer = null;
this.init();
}
init() {
this.observer = new MutationObserver((mutations) => {
const affectedSelectors = new Set();
mutations.forEach((mutation) => {
// Determine which selectors might be affected
this.collections.forEach((collection, selector) => {
if (this.mutationAffectsSelector(mutation, selector)) {
affectedSelectors.add(selector);
}
});
});
// Update affected collections
affectedSelectors.forEach((selector) => {
this.updateCollection(selector);
});
});
this.observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true,
attributeFilter: ['class', 'id', 'data-*'],
});
}
createLiveCollection(selector, onChange) {
const collection = {
selector,
elements: new Set(),
onChange,
callbacks: new Set(),
};
this.collections.set(selector, collection);
this.updateCollection(selector);
return {
get elements() {
return Array.from(collection.elements);
},
get size() {
return collection.elements.size;
},
forEach(callback) {
collection.elements.forEach(callback);
},
subscribe(callback) {
collection.callbacks.add(callback);
return () => collection.callbacks.delete(callback);
},
destroy() {
this.collections.delete(selector);
},
};
}
updateCollection(selector) {
const collection = this.collections.get(selector);
if (!collection) return;
const oldElements = new Set(collection.elements);
const newElements = new Set(document.querySelectorAll(selector));
const added = [];
const removed = [];
// Find added elements
newElements.forEach((element) => {
if (!oldElements.has(element)) {
added.push(element);
}
});
// Find removed elements
oldElements.forEach((element) => {
if (!newElements.has(element)) {
removed.push(element);
}
});
// Update collection
collection.elements = newElements;
// Notify if changed
if (added.length > 0 || removed.length > 0) {
const change = { added, removed, elements: Array.from(newElements) };
if (collection.onChange) {
collection.onChange(change);
}
collection.callbacks.forEach((callback) => {
callback(change);
});
}
}
mutationAffectsSelector(mutation, selector) {
// This is a simplified check - a real implementation would use a CSS parser
if (mutation.type === 'childList') {
return true; // Any DOM structure change could affect any selector
}
if (mutation.type === 'attributes') {
const { target, attributeName } = mutation;
// Check if selector uses this attribute
if (attributeName === 'class' && selector.includes('.')) {
return true;
}
if (attributeName === 'id' && selector.includes('#')) {
return true;
}
if (
attributeName.startsWith('data-') &&
selector.includes(`[${attributeName}`)
) {
return true;
}
}
return false;
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.collections.clear();
}
}
// Usage example
const collectionManager = new LiveCollectionManager();
const activeButtons = collectionManager.createLiveCollection(
'button.active',
(change) => {
console.log('Active buttons changed:', change);
console.log(
`Added: ${change.added.length}, Removed: ${change.removed.length}`
);
}
);
// Subscribe to changes
const unsubscribe = activeButtons.subscribe((change) => {
console.log(`Now ${change.elements.length} active buttons`);
});
Performance Optimization
Efficient Observer Patterns
// Debounced mutation handling
class DebouncedMutationObserver {
constructor(callback, delay = 100) {
this.callback = callback;
this.delay = delay;
this.mutations = [];
this.timeout = null;
this.observer = new MutationObserver((mutations) => {
this.mutations.push(...mutations);
this.scheduleCallback();
});
}
scheduleCallback() {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
const mutations = this.mutations.slice();
this.mutations = [];
this.callback(mutations);
}, this.delay);
}
observe(target, config) {
this.observer.observe(target, config);
}
disconnect() {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.observer.disconnect();
}
takeRecords() {
return this.observer.takeRecords();
}
}
// Selective observation
class SelectiveMutationObserver {
constructor() {
this.observers = new Map();
this.globalObserver = null;
}
observeSelector(selector, callback, options = {}) {
const config = {
childList: true,
subtree: true,
...options,
};
// Find all matching elements
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
if (!this.observers.has(element)) {
const observer = new MutationObserver(callback);
observer.observe(element, config);
this.observers.set(element, { observer, selector, config });
}
});
// Watch for new elements matching selector
if (!this.globalObserver) {
this.globalObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches(selector)) {
this.observeElement(node, selector, callback, config);
}
node.querySelectorAll(selector).forEach((el) => {
this.observeElement(el, selector, callback, config);
});
}
});
}
});
});
this.globalObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
}
observeElement(element, selector, callback, config) {
if (!this.observers.has(element)) {
const observer = new MutationObserver(callback);
observer.observe(element, config);
this.observers.set(element, { observer, selector, config });
}
}
unobserveSelector(selector) {
this.observers.forEach((data, element) => {
if (data.selector === selector) {
data.observer.disconnect();
this.observers.delete(element);
}
});
}
disconnect() {
this.observers.forEach((data) => {
data.observer.disconnect();
});
this.observers.clear();
if (this.globalObserver) {
this.globalObserver.disconnect();
this.globalObserver = null;
}
}
}
Best Practices
- Be specific with observation config
// Good - observe only what you need
observer.observe(element, {
attributes: true,
attributeFilter: ['class', 'style'],
});
// Avoid - observing everything
observer.observe(element, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
- Disconnect observers when not needed
// Good - cleanup
class Component {
connectedCallback() {
this.observer = new MutationObserver(this.handleMutations);
this.observer.observe(this, config);
}
disconnectedCallback() {
this.observer.disconnect();
}
}
- Batch mutations when possible
// Good - batch DOM updates
const fragment = document.createDocumentFragment();
items.forEach((item) => {
fragment.appendChild(createItem(item));
});
container.appendChild(fragment);
// Avoid - individual updates
items.forEach((item) => {
container.appendChild(createItem(item));
});
- Use takeRecords() for synchronous processing
// Process pending mutations immediately
const records = observer.takeRecords();
if (records.length > 0) {
processMutations(records);
}
observer.disconnect();
Conclusion
The Mutation Observer API is a powerful tool for monitoring DOM changes efficiently. It enables reactive programming patterns, dynamic component initialization, and content validation without the performance penalties of older approaches. By understanding its capabilities and following best practices, you can build robust applications that respond intelligently to DOM mutations while maintaining excellent performance.