はじめに
業務委託でお手伝いさせていただいてる田中です。普段は(?)屋号studioTeaTwoとしてフロントエンドを中心にフリーランスエンジニアしていたり自社プロダクトを作ったりしています。
この記事は、ビットバンク株式会社 Advent Calendar 2020の21日目の記事です。Angularのレンダリングメカニズムを解説します。明日現場ですぐに使えないディープなテーマですが、詳細には立ち入らず、他のフロントエンドライブラリの経験はあるけどAngularは知らない人がなんとなくAngularの仕組みをわかった気になれることをゴールにしていますので、リラックスしてご覧いただければと思います。
対象にすること
- SPA(シングルページアプリケーション)のフレームワークとしてどのようにDOMの操作をしているのか
- ブラウザで軽量に安定して動くためにどんなビルドをしているのか
- アプリケーションの状態が変化したときにどのように変更を検知して画面が再描画されるのか
- Not Included: ソースコードレベルの詳細解説
- Not Included: ブラウザからディスプレイに描画するまでのレンダリングパイプライン
文書構成
- Angular登場前後のDOMマネジメントの歴史を振り返る
- AngularのDOMマネジメントのコンセプト
- Angularも仮想ビュー層を持っている
- 仮想DOMではなくIncrementalDOM方式
- 描画や更新のメカニズム
- Angularのレンダリングメカニズムの特徴
- ビルドで事前準備していること
- 描画する
- 更新モデル: UIや通信による変更を画面に反映する
1. Angular登場前後のDOMマネジメントの歴史を振り返る
はじめに昔話をします。Angularのレンダリングメカニズムについてだけ知りたい方は、次の章にお進みください。
最近は知らない人も増えてきたと思いますが、2010年代の前半ごろをピークに、リッチなフロントエンドアプリケーション(インタラクティブなゲームや、膨大な量になる業務アプリなど)を作る際に、DOMをほぼ直接操作することが当たり前だった時期がありました。HTML5なら大丈夫だ問題ないという空気感でしたが、HTML要素のたくさんの属性やイベントリスナーの設定・解放を手続き的に操作することは、HTML5になっても何も変わりありません。もちろんライブラリはありましたが、JQueryはDOM操作のショートハンディングを提供するもので、抽象化されたレイヤーによりDOMの手続き的操作のパラダイムを変えるようなものではありません。
2000年代にリッチなフロントエンドアプリケーションを作る場合は、JavaアプレットやActiveXやFlashなどのGUI基盤(プラグインと呼ばれていました1)がブラウザの上に覆い被さっていました。2010年代半ば以降は、reactに代表される宣言的なGUIライブラリが、直接的なDOM操作を隠蔽しています。つまり、リッチなフロントエンドアプリケーションを作るためにDOMを直接触ることが主流だった時期は、2010年代前半ごろだけだったと言えるのではないでしょうか。
私は2000年代から主に業務アプリを開発していましたが、2010年代前半にBackbone.jsやCreateJSのプロジェクトに参加して、プログラマーのやっていることそのものは複雑で高度化していますが、成果物の生産性が比例していないことに驚きました。これはプラットフォームのセキュリティや性能を題目にさまざまな議論を積み重ねた上でプラグインを仕切り直しして(javaアプレットやflashの退場、ブラウザの機能再考とHTML5など)、パブリックインターネット環境で提供する機能を少しずつ向上させていったことが主な要因になるので、現場のせいにできるものではありませんが、時代を俯瞰して退化していることに不思議な気持ちになったものです。
さて、そんな中でHTML5ベースながら、DOM操作のトップにデータバインティングを被せて、DOMマネジメントを簡易化するEmberやAngular(v1)が出てきました。さらにその後、仮想DOMを提供するreactが完全に次の時代をもたらしました。Angular(v1)らはHTML拡張というアプローチでしたが、reactはall jsのアプローチで物理的なDOM層を完全に隠蔽した、仮想ビュー層という新しいレイヤーを追加しました。仮想ビュー層は、js空間内でDOMとは別に画面状態の論理情報を一通り保有している層です(DOMはあくまでjs空間に対するAPIであり状態が格納されているのはレンダリングエンジン側です)。これによって、レンダリングパイプラインの性能・資源管理をフレームワークが担えるようになり、HTML要素のたくさんの属性やイベントリスナーの設定・解放を手続き的に操作することを要求するDOMを、開発者が触らなくて良いようになっています。
2. AngularのDOMマネジメントに対する方針
Angularも仮想ビュー層を持っている
Angularはv1とv2以降で大きく仕組みが変わっています。v1は前項でも触れたようにHTML拡張方式で、アドオンによりjs側で追加・拡張していく方式(ディレクティブと呼んでいます)です。htmlファイルはまさしくhtmlであり、ブラウザがその各々のhtmlファイルを読み込みjsが後から動きます。
一方、Angularのv2以降(本記事ではv2以降を取り扱います)では、実はreactと同じくjs空間で仮想ビュー層を用意していて、そこで一通りの画面状態の論理情報を管理しています。一見すると、reactらと異なり、htmlファイルが存在しています。しかし、このhtmlファイルはわかりやすいように以前の姿形を模倣しているだけです。各々のhtmlファイルは、ビルド後はjsコードになっています。htmlファイルとしてブラウザに読み込まれているわけではなく、実行時にjsファイルから画面生成指示が行われていて、その構成はreactやvueらと変わりません。
仮想DOMではなくIncrementalDOM方式
では、Angularは仮想DOMということになるのでしょうか?その答えは、半分当たりで半分外れです。なぜなら、v2以降は、仮想DOMを改良したIncrementalDOMというDOMマネジメント方式がベースになっているからです。このIncrementalDOM方式と、Angularの特徴であるDIシステム(依存性注入)を組み合わせて、ビュー層が形成されています。IncrementalDOM方式は、仮想DOMに対して2点の強みを持っています。2
- メモリリソースの節約とガベージ・コレクションの負荷軽減
- html記法をそのまま使える
一つ目を実現するために、仮想DOMを作るのは最初だけにして、更新は既存のツリーを書き換えるようにしています。そのために、リアルDOMと仮想DOMを別にせずに、リアルノードにメタ情報を追加したバーチャルノードを作成してツリーを作っています。更新時は、このツリーをトラバースして、変更するノードを見つけます。その仮想ノードは、リアルノードの参照を持っているため、リアルDOMを更新することができるわけです。
AngularはIncrementalDOM方式からすごくパワーアップしているというか、純粋なIncrementalDOMの実装とはだいぶ変わるのですが、今でもIncrementalDOMがベースの考え方になっていて(というより最新のレンダリングエンジン設計で参照されているのかもしれません)、Angularチームメンバーによるブログやセッションでも度々引用されています。3
3. 描画や更新のメカニズム
Angularのレンダリングメカニズムの特徴
初めにAngularのレンダリングメカニズムの特徴を3つ挙げます。
- Incremental DOM方式
- プッシュ型の更新モデル
- コンパイル必須
一つ目は、前章で紹介しました。二つ目は、描画後の更新モデルです。Angularは何か変更があったら即時更新するプッシュ型のモデルになっています。三つ目は、typescriptで書くことが必須になっていることに加えて、フレームワークのための解析加工処理も行われます。
これらを念頭に置いて、以降はビルド -> 描画 -> 画面更新
までの流れを解説していきます。
ビルドで事前準備していること
Angularは、JIT(Just-in-time)も細い道がありますが、基本的にはAoT(Ahead-of-time)です。事前に静的解析してビルドファイルを作っておきます。ビルドでは、ただTypeScriptをJavaScriptにトランスパイルしたり最適化しているだけでなく、フレームワークとして実行に必要な加工処理が加わります。先述したようにhtmlファイルをJavaScriptコードに変換しているし、@Componentや@Injectableのようなデコレータで記述されているメタデータや、NgModuleとして記述されているコンパイルコンテクストなどの解決を行っています。
- htmlをjsの生成関数に変換する
- cssやアニメーションもjsコードに整理される
- コンパイルコンテクストに基づきDIやModuleの解決や結合が行われる
特にレンダリングのキーとなる1をもう少し詳しく説明しましょう。要は、htmlのjs化です。
以下のような典型的なAngularのhtmlテンプレートを例にします。通常のhtmlの<div>
や<span>
に加えて、テキスト置換の目印になる{{}}
記法、ユーザー作成したコンポーネントであるchild-cmp
、ディレクティブの*ngIf
のような記述が見られます。
<div>
<span>{{title}}</span>
<child-cmp *ngIf="show"></child-cmp>
</div>
コンパイルにより変換すると、以下のように一つ一つのHTMLノードを生成する関数で再構成されています。これらの関数は、最終的にはDOMのcreateElement/appendChildを実行することになるのですが、それだけでなく仮想ノードを作って仮想ビューのツリーに追加していくことも行っています。
function RootCmp_Template(renderFlag, context) {
elementStart(0, "div");
elementStart(1, "span");
text(2);
elementEnd();
template(3, RootCmp_child_cmp_Template_3,
1, 0, null, [1, "ngIf"]);
elementEnd();
}
この生成関数は、先述のIncrementalDOMの箇所で説明したように、初回生成とそれ以降の更新用でそれぞれ用意されます。このようにして、開発時はhtml記法に則り宣言的に記述
でき、一方で、実行時は最小限のDOM操作で性能を実現
するという、人間と機械の双方の利便性を両立させています。
描画する
ブラウザがビルドファイルを読み込むと、生成関数が動きソースコードで記述していたテンプレートが画面に描画されます。この過程では、先述の生成関数が動くだけでなく、DIが核になりさまざまなサービスの注入やその機能が実施されます。
描画自体は以上でできますので、ここからは蛇足になります。徐々にAngularもコンポーネントやテンプレートをメタプロ的に生成できるようにAPIが整備されています。例えば、デコレーターは昔はメタデータとしてjsonオブジェクトに記述されていたりもしましたが、今では普通にjsコードに変換されるようになっています。それでもソースコードで見ているものとは姿形が変わりますが、雰囲気を掴める程度にサンプルを記載します。この辺は知らなくても開発できるような話ですが、Angularのソースコードを読むときや、ブラウザでデバッグしていてAngularのインスタンスを確認したい時などに役立ってくると思います。
先程のhtmlテンプレートをデコレーターに追加して、 Angularのフルコンポーネントにします。
@Component({
selector: 'root-cmp',
template: `
<div>
<span>{{title}}</span>
<child-cmp *ngIf="show"></child-cmp>
</div>`,
})
export class RootCmp { … }
ビルド後は、デコレーターが以下のようなコードに変換されています。テンプレートの生成関数RootCmp_Template
の中身は前項に記載したものです。
export class RootCmp { … }
RootCmp.ngComponentDef = defineComponent({
type: RootCmp,
selectors: [["root-cmp"]],
consts: 4,
vars: 2,
factory: function RootCmp_Factory(t) {
return new (t || RootCmp)();
},
directives: [ChildCmp, NgIf],
template: function RootCmp_Template(rf, ctx) { … },
});
プログラマティカリーに動的に生成できると言ってもそんなに簡単ではないですが、雰囲気は掴めるかと思います。
更新モデル: UIや通信による変更を画面に反映する
数多あるjsフレームワークを比較するときに、テンプレートの記述方式と並んで、更新方式は関心が寄せられるところです。Angularの説明でもボリュームが大きくなるところですが、1.更新する方法、2.更新するトリガー、3.更新する箇所の特定
を順番に解説していきます。
更新する方法
Angularは、IncrementalDOM方式で説明したように、更新が発生した時は変更箇所だけを書き換える方式です。これは実際のコードでは以下のように表されます。
ビルドの章で記載したテンプレート生成関数ですが、実は初回生成と更新用に分岐するものでした。前回は端折りましたが、今回はフルで記載します。以下のif文内で、初めのrenderFlag & 1
の条件マッチで実行されるブロックが初回生成用のコードブロックです。次のrenderFlag & 2
の条件マッチで実行されるブロックが更新用のコードブロックです。
function RootCmp_Template(renderFlag, context) {
if (renderFlag & 1) {
elementStart(0, "div");
elementStart(1, "span");
text(2);
elementEnd();
template(3, RootCmp_child_cmp_Template_3,
1, 0, null, [1, "ngIf"]);
elementEnd();
} if (renderFlag & 2) {
textBinding(2, interpolation1("",
context.title, ""));
elementProperty(3, "ngIf",
bind(context.show));
}
}
主だった要素作成やイベントリスナーの付与などの重い処理は初回生成時に一度だけ行うようにして、以後は更新用のコードブロックだけが繰り返し実行されます。ゲームエンジンのUnityをご存知の方は、updateメソッドに相当すると言われるとピンとくるでしょうか。
更新するトリガー
さて、この更新用のコードブロックはどんなトリガーで実行されることになるのでしょうか?その答えは、変更が発生した時です。FlashやUnityなどのようにフレームレートに従い一定間隔毎にアップデートが実行されるわけではありません。
では、その変更が発生したことはどのようにして知るのでしょうか?ウェブのフロントエンドでアプリケーションの状態が変化するタイミングは、大きく3つに集約されます。
- UIイベント - click, submit, ...
- 通信 - サーバーからデータを取得
- タイマー - setTimeout(), setInterval()
これら3つはいずれも非同期スタックで実施されます。Angularは、これらの非同期関数が解決されたときにフレームワーク側に通知を上げることで、即座に終了を検知して画面の更新作業に入っています。
この非同期関数の解決を知るためにzone.js
というライブラリを利用しています。これはDartの同名の機能からインスパイアされて開発されたものですが、他にも類似なものとして、Node.jsのdomains/continuation local storage/asyncwrap、C#のlogical call context、Go言語のgoroutinesなどが挙げられます。4
更新する箇所の特定
非同期関数の終了を検知しても、画面の更新が必要になったかどうかはまだ確定していません。けっきょく状態変数が何も変わらなかったことだってあり得るからです。
そこで、zoneの通知をトリガーにして、画面に描画されているコンポーネントのツリーのどこに変化が起こったどうかを調べるアルゴリズムを実施します。この変更検知のアルゴリズムおよび実行モジュールをチェンジ・ディテクション(ChangeDetection)と呼んでいます。
これにより変更が起こったと判定されたコンポーネントを洗い出し、そのコンポーネントのテンプレートの更新用関数だけを実行するわけです。
おわりに
Angularのレンダリングメカニズムに関して、ざっとjsフレームワークの歴史の中での位置付けから、ビルド -> 描画 -> 画面更新
までを解説しました。ソースコードはバージョンによりどんどん変わっていきますが、解説はなるべくバージョンに依らずに通用できるようにしました。他のフロントエンドライブラリの経験ならあるけどAngularは知らない人がなんとなく仕組みをわかった気になれるように、噛み砕いて解説したつもりですがいかがでしたでしょうか。
ちなみにこの記事の応用編として、熟練の方向けになるべく一次情報で設計思想がわかるドキュメント群を集めた記事も書いています。本記事で取り上げた各キーワード(IncrementalDOM、ChangeDetection、Zoneなど)の詳細ドキュメントもあるので、もっと知りたくなった方はそちらもご覧ください。