【しばらく編集不可モードで運営します】 編集(管理者用) | 差分 | 新規作成 | 一覧 | RSS | FrontPage | 検索 | 更新履歴

EfficientJavaScript - Dev.Opera - 効率的な JavaScript

目次

Dev.Opera - 効率的な JavaScript

この文書について

効率的な JavaScript

これまでのウェブページはスクリプトをあまり含んでいなかった. 含んでいたとしても, ウェブページの性能に響くようなものではなかった. しかしウェブページがアプリケーションのようになってくると, スクリプトの性能が大きく響くようになる. ウェブの技術を使って作られるアプリケーションが多くなるほど, スクリプトの性能向上はだんだん重要になってくる.

デスクトップのアプリケーションでは, コンパイラを使ってソースを最終的なバイナリに変換する. コンパイラは時間をかけて, できあがるアプリケーションの性能を可能な限り最適化する. ウェブアプリケーションにそんな洒落た仕組みはない. 複数のブラウザ, プラットホーム, アーキテクチャで動かすから, 前もってコンパイルはできない. ブラウザはスクリプトを取得するたびに解釈とコンパイルをしないといけない. アプリケーションはデスクトップばりにスムーズに動かさないといけないし, 読み込みも速い方がいい. デスクトップの計算機からケータイまで, 広く色々な機器で動いて欲しい.

各種ブラウザは上手くやっている. そして Opera は現行のブラウザの中でも最速に並ぶスクリプトエンジンを積んでいる. とはいえ, どのブラウザにも限界はある. そこがウェブ開発者の頑張るところだ. ウェブアプリケーションをできるだけ速く動かすための, 簡単な工夫がある. ループの書き方を変える, スタイルの変更は三回やらず一回にまとめる, 実際に使うスクリプトだけを追加する, など.

この記事では, そうした簡単な変更をいくつか紹介する. それでウェブアプリケーションの性能を改善できる. 扱う範囲は ECMAScript ... JavaScript のコア言語や DOM, 文書のロード方法とした.

ECMAScript

eval や Function のコンストラクタを使うのはやめよう

eval や Function のコンストラクタにソースコードの文字列を渡して呼び出すとき, スクリプトエンジンはソースコードを実行ファイルに変換する機能を呼び出してしまう. これは性能の上で高くつく. たとえば, 単なる関数呼び出しの数百倍はかるくかかる.

eval 関数は特に性質が悪い. eval に渡される引数は前もってわからない. eval は呼び出された環境で評価されるから, 結果としてその周辺環境は最適化できない. その結果ブラウザは周辺コードを実行時にまるまる解釈しないといけない. これが更に響く.

Function のコンストラクタは eval ほど悪くない. 利用箇所周辺のコードには影響しないからだ. それでもかなり遅い.

eval を書き換えよう

eval は非効率なだけだ. ほぼ常に必要ない. 多くの場合, eval を使うのは情報が文字列でやってきた時だ. その情報を使うのに eval がいると思いこんでしまう. よくある失敗の例を以下に示す:

 function getProperty(oString) {
   var oReference;
   eval('oReference = test.prop.'+oString);
   return oReference;
 }

同じ関数は eval なしで書ける:

 function getProperty(oString) {
   return test.prop[oString];
 }

eval を使わないコードの性能はおよそ 95% 高速だった. Opera9, Firefox, Internet Explorer で計測. Safari では 85% 速かった. (なお, 関数それ自体を呼ぶ時間は含まない.)

関数を使いたいなら function を使おう

Function のコンストラクタを使う典型的な例を以下に示す:

 function addMethod(oObject,oProperty,oFunctionCode) {
   oObject[oProperty] = new Function(oFunctionCode);
 }
 addMethod(myObject,'rotateBy90','this.angle=(this.angle+90)%360');
 addMethod(myObject,'rotateBy60','this.angle=(this.angle+60)%360');

同じ関数は Function コンストラクタなしで書ける. ここでは匿名関数を使う. 匿名関数は普通のオブジェクトと同じよう参照できる.

 function addMethod(oObject,oProperty,oFunction) {
   oObject[oProperty] = oFunction;
 }
 addMethod(myObject,'rotateBy90',function () { this.angle=(this.angle+90)%360; });
 addMethod(myObject,'rotateBy60',function () { this.angle=(this.angle+60)%360; });

with を使うのはやめよう

開発者から見ると便利な with も, 性能の点では高くつく. with はスクリプトエンジンに余計なスコープを作り, 変数を参照する間はそれを使う. これ単体は小さな性能減少にしかならない. しかしスコープの中味がコンパイル時にわからないため, コンパイラは(関数が作るような)普通のスコープと同じようには最適化を行うことができない.

より効率的で, かつ開発者にも利のある方法がある. オブジェクトを普通の変数で参照して, その変数からプロパティをさわればいい. この方法はプロパティが文字列やブーリアンのようなリテラル型でない時のみ上手くいく. (訳注:変数にオブジェクトを代入すると, 変数はそのオブジェクトの参照となるが、リテラル型の場合は値のコピーが代入されてしまう.)

以下のコードを:

 with( test.information.settings.files ) {
   primary = 'names';
   secondary = 'roles';
   tertiary = 'references';
 }

次のようにするとスクリプトエンジンにとって効率がいい.

 var testObject = test.information.settings.files;
 testObject.primary = 'names';
 testObject.secondary = 'roles';
 testObject.tertiary = 'references';

性能を決める関数で try-catch-finally を使うのはやめよう

try-catch-finally 構文は特別だ. 他の構文と違い, この制御構造は実行時にスコープの中で新しい変数をつくる. catch 節が実行されるたび, 捕捉された例外は変数にセットされる. この変数は catch 節の冒頭で作られ, 終わりで破棄される.

変数が実行時に生成, 破棄されるため, これは言語の例外事項になっている. そのためブラウザによってはこれを効率的に処理できない. catch ハンドラを性能の肝になるループに置くと, 例外を補足した時に性能の問題が起こる.

可能なら, 例外処理はあまり通ることのないスクリプトの上のレベルに書いた方がいい. または, ある処理が許されるのかを前もってチェックする. 以下の例では, 期待したプロパティがない時にループは例外を投げる.

 var oProperties = ['first','second','third',...,'nth'], i;
 for( i = 0; i < oProperties.length; i++ ) {
   try {
     test[oProperties[i]].someproperty = somevalue;
   } catch(e) {
     ...
   }
 }

多くの場合, try-catch-finally 構文はループを囲むように移動できる. コードの意味は少し変わる. 例外が起きると, コード自体の実行は続くもののループは中断してしまう.

 var oProperties = ['first','second','third',...,'nth'], i;
 try {
   for( i = 0; i < oProperties.length; i++ ) {
     test[oProperties[i]].someproperty = somevalue;
   }
 } catch(e) {
   ...
 }

場合によっては try-catch-finally 構文をすっかりなくすことができる. プロパティをチェックするなど, 適当なテストを使えばいい.

 var oProperties = ['first','second','third',...,'nth'], i;
 for( i = 0; i < oProperties.length; i++ ) {
   if( test[oProperties[i]] ) {
     test[oProperties[i]].someproperty = somevalue;
   }
 }

eval と with は隔離しよう

これらの構文は性能への影響がとても大きいため, 利用は最小限に留めたい. とはいえどうしても使いたいことがあるかもしれない. 関数を何度も呼ぶ, あるいはループを回すときは, これらの構文を処理内に置かない方がいい. そういうコードは一度だけ実行されるか, せいせい数回に留めよう. 性能に響くコードの中には置かないこと.

できるなら, こうした構文は他のコードから隔離しよう. そうすれば性能への影響はなくなる. たとえばトップレベル関数の中に置く. 一度だけ動かして結果を保存し, あとからその結果を使えば再実行はいらない.

それほど重要ではないけれど, Opera を含むいくつかのブラウザでは try-catch-finally 構文が性能に響く. これらも同じような方法で隔離できるかもしれない.

グローバル変数を使うのはやめよう

グローバルなスコープに変数を作りたい誘惑はある. なにしろ楽だから. けれど, それはスクリプトを遅くする原因になる.

まず, 関数その他のスコープからそのグローバル変数を参照しようとすると, スクリプトエンジンはグローバル変数がみつかるまでスコープを順に上らないといけない. ローカルのスコープにある変数はもっとすぐみつかる.

グローバルのスコープにある変数はスクリプトの寿命と同じ間だけ残る. ローカルのスコープでは, そのスコープが消えた時に変数は破棄される. 使っていたメモリはガベージ・コレクタで解放される.

最後に, グローバルのスコープは window オブジェクトに共有される. つまり二つのスコープにまたがる. 一つだけではなくなってしまう. グローバルスコープの変数は常に名前で参照される. 事前計算で最適化されたインデックスではない. ローカルスコープにはインデックスが使われる. このように, スクリプトエンジンがグローバル変数をみつけるのには時間がかかる.

関数もふつうグローバルスコープに作られる. つまり関数が他の関数を, その関数が更に他の関数を呼ぶと, スクリプトエンジンがグローバルスコープまで戻って関数をみつける回数は増えていく.

次の単純な例では, i と s がグローバル変数だ. 関数がそのグローバル変数を使っている.

 var i, s = '';
 function testfunction() {
   for( i = 0; i < 20; i++ ) {
     s += i;
   }
 }
 testfunction();

