Introducción a la API de MutationObserver
Publicado: 2022-03-10En aplicaciones web complejas, los cambios de DOM pueden ser frecuentes. Como resultado, hay instancias en las que su aplicación podría necesitar responder a un cambio específico en el DOM.
Durante algún tiempo, la forma aceptada de buscar cambios en el DOM fue por medio de una función llamada Mutation Events, que ahora está obsoleta. El reemplazo aprobado por W3C para Mutation Events es la API MutationObserver, que es lo que discutiré en detalle en este artículo.
Varios artículos y referencias anteriores analizan por qué se reemplazó la función anterior, por lo que no entraré en detalles al respecto aquí (además del hecho de que no podría hacerle justicia). La API de MutationObserver
tiene soporte de navegador casi completo, por lo que podemos usarla de manera segura en la mayoría de los proyectos, si no en todos, en caso de que surja la necesidad.
Sintaxis básica para un MutationObserver
Un MutationObserver
se puede usar de varias maneras diferentes, que cubriré en detalle en el resto de este artículo, pero la sintaxis básica para un MutationObserver
se ve así:
let observer = new MutationObserver(callback); function callback (mutations) { // do something here } observer.observe(targetNode, observerOptions);
La primera línea crea un nuevo MutationObserver
utilizando el constructor MutationObserver()
. El argumento pasado al constructor es una función de devolución de llamada que se llamará en cada cambio de DOM que califique.
La forma de determinar qué califica para un observador en particular es por medio de la línea final en el código anterior. En esa línea, estoy usando el método observe()
de MutationObserver
para comenzar a observar. Puede comparar esto con algo como addEventListener()
. Tan pronto como adjunte un oyente, la página 'escuchará' el evento especificado. De manera similar, cuando comience a observar, la página comenzará a 'observar' para el MutationObserver
especificado.
El método observe()
toma dos argumentos: el objetivo , que debe ser el nodo o el árbol de nodos en el que observar los cambios; y un objeto de opciones , que es un objeto MutationObserverInit
que le permite definir la configuración para el observador.
La característica básica clave final de un MutationObserver
es el método de disconnect()
. Esto le permite dejar de observar los cambios especificados y se ve así:
observer.disconnect();
Opciones para configurar un MutationObserver
Como se mencionó, el método observe()
de MutationObserver
requiere un segundo argumento que especifica las opciones para describir MutationObserver
. Así es como se vería el objeto de opciones con todos los posibles pares de propiedad/valor incluidos:
let options = { childList: true, attributes: true, characterData: false, subtree: false, attributeFilter: ['one', 'two'], attributeOldValue: false, characterDataOldValue: false };
Al configurar las opciones de MutationObserver
, no es necesario incluir todas estas líneas. Los incluyo simplemente como referencia, para que pueda ver qué opciones están disponibles y qué tipos de valores pueden tomar. Como puede ver, todos excepto uno son booleanos.
Para que MutationObserver
funcione, al menos uno de childList
, attributes
o characterData
debe establecerse en true
, de lo contrario, se generará un error. Las otras cuatro propiedades funcionan en conjunto con una de esas tres (más sobre esto más adelante).
Hasta ahora, simplemente he pasado por alto la sintaxis para brindarle una descripción general. La mejor manera de considerar cómo funciona cada una de estas funciones es proporcionar ejemplos de código y demostraciones en vivo que incorporen las diferentes opciones. Así que eso es lo que haré por el resto de este artículo.
Observación de cambios en elementos secundarios mediante childList
El primer y más simple MutationObserver
que puede iniciar es uno que busca nodos secundarios de un nodo específico (generalmente un elemento) para agregarlos o eliminarlos. Para mi ejemplo, voy a crear una lista desordenada en mi HTML, y quiero saber cuándo se agrega o elimina un nodo secundario de este elemento de la lista.
El HTML de la lista se ve así:
<ul class="list"> <li>Apples</li> <li>Oranges</li> <li>Bananas</li> <li class="child">Peaches</li> </ul>
El JavaScript para mi MutationObserver
incluye lo siguiente:
let mList = document.getElementById('myList'), options = { childList: true }, observer = new MutationObserver(mCallback); function mCallback(mutations) { for (let mutation of mutations) { if (mutation.type === 'childList') { console.log('Mutation Detected: A child node has been added or removed.'); } } } observer.observe(mList, options);
Esto es solo una parte del código. Para abreviar, muestro las secciones más importantes que se ocupan de la propia API de MutationObserver
.
Observe cómo estoy recorriendo el argumento de mutations
, que es un objeto MutationRecord
que tiene varias propiedades diferentes. En este caso, estoy leyendo la propiedad de type
y registrando un mensaje que indica que el navegador ha detectado una mutación que califica. Además, observe cómo estoy pasando el elemento mList
(una referencia a mi lista HTML) como el elemento de destino (es decir, el elemento en el que quiero observar los cambios).
- Ver demostración interactiva completa →
Utilice los botones para iniciar y detener el MutationObserver
. Los mensajes de registro ayudan a aclarar lo que está sucediendo. Los comentarios en el código también proporcionan alguna explicación.
Tenga en cuenta algunos puntos importantes aquí:
- La función de devolución de llamada (que he llamado
mCallback
, para ilustrar que puede nombrarla como desee) se activará cada vez que se detecte una mutación exitosa y después de que se ejecute el métodoobserve()
. - En mi ejemplo, el único 'tipo' de mutación que califica es
childList
, por lo que tiene sentido buscar este al recorrer el MutationRecord. Buscar cualquier otro tipo en esta instancia no haría nada (los otros tipos se usarán en demostraciones posteriores). - Usando
childList
, puedo agregar o eliminar un nodo de texto del elemento de destino y esto también calificaría. Por lo tanto, no tiene que ser un elemento que se agregue o elimine. - En este ejemplo, solo calificarán los nodos secundarios inmediatos. Más adelante en el artículo, le mostraré cómo se puede aplicar esto a todos los nodos secundarios, nietos, etc.
Observación de cambios en los atributos de un elemento
Otro tipo común de mutación que quizás desee rastrear es cuando cambia un atributo en un elemento específico. En la próxima demostración interactiva, observaré los cambios en los atributos de un elemento de párrafo.
let mPar = document.getElementById('myParagraph'), options = { attributes: true }, observer = new MutationObserver(mCallback); function mCallback (mutations) { for (let mutation of mutations) { if (mutation.type === 'attributes') { // Do something here... } } } observer.observe(mPar, options);
- Prueba la demostración →
Nuevamente, he abreviado el código para mayor claridad, pero las partes importantes son:
- El objeto de
options
está usando la propiedad deattributes
, establecida entrue
para decirle aMutationObserver
que quiero buscar cambios en los atributos del elemento de destino. - El tipo de mutación que estoy probando en mi ciclo es
attributes
, el único que califica en este caso. - También estoy usando la propiedad
attributeName
del objeto demutation
, que me permite averiguar qué atributo se cambió. - Cuando activo el observador, paso el elemento de párrafo por referencia, junto con las opciones.
En este ejemplo, se usa un botón para alternar un nombre de clase en el elemento HTML de destino. La función de devolución de llamada en el observador de mutaciones se activa cada vez que se agrega o elimina la clase.
Observación de cambios en los datos de caracteres
Otro cambio que quizás desee buscar en su aplicación son las mutaciones en los datos de los caracteres; es decir, cambios en un nodo de texto específico. Esto se hace estableciendo la propiedad characterData
en true
en el objeto de options
. Aquí está el código:
let options = { characterData: true }, observer = new MutationObserver(mCallback); function mCallback(mutations) { for (let mutation of mutations) { if (mutation.type === 'characterData') { // Do something here... } } }
Observe nuevamente que el type
que se busca en la función de devolución de llamada es characterData
.
- Ver demostración en vivo →
En este ejemplo, estoy buscando cambios en un nodo de texto específico, al que me dirijo a través element.childNodes[0]
. Esto es un poco complicado, pero servirá para este ejemplo. El usuario puede editar el texto a través del atributo contenteditable
en un elemento de párrafo.
Desafíos al observar cambios en los datos de los personajes
Si ha jugado con contenteditable
, es posible que sepa que existen atajos de teclado que permiten la edición de texto enriquecido. Por ejemplo, CTRL-B pone el texto en negrita, CTRL-I pone el texto en cursiva, etc. Esto dividirá el nodo de texto en varios nodos de texto, por lo que notará que MutationObserver
dejará de responder a menos que edite el texto que todavía se considera parte del nodo original.
También debo señalar que si elimina todo el texto, MutationObserver
ya no activará la devolución de llamada. Supongo que esto sucede porque una vez que desaparece el nodo de texto, el elemento de destino ya no existe. Para combatir esto, mi demostración deja de observar cuando se elimina el texto, aunque las cosas se complican un poco cuando usas atajos de texto enriquecido.
Pero no se preocupe, más adelante en este artículo, discutiré una mejor manera de usar la opción characterData
sin tener que lidiar con tantas de estas peculiaridades.
Observación de cambios en atributos especificados
Anteriormente le mostré cómo observar los cambios en los atributos de un elemento específico. En ese caso, aunque la demostración desencadena un cambio de nombre de clase, podría haber cambiado cualquier atributo en el elemento especificado. Pero, ¿qué pasa si quiero observar cambios en uno o más atributos específicos mientras ignoro los demás?
Puedo hacerlo usando la propiedad de attributeFilter
opcional Filter en el objeto de option
. Aquí hay un ejemplo:
let options = { attributes: true, attributeFilter: ['hidden', 'contenteditable', 'data-par'] }, observer = new MutationObserver(mCallback); function mCallback (mutations) { for (let mutation of mutations) { if (mutation.type === 'attributes') { // Do something here... } } }
Como se muestra arriba, la propiedad attributeFilter
acepta una matriz de atributos específicos que quiero monitorear. En este ejemplo, MutationObserver
activará la devolución de llamada cada vez que se modifique uno o más de los atributos hidden
, contenteditable
o data-par
.
- Ver demostración en vivo →
De nuevo, estoy apuntando a un elemento de párrafo específico. Observe el menú desplegable de selección que elige qué atributo se cambiará. El atributo draggable
es el único que no calificará ya que no lo especifiqué en mis opciones.
Observe en el código que estoy usando nuevamente la propiedad attributeName
del objeto MutationRecord
para registrar qué atributo se cambió. Y, por supuesto, al igual que con las otras demostraciones, MutationObserver
no comenzará a monitorear los cambios hasta que se haga clic en el botón "Inicio".
Otra cosa que debo señalar aquí es que no necesito establecer el valor de los attributes
en true
en este caso; está implícito debido a que attributesFilter
se establece en verdadero. Es por eso que mi objeto de opciones podría tener el siguiente aspecto y funcionaría igual:
let options = { attributeFilter: ['hidden', 'contenteditable', 'data-par'] }
Por otro lado, si establezco explícitamente los attributes
en false
junto con una matriz de attributeFilter
, no funcionaría porque el valor false
tendría prioridad y la opción de filtro se ignoraría.
Observación de cambios en los nodos y su subárbol
Hasta ahora, al configurar cada MutationObserver
, solo he estado tratando con el elemento de destino en sí y, en el caso de childList
, los elementos secundarios inmediatos del elemento. Pero ciertamente podría haber un caso en el que podría querer observar cambios en uno de los siguientes:
- Un elemento y todos sus elementos secundarios;
- Uno o más atributos en un elemento y en sus elementos secundarios;
- Todos los nodos de texto dentro de un elemento.
Todo lo anterior se puede lograr utilizando la propiedad de subtree
del objeto de opciones.
childList con subárbol
Primero, busquemos cambios en los nodos secundarios de un elemento, incluso si no son elementos secundarios inmediatos. Puedo modificar mi objeto de opciones para que se vea así:
options = { childList: true, subtree: true }
Todo lo demás en el código es más o menos igual que el ejemplo anterior childList
, junto con algunas marcas y botones adicionales.
- Ver demostración en vivo →
Aquí hay dos listas, una anidada dentro de la otra. Cuando se inicia MutationObserver
, la devolución de llamada activará los cambios en cualquiera de las listas. Pero si volviera a cambiar la propiedad del subtree
a false
(el valor predeterminado cuando no está presente), la devolución de llamada no se ejecutaría cuando se modifica la lista anidada.
Atributos con subárbol
Aquí hay otro ejemplo, esta vez usando un subtree
con attributes
y attributeFilter
. Esto me permite observar los cambios en los atributos no solo en el elemento de destino sino también en los atributos de cualquier elemento secundario del elemento de destino:
options = { attributes: true, attributeFilter: ['hidden', 'contenteditable', 'data-par'], subtree: true }
- Ver demostración en vivo →
Esto es similar a la demostración de atributos anterior, pero esta vez configuré dos elementos de selección diferentes. El primero modifica los atributos del elemento de párrafo de destino, mientras que el otro modifica los atributos de un elemento secundario dentro del párrafo.
Nuevamente, si tuviera que volver a establecer la opción del subtree
en false
(o eliminarla), el segundo botón de alternar no activaría la devolución de llamada de MutationObserver
. Y, por supuesto, podría omitir el filtro de attributeFilter
por completo, y MutationObserver
buscaría cambios en cualquier atributo en el subárbol en lugar de los especificados.
characterData Con subárbol
Recuerde que en la demostración anterior de characterData
, hubo algunos problemas con la desaparición del nodo objetivo y luego el MutationObserver
dejó de funcionar. Si bien hay formas de evitar eso, es más fácil apuntar a un elemento directamente en lugar de un nodo de texto, luego use la propiedad del subtree
para especificar que quiero que todos los datos de caracteres dentro de ese elemento, sin importar qué tan profundamente anidado esté, para desencadenar la devolución de llamada de MutationObserver
.
Mis opciones en este caso se verían así:
options = { characterData: true, subtree: true }
- Ver demostración en vivo →
Después de iniciar el observador, intente usar CTRL-B y CTRL-I para formatear el texto editable. Notará que esto funciona de manera mucho más efectiva que el ejemplo anterior de characterData
. En este caso, los nodos secundarios divididos no afectan al observador porque estamos observando todos los nodos dentro del nodo de destino, en lugar de un solo nodo de texto.
Registro de valores antiguos
A menudo, al observar los cambios en el DOM, querrá tomar nota de los valores antiguos y posiblemente almacenarlos o usarlos en otro lugar. Esto se puede hacer usando algunas propiedades diferentes en el objeto de options
.
atributoOldValue
Primero, intentemos cerrar sesión en el valor del atributo anterior después de que se haya cambiado. Así es como se verán mis opciones junto con mi devolución de llamada:
options = { attributes: true, attributeOldValue: true } function mCallback (mutations) { for (let mutation of mutations) { if (mutation.type === 'attributes') { // Do something here... } } }
- Ver demostración en vivo →
Observe el uso de las propiedades attributeName
y oldValue
del objeto MutationRecord
. Pruebe la demostración ingresando diferentes valores en el campo de texto. Observe cómo se actualiza el registro para reflejar el valor anterior que se almacenó.
characterDataOldValue
Del mismo modo, así es como se verían mis opciones si quiero registrar datos de caracteres antiguos:
options = { characterData: true, subtree: true, characterDataOldValue: true }
- Ver demostración en vivo →
Observe que los mensajes de registro indican el valor anterior. Las cosas se ponen un poco raras cuando agregas HTML a través de comandos de texto enriquecido a la mezcla. No estoy seguro de cuál se supone que es el comportamiento correcto en ese caso, pero es más sencillo si lo único dentro del elemento es un solo nodo de texto.
Interceptar mutaciones usando takeRecords()
Otro método del objeto MutationObserver
que aún no he mencionado es takeRecords()
. Este método le permite interceptar más o menos las mutaciones que se detectan antes de que sean procesadas por la función de devolución de llamada.
Puedo usar esta característica usando una línea como esta:
let myRecords = observer.takeRecords();
Esto almacena una lista de los cambios de DOM en la variable especificada. En mi demostración, estoy ejecutando este comando tan pronto como se hace clic en el botón que modifica el DOM. Tenga en cuenta que los botones de inicio y agregar/eliminar no registran nada. Esto se debe a que, como se mencionó, estoy interceptando los cambios de DOM antes de que la devolución de llamada los procese.
Observe, sin embargo, lo que estoy haciendo en el detector de eventos que detiene al observador:
btnStop.addEventListener('click', function () { observer.disconnect(); if (myRecords) { console.log(`${myRecords[0].target} was changed using the ${myRecords[0].type} option.`); } }, false);
Como puede ver, después de detener al observador usando observer.disconnect()
, estoy accediendo al registro de mutación que fue interceptado y estoy registrando el elemento de destino así como también el tipo de mutación que fue registrada. Si hubiera estado observando varios tipos de cambios, el registro almacenado tendría más de un elemento, cada uno con su propio tipo.
Cuando se intercepta un registro de mutación de esta manera llamando a takeRecords()
, la cola de mutaciones que normalmente se enviaría a la función de devolución de llamada se vacía. Entonces, si por alguna razón necesita interceptar estos registros antes de que se procesen, takeRecords()
sería útil.
Observación de múltiples cambios usando un solo observador
Tenga en cuenta que si busco mutaciones en dos nodos diferentes de la página, puedo hacerlo con el mismo observador. Esto significa que después de llamar al constructor, puedo ejecutar el método observe()
para tantos elementos como quiera.
Así, después de esta línea:
observer = new MutationObserver(mCallback);
Entonces puedo tener múltiples llamadas a observe()
con diferentes elementos como primer argumento:
observer.observe(mList, options); observer.observe(mList2, options);
- Ver demostración en vivo →
Inicie el observador, luego pruebe los botones Agregar/Eliminar para ambas listas. El único inconveniente aquí es que si presiona uno de los botones "detener", el observador dejará de observar ambas listas, no solo la que está apuntando.
Mover un árbol de nodos que se está observando
Una última cosa que señalaré es que MutationObserver
continuará observando los cambios en un nodo específico incluso después de que ese nodo haya sido eliminado de su elemento principal.
Por ejemplo, pruebe la siguiente demostración:
- Ver demostración en vivo →
Este es otro ejemplo que usa childList
para monitorear los cambios en los elementos secundarios de un elemento de destino. Fíjate en el botón que desconecta la sublista, que es la que se está observando. Haga clic en el botón "Inicio...", luego haga clic en el botón "Mover..." para mover la lista anidada. Incluso después de que la lista se elimine de su padre, MutationObserver
continúa observando los cambios especificados. No es una gran sorpresa que esto suceda, pero es algo a tener en cuenta.
Conclusión
Eso cubre casi todas las características principales de la API de MutationObserver
. Espero que esta inmersión profunda haya sido útil para que se familiarice con este estándar. Como se mencionó, el soporte del navegador es sólido y puede leer más sobre esta API en las páginas de MDN.
Puse todas las demostraciones de este artículo en una colección de CodePen, si desea tener un lugar fácil para jugar con las demostraciones.