Conhecendo a API MutationObserver
Publicados: 2022-03-10Em aplicativos Web complexos, as alterações do DOM podem ser frequentes. Como resultado, há casos em que seu aplicativo pode precisar responder a uma alteração específica no DOM.
Por algum tempo, a forma aceita de buscar mudanças no DOM era por meio de um recurso chamado Mutation Events, que agora está obsoleto. A substituição aprovada pelo W3C para Mutation Events é a API MutationObserver, que é o que discutirei em detalhes neste artigo.
Vários artigos e referências mais antigos discutem por que o recurso antigo foi substituído, então não entrarei em detalhes sobre isso aqui (além do fato de que não seria capaz de fazer justiça). A API MutationObserver
tem suporte quase completo ao navegador, para que possamos usá-la com segurança na maioria — se não em todos — projetos, caso haja necessidade.
Sintaxe básica para um MutationObserver
Um MutationObserver
pode ser usado de várias maneiras diferentes, que abordarei em detalhes no restante deste artigo, mas a sintaxe básica de um MutationObserver
é assim:
let observer = new MutationObserver(callback); function callback (mutations) { // do something here } observer.observe(targetNode, observerOptions);
A primeira linha cria um novo MutationObserver
usando o construtor MutationObserver()
. O argumento passado para o construtor é uma função de retorno de chamada que será chamada em cada alteração do DOM qualificada.
A maneira de determinar o que se qualifica para um observador específico é por meio da linha final no código acima. Nessa linha, estou usando o método observe()
do MutationObserver
para começar a observar. Você pode comparar isso com algo como addEventListener()
. Assim que você anexar um ouvinte, a página 'escutará' o evento especificado. Da mesma forma, quando você começar a observar, a página começará a 'observar' para o MutationObserver
especificado.
O método observe()
recebe dois argumentos: O alvo , que deve ser o nó ou a árvore de nós na qual observar as alterações; e um objeto de opções , que é um objeto MutationObserverInit
que permite definir a configuração para o observador.
O último recurso básico de um MutationObserver
é o método disconnect()
. Isso permite que você pare de observar as alterações especificadas e fica assim:
observer.disconnect();
Opções para configurar um MutationObserver
Como mencionado, o método observe()
de um MutationObserver
requer um segundo argumento que especifica as opções para descrever o MutationObserver
. Veja como o objeto de opções ficaria com todos os pares de propriedades/valores possíveis incluídos:
let options = { childList: true, attributes: true, characterData: false, subtree: false, attributeFilter: ['one', 'two'], attributeOldValue: false, characterDataOldValue: false };
Ao configurar as opções do MutationObserver
, não é necessário incluir todas essas linhas. Estou incluindo isso apenas para fins de referência, para que você possa ver quais opções estão disponíveis e quais tipos de valores elas podem assumir. Como você pode ver, todos, exceto um, são booleanos.
Para que um MutationObserver
funcione, pelo menos um childList
, attributes
ou characterData
precisa ser definido como true
, caso contrário, um erro será gerado. As outras quatro propriedades funcionam em conjunto com uma dessas três (mais sobre isso depois).
Até agora, apenas dei uma olhada na sintaxe para lhe dar uma visão geral. A melhor maneira de considerar como cada um desses recursos funciona é fornecendo exemplos de código e demonstrações ao vivo que incorporam as diferentes opções. Então é isso que eu vou fazer para o resto deste artigo.
Observando alterações em elementos filho usando childList
O primeiro e mais simples MutationObserver
que você pode iniciar é aquele que procura por nós filhos de um nó especificado (geralmente um elemento) a ser adicionado ou removido. Para o meu exemplo, vou criar uma lista não ordenada no meu HTML e quero saber sempre que um nó filho é adicionado ou removido desse elemento da lista.
O HTML da lista é assim:
<ul class="list"> <li>Apples</li> <li>Oranges</li> <li>Bananas</li> <li class="child">Peaches</li> </ul>
O JavaScript para meu MutationObserver
inclui o seguinte:
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);
Esta é apenas uma parte do código. Para resumir, estou mostrando as seções mais importantes que tratam da própria API do MutationObserver
.
Observe como estou fazendo um loop pelo argumento de mutations
, que é um objeto MutationRecord
que tem várias propriedades diferentes. Nesse caso, estou lendo a propriedade type
e registrando uma mensagem indicando que o navegador detectou uma mutação que se qualifica. Além disso, observe como estou passando o elemento mList
(uma referência à minha lista HTML) como o elemento de destino (ou seja, o elemento no qual quero observar as alterações).
- Veja a demonstração interativa completa →
Use os botões para iniciar e parar o MutationObserver
. As mensagens de log ajudam a esclarecer o que está acontecendo. Comentários no código também fornecem alguma explicação.
Observe alguns pontos importantes aqui:
- A função de retorno de chamada (que chamei de
mCallback
, para ilustrar que você pode nomeá-la como quiser) será acionada toda vez que uma mutação bem-sucedida for detectada e depois que o métodoobserve()
for executado. - No meu exemplo, o único 'tipo' de mutação que se qualifica é
childList
, então faz sentido procurar por este ao fazer um loop pelo MutationRecord. Procurar qualquer outro tipo nesta instância não faria nada (os outros tipos serão usados em demonstrações subsequentes). - Usando
childList
, posso adicionar ou remover um nó de texto do elemento de destino e isso também se qualificaria. Portanto, não precisa ser um elemento adicionado ou removido. - Neste exemplo, apenas nós filho imediatos serão qualificados. Mais adiante neste artigo, mostrarei como isso pode ser aplicado a todos os nós filhos, netos e assim por diante.
Observando as mudanças nos atributos de um elemento
Outro tipo comum de mutação que você pode querer rastrear é quando um atributo em um elemento especificado é alterado. Na próxima demonstração interativa, observarei as alterações nos atributos de um elemento de parágrafo.
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);
- Experimente a demonstração →
Novamente, abreviei o código para maior clareza, mas as partes importantes são:
- O objeto de
options
está usando a propriedadeattributes
, definida comotrue
para informar aoMutationObserver
que quero procurar alterações nos atributos do elemento de destino. - O tipo de mutação que estou testando no meu loop é
attributes
, o único que se qualifica nesse caso. - Também estou usando a propriedade
attributeName
do objeto demutation
, que me permite descobrir qual atributo foi alterado. - Quando eu aciono o observador, estou passando o elemento de parágrafo por referência, junto com as opções.
Neste exemplo, um botão é usado para alternar um nome de classe no elemento HTML de destino. A função de retorno de chamada no observador de mutação é acionada toda vez que a classe é adicionada ou removida.
Observando Alterações de Dados de Caracteres
Outra mudança que você pode querer procurar em seu aplicativo são as mutações nos dados de caracteres; ou seja, muda para um nó de texto específico. Isso é feito definindo a propriedade characterData
como true
no objeto de options
. Aqui está o 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 novamente que o type
procurado na função de retorno de chamada é characterData
.
- Veja a demonstração ao vivo →
Neste exemplo, estou procurando alterações em um nó de texto específico, que direciono por meio de element.childNodes[0]
. Isso é um pouco hacky, mas servirá para este exemplo. O texto é editável pelo usuário por meio do atributo contenteditable
em um elemento de parágrafo.
Desafios ao observar as alterações nos dados dos caracteres
Se você brincou com contenteditable
, deve estar ciente de que existem atalhos de teclado que permitem a edição de rich text. Por exemplo, CTRL-B torna o texto em negrito, CTRL-I torna o texto em itálico e assim por diante. Isso dividirá o nó de texto em vários nós de texto, então você notará que o MutationObserver
parará de responder, a menos que você edite o texto que ainda é considerado parte do nó original.
Também devo salientar que, se você excluir todo o texto, o MutationObserver
não acionará mais o retorno de chamada. Estou assumindo que isso acontece porque uma vez que o nó de texto desaparece, o elemento de destino não existe mais. Para combater isso, minha demonstração para de observar quando o texto é removido, embora as coisas fiquem um pouco complicadas quando você usa atalhos de rich text.
Mas não se preocupe, mais adiante neste artigo, discutirei uma maneira melhor de usar a opção characterData
sem ter que lidar com tantas dessas peculiaridades.
Observando Mudanças nos Atributos Especificados
Anteriormente, mostrei a você como observar as alterações nos atributos de um elemento especificado. Nesse caso, embora a demonstração acione uma alteração no nome da classe, eu poderia ter alterado qualquer atributo no elemento especificado. Mas e se eu quiser observar mudanças em um ou mais atributos específicos enquanto ignoro os outros?
Eu posso fazer isso usando a propriedade attributeFilter
opcional no objeto de option
. Aqui está um exemplo:
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... } } }
Conforme mostrado acima, a propriedade attributeFilter
aceita uma matriz de atributos específicos que desejo monitorar. Neste exemplo, o MutationObserver
acionará o retorno de chamada toda vez que um ou mais dos atributos hidden
, contenteditable
de conteúdo ou data-par
forem modificados.
- Veja a demonstração ao vivo →
Novamente, estou direcionando um elemento de parágrafo específico. Observe a lista suspensa de seleção que escolhe qual atributo será alterado. O atributo draggable
é o único que não se qualifica, pois não o especifiquei nas minhas opções.
Observe no código que estou usando novamente a propriedade attributeName
do objeto MutationRecord
para registrar qual atributo foi alterado. E, claro, como nas outras demos, o MutationObserver
não iniciará o monitoramento de alterações até que o botão “iniciar” seja clicado.
Uma outra coisa que devo salientar aqui é que não preciso definir o valor dos attributes
como true
neste caso; está implícito devido a attributesFilter
sendo definido como true. É por isso que meu objeto de opções pode ter a seguinte aparência e funcionaria da mesma forma:
let options = { attributeFilter: ['hidden', 'contenteditable', 'data-par'] }
Por outro lado, se eu definir explicitamente os attributes
como false
junto com uma matriz attributeFilter
, não funcionará porque o valor false
teria precedência e a opção de filtro seria ignorada.
Observando as alterações nos nós e sua subárvore
Até agora, ao configurar cada MutationObserver
, eu estava lidando apenas com o próprio elemento de destino e, no caso de childList
, com os filhos imediatos do elemento. Mas certamente pode haver um caso em que eu queira observar as alterações em um dos seguintes:
- Um elemento e todos os seus elementos filhos;
- Um ou mais atributos em um elemento e em seus elementos filhos;
- Todos os nós de texto dentro de um elemento.
Todos os itens acima podem ser obtidos usando a propriedade subtree
do objeto options.
childList Com subárvore
Primeiro, vamos procurar alterações nos nós filhos de um elemento, mesmo que não sejam filhos imediatos. Eu posso alterar meu objeto de opções para ficar assim:
options = { childList: true, subtree: true }
Todo o resto no código é mais ou menos o mesmo que o exemplo childList
anterior, junto com algumas marcações e botões extras.
- Veja a demonstração ao vivo →
Aqui há duas listas, uma aninhada dentro da outra. Quando o MutationObserver
for iniciado, o retorno de chamada será acionado para alterações em qualquer uma das listas. Mas se eu mudar a propriedade da subtree
de volta para false
(o padrão quando não está presente), o retorno de chamada não será executado quando a lista aninhada for modificada.
Atributos com subárvore
Aqui está outro exemplo, desta vez usando subtree
com attributes
e attributeFilter
. Isso me permite observar alterações nos atributos não apenas no elemento de destino, mas também nos atributos de qualquer elemento filho do elemento de destino:
options = { attributes: true, attributeFilter: ['hidden', 'contenteditable', 'data-par'], subtree: true }
- Veja a demonstração ao vivo →
Isso é semelhante à demonstração de atributos anterior, mas desta vez eu configurei dois elementos de seleção diferentes. O primeiro modifica atributos no elemento de parágrafo de destino, enquanto o outro modifica atributos em um elemento filho dentro do parágrafo.
Novamente, se você definir a opção de subtree
de volta para false
(ou removê-la), o segundo botão de alternância não acionaria o retorno de chamada MutationObserver
. E, é claro, eu poderia omitir attributeFilter
completamente, e o MutationObserver
procuraria alterações em quaisquer atributos na subárvore em vez dos especificados.
characterData Com subárvore
Lembre-se na demonstração anterior do characterData
, houve alguns problemas com o desaparecimento do nó de destino e, em seguida, o MutationObserver
não funcionou mais. Embora existam maneiras de contornar isso, é mais fácil direcionar um elemento diretamente em vez de um nó de texto e, em seguida, usar a propriedade subtree
para especificar que eu quero que todos os dados de caracteres dentro desse elemento, não importa o quão profundamente aninhados sejam, sejam acionados o retorno de chamada MutationObserver
.
Minhas opções neste caso ficariam assim:
options = { characterData: true, subtree: true }
- Veja a demonstração ao vivo →
Depois de iniciar o observador, tente usar CTRL-B e CTRL-I para formatar o texto editável. Você notará que isso funciona de forma muito mais eficaz do que o exemplo anterior de characterData
. Nesse caso, os nós filhos quebrados não afetam o observador porque estamos observando todos os nós dentro do nó de destino, em vez de um único nó de texto.
Gravando Valores Antigos
Muitas vezes, ao observar as alterações no DOM, você desejará anotar os valores antigos e possivelmente armazená-los ou usá-los em outro lugar. Isso pode ser feito usando algumas propriedades diferentes no objeto de options
.
atributoOldValue
Primeiro, vamos tentar desconectar o valor do atributo antigo depois que ele for alterado. Veja como minhas opções serão exibidas junto com meu retorno de chamada:
options = { attributes: true, attributeOldValue: true } function mCallback (mutations) { for (let mutation of mutations) { if (mutation.type === 'attributes') { // Do something here... } } }
- Veja a demonstração ao vivo →
Observe o uso das propriedades attributeName
e oldValue
do objeto MutationRecord
. Experimente a demonstração inserindo valores diferentes no campo de texto. Observe como o log é atualizado para refletir o valor anterior que foi armazenado.
characterDataOldValue
Da mesma forma, veja como minhas opções ficariam se eu quisesse registrar dados de caracteres antigos:
options = { characterData: true, subtree: true, characterDataOldValue: true }
- Veja a demonstração ao vivo →
Observe que as mensagens de log indicam o valor anterior. As coisas ficam um pouco complicadas quando você adiciona HTML por meio de comandos de rich text à mistura. Não tenho certeza de qual deve ser o comportamento correto nesse caso, mas é mais direto se a única coisa dentro do elemento for um único nó de texto.
Interceptando Mutações Usando TakeRecords()
Outro método do objeto MutationObserver
que ainda não mencionei é takeRecords()
. Esse método permite interceptar mais ou menos as mutações detectadas antes de serem processadas pela função de retorno de chamada.
Eu posso usar esse recurso usando uma linha como esta:
let myRecords = observer.takeRecords();
Isso armazena uma lista das alterações do DOM na variável especificada. Na minha demonstração, estou executando este comando assim que o botão que modifica o DOM é clicado. Observe que os botões iniciar e adicionar/remover não registram nada. Isso ocorre porque, como mencionado, estou interceptando as alterações do DOM antes que elas sejam processadas pelo retorno de chamada.
Observe, no entanto, o que estou fazendo no ouvinte de eventos que interrompe o observador:
btnStop.addEventListener('click', function () { observer.disconnect(); if (myRecords) { console.log(`${myRecords[0].target} was changed using the ${myRecords[0].type} option.`); } }, false);
Como você pode ver, após parar o observador usando observer.disconnect()
, estou acessando o registro de mutação que foi interceptado e estou registrando o elemento alvo bem como o tipo de mutação que foi registrada. Se eu estivesse observando vários tipos de alterações, o registro armazenado teria mais de um item, cada um com seu próprio tipo.
Quando um registro de mutação é interceptado dessa maneira chamando takeRecords()
, a fila de mutações que normalmente seria enviada para a função de retorno de chamada é esvaziada. Portanto, se por algum motivo você precisar interceptar esses registros antes de serem processados, takeRecords()
seria útil.
Observando Múltiplas Mudanças Usando um Único Observador
Observe que, se estou procurando mutações em dois nós diferentes na página, posso fazê-lo usando o mesmo observador. Isso significa que depois de chamar o construtor, posso executar o método observe()
para quantos elementos eu quiser.
Assim, após esta linha:
observer = new MutationObserver(mCallback);
Eu posso então ter várias chamadas observe()
com diferentes elementos como o primeiro argumento:
observer.observe(mList, options); observer.observe(mList2, options);
- Veja a demonstração ao vivo →
Inicie o observador e tente os botões adicionar/remover para ambas as listas. O único problema aqui é que, se você pressionar um dos botões “parar”, o observador parará de observar as duas listas, não apenas a que está mirando.
Movendo uma árvore de nós que está sendo observada
Uma última coisa que vou apontar é que um MutationObserver
continuará a observar as alterações em um nó especificado mesmo depois que esse nó for removido de seu elemento pai.
Por exemplo, experimente a seguinte demonstração:
- Veja a demonstração ao vivo →
Este é outro exemplo que usa childList
para monitorar alterações nos elementos filho de um elemento de destino. Observe o botão que desconecta a sub-lista, que é a que está sendo observada. Clique no botão “Iniciar…” e, em seguida, clique no botão “Mover…” para mover a lista aninhada. Mesmo depois que a lista é removida de seu pai, o MutationObserver
continua a observar as alterações especificadas. Não é uma grande surpresa que isso aconteça, mas é algo para se ter em mente.
Conclusão
Isso abrange praticamente todos os principais recursos da API MutationObserver
. Espero que este mergulho profundo tenha sido útil para você se familiarizar com esse padrão. Como mencionado, o suporte ao navegador é forte e você pode ler mais sobre essa API nas páginas do MDN.
Coloquei todas as demos deste artigo em uma coleção do CodePen, caso você queira ter um lugar fácil para brincar com as demos.