Vue.js + Nuxt.js でのSSR概要

2020-01-16TIPS,Vue.jsNuxt

Vue.jsなどのJavaScriptフレームワーク/ライブラリはサイト閲覧時に優れたUXを提供できます。
殆どの場合サーバーに毎回リクエストを送信することなくコンテンツを動的に変更する方法が提供されています。

ただしSPAにも問題がありWebサイトを最初にロードするときにブラウザは完全なページを受け取れません。
代わりにページ(HTML/CSS/その他の画像ファイル等)を構築するための断片と、それらを全てを纏める方法の指示(JavaScriptフレームワーク/ライブラリ)が送信されます。
ブラウザに実際に表示する前にこれらの情報をクライアント側で纏めるのは時間がかかります。
組み立て式の家具を購入したようなイメージで、最初に家具を作ってから物を載せる必要があります。

これに対する解決策はとてもシンプルです。
クライアント側でページを組み立てずにサーバーサイドでページを構築してからブラウザに渡すようにします、既製品の家具が届くのでそのまま物を置いて使い始める事ができます。

他にも多くの利点があります。
例えばAbout UsやContact Usページなど滅多に更新しないページはユーザーがリクエストするたびに構築する必要はありません。
そのためサーバーは一度作成してからページをキャッシュまたは保存しておく事ができます。
速度改善としては極わずかな効果しか得られませんが、応答までの時間がミリ秒(またはそれ以下)で測定される環境ではジワジワと効いてきます。

VueでのSSRに対するメリットの詳細情報はこちらを参照してください。

DjangoとNuxtを使用したサンプルはこちらを参照ください。


Nuxt.jsを使う理由

Nuxt.js は Next と呼ばれるReactライブラリのSSR実装に基づいています。
React + Nextの組み合わせに経験がある人はアプリケーション設計とレイアウトがよく似ている事がわかると思います。
ただしNuxtはVue向けの強力かつ柔軟なSSRソリューションを作成するためにVue固有の機能も提供しています。

Nuxtは現在もアクティブで十分なサポートが得られます。
特に素晴らしいことはNuxtを使用してプロジェクトを構築する時と他のVueプロジェクトを構築する時とでそれほど変わらないことです。


Nuxtプロジェクトの作成

まずはVueCLIを使用しプロジェクトを作成します。

# install VueCLI globally
$ npm install -g @vue/cli

# create a project using a nuxt template
$ vue init nuxt-community/starter-template my-nuxt-app

これによりmy-nuxt-appフォルダの中にプロジェクトが作成されます。
次に依存関係をインストールして開発サーバーを起動します。

$ cd my-nuxt-app
$ npm install
$ npm run dev

ブラウザで localhost:3000 を開くと次のようにアプリがローディングされ、読み込みが終わるとHelloWorldが表示されます。

表示される内容は通常のVueAppと大きな差はありませんが、アプリの構造を見るとファイル数が通常のVueAppに比べそれほど多くないはずです。

package.json を見るとNuxt自体の依存関係は1つしかないこともわかります。
これはNuxtの各バージョンがVue、Vue-router、およびVuexの特定のバージョンで動作するように調整されており、それらをすべて一緒にバンドルしているためです。

また、nuxt.config.jsというファイルもプロジェクトルートにあります。
このファイルでNuxtが提供する一連の機能をカスタマイズできます。

デフォルトではヘッダータグ、読み込みバーの色、およびESLintルールが設定されています。
その他の設定可能なオプションはこちらのドキュメントをご覧ください。

この投稿でもいくつかのオプションについて説明します。


プロジェクトのレイアウト

作成されたディレクトリを参照するとすべてのディレクトリに簡単な概要とドキュメントへのリンクが記載されたReadmeが用意されています。
これはNuxtを使用する利点の1つでアプリケーションのデフォルト構造です。

またNuxtはビルド時にこれらを確認し、使われているディレクトリに応じてアプリケーションをビルドします。
それではNuxtが提供するディレクトリを1つずつ見てみましょう。

