A Simple Pre-Rendered Web App Using Vue + Nuxt

Michael Lynch

Vue + Nuxtで作るシンプルなプリレンダリングWebアプリ

この記事では、VueとNuxtを使ってページをプリレンダリングする方法を紹介します。この手法を使えば、Vueならではの快適な開発体験を損なうことなく、ソーシャルシェアや検索エンジン最適化といった重要な機能も両立できます。

このチュートリアルはVueやNuxtが初めての方を想定しています。必要なことは順を追って解説していきます。

Vueが抱える課題

AngularやReactと同様に、Vueはシングルページアプリケーション(SPA)を構築するためのフレームワークです。従来のウェブサイトではサイト内のリンクをクリックするたびにブラウザがページ全体を新たにダウンロードする必要がありますが、SPAではすべてが1つのページに収まっています。ユーザーがサイト内を移動しても、JavaScriptがサーバーからすべてを再取得することなく新しいページを描画します。これによりユーザーのブラウザとWebサーバー間の遅いネットワーク通信が不要になり、高速でスムーズなユーザー体験が実現します。

Vueのレスポンスの良さと引き換えに、ページの初期HTMLをコントロールしにくくなります。ブラウザがサーバーからSPAを取得すると、次のようなHTMLが返されます。

<html>
<head>
  <title>My Awesome Website</title>
</head>
<body>
  <div id="app"></div>
  <!-- app.js populates the rest of the page after the browser executes the script. -->
  <script type="text/javascript" src="app.js">
</body>
</html>

シングルページアプリであるため、このHTMLの雛形はサイト内のどのページでも同じです。つまり、ユーザーがyoursite.com/aboutにアクセスしてもyoursite.com/contactにアクセスしても、サーバーは同じHTMLの雛形を返します。パスを判別して適切なページを描画するのは、ユーザーのブラウザで実行されるJavaScriptの役割です。

動的なページ描画はサイト内の移動を高速化する優れた仕組みですが、サイトをソーシャルネットワークや検索エンジンと連携させる際に問題が生じます。

SPAの問題その1:ソーシャルシェア

私がブログ記事をTwitterでシェアすると、次のように表示されます。

リッチなTwitterカードの例

Open Graphタグを使って、投稿がTwitterでリッチカードとして表示されるようにしています。

Twitterは、ページ内のOpen Graph規格に準拠したHTMLタグをもとにこのカードを生成します。たとえば、カードに表示する画像を指定するには、次のようなタグを追加します。

<meta property="og:image" content="https://mtlynch.io/post-42/cover.jpg" />

サイトがSPAの場合、すべてのページが同じHTMLの骨組み、つまり同じOpen Graphタグを共有することになります。TwitterやFacebookといった主要なソーシャルネットワークでは、JavaScriptが実行される前にOpen Graphタグが存在している必要があります。そのため、サイト内のページごとに異なるTwitterカードやFacebookカードを作成できません。

SPAの問題その2:検索エンジン最適化(SEO)

ソーシャルネットワークとは異なり、検索エンジンはJavaScriptを使ってウェブサイトをレンダリングします。問題は、それが完璧ではないことです。

多くのウェブサイトでは、ユーザーが閲覧している間もJavaScriptでページ内容を継続的に更新しています。Googleから見れば、ページはいつ「描画が完了」し、インデックスの準備ができたと判断すればよいのでしょうか。通常のSPAでは、Googleはページのインデックスを試みますが、正しくインデックスされる保証はありません。

Nuxtが解決します

現代のウェブではソーシャルネットワークやSEOは非常に重要です。Vueを使うことでアプリがそれらのサービスと十分に連携できなくなるとしたら、大きな痛手です。

Nuxt.jsのロゴ

Nuxtは、この課題を解決するフレームワークです。Vueの上にレイヤーを追加し、ブラウザ側の処理の一部をサーバーに戻します。空のHTML雛形を送ってクライアント側のJavaScriptですべてをレンダリングするのを待つのではなく、Nuxtがサーバー側でページを事前に処理し、より完全にレンダリングされたHTMLを生成します。

