ループ処理を最適化する

2013年10月31日

カテゴリー:

ようやくJSLintオプション考察の呪縛から解放されたので、今回からまたJavaScriptのコーディング・パターンネタに戻ります。


さて唐突ですが、JavaScriptでfor文を書くときってどう書いてますか?
私はこれまで、例えば配列に格納された要素を1つずつ調べていくような場合、↓こんな書き方をしていました。

var ary = ['one', 'two', 'three'];

for (var i = 0; i < ary.length; i++) {
    window.console.log(ary[i]);
}

まぁ、これはこれで間違いではないと思うのですが、1か所無駄な部分があります。

それは、ループの繰り返しごとに配列の長さがチェックされることです。(i < ary.length;の部分ですね。)

上記のコード例の場合、ループの最中に配列の長さ(要素の数)が変化することはありませんので、ループを1回まわる度に配列の長さをチェックするのは無駄な処理になります。
ですので、このような場合は次のコード例のように、配列(またはコレクション)の長さをキャッシュ(別の変数に格納)しておくのが良いパターンになります。

var ary = ['one', 'two', 'three'];

//配列の長さを len にキャッシュしておき、
//ループ中は len を参照する
for (var i = 0, len = ary.length; i < len; i++) {
    window.console.log(ary[i]);
}

さらに、このコードをJSLintのチェックにも通るように単独varパターンを適用すると、最終的に次のコードになります。(※実際にはJSLintは「i++」の部分にも文句を言ってきますが、この制約は解除しています

var ary = ['one', 'two', 'three'],
    i = 0,
    len = ary.length;

for (i = 0; i < len; i++) {
    window.console.log(ary[i]);
}

まぁ、上記のコード例程度の配列であれば、配列の長さをキャッシュしようがしまいが体感できる違いは全くないと思います。しかし、ループの対象がHTMLCollectionオブジェクトだった場合、その数が多ければ多い程違いが顕著に表れてきます。 一般的にDOMへのアクセスにはコストがかかるからです。

次のコード例は、大量のDOMに対して要素の数をキャッシュしない(ループの都度要素の数をチェックする)でループする場合と、要素の数をキャッシュしてループした場合の比較です。(このコード例が適切かどうかは微妙な気がしますが、他に良いコード例が思いつきませんでした。)

(function () {
    'use strict';

    var container = document.createElement('ul'),
        i = 0,
        max = 30000,
        len = 0,
        start_time,
        end_time;

    //まずは検証用として大量のDOMを作成
    for (i = 0; i < max; i++) {
        container.appendChild(document.createElement('li'));
    }

    //パターン1:要素数をキャッシュしない場合
    start_time = (new Date()).getTime();

    for (i = 0; i < container.getElementsByTagName('li').length; i++) {
        //空のループ
    }

    end_time = (new Date()).getTime();
    window.console.log(end_time - start_time);  //(A)


    //パターン2:要素数をキャッシュした場合
    start_time = (new Date()).getTime();

    for (i = 0, len = container.getElementsByTagName('li').length; i < len; i++) {
        //空のループ
    }

    end_time = (new Date()).getTime();
    window.console.log(end_time - start_time);  //(B)
}());

私のPC環境(低スペック)で上記のコードをIE8で実行すると、(A)の出力が「5797」、(B)の出力が「0」となりました。
つまり、要素の数をキャッシュしない場合はループの開始から終了まで6秒弱程度かかっていたのに対し、要素の数をキャッシュした場合は一瞬(1ミリ秒もかかっていない)で終わったことになります。

もちろん、ループ対象のDOM要素の数が多ければ多い程両者の違いは顕著に表れてくるはずです。このことは「JavaScript: JavaScriptパターン ―優れたアプリケーションのための作法」には以下の様に書かれています。

HTMLCollectionオブジェクトを繰り返し処理する前にその長さをキャッシュしておくと、あらゆるブラウザで処理が速くなります。Safari 3 で2倍、IE7 で 190倍です。

引用元:JavaScript: JavaScriptパターン ―優れたアプリケーションのための作法

ただ、上記のコードをIE10やChrome(ver 31)などといった新しいブラウザで実行すると、両者の間に体感できるような違いはありませんでした。おそらく最新のブラウザではこのあたりの処理も実行時に最適化されるのではないかと思います。

ちなみに、要素数のキャッシュはループ中に要素の数が不変であることが条件なので、ループ内でコレクションを明示的に変更する(例えばDOM要素を追加、削除する)ような場合には、要素数をキャッシしない方が良いでしょう。

また、jQueryでDOMを扱う場合、対象となるDOM要素の数がjQueryオブジェクトのlengthプロパティに格納されるので、この場合もキャッシュさせる必要はなさそうです。

まとめ

配列(またはコレクション)をループで処理する際、ループ中にこれらの要素の数が変わらないのであれば、要素の数(配列の長さ)をキャッシュさせた方が良いでしょう。特に、ループの対象がHTMLCollectionである場合は重要度が高いです。

実行環境(ブラウザ)や、JavaScriptフレームワークの使用により、これらのメリットが享受できない場合もありますが、デメリットは無いはずですので、ループの最適化が適用できるのであれば積極的に適用した方が良いと思います。