Pages

これが唯一の必須ディレクトリです。
このディレクトリ内のVueコンポーネントはファイル名とディレクトリ構造に基づいてvue-routerに自動的に追加されます。
これは非常に便利です。
通常はPagesやViewsといったディレクトリを用意しこれらの各コンポーネントをrouter.jsに手動で登録する必要があります。

router.jsは大規模なプロジェクトではかなり複雑になる可能性があり、可読性を保つために分割が必要になる場合があります。
しかしNuxtでは我々の代わりにすべてのロジックを処理してくれます。

これを確かめてみましょう。
Pagesディレクトリ内にabout.vueというVueコンポーネントを作成し、 単純なテンプレートを追加してみましょう。

<template>
 <h1>About Page</h1>
</template>

コンポーネントを保存するとNuxtがルートを再生成します。
/aboutに移動するとこのabout.vueが呼び出され表示されます、とてもシンプルです。

また、Nuxtでは特別なファイル名が1つあります。
それは index.vue です。
この名前が付けられたディレクトリはホームページもしくはランディングページとしてルーティングされます。

Pagesの中に更にサブディレクトリを作成するとルートの構造化に役立ちます。
例えばView Documentページが必要な場合、Pagesディレクトリを次のようにします。

/pages
--| /documents
----| index.vue
----| view.vue

この状態でブラウザから/documents/viewへ移動するとview.vueが読み込まれます。
勿論、/documentsへ移動した場合はdocumentsディレクトリ内のindex.vueが読み込まれます。

/aboutページを作成したときと結果は同じだと思うかもしれませんが、この2つの構造には違いがあります。
別の新しいページを追加して、これを検証してみましょう。

例えば各ユーザ事にAboutページが必要だったとします。
Pagesディレクトリにaboutというサブディレクトリを作ってみましょう。

/pages
--| about.vue
--| /about
----| hogehoge-taro.vue

この状態で /about/hogehoge-taro にアクセスしようとすると、/about にアクセスした際に読み込まれるabout.vueが表示されてしまいます。

これはNuxtがネストされたrouteの生成を行っている事に起因しています。
この構造は永続的な/aboutルートが必要であり、そのルート内のすべてのものを独自のビュー領域にネストする必要があることを示しています。

通常のVueAppでは<router-view />コンポーネント内にabout.vueコンポーネントを指定することで実現されます。
Nuxtでも同じ概念なのですが<router-view/>の代わりに<nuxt/>を使用します。
早速about.vueコンポーネントを更新して、ネストされたルートを許可してみましょう。

<template>
 <div>
   <h1>About Page</h1>
   <nuxt/>
 </div>
</template>

ここで、/about に移動すると以前のabout.vueコンポーネントがタイトルだけで取得されます。
ただし、/about/hogehoge-taro に移動するとben-jones.vueコンポーネントがレンダリングされます。

もう一つのルーティングパターンに切り替えるならディレクトリを再構築するだけです。
about.vueコンポーネントを/aboutディレクトリ内に移動し、index.vueという名前に変更するだけで大丈夫です。

/pages
--| /about
----| index.vue
----| hogehoge-taro.vue

最後にrootパラメーターを使用して特定のページを取得したいとします。
例えば64がpage_idである/documents/edit/64に移動して編集できるようにします。
これは次の方法で実行できます。

/pages
--| /documents
----| /edit
------| _document_id.vue

_document_id.vueコンポーネントの先頭にあるアンダースコアに注目してください。
これは、$ route.paramsオブジェクトまたはNuxtのコンテキストのparamsオブジェクトでアクセス可能なルートパラメーターを示しています。
このパラメータのキーはアンダースコア以降のコンポーネント名(この場合はdocument_id)となる事に注意してください。
また、キーはプロジェクト内で一意に保つ必要があります。

_product_id.vueには次のコードを書き、/documents/edit/63 にアクセスするとルーティングされている事が確認できます。

<template>
 <h1>Editing Document {{ $route.params.document_id }}</h1>
