これでできる! クロスブラウザJavaScript入門

第8回実践DOMスクリプティング#1:HTMLとテキストの操作

こんにちは、太田です。前々回前回でDOMの基礎を簡単に解説しました。今回からは、DOMを使った実用的なスクリプトを解説していきます。特に今回はHTMLの操作、テキストの操作にフォーカスを当てていくつかのサンプルコードを解説していきます。

HTML操作の基本

JavaScriptによってHTMLを書き出したり、一部を書き換えたり、削除したりといった方法は実は様々な方法が用意されています。目的に合わせて適切な方法を選ばないと非効率だったり、最悪クロスサイトスクリプティングなどの問題を抱えてしまう危険もあります。

document.writeと同期読み込み

JavaScriptでHTMLを書き出すというと、最初に学ぶのはこのdocument.writeかもしれません。いわゆるprint文のようにシンプルなAPIなので、入門書の最初のサンプルなどで扱われることも多いようです。しかし、document.writeは扱いに注意が必要なメソッドです。

ウェブページはブラウザに読み込まれ始めたときにdocumentがopenな状態になり、すなわちHTMLを解析し、画像・CSS・JavaScriptなどの外部ファイルを取得し、取得したそれぞれもやはり解析して最終的にレンダリングを行っていくという一連の処理が行われます。それらの処理が終わったところでdocumentはclose状態になります。このopenな状態なときにwriteを実行すると現在のdocumentにHTMLを書き出すことができます。一方、既に読み込みが完了してcloseな状態でwriteを実行すると暗黙的にopen状態になり、その際にそれまでのdocumentはすべてリセットされて白紙になってしまいます。また、このときは読み込みの完了という自動的にcloseする機会が存在しないので、closeを明示的に呼ばない限りopen状態(読み込み中)のままになってしまいます。

では、document.writeを使うとタイミングによって一度表示したページをリセットしてしまうことがあるのかというと、そういったことは起こりません。もちろん、ロード後にdocument.writeを呼び出せばそれが起こるわけですが、その場合はその現象が必ず発生するのですぐに気が付くことができます。問題は、外部JavaScriptなどの読み込みと実行に極端に時間がかかった場合ですが、その場合ブラウザはHTMLの解析を止めてJavaScriptの読み込みを待ってから実行し、JavaScriptの実行完了後にHTMLの解析を再開します。従って、JavaScriptの読み込みが遅延しても、後からdocument.writeが実行されてしまうということは起こりません。逆に言えば、JavaScriptの読み込みと実行中はHTMLの解析が止まってしまうのでユーザーを待たせる重大な原因となるということです。実は、まさにdocument.writeがあるために、ブラウザはJavaScriptを同期的に実行しなければいけないのです。もし外部JavaScriptについてソースの読み込みと実行を非同期で行いつつHTMLの解析を先に進めて行くという「HTMLとJavaScriptの並列処理」ができれば表示を高速化することができます。しかし、それが現状できないのはdocumet.writeがあるためなのです。

scriptの非同期読み込み

ここでやや脱線しますが、JavaScriptを非同期読み込みする方法を紹介します。方法としてはいくつかありますが、今回はウェブ標準な方法と実用的な方法の2つに絞ります。なお、非同期に読み込むということは実行順が不定になるので、うっかりライブラリなどを非同期にしてしまうと、ライブラリを利用しようとしたらまだ読み込みが完了していなかったといったことが起こりえます。非同期化は慎重に行う必要があります。

まず、HTML5ではこのJavaScriptの読み込みについての解決策が用意されています。document.writeが使われていないという条件付きのもと、scriptタグにasync属性を設定することでまさにHTMLの解析と並行してJavaScriptを実行できます。ただし、2010年6月時点でこのasync属性をサポートしているのはFirefox 3.6のみです。ただ、サポートしていないからといってasync属性を設定することで何か問題の起こることはないので、将来多くのブラウザでサポートされることを期待して今から設定しておくのもよいでしょう。もちろん、あるブラウザがasync属性をサポートした時に動くのか、つまり突然非同期になっても問題がないとはっきりしているなら、という前提がある場合に限ります。

ちなみに、async属性と似たdefer属性というものもあります。こちらは非同期ではなく遅延です。scriptの実行をDOMContentLoaded相当のタイミングに設定します。つまり、DOMの構築が完了しているので確実にDOMを操作することができます。asyncはなるべく早く実行したい場合、deferはDOM構築後の処理と、役割が分かれています。なお、asyncで実行してDOMContentLoadedをセットするという方法はDOMContentLoadedイベントが発生する前にイベントをセットできる保証がないので避けたほうがよいでしょう。

もう1つの実用的な方法として、document.createElementでscriptタグを作り、src属性に外部スクリプトのURLを設定してappendChildなどで挿入するという方法があります。DOMのメソッドで挿入された要素は既に構築されている要素に追加されるので、HTMLを解析していく処理とは切り離されます。外部スクリプトの読み込みも非同期に行われることになり、HTMLの解析をブロックしません。これはクロスブラウザで動作します。

