Padroneggiare HTML5: le mutazioni del DOM
() translation by (you can also view the original English article)



Durante lo sviluppo di applicazioni web client-side spesso nasce la necessità di ricevere notifiche qualora avvenga una modifica nella struttura del DOM (Document Object Mode - una rappresentazione ad oggetti della struttura del documento HTML). In un recente articolo abbiamo avuto la possibilità di affrontare vari argomenti, dalla validazione dei vincoli fino alla iterazione attraverso i nodi del DOM. In questi casi abbiamo sempre fatto l'assunzione che le informazioni relative alla struttura di uno specifico nodo fossero necessarie solamente al momento dell'esecuzione del codice. Tale assunzione è nella maggior parte dei casi corretta ma può venir meno quando si comincia a lavorare con i view manager.
Un view manager è un componente che si preoccupa di mantenere la vista consistente con l'insieme dei dati associati alla vista stessa. Spesso tale consistenza deve essere bidirezionale nel senso che anche i dati associati alla vista devono poter essere modificati in seguito ad azioni effettuate sulla vista. Se avviene un cambiamento nella vista, questo cambiamento deve essere in qualche modo riflesso sull'insieme dei dati associati alla vista.
In questo tutorial guarderemo più in dettaglio MutationObserver
il componente che ci fornisce un modo per rilevare le modifiche che avvengono nel DOM. Questo componente è progettato per sostituire le funzionalità in origine offerte dagli eventi di mutazione (Mutation Events) definiti nelle specifiche DOM L3 Events.
Ricevere notifiche sulle mutazioni
Le API relative ai Mutation Event nascono attorno all'anno 2000. Tali API dovevano fornire in modo semplice la possibilità di osservare e reagire alle modifiche del DOM. In origine tali API erano composte da un insieme di eventi quali, ad esempio, DOMNodeRemoved
and DOMAttrModified
. Tali eventi venivano scatenati immediatamente al cambiamento della struttura dei nodi. Si trattava quindi di eventi sincroni.
Nonostante queste funzionalità non abbiano mai avuto una grande popolarità consentivano comunque di accedere alle informazioni relative ai cambiamenti del DOM in modo particolarmente comodo. Uno dei casi in cui queste API erano particolarmente utilizzate è relativo alle estensioni per i browser. Le estensioni installate avevano quindi a disposizione un semplice modo per ricevere una notifica nel caso in cui ci fosse stata una modifica nella struttura della pagina. Una volta ricevuta tale notifica erano quindi in grado di effettuare a loro volta delle operazioni sulla pagina stessa.
Vediamo quindi una tipica soluzione tramite la quale è possibile essere notificati di eventuali cambiamenti nella struttura del DOM. La creazione del codice necessario per ricevere la notifica relativa agli eventi di cui sopra è estremamente semplice, come mostrato nel seguente frammento di codice.
1 |
["DOMNodeInserted", "DOMAttrModified", "DOMNodeRemoved"].forEach(function (eventName) { |
2 |
document.documentElement.addEventListener(eventName, callback, true); |
3 |
});
|
Il codice invocato al momento della mutazione è definito come segue. In base al valore della proprietà attrChange
dell'evento, tramite il costrutto switch, il codice esegue azioni specifiche (nell'esempio si limita a scrivere sulla Console).
1 |
var callback = function (ev) { |
2 |
var nodeOrAttr = ev.relatedNode; |
3 |
|
4 |
switch (ev.attrChange) { |
5 |
case MutationEvent.MODIFICATION: |
6 |
console.log('changed attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); |
7 |
break; |
8 |
case MutationEvent.ADDITION: |
9 |
console.log('added attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); |
10 |
break; |
11 |
case MutationEvent.REMOVAL: |
12 |
console.log('removed attribute', ev.attrName, ev.prevValue, ev.newValue, nodeOrAttr); |
13 |
break; |
14 |
default: |
15 |
console.log(ev.type, nodeOrAttr); |
16 |
break; |
17 |
}
|
18 |
};
|
19 |
È possibile provare il codice appena scritto all'interno di una qualsiasi pagina HTML. Per fare questo useremo il codice che segue per verificare il modo in cui viene invocato il codice di gestione dell'evento.
1 |
var newNode = document.createElement('div'); |
2 |
newNode.setAttribute('id', 'initial'); |
3 |
document.body.appendChild(newNode); |
4 |
newNode.setAttribute('class', 'foo'); |
5 |
newNode.setAttribute('id', 'bar'); |
6 |
newNode.removeAttribute('id'); |
7 |
document.body.removeChild(newNode); |
Se utilizziamo Firefox otterremo un risultato simile a quello qui sotto.

Purtroppo il comportamento osservato non è lo stesso in tutti i browser. Esistono infatti molte piccole differenze relative all'approccio che abbiamo appena implementato. Uno dei problemi principali è infatti che gli eventi di mutazione non sono stati implementati in modo completo e consistente all'interno dei principali browser.
Un altro importante motivo è che nonostante questi eventi siano di grande utilità la loro implementazione è sempre stata responsabile di un importante calo nelle prestazioni. Questi eventi sono, infatti, estremamente lenti. Uno dei motivi per cui questo accade è che sono invocati troppo spesso in modo sincrono. Inoltre è molto facile ricadere in un ciclo di ricorsione, causando un riempimento dello stack e una conseguente terminazione anomala dell'esecuzione del codice. Tutto questo fa sì che questi eventi possano essere la causa di errori nel browser.
È possibile fare di meglio? Certo! Per poter fare di meglio è stata introdotta l'interfaccia MutationObserver
.
Mutation Observer
Introdotti nelle specifiche per DOM L4, i mutation observer avranno il compito di sostituire completamente gli eventi di mutazione sopra descritti. Fra i due approcci esistono molte importanti differenze. Una delle differenze principali è la loro esecuzione in modo asincrono. Un'istanza di MutationObserver
infatti non sarà mai eseguita nello stesso ciclo di esecuzione che ha causato la modifica del DOM ma sarà eseguita in quello successivo. Un'importante conseguenza di questo principio è che così facendo è possibile raggruppare le notifiche. Invece di ricevere quindi molte notifiche per molte modifiche potremmo invece ricevere una singola notifica per molte modifiche.
L'importanza di questa metodologia è legata al fatto che, essendo l'esecuzione asincrona, il nostro metodo responsabile della notifica non verrà invocato per ogni singola modifica del DOM. Quello che avviene è che la callback verrà invocata poco dopo che la modifica sarà stata effettuata. In questo modo è possibile evitare le problematiche legate a contenuto inizialmente non stilizzato prima che il nostro codice possa agire.
Quello che segue è il modo in cui potremmo scrivere il codice di un mutation observer:
1 |
var callback = function (mutations) { |
2 |
mutations.forEach(function (mutation) { |
3 |
var target = mutation.target; |
4 |
|
5 |
switch (mutation.type) { |
6 |
case 'attributes': |
7 |
var attribute = mutation.attributeName; |
8 |
var oldValue = mutation.oldValue; |
9 |
var newValue = target.getAttribute(attribute); |
10 |
|
11 |
if (mutation.oldValue === null) |
12 |
console.log('added attribute', attribute, '', newValue, target); |
13 |
else
|
14 |
console.log('changed attribute', attribute, oldValue, newValue, target); |
15 |
|
16 |
break; |
17 |
case 'childList': |
18 |
if (mutation.addedNodes.length > 0) |
19 |
console.log('added nodes', mutation.addedNodes, target); |
20 |
else if (mutation.removedNodes.length > 0) |
21 |
console.log('removed nodes', mutation.removedNodes, target); |
22 |
break; |
23 |
}
|
24 |
});
|
25 |
};
|
Questa callback è passata al costruttore per la creazione di una nuova istanza di un MutationObserver
. In questo esempio specifichiamo inoltre tutte le possibili opzioni per l'avvio del processo di osservazione. In pratica però potremmo non specificare le opzioni che abbiamo lasciate disabilitate.
1 |
var mo = new MutationObserver(callback); |
2 |
mo.observe(document.documentElement, { |
3 |
childList: true, |
4 |
attributes: true, |
5 |
characterData: false, |
6 |
subtree: true, |
7 |
attributeOldValue: true, |
8 |
characterDataOldValue: false, |
9 |
|
10 |
});
|
Ovviamente le possibilità di utilizzo sono più complesse ma possono essere ridotte a:
- Creazione di un nuovo oggetto
MutationObserver
con una callback che verrà invocata alla ricezione degli eventi. - Specificare che tale
MutationObserver
debba osservare un certo nodo con certe opzioni. - Terminare il processo di osservazione disconnettendo l'oggetto
MutationObserver
in questo modo
1 |
mo.disconnect(); |
L'esempio appena illustrato reagisce alle mutazioni nello stesso modo dell'esempio precedente che utilizzava i Mutation event. La differenza principale è che adesso utilizziamo un oggetto MutationObserver
che ci garantisce migliori performance e flessibilità. Usando un MutationObserver
otterremo quindi, utilizzando Firefox, il seguente risultato.



Nonostante i due risultati appaiano simili notiamo subito una importante differenza: in tutti gli esempi il nodo osservato ha già subito la mutazione quando l'evento viene scatenato. Guardiamo ad esempio la linea relativa all'evento added attribute
. Qui abbiamo aggiunto un nuovo attributo class=foo
. Ciò nonostante l'attributo appare essere già presente. La linea successiva è ancora più ovvia. Questa linea è stata generata dall'azione di modifica del valore dell'attributo id
da initial
a bar
. Nonostante questo non c'è possibilità di vedere il nuovo attributo, anzi, non c'è proprio alcun attributo id
associato al nodo.
Il motivo per cui questo accade è che il nodo è già stato rimosso (ricordiamo che la funzione che abbiamo scritto viene eseguita una sola volta in seguito a più azioni di modifica del nodo). L'esecuzione in modo asincrono del MutationObserver
non ha in alcun odo influito sull'esecuzione del nostro codice. In questo senso l'utilizzo di questo nuovo metodo può mancare di alcune delle possibilità del metodo visto prima ma aggiunge performance ed esecuzione del codice in un secondo tempo. Queste due caratteristiche sono molto più importanti della perdita di flessibilità.
Esempi pratici
Abbiamo già detto che le estensioni per i browser sono un ottimo esempio di come possa essere usato lo strumento dei mutation observer. Un altro esempio importante sono anche alcuni framework. Per questo motivo vediamo adesso come due popolari framework MVC, Aurelia e Polymer, utilizzano queste tecniche.
Aurelia è uno dei framework più recenti. È un framework multipiattaforma lato client allo stato dell'arte. Uno dei principali vantaggi è che adotta un insieme di convenzioni che minimizzano la necessità di configurazione esplicita. Una delle principali sfide accolte da Aurelia è stata quella relativa al supporto di browser quali IE9. La mancanza dei MutationObserver
è stata infatti identificata come la causa principale dei problemi relativi alla compatibilità. La scelta è stata quella di sopperire a tale mancanza tramite l'utilizzo di un polyfill.
Al suo interno Aurelia osserva le modifiche al DOM nel modulo relativo al templating. I componenti principali del framework, invece, non sono interessati a tali cambiamenti. All'interno del modulo per la gestione dei template troviamo una classe chiamata ChildObserver
che usa MutationObserver
all'interno di istanze di ChildObserverBinder
. Questi elementi vengono usati dai ChildObserver per osservare le mutazioni di ogni elemento importante. Within the binder we see code similar to the following code snippet.
1 |
function bind (source) { |
2 |
this.observer.observe(this.target, { childList:true, subtree: true }); |
3 |
var results = this.target.querySelectorAll(this.selector); |
4 |
|
5 |
for (var i = 0; i < results.length; ++i) |
6 |
this.behavior[this.property].push(results[i]); |
7 |
}
|
8 |
|
9 |
function unbind () { |
10 |
this.observer.disconnect(); |
11 |
}
|
Dopo aver creato una nuova istanza di MutationObserver
nel costruttore è possibile utilizzare il metodo bind
per abilitare la notifica delle modifiche che avvengono su target
. In questo modo le modifiche vengono comunque registrate. Questo meccanismo principale può essere facilmante osservato nel codice appena descritto. Infatti identifichiamo tutti i nodi del DOM attraverso un determinato selettore e li aggiungiamo ad una lista di behavior usando il nome di una specifica proprietà. Questa lista verrà poi utilizzata al momento delle notifiche di modifica per capire se tali modifiche sono o meno di nostro interesse.
Polymer è un altro framework che usa in modo esteso i MutationObserver
. Addirittura il polyfill utilizzato da Aurelia è stato sviluppato dal team di Polymer. La maggior parter dei polyfill di Polymer sono stati raggruppati nel progetto webcomponents.js.
Uno dei molti casi in cui Polymer utilizza i MutationObserver
è quello di tenere sotto controllo le modifiche all'interno dei container. Se, ad esempio, lo scoping degli stili deve essere applicato ad un container ed a tutti i suoi discendenti viene usato un mutation observer che reagisca alle modifiche e applichi lo stesso stile agli eventuali nuovi elementi inseriti.
1 |
scopeSubtree: function(container, shouldObserve) { |
2 |
var scopify = function(node) { |
3 |
// ...
|
4 |
};
|
5 |
|
6 |
scopify(container); |
7 |
|
8 |
if (shouldObserve) { |
9 |
var mo = new MutationObserver(function (mxns) { |
10 |
mxns.forEach(function (m) { |
11 |
if (m.addedNodes) { |
12 |
for (var i = 0; i < m.addedNodes.length; i++) |
13 |
scopify(m.addedNodes[i]); |
14 |
}
|
15 |
});
|
16 |
});
|
17 |
|
18 |
mo.observe(container, { childList: true, subtree: true }); |
19 |
return mo; |
20 |
}
|
21 |
}
|
L'esempio qui sopra si assicura l'applicazione del corretto stile per tutti gli elementi creati nello scope locale ma fuori dal controllo del container. Questo è particolarmente utile nel caso di utilizzo di librerie di terze parti. Naturalmente un'implementazione nativa dello shadow DOM sarebbe in grado di gestire uno scenario di questo tipo in modo autonomo.
Conclusioni
I Mutation Observers sono un modo efficace per tener traccia delle modifiche che avvengono all'interno del DOM. Essi infatti forniscono un modo veloce e robusto per ricevere notifiche ogni qualvolta avvenga una modifica. Nonostante il loro uso sia particolarmente importante per chi scrive estensioni per ii browser o nel caso di alcuni framework MV* è comunque importante capire i principi fondamentali del loro funzionamento. Dopotutto il motivo per cui qualcosa nella nostra libreria funziona in un certo modo può essere dovuto al fatto che alcune funzionalità sono state implementate utilizzando questi componenti.
Qui concludiamo la serie di tutorial su HTML5. Spero che vi sia servito per apprendere qualcosa. Non è detto che per tutti i progetti sia necessario applicare le tecniche che abbiamo visto ma potrebbero comunque essere utili in alcuni specifici casi. Le specifiche relative a HTML5 sono piuttosto ampie. Se però consideriamo le specifiche supplementari relative alle estensioni, queste sono sicuramente più ampie. Le specifiche relative al DOM sono un'altra area molto complessa.