</template>

続いてもう少し複雑なレイアウトを構成してみましょう。
次のような構成にしてみます。

/pages
--| /categories
----| /_category_id
------| documents.vue
------| /documents
--------| _document_id.vue

この場合、/categories/2/documents/3 が何を表示するかを想像するのはそれほど難しくありませんね。
ネストされた category_iddocument_idパラメータを持つ documents.vue コンポーネントがあります。
これはvue-routerで構成するよりもはるかに簡単です。

他にルーター構成で採用される傾向があるのはルーターガードのセットアップです。
NuxtではbeforeRouteEnterを使用してコンポーネント自体でルーターを構築できます。
ルートパラメータを検証する場合、Nuxtはvalidateと呼ばれるコンポーネントメソッドを提供しており、コンポーネントをレンダリングする前にproduct_idが数値であるかどうかを確認したい場合 _document_id.vueのスクリプトタグに次のコードを追加します。

export default {
 validate ({ params }) {
   // Must be a number
   return /^\d+$/.test(params.product_id)
 }
}

この状態で /categories/2/documents/somedocument に移動すると’somedocument’ は有効になっていないため404エラーが返されます。

Pagesディレクトリについての説明はこれで終わりです。
ディレクトリでルートを適切に構成する方法を習得する事は不可欠であるため、Nuxtを最大限に活用するには少し時間をかけてでも色々試してみることが重要です。
公式のドキュメントにも目を通しておいてください。

上述したこのルーティング方法は適切に構成されていれば様々なプロジェクトに最適です。
また、Nuxtが自動的に生成もしくは再構築するよりも多くのルートをルーターに追加する必要がある場合Nuxtはルーターインスタンスをカスタマイズする方法も提供しています。
これにより新しいルートを追加し生成されるルートをカスタマイズできるため、イレギュラーなケースに遭遇した場合でも対応できる柔軟性があります。

Store

Pagesディレクトリ同様にストアディレクトリの構造に基づいてVuexストアを構築できます。
もしストアが必要ない場合はディレクトリごと削除してください。

ストアにはClassicとModuleの2つの動作モードがあります。
Classicではstoreディレクトリにindex.jsファイルが必要なため、Vuexインスタンスを返す関数をエクスポートする必要があります。

import Vuex from 'vuex'

const createStore = () => {
 return new Vuex.Store({
   state: ...,
   mutations: ...,
   actions: ...
 })
}

export default createStore

これで通常のVueプロジェクトでVuexを使用するのと同じように必要に応じてストアを作成できます。

Moduleモードでもstoreディレクトリにindex.jsファイルを作成する必要がありますが、state/mutations/actionsのみエクスポートするだけでOKです。

以下の例は空のstateを指定しています。

export const state = () => ({})


次にストアディレクトリ内の各ファイルは独自の名前空間、またはモジュール内のストアに追加されます。
例として現在のドキュメントを保存する場所を作成します。
storeディレクトリにdocument.jsというファイルを作成するとストアの名前空間セクションが$ store.productで利用可能になります。

試しに簡単な例を挙げてみます。

export const state = () => ({
 _id: 0,
 title: 'Unknown',
 maintainer: 'None'
})

export const actions = {
 load ({ commit }) {
   setTimeout(
     commit,
     1000,
     'update',
     { _id: 1, title: 'Document', maintainer: 'hedgehog' }
   )
 }
}

export const mutations = {
 update (state, document) {
   Object.assign(state, document)
 }
}

ロードアクションのsetTimeout でどこぞやのAPI呼び出しを行った場合を想定し、1秒後にResponseを使ってストアを更新する事にしました。

これを documents/view ページで使用してみます。

<template>
 <div>
   <h1>View Document {{ document._id }}</h1>
   <p>{{ document.title }}</p>
   <p>Price: {{ document.maintainer }}</p>
 </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
 created () {
   this.$store.dispatch('document/load')
 },
 computed: {
   ...mapState(['document'])
 }
}
</script>

