はじめに
ウェブページをブラウザで表示する際、背後では複雑なレンダリングプロセスが実行されています。
今回は、レンダリングプロセスの中の工程である Scripting( JavaScript の実行) のパフォーマンスを向上するためのテクニックについて、記事を書いていこうと思います。
関連記事
本記事に関連した記事も投稿したので、ぜひ読んでいただけると幸いです!
参考書
前提
JS 実行時のパフォーマンス改善のテクニックを紹介する前に、前提の知識としてブラウザ内での JS 実行モデル( JS がどのようにブラウザ上で実行されるのか)について解説します。
それは、下記の 2 つです。
- UI スレッド
- イベントループと実行キュー
UIスレッド
JavaScriptの実行は、基本的にタブ1つにつき 1 つの UI スレッド(メインスレッド)上で行われます。
※ Web Workers や Service Workers といった別スレッドで動作する場合を除いて
UI スレッドは、ブラウザのユーザーインターフェイスに関連する処理を担当し、ブラウザの各タブごとに1つ存在します。
このスレッドでは、JavaScript の実行だけでなく、レンダリングエンジンの様々な処理(レイアウトの計算やレンダリング、 DOM イベントの発火など)も行われます。
UI スレッド上での処理が遅延した場合
UI スレッド上の処理が遅延すると、JavaScript の実行が遅くなる(処理が重くなる)ことによって、場合によっては他の UI 関連の処理も遅れること状態になる倍もあります。
例えば、DOM イベントの発火が遅延してしまい、重い処理中にクリックしても、そのイベントは処理が完了するまで発火しないといったことになります。
イベントループと実行キュー
UI スレッドでの処理は、 UI スレッド内の「実行キュー」にタスクとして格納され、順次消費されます。
タスクが全て処理されると、 UI スレッドは Idle 状態(アイドル状態)になります。
JavaScript はこの UI スレッド内で、イベントループと呼ばれるモデルで動作します。
このモデルにより、シングルスレッドでありながら、並行処理が可能となります。
例えば、Ajax を利用した HTTP リクエストのレスポンス待ちの間にも、他の JavaScript の処理や UI スレッドの処理を実行することができます。
なぜなら、イベントループでは、ネットワーク処理や待ち時間の処理が UI スレッドをブロックしないためです。
注意点
イベントループや非同期処理の概念は、async
/await
と関連していますが、完全に同義ではありません。
イベントループや非同期処理の概念は、 JavaScript のコアに組み込まれたメカニズムであり、async
/await
はその上に構築された、非同期処理をより扱いやすくするための言語機能です。
つまり、async
/await
はイベントループを活用して非同期処理を行う方法の一つであり、特に非同期コードをシンプルで読みやすくするための糖衣構文(シンタックスシュガー)です。
JavaScript のパフォーマンス向上のためのテクニック
前提の知識としてブラウザ内での JS 実行モデルを解説することができたので、実際に JavaScript のパフォーマンスを向上のためのテクニックを解説していきたいと思います。
1. GC を避ける
JavaScript の実行が停止される GC は避けるべきです。
前提
そもそもJavaScript では、 C 言語や C++ とは異なり、メモリの直接的な管理を行うことはありません。
その代わりに、不要になったオブジェクトを自動的に解放する GC (ガベージコレクション)機能が備わっています。
しかし、 GC が発生すると JavaScript の実行が一時的に停止するため、特にアニメーションやインタラクションの処理において、パフォーマンスに悪影響を及ぼす可能性があります。
GC の問題点(詳細)
GC は、オブジェクトをnew
で生成するたびに、メモリの使用量がある閾値を超えると引き起こされます。
GC が実行されると、 JavaScript の処理が一時的に停止し、メモリの解放処理が行われます。
この一時停止が、パフォーマンスに悪影響を及ぼし、特にアニメーションやユーザーインタラクション中に問題を引き起こす可能性があります。
そのため、GC による一時停止は極力避けるべきです。
GCを検知し防ぐ方法
GC がどのタイミングで発生しているかは、Chrome DevTools (開発者ツール)のPerformance パネルを使って確認できます。
Performance パネルでは、 GC によって JavaScript の実行が停止した時間の記録がされており、これによりパフォーマンスのボトルネックを特定することができます。
GC を防ぐ方法
GC を防ぐ方法として、すぐに実践できる観点から下記の 2 つを今回記載したいと思います。
- オブジェクトの再利用
- 新しいオブジェクトを頻繁に生成せず、既存のオブジェクトを再利用することで GC の発生を抑えます
- オブジェクトの事前生成
- アニメーションやコールバック処理で使用するオブジェクトは、処理前にすべて生成しておき、実行中に新たに生成しないようにします
2. メモリリークを防ぐ
十分なメモリを確保できず、パフォーマンスが低下する可能性のあるメモリリークは、防ぐべきです。
メモリリークとは
メモリリークとは、プログラムが不要になったメモリを解放せずに残してしまうことで、利用可能なメモリが徐々に減少し、最終的にシステムのパフォーマンスが低下する現象です。
JavaScript では、 GC (ガベージコレクション)が不要なオブジェクトを自動的に解放します。
ただ、 GC が全ての不要なメモリを解放してくれるわけではない開発者が適切にメモリ管理を行わないと、メモリリークが発生することがあります。
(思わぬ箇所で参照を保持されており、メモリの解放が行われていなかった... etc )
メモリリークを防ぐための基本原則
1. メモリ使用量を抑えることの重要性の再認知
そもそもメモリ使用量を最小限に抑えることで、アプリケーションのパフォーマンスを向上させることができます。
デスクトップ OS やモバイル OS においても、メモリリソースは限られているため、無駄なメモリの使用を避けることが重要です。
次で記載している「メモリリークを検知する」の手段を用いて、「現状のメモリ使用量」を把握して、無駄なメモリを食っていないかコードをデバック(チェック)することが大切です。
2. メモリリークを検知する
Chrome DevTools の Performance パネルを使用して、メモリ使用量の推移を確認することで、メモリリークが発生しているかどうかを特定できます。
正常にメモリが解放されている場合は、メモリ使用量が一定範囲で上下しますが、メモリリークがある場合は、メモリ使用量が徐々に増加します。
3. console.log()
によるメモリリーク
デバッグのためにconsole.log()
でオブジェクトを表示することがありますが、これによりメモリリークが発生する可能性があります。
これは、Chrome DevTools が参照を保持するため、 GC によって解放されないからです。
console.log()
の代わりにconsole.dir()
やconsole.debug()
など、特定のデバッグ用メソッドを使用することで、メモリリークを防ぐことができます。
また、プロダクション環境では、デバッグ用コードを削除するか無効化することが重要です。
余談
ESLint を使用してプロダクション環境でデバッグ用コードを削除または無効化するができます。
例えば、下記だと .eslintrc.json
で本番環境でのconsole.log
とconsole.error
の使用を禁止しています。
{
"env": {
"browser": true,
"es6": true
},
"overrides": [
{
"files": ["*.js"],
"env": {
"production": {
"rules": {
"no-console": "error",
"no-debugger": "error"
}
},
"development": {
"rules": {
"no-console": "off",
"no-debugger": "warn"
}
}
}
}
]
}
3. DOM リークを防ぐ(= メモリリーク)
メモリリークの事象の 1 つとして DOM リークがあります。
この DOM リークを防ぐことで、不要なメモリを解放して、パフォーマンス向上に寄与することができます。
DOM リークとは
そもそもDOM リークとは、誤って参照を保持したままのために DOM 要素がメモリから解放されず、リークすることを指します。
特定の DOM 要素がリークすると、その要素が含まれる全体の DOM ツリーが切り離されずに残ってしまい、結果として不要なメモリが占有されたままになります。
例えば、DOM 要素内で子要素や親要素への参照を持っていると、その参照のせいでツリー全体がメモリから解放されなくなります。
さらに、DOM 要素には画像やファイルなどのリソースも関連付けられているため、DOM リークが発生するとこれらのリソースも解放されず、メモリの消費が続くことになります。
たとえば、img
要素のimage
属性に格納された画像データが解放されなくなります。
DOM リークを検知する方法
DOM リークが発生しているかを確認するには、Chrome DevTools の Performance パネルを使用します。
プロファイルを取りながら DOM 要素を操作し続け、その後プロファイル内で「 Nodes 」グラフを確認します。
DOM 要素が正しく解放されていない場合、このグラフが徐々に増加していくことでリークが検知できます。
DOM リークを防ぐ方法
DOM リークを防ぐ方法としては、主に下記があります。
1. 不要なイベントリスナーのかいじょ
DOM 要素にイベントリスナーを追加した場合、要素が不要になった時点でリスナーを解除することで、要素をメモリから解放することができます。
const button = document.getElementById('myButton');
const handleClick = () => {
console.log('Button clicked');
};
button.addEventListener('click', handleClick);
// 要素が削除される前にイベントリスナーを解除する
button.removeEventListener('click', handleClick);
2. DOM 要素が不要になったら null
を設定する
DOM 要素を変数やオブジェクトに保存する場合、その要素が不要になったときには、参照を null
に設定することで、 GC が解放できるようになります。
3. クロージャの解放
クロージャが DOM 要素をキャプチャすると、クロージャがメモリに残っている限り、 DOM 要素も解放されないため、クロージャを含めてメモリから解放することで、メモリリークを防ぐことができます。
function createHandler(element) {
return function() {
console.log(element.id);
};
}
const button = document.getElementById('myButton');
button.addEventListener('click', createHandler(button));
// buttonが不要になったら、クロージャも含めて適切にメモリから解放
button.removeEventListener('click', createHandler(button));
button = null;
まとめ
今回は、レンダリングプロセスの中の工程である Scripting( JavaScript の実行) のパフォーマンスを向上するためのテクニックについて記事を書きました。
最近は、ライブラリやフレームワークが自動的に DOM 管理やイベントリスナーの解除を行なっていることで、あまり意識せずともメモリリークを防ぐことはできています。
ただ、一方でメモリリークが意図せず発生した場合や、パフォーマンス改善に乗り出す機会があれば、これらの JS の高速化テクニックは知っていて損はないはずです。
今後とも、細かい粒度でブラウザのレンダリングの仕組みに対する知見を広げていきたいと思います。