uhyohyo.net

JavaScript初級者から中級者になろう

十七章第一回 MutationObserver

このページの最終更新日:

第十七章では、久しぶりにDOMの話題です。DOMは主に第一篇(第二章〜第十章)で詳しく紹介しました。第十二章のHTML5に関する内容は、これまでに紹介したDOMの内容を基礎としていました。というのも、第十二章で紹介したのはDOMを使ってHTMLの各機能を操作する方法、つまりDOMの枠組にHTML5を当てはめた結果です。実際、HTML5の仕様には、各要素の定義と同時にその要素のDOM定義も定められています。

DOMも数回のバージョンアップを経験しており、伝統的にDOMのバージョンはレベルと呼ばれています。レベルが高いほど、後に定めれた高度な内容となっています。今までDOMにはレベル1から3までありました。例えばgetElementByIdなど基礎的なものはレベル1で、第十章で紹介したXPath、またtextContentなどがレベル3にあたります。レベル3までのDOMは2004年までに策定完了しており、比較的古典的な概念でした。

そして今回、第十七章で紹介するのがDOM4です。DOM Level 3がさらに進化したものであるのは言うまでもありません。ちなみに、W3C仕様では、DOM3までは正式名称を「Document Object Model Level 3」といったのに対し、DOM4はそれが正式名称となっています。なお、HTML5と同様、WHATWGによるLiving Standardも策定されています。

また、DOMの大きなターゲットはやはりHTML文書の操作です。本章ではDOM4とHTMLの関わりについても紹介していきます。

MutationObserver

DOM4にはいろいろな新要素が追加されたわけですが、今回紹介するのはMutationObserverです。どんなものか詳しく紹介する前に、まずはその使い所を見てみましょう。

例えば、HTML5で新しく追加されたtime要素。これは、「時間」の情報を持った要素です。例えば、


<time datetime="2015-05-09">今日</time>

とした場合、この「今日」が2015年5月9日を示しているということを表します。ただ、このtime要素に対するブラウザ側の視覚的対応はあまり進んでいません。time要素により日時情報が指定されていても、ユーザーがそれを知るのは難しいでしょう。

そこで、JavaScriptでtime要素に対してtitle属性を付加し、マウスを乗せたら日時の情報が表示されるようにしましょう。CSSで見た目をこんな感じにしておけば、マウスを乗せたくなるかもしれません(スマートフォンはどうするかとかはここでは考えません)。

それを実現する典型的な方法は、ページを読み込み終わった時点でページの中の全てのtime要素を調べてひとつひとついじるというものです。

ページを読み込み終わった時点というのは、JavaScript的観点からは2種類あります。1つは、HTMLを読んで解析が終わりDOMツリー(documentを頂点とし、その下にhtml要素、その下にheadとbody……と続くDOMの木構造)が構築完了した段階で、この時点で発生するイベントをDOMContentLoadedといいます。もう1つは、ページを全て、画像や外部CSSなどの構成要素を全て読み込み終わった段階で、こちらで発生するイベントはloadです。DOMContentLoadedはdocumentで発生し、loadはwindowで発生するという違いがあるので注意してください。

こういう風にDOMをいじりたい場合、画像やCSSが読み込まれているかどうかは関係ないため、DOMContentLoadedがよく使われます。


document.addEventListener("DOMContentLoaded",function(e){

},false);

文書中のtime要素を全て取得するにはgetElementsByTagNameを使う方法もありますが、ここでは別の方法を紹介します。

それはquerySelectorAllを使う方法です。これは、要素名の代わりにCSSのセレクタを指定して、全て集めてきてもらうことができます。今回の場合は要素名なのでgetElementsByTagNameと変わりませんが、querySelectorAllのほうはより複雑な条件を指定できます。

返り値はどちらもNodeListですが、getElementsByTagNameで返されるものとquerySelectorAllで返されるものは、動的かそうでないかという違いがあります。getElementsByTagNameで返されるNodeListは動的ですが、querySelectorAllはそうではありません。