次の別バージョンは目にみえるほど速い. 大半の現行ブラウザ, Opera や 最新版の Internet Explorer, Firefox, Konqueror, Safari では 前のバージョンよりこの方が 30% 速い.

 function testfunction() {
   var i, s = '';
   for( i = 0; i < 20; i++ ) {
     s += i;
   }
 }
 testfunction();

暗黙のオブジェクト変換に気をつけよう

文字列, 数字, ブール値などのリテラルは, ECMAScript で二種類の表現方法がある. どれも値かオブジェクトとして作ることができる. たとえば, 文字列の値は単に var oString = 'some content'; と作れる. 同じ文字列オブジェクトは var oString = new String('some content'); となる.

プロパティやメソッドはどれも文字列オブジェクトに定義される. 値にではない. 文字列値のプロパティやメソッドを参照すると, スクリプトエンジンはメソッドの実行前に値と等しい暗黙の文字列オブジェクトをつくる. このオブジェクトはその一回の要求でしか使われない. 次にその文字列値のメソッドを呼べば, またつくられる.

次のサンプルでスクリプトエンジンは 21 個の文字列オブジェクトを新たにつくっている. length プロパティにアクセスする時と, charAt メソッドを呼ぶ時だ.

 var s = '0123456789';
 for( var i = 0; i < s.length; i++ ) {
   s.charAt(i);
 }

同じことにオブジェクトを使った例. この方が良い結果を得られる.

 var s = new String('0123456789');
 for( var i = 0; i < s.length; i++ ) {
   s.charAt(i);
 }

もしコードの中でリテラル値のメソッドを何度も呼ぶなら, 前の例のようにかわりのオブジェクトに直すことを考えた方がいい.

なお本記事のポイントはどのブラウザにも意味があるが, この高速化はもっぱら Opera で効き目がある. 他のブラウザでも効果はあるかもしれないけれど, Internet Explorer や Firefox では少し遅くなる.

性能を決める関数で for-in を使うのはやめよう

for-in にも使い道はあるものの, for を使うべき場面でよく誤って使われている. for-in で列挙をする前に, スクリプトエンジンは列挙可能なプロパティのリストを作って重複を弾かなければいけない.

スクリプト側が列挙するプロパティを知っていることはよくある. こうしたプロパティをなめるならふつうの for 文が使える. 配列や, 配列風のプロパティを持つオブジェクト (DOM の NodeList? など) のように, 連番の数字なら特にそうだ.

for-in 誤用の例を以下に示す:

 var oSum = 0;
 for( var i in oArray ) {
   oSum += oArray[i];
 }

for を使う方が効率的になる:

 var oSum = 0;
 var oLength = oArray.length;
 for( var i = 0; i < oLength; i++ ) {
   oSum += oArray[i];
 }

文字列は累積スタイルで使おう

文字列の結合は高くつくことがある. + 演算子は結果を代入するまで待ってくれない. メモリ上に新しく文字列がつくられ, そこに結果がセットされる. その文字列が変数に代入される. 次のコードはよくある連結文字列の代入だ:

 a += 'x' + 'y';

このコードではまず一時文字列がメモリ上につくられ, そこに結合してできた 'xy' という値がセットされる. それが a の現在の値と結合される. 結果は a に代入される. 以下のコードではコマンドを二つにわけた. 結果は毎回 a に直接代入されるため, 一時文字列は使われない. 結果のコードは多くの現行ブラウザで 20% 速い. 連結文字列を一時的に保存しないぶんメモリ消費も少ないかもしれない.

 a += 'x';
 a += 'y';

(訳注: この話は怪しい. 元記事のコメント でも反論がでている.)

プリミティブの操作は関数呼び出しより速い

関数呼び出しを等価なプリミティブの操作に置き換えてみよう. ふつうのコードでは効き目が薄いけれど, 性能の肝になるループや関数では高速化できることがある. 例の一つは配列の push メソッド. これは配列の最後を指す添字に要素を追加するより遅い. 別の例は Math オブジェクトのメソッド. 大半のケースでは単純な算術操作の方が適している.

 var min = Math.min(a,b);
 A.push(v);

上と同じことをする別の書き方. 性能はこの方がいい:

 var min = a < b ? a : b;
 A[A.length] = v;

setTimeout() や setInterval() には文字列でなく関数を渡そう

setTimeout() や setInterval() メソッドは eval と近い関係にある. 文字列を渡すと, 指定した経過時間のあとに文字列は eval とまったく同じ方法で評価される. 性能への影響も同じだ.

