僕が触り始めた頃のウェブフロントエンド開発はデバッガーもなく、ダイナミックHTMLと呼ばれて文字をチカチカさせたりするようなものでした。IE6という超安定ブラウザが出てきたり(Netscape 4.xも7.xも不安定だった)その後jQueryが登場したときは、天使が降臨したように思えたものです。
そこから長い年月が経ち、ウェブフロントエンドの比重が大きくなるにつれ、フロントエンドのコードはどんどん複雑化しました。OpenAPIなどのコードジェネレータなども普及した結果、通信というものが隠され、イベントの中でawait
や.then()
で呼ばれる何か、みたいな理解をしているメンバーも今後増えていくのではないかという懸念があります。
現在ではウェブフロントエンド開発はReactやVueといったフレームワーク上で行われ、イベントというのはそのフレームワークの提供するライフサイクルイベントに対応付けられて処理されます。この手の原理原則の理解というと「フレームワークを全部ひっぺがせ!そしたらシンプルだ!」みたいな言説もよく見かけますし、ブラウザを実装しよう的な記事も増えました。しかし、藤井九段を理解するのに、コマの動かし方の本を読んでも遠すぎるし、恋愛小説について解説するのに「ドーパミンとアドレナリンが」という話をすると1000年の恋も動物的物語になって幻滅してしまうのと同様(そちらの方が趣向に刺さる人もいるかもしれませんが)、いくら勉強しても普段の実装力がつくかというと微妙な気がしています。
現代のウェブフレームワークの構成
ブラウザが持っている基本機能は「HTMLを表示する」しかないため、雑な表現をすれば、動的なウェブアプリケーションは最終的に「HTMLを組み立てる」のが仕事です。HTMLはWebサイトのテキスト表現なので、正確にはHTMLを読み込んで作られるDOMという内部の木構造のドキュメントのオブジェクトツリーを動的に書き換えます。
現代のウェブフレームワークであるReact, Vue, Angular, SolidJS, Svelteなど、ほとんどのフレームワークはどれも同じ構成をしています。コンポーネントと呼ばれる部品を作り、それを階層構造に並べていきます。そのコンポーネントにはHTMLタグへの変換ルールが記述されており、処理の中でHTMLが作られて表示されます。コンポーネントの親子関係とHTMLの親子関係は基本的に一致しています。
ライフサイクルメソッド
フレームワークは初期化時に指定された特定のDOM要素以下を自分の管轄下の自由にしていい階層としてDOMを操作していきます。そのDOMのところにルートのコンポーネントを配置します。配置された後にいくつかのライフサイクルのタイミングでコールバックが呼ばれます。
通常は初期化時は以下のようなイベントが順番に自動で呼ばれます。だいたいどのフレームワークでも共通です。
- createイベント: 初期化時に呼ばれる
- renderイベント: ここでDOMの設計図を作る
- mountイベント: DOMが反映されてブラウザ上に表示される
DOMを作るときに、ボタンなどのフォーム要素にもイベントが設定されます。そのボタンをクリックすると、何かしら情報更新が行われたりページ遷移などが発生したりします。
- unmountイベント: コンポーネントがこれから削除される(DOMはまだある)
- deleteイベント: コンポーネントがDOM上から削除される
Vue.jsは公式にこのライフサイクルをドキュメントに乗せています。
https://vuejs.org/assets/lifecycle.MuZLBFAS.png
Reactの以前のクラスコンポーネントはVueそっくりな感じでした。現在の関数型コンポーネントのReactの公式では図はないのですが、だいたいこんな感じです。useInsertionEffect()
はCSS in JSライブラリがスタイルを挿入する目的、useLayoutEffect()
はサイズの変更など、描画後に実行すると画面のちらつきに影響するような特殊ケースで使うので、基本的にはuseEffect()
だけをみておけば問題ありません。useInsertionEffect
はドキュメントではDOM操作の前後どちらかで実行とありますが、ここでは前の方に書いています。
Reactにおいては、useEffect()
はコンポーネント初期化時だけではなく、特定の状態に関連して引き起こされる汎用な「(副)作用」を表します。コンポーネントの状態変化も、属性の状態変化も両方等しく扱う、ジェネリックな作用となっています。1つのコンポーネント内部で複数の作用を定義できますし、ドキュメントを見ると「コンポーネントのライフサイクル」と表現するのは不適切で、効果自身がそれぞれライフサイクルを持っていて、コンポーネントはそれらが属している物、ぐらいの扱いになっています。
fetch()
fetch()
がデータ取得の基本要素です。時代を作ったのはXMLHttpRequest(XHR)ですが、今後はfetch()
だけをみておけば良いでしょう。
ウェブアプリケーション開発でHTML生成がサーバー側の役割で、作成するコードのほとんどのがJavaだったりした時代だと、POSTメソッドで情報取得をするといったものも過去ありましたが、現在では少数派でしょう。
何か通信を行うというときは基本的にはこのfetch()
が最後に呼ばれます。このエントリーでは触れませんが、サーバーからストリーミングで結果を随時受け取るような場合にはfetch()
の最近の更新で追加されたStream対応でもできますが、WebSocketやServerSentEventも使われます。
初期化ライフサイクル
ウェブ画面の表示時に最新情報を取得してそれを詰めこんだ画面を表示します。いくつかの作戦があります。
初期化時のライフサイクルメソッドの中から情報取得
一番シンプルなのが初期化のライフサイクルメソッドからのサーバーアクセスです。ReactではuseEffect(処理, [])
でコンポーネントが画面に表示された直後に呼ばれるロジックが記述できるので、ここでサーバーのデータアクセスを行います。Vue.jsのComposition APIだとonMounted(処理)
ですね。
「コンポーネントの初期化のイベントハンドラで必要な情報を取得する」というのはコンポーネント単位で見れば独立性が高く、一番ナイーブでソフトウェアの設計としては正しい姿ではありますが、ユーザーがレンダリング結果を見るまでの行程が長く、時間がかかります。特に通信待ちが2往復あります。レスポンスの結果に画像データのリンクが含まれていて、それが画面に<img>
タグとして置かれてからブラウザがその情報を取得しにいくとしたらさらに1往復追加されます。
- フロントエンドのJSコードの取得(多いと数MB)
- JSをロードしてコンポーネントの描画(First Paint)
- サーバーへのデータリクエストとレスポンス待ち
- 再描画
Reactを開発していると「useEffect()
の使用は最小限に」と言われます。最上位の親コンポーネントを除けば、公式ドキュメントのこの場合は使うなユースケース集にコンポーネント単位でのこの方法は載っていません。Reactは初期化の後処理などが怪しいアプリケーションを炙り出すためにStrictModeかつ開発モードではマウント時のuseEffect()
が2回呼ばれるようになっていたりする点は要注意です。
Stale-While-Revalidate
ブラウザ本体のキャッシュ戦略のStale-While-Revalidateのアイディアをウェブフレームワークに取り入れたデータ取得のライブラリに、Vercelが開発したSWRがあります。Vueにもそれをインスパイアして作られたswrvがあります。
Stale-While-Revalidateはキャッシュがあればまずはそれを返して表示してしまって、裏でこっそりとサーバーにあとから情報を取得しにいき、変更があれば更新をするといった動きをします。キャッシュはLocalStorageなどにも入れられますし、メモリにキャッシュする場合でも、他の箇所で同じURLにアクセスしていた場合はそれを即座に返します。
最初に紹介したライフサイクルメソッドと比べると、ロード中かどうかの管理やとってきた情報をstate化するところもSWRが面倒をみてくれるため、コードはシンプルになります。なお、キャッシュがない場合の最悪ケースのパフォーマンスは、ライフサイクルメソッドからのfetch()
と同じです。
サーバー側で取得して一緒に送信
Next.jsやNuxt.jsで一般的になったサーバーサイドレンダリング(SSR)はさらに攻めた最適化を行います。前述の2つはウェブサイトが初回レンダリングされてから初めてサーバー通信を開始します。スタートが遅くなれば最終的な結果が得られるのも遅くなります。サーバー上で必要なコンテンツを全て集めてそれを初回のレスポンスに一緒に返してしまえばよい、レンダリングも終わらせて完成系のHTMLを返せばSEO上も良いと考えられたのがSSRです。
サーバー上でAPI呼び出しを行いその結果を使ってページに必要な情報をまずはまとめて取得します。Next.jsであればgetServerSideProps
を、Nuxt.jsはuseFetch()
やuseAsyncData()
をつかってサーバー上でデータアクセスを行います。
図では便宜上サーバーを1つしか書いてないですが、他のAPIサーバーがある場合なども同様です。ブラウザ↔︎サーバー間よりも、サーバーから他のAPIサーバーや自分自身の方が距離の方が一般的に近いため、往復の時間ロスが少ないため、ブラウザから情報をわざわざ取りに行くよりも高速です。現代のフロントエンドアプリはサイズも大きいため、JSがロードされて結果が表示されるのも時間がかかります。SSRでは初回はレンダリング済みのHTMLを返すため、初回表示が最速です。あと、一度結果を表示させたあとにReactコンポーネントをロードしてレンダリングして返していますが、これはハイドレーションという処理になっており、フロントでReactで作り直すことで、イベントハンドラ類が全部きちんと設定された完全なアプリケーションになります。
初回表示はサーバー側でレンダリングしたHTMLを返しますが、そこからページ遷移して新しいページを表示するときは、レンダリングに必要な、getServerSideProps
やuseFetch()
やuseAsyncData()
をサーバーで実行し、結果のJSONだけをブラウザに送って描画します。
問題点としては、サーバー側の技術スタックがNode.jsなどのJavaScript系の処理系にする必要があったりします。Next.jsではページトップのコンポーネントにしかgetServerSideProps
が書けないため、親子関係の依存が強くなりがちといった問題もあります。
ブログやニュースなど閲覧者ごとに違いがないページでしか使えませんが、Next.jsではさらに情報の取得を事前に静的に行ってHTMLを生成しておく、静的サイトジェネレーション(SSG)もあります。
サーバーコンポーネント
Reactがパフォーマンス改善のキーとして現在取り組んでいるのがサーバーコンポーネントです。Vue.js本体が取り入れるかはわかりませんが、Nuxt.jsも実験的に取り組んでいます。
Reactはコンポーネントは再描画のたびに実行される前提であったため、通信コードはuseEffect()
などのライフサイクルメソッド側に書く必要がありました。しかし、Reactサーバーコンポーネントが登場したことで、「必ず一度だけサーバー側でレンダリングされるコンポーネント」が登場しました。通常はコンポーネント定義では仮想DOMの構築以外はせず、サーバーアクセスはuseEffect()
でやるのが通例でしたが、サーバーコンポーネントでは直接サーバー通信コードが書けます。
export function AsyncComponent() { |
このコンポーネントを含む親ページに遷移すると、まずはこの通信中で結果が決まらない箇所以外をサーバーでレンダリングし、JSON形式でフロントエンドに返します。半完成状態のJSONを元とにすばやくDOMを更新してユーザーに結果を返します。その裏でサーバーはAPIアクセスを行い、結果が帰ってきたらこの通信した箇所のみの仮想DOMの差分を作ってフロントに返します。
ずいぶんと複雑な機構に見えますが、最初のサーバーへのアクセスのタイミングでサーバーから外部サーバーへの通信を開始するため、外部APIが遅い場合にはリードタイムが節約できるのがこの方式のメリットです。また、サーバーで処理するコードはフロントエンド側にロジックなどを送信しないため、JavaScriptコードの転送時間が節約できます。レンダリングが大変で巨大なライブラリを必要とするようなものがあれば効果はさらに顕著に現れるでしょう。
データの追加受信・更新
次にデータの追加受信や更新のサーバーアクセスのパターンを見ていきます。データの追加受信というのは、補完機能付きのプルダウンメニューでユーザーの入力を受けて絞り込まれたリスト項目をサーバーからとってくる、表のページングで次のページの情報を取得してくる、といったことに該当するものとここでは定義します。画面が表示された時に自動発動するGETリクエスト以外です。
更新というのはフォームの送信などの編集作業のことです。どちらも似ているのでここでは両方扱います。後者だけのものもあります。
DOMのコールバック
通常、データの追加取得や更新処理はユーザーの明確な操作時のみ行われます。明確な操作というのは次のような操作です。そしてこれらは、DOMのイベントに設定したイベントのコールバックが起点となります。
- ボタンを押す
- キーボード操作
- ファイルをドロップ
- マウスのスクロール
コールバックの中からサーバーリクエストを行います。その結果を待って情報を更新したり、ページ遷移したりして結果の画面をユーザーに見せます。基本的にやっていることはVisual BasicやDelphiとかQtとかのデスクトップアプリと変わりませんし、生HTMLやjQueryとかの古来のウェブフロントエンドとも違いはありません。
状態管理ライブラリ
近年のウェブフロントエンド開発では、大規模化するにつれて、コードの関心で分離するために画面表示と、状態管理をレイヤー分けすることがよく行われます。コンポーネントを跨いだ情報共有を行いやすくするなりますし、通信部分を隠蔽し、フロントとサーバーの状態の同期を取る形式で通信を任せることも行われます。ReactであればRedux ToolkitやJotaiなどが使われます。VueだとPinia(Vuexの後継)が使われます。
ウェブのコンポーネントからは状態管理の状態を変更する、もしくは変更アクションを実行するなどするとそこでサーバーアクセスが発生し、状態管理の持っている情報と同期を取ったり、追加の情報を取ってきます。
サーバーアクション
Reactの19から入る機能で、Next.jsがすでにサポートしているのがサーバーアクションです。状態管理ライブラリの方は、ビューにまつわるコンポーネントコードから通信部分を別レイヤーとして切り出すことでエントロピーを下げる方向性でしたが、これは逆方向に振り切った機能です。
サーバーアクションは"use server"
という文字列がボディの先頭にディレクティブとして書かれた関数です。コンポーネントの<form>
のaction
属性、もしくは<button>
のformAction
属性に設定されるイベントハンドラで、見たい目的には単なるコールバック関数です。ですが、このコードの実体はサーバーにあり、サーバーの中だけでしかできない外部APIアクセスやDBアクセスもここの中に直接書けます。
フォームやボタンの操作が行われるとこのコールバックが直接呼ばれているように見えます。しかし、実態はサーバーにあるので自動で作られたサーバー側のAPIを呼び、その中でサーバーアクションが処理されます。あたかもフロントエンドから直接DBアクセスを行っているかのように見える点がポイントで、サーバーアクセスが完全に隠蔽化されています。
まとめ
ウェブフロントエンドの仕組みは複雑化の一途を辿っています。すべて還元して中身を理解しようとすると大事です。ただ、イジワルをしたくて複雑化しようとしているわけではありません。目的があります。このエントリーを見れば、待ち時間をいかに減らすかのために苦心しているのがわかるでしょう。また、構造が複雑であっても、実際にコーディングしてみると開発体験は悪くないことがわかります。
ただ、最適な設計を考えたり、デバッグ時の挙動を追いかけるには多少は裏で何をやっているかを知っておくことは価値があります。本エントリーでは今時のフレームワーク中で利用されるパターンを紹介しました。