動的というのは、呼び出しの後に起きた変化が反映されるということです。つまり、getElementsByTagNameでtime要素の一覧を取得した後にtime要素が増えたり減ったりした場合、それに応じて返されたNodeListが勝手に変化するのです。一方querySelectorAllは動的でないので、返されたNodeListは返されたときの状態を保ち、勝手に変化しません。

さて、なんだかんだでtime要素のノード(HTMLTimeElement)を取得すると、そのdatetime属性は文字列でdateTimeプロパティに入っています。大文字小文字に注意しましょう。

……と言いたいのですが、現在(2017年10月)、主要ブラウザはこのdateTimeプロパティに対応していません。そこで、今回はgetAttributeを使うことにします。また、本当は必ずしもdatetime属性が使われるわけではなく、


<time>2015-05-09 23:00</time>

のように、datetime属性の代わりにtime要素の中に直接日時を表す文字列を書くこともあります。さらに、ひとえに日時を表す文字列といっても、ISO 8601形式をはじめとしてさまざまな書き方が既定されています。さらに、日時時刻ばかりでなく時間(何分とか何時間とか)を表す文字列かもしれません。

今回はサンプルなのであまり深く考えず、とりあえずdateTimeから取ってきて、Dateコンストラクタに渡してDateオブジェクトに変換して表示することにしましょう。これをまとめると以下のようになります。今回はArray.fromを使ってNodeListを簡単に扱うやり方を実践してみました。


document.addEventListener("DOMContentLoaded",function(e){
  const times = document.querySelectorAll("time");
  for(const time of Array.from(times)){
    const date=new Date(time.getAttribute("datetime"));
    time.title = date.toString();
  }
},false);

さて、できました。しかしこれは前座です。そもそもまだMutationObserverが出てきてませんね。

この例で何が問題かというと、ページを読み込んだ時点であったtime要素には対応できますが、逆にいうとそれ以外は対応できません。今の時代、ページはJavaScriptによってどんどん書き換えられます。この例では処理はDOMContentLoadedイベント発生時に走るだけなので、当然ながら途中で新しく追加されたtime要素に対応することができません。これで困ったという場面に遭遇するのは、多くの人がする経験でしょう。

この問題を解決するには。新しいtime要素(より一般には、新しい要素)がページ(DOMツリー)に追加されたことを検知する方法があればいいことになります。

実は、その方法がDOM3の時点ですでに提供されていました。Mutation Eventsと呼ばれるもので、新しい要素が追加されるとDOMNodeInsertedというイベントが発生し、それを検知することで前述のことを実現することができました。しかし、Mutation Eventsはパフォーマンスに問題があるとかで、現在では非推奨になっています。その代わりに推奨される方法としてDOM4で提供されているのが、Mutation Observerなのです。

Mutation Observerは、DOMに対する変更を検知し、コールバックにより通知してくれます。ではいよいよ、具体例を見てみましょう。

Mutation Observerを使うには、まずMutationObserverオブジェクトを作ります。


var mo = new MutationObserver(function(records){
});

ここでコンストラクタの第1引数にコールバック関数が渡されています。実は、DOMツリーに変更があるたびにこの関数が呼ばれます。第1引数(今回はrecords)としてMutationRecordの配列です。MutationRecordはどんな変更が加えられたのかを示すオブジェクトです。

MutationRecordの見方は一旦置いておいて、まずはここで作ったMutationObserverの使い方を見てみましょう。

実際に監視を開始するには、observeメソッドを呼び出します。第1引数は監視するノード、第2引数はオプションオブジェクトです。

オプションでは、どのような変更を検知するかを指定できます。次のプロパティをtrueにすることで、その変更を監視できます。

childList
子要素が増減したときに検知します。子要素の中身が変わったというわけでは対象になりません(その子要素自身は、中身がどうなっても健在なので)。
attributes
属性が変更されたときに検知します。
characterData
テキストノードの内容が変更されたときに検知します(実はコメントノードも監視できます)。

注意すべきなのは、ここではobserveの第1引数に指定したノードのみが監視されます。つまり、例えばbody要素をchildList:trueで監視した場合、body要素の直下に要素が追加された場合は該当するので検知しますが、遠い子孫は検知できません。また、characterDataでテキストの変更を検知するには、まさにそのテキストノードを監視対象にしなければいけません。

