十七章第一回 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>
- #text
"ああ、"
- time
- #text
"今日"
- #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の説明は以上です。