Nuxt版のServer Componentsでサーバー環境限定でレンダリングする
Server Componentsと言えば、React[1]やNext.jsのApp Router[2]を思い浮かべる方が多いと思います。
特に、Next.jsのApp RouterはデフォルトでServer Componentsが適用されるので、今後目にするケースが増えていくことと思います。
Server Componentsは文字通りサーバー環境のみでレンダリングされるコンポーネント技術です。
これによる制約もありますが、パフォーマンスや効率性が大きく改善されます。
そんなServer Componentsですが、Nuxtでも実験的扱いですが開発が進められています。
今回はこのNuxtのServer Componentsを試してみたいと思います。
NuxtのServer Componentsは実験的段階です。検証/利用する場合は最新の状況を確認してください。
NuxtでのServer Componentsのロードマップやその進捗状況は、以下GitHub Issueで管理されています。
nuxt.config.ts
#前述しましたが、現時点ではServer Componentsは実験的段階です。
利用するにはnuxt.config.ts
でフィーチャーフラグ(componentIslands
)を有効にします。
export default defineNuxtConfig({
experimental: {
componentIslands: true
}
});
フィーチャーフラグ名(componentIslands
)の通り、Server ComponentsはNuxt版のアイランドアーキテクチャの一部になっているようです。
なお、ここで使ったNuxtのバージョンは3.6.1
です。
Nuxtのバージョンアップとともに、Server Componentsもアップグレードされています。利用するNuxtバージョンには注意が必要です。
サーバーコンポーネント
#作成したサーバーコンポーネントは以下のようなものです(Blog.server.vue)。
<script setup lang="ts">
import MarkdownIt from 'markdown-it';
const props = defineProps<{
page: string
}>();
const { blog, fetchByPage } = useBlog(); // マークダウン形式のブログをフェッチするダミーComposable
fetchByPage(Number(props.page));
const md = new MarkdownIt();
const html = md.render(blog.value?.content ?? 'Page Not Found');
console.log('server-side initialization completed!!');
</script>
<template>
<div v-html="html" />
</template>
Nuxtの公式ドキュメントの動画を見ながら作りました。
ファイル名がポイントです。サフィックスを.server.vue
にします。これでNuxtはこのコンポーネントをサーバーコンポーネントとして認識します。
このコンポーネントは、propsで受け取ったページ(page)に対応するブログ(マークダウン形式)を取得し、マークダウンパーサー(markdown-it)でHTMLに変換してレンダリングしています。
Reactも同様ですが、サーバーコンポーネントはサーバー環境のみでレンダリングされますので、その実装に注意が必要です。
以下の機能は使えません。
- ライフサイクルイベント(onMounted等)
- イベントハンドリング(@click等)
- リアクティブな変数(ref()等)
- ブラウザでのみ動作するAPI(DOM API等)
これらは実装したところで、期待通りに動作しません。
また、サーバーコンポーネント内で通常のコンポーネント(ファイル名から.server
を外したもの)[3]を使っても、サーバーコンポーネントとして扱われてしまうようです。
つまり、上記制約が通常コンポーネントに対しても適用されてしまいました。
後述しますが、この現象は通常コンポーネントをサーバーコンポーネントのスロットとして使うことで解消します[4]。
このようにいくつかの制約に気を付ける必要がありますが、サーバーコンポーネントのソースコードはクライアント向けのバンドルファイルには含まれません。
このため、ファイルサイズが削減されるのはもちろん、ブラウザからアクセスできないようなプライベートなデータのフェッチ等をここでしても問題ありません。
サーバーコンポーネントを利用するページ
#サーバーコンポーネントを使うページは以下のようになります(pages/index.vue)。
<script setup lang="ts">
const page = ref('1');
</script>
<template>
<div>
<div>
<label for="page">Page: </label>
<input id="page" v-model="page" />
</div>
<Blog :page="page" />
</div>
</template>
通常のVueコンポーネントと特に変わるところはありません。
サーバーコンポーネントのpropsとしてユーザー入力値を渡すようにしています。
つまり、入力値の変更に反応して、サーバーコンポーネントの内容を切り替えるリアクティブ性を持たせています。
サーバーコンポーネントの動きを確認する
#これをビルドして動かしてみます。
npm run build
node .output/server/index.mjs
以下のイメージになります。
試しに、通常コンポーネント(.server
を外した場合)とサーバーコンポーネントでクライアントにダウンロードされるNuxtアプリ(.nuxt/dist/client/_nuxt/index.xxxxxxxx.js
)のバンドルサイズを比較してみました。
- 通常コンポーネント: 109KB
- サーバーコンポーネント: 11.9KB
サーバーコンポーネントの場合は、劇的にファイルサイズが削減されています。
これは、通常コンポーネントではクライアント側のレンダリングのために、コンポーネント自体と外部ライブラリ(この場合はmarkdown-it)のソースコードがバンドルファイルに含まれているためです。
このサーバーコンポーネントもユーザーの入力値(page)が変わると再レンダリングする必要があります。
通常コンポーネントであれば、入力値の変更に反応してクライアントサイドで再レンダリングされますが、ここではサーバー環境でレンダリングする仕組みが必要です。
Nuxtではサーバーサイド側にGETリクエスト(クエリパラメータとしてpropsを連携)を投げてレンダリング結果をJSONで受け取っていました。
以下はChrome DevToolでこの動きを追ってみた様子です。
なお、この結果はクライアント側にキャッシュされていて、同一のpropsで再取得することはありませんでした[5]。
ここで、サーバーコンポーネントを使った場合の動きをシーケンスで整理しておきます。
sequenceDiagram actor B as ブラウザ participant SV as サーバーサイドアプリ participant CDN as 静的コンテンツ(CDN) B ->> SV: 初期ページ要求 SV ->> SV: ページレンダリング SV -->> B: HTML B ->> CDN: クライアント向けNuxtアプリ(JS)要求 CDN -->> B: クライアント向けNuxtアプリ(JS)<br />※サーバーコンポーネントを含まない B ->> B: ハイドレーション alt props変更 B ->> SV: レンダリング要求 SV ->> SV: コンポーネントレンダリング SV -->> B: レンダリング結果(JSON) B ->> B: レンダリング結果反映 end
今回はローカル環境(Node.jsサーバー)で動かしていますが、実運用をイメージして上記シーケンスではサーバーサイドアプリ(Nitro)と静的コンテンツとして配信する部分(CDN)はレーンを分けています。
サーバーコンポーネントと通常コンポーネントを組み合わせる
#Next.jsの場合はコンポーネントツリーのリーフコンポーネントに、リアクティブ性が要求されるコンポーネントを配置すること推奨しています[6]。
ただし、前述の通りNuxtではサーバーコンポーネント内に通常のコンポーネントを使ってもリアクティブになりません。
例えば、以下のイベントハンドラを持つボタンコンポーネントがあるとします。
<script setup lang="ts">
const log = () => console.log('click!!')
</script>
<template>
<button @click="log">クリック</button>
</template>
ボタンをクリックすると、コンソールにログ出力するコンポーネントです。
これを先ほど作成したサーバーコンポーネントに配置します。
<script setup lang="ts">
// ソースコード省略
</script>
<template>
<div>
<Button />
<div v-html="html" />
</div>
</template>
これは動作しません。ボタンをクリックしてもコンソールログは出力されません。
現時点では、このような場合はスロットを使います(スロットのサポートはv3.5よりサポートされました)。
サーバーコンポーネントは以下のような実装になります。
<script setup lang="ts">
// ソースコード省略
</script>
<template>
<div>
<slot />
<div v-html="html" />
</div>
</template>
直接Buttonコンポーネントを配置していた部分をslot
に置き換えました。
代わりに、サーバーコンポーネントを利用するページファイル(index.vue)で、このスロットにButtonコンポーネントを配置します。
<script setup lang="ts">
const page = ref('1');
</script>
<template>
<div>
<div>
<label for="page">Page: </label>
<input id="page" v-model="page" />
</div>
<Blog :page="page">
<!-- default slot -->
<Button />
</Blog>
</div>
</template>
このように変更することで、期待通りクリックイベントが動作するようなります。
2023-12-25にリリースされたNuxt3.9でnuxt-client
ディレクティブが導入され、サーバーコンポーネント内に通常のコンポーネントを配置できるようになりました。
これによりスロットを使う必要はなくなっています。
nuxt-client
ディレクティブを使う場合は、nuxt.config.ts
を以下のようにします。
export default defineNuxtConfig({
experimental: {
componentIslands: {
selectiveClient: true
}
},
});
この設定を入れることで、サーバーコンポーネントのソースコードは以下のように記述できます。
<template>
<div>
<div v-html="html" />
<Button nuxt-client />
</div>
</template>
Button
コンポーネントにnuxt-client
ディレクティブを設定します。
最後に
#今回は現時点でのNuxt版Server Componentsの実装について見ていきました。
まだ実験的段階であるものの、うまく使いことなすとパフォーマンス観点のメリットが大きいと思います。
語弊があるかもしれませんが、大きな潮流としてフロントエンドはSPA全盛の時代が終わり、サーバーサイドに(グレードアップして)回帰しているのを感じます。
Next.jsのApp RouterがStableになって、Server Componentsの普及が進んでいくのかなと思いますが、Nuxtの方の動きも注目していきたいと思いました。
ここでいう「通常のコンポーネント」はクライアントサイドでリアクティブに動作するコンポーネントを指します。
.client.vue
サフィックスのコンポーネントと区別するために、クライアントコンポーネントの表現は使いませんでした。 ↩︎GitHub Nuxt Issue - support client interactivity within server components ↩︎
現時点でこのキャッシュ戦略を変更できるのかは検証できませんでした。 ↩︎