ただし、念のため注意しなければいけないのがscript要素の挿入方法です。

scriptタグの挿入
(function(){
  var script = document.createElement('script');
  var head = document.getElementsByTagName('head')[0];
  script.src = 'xxx.js';
  head.appendChild(script);
})();

ほとんどの場合は上記のコードで問題ありませんが、IE6でbase要素がある場合にscript要素を削除するとエラーが発生するという問題があります。そのエラーを回避するのが次のコードです。

scriptタグの挿入#2
(function(){
  var script = document.createElement('script');
  var head = document.getElementsByTagName('head')[0];
  script.src = 'xxx.js';
  head.insertBefore(script, head.firstChild);
})();

このように、appendChildの代わりにinsertBeforeを使うだけです。このバグに付いてはjQueryのバグトラッカー #2709に詳細が書かれています。発生条件は十分に特殊(scriptを削除する理由がない)なので、appendChildでも十分でしょう。

ところで、document.getElementsByTagName('head')[0]という部分に、head要素がなかったらエラーになるのではないかと思われるかもしれません。しかし、実際に次のようなhead要素のないhtmlを実行してみるとしっかりhead要素が取得できて、省略したhead要素がブラウザ(のレンダリングエンジン)によって補完されていることを確認できます。

head要素の補完
<script>
var head = document.getElementsByTagName('head')[0];
var x = head.innerHTML;
</script>
<title>head test</title>
<body>
<script>
document.body.appendChild(document.createTextNode(x));
// <script> var head = … </script>
document.body.appendChild(document.createTextNode(document.documentElement.innerHTML));
// <head><script> … </head><body> … </body>
</script>
</body>

HTML 4.01ではHTML要素の直下に含めることができるのはhead要素かbody要素のみと定義されています。これに従ってブラウザはHTML要素の直下にはhead要素を補完してその中にscript要素などのhead要素の子要素にできる要素を入れていきます。head要素の子要素にできない要素を見つけるとそこでhead要素を閉じ、bodyタグを開始します。このようにHTMLの仕様を把握することでJavaScriptを書く際の疑問点が解消されることは多々あります。

