javascript
exercises
exercises.js⚡javascript
/**
* 19.2 Mutation Observer - Exercises
*
* Practice implementing MutationObserver patterns
*/
// ============================================
// EXERCISE 1: DOM Change Logger
// ============================================
/**
* Create a comprehensive DOM change logger that:
* - Tracks all types of mutations
* - Provides detailed change reports
* - Supports filtering by mutation type
*
* Requirements:
* - Track childList, attributes, and characterData
* - Record timestamps for each change
* - Provide getHistory() method
*/
class DOMChangeLogger {
// Your implementation here
}
/*
// SOLUTION:
class DOMChangeLogger {
constructor(target, options = {}) {
this.target = target;
this.options = options;
this.history = [];
this.observer = null;
this.maxHistory = options.maxHistory || 1000;
}
start() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
const record = {
timestamp: Date.now(),
type: mutation.type,
target: mutation.target,
targetPath: this.getNodePath(mutation.target)
};
if (mutation.type === 'childList') {
record.addedNodes = Array.from(mutation.addedNodes);
record.removedNodes = Array.from(mutation.removedNodes);
} else if (mutation.type === 'attributes') {
record.attributeName = mutation.attributeName;
record.oldValue = mutation.oldValue;
record.newValue = mutation.target.getAttribute(mutation.attributeName);
} else if (mutation.type === 'characterData') {
record.oldValue = mutation.oldValue;
record.newValue = mutation.target.textContent;
}
this.history.push(record);
// Trim history if needed
if (this.history.length > this.maxHistory) {
this.history = this.history.slice(-this.maxHistory);
}
});
});
this.observer.observe(this.target, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
});
}
stop() {
if (this.observer) {
const pending = this.observer.takeRecords();
// Process pending if needed
this.observer.disconnect();
this.observer = null;
}
}
getNodePath(node) {
const path = [];
let current = node;
while (current && current !== document) {
let selector = current.nodeName.toLowerCase();
if (current.id) {
selector += '#' + current.id;
} else if (current.className && typeof current.className === 'string') {
selector += '.' + current.className.split(' ').join('.');
}
path.unshift(selector);
current = current.parentNode;
}
return path.join(' > ');
}
getHistory(filter = null) {
if (!filter) return [...this.history];
return this.history.filter(record => {
if (filter.type && record.type !== filter.type) return false;
if (filter.since && record.timestamp < filter.since) return false;
if (filter.attributeName && record.attributeName !== filter.attributeName) return false;
return true;
});
}
clear() {
this.history = [];
}
getSummary() {
return {
total: this.history.length,
byType: this.history.reduce((acc, r) => {
acc[r.type] = (acc[r.type] || 0) + 1;
return acc;
}, {}),
firstChange: this.history[0]?.timestamp,
lastChange: this.history[this.history.length - 1]?.timestamp
};
}
}
*/
// ============================================
// EXERCISE 2: Element Change Tracker
// ============================================
/**
* Create a change tracker for specific elements:
* - Track when elements gain/lose specific classes
* - Track when elements become visible/hidden
* - Track when elements are added/removed from DOM
*
* Requirements:
* - Emit events for each change type
* - Support multiple tracked elements
*/
class ElementChangeTracker {
// Your implementation here
}
/*
// SOLUTION:
class ElementChangeTracker {
constructor() {
this.observers = new Map();
this.listeners = new Map();
}
track(element, options = {}) {
const {
classes = [],
visibilityAttribute = 'hidden',
trackRemoval = true
} = options;
const state = {
classes: new Set(classes.filter(c => element.classList.contains(c))),
visible: !element.hasAttribute(visibilityAttribute),
inDOM: document.contains(element)
};
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes') {
if (mutation.attributeName === 'class') {
// Check class changes
classes.forEach(className => {
const hasClass = element.classList.contains(className);
const hadClass = state.classes.has(className);
if (hasClass && !hadClass) {
state.classes.add(className);
this.emit('classAdded', { element, className });
} else if (!hasClass && hadClass) {
state.classes.delete(className);
this.emit('classRemoved', { element, className });
}
});
}
if (mutation.attributeName === visibilityAttribute) {
const visible = !element.hasAttribute(visibilityAttribute);
if (visible !== state.visible) {
state.visible = visible;
this.emit(visible ? 'shown' : 'hidden', { element });
}
}
}
});
});
observer.observe(element, {
attributes: true,
attributeFilter: ['class', visibilityAttribute]
});
// Track removal from DOM
if (trackRemoval && element.parentNode) {
const parentObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.removedNodes.forEach(node => {
if (node === element || node.contains(element)) {
state.inDOM = false;
this.emit('removed', { element });
parentObserver.disconnect();
}
});
});
});
parentObserver.observe(document.body, {
childList: true,
subtree: true
});
this.observers.set(element + '_parent', parentObserver);
}
this.observers.set(element, observer);
return this;
}
untrack(element) {
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
}
const parentObserver = this.observers.get(element + '_parent');
if (parentObserver) {
parentObserver.disconnect();
this.observers.delete(element + '_parent');
}
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this;
}
off(event, callback) {
const listeners = this.listeners.get(event);
if (listeners) {
const index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
return this;
}
emit(event, data) {
const listeners = this.listeners.get(event) || [];
listeners.forEach(callback => callback(data));
}
destroy() {
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
this.listeners.clear();
}
}
*/
// ============================================
// EXERCISE 3: DOM Diff Tracker
// ============================================
/**
* Create a DOM diff tracker that:
* - Takes snapshots of DOM state
* - Computes differences between snapshots
* - Can undo/redo changes
*
* Requirements:
* - Capture element attributes and content
* - Support nested elements
* - Provide diff report
*/
class DOMDiffTracker {
// Your implementation here
}
/*
// SOLUTION:
class DOMDiffTracker {
constructor(root) {
this.root = root;
this.snapshots = [];
this.currentIndex = -1;
}
takeSnapshot(label = '') {
const snapshot = {
label,
timestamp: Date.now(),
state: this.serializeElement(this.root)
};
// Remove any redo history
this.snapshots = this.snapshots.slice(0, this.currentIndex + 1);
this.snapshots.push(snapshot);
this.currentIndex = this.snapshots.length - 1;
return snapshot;
}
serializeElement(element) {
if (element.nodeType === Node.TEXT_NODE) {
return {
type: 'text',
content: element.textContent
};
}
if (element.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const attributes = {};
for (const attr of element.attributes) {
attributes[attr.name] = attr.value;
}
return {
type: 'element',
tagName: element.tagName.toLowerCase(),
attributes,
children: Array.from(element.childNodes)
.map(child => this.serializeElement(child))
.filter(Boolean)
};
}
diff(index1, index2) {
if (index1 < 0 || index1 >= this.snapshots.length ||
index2 < 0 || index2 >= this.snapshots.length) {
return null;
}
const state1 = this.snapshots[index1].state;
const state2 = this.snapshots[index2].state;
return this.computeDiff(state1, state2, '');
}
computeDiff(obj1, obj2, path) {
const changes = [];
if (!obj1 && !obj2) return changes;
if (!obj1) {
changes.push({ type: 'added', path, value: obj2 });
return changes;
}
if (!obj2) {
changes.push({ type: 'removed', path, value: obj1 });
return changes;
}
if (obj1.type !== obj2.type) {
changes.push({ type: 'replaced', path, oldValue: obj1, newValue: obj2 });
return changes;
}
if (obj1.type === 'text') {
if (obj1.content !== obj2.content) {
changes.push({
type: 'textChanged',
path,
oldValue: obj1.content,
newValue: obj2.content
});
}
return changes;
}
// Compare attributes
const allAttrs = new Set([
...Object.keys(obj1.attributes || {}),
...Object.keys(obj2.attributes || {})
]);
allAttrs.forEach(attr => {
const val1 = obj1.attributes?.[attr];
const val2 = obj2.attributes?.[attr];
if (val1 !== val2) {
changes.push({
type: 'attributeChanged',
path: `${path}@${attr}`,
attribute: attr,
oldValue: val1,
newValue: val2
});
}
});
// Compare children
const maxChildren = Math.max(
obj1.children?.length || 0,
obj2.children?.length || 0
);
for (let i = 0; i < maxChildren; i++) {
const childPath = `${path}/${i}`;
changes.push(...this.computeDiff(
obj1.children?.[i],
obj2.children?.[i],
childPath
));
}
return changes;
}
canUndo() {
return this.currentIndex > 0;
}
canRedo() {
return this.currentIndex < this.snapshots.length - 1;
}
undo() {
if (!this.canUndo()) return null;
this.currentIndex--;
const snapshot = this.snapshots[this.currentIndex];
this.applySnapshot(snapshot.state);
return snapshot;
}
redo() {
if (!this.canRedo()) return null;
this.currentIndex++;
const snapshot = this.snapshots[this.currentIndex];
this.applySnapshot(snapshot.state);
return snapshot;
}
applySnapshot(state) {
this.rebuildElement(this.root, state);
}
rebuildElement(element, state) {
if (state.type === 'text') {
element.textContent = state.content;
return;
}
// Update attributes
const currentAttrs = new Set(Array.from(element.attributes).map(a => a.name));
for (const [name, value] of Object.entries(state.attributes || {})) {
element.setAttribute(name, value);
currentAttrs.delete(name);
}
// Remove extra attributes
currentAttrs.forEach(attr => element.removeAttribute(attr));
// Rebuild children
element.innerHTML = '';
state.children?.forEach(childState => {
if (childState.type === 'text') {
element.appendChild(document.createTextNode(childState.content));
} else {
const child = document.createElement(childState.tagName);
element.appendChild(child);
this.rebuildElement(child, childState);
}
});
}
getHistory() {
return this.snapshots.map((s, i) => ({
index: i,
label: s.label,
timestamp: s.timestamp,
isCurrent: i === this.currentIndex
}));
}
}
*/
// ============================================
// EXERCISE 4: Reactive DOM Bindings
// ============================================
/**
* Create a simple reactive binding system:
* - Bind data to DOM elements
* - Automatically update DOM when data changes
* - Support two-way binding for inputs
*
* Requirements:
* - Use MutationObserver for DOM monitoring
* - Use Proxy for data reactivity
*/
class ReactiveBindings {
// Your implementation here
}
/*
// SOLUTION:
class ReactiveBindings {
constructor(root, data = {}) {
this.root = root;
this.bindings = new Map();
this.observer = null;
this.updating = false;
// Create reactive data
this.data = this.createReactiveData(data);
this.init();
}
createReactiveData(data) {
const self = this;
return new Proxy(data, {
set(target, property, value) {
target[property] = value;
self.updateDOM(property, value);
return true;
},
get(target, property) {
return target[property];
}
});
}
init() {
this.scanBindings();
this.setupDOMObserver();
this.setupInputListeners();
}
scanBindings() {
// Find all bound elements
const textBindings = this.root.querySelectorAll('[data-bind]');
const inputBindings = this.root.querySelectorAll('[data-model]');
textBindings.forEach(element => {
const property = element.dataset.bind;
if (!this.bindings.has(property)) {
this.bindings.set(property, { text: [], inputs: [] });
}
this.bindings.get(property).text.push(element);
// Initial render
if (this.data[property] !== undefined) {
element.textContent = this.data[property];
}
});
inputBindings.forEach(element => {
const property = element.dataset.model;
if (!this.bindings.has(property)) {
this.bindings.set(property, { text: [], inputs: [] });
}
this.bindings.get(property).inputs.push(element);
// Initial render
if (this.data[property] !== undefined) {
element.value = this.data[property];
}
});
}
setupDOMObserver() {
this.observer = new MutationObserver((mutations) => {
if (this.updating) return;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Scan new elements for bindings
if (node.dataset?.bind) {
const property = node.dataset.bind;
if (!this.bindings.has(property)) {
this.bindings.set(property, { text: [], inputs: [] });
}
this.bindings.get(property).text.push(node);
if (this.data[property] !== undefined) {
node.textContent = this.data[property];
}
}
if (node.dataset?.model) {
const property = node.dataset.model;
if (!this.bindings.has(property)) {
this.bindings.set(property, { text: [], inputs: [] });
}
this.bindings.get(property).inputs.push(node);
if (this.data[property] !== undefined) {
node.value = this.data[property];
}
this.addInputListener(node, property);
}
}
});
});
});
this.observer.observe(this.root, {
childList: true,
subtree: true
});
}
setupInputListeners() {
this.bindings.forEach((binding, property) => {
binding.inputs.forEach(input => {
this.addInputListener(input, property);
});
});
}
addInputListener(input, property) {
const eventType = input.type === 'checkbox' ? 'change' : 'input';
input.addEventListener(eventType, () => {
const value = input.type === 'checkbox' ? input.checked : input.value;
this.updating = true;
this.data[property] = value;
this.updating = false;
});
}
updateDOM(property, value) {
const binding = this.bindings.get(property);
if (!binding) return;
this.updating = true;
binding.text.forEach(element => {
element.textContent = value;
});
binding.inputs.forEach(input => {
if (input.type === 'checkbox') {
input.checked = !!value;
} else {
input.value = value;
}
});
this.updating = false;
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.bindings.clear();
}
}
*/
// ============================================
// EXERCISE 5: Virtual DOM Reconciler
// ============================================
/**
* Create a simple virtual DOM reconciler:
* - Build virtual DOM tree
* - Compute minimal patches
* - Apply patches efficiently
*
* Use MutationObserver to verify patches
*/
class SimpleVDOM {
// Your implementation here
}
/*
// SOLUTION:
class SimpleVDOM {
constructor(container) {
this.container = container;
this.currentVTree = null;
this.patchLog = [];
// Set up observer for debugging
this.observer = new MutationObserver((mutations) => {
this.patchLog.push({
timestamp: Date.now(),
mutations: mutations.length,
types: mutations.map(m => m.type)
});
});
this.observer.observe(container, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
}
// Create virtual node
createElement(type, props = {}, ...children) {
return {
type,
props: props || {},
children: children.flat().map(child =>
typeof child === 'string' || typeof child === 'number'
? { type: 'TEXT', props: { nodeValue: String(child) } }
: child
)
};
}
// Render virtual tree to real DOM
render(vTree) {
if (!this.currentVTree) {
const dom = this.createDOM(vTree);
this.container.innerHTML = '';
this.container.appendChild(dom);
} else {
this.patch(this.container.firstChild, this.currentVTree, vTree);
}
this.currentVTree = vTree;
}
createDOM(vNode) {
if (vNode.type === 'TEXT') {
return document.createTextNode(vNode.props.nodeValue);
}
const dom = document.createElement(vNode.type);
// Set properties
Object.entries(vNode.props).forEach(([name, value]) => {
if (name.startsWith('on')) {
const eventName = name.slice(2).toLowerCase();
dom.addEventListener(eventName, value);
} else if (name === 'className') {
dom.setAttribute('class', value);
} else {
dom.setAttribute(name, value);
}
});
// Create children
vNode.children?.forEach(child => {
dom.appendChild(this.createDOM(child));
});
return dom;
}
patch(dom, oldVNode, newVNode) {
// Node removed
if (!newVNode) {
dom.parentNode.removeChild(dom);
return;
}
// Node added
if (!oldVNode) {
const newDOM = this.createDOM(newVNode);
dom.parentNode.appendChild(newDOM);
return;
}
// Different node type - replace
if (oldVNode.type !== newVNode.type) {
const newDOM = this.createDOM(newVNode);
dom.parentNode.replaceChild(newDOM, dom);
return;
}
// Text node
if (newVNode.type === 'TEXT') {
if (oldVNode.props.nodeValue !== newVNode.props.nodeValue) {
dom.nodeValue = newVNode.props.nodeValue;
}
return;
}
// Update properties
this.updateProps(dom, oldVNode.props, newVNode.props);
// Patch children
this.patchChildren(dom, oldVNode.children || [], newVNode.children || []);
}
updateProps(dom, oldProps, newProps) {
// Remove old props
Object.keys(oldProps).forEach(name => {
if (!(name in newProps)) {
if (name.startsWith('on')) {
const eventName = name.slice(2).toLowerCase();
dom.removeEventListener(eventName, oldProps[name]);
} else {
dom.removeAttribute(name === 'className' ? 'class' : name);
}
}
});
// Set new props
Object.entries(newProps).forEach(([name, value]) => {
if (oldProps[name] !== value) {
if (name.startsWith('on')) {
const eventName = name.slice(2).toLowerCase();
if (oldProps[name]) {
dom.removeEventListener(eventName, oldProps[name]);
}
dom.addEventListener(eventName, value);
} else if (name === 'className') {
dom.setAttribute('class', value);
} else {
dom.setAttribute(name, value);
}
}
});
}
patchChildren(parent, oldChildren, newChildren) {
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
const childDOM = parent.childNodes[i];
if (!oldChild && newChild) {
parent.appendChild(this.createDOM(newChild));
} else if (oldChild && !newChild) {
if (childDOM) {
parent.removeChild(childDOM);
}
} else if (oldChild && newChild) {
this.patch(childDOM, oldChild, newChild);
}
}
}
getPatchLog() {
return [...this.patchLog];
}
destroy() {
this.observer.disconnect();
}
}
// Usage:
// const vdom = new SimpleVDOM(document.getElementById('app'));
// const h = vdom.createElement.bind(vdom);
//
// vdom.render(
// h('div', { className: 'container' },
// h('h1', null, 'Hello'),
// h('p', null, 'World')
// )
// );
*/
// ============================================
// TEST UTILITIES
// ============================================
console.log('=== MutationObserver Exercises ===');
console.log('');
console.log('Exercises:');
console.log('1. DOMChangeLogger - Comprehensive DOM change logging');
console.log('2. ElementChangeTracker - Track specific element changes');
console.log('3. DOMDiffTracker - DOM snapshots and diffing');
console.log('4. ReactiveBindings - Two-way data binding');
console.log('5. SimpleVDOM - Virtual DOM reconciler');
console.log('');
console.log('These exercises require a browser DOM environment.');
console.log('Uncomment solutions to see implementations.');
// Export for browser use
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
DOMChangeLogger,
ElementChangeTracker,
DOMDiffTracker,
ReactiveBindings,
SimpleVDOM,
};
}