サーバーサイドレンダリングの問題点

多くの人はNuxtをWebサーバー上で実行しています。これは「サーバーサイドレンダリング」と呼ばれます。ユーザーがサーバーにページをリクエストすると、Nuxtがサーバー側でページをその場で構築してからユーザーのブラウザに送信します。

サーバーサイドレンダリングは、サーバーがブラウザの処理の一部を肩代わりするため、アプリの初期ページ読み込みを高速化します。しかし、ソーシャルシェアやSEOのためにいくつかのHTMLタグを埋めたいだけなら、技術スタックにNuxtとNode.jsサーバー全体を追加するのは大げさです。

SPAの最大の強みの1つは、単なる静的なHTML、CSS、JavaScriptで構成されているため、アプリケーションサーバーをまったく必要としないことです。Google Cloud StorageやAmazon S3のようなシンプルなファイルホスティングでも、通常のSPAをホストできます。サーバーサイドレンダリングを使う場合は、静的ファイルホスティングから本格的なアプリサーバーへ移行する必要があり、コストも複雑さも増します。

幸い、サーバーサイドレンダリングには代替手段があります。それがプリレンダリングです。HTTPリクエストに応じてページをオンデマンドでレンダリングするのではなく、Nuxtがサイト内のすべてのページを事前にレンダリングします。この処理で静的ファイルが生成されるため、通常のSPAをホストできる場所ならどこでもアプリをホストできます。

プリレンダリングを使うべきか

プリレンダリングがすべての状況に適しているわけではありません。アプリに何が必要か、プリレンダリングが必要なのか、サーバーサイドレンダリングが必要なのか、それとも素のVueのままでよいのかを判断する必要があります。以下では、プリレンダリングを採用するかどうかを決める際の参考になるよう、メリットとデメリットをまとめました。

プリレンダリングのメリット

  • サイト内のページごとに異なるソーシャルシェア用カードを設定できます
  • ページの読み込み時間が改善します
    • 通常のSPAでは、ブラウザがJavaScriptをダウンロードして実行するまでページのレンダリングが始まりません。プリレンダリングでは、ブラウザがJavaScriptを実行する前にユーザーにページが表示されます。

プリレンダリングのデメリット

  • 通常のVueより複雑になります
    • NodeサーバーでNuxtを動かすよりは複雑ではありませんが、完全にクライアントサイドで動作するVueアプリよりは複雑です。
    • プリレンダリングでは、コードがサーバー側で実行されるのかクライアント側で実行されるのか、実行時にどのようなコンテキストが利用できるのかを常に意識する必要があります。
  • ユーザー生成ページに対応できません
    • プリレンダリングでは、ビルド時にすべてのページルートを把握しておく必要があります。明示的に動的ルートはサポートされていません
    • たとえばユーザー生成コンテンツを扱うサイトで、参加後のユーザーに独自のURL(例:yoursite.com/users/michael123)を持たせたい場合、プリレンダリングでは実現できません。
    • ルートの一部をURLクエリに含める(例:yoursite.com/users?id=michael123)ことで回避し、動的データをクライアント側で取得する方法もありますが、それらのページごとに異なるソーシャルシェア用タグを生成することはできません。

プリレンダリングされた「Hello, world」

プリレンダリングを実演するために、わずか3つのファイルで構成された基本的なプリレンダリング済み「Hello, world!」アプリを紹介します。

唯一の前提条件はNode.jsです。執筆時点の最新の安定版であるNode v12.13.1を使用しました。

pages/index.vue

1つ目のファイルはWebアプリのページを定義します。pages/フォルダはNuxtにとって特別な意味を持ちます。Nuxtはpages/フォルダ内で見つかった.vueファイルごとに個別のページをプリレンダリングします。index.vueという名前はこれがルートページであることを示し、ユーザーがパスを指定しなかった場合に表示されるページです。

index.vueは、ウェルカムメッセージとボタンを表示するシンプルな「Hello, world!」ページを生成します。Vueのクライアントサイド機能を示すために、ボタンをクリックするたびにテキストが更新されるようにしています。

