吉川 徹

DOM操作の最適化によるJavaScriptチューニング(後編)

連載「Webサイト・アプリ高速化テクニック徹底解説」の第5回は、前回の「DOM操作の最適化によるJavaScriptチューニング(前編)」に続く後編です。後編では、createElement()などのDOM操作メソッドを使ったさまざまなテクニックや、パフォーマンスを劣化させるよくあるパターンについて詳しく解説します。

CodeIQとの連動企画!

この記事で学べるJavaScriptチューニングのテクニックを、実際にCodeIQの問題で試すことができます。もう既に自信がある方は腕試しに、もしくは理解度チェックのための復習として是非ご活用ください!こちらから問題にチャレンジ!

DOM操作の最適化によるJavaScriptチューニング(後編)

前回は、DOM操作が遅い原因と仕組みについて簡単に説明し、チューニングのサンプルをいくつか解説しました。その中で、innerHTMLを利用したコードをサンプルにあげていますが、innerHTMLを利用する場合、いくつかの点で注意が必要です。

  • HTMLの文字列を扱うため単純な子要素の追加ができない(子要素をすべて書き換えることになる)
  • 追加する要素が多くなると、文字列連結のコストやHTMLをパースするコストが膨れ上がる
  • HTMLを直接扱うため、きちんとエスケープしないとクロスサイトスクリプティングの脆弱性を作りこむ可能性がある

以上の点から、特に理由がなければinnerHTMLよりもDOM操作メソッドを使ったほうが良いでしょう。今回は、DOM操作メソッドを使ったさまざまなテクニックや、パフォーマンスを劣化させるよくあるパターンを紹介し、そのチューニング方法を見ていきます。

また、前回の記事のinnerHTMLを使ったサンプルでは、上に挙げたような複数の要因でパフォーマンスに影響与えていることから、レイアウト・レンダリングの遅さのみを理由とするには、あまり適切ではありませんでした(チューニング自体には問題はありませんので心配しないでください)。折を見て改訂したいと思います。ご指摘して頂いた方々、ありがとうございました。 本連載では、より良い情報を皆さんに届けるため、ご指摘や疑問点などがあれば是非フィードバックを頂ければと思います。フィードバックを受け、できるかぎり記事を更新していきたいと思います。

DOM操作メソッドを使ったテクニック

createElement()やappendChild()などのDOM操作メソッドを使った方法では、単純にinnerHTMLによるコードを置き換えるだけでなく、さまざまなテクニックがあります。ここでは、DOM操作メソッドを使う際に覚えておいた方がよいテクニックをいくつか紹介します。

複数の要素をまとめて追加する

複数の要素を追加する場合、構造によってはそのままでは一度に追加できないことがあります。例えば、次のサンプルのようにul要素にli要素を追加していくようなケースです。

:javascript:
// サンプル1: ul要素にli要素を追加していく(低速)
var ul = document.querySelector('#output');
for ( var i = 0; i < data.length; i++ ) {
  var li = document.createElement('li');
  li.textContent = data[i];

  // ループのたびにli要素を追加
  ul.appendChild(li);
}

このサンプルでは、ループのたびにli要素を追加していますので、DOMツリーへ更新が何度も発生します。こういった場合には、DocumentFragmentを利用して複数のli要素をまとめて追加することができます。次のコードは、DocumentFragmentを利用して書き換えたコードです。

:javascript:
// サンプル1: ul要素にli要素をまとめて追加(高速)
var ul = document.querySelector('#output'),
    fragment = document.createDocumentFragment();

for ( var i = 0; i < data.length; i++ ) {
  var li = document.createElement('li');
  li.textContent = data[i];

  // いったんDocumentFragmentに追加する
  fragment.appendChild(li);
}

// 最後にDocumentFragmentをul要素に追加する
ul.appendChild(fragment);

DocumentFragmentは、従来のDOMツリーとは分離された独立した小さなDOMツリーです。createDocumentFragment()メソッドを使って作成します。DocumentFragmentに追加された要素は、そのままでは見た目に影響を与えないため、まずはDocumentFragmentにli要素を追加していきます。そして、最後にul要素にそのDocumentFragmentを追加し、反映します。こうすることで、元のDOMツリーへの更新が1回だけで済みます。

繰り返し同じような要素を追加する(テンプレート化)

同じような要素を繰り返し追加する場合、要素をまた一から作成していくのは面倒です。また、要素が多くなってくるとパフォーマンス的にもあまり良くありません。例えば、ボタンが押されるたびに、次のような構造を持つli要素を追加していくことを考えてみましょう。

:html:
<li>
  <artcile class="item">
    <h1 class="title">アイテム</h1>
    <p class="detail">詳細</p>
  </artcile>
</li>

これを素直にDOM操作メソッド使って構築すると次のようにボタンが押されるたびに毎回要素を生成して構築する形になります。

:javascript:
// ボタンがクリックされる度に複雑な要素を追加
button.addEventListener('click', function(){

  // 各要素を生成
  var li = document.createElement('li'),
      article = document.createElement('article'),
      h1 = document.createElement('h1'),
      p = document.createElement('p');

  // 各要素のプロパティを設定し、組み立てる(省略)

  ul.appendChild(li);
}, false);

サンプルでは、コードが多い部分を省略していますが、多くのDOM操作をしています。このような場合、構築される要素をテンプレート化して、それをcloneNode()を使ってコピーするようにすれば、DOM操作の数を減らすことができます。cloneNode()を利用すると次のようなコードになります。

:javascript:
// テンプレートとしてli要素を構築
var template = document.createElement('li'),
    article = document.createElement('article'),
    h1 = document.createElement('h1'),
    p = document.createElement('p');

// 各要素のプロパティを設定し、組み立てる(省略)

// ボタンがクリックされる度にテンプレートから要素を追加
button.addEventListener('click', function(){

  // テンプレートから要素をコピー
  var li = template.cloneNode(true);

  // 一部書き換えて追加
  li.querySelector('.title').textContent = 'アイテム';
  li.querySelector('.detail').textContent = 'アイテムの詳細'
  ul.appendChild(li);
}, false);

こうすると、ボタン押すたびにテンプレートからコピーするだけなので効率的です。構築する要素が複雑になればなるほど、cloneNode()を利用する方が高速になります。cloneNode()の引数は、子要素を一緒にコピーするかどうかなので、trueを指定しておきます。また、cloneNode()ではイベントリスナーをコピーすることはできませんので注意してください。

複数の要素をまとめて置き換える

既に表示されているインターフェースに対して最新の情報を反映するような場合があるかと思います。その際に、表示するデータが細かいとDOM操作も細かくなってしまうことがあります。例えば、次のようなHTMLの各p要素の内容を書き換えることを考えてください。

:html:
<div class="results">
  <p>結果1</p>
  <p>結果2</p>
  <p>結果3</p>
</div>

これを単純に書き換える場合は、次のようなコードになりがちです。

:javascript: 
var elements = document.querySelectorAll('.results p');
elements[0].textContent = '結果a';
elements[1].textContent = '結果b';
elements[2].textContent = '結果c';

この場合、3つのp要素を順に書き換えているので、DOMツリーへの更新が3回発生してしまいます。このような場合、replaceChild()を使って一度に置き換えることができます。

:javascript:
var origin = document.querySelector('.results'),
    clone = origin.cloneNode(true);

// コピーした要素を更新する
var elements = clone.querySelectorAll('p');
elements[0].textContent = '結果a';
elements[1].textContent = '結果b';
elements[2].textContent = '結果c';

// 元の要素とコピーした要素を入れ替える
origin.parentNode.replaceChild(clone, origin);

この方法であれば、DOMツリーへの更新は1回で済みます。また、先ほどのサンプルと同様にcloneNode()がイベントハンドラーをコピーできないことに注意しましょう。

パフォーマンスを劣化させるよくあるパターン

ここからは、パフォーマンスを劣化させるよくあるパターンや、そのチューニング方法を紹介します。

複数のスタイルの書き換え

ある要素のスタイルを複数書き換える場合に、ついやってしまいがちなのが次のコードです。

:javascript:
// 複数のスタイルの書き換え(低速)
element.style.background = 'gray';
element.style.border = '1px solid black';

この場合も、レイアウトが複数発生する可能性があるので、次のように記述しましょう。

:javascript:
// 複数のスタイルの書き換え(高速)
// style属性で一度にすべて指定する
element.setAttribute('style', 'background: gray; border: 1px solid gray;');

setAttribute()を使ってsytle属性を使って一度に複数のスタイルを指定しています。また、あらかじめ指定するスタイルがある程度決っているなら、可読性やメンテナンスを考えてclass属性を使っても良いでしょう。