しかし, これらの関数は第一引数に文字列でなく関数を渡せる. この関数は同じ経過時間のあとで実行される. ただし解釈と最適化はコンパイル時にできる. 結果として性能も優れている. 文字列をパラメタにする典型例を示す:

 setInterval('updateResults()',1000);
 setTimeout('x+=3;prepareResult();if(!hasCancelled){runmore();}',500);

最初の例では関数を直接参照すればいい. 二番目の例は匿名関数でコードをラップできる.

 setInterval(updateResults,1000);
 setTimeout(function () {
   x += 3;
   prepareResult();
   if( !hasCancelled ) {
     runmore();
    }
 },500);

なお, いずれの場合も timeout や interval の時間がきちんと守られることはない. 一般に, ブラウザは指定した時間より少し長い時間をかける. 次回呼び出しの間隔を少し早めれば済むこともある. 正しい時間が来るまで毎回ただ待ってみる手もある. CPU の速度, スレッドの状態, JavaScript のロードといった要素が時間の精度に影響する. 0 ミリ秒 という時間を実現できるブラウザはほとんどない. 最小の時間, 普通は 10 から 100 ミリ秒はかかる.

DOM

概観として, DOM が遅くなる原因は主に 3 つある. 1 つ目はスクリプトが大量の DOM 操作をしたとき. 取得したデータから新しいツリーを作るとか. 2 つ目はスクリプトが再フローや再描画を頻繁に起こしすぎたとき. 3 つ目はスクリプトが DOM ツリーのノードを探す遅い方法を使った時.

一番よくあるのは 2 つ目と 3 つ目で, 同時に最も影響が大きい. だからまずその話をしよう.

再描画と再フロー

再描画は, これまで見えなかったものが見えるように (またはその逆に) なるとおきる. これは文書のレイアウトが変わらなくてもおこる. ある要素に外枠の線を追加したとき, 背景色を変えたとき, visibility のスタイルを変えたときなどだ. 再描画は性能の点で高くつく. エンジンは全ての要素の中から, 可視なもの, 表示すべきものを探さないといけない.

再フローはもっと目立つ変更だ. これは DOM ツリーを操作したとき, レイアウトに関係するスタイルを変更した時, 要素の className プロパティを変更したとき, ブラウザのウィンドウの大きさを変更した時におこる. こうなるとエンジンは関係する要素をフローしなおし, 様々なパーツを表示すべき場所にやらなければいけない. 各要素の子も, 親要素の新しいレイアウトを踏まえてフローしなおすことになる. DOM 上で各要素の後にある要素も新しいレイアウトでフローしなおす. これは初回のフローと同じように起こる. 上流の要素もフローしなおす. 子のサイズ変更を反映するからだ. 最後には全体の再描画がおこる.

再フローは性能の点から見てとても重い. そして DOM スクリプトを重くする主な原因の一つだ. 特にケータイにように遅い計算能力の機器では厳しい. 多くの場面で, 再フローはページ全体をレイアウトしなおすのと同じようなことになる.

再フローの回数をできるだけ減らそう

スクリプトが再描画や再フローにつながる操作の必要に迫られることは多い. アニメーションは根本から再フローだ. しかも引き続き望まれている. このように再フローはウェブ開発の肝の一つになっている. スクリプトを高速化するには, 同じ効果の得られる最小限に再フローを留める必要がある.

ブラウザは再フローをする前にスクリプトのスレッドが終わるのを待つことがある. Opera は十分な変更が起こるか十分な時間が経つまで待つし, スレッドの終わりに来るまで待つこともある. したがって, 同じスレッド内で十分に素早く変更を済ませれば, 再フローは一回だけ起きて済むかもしれない. ただし, この挙動に依存することはできない. 特に Opera の動作している異なる速度の機器を考慮すると無理がある.

再フローの範囲をできるだけ小さくしよう

通常の再フローは文書全体に影響する. 再フローする文書が多ければ, それだけ時間もかかる. absolute か fixed で位置指定された要素は, 文書本体のレイアウトに影響しない. それらの要素を再フローする時は, それらだけを再フローすれば済む. その背後にある文書は変更を反映すべく再描画の必要があるものの, 全体の再フローと比べてば微々たる問題と言える.

こうした事情から, 文書全体に適用しないアニメーションは位置指定した要素にのみ用いるといい. 結局のところ大半のアニメーションはこれで十分だ.

文書ツリーの変更