<template>
  <div>
    <h1>Hello, world!</h1>
    <p>I'm an example of a pre-rendered Vue webpage.</p>
    <button v-on:click="count++">I have been clicked {{ count }} times</button>
  </div>
</template>

<script>
  export default {
    data: function () {
      return {
        count: 0,
      };
    },
  };
</script>

package.json

package.jsonファイルは、このアプリをどのようにビルドするかをNode.jsに伝えます。

{
  "name": "hello-world-vue-pre-rendered",
  "dependencies": {
    "nuxt": "latest"
  },
  "scripts": {
    "dev": "nuxt --port 3600",
    "generate": "nuxt generate"
  }
}

nuxt.config.js

最後に、たとえ空でもNuxtには設定ファイルが必要です。

// Even though we have no Nuxt settings, this file is required.

「Hello, world」を実行する

このアプリはCodesandbox上で実行できます。

あるいは、次のコマンドでローカルマシン上でアプリを実行することもできます。

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered
git checkout step-1

npm install
npm run dev

アプリはhttp://localhost:3600で実行されます。

アプリをプリレンダリングする

npm run devを実行すると、サーバーサイドレンダリングが使われます。Nodeがローカルの開発サーバーを起動し、リクエストに応じてページをオンデマンドで生成します。

しかし、私がお約束したのはプリレンダリングされたページです。プリレンダリングされたページは静的ファイルの集合なので、Webサーバーすら必要ありません。

アプリをプリレンダリングするには、次のコマンドを実行します。

npm run generate

dist/フォルダを確認すると、Nuxtがページをプリレンダリングしていることがわかります。

$ find ./dist/ -type f
./dist/.nojekyll
./dist/200.html
./dist/index.html
./dist/_nuxt/7cef7880379068a94897.js
./dist/_nuxt/b10d0692e6306468ee9f.js
./dist/_nuxt/cae55ee8b1125819f113.js
./dist/_nuxt/ee10340617a3beab9da2.js
./dist/_nuxt/LICENSES

これらはPython2のSimpleHTTPServerのようなシンプルなHTTPサーバーで確認できます。

cd dist
python -m SimpleHTTPServer 8123

PythonがWebサーバーを起動し、http://localhost:8123でプリレンダリングされたアプリを確認できるようになります。後ほど、このアプリを静的ファイルホスティングサービスに公開する方法を紹介します。

Aboutページを追加する

より興味深くするために、このアプリに2つ目のページを追加します。

pages/about.vue

このページではVueのフックを使って、ページがどのようにレンダリングされたかについての情報を表示します。コードの詳細は後述します。

<template>
  <div>
    <h1>About this Build</h1>
    <p v-if="buildTime">
      Nuxt pre-rendered this page at
      <b>{{ buildTime }}</b> (before the browser ever saw it).
    </p>
    <template v-else>
      <p>
        Vue generated this page client-side because you navigated here from
        another route on the same site.
      </p>
      <p>
        <a href="/about">Refresh the page</a> to see the pre-rendered version.
      </p>
    </template>
    <p>
      The browser loaded this page at
      <b>{{ loadTime }}</b>.
    </p>
    <p><nuxt-link to="/">Home</nuxt-link></p>
  </div>
</template>

<script>
  export default {
    asyncData() {
      // Don't re-evaluate buildTime when the client loads this page in the
      // browser.
      if (!process.client) {
        return {
          buildTime: new Date().toUTCString(),
        };
      }
    },
    // Vue evaluates data variables at page render time and again every time the
    // browser loads this page.
    data: function () {
      return {
        loadTime: new Date().toUTCString(),
      };
    },
  };
</script>

Aboutページのライブ版はこちらです。

Aboutページの2つのバージョンを理解する

Aboutページは、NuxtとVueがどのように連携してプリレンダリングされたページを作成するかを示しています。サイト内の移動方法によって、2つの異なるバージョンのページが表示されます。

Aboutページの異なるバージョンのスクリーンショット

Aboutページは、どのようにページにたどり着いたかによって異なる情報を表示します。