しかしこれでは、今の目標である「任意の位置に挿入されたtime要素に対して処理する」という目的を達成できません。そこで、次のオプションが有用です。

subtree
observeの第1引数に指定したノードだけでなく、その子孫のノードたちも一緒に監視します。

このsubtreeをtrueにすると、そこから下を全部監視できます。これを利用してdocumentを監視すれば、文書中の全てのノードの増減を検知できます。

こうしてスタートした監視は、disconnectメソッド(引数なし)を呼ぶことで中止できます。ここまでをまとめるとこうですね。


var mo = new MutationObserver(function(records){
  // ...
});
mo.observe(document, {
  childList: true,
  subtree: true,
});

では、いよいよコールバックが呼ばれたときの話です。MutationRecordオブジェクトは以下のプロパティを持ちます。

type
何が発生したかです。"childList", "attributes", "characterData"の3種類です。
target
変更が発生したノードです。childListの場合は、実際に追加あるいは削除されたノードの親ノードが該当することに注意してください。
addedNodes
typeがchildListの場合に、新たに追加されたノードが入ったNodeListです。
removedNodes
同様に、除去されたノードのNodeListです。
previousSibling
追加/除去されたノードのpreviousSibling(前の兄弟)にあたるノードです。存在しないことがあります。
nextSibling
同様に、追加/除去されたノードのnextSibling(次の兄弟)にあたるノードです。こちらも存在しないことがあります。
attributeName
typeがattributesの場合に、変更された属性の名前です。
attributeNamespace
変更された属性が名前空間に属する場合、その名前空間です。
oldValue
typeがattributesかcharacterDataの場合、変更前の値です。

どのような変更が起きたかに関する情報が集まっています。注意点がいくつかあります。

まず、最後のoldValueを使いたい場合は、observeのオプションで追加のフラグを立てる必要があります。

attributeOldValue
typeがattributesのときにoldValueを使いたい場合。
characterDataOldValue
typeがcharacterDataのときにoldValueを使いたい場合。

余談ですが、DOM4は最近の仕様なので気を利かせてくれているのか、attributeOldValueをtrueにしたときはattributesオプションを省略できます。characterDataも同様です。しかし、attributeOldValueをtrueにしたのにattributesをわざとfalseにした場合は怒られます。

もう1つの注意点は、typeがchildListの場合、addedNodesやremovedNodesが複数入っている場合があるということです。例えばDocumentFragmentをappendChildやinsertBeforeで追加した場合は複数のノードがまとめて入るので、addedNodesが複数のノードからなるNodeListになりえます。ちなみに、DocumentFragmentを他のノードに追加したとき、その中身のみ追加され、DocumentFragmentは空になります。このときもしDocumentFragmentを監視していれば、複数のノードからなるremovedNodesを観測することができます。他に、addedNodesやremovedNodesが両方あるという場合もあります。これはreplaceChildを使った場合です。このように多様な場合があるので、ちゃんとaddedChildとremoveChildの中身を(必要ならば)全て見るようにするのがよいでしょう。ちなみに、Range等を使った操作の場合、1回の操作で複数のMutationRecordが発生する場合もあります。そのため、配列として渡されたMutationRecordはちゃんと全部読むようにしましょう。

では、早速やってみましょう。新しく追加されたtime要素に対してもさっきと同様の処理をするサンプルを作ります。


var mo = new MutationObserver(function(records){
  for (const record of records){
    //typeが"childList"だったら……(今回は他のは監視していないけど一応)
    if(record.type==="childList"){
      //addedNodesを確認する
      for(const node of Array.from(record.addedNodes)){
        //time要素だったらさっきの処理をする
        if(node.nodeName === "TIME"){
          addTitle(node);
        }
      }
    }
  }
});
mo.observe(document,{
  childList:true,
  subtree:true
});

//time要素にtitleを加える処理
function addTitle(time){
  var date=new Date(time.getAttribute("datetime"));
  time.title=date.toString();
}

一見よさそうですね。しかし、実はこれではうまくいきません。何がまずいかお分かりでしょうか。

