【ITニュース解説】From Angular.js to Fine-Grained Reactivity: Part 3 - How to Optimize the Render Phase
2026年08月24日に「Dev.to」が公開したITニュース「From Angular.js to Fine-Grained Reactivity: Part 3 - How to Optimize the Render Phase」について初心者にもわかりやすく解説しています。
ITニュース概要
JavaScriptで複数のデータ変更が個別にUIを更新すると、表示の遅延や画面のちらつきが生じる。解決策はマイクロタスク活用だ。変更をバッチ処理でまとめて一度にUI更新することで、表示効率を高め、ユーザー体験改善を図る。
ITニュース解説
Webアプリケーションを開発する際、画面に表示される情報(ユーザーインターフェース、UI)を効率的に更新することは非常に重要である。特に、ユーザーの操作やプログラムの処理によってデータが変更されたときに、その変更を画面に素早く、かつ無駄なく反映させる仕組みは、アプリケーションのパフォーマンスやユーザー体験に直結する。ここでは、そのようなUI更新の最適化について、具体的な問題とその解決策を解説する。
まず、WebアプリケーションにおけるUIの更新方法について考える。多くのフレームワークやライブラリでは、データが変更されると、その変更を検知して画面を更新する仕組みが備わっている。例えば、JavaScriptのProxyという機能を使うと、オブジェクトのプロパティ(値)が変更されたことを監視できる。あるプロパティに新しい値が代入されると、Proxyがそれを検知し、画面を更新するための関数(ここではupdate関数と呼ぶ)が呼び出される、という一連の流れが一般的である。
しかし、この仕組みにはいくつかの課題がある。 例えば、以下のようなコードを考えてみよう。
1export function SimpleController($scope) { 2 $scope.name = "Mario"; 3 $scope.age = 24; 4}
このコードでは、$scopeオブジェクトのnameプロパティとageプロパティにそれぞれ値を代入している。一般的な変更検知の仕組みでは、$scope.name = "Mario";という代入が行われるたびに一度update関数が呼び出され、次に$scope.age = 24;という代入が行われるたびに、もう一度update関数が呼び出されてしまう。つまり、二つの別々の代入が、二回の画面更新処理をトリガーしてしまうのである。もし実際のエンタープライズアプリケーションのように、一つの処理の中で何十ものプロパティが更新される場合、そのたびに画面更新処理が走ってしまうと、アプリケーションの動作が遅くなったり、無駄な処理が増えてしまう原因となる。
さらに深刻な問題は、同じプロパティに複数回値が代入されるケースである。
1export function SimpleController($scope) { 2 $scope.name = "Mario"; 3 4 // 時間のかかる処理を挟む 5 const age = computeAge(); // この計算に100ミリ秒かかると仮定する 6 7 $scope.name = `${$scope.name} ${age}`; 8}
この例では、まず$scope.name = "Mario";という代入が行われると、すぐにupdate関数が呼び出され、画面に「Mario」と表示される。しかし、その後computeAge()という時間がかかる処理が実行され、その結果を使って再度$scope.nameに値が代入される。この二回目の代入によって、またupdate関数が呼び出され、画面は「Mario 24」(computeAge()の結果が24だった場合)に更新される。
この一連の動きの中で、ユーザーは最初に「Mario」という文字列を画面上で一瞬だけ目にする。そして、computeAge()の計算時間に応じて、わずかな時間の後に画面が「Mario 24」に切り替わる。このように画面の内容が一瞬で変わってしまう現象を「ちらつき(flickering)」と呼ぶ。ユーザーにとって、画面が頻繁に、しかも不自然に変わることは、非常に不快な体験となり、アプリケーションの品質が低いと感じさせてしまうリスクがある。
これらの問題を解決するためのエレガントな方法として、「イベントループ」と「マイクロタスクキュー」という仕組みを利用する手法がある。JavaScriptが動作する環境(ブラウザなど)には「イベントループ」という仕組みがあり、これはプログラムのさまざまなタスク(処理)をどのように実行していくかを管理している。イベントループには、大きく分けて「マクロタスク」と「マイクロタスク」という種類のタスクがキュー(待ち行列)に並べられる。
ここで重要なのが「マイクロタスク」である。マイクロタスクは、現在実行中の処理がすべて完了した後、しかしイベントループが次の大きな処理(マクロタスク)に移る前、さらに言えば、ブラウザが画面を更新する直前に実行される特別なタスクである。Promiseのコールバック関数などがマイクロタスクとして扱われる。この性質を利用すれば、複数のデータ変更が発生しても、それらを一旦まとめてから、一度だけ画面更新処理を呼び出すことが可能になる。
具体的な解決策は、変更を即座に画面に反映するのではなく、すべての変更を一時的に「キュー(待ち行列)」に入れておき、画面更新が必要になる直前に、キューに溜まった変更をまとめて処理する、という「バッチ処理」の仕組みを導入することである。
このバッチ処理エンジンは次のように動作する。
まず、pendingChangesというオブジェクトを用意する。これは、発生した変更を一時的に保存しておくための「入れ物」である。また、isScheduledという真偽値のフラグを用意する。これは、画面更新のためのマイクロタスクがすでに予約されているかどうかを示す「目印」となる。
データが変更され、Proxyがそれを検知したときに、直接update関数を呼び出す代わりに、scheduleUpdateという新しい関数を呼び出すように変更する。scheduleUpdate関数の中では以下の処理が行われる。
-
変更されたデータ(
change)を、Object.assignというメソッドを使ってpendingChangesオブジェクトにマージ(結合)する。Object.assignは、同じプロパティが複数回現れた場合、常に最新の値で上書きする。この特性のおかげで、例えば$scope.name = "Mario";の後に$scope.name = "Mario 24";と代入されても、pendingChangesには最終的に「Mario 24」という最新の値だけが残る。これにより、前述の「ちらつき」の問題が解決される。 -
次に、
isScheduledフラグを確認する。もしisScheduledがfalse(つまり、まだ画面更新のためのマイクロタスクが予約されていない)であれば、isScheduledをtrueに設定し、queueMicrotaskという関数を使ってマイクロタスクをイベントループに予約する。 -
この予約されたマイクロタスクの中では、
pendingChangesに集められたすべての変更を引数として、実際の画面更新関数(updateFn)を一度だけ呼び出す。この際、pendingChangesにはすべての変更がまとまっており、同じプロパティへの変更は最新の値だけが残っているため、効率的かつ正確な画面更新が可能となる。 -
画面更新が完了したら、
pendingChangesオブジェクトを空に戻し、isScheduledフラグをfalseにリセットする。これにより、次の変更サイクルに備えることができる。
この仕組みにより、開発者は複数のプロパティを次々と変更しても、また同じプロパティを複数回変更しても、ブラウザの画面更新はマイクロタスクのタイミングで一度だけ行われるようになる。これにより、無駄な画面更新が削減され、ちらつきも解消され、アプリケーション全体のパフォーマンスとユーザー体験が大きく向上するのである。
ここまでで、同じコントローラー(データ管理の単位)の実行の中で発生する複数のデータ変更を効率的に処理し、画面更新を最適化する仕組みを構築できた。しかし、この解決策はまだ完璧ではない。例えば、$scope.person.name = 'Mario';のように、オブジェクトがさらにネスト(入れ子)になったプロパティの変更や、配列の要素の追加や削除といった変更には、この現在の仕組みでは対応できない。これらの「深い変更」をどのように追跡し、通知していくかという課題が、今後のさらなる最適化のテーマとなるだろう。