DOM 要素の変更を監視する方法を調べました

span の中の文字列が変わったというイベントをトリガーにして処理したかったので調べました。 jQuery を使う前提で調べたのですが、調べた結果、 jQuery を使わない方法 (MutationObserver) になりました。 調べたことを書いておきます。

環境

  • jQuery 3.3.1
  • Microsoft Edge 16.16299
  • Firefox 59.0.2

MutationEvent

最初、次のイベントが検索で見つかりましたので、実装してみました。

MutationEvent - Web APIs | MDN

次のようなファイルを作成しました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>MutationEvent</title>
  </head>
  <body>
    <h1>MutationEvent</h1>
    <hr>
    <form>
      <input type="text" id="text1" value="value1">
      <span id="span1">span1</span>
    </form>
    <hr>
    <script src="//code.jquery.com/jquery-3.3.1.slim.min.js"></script>
    <script>
      $(function () {
        $('#text1').on('change', function () {
          $('#span1').text($(this).val());
        });
        var callback = function (event) {
          console.log('DOMSubtreeModified')
        };
        // Firefox の警告
        // Mutation Event の使用は推奨されません。代わりに MutationObserver を使用してください。
        $('#span1').on('DOMSubtreeModified', callback);
        // document.querySelector('#span1').addEventListener('DOMSubtreeModified', callback); // jQuery を使わない場合
      })
    </script>
  </body>
</html>

text の値を変えて、フォーカスを移動すると、 text の値が span に表示されて、コンソールに “DOMSubtreeModified” と出力されます。

なぜか 2 回も出力されます。

そして、 code blocks のコメントにも書きましたが、 Firefox で実行すると、次の警告がコンソールに出力されます。 “Mutation Event の使用は推奨されません。代わりに MutationObserver を使用してください。” これは、 jQuery から出力されているようです。

Deprecated

This feature has been removed from the Web standards. Though some browsers may still support it, it is in the process of being dropped. Avoid using it and update existing code if possible; see the compatibility table at the bottom of this page to guide your decision. Be aware that this feature may cease to work at any time.

MutationEvent - Web APIs | MDN

ここにも非推奨であることが記載されていました。

MutationObserver

なので、 MutationObserver について調べました。

MutationObserver - Web APIs | MDN

次のファイルを作成しました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>MutationObserver</title>
  </head>
  <body>
    <h1>MutationObserver</h1>
    <hr>
    <form>
      <input type="text" id="text1" value="value1">
      <span id="span1">span1</span>
    </form>
    <hr>
    <script>
      document.addEventListener("DOMContentLoaded", function (event) {
        document.querySelector('#text1').addEventListener('change', function () {
          document.querySelector('#span1').textContent = this.value;
        });
        // Select the node that will be observed for mutations
        var targetNode = document.querySelector('#span1');

        // Options for the observer (which mutations to observe)
        var config = { attributes: true, childList: true, characterData: true };

        // Callback function to execute when mutations are observed
        var callback = function (mutationsList) {
          for (var mutation of mutationsList) {
            if (mutation.type === 'childList') {
              console.log('A child node has been added or removed.');
            } else if (mutation.type === 'attributes') {
              console.log('The ' + mutation.attributeName + ' attribute was modified.');
            } else if (mutation.type === 'characterData') {
              console.log('characterData was modified.');
            }
          }
        };

        // Create an observer instance linked to the callback function
        var observer = new MutationObserver(callback);

        // Start observing the target node for configured mutations
        observer.observe(targetNode, config);
      });
    </script>
  </body>
</html>

text の値を変えて、フォーカスを移動すると、 text の値が span に表示されて、コンソールに “A child node has been added or removed.” と出力されます。 2 回も出力されますけれども。

var config = { attributes: true, childList: true, characterData: true }; について、監視対象を選択できるようで、次のような意味になるようです。

  • attributes: 対象ノードの属性に対する変更を監視する場合は true にします。
  • childList: 対象ノードの子ノード(テキストノードも含む)に対する追加・削除を監視する場合は true にします。
  • characterData: 対象ノードのデータに対する変更を監視する場合は true にします。
  • subtree: 対象ノードとその子孫ノードに対する変更を監視する場合は true にします。
  • attributeOldValue: 対象ノードの変更前の属性値を記録する場合は true にします(attributes が true の時に有効)。
  • characterDataOldValue: 対象ノードの変更前のデータを記録する場合は true にします(characterData が true の時に有効)。
  • attributeFilter: すべての属性の変更を監視する必要がない場合は、(名前空間を除いた)属性ローカル名の配列を指定します。

コンソールに出力された内容を見ると、 span の中の文字列を変更すると、 childList が変更されたことになるようです。

MutationObserver の characterData

最初、 span の中の文字列が変わった場合に処理をしたかったので、 characterData を使えば良いと思って、 characterData だけ true にしました。 が、イベントが発生しませんでした。 childListtrue にしたところ、イベントが発生したので、そういうものなのかな?と思いました。

childList

対象ノードの子ノード(テキストノードも含む)に対する追加・削除を監視する場合は true にします。

MutationObserver - Web API インターフェイス | MDN

テキストノードの追加・削除の監視も含まれるようです。 だから childList のイベントが発生しているようではあります。