this.$store.dispatch している document/load アクションがDocumentの下で名前空間となっていることがわかりますね。
これによりstoreのどのセクションを扱っているかが明確になります。
そしてstateをプロパティにマッピングすることでテンプレートで使用できるようになります。

ただし、実行してみるとわかりますがこのままでは1つ問題があります。
(偽の)APIキック中に元のstateが一瞬表示されます。
これの対処は後ほどNuxtが提供するfetchという機能を使用し修正します。

Components

Componentsディレクトリには、ナビゲーションバー、画像ギャラリー、ページネーション、データテーブルなどの再利用可能なコンポーネントが含まれています。
これらをPagesディレクトリ内に配置してしまうと上述した通りルートに変換されてしまうため、ページを構成するパーツはComponentsディレクトリに配置するようにします。

これらのコンポーネントはインポートすることでページまたは他のコンポーネントで使用できます。

import Hoge from ~/components/Hogehoge.vue

Assets

Assetsディレクトリにはコンパイルされていないファイルが含まれています。
これはNuxtWebpackがファイルをロード及び処、理する方法に関係しています。
詳細は公式のドキュメントを読んでください。

Static

Staticディレクトリにはサイトのルートディレクトリにマップされる静的ファイルが含まれます。
例としてこのディレクトリに logo.png という画像を配置すると、/logo.png で利用できるようになります。
これは robots.txtfavicon.ico といったMetaファイルなどに適しています。

Layouts

VueAppには通常 App.vue と呼ばれるrootコンポーネントがあり、アプリの基本レイアウトを設定できます。
NuxtではこれをLayoutsディレクトリで構成でき、特定のページに対してのみ他と異なるレイアウトを適用することも可能です。

例としてヘッダータグのみがありナビゲーションバーがない layouts/admin-layout.vue を作してみましょう。

<template>
 <div>
   <h1>Admin Layout</h1>
   <nuxt />
 </div>
</template>

次にPagesディレクトリに admin.vue ページを作成し、Nuxtが提供するlayoutというプロパティを使用してコンポーネントに使用するレイアウト名を指定します。

<template>
 <h1>Admin Page</h1>
</template>

<script>
export default {
 layout: 'admin-layout'
}
</script>


これだけで完了です。
他のページではデフォルトレイアウトを使用しますが、/admin に移動するとadmin-layout.vueレイアウトが使用されます。

ちなみに無効なURLを入力された際などに表示するエラーページは、デフォルトではNuxtが提供する独自のエラー画面が表示されます。
もしこれを編集したい場合は、error.vueレイアウトを作成するだけで切替えできます。
ただし、error用のレイアウトには他のコンポーネントを含めることはできません。

Middleware

ミドルウェアはページまたはレイアウトをレンダリングする前に実行できる機能です。
例えばルートガードは、(コンポーネント自体でvalidateメソッドを使用する代わりに)有効なログインのVuexストアを確認したり、いくつかのパラメーターを検証したりすることができる一般的な使用法です。

これらの関数は非同期にすることができ、Nuxtのコンテキストにもアクセスできます。
後ほど説明します。

Plugins

Pluginsディレクトリを使用するとアプリケーションを作成する前にVueプラグインを登録できます。
これによりプラグインをVueインスタンス上のアプリ全体で共有し、任意のコンポーネントでアクセスできるようになります。

例としてvue-notifications を使用してみましょう。

$ npm install vue-notifications --save

次に、プラグインディレクトリに vue-notifications.js というファイルを作成します。

import Vue from 'vue'
import VueNotifications from 'vue-notifications'

Vue.use(VueNotifications)


そしてプロジェクトルートのnuxt.config.jsファイルを編集し、module.exportsオブジェクトに次のエントリを追加します。

plugins: ['~/plugins/vue-notifications']

これでアプリ全体でvue-notificationsを使用できます。

これで、ディレクトリ構造の説明は以上です。


Nuxt’s Supercharged Components

