A Simple Pre-Rendered Web App Using Vue + Nuxt

Michael Lynch

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

原文は Michael Lynch により に公開されました。 このブログを購読する

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

このチュートリアルは、VueやNuxtの経験がなくても大丈夫なように作られています。必要なことはすべて順を追って説明します。

Vueの問題点

AngularやReactと同様に、Vueはシングルページアプリケーション(SPA)を構築するためのフレームワークです。従来のウェブサイトでは、ユーザーがサイト内のリンクをクリックするたびにブラウザがページ全体を再ダウンロードしなければなりませんが、SPAではすべてが単一のページに収まっています。ユーザーがサイト内を移動する際、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

最初のファイルは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

これらはPython 2の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がプリレンダリングするのは、ユーザーが最初に訪れたページだけです。その後は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のサポートを追加する
  • faviconを追加する
  • 404エラーを処理する

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

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

コメント