Conhecendo a API MutationObserver

Publicados: 2022-03-10
Resumo rápido ↬ O monitoramento de alterações no DOM às vezes é necessário em estruturas e aplicativos Web complexos. Por meio de explicações junto com demonstrações interativas, este artigo mostrará como você pode usar a API MutationObserver para tornar a observação de alterações do DOM relativamente fácil.

Em 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.

Mais depois do salto! Continue lendo abaixo ↓

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étodo observe() 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 propriedade attributes , definida como true para informar ao MutationObserver 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 de mutation , 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.