Stack Overflow

じゃあ、 characterData っていうのは、どういうときに発生するのか? もう少し調べてみると、次のような場合に発生するようです。

You can observe a text node directly. In that case you don't need to observe childList. There are many cases where it could be useful, in a contenteditable element for example. Like this:

// select the target node
var target = document.querySelector('#some-id').childNodes[0];

// create an observer instance
var observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    console.log(mutation.type);
  });    
});
 
// configuration of the observer:
var config = { attributes: true, childList: false, characterData: true };
 
// pass in the target node, as well as the observer options
observer.observe(target, config);

<div id='some-id' contenteditable='true'>Modify content</div>

javascript - MutationObserver characterData usage without childList - Stack Overflow

document.querySelector('#some-id') ではダメで、 document.querySelector('#some-id').childNodes[0] というテキストノードを MutationObserver.observe の 1 つ目の引数に渡す必要があるようでした。

修正 1

Stack Overflow を参考に、次のファイルを作成しました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>MutationObserver</title>
  </head>
  <body>
    <h1>MutationObserver</h1>
    <hr>
    <form id="form1">
      <input type="text" id="text1" value="value1">
      <span id="span1" contenteditable="true">span1</span><!-- ← contenteditable="true" を追加する -->
    </form>
    <hr>
    <script>
      document.addEventListener("DOMContentLoaded", function (event) {
        document.querySelector('#text1').addEventListener('change', function () {
          document.querySelector('#span1').textContent = this.value;
        });
        // Select the node that will be observed for mutations
        var targetNode = document.querySelector('#span1').childNodes[0]; // ← テキストノード

        // Options for the observer (which mutations to observe)
        var config = { attributes: false, childList: false, characterData: true }; // ← characterData だけ true にする

        // Callback function to execute when mutations are observed
        var callback = function (mutationsList) {
          for (var mutation of mutationsList) {
            if (mutation.type === 'childList') {
              console.log('A child node has been added or removed.');
            } else if (mutation.type === 'attributes') {
              console.log('The ' + mutation.attributeName + ' attribute was modified.');
            } else if (mutation.type === 'characterData') {
              console.log('characterData was modified.');
            }
          }
        };

        // Create an observer instance linked to the callback function
        var observer = new MutationObserver(callback);

        // Start observing the target node for configured mutations
        observer.observe(targetNode, config);
      });
    </script>
  </body>
</html>

初めて知ったのですが、 span って、 contenteditable="true" の属性をつけることで、ユーザーが直接 span の中の文字列を変えることができるのですね。 ユーザーが span の中の文字列を変えると、コンソールに “characterData was modified.” と出力されます。 characterData のイベントが発生しているようです。

しかし、 text の値を変えてフォーカスを移動すると、 span の中の文字列は変わるのに、コンソールに “characterData was modified.” とは出力されません。 (何も出力されません) イベントが発生していないようです。

document.querySelector('#span1').childNodes[0] (テキストノード)に対してイベントリスナーを設定しているので、 document.querySelector('#span1').textContent = this.value とすることで、イベントリスナーを設定したテキストノード自体が別のテキストノードと入れ替わってしまっているように見えました。 入れ替わったテキストノードにはイベントリスナーは設定されていないから、その後、 span の中の文字列を直接変えてもイベントは発生しませんでした。

修正 2

なので、次のように修正しました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>MutationObserver</title>
  </head>
  <body>
    <h1>MutationObserver</h1>
    <hr>
    <form id="form1">
      <input type="text" id="text1" value="value1">
      <span id="span1">span1</span><!-- ← contenteditable="true" を削除する -->
    </form>
    <hr>
    <script>
      document.addEventListener("DOMContentLoaded", function (event) {
        document.querySelector('#text1').addEventListener('change', function () {
          document.querySelector('#span1').childNodes[0].textContent = this.value; // ← テキストノードに値を設定する
        });
        // Select the node that will be observed for mutations
        var targetNode = document.querySelector('#span1').childNodes[0];

        // Options for the observer (which mutations to observe)
        var config = { attributes: false, childList: false, characterData: true };

        // Callback function to execute when mutations are observed
        var callback = function (mutationsList) {
          for (var mutation of mutationsList) {
            if (mutation.type === 'childList') {
              console.log('A child node has been added or removed.');
            } else if (mutation.type === 'attributes') {
              console.log('The ' + mutation.attributeName + ' attribute was modified.');
            } else if (mutation.type === 'characterData') {
              console.log('characterData was modified.');
            }
          }
        };

        // Create an observer instance linked to the callback function
        var observer = new MutationObserver(callback);

        // Start observing the target node for configured mutations
        observer.observe(targetNode, config);
      });
    </script>
  </body>
</html>

document.querySelector('#span1').childNodes[0].textContent = this.value と、テキストノードに値を設定してみました。

text の値を変えて、フォーカスを移動すると、 text の値が span に表示されて、コンソールに “characterData was modified.” と出力されます。

思ったことは実現できました。 でも、 span の中の文字列を変えるのに .childNodes とか、あまり記述したくない感じがします。

終わり

MutationObserver を使って、 childList: true の設定にするのが妥当なところかな。

それから、 MutationObserver は IE11 から実装されたそうです。