文書ツリーの変更は, まず再フローにつながる. DOM に新しい要素を追加する, テキストノードの値を変える. 様々な属性の値を変える. これらはみな再フローを起こすのに十分だ. 複数の変更を順番に行うのは一回以上の再フローを起こすことがある. そこで一般に, 複数の変更は表示していない DOM ツリーのフラグメントに行うのが最善となる. 変更はそのあと実行中の文書の DOM に一回の操作で行う.

 var docFragm = document.createDocumentFragment();
 var elem, contents;
 for( var i = 0; i < textlist.length; i++ ) {
   elem = document.createElement('p');
   contents = document.createTextNode(textlist[i]);
   elem.appendChild(contents);
   docFragm.appendChild(elem);
 }
 document.body.appendChild(docFragm);

文書ツリーの変更を要素の複製に対して行おう. 次に変更が終わったら本物の要素と交換する. 再フローは一回になる. なお要素が form のコントロールを含む時にはこの方法を使わないこと. ユーザが行った値の変更が DOM ツリーに反映されない. 要素やその子につけたイベントハンドラに依存しているときもこの方法をとるべきでない. イベントハンドラは複製できないことになっている.

 var original = document.getElementById('container');
 var cloned = original.cloneNode(true);
 cloned.setAttribute('width','50%');
 var elem, contents;
 for( var i = 0; i < textlist.length; i++ ) {
   elem = document.createElement('p');
   contents = document.createTextNode(textlist[i]);
   elem.appendChild(contents);
   cloned.appendChild(elem);
 }
 original.parentNode.replaceChild(cloned,original);

不可視な要素を変更しよう

要素の display スタイルが none なら, その要素は再描画の必要がない. その中味を変更したところで表示はされないからだ. これを活かしてみよう. 複数の変更を要素やその中味に行うなら, そしてこの変更を一回の再描画にまとめられないなら, 要素に display:none をセットする. 変更をして, そのあと元の display に戻す.

これは再フローが二回余計に起こる. 一度は隠したとき, もう一度は再表示したときだ. それでも全体では速くなることがある. 要素がスクロールのオフセットに関係しているなら, 意図しないスクロールバーのジャンプが起こるかもしれない. ただ, 位置指定された要素に使うなら変な作用もなく簡単にできる.

 var posElem = document.getElementById('animation');
 posElem.style.display = 'none';
 posElem.appendChild(newNodes);
 posElem.style.width = '10em';
 ... other changes ...
 posElem.style.display = 'block';

測りごとの罠

既に述べたとおり, ブラウザは複数の変更をキャッシュしてくれるかもしれない. 全ての変更が終わったら一回だけ再フローをする. ただし, 要素の距離などを測ると再フローを強制してしまうのには気をつける必要がある. 再フローをしないと, 値が正しく求められないのだ. 変更は再描画で見えることもあるし見えないこともある. しかし再フロー自体は裏で起きている.

この測定効果は offsetWidth のようなプロパティを使ったり getComputedStyle のようなメソッドを呼ぶと起こる. 数字を使っていなくても, ブラウザが変更をキャッシュしている時にこれらを使えば効果がある. 隠れ再フローを起こすのには十分だ. 測定を繰り返し行うなら, 測るのは一度だけにして結果を保存し, 後で使いまわすことを考えるべきだろう.

 var posElem = document.getElementById('animation');
 var calcWidth = posElem.offsetWidth;
 posElem.style.fontSize = ( calcWidth / 10 ) + 'px';
 posElem.firstChild.style.marginLeft = ( calcWidth / 20 ) + 'px';
 posElem.style.left = ( ( -1 * calcWidth ) / 2 ) + 'px';
 ... other changes ...

複数回のスタイル変更は一回にまとめよう

DOM ツリーの変更と同様, 関連のある複数のスタイル変更もいっぺんに行うことができる. そうすることで再描画や再フローの数を最小化できる. 一度に複数のスタイルをセットする時, よくやる方法はこうだ:

 var toChange = document.getElementById('mainelement');
 toChange.style.background = '#333';
 toChange.style.color = '#fff';
 toChange.style.border = '1px solid #00f';

この方法だと複数の再フローと再描画が起こりうる. 改善するやりかたは二通りある. もしある要素が複数のスタイルを受け付けるなら, かつその値が事前にすべて判っているなら, 要素の class を変更すればいい. こうすると, まるごとその class で定義した新しいスタイルに差しかわる.

 div {
   background: #ddd;
   color: #000;
   border: 1px solid #000;
 }
 div.highlight {
   background: #333;
   color: #fff;
   border: 1px solid #00f;
 }
 ...
 document.getElementById('mainelement').className = 'highlight';

