SSR をしているとき、基本的にはアプリケーションの「スナップショット」をレンダリングしています、したがって、アプリケーションがいくつかの非同期データに依存している場合においては、それらのデータを、レンダリング処理を開始する前にプリフェッチして解決する必要があります。
もうひとつの重要なことは、クライアントサイドでアプリケーションがマウントされる前に、クライアントサイドで同じデータを利用可能である必要があるということです。そうしないと、クライアントサイドが異なるステートを用いてレンダリングしてしまい、ハイドレーションが失敗してしまいます。
この問題に対応するため、フェッチされたデータはビューコンポーネントの外でも存続している必要があります。つまり特定の用途のデータストアもしくは "ステート・コンテナ" に入っている必要があります。サーバーサイドではレンダリングする前にデータをプリフェッチしてストアの中に入れることができます。さらにシリアライズして HTML にステートを埋め込みます。クライアントサイドのストアは、アプリケーションをマウントする前に、埋め込まれたステートを直接取得できます。
このような用途として、公式のステート管理ライブラリである Vuex を使っています。では store.js
ファイルをつくって、そこに id に基づく item を取得するコードを書いてみましょう:
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// Promise を返すユニバーサルなアプリケーションを想定しています
// また、実装の詳細は割愛します
import { fetchItem } from './api'
export function createStore () {
return new Vuex.Store({
state: {
items: {}
},
actions: {
fetchItem ({ commit }, id) {
// store.dispatch() を使って Promise を返します
// そうすればデータがフェッチされたときにそれを知ることができます
return fetchItem(id).then(item => {
commit('setItem', { id, item })
})
}
},
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item)
}
}
})
}
そして app.js
を更新します:
// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'
export function createApp () {
// ルーターとストアのインスタンスを作成します
const router = createRouter()
const store = createStore()
// ルートのステートをストアの一部として利用できるよう同期します
sync(store, router)
// アプリケーションのインスタンスを作成し、ルーターとストアの両方を挿入します
const app = new Vue({
router,
store,
render: h => h(App)
})
// アプリケーション、ルーター、ストアを露出します
return { app, router, store }
}
ではデータをプリフェッチするアクションをディスパッチするコードはどこに置けばよいでしょうか?
フェッチする必要があるデータはアクセスしたルートによって決まります。またそのルートによってどのコンポーネントがレンダリングされるかも決まります。実のところ、与えられたルートに必要とされるデータは、そのルートでレンダリングされるコンポーネントに必要とされるデータでもあるのです。したがって、データをフェッチするロジックはルートコンポーネントの中に置くのが自然でしょう。
ルートコンポーネントではカスタム静的関数 asyncData
が利用可能です。この関数はそのルートコンポーネントがインスタンス化される前に呼び出されるため this
にアクセスできないことを覚えておいてください。ストアとルートの情報は引数として渡される必要があります:
<!-- Item.vue -->
<template>
<div data-segment-id="282437">{{ item.title }}</div>
</template>
<script>
export default {
asyncData ({ store, route }) {
// アクションから Promise が返されます
return store.dispatch('fetchItem', route.params.id)
},
computed: {
// ストアのステートから item を表示します
items () {
return this.$store.state.items[this.$route.params.id]
}
}
}
</script>
entry-server.js
において router.getMatchedComponents()
を使ってルートにマッチしたコンポーネントを取得できます。そしてコンポーネントが asyncData
を利用可能にしていればそれを呼び出すことができます。そしてレンダリングのコンテキストに解決したステートを付属させる必要があります。
// entry-server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
reject({ code: 404 })
}
// マッチしたルートコンポーネントすべての asyncData() を呼び出します
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// すべてのプリフェッチのフックが解決されると、ストアには、
// アプリケーションをレンダリングするために必要とされるステートが入っています。
// ステートを context に付随させ、`template` オプションがレンダラーに利用されると、
// ステートは自動的にシリアライズされ、HTML 内に `window.__INITIAL_STATE__` として埋め込まれます
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
template
を使うと context.state
は自動的に最終的な HTML に window.__INITIAL__
というかたちのステートとして埋め込まれます。クライアントサイドでは、アプリケーションがマウントされる前に、ストアがそのステートを取得します:
// entry-client.js
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
クライアントサイドではデータ取得について 2つの異なるアプローチがあります:
- ルートのナビゲーションの前にデータを解決する:
この方法では、アプリケーションは、遷移先のビューが必要とするデータが解決されるまで、現在のビューを保ちます。良い点は遷移先のビューがデータの準備が整い次第、フルの内容をダイレクトにレンダリングできることです。しかしながら、データの取得に時間がかかるときは、ユーザーは現在のビューで「固まってしまった」と感じてしまうでしょう。そのため、この方法を用いるときにはローディング・インジケーターを表示させることが推奨されます。
この方法は、クライアントサイドでマッチするコンポーネントをチェックし、グローバルなルートのフック内で asyncData
関数を実行することにより実装できます。重要なことは、このフックは初期ルートが ready になった後に登録するということです。そうすれば、サーバーサイドで取得したデータをもう一度無駄に取得せずに済みます。
// entry-client.js
// 関係のないコードは除外します
router.onReady(() => {
// asyncData を扱うためにルーターのフックを追加します。これは初期ルートが解決された後に実行します
// そうすれば(訳注: サーバーサイドで取得したために)既に持っているデータを冗長に取得しなくて済みます
// すべての非同期なコンポーネントが解決されるように router.beforeResolve() を使います
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
// まだレンダリングされていないコンポーネントにのみ関心を払うため、
// 2つのマッチしたリストに差分が表れるまで、コンポーネントを比較します
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
if (!activated.length) {
return next()
}
// もしローディングインジケーターがあるならば、
// この箇所がローディングインジケーターを発火させるべき箇所です
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// ローディングインジケーターを停止させます
next()
}).catch(next)
})
app.$mount('#app')
})
- マッチするビューがレンダリングされた後にデータを取得する:
この方法ではビューコンポーネントの beforeMount
関数内にクライアントサイドでデータを取得するロジックを置きます。こうすればルートのナビゲーションが発火したらすぐにビューを切り替えられます。そうすればアプリケーションはよりレスポンスが良いと感じられるでしょう。しかしながら、遷移先のビューはレンダリングした時点ではフルのデータを持っていません。したがって、この方法を使うコンポーネントの各々がローディング中か否かの状態を持つ必要があります。
この方法はクライアントサイド限定のグローバルな mixin で実装できます:
Vue.mixin({
beforeMount () {
const { asyncData } = this.$options
if (asyncData) {
// データが準備できた後に、コンポーネント内で `this.dataPromise.then(...)` して
// 他のタスクを実行できるようにするため、Promise にフェッチ処理を割り当てます
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})
これら 2つの方法のどちらを選ぶかは、究極的には異なる UX のどちらを選ぶかの判断であり、構築しようとしているアプリケーションの実際のシナリオに基づいて選択されるべきものです。しかし、どちらの方法を選択したかにかかわらず、ルートコンポーネントが再利用されたとき(つまりルートは同じだがパラメーターやクエリが変わったとき。例えば user/1
から user/2
) へ変わったとき)には asyncData
関数は呼び出されるようにすべきです。これはクライアントサイド限定のグローバルな mixin でハンドリングできます:
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
ふぅ、コードが長いですね。これはどうしてかというと、ユニバーサルなデータ取得は、大抵の場合、サーバーサイドでレンダリングするアプリケーションの最も複雑な問題であり、また、今後、スムーズに開発を進めていくための下準備をしているためです。一旦ひな形が準備できてしまえば、あとは、それぞれのコンポーネントを記述していく作業は、実際のところ実に楽しいものになるはずです。