:javascript:
// classを指定して複数のスタイルを適用する
element.className = 'hoge';

アニメーション

ある要素の位置やサイズを動かしてアニメーションさせるような場合、スタイルでposition: absoluteやpositon: fixedを指定しておくと良いでしょう。これは、レイアウト・レンダリングの範囲を最少化するためです。position: absoluteなどを指定すると他の要素との位置関係やサイズ計算から切り離されるため、その要素に対する変更が他の要素に影響しなくなります。そのため、レイアウト・レンダリングのコストが小さくなり、パフォーマンスが向上します。アニメーションのチューニングについては、また別の記事で触れますので楽しみにしていてください。

スタイル情報の取得

多くのDOM操作によるスタイル情報の取得の中でも、次に挙げるプロパティ、メソッドについては特に注意が必要です。

  • getComputedStyle()
  • offset*系のプロパティ
  • client*系のプロパティ
  • scroll*系のプロパティ

(offset*系のプロパティとは、offsetという単語を含むoffsetTop、offsetLeft、offsetHeight、offsetWidthなどのプロパティです)

これらのプロパティ、メソッドは、現時点での最新の情報を返そうとするため、それまでに実行したDOM操作があれば、すぐさまレイアウト・レンダリングを実行します。本来であればブラウザが自動的に最適化し、非同期に実行しているものを強制的に実行してしまうため大きなボトルネックになります。例えば、あるブロックを単純に右に動かすだけのコードを見てみましょう。

:javascript:
// あるブロックを右に動かす
setInterval(function(){
  block.style.left = block.offsetLeft + 1 + 'px';
}, 1000 / 60 );

ここでは、あるブロックのoffsetLeftを取得し、1を加えてスタイルのleftに代入しています。leftの値を更新した後に、またすぐにoffsetLeftを参照しているので、その時点でレイアウト・レンダリングが発生し、遅くなります。そのため、offsetLeftの値をキャッシュしておき、以降はそれを利用するように変更しましょう。

:javascript:
// あるブロックを右に動かす
// offsetLeftの値を一度キャッシュして以降は使いまわす
var left = block.offsetLeft;
setInterval(function(){
  left++;
  block.style.left = left + 'px';
}, 1000 / 60 );

このように、上記であげたプロパティ、メソッドは、多くのDOM操作をしているような箇所では特にボトルネックになりやすいので注意しましょう。(サンプルでは、JavaScriptを使っていますが、もちろんCSSで記述しても良いでしょう。)

要素の取得

それほど気にするほどの差ではありませんが、要素を取得する際にquerySelector()やquerySelectorAll()よりも、getElementById()やgetElementsByTagName()、getElementsByClassName()を利用した方が若干速くなります。

:javascript:
// div要素をすべて取得
var elements = document.querySelectorAll('div');

// querySelectorAll()よりも若干速い
var elements = document.getElementsByTagName('div');

その他の違いにも、getElementsByTagName()などは、結果として返るリスト(NodeList)が常に現在のDOMツリーを反映したものになるのに対して、querySelectorAll()などは取得した時点のものになります。例えば、これらのメソッドで要素を取得した後に、要素が追加された場合、getElementsByTagName()で取得したリストではリストにその要素が自動的に追加されますが、querySelectorAll()ではリストに変化はありません。そういった違いも覚えておくと良いでしょう。

まとめ

ここまで前編、後編にわたってDOM操作の最適化について解説しました。いくつかのサンプルやパターンを交えて、チューニング方法を紹介してきましたが、もちろんこれ以外にもたくさんのテクニックがあります。細かいテクニックをあげていくときりがありませんが、重要なのはDOM操作自体の回数を減らすことと、DOMツリーへの更新とレイアウトやレンダリングなどの範囲、回数を減らすことです。それさえ覚えておけば、自分でいろいろなコードに応用できるかと思います。自分自身で考えてチューニングしていきましょう。

Powered byNTT Communications

tag list

アクセシビリティ イベント エンタープライズ デザイン ハイブリッド パフォーマンス ブラウザ プログラミング マークアップ モバイル 海外 高速化 Angular2 AngularJS Chrome Cordova CSS de:code ECMAScript Edge Firefox Google Google I/O 2014 HTML5 Conference 2013 html5j IoT JavaScript Microsoft Mozilla Node.js PhoneGap Polymer React Safari SkyWay TypeScript UI UX W3C W3C仕様 Webアプリ Web Components WebGL WebRTC WebSocket