二つ目は, 要素に style 属性を定義するやりかただ. スタイルを一つずつ適用はしない. この方法を一番よく使うのはアニメーションのような動的な変更だ. アニメーションなどでは, 新しいスタイルの値が前もってわからない. これは style オブジェクトの cssText プロパティを使ってもいいし, setAttribute を使ってもいい. Internet Explorer では後者を使えない. 最初の方を使うこと. Opera8 を含む古いブラウザによっては後者を使う必要がある. 前者は動かない. ベタにやるならまずバージョンからサポートの有無をチェックする. 使えれば前者を使い, 駄目なら後者に逃げればいい.

 var posElem = document.getElementById('animation');
 var newStyle = 'background: ' + newBack + ';' +
   'color: ' + newColor + ';' +
   'border: ' + newBorder + ';';
 if( typeof( posElem.style.cssText ) != 'undefined' ) {
   posElem.style.cssText = newStyle;
 } else {
   posElem.setAttribute('style',newStyle);
 }

なめらかさと速度にはトレードオフがある

開発者からすると, アニメーションはできだけスムーズにしたい. タイムアウトの間隔を狭め, 変更を小さくする. たとえばアニメーションでの動作を 10 ミリ秒の間隔で行い, 移動は毎度 1 ピクセルにしたい. アニメーションはこの速さでちゃんと動くかもしれない. PC やブラウザによる. しかし 10 ミリ秒はブラウザにできる最小の間隔だ. 今時の PC なら CPU は 100% まで振り切らない. ブラウザによっては上手く扱えないこともある ... 毎秒 100 回の再フロー要求は大半のブラウザにってかなりの量だ. 性能の低い計算機や機器のブラウザだと, この速度で動くことはできないだろう. アニメーションは遅くなり, 反応も悪くなる.

開発者の名誉を汲めば, アニメーションのなめらかさと引き換えに速度が手に入る. 間隔を 50 ミリ秒にして, アニメーション幅を 5 ピクセルにしてみよう. 計算能力が少しで済み, 性能の低いマシンでも速いアニメーションになるだろう.

大量のノードを覗くのはやめよう

特定の単一ノードや複数ノートを特定するときは, 組込みメソッドや DOM のコレクションを使ってできるだけ探索するノードの数を絞り込もう. たとえば特定の属性を持つノードを探しているとき, こんな風に書いていないだろうか:

 var allElements = document.getElementsByTagName('*');
 for( var i = 0; i < allElements.length; i++ ) {
   if( allElements[i].hasAttribute('someattr') ) {
     ...
   }
 }

XPath のように高度な技術を使っていない点を見逃してもなお, この例には二つ問題がある. まず全ての要素を検索している. 検索範囲をまったく狭めていない. 二つ目は, 欲しい要素が見つかったあとも検索を続けている. 例として, 探している要素が inhere という id を持つ div の中にあるとしよう. コードはずっとよくできる.

 var allElements = document.getElementById('inhere').getElementsByTagName('*');
 for( var i = 0; i < allElements.length; i++ ) {
   if( allElements[i].hasAttribute('someattr') ) {
     ...
     break;
   }
 }

もし探すノードが div の直下の子だとわかっているなら, この方法はもっと良くなる. div の個数と childNodes コレクションの length によるけれど.

 var allChildren = document.getElementById('inhere').childNodes;
 for( var i = 0; i < allChildren.length; i++ ) {
   if( allChildren[i].nodeType == 1 && allChildren[i].hasAttribute('someattr') ) {
     ...
     break;
   }
 }

基本として DOM の要素を手動で辿るのはなるべくやめるべく気をつかおう. DOM は様々の場面で代替手段を多く用意している. DOM2 の Traversal TreeWalker? を使えば, childNodes コレクションを 再帰で辿らずにすむなど.

XPath で速度を稼ごう

単純な例として, HTML の文書内に目次をつくる作業を考える. H2 から H4 を元にする. HTML では, これらの要素は様々な場所に現れる. ちゃんとした階層構造はない. したがって再帰関数を使い正しい順序で要素を取り出すことができない. 従来の DOM を使う方法はこんな風になるだろう:

 var allElements = document.getElementsByTagName('*');
 for( var i = 0; i < allElements.length; i++ ) {
   if( allElements[i].tagName.match(/^h[2-4]$/i) ) {
     ...
   }
 }

2000 個の要素を含むような文書では, これは深刻な遅延をおこす. 個々の要素を別々に調べるからだ. XPath がネイティブで動くなら, それを使うとずっと高速になる. XPath の問合せエンジンは JavaScript で処理するより効率的になるよう最適化されている. 場合によっては二桁速い. 次の例は従来の方法を使う例と等価だが, XPath を使って速度を改善している.

 var headings = document.evaluate( '//h2|//h3|//h4', document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null );
 var oneheading;
 while( oneheading = headings.iterateNext() ) {
   ...
 }

