escapeHTML の実装 3 パターン (ベンチマーク付き)

ウェブを扱うライブラリやプログラムで必ずと言って良いほど見かけるものに、escapeHTML という関数があります。

"&" 等、特別な意味を持つ文字を、表示等のために実体参照 (&) に変換するお決まりの関数なんですが、実装には色々とバリエーションがあるものです。


1. String#replace メソッドを繰り返す (MochiKit 等)

function escapeHTML(str) {
  return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

このパターンが最も多く見受けられます。Ruby でも同様に gsub を繰り返す方式を見かけることがあります (例: RSS::Utils.html_escape)。


2. String#replace の第二引数に関数を用いる

function escapeHTML(str) {
  return str.replace(/[&"<>]/g, function(c) {
    return {
      "&": "&amp;",
      '"': "&quot;",
      "<": "&lt;",
      ">": "&gt;"
    }[c];
  });
}

自前のライブラリから持ってきたものです。(1) の例に比べて美しく感じるのは私だけでしょうか?(笑 ちなみに Ruby でも gsub + ブロックで同様の書き方が出来ます。


3. document 要素を利用する (prototype.js)

function escapeHTML(str) {
  var div = document.createElement('div');
  var text = document.createTextNode(str);
  div.appendChild(text);
  return div.innerHTML;
}

prototype.js のコードを説明のために少し変更したものです。上二つとは全く違う発想で面白いですよね。ただ、createTextNode が必要なのかどうか気になります (直接 div 要素の innerHTML に代入すれば良いような…)。

ということで、JavaScript での escapeHTML 実装のパターンには基本的に「文字列の置換」と「DOM 要素の innerHTML の利用」の二種類が可能と言えそうです。

(ちなみに LDR のライブラリは前者のパターンですが、かなり凝った実装になっています。興味ある方は是非探してみてください)


さて、三つの例を出したところで性能を調べてみましょう。

それぞれのバージョンに別の名前を付けて計測します (on IE6):

// Ruby に同梱の benchmark.rb を適当に真似たものです
Benchmark.benchmark(function(x) {
  var count = 100;
  // テスター
  function test(f) {
    count.times(function() {
      f('<taka> & "toshi"');
    });
  }

  x.report("multi replace: ", function() {
    test(escapeHTML_replace_multi);
  });
  x.report("single replace: ", function() {
    test(escapeHTML_replace_func);
  });
  x.report("innerHTML: ", function() {
    test(escapeHTML_innerHTML);
  });
});

出力 (単位は秒):

multi replace:  0.17
single replace: 0.33
innerHTML:      1.21

なんと、replace を繰り返すバージョンが最も速いようです。

replace + 関数版が遅い理由は、関数内で毎回連想配列 (オブジェクト) を新しく作成している点だと思われます。なので、キャッシュ版も試してみましょう:

escapeRules = {
  "&": "&amp;",
  '"': "&quot;",
  "<": "&lt;",
  ">": "&gt;"
};
function escapeHTML_replace_func_rulescached(s) {
  return s.replace(/[&"<>]/g, function(c){
    return escapeRules[c];
  });
}

ついでに DOM 要素版の、createTextNode を使わないバージョンも作ってみます:

function escapeHTML_innerHTML_without_textnode(s) {
  var div = document.createElement('div');
  div.innerHTML = s;
  return div.innerHTML;
}

全部のテスト結果:

multi replace:                      0.22
single replace:                     0.33
single replace (with cached rules): 0.22
innerHTML:                          1.21
innerHTML (without createTextNode): 2.14

キャッシュの効果が現れているのが分かります。

が、驚いたことに createTextNode を使わない方が、使うよりも圧倒的に重いという結果が出ました。逆だと思っていたので意外ですが、prototype.js の開発者はこのパフォーマンスの違いを検証済みだったんでしょうね…。いずれにせよ DOM 要素の生成は重いという結論ではあります。


追記 [20060713]:

prototype.js で createTextNode を使っている理由がわかりました。(?B のコメントにてご指摘いたきました。有り難うございます!)

直接 innerHTML に代入してしまうと <> で囲まれた文字列がタグとして解釈されてしまう (文字列が消えてしまう)、ということなんですね。

(速度の遅さも「タグとして解釈しようとする」ことが原因ということになりますね)

いやはや、初歩的な見落としでした。テストの出力確認を怠った結果です。失礼いたしました。


追記 [20060718]:

replace + 関数を利用するパターンに関して、「連想配列を毎回生成している」点が遅さの原因と考えましたが、もう一つ重大な点を見落としていました:

replace を繰り返すものも含め、「正規表現オブジェクトが毎回生成されてしまう」という点です。

(コメントとトラックバックでのご指摘で気付きました。有り難うございました)

ということで、正規表現をキャッシュするパターンも含めて再検証する必要があるわけなんですが、これは既に id:f99aq さんがやってくださっています: id:f99aq:20060714

IE に加え、OperaFirefox でもベンチを取ってくださいました。素晴らしいです。