SSRのハイドレーションをVueで学んだ
はじめに
[Vue warn]: Hydration ~~~ mismatch
の警告にたまに遭遇することがあり、そういえばハイドレーションをちゃんと理解してなかったので、自分のために記事を書いて理解していきます。
Vueのハイドレーションを知る
SSRで行われるハイドレーション(水和)は、物質が水と反応する化学反応の水和反応になぞらえて、乾いた静的なHTMLに水分となるJavaScriptを与えて、完全に機能するWebアプリケーションを作ることです。
VueでSSRする際は、createApp
の代わりにcreateSSRApp
を使ってハイドレーションを行います。VueでSSRしたいとき、多くの場合はNuxtを使うのが便利だと思います。(というよりほぼ一択?)
今回はシンプルな構成にするために、Viteでサンプルを作りました。
ちなみに、npm create vite-extra@latest
でssr-vueを選択しても簡単に作れます。
サンプルでは、ハイドレーションの前後をわかりやすくするために、あえてHydration mismatchを引き起こしています。これにより、SSRされた要素と、CSRされる要素に差分ができます。
詳細は省略しますが、src/entry-server.ts
とserver.js
では、Node.jsサーバー上でVueアプリケーションを一度完全にレンダリングして、生成したHTMLを返す処理をしています。
実際にハイドレーションを見る
npm run dev
を実行してブラウザでhttp://localhost:5173
を開き、開発者ツールのネットワークタブを見ると、レスポンスされるHTMLを確認できます。ここではcount is 0
のHTMLが返されています。
しかし、ブラウザで実際に表示される画面ではcount is 1
となっています。これがハイドレーションの結果です。
ハイドレーションは、ざっくりと以下のようなフローで処理されています。
SSRされたHTMLがcount is 0
、クライアントで初期化されるコンポーネントがcount is 1
となっており、このようにハイドレーションの結果に差分があれば、クライアント側のcount is 1
が優先されてHTMLが更新されます。
ハイドレーションの処理を詳しく知る
では、ハイドレーションの処理を詳しく追っていきます。
createSSRApp().mount("#app")
を実行します。createApp
ではなくcreateSSRApp
を使うことで、mount()
で必ずハイドレーションが実行されます。また、mount
の中で中身がほぼ空っぽなVNode(initialVNode
)も生成されます。
ここからは、ハイドレーションの主要な関数を順番に説明していきます。
hydrate
createSSRApp().mount()
から呼び出され、initialVNode
とdiv#app
のNodeが渡されます。ここからハイドレーションが始まります。
hydrateNode
(1回目の呼び出し)
div#app
のNodeとinitialVNode
が渡されて、Nodeに対してハイドレーションを行っていきます。最初は必ずmountComponent
でルートコンポーネント(今回はApp.vue)を初期化します。それ以外の場合は後ほど説明します。
CSRとSSRではinitialVNode
が持つプロパティに差分があります。CSRはまだレンダリングされていないためinitialVNode.el
はnull
、SSRはSSRされたHTMLが持つ要素(今回はApp.vueのrootのdiv)が入ります。
mountComponent
ここで初めてルートコンポーネントのインスタンスが作られます。このインスタンスの.vnode
プロパティにinitialVNode
も紐づけられます。mountComponent
自体はCSRでもSSRでもそれほど変わりません。
mountComponent
からは、コンポーネントの初期化のために様々な処理が呼ばれます。説明しきれないので今回は省略します。
hydrateSubTree
コンポーネントインスタンスが持つサブツリー(VNode)を生成します。実際には1321行目のrenderComponentRoot
がサブツリーを生成します。
余談ですが、インスタンスが持つ.subTree
と.vnode
プロパティはどちらもVNodeですが、.subTree
はコンポーネント内部で持っているNodeを指すVNodeで、.vnode
はコンポーネント自身のVNodeを指しています。
hydrateSubTree
はSSRされているときだけmountComponent
のあとに呼ばれるcomponentUpdateFn
から呼ばれます。
CSRならhydrateSubTree
は呼ばれず、代わりにpatch
が呼ばれてコンポーネントをCSRします。
次以降の処理でもコンポーネントがあれば、またhydrateSubTree
が呼ばれます。
hydrateNode
(2回目以降の呼び出し)
再びhydrateNode
が呼ばれ、App.vueのrootのdivのNodeとhydrateSubTree
で生成されたinstance.subTree
(VNode)が渡されます。以降はNodeのツリーとVNodeツリーを親から子に再帰的に辿って、SSRされたNodeとクライアントのVNodeをハイドレーションします。エレメントだけでなく、コンポーネント、Teleport、Suspense、フラグメント、テキスト、コメントなど、タイプによって処理が異なります。
hydrateElement
ElementがChildrenを持てばhydrateChildren
が呼ばれます。ChildrenのNodeに対し、hydrateNode
が呼び出され、Nodeツリーを走査していきます。hydrateElement
がミスマッチのチェックとミスマッチがあったときの要素の更新を行い、propsとイベントハンドラをバインドします。
例えば、サンプルのApp.vueのhydrateSubTree
以降の処理フローは次のようになります。
ハイドレーションが終わると、その後はCSRと同じ動作になります。リポジトリのコードに少し説明を加えただけなので、実際に手元で動かしたり、自分で処理を追いかけるとより理解が深まると思います。
おわりに
今回は備忘録の側面が強い記事になりました。ハイドレーションの完全な説明をするのは複雑なので、一旦は概要の理解にとどめています。
ハイドレーションの概念は、Vueに限らずReactや他のフレームワークでも考え方自体はそこまで大きく変わらないと思うので、概念の理解が重要だと自分は考えています。
最初は自力でデバッグしながら調べていましたが、途中からめんどくさくなってDeepWiki使って色々質問しながら書きました。もはや記事よりDeepWikiでいいや〜とも思いましたが、頑張って最後まで書きました。DeepWikiや各種エージェントのお陰で調べものと執筆作業はグンと楽になった、いい時代ですね。
Discussion