ただ、document.getElementsByTagName('head')[0] || document.documentElement と書いておくことも決して無駄ではありません。HTMLの仕様に従わないブラウザがあってもおかしくはありませんし(もちろんバグとして認識するのが普通ですが、修正されないバグもあります⁠⁠、書くことで問題が出るわけでないので、保険は多いほうがよいという考えもあります。唯一の正解はなく、好みの問題とも言えます。

innerHTMLとDOM

document.writeの次はやはりinnerHTMLです。こちらも手軽なAPIで、なんといってもクロスブラウザで動作するので頻繁に使用されています。ただし、前回の最後でも触れましたが、innerHTMLはイベントを消してしまうことがありますし、その削除されたイベントが原因でメモリリークを起こすこともあります(メモリリークについては、やはり連載の中盤以降で取り上げたいと思います⁠⁠。パフォーマンスについては使い方を間違えなければ比較的優秀ですが、最善というほどではありません。そのため、innerHTMLを使う際はほかによい方法がないか検討してみるべきでしょう。

さて、1つ典型的なinnerHTMLの誤用例を見てみます。

innerHTMLの誤用例
var data = [1,2,3];
document.body.innerHTML='<ul>'
for(var i = 0;i<data.length;i++){
    document.body.innerHTML += '<li>'+data[i]+'</li>';
}
document.body.innerHTML += '</ul>';

このコードは、<ul></ul><li>1</li><li>2</li><li>3</li>のようなHTMLを作ってしまいます。liがulの中に含まれていません。これはinnerHTMLへの代入のたびにHTMLとして解釈されていることを示しています。HTMLのパースとレンダリングは重い処理なので、パフォーマンスの点でも問題があります。そこで次のように文字列を組み立ててから1度で代入するのが定石です。

文字列とinnerHTMLその1
var data = [1,2,3];
var html_str = '<ul>'
for(var i = 0;i<data.length;i++){
    html_str += '<li>'+data[i]+'</li>';
}
html_str += '</ul>';
document.body.innerHTML = html_str;

ただし、長い文字列を+=で連結するのはIE(特にIE6)で重くなることがあります。そこで、配列を使う方法もよく使われます。

文字列とinnerHTMLその2
var data = [1,2,3];
var html_strs = ['<ul>'];
for(var i = 0;i<data.length;i++){
    html_strs.push('<li>'+data[i]+'</li>');
}
html_strs.push('</ul>');
document.body.innerHTML = html_strs.join('');

なお、このケースではさらに次のように最適化することもできます。

文字列とinnerHTMLその3
var data = [1,2,3];
var len = data.length;
var html_strs = len ? new Array(len) : [];
for(var i = 0;i < len;i++){
    html_strs[i] = '<li>'+data[i]+'</li>';
}
document.body.innerHTML = '<ul>' + html_strs.join('') + '</ul>';

配列の長さが先に決まっているならその長さで配列を初期化しておき、さらにpushではなくカウント用の変数を添字にして代入を行うことで関数呼び出しを減らすことができます。大きな文字列を作る場合にはこういった最適化が大きく影響することもありますが、実際には1msにも満たない差でしかないことのほうが多いでしょう。最適化を行うべきかどうかは悩ましいところですが、1つの目安として「廉価なネットブック上のIE6で遅さを感じるほどでなければ、そもそも最適化の必要はないと判断する」など、何か基準を決めて判断するとよいでしょう。また最適化する場合も、最適化の結果を見ながら可読性やメンテナンスを考えて、落しどころを決めることになります。

HTML中の文字列を置換する方法

ユーザーが検索したキーワードなどをハイライトさせたり、何かの条件にマッチした文字列をマークアップし直したい、置換したいといったケースはよくあることだと思います。

そういったHTML中の文字列を置換するとき、最も手軽で乱暴な方法はdocument.body.innerHTMLの全書き換えです。

文字列とinnerHTML
// BAD!
document.body.innerHTML = 
    document.body.innerHTML.replace(/foo/g,'bar');

この方法は多くの問題を抱えているので、オススメできません。では実際にどういった方法があるのかというと、これが少々厄介です。まず、テキストノードを取得する必要がありますが、クロスブラウザなコードとしては単純に全ノードを再帰的に探索するしかありません。コードとして次のようになります。複雑ではありませんが、HTMLが大きくなるほど処理が重くなってしまいます。

テキストノードの探索
function walker(node) {
  if (node.nodeType === 3) {
    // テキストノードに対する処理
  } else if (node.nodeType === 1 && !/^(IFRAME|STYLE|SCRIPT|TEXTAREA)$/i.test(node.tagName)) {
    var childNodes = node.childNodes;
    for (var i = 0, len = childNodes.length; i < len; ++i) {
      walker(childNodes[i]);
    }
  }
};
walker(document.body);

テキストノードに対する処理、ここでは特定のキーワードにマッチしたとき、その文字列をspanタグで囲ってハイライトするようにしてみます。

テキストノードに対する処理(キーワードハイライト)
if (node.nodeValue.indexOf(keyword) >= 0) {
  var text = node.nodeValue, index;
  var parent = node.parentNode;
  while (text && (index=text.indexOf(keyword)) >= 0 ){
    // テキストを分割し、後ろ側のノードを取得
    var _txt = txt.splitText(index);
    // キーワードの終わりで再度分割
    var __txt = _txt.splitText(keywordLength);
    var s = document.createElement('span');
    s.className ='XXX';
    s.appendChild(_txt);
    parent.insertBefore(s, __txt);
    // ループ用に初期化
    text = __txt.nodeValue;
    node = __txt;
  }
}

実例

ヒット数:

処理時間:

splitTextは指定した位置でテキストノードを分割するメソッドです。DOM Level 1で定義されており、IEでも使えるためクロスブラウザで動きます。少々複雑な処理に見えるかもしれませんが、順を追ってみればテキストノードを2回分割して間のノードをspanタグに詰め込んでいるだけの処理です。

さて、テキストノードの探索はなんとかしたいですね。いくつか方法はありますが、まずIE以外のブラウザで有効なのがXPathです。

テキストノードの探索(XPath)
function xpath(node) {
  var TEXT = 'descendant::text()[contains(self::text(),"' + keyword +
    '") and not(ancestor::'+ ['textarea','script','style','head'].
    join(' or ancestor::') + ')]';
  document.evaluate(TEXT, node, null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);
  var len = exp.snapshotLength;
  for (var i = 0; i < len; i++) {
    var txt = exp.snapshotItem(i);
    // テキストノードに対する処理
  };
xpath(document.body);

XPathの場合、XPath自体に検索条件として絞り込みをすることが可能なため、一発で指定のキーワードを含むテキストノードを取得することができます。そのため、非常に高速に動作します。もちろんXPathが万能というわけではなく、今回のようないくつかの条件で絞込したいというケースにマッチしたという面はあります。

さて、IEも何とか高速化したいところです。しかし、IEではネイティブに実装されたXPathは使えません。が、実はIEには少しユニークな方法があります。

IEはTextRangeというテキストノードを操作するためAPIを持っています。このTextRangeのfindTextメソッドでテキストを検索し、さらにexecCommandで文字色や背景色を変えたり、といったことが可能です。

テキストのハイライト(TextRange#findText)
if(document.body.createTextRange){
  var keywordLength = keyword.length;
  var tree = document.body.createTextRange(), prevnode;
  while(tree.findText(keyword)) {
    tree.execCommand('BackColor',false,'#FFFF00');
    tree.move('character',keywordLength);
    // moveしておかないと無限ループになるので注意
  }
}

IEではこれだけで検索からハイライトまで対応できてしまいます。パフォーマンスも十分に高速です。特にマッチする文字列が少ない時に最低限の探索で済むのが特徴です。

実例(最適化)

ヒット数:

処理時間:

まとめ

今回はDOM操作の実例に触れつつ、最適化にも踏み込んで解説しました。次回も引き続きDOM周りの実用的なコードを解説していきたいと思います。

おすすめ記事

記事・ニュース一覧