/aboutページから直接アクセスした場合は左側のバージョンが表示されます。ルートページから開始して「about page」リンクをクリックした場合は右側のバージョンが表示されます。

なぜ2つの異なるバージョンが表示されるのでしょうか。答えはasyncDataフックにあります。この関数は2つのタイミングで実行されます。

  1. (サーバー側)Nuxtがページをプリレンダリングするとき
  2. (クライアント側)ブラウザがサイト内の別の場所からこのページに遷移したとき

定義を再掲します。

asyncData() {
  // Don't re-evaluate buildTime when the client loads this page in the
  // browser.
  if (!process.client) {
    return {
      buildTime: new Date().toUTCString(),
    };
  }
},

Nuxtがサイトをプリレンダリングする際、サーバーがasyncDataメソッドを実行します。サーバー環境ではprocess.clientはnullなので、buildTimeを現在時刻に設定し、その変数を使ってページのHTMLをプリレンダリングします。

サイト内の別のページから/aboutパスに遷移すると、ページ読み込み時にブラウザがasyncDataメソッドを実行します。コードがクライアント側で実行されているためprocess.clientはnullではなく、メソッドはbuildTimeを定義せず、VueはbuildTimeが未定義の場合のテンプレートをレンダリングします。

<p v-if="buildTime">...</p>
<template v-else>
  <p>
    Vue generated this page client-side because you navigated here from another
    route on the same site.
  </p>
  <p><a href="/about">Refresh the page</a> to see the pre-rendered version.</p>
</template>

プリレンダリングされるのは最初のページだけです

Aboutページは、プリレンダリングの微妙な点の1つを示しています。Nuxtがプリレンダリングするのは、ユーザーが最初に訪れた1ページ目だけです。その後はVueが通常のSPAとして振る舞い、ユーザーがサイト内を移動するたびにクライアント側でページを再描画します。これは良いことです。サーバーサイドレンダリングを必要とするサービスとの互換性を犠牲にすることなく、Vueならではの瞬時のページ遷移を維持できるからです。

Aboutページをローカルで実行する

Aboutページを試すには、次のコマンドを実行してください。

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered

npm install
npm run dev

https://localhost:3600/aboutに移動すると、ビルド時刻と読み込み時刻がほぼ一致することに気づくでしょう。npm run devを実行すると、Nuxtがサーバーサイドレンダリングを使ってページをジャストインタイムで生成するためです。

サーバーサイドレンダリングでレンダリングされたAboutページのスクリーンショット

npm run devはユーザーのリクエストに応じてページをレンダリングするため、ビルド時刻と読み込み時刻が一致します。

ページを一度生成して同じページを提供し続けるプリレンダリングとは異なり、サーバーサイドレンダリングではユーザーが訪れるたびに新しいバージョンのページが生成されます。

アプリを公開する

プリレンダリングを使えば、アプリをホストするためにNode.jsサーバーは必要ありません。必要なのは静的ファイルホスティングに対応したホスティングサービスだけです。

以下に、人気のあるプロバイダで静的ファイルを公開する手順を紹介します。

ソースコード

このサンプルのすべてのコードは、MITライセンスのもとGitHubで公開されています。

より高機能なサンプル

VueとNuxtで実際のアプリを構築するなら、プリレンダリングされた2ページだけでは物足りないでしょう。SEOやソーシャルシェアに必要なボイラープレートをすべて含むテンプレートプロジェクトpre-vueを作成しました。

以下の機能を備えています。

  • robots.txtファイルを生成します
  • サイトマップを生成します
  • ページごとに異なる<title>タグや、その他SEOに関連する<meta>タグに対応しています
  • 各ページに固有のOpen Graphタグを追加します
  • Google Analyticsに対応しています
  • ファビコンを追加します
  • 404エラーを処理します

以前はAngularのSPAだったZestfulのデモサイトを、このpre-vueテンプレートを使って書き直しました。pre-vueの使い方はREADMEで解説していますが、需要があれば詳細を解説するブログ記事も公開する予定です。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。