次は両方の例を合わせたバージョン. XPath があれば使い, なければ従来の DOM になる.

 if( document.evaluate ) {
   var headings = document.evaluate( '//h2|//h3|//h4', document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null );
   var oneheading;
   while( oneheading = headings.iterateNext() ) {
     ...
   }
 } else {
   var allElements = document.getElementsByTagName('*');
   for( var i = 0; i < allElements.length; i++ ) {
     if( allElements[i].tagName.match(/^h[2-4]$/i) ) {
       ...
     }
   }
 }

DOM を辿りながら書き換えるのはやめよう

ある種の DOM コレクションは生きている. つまりスクリプトがそのコレクションを見ながら関係のある要素を変更すると, コレクションはスクリプトの終わりを待たずに変化する. これには childNodes コレクションが該当する. getElementsByTagName の戻り値もそう.

スクリプトがこれらのコレクションでループをして, 同時にその要素を追加したら, ここには無限ループの危険がある. スクリプトは終端に至る手前で要素を追加しつづけてしまう. 問題はこれだけではない. これらのコレクションは性能のため最適化されている. 自分の長さを覚えているし, 最後に参照された添字も覚えている. そうすれば添字をインクリメントしてアクセスした時, すぐに次のノードを参照できる.

DOM ツリーを変更すると, その変更が先のコレクションを含まなくても, そのコレクションは新規エントリがないか再確認しなければいけない. そうなると最後の添字や長さは覚えておけず, まるで変更されたかのように最適化はどこかに行ってしまう:

 var allPara = document.getElementsByTagName('p');
 for( var i = 0; i < allPara.length; i++ ) {
   allPara[i].appendChild(document.createTextNode(i));
 }

次の等価なコードは Opera や Internet Explorer のような最近のブラウザだと 10 倍速い. まず変更する要素の静的なリストをつくり, 次にそのリストを辿りながら変更を加えている. getElementsByTagName の返すノードのリストは辿らない.

 var allPara = document.getElementsByTagName('p');
 var collectTemp = [];
 for( var i = 0; i < allPara.length; i++ ) {
   collectTemp[collectTemp.length] = allPara[i];
 }
 for( i = 0; i < collectTemp.length; i++ ) {
   collectTemp[i].appendChild(document.createTextNode(i));
 }
 collectTemp = null;

DOM の値はスクリプトの変数にキャッシュしよう

DOM の値のうちいくつかはキャッシュが利かない. 呼び出しの度に再確認される. 例として getElementById がある. 次のコードは無駄が多い.

 document.getElementById('test').property1 = 'value1';
 document.getElementById('test').property2 = 'value2';
 document.getElementById('test').property3 = 'value3';
 document.getElementById('test').property4 = 'value4';

このコードでは同じオブジェクトをみつけるのに 4 回リクエストを出している. 次のコードはリクエストを一回して値を保存する. ここまでの速度は同じか, 代入のぶん少し遅い. しかし続く回ではキャッシュした値を使う. そのため上と等価なコードでありながら, 最近のブラウザで処理が 5 - 10 倍速い.

 var sample = document.getElementById('test');
 sample.property1 = 'value1';
 sample.property2 = 'value2';
 sample.property3 = 'value3';
 sample.property4 = 'value4';

文書のロード

ある文書から他の文書への参照を生かしておくのはやめよう

ある文書が他の文書にあるノードやオブジェクトにアクセスするとき, スクリプトで使い終わった参照を持ちつづけるのはやめよう. 参照を今の文書のグローバル変数や長寿なオブジェクトのプロパティに保存したなら, null をセットするなり削除するなりしてクリアしよう.

理由はこうだ. ある外部文書を破棄したとき, たとえばウィンドウをポップアップしてからそのウィンドウを閉じたら, その文書に由来するオブジェクトへの参照があると DOM ツリー全体とスクリプト環境が RAM に留まってしまう. 文書自体はもうロードされていないのに. 同じことはページ内のフレーム, インラインフレーム, OBJECT 要素についても言える.

 var remoteDoc = parent.frames['sideframe'].document;
 var remoteContainer = remoteDoc.getElementById('content');
 var newPara = remoteDoc.createElement('p');
 newPara.appendChild(remoteDoc.createTextNode('new content'));
 remoteContainer.appendChild(newPara);
 // 参照を消す
 remoteDoc = null;
 remoteContainer = null;
 newPara = null;

高速な履歴移動を使おう

Opera (や他のブラウザの多く) はデフォルトで高速な履歴移動を使う. ユーザがブラウザの履歴を進んだり戻ったりするとき, ページやスクリプトの状態は保存されている. ユーザがページに戻ってくると, ページから出たことなどなかったかのように話が進む. 文書は再読み込みも再初期化もされない. スクリプトは動きつづけているし, DOM はページを出た時のままだ. おかげでユーザの体感は高速になる. 読み込みの遅いウェブアプリケーションも 履歴の移動中はましになる.

