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

第9回実践DOMスクリプティング#2:DOMとHTML

こんにちは、太田です。前回はHTMLとテキスト操作を解説しました。今回は、さらにHTMLの操作の実例を中心に解説していきます。

JavaScriptとエスケープ

まず、エスケープについて確認しておきましょう。外部から入力されたデータを画面上に表示する際はクロスサイトスクリプティングに注意する必要があることはもはや常識と言ってよいと思います。サーバーサイドのウェブ開発用フレームワークでは、ほとんどの場合HTMLのエスケープ用のメソッドが用意されていて、HTMLのエスケープは適切に行えるはずです。しかし、注意しなければいけないのは、HTMLのエスケープはJavaScriptにそのまま当てはめることはできないという点です。当たり前の話ですが、HTMLとJavaScriptではエスケープすべき文字が異なります。また、IEの6、7ではCSSにJavaScriptを埋めこむことができるのでCSSにも注意が必要です。

さて、実際にどのようにエスケープすればよいのでしょうか。JavaScriptの仕様、文字コードの問題などを含めて自分なりに実装してみるというのはお勧めできません。こういった場合、既に用意されている方法があるならそれを使うべきです。

その具体的な方法の1つがJSONです。最近では多くの言語がJSONをネイティブにサポートしていますし、ネイティブサポートしていなくともライブラリなどはすぐに見つけられると思います。JSONはJavaScriptから生まれたデータ記述フォーマットで、真偽値、数値、文字列、null値の組み合わせを持ったハッシュか配列かその両方で構成されます。関数や正規表現などはJSONには含まれません。よって、適切にJSONとして出力されたデータは安全なデータといえます(ただし、文字コードによってはデータを壊して読み取る方法が存在しないこともないので、JSONを単体で出力する際にはContent-Typeで文字コードを指定することを忘れないようにする必要があります⁠⁠。

ただし、JSONとして出力したものをHTMLに埋め込む場合はさらに注意が必要です。例えば次のようにHTML中にJSONを出力する場合、scriptの閉じタグが含まれればそこが閉じタグになってしまいます。

scriptの閉じタグ
<script>
var data = {"a":1,"b":[1,2,3],"c":{"d":"XXX</script><script>"}};
</script>

この場合は当然HTMLとしてのエスケープも必要で、次のように出力する必要があります。

scriptの閉じタグのエスケープ
<script>
var data = {"a":1,"b":[1,2,3],"c":{"d":"XXX&lt;/script&gt;&lt;script&gt;"}};
</script>

この場合は/の前にバックスラッシュを入れることで閉じタグとして解釈されることを回避できますが、script要素を<--でコメントにしていた場合は、-->でコメント部分をそこで終了させることができてしまいます。こういった複雑さから、scriptは動的に作らないというのがまず基本として、どうしても値を渡したい場合はinput要素のhiddenの値として埋め込んだり、数値や真偽値などの特定の値に限定するようにしたほうがよいでしょう。

なお、前回のキーワードハイライトで使用したXPathでも、ユーザーの入力を元にクエリを作っていたので正確にはエスケープ処理が必要でした。XPath1.0でのエスケープ処理は少々厄介ですが、XPath に文字列を埋め込むときの注意 - IT戦記のescapeXPathExpr関数を利用することで正しくエスケープ処理をすることができます。

HTMLの出力方法

さて、JSONとして埋め込んだデータを画面に出力する方法をすこし考えてみます。まずはDOMのメソッドで要素を作る方法です。createElementで要素を作り、テキストはcreateTextNodeで作って、それぞれを順番にappendChildで追加する方法です。この方法でTwitterの検索APIからHTMLを組み立てるコードは次のとおりです。

DOMのメソッドによるHTMLの組み立て
function TwitterCallback1(data){
  var div = document.getElementById('twitter-search-r1');
  /* 前回の結果がある場合に削除 */
  while (div.firstChild) {
    div.removeChild(div.firstChild);
  }
  var results = data.results;
  var ul = document.createElement('ul');
  ul.className = 'twl';
  for (var i = 0, len = results.length;i < len; i++){
    var usr = results[i];
        var user = usr.from_user;
    /* 要素を作る */
    var li = document.createElement('li');
    var link = document.createElement('a');
    var icon = document.createElement('img');
    var name = document.createElement('span');
    var entry = document.createElement('p');
    var time = document.createElement('div');
    var timelink = document.createElement('a');
    /* CSS用にclassを設定 */
    li.className = ((i+1)%2) ? 'odd' : 'even';
    link.className = 'usr';
    entry.className = 'entry';
    time.className = 'time';
    /* リンクや画像などの属性を設定 */
    link.href = 'http://twitter.com/' + user;
    var src = usr.profile_image_url;
    if (src.indexOf('http') === 0) {
      icon.src = src;
    }
    icon.width = 48;
    icon.height = 48;
    timelink.href = 'http://twitter.com/' +
                user +'/status/' + usr.id;
    var d = new Date(usr.created_at);
    var date = d.getFullYear() + '/' + (d.getMonth()+1) +
           '/' + d.getDate() + ' ' + d.getHours() + ':' +
           ('0'+d.getMinutes()).slice(-2);
    /* テキストノードの挿入 */
    entry.appendChild(document.createTextNode(usr.text));
    timelink.appendChild(document.createTextNode(date));
    name.appendChild(document.createTextNode(user));
    /* 要素の組み立て */
    link.appendChild(icon);
    link.appendChild(document.createElement('br'));
    link.appendChild(name);
    li.appendChild(link);
    li.appendChild(entry);
    time.appendChild(timelink);
    li.appendChild(time);
    ul.appendChild(li);
  }
  /* 画面に反映 */
  div.appendChild(ul);
}
var b1 =document.getElementById('twitter-search-b1');
b1.onclick=function(){
  var script = document.createElement('script');
  script.src = 'http://search.twitter.com/search.json'+
    '?callback=TwitterCallback1&lang=ja&q=JavaScript';
  document.body.appendChild(script);
}

この方法は、属性には文字列を設定しているだけですし、テキストはテキストノードとして挿入しているので、万が一TwitterのAPIのエスケープ処理が不十分でHTMLタグが混入したとしてもクロスサイトスクリプティングは成立しません。

しかし、appendChildで要素を順番に挿入してHTMLの構造を組み立てているところなど、実際にどういったHTMLができあがるのか見通しが悪く、メンテナンスの面ではかなり問題があります。

cloneNodeによる要素の組み立て

そこでオススメなのが、cloneNodeで要素を複製する方法です。まず、次のような⁠できあがった⁠HTMLを用意します。

テンプレート用HTML
<ul id="twitter-search-t2" style="display:none;">
 <li>
   <a class="usr">
     <img width="48" height="48"><br>
     <span>boosim</span>
   </a>
   <p class="entry"></p>
   <div class="time"><a></a></div>
 </li>
</ul>

このHTMLをベースに、cloneNodeで複製した要素を基点にgetElementsByTagNameで要素を選択し、各属性とテキストを設定します。

cloneNode方式の組み立て
var _t=document.getElementById('twitter-search-t2');
var tmpl = _t.getElementsByTagName('li')[0];
function TwitterCallback2(data){
  var div = document.getElementById('twitter-search-r2');
  /* 前回の結果がある場合に削除 */
  while (div.firstChild) {
    div.removeChild(div.firstChild);
  }
  var results = data.results;
  var ul = document.createElement('ul');
  ul.className = 'twl';
  for (var i = 0, len = results.length;i < len; i++){
    var usr = results[i];
        var user = usr.from_user;
    /* 要素を作る */
    var li = tmpl.cloneNode(true);
    var link = li.getElementsByTagName('a')[0];
    var icon = link.getElementsByTagName('img')[0];
    var name = link.getElementsByTagName('span')[0];
    var entry = li.getElementsByTagName('p')[0];
    var time = li.getElementsByTagName('div')[0];
    var timelink = time.getElementsByTagName('a')[0];
    /* CSS用にclassを設定 */
    li.className = ((i+1)%2) ? 'odd' : 'even';
    /* リンクや画像などの属性を設定 */
    link.href = 'http://twitter.com/' + user;
    var src = usr.profile_image_url;
    if (src.indexOf('http') === 0) {
      icon.src = src;
    }
    icon.width = 48;
    icon.height = 48;
    timelink.href = 'http://twitter.com/' +
                user +'/status/' + usr.id;
    var d = new Date(usr.created_at);
    var date = d.getFullYear() + '/' + (d.getMonth()+1) +
           '/' + d.getDate() + ' ' + d.getHours() + ':' +
           ('0'+d.getMinutes()).slice(-2);
    /* テキストノードの挿入 */
    entry.appendChild(document.createTextNode(usr.text));
    timelink.appendChild(document.createTextNode(date));
    name.appendChild(document.createTextNode(user));
    /* 要素の組み立て */
    ul.appendChild(li);
  }
  /* 画面に反映 */
  div.appendChild(ul);
}
var b2 =document.getElementById('twitter-search-b2');
b2.onclick=function(){
  var script = document.createElement('script');
  script.src = 'http://search.twitter.com/search.json'+
    '?callback=TwitterCallback2&lang=ja&q=JavaScript';
  document.body.appendChild(script);
}

ご覧の通り、こちらの方法ではappenChildの順番で要素を組み立てていた部分をきれいに省くことができます。また、ウェブデザイナーから受け取ったHTMLをほぼそのまま使え、style="display:none;"の指定を削除すればJavaScript不要で見た目の調整ができる点もポイントです。

またパフォーマンスの点でも、IEではcloneNode版のほうが速いくらいです。IE以外ではややcloneNode版のほうが劣るものの、一回あたりの差は1msにも満たいない程度と十分な速さです。

なお、img要素のsrc属性に付いてはスキーマ次第でスクリプトが実行できてしまうケースがあるので、httpで始まっているかチェックしています。同様にhref属性なども注意が必要です。

また、JavaScriptというテーマから外れる部分ではありますが、今回のように見栄えをそれなりにすると当然CSSのクロスブラウザ化が問題となります。今回は角丸をborder-radiusをサポートしている場合だけと割り切っているので、CSSハック的なものはIE6でmin-heightを使う方法のみを使用するだけで済んでいます。

IE6でmin-heightを使う方法とは、次のように全ブラウザにmin-heightを指定し、IE6だけにheightを指定しています。IE6はmin-heightが無視されheightが指定されるので、60pxに収まらないときは要素をはみ出してしまうように思えますが、IE6は要素内がはみ出した時のデフォルトの挙動が「要素を広げる」であるため、min-heightが指定されているかのように内容に応じて高さが変わります。

min-heightの代替ハック
li p.entry{
  min-height:60px;
}
/*スターハック IE6(とIE7の互換モードなど)のみに適用*/
* html li p.entry{
  height:60px;
}

なお、IE6だけにスタイルを当てるCSSハックはいくつかありますが、スターハックはCSSの文法には違反しないためバリデータでエラーがでないという点がポイントです。

まとめ

今回はDOM操作の実例に触れてみましたが、実は今回の範囲ではブラウザごとの分岐処理などを一切使用していない点も重要なポイントです。DOMを使った要素の組み立ては基本に忠実な扱い方を押さえるだけで、ライブラリなどを使うまでもなくクロスブラウザ化することが可能です。

次回はJavaScriptから操作するCSSを中心に取り上げる予定です。

おすすめ記事

記事・ニュース一覧