まずいのは、例えば次のようなHTMLが挿入された場合です。


<p>ああ、<time datetime="2015-05-09">今日</time>はいい天気だなあ。</p>
p
  • #text "ああ、"
  • time
    • #text "今日"
  • #text "はいい天気だなあ。"

このように木構造を図示してみせるのも久しぶりですね。実はこれを文書に追加した場合、p要素が1つ追加されたと見なされます。addedNodesにはp要素しか入りません。中身の要素を一つ一つ教えてくれるなんてことはないのです。

なので、全ての要素を網羅したければ、追加された各要素の中身を自分で調べる必要があります。

それを考慮に入れた完成形が以下です。


var mo = new MutationObserver(function(records){
  for (const record of records){
    //typeが"childList"だったら……(今回は他のは監視していないけど一応)
    if(record.type==="childList"){
      //addedNodesを確認する
      for(const node of Array.from(record.addedNodes)){
        //テキストノード等の場合querySelectorAllがないので確認する
        if(node.querySelectorAll){
          //ノードの中身からtime要素を探す
          const times=node.querySelectorAll("time");
          for(const time of Array.from(times)){
            addTitle(time);
          }
        }
      }
    }
  }
});
mo.observe(document,{
  childList:true,
  subtree:true
});

//time要素にtitleを加える処理
function addTitle(time){
  var date=new Date(time.getAttribute("datetime"));
  time.title=date.toString();
}

これを使ったサンプルを用意したので、実際に試してみましょう。サンプル

例のごとく、ソースを見るなりなんなりして研究してみてください。

今回MutationRecordのtargetは使わずにaddedNodesを見ましたが、targetはこれらの親が入っていました。attributesやcharacterDataの監視をする場合は、常に変更があった対象はtargetになるのでそちらを参照してください。

これでMutationObserverの紹介はだいたい終わりですが、最後に紹介しきれなかった部分を紹介して終わります。

まず、observeのオプションでtypeがattributesのとき、追加で指定できるオプションがあります。

attributeFilter
属性名(文字列)の配列です。指定すると、この配列で指定された属性しか監視されなくなります。

これにより、関係のない属性の変更を監視対象から外すことができます。

他に、MutationObserverのメソッドしてobserveとdisconnectを紹介しましたが、もうひとつtakeRecordsというメソッドがあるのでこれも紹介します。ただし、このメソッドはあまり使い所が多くないでしょう。

takeRecordsは、既に発生したけどまだコールバックが呼ばれていないMutationRecordの配列を返します。

というのも、実はMutationObserverは、DOMに対して変更が発生したら何がなんでもすぐにコールバックを呼ぶわけではなく、今実行中の処理が終わるまでMutationRecordをためておいて、一段落してからコールバックを呼び出します。コールバックの引数がMutationRecordそのものではなくMutationRecordの配列となっているのはそのためであり、1回の実行で複数たまったら複数のMutationRecordがまとめてコールバックに渡されることもあります。

takeRecordsは、そのたまった状態のMutationRecordを取得するメソッドです。なお、takeRecordsの返り値として得られたMutationRecordはそれで消費されたことになり、それに対してコールバックはもう呼ばれません。

サンプル2を見てください。コンソールには以下の順番でログが出ます。


divを追加しました。
pを追加しました。
DIV が追加されました。
P が追加されました。

これは、divを追加したあと即座にコールバックが呼ばれるのではなく、実行が終了してからコールバックが呼ばれていることを示しています。言い換えるなら、MutationObserverによるコールバックは非同期的に呼ばれるということです。

次のサンプル3では、div要素を追加したあとにtakeRecordsを呼んで結果を見ています。これは次のような結果になります。


divを追加しました。
takeRecordsの返り値は 1 件ありました。
pを追加しました。
P が追加されました。

MutationObserverのコールバックにより表示されていた「DIV が追加されました。」のログが消えました。これは、takeRecordsの返り値としてdivの分のMutationRecordが消費されたことによります。

takeRecordsは、コールバックが呼ばれるのを待ちきれない、今すぐ処理したいという場合に使えるかもしれません。

MutationObserverの説明は以上です。