Opera だとページ作者はこの挙動を制御できるけれど, できるなら高速履歴移動モードを使う方がいい. だからスクリプトもできるだけこの挙動を邪魔しないようにしたい. 邪魔になるものには, 投稿後にフォームのコントールを無効化する, クリックしたメニューの作用を止める, ページ退出時に中味を見えなくるフェードアウト効果などが ある.

onunload リスナでフェード効果を消したりフォームのコントロールを有効にすればいいのなら, 話は簡単だった. しかし Firefox や Safari など一部のブラウザでは unload イベントにリスナをつけると 高速履歴移動が無効になってしまう. それに, submit ボタンの無効化をするだけで Opera の高速履歴移動を止めるには十分だったりする.

 window.onunload = function () {
   document.body.style.opacity = '1';
 };

XMLHttpRequest を使おう

この方法はどんなプロジェクトにも効くわけではない. しかしサーバから取得するコンテンツの量を減らす楽な方法にはなりうる. それにページを読み込み間でのスクリプト環境破棄や再構築という, 重い作業を避けることにもなる. まず最初は普通にページをロードすればいい. それ以降のロードでは XMLHttpRequest を使って最小限のコンテンツをロードする. こうすればスクリプト環境を生かしておくこともできる.

そうは言うものの, このアプローチにも問題はある. まず履歴移動は完全に破綻する. インラインフレームに情報を持たせてごまかすことはできるけれど, この問題は XMLHttpRequest を第一線で使う邪魔になる. だから使うのはほどほどにしておこう. 前のコンテンツに戻る必要がない時にだけ役に立つ.

JavaScript が使えないときや, XMLHttpRequest がサポートされていない時にも困る. 問題を回避するいちばん簡単な方法は, ふつうのリンクを使うことだ. リンクは新しいページを指す. リンクにはイベントハンドラを設定して, リンクの動作を検出する. ハンドラでは XMLHttpRequest のサポートをチェックして, サポートありならリンクのデフォルト動作を止めつつデータをロードする. データがロードされたらそのコンテンツでページを置き換える. リクエストオブジェクトは破棄して, ガベージコレクタが回収できるようにしよう.

 document.getElementById('nextlink').onclick = function () {
   if( !window.XMLHttpRequest ) { return true; }
   var request = new XMLHttpRequest();
   request.onreadystatechange = function () {
     if( request.readyState != 4 ) { return; }
     var useResponse = request.responseText.replace( /^[\w\W]*<div id="container">|<\/div>\s*<\/body>[\w\W]*$/g , '' );
     document.getElementById('container').innerHTML = useResponse;
     request.onreadystatechange = null;
     request = null;
   };
   request.open( 'GET', this.href, true );
   request.send(null);
   return false;
 }

SCRIPT 要素を動的に作ろう

スクリプトをロードして処理するには時間がかかる. にもかかわらず, スクリプトによってはロードされたのに使われないことがある. そんなスクリプトは時間と資源の無駄にしかならない. 本体のスクリプトが動くのを妨げるだけだ. 使わないスクリプトは一切ロードしない方がいい.

理屈の上では, 追加のスクリプトは SCRIPT 要素を作って DOM で文書に追加すればロードできる. これは現行バージョンの全ての主要ブラウザで動く. ただロードするスクリプトよりロード処理の方が重くなってしまうかもしれない. 更に, 追加スクリプトはページのロードが終わる前に必要かもしれない. だから最良の手はページのロード状況をチェックし, document.write で script タグを 作る方法だ. スクリプトを途中で閉じないよう, スラッシュをエスケープするのだけは忘れずに.

 if( document.createElement && document.childNodes ) {
   document.write('<script type="text\/javascript" src="dom.js"><\/script>');
 }
 if( window.XMLHttpRequest ) {
   document.write('<script type="text\/javascript" src="xhr.js"><\/script>');
 }

location.replace() で履歴を抑えよう

たまにページのアドレスをスクリプトから変更したいことがある. よくやるのは location.href に新しいアドレスを代入する方法. この方法だと履歴にエントリが追加され, 新しいページをロードする. ふつうのリンクをたどったのと同じようになる.

履歴の追加が望ましくないこともある. ユーザが前のページに戻る必要の無い時などだ. 特に使用メモリが限られている場合は重宝する. 履歴を置き換えれば, 現在のページで使われているメモリは復活する. (訳注: 履歴に入らない.) そうするには location.replace() メソッドを使えばいい.

 location.replace('newpage.html');

なお, キャッシュにはページが残る. メモリを使うかもしれない. それでも履歴に保持しておくより多いということはない.

著作権情報


反応