NuxtではすべてのPageコンポーネントに追加機能を提供するために使用できる追加メソッドが用意されています。
例えば、validsメソッドを使用してパラメータをチェックし無効な場合はユーザーをリダイレクトする際などにこの機能は使われています。

そしてNuxtプロジェクトで使用される主な機能は asyncDataメソッドとfetchメソッドです。
どちらも概念が非常に似ており、コンポーネントが生成される前に非同期で実行されコンポーネントとストアのデータを取り込むために使用できます。
また、データベースまたはAPI呼び出しを待つ必要がある場合でもクライアントに送信する前にサーバー上でページを完全にレンダリングできるようにしてくれます。

asyncDataとfetchの違い

  • asyncDataは、Pageコンポーネントのデータを取り込むために使用されます。
    オブジェクトを返すと、レンダリングの前にデータの出力とマージされます。
  • fetchは、Vuex Storeを作成するために使用されます。
    プロミスを返すと、Nuxtはレンダリングされる前にプロミスが解決されるまで待機します。

早速これを試してみましょう。
先ほどの /products/view ページでAPI呼び出しの実行中にストアの初期状態が短時間表示されるという問題がありました。

これを解決する方法の1つは、コンポーネントまたはストアにloading = trueなどの値を保存し、API呼び出しの終了まで読み込みコンポーネントを表示することです。
その後、loading = falseに設定しデータを表示します。

ではフェッチを使用しレンダリングの前にストアにデータを入力できるようにしましょう。
/documents/view-async という新しいページで次のコードを記述します。

export default {
 fetch () {
   this.$store.dispatch('document/load')
 },
 computed: {...}
}

ただしこれらの「Supercharged」メソッドはコンポーネントが作成される前に実行されるため、ストアにアクセスできません。
それではストアにアクセスするにはどうすれば良いでしょうか。


The Context API

もちろん解決策があります。
Nuxtのすべてのメソッドで、Contextという非常に便利なオブジェクトを含む引数(通常は最初の引数)が提供されます。

ストアにアクセスするためにコンテキストを分解し、そこからストアを抽出できますがコンポーネントをレンダリングする前にNuxtが解決するのを待つことができるようにPromiseが返ることを確認する必要があります。

そのためにStoreアクションも少し調整します。

// Component
export default {
 fetch ({ store }) {
   return store.dispatch('documnt/load')
 },
 computed: {...}
}

// Store Action
load ({ commit }) {
 return new Promise(resolve => {
   setTimeout(() => {
     commit('update', { _id: 1, title: 'Document', maintainer: 'hedgehog' })
     resolve()
   }, 1000)
 })
}

状況に応じてasync / await または他の方法を使用できます。
API呼び出しが終了しストアがレスポンスで更新されてからるようにNuxtに指示します。
これで /documents/view-asyncに移動した際、初期化中のコンテンツがフラッシュ(チラつき)されなくなりました。

仮にSSRでの実装をしていなくてもVueAppでこれはとても役立ちます。
コンテキストはすべてのミドルウェアやストアが初期化される前に実行される特別なストアアクションであるNuxtServerInitなどの他のNuxtメソッドでも使用できます。


SSRでのセッション管理

これを考えるにあたってNuxtではクライアントとサーバーはそれぞれの別のエンティティと考えることが重要です。
ユーザが最初にページへアクセスすると、RequestがNuxtに送信されサーバーはそのページとアプリの残りの部分を可能な限りサーバ側で構築してから送信しクライアント側で必要に応じてチャンクをロードします。

この時サーバーはできる限り処理を進めておく必要がありますが、必要な情報にアクセスできない場合もありその際は代わりにクライアント側で処理が行われます。
更にクライアントによって処理された最終的なコンテンツがサーバー側で予期したものと異なる場合、クライアントはそれをゼロから再構築するように注意されます。
これが発生し始めるとブラウザのコンソールでもエラーが表示されます。

セッション管理の方法としてログイン機能のあるVueAppがあり、localStorageに保持することにしたトークン(JWTなど)を使用してセッションが保存されているとします。
サイトにアクセスするときにAPIに対してトークンを認証し、基本的なユーザー情報が返されたらストアに格納したとしましょう。

NuxtにはNuxtServerInitと呼ばれる便利なメソッドがあります。
このメソッドを使用すると初期ロード時にストアを非同期で1回読み込むことができます。

export const actions = {
 nuxtServerInit ({ dispatch }) {
   const token = localStorage.getItem('token')
   if (token) return dispatch('user/load', token)
 }
}

完璧なように思えますが、この状態でページを更新するとエラーが発生します。
何故ならこのメソッドはサーバー上で実行されるため、クライアントのlocalStorageに何が保存されているかわからないのです。

代替手段としてlocalStorageに最も似ているのは代わりにCookieを使用することです。

Nuxtはクライアントからサーバーへのリクエストとともに送信されるCookieにアクセスできます。
他のNuxtメソッドと同様に、nuxtServerInitはコンテキストにアクセスできます。
コンテキストではクライアントリクエストからのすべてのヘッダーおよびその他の情報を格納するreqオブジェクトにアクセスできます。
Node.jsではお馴染みですね。

よってCookieにトークンを保存した後、サーバー上でトークンにアクセスしましょう。

import Cookie from 'cookie'

export const actions = {
 nuxtServerInit ({ dispatch }, { req }) {
   const cookies = Cookie.parse(req.headers.cookie || '')
   const token = cookies['token'] || ''
   if (token) return dispatch('user/load', token)
 }
}

デプロイ

Nuxtでのデプロイは非常に簡単です。
同じコードを使用してSSRアプリ、SPA、または静的ページを作成できます。

SSR

デプロイの基本的な考えとして選択したプラットフォームでビルドプロセスを実行し、いくつか構成を設定します。
公式ドキュメントに記載されているHerokuの例を使用します。

まず、package.jsonでHerokuのスクリプトを設定します。

"scripts": {
 "dev": "nuxt",
 "build": "nuxt build",
 "start": "nuxt start",
 "heroku-postbuild": "npm run build"
}

次に、heroku-cliを使用してHeroku環境をセットアップします。

$ heroku config:set NPM_CONFIG_PRODUCTION=false
$ heroku config:set HOST=0.0.0.0
$ heroku config:set NODE_ENV=production

$ git push heroku master

これだけで完了です。
他のプラットフォームでは設定方法は異なりますがプロセスは似ています。

現在はAzure, GCP, AWSを初めGitHubなど様々なプラットフォームに対応しています。

SPA

Nuxtが提供する追加機能の一部を利用したいが、サーバーがページをレンダリングしようとするのを避けたい場合はSPAとしてデプロイできます。

まず、デフォルトでnpm run devはSSRをオンにして実行されるため、SSRなしでアプリケーションをテストしておくのが最善です。
これを実施するにはnuxt.config.jsファイルを編集し、次のオプションを追加します。

mode: 'spa',

これでnpm run devを実行するとSSRがオフになり、アプリケーションがテスト用のSPAとして実行されます。
またこの設定によりビルド時にSSRが含まれなくなります。

静的ページ

S3などにホスティングする場合、コンテキスト内のreqおよびresにアクセスできないためコードが依存している場合は他の手段に置き換える必要があります。
例えば、先ほどnuxtServerInit関数はリクエストヘッダー内のCookieからトークンを取得しようとしているためエラーをスローします。

デプロイ自体はとても簡単で、nuxt.config.json にオプションを追加します。

generate: { fallback: true },

まとめ

NuxtはSPAとSSRに静的ページと幅広くサポートしており、storeやrouter部分などVue単体でアプリケーションを作成する時の面倒な部分を簡略化してくれます。
初めて使用する際はつまずくポイントもあるかもしれませんが公式のDocumentが良く纏まっているのでまずは一読しておくことをオススメします。

2020-01-16TIPS,Vue.jsNuxt

Posted by Kenny