💧

SSRのハイドレーションをVueで学んだ

に公開

はじめに

[Vue warn]: Hydration ~~~ mismatchの警告にたまに遭遇することがあり、そういえばハイドレーションをちゃんと理解してなかったので、自分のために記事を書いて理解していきます。

Vueのハイドレーションを知る

SSRで行われるハイドレーション(水和)は、物質が水と反応する化学反応の水和反応になぞらえて、乾いた静的なHTMLに水分となるJavaScriptを与えて、完全に機能するWebアプリケーションを作ることです。
VueでSSRする際は、createAppの代わりにcreateSSRAppを使ってハイドレーションを行います。VueでSSRしたいとき、多くの場合はNuxtを使うのが便利だと思います。(というよりほぼ一択?)

今回はシンプルな構成にするために、Viteでサンプルを作りました。
https://github.com/konkarin/vue-ssr-sandbox

ちなみに、npm create vite-extra@latestでssr-vueを選択しても簡単に作れます。

サンプルでは、ハイドレーションの前後をわかりやすくするために、あえてHydration mismatchを引き起こしています。これにより、SSRされた要素と、CSRされる要素に差分ができます。

https://github.com/konkarin/vue-ssr-sandbox/blob/main/src/App.vue

詳細は省略しますが、src/entry-server.tsserver.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)も生成されます。

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/apiCreateApp.ts#L358-L419

ここからは、ハイドレーションの主要な関数を順番に説明していきます。

hydrate

createSSRApp().mount()から呼び出され、initialVNodediv#appのNodeが渡されます。ここからハイドレーションが始まります。

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/hydration.ts#L119-L135

hydrateNode(1回目の呼び出し)

div#appのNodeとinitialVNodeが渡されて、Nodeに対してハイドレーションを行っていきます。最初は必ずmountComponentでルートコンポーネント(今回はApp.vue)を初期化します。それ以外の場合は後ほど説明します。

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/hydration.ts#L137-L366

CSRとSSRではinitialVNodeが持つプロパティに差分があります。CSRはまだレンダリングされていないためinitialVNode.elnull、SSRはSSRされたHTMLが持つ要素(今回はApp.vueのrootのdiv)が入ります。

mountComponent

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/renderer.ts#L1169-L1246

ここで初めてルートコンポーネントのインスタンスが作られます。このインスタンスの.vnodeプロパティにinitialVNodeも紐づけられます。mountComponent自体はCSRでもSSRでもそれほど変わりません。
mountComponentからは、コンポーネントの初期化のために様々な処理が呼ばれます。説明しきれないので今回は省略します。

hydrateSubTree

コンポーネントインスタンスが持つサブツリー(VNode)を生成します。実際には1321行目のrenderComponentRootがサブツリーを生成します。

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/renderer.ts#L1317-L1338

余談ですが、インスタンスが持つ.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、フラグメント、テキスト、コメントなど、タイプによって処理が異なります。

hydrateNodeの全分岐パターンの図

https://deepwiki.com/vuejs/core/6.2-hydration#hydration-process-overview

hydrateElement

https://github.com/vuejs/core/blob/347ef1d3f5a25e21ba0f2b1cc1e9730a5ac27001/packages/runtime-core/src/hydration.ts#L368-L552

ElementがChildrenを持てばhydrateChildrenが呼ばれます。ChildrenのNodeに対し、hydrateNodeが呼び出され、Nodeツリーを走査していきます。hydrateElementがミスマッチのチェックとミスマッチがあったときの要素の更新を行い、propsとイベントハンドラをバインドします。

例えば、サンプルのApp.vueのhydrateSubTree以降の処理フローは次のようになります。

ハイドレーションが終わると、その後はCSRと同じ動作になります。リポジトリのコードに少し説明を加えただけなので、実際に手元で動かしたり、自分で処理を追いかけるとより理解が深まると思います。

おわりに

今回は備忘録の側面が強い記事になりました。ハイドレーションの完全な説明をするのは複雑なので、一旦は概要の理解にとどめています。
ハイドレーションの概念は、Vueに限らずReactや他のフレームワークでも考え方自体はそこまで大きく変わらないと思うので、概念の理解が重要だと自分は考えています。

最初は自力でデバッグしながら調べていましたが、途中からめんどくさくなってDeepWiki使って色々質問しながら書きました。もはや記事よりDeepWikiでいいや〜とも思いましたが、頑張って最後まで書きました。DeepWikiや各種エージェントのお陰で調べものと執筆作業はグンと楽になった、いい時代ですね。

GitHubで編集を提案
Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion