SCP-404-JPに見るinnerHTMLの非効率性

注意:この記事はSCP-404-JP - SCP財団のネタバレを含むかもしれません。まだSCP-404-JPを読んでおらずネタバレを避けたいというかたはこの記事を読まないことをおすすめします。

概要

SCP-404-JPはSCP財団と呼ばれるコミュニティにより創作された数ある“報告書”のひとつです。報告書はwiki上のページとして公開されており、そのほとんどは内容のみで勝負していますが、一部の報告書はJavaScriptによるギミックが組み込まれています。このようなギミック付きの報告書は邪道とする人もいる一方で、そのインパクトや内容を引き立てる効果により一般には高い評価を受けているようです。SCP-404-JPもそのようなギミック付き報告書のひとつです。

SCP-404-JPのギミックは、ページに書かれた内容がどんどん消えていくというものです。具体的には、ページの内容が時間の経過とともに1文字ずつ先頭から消えていきます。そして、この処理は全文のHTMLを文字列として持っておき、時間の経過とともにその文字列を削りながらページのinnerHTMLを更新していくことにより行われています。

残念ながら、このような実装はinnerHTMLの不適切な利用の典型例です。この記事ではそのことを指摘するとともに、代替の方法を紹介し、性能を比較します。

innerHTMLを使うことの問題点

SCP-404-JPのギミックはだいたい以下のような感じです。

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <div id="main"></div>
    <script>
      let html = `<p>これは段落です。これは段落です。<b>これは段落です。</b>これは段落です。</p>
<p>これも段落です。これも段落です。これも段落です。これも段落です。</p>`;
      main();
      function main(){
        document.getElementById('main').innerHTML = html;
        if (html !== ''){
          html = html.replace(/^<.+?>|[^<]/, '');
          setTimeout(main, 50);
        }
      }
    </script>
  </body>
</html>

文字を1つ消すごとに(1ループごとに)、div#mainのinnerHTMLの書き換えが行われています。innerHTMLはページの内容を操作する方法としては分かりやすく(HTMLが分かっていればいいわけですから)、初心者はinnerHTMLを用いたページ操作をやろうとしがちです。

しかし、ここに罠があります。ブラウザにおいてはページの内容というのは木構造で表されていますから、innerHTMLに文字列が代入されたらまずそれをHTMLとして解析し、新しい木構造を構築しなければなりません。これは明らかに重い処理です。ですから、ループごとに(上の例では50ミリ秒に1回)このような処理を実行するのは得策ではありません。また、このサンプルでは毎ループごとに文字が1つ消えるだけなので、ループごとに木構造に与えられる影響はとても小さいと考えられます。この事実は、innerHTMLにより毎回木構造を全部組み立て直すのは無駄であり、より効率的な方法が存在することを示唆しています。

木構造への影響を最低限にするアルゴリズム

木構造への影響を最低限にするアルゴリズムは、以下に示すように木構造を掘り進みながら1文字ずつ消していくものです。

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <div id="main">
      <p>これは段落です。これは段落です。<b>これは段落です。</b>これは段落です。</p> 
      <p>これも段落です。これも段落です。これも段落です。これも段落です。</p>
    </div>
    <script>
      let currentNode = document.getElementById('main');
      main();
      function main(){
        while (currentNode.hasChildNodes()){
          currentNode = currentNode.firstChild;
        }
        if (currentNode.id === 'main'){
          return;
        }
        if (currentNode.nodeType === Node.TEXT_NODE && /\S/.test(currentNode.nodeValue)){
          currentNode.nodeValue = currentNode.nodeValue.slice(1);
        } else {
          const nextNode = currentNode.nextSibling || currentNode.parentNode;
          currentNode.parentNode.removeChild(currentNode);
          currentNode = nextNode;
        }
        setTimeout(main, 50);
      }
    </script>
  </body>
</html>

このアルゴリズムでは明らかに、1文字消す際に影響を受けるのは1つのテキストノードのみです。他のノードはページにそのまま残りますから、毎回木構造を全部作りなおすよりローコストで効率的です。

比較

innerHTMLを使う版と使わない版のアルゴリズムの実行時間を比較してみました。具体的には、上のサンプルでmainの処理1回にかかった時間をconsole.timeで計測しました。環境はUbuntu 16.04 LTS上のChromeです。

innerHTMLを用いる innerHTMLを用いない
上のサンプル 0.56 ms 0.18 ms
SCP-404-JP 4.24 ms 0.33 ms

上の小さなサンプルでも3倍程度の実行時間の差が出ており、innerHTMLを使わないほうが有利であることが明らかになりました。

注目すべき点としては、実際にSCP-404-JPを用いて2つのアルゴリズムを比較する実験ではさらに差は広がったことが挙げられます。その理由は、innerHTMLを用いるほうは全てのHTMLを毎回解析するためページの規模が大きくなるほど実行にかかる時間が増加する一方で、innerHTMLを使わないほうのアルゴリズムは毎回木構造の局所的な変更を行うためページの大きさの影響を受けないからです。2つのサンプル間でそれでも実行時間が約2倍になっている理由は定かではありませんが、ページのコンテンツの複雑度の違いによりレンダリングにかかる時間が異なっているのではないかと推測します。

結論

SCP-404-JPを例にとり、innerHTMLを用いてページの内容を変更することの非効率性を説明しました。実際、SCP-404-JPのディスカッションではFirefoxで機能しないなどという報告も上がっており(3年も前のことなので今がどうなのかは分かりませんが)、それは処理の重さが原因なのかもしれません。

なお、SCP-404-JPを例に挙げた理由は、innerHTMLをこのように利用している実装がされているページの中では(自分の知る限り)比較的知名度が高いような気がするからです。