T-CREATOR

Vue.js で実践する SSR(サーバーサイドレンダリング)

Vue.js で実践する SSR(サーバーサイドレンダリング)

モダンな Web アプリケーション開発において、ユーザー体験の向上は最重要課題の一つです。特に、初期表示速度と SEO 対策は、アプリケーションの成功を左右する重要な要素となっています。

従来の SPA(Single Page Application)では、JavaScript が実行されるまでコンテンツが表示されないため、ユーザーは白い画面を見つめる時間を強いられていました。また、検索エンジンが JavaScript を実行できない環境では、コンテンツが正しく認識されないという問題も発生していました。

これらの課題を解決するのが、SSR(Server-Side Rendering)です。Vue.js で SSR を実装することで、サーバー側で HTML を生成し、ユーザーに即座にコンテンツを提供できるようになります。

この記事では、Vue.js での SSR 実装について、基礎から実践まで詳しく解説していきます。初心者の方でも理解しやすいように、段階的に説明し、実際のエラーとその解決策も含めて紹介します。

SSR とは?Vue.js での重要性

SSR(Server-Side Rendering)とは、サーバー側で HTML を生成してクライアントに送信する技術です。従来の SPA とは異なり、ブラウザが JavaScript を実行する前に、すでに完全な HTML が表示されるため、初期表示速度が大幅に向上します。

SPA vs SSR の違い

まず、従来の SPA と SSR の違いを理解しましょう。

SPA(Client-Side Rendering)の場合:

html<!-- 初期HTML(空の状態) -->
<!DOCTYPE html>
<html>
  <head>
    <title>Vue App</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="/js/app.js"></script>
  </body>
</html>

この場合、JavaScript が実行されるまで<div id="app">の中身は空のままです。ユーザーは白い画面を見ることになります。

SSR(Server-Side Rendering)の場合:

html<!-- サーバーで生成された完全なHTML -->
<!DOCTYPE html>
<html>
  <head>
    <title>Vue App</title>
  </head>
  <body>
    <div id="app">
      <h1>ようこそ!</h1>
      <p>このコンテンツはサーバーで生成されました</p>
      <nav>
        <a href="/">ホーム</a>
        <a href="/about">会社概要</a>
      </nav>
    </div>
    <script src="/js/app.js"></script>
  </body>
</html>

SSR では、サーバー側で Vue コンポーネントをレンダリングし、完全な HTML を生成してからクライアントに送信します。

SSR のメリット

  1. 初期表示速度の向上

    • JavaScript の実行を待たずにコンテンツが表示される
    • ユーザー体験の大幅な改善
  2. SEO 対策

    • 検索エンジンがコンテンツを正しく認識できる
    • SNS でのシェア時に適切なプレビューが表示される
  3. アクセシビリティの向上

    • JavaScript が無効な環境でもコンテンツが表示される
    • スクリーンリーダーなどの支援技術との相性が良い

Vue.js で SSR が重要な理由

Vue.js は、その軽量さと学習曲線の緩やかさから、多くの開発者に愛されています。しかし、SPA として実装すると、上記のような課題に直面することになります。

特に、以下のようなケースでは SSR が必須となります:

  • ブログやニュースサイトなど、SEO が重要なコンテンツサイト
  • 初期表示速度がユーザー離脱に直結する EC サイト
  • モバイル環境での利用が多いアプリケーション

Vue.js SSR の基本概念

Vue.js で SSR を実装する際の基本概念について理解しましょう。

レンダリングプロセス

SSR のレンダリングプロセスは以下のようになります:

  1. サーバーサイドレンダリング

    • サーバーで Vue アプリケーションを実行
    • コンポーネントツリーを HTML に変換
    • 完全な HTML をクライアントに送信
  2. クライアントサイドハイドレーション

    • ブラウザで Vue アプリケーションを再実行
    • 既存の HTML と Vue の仮想 DOM を同期
    • インタラクティブな機能を有効化

基本的な SSR セットアップ

まず、必要なパッケージをインストールします:

bashyarn add vue vue-server-renderer express
yarn add -D @vue/cli-service @vue/cli-plugin-babel

サーバーサイドエントリーポイント

SSR では、サーバー用とクライアント用で異なるエントリーポイントが必要です。

src/entry-server.js(サーバー用):

javascriptimport { createApp } from './main';

// サーバーサイドレンダリング用の関数
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) {
        return reject({ code: 404 });
      }

      // 各コンポーネントでasyncDataを実行
      Promise.all(
        matchedComponents.map((Component) => {
          if (Component.asyncData) {
            return Component.asyncData({
              store,
              route: router.currentRoute,
            });
          }
        })
      )
        .then(() => {
          // ストアの状態をコンテキストに注入
          context.state = store.state;
          resolve(app);
        })
        .catch(reject);
    }, reject);
  });
};

src/entry-client.js(クライアント用):

javascriptimport { createApp } from './main';

const { app, router, store } = createApp();

// サーバーサイドの状態を復元
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

// ルーターが準備できたらアプリをマウント
router.onReady(() => {
  app.$mount('#app');
});

メインアプリケーションファイル

src/main.js:

javascriptimport Vue from 'vue';
import App from './App.vue';
import { createRouter } from './router';
import { createStore } from './store';

// アプリケーションのファクトリ関数
export function createApp() {
  const router = createRouter();
  const store = createStore();

  const app = new Vue({
    router,
    store,
    render: (h) => h(App),
  });

  return { app, router, store };
}

Nuxt.js を使った SSR 実装

Nuxt.js は、Vue.js の SSR を簡単に実装できるフレームワークです。多くの設定が自動化されており、初心者でも安心して SSR を始められます。

Nuxt.js のセットアップ

まず、新しい Nuxt.js プロジェクトを作成します:

bashyarn create nuxt-app my-ssr-app
cd my-ssr-app

プロジェクト作成時に以下の選択肢が表示されます:

bash? Project name: my-ssr-app
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: Tailwind CSS
? Nuxt.js modules: Axios
? Linting tools: ESLint
? Testing framework: Jest
? Rendering mode: Universal (SSR)
? Deployment target: Server
? Development tools: jsconfig.json
? Version control system: Git

重要なのは「Rendering mode」で「Universal (SSR)」を選択することです。

基本的なページ構造

Nuxt.js では、pagesディレクトリ内のファイルが自動的にルートになります。

pages/index.vue:

vue<template>
  <div class="container mx-auto px-4">
    <h1 class="text-3xl font-bold mb-4">
      {{ title }}
    </h1>
    <p class="text-gray-600">
      {{ description }}
    </p>
    <div class="mt-6">
      <NuxtLink
        to="/about"
        class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
      >
        会社概要へ
      </NuxtLink>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      title: 'ようこそ!',
      description:
        'これはNuxt.jsで作成されたSSRアプリケーションです。',
    };
  },
};
</script>

サーバーサイドデータフェッチング

Nuxt.js では、asyncDatafetchフックを使ってサーバーサイドでデータを取得できます。

pages/posts.vue:

vue<template>
  <div class="container mx-auto px-4">
    <h1 class="text-2xl font-bold mb-6">ブログ記事一覧</h1>
    <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
      <article
        v-for="post in posts"
        :key="post.id"
        class="border rounded-lg p-4 hover:shadow-lg transition-shadow"
      >
        <h2 class="text-xl font-semibold mb-2">
          {{ post.title }}
        </h2>
        <p class="text-gray-600 mb-3">{{ post.excerpt }}</p>
        <span class="text-sm text-gray-500">{{
          post.date
        }}</span>
      </article>
    </div>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios }) {
    try {
      // サーバーサイドでデータを取得
      const posts = await $axios.$get('/api/posts');
      return { posts };
    } catch (error) {
      console.error('記事の取得に失敗しました:', error);
      return { posts: [] };
    }
  },
};
</script>

よくあるエラーと解決策

エラー 1: asyncData is not a function

javascript// 間違い
export default {
  asyncData() {
    // ...
  }
}

// 正しい
export default {
  async asyncData({ $axios }) {
    // ...
  }
}

エラー 2: Cannot read property 'state' of undefined

javascript// 間違い
export default {
  asyncData({ store }) {
    return store.dispatch('fetchPosts')
  }
}

// 正しい
export default {
  async asyncData({ store }) {
    await store.dispatch('fetchPosts')
    return {}
  }
}

手動 SSR の実装方法

Nuxt.js を使わずに、手動で SSR を実装する方法を紹介します。より細かい制御が可能ですが、設定が複雑になります。

Express.js サーバーの設定

server.js:

javascriptconst express = require('express');
const fs = require('fs');
const {
  createBundleRenderer,
} = require('vue-server-renderer');
const path = require('path');

const app = express();

// 静的ファイルの配信
app.use(
  '/dist',
  express.static(path.join(__dirname, './dist'))
);

// バンドルファイルの読み込み
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const clientManifest = require('./dist/vue-ssr-client-manifest.json');

// レンダラーの作成
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: fs.readFileSync(
    './public/index.template.html',
    'utf-8'
  ),
  clientManifest,
});

// SSR ルートの処理
app.get('*', (req, res) => {
  const context = {
    url: req.url,
    title: 'Vue SSR App',
  };

  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (err.code === 404) {
        res.status(404).end('Page not found');
      } else {
        res.status(500).end('Internal Server Error');
      }
      return;
    }
    res.end(html);
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Webpack 設定

webpack.server.config.js:

javascriptconst { VueLoaderPlugin } = require('vue-loader');
const nodeExternals = require('webpack-node-externals');
const path = require('path');

module.exports = {
  target: 'node',
  entry: './src/entry-server.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2',
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [new VueLoaderPlugin()],
  externals: nodeExternals({
    allowlist: /\.css$/,
  }),
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
};

よくあるエラーと解決策

エラー 1: Cannot find module 'vue-server-renderer'

bash# 解決策
yarn add vue-server-renderer

エラー 2: ReferenceError: window is not defined

javascript// 間違い
const app = new Vue({
  // window オブジェクトを直接参照
});

// 正しい
const app = new Vue({
  // サーバーサイドでは window が存在しないため、
  // 条件分岐で処理を分ける
  mounted() {
    if (typeof window !== 'undefined') {
      // クライアントサイドのみの処理
    }
  },
});

エラー 3: Hydration node mismatch

vue<!-- 間違い -->
<template>
  <div>
    <span v-if="isClient">クライアントのみ</span>
  </div>
</template>

<!-- 正しい -->
<template>
  <div>
    <ClientOnly>
      <span>クライアントのみ</span>
    </ClientOnly>
  </div>
</template>

SSR での状態管理

SSR では、サーバーサイドとクライアントサイドで状態を同期する必要があります。Vuex を使った状態管理の実装方法を紹介します。

Vuex ストアの設定

src/store/index.js:

javascriptimport Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export function createStore() {
  return new Vuex.Store({
    state: {
      posts: [],
      loading: false,
      error: null,
    },
    mutations: {
      SET_POSTS(state, posts) {
        state.posts = posts;
      },
      SET_LOADING(state, loading) {
        state.loading = loading;
      },
      SET_ERROR(state, error) {
        state.error = error;
      },
    },
    actions: {
      async fetchPosts({ commit }) {
        commit('SET_LOADING', true);
        commit('SET_ERROR', null);

        try {
          // 実際のAPIエンドポイントに置き換えてください
          const response = await fetch('/api/posts');
          const posts = await response.json();
          commit('SET_POSTS', posts);
        } catch (error) {
          commit('SET_ERROR', error.message);
        } finally {
          commit('SET_LOADING', false);
        }
      },
    },
    getters: {
      getPostById: (state) => (id) => {
        return state.posts.find((post) => post.id === id);
      },
    },
  });
}

サーバーサイドでの状態復元

src/entry-server.js(更新):

javascriptimport { createApp } from './main';

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) {
        return reject({ code: 404 });
      }

      // 各コンポーネントでasyncDataを実行
      Promise.all(
        matchedComponents.map((Component) => {
          if (Component.asyncData) {
            return Component.asyncData({
              store,
              route: router.currentRoute,
            });
          }
        })
      )
        .then(() => {
          // ストアの状態をコンテキストに注入
          context.state = store.state;
          resolve(app);
        })
        .catch(reject);
    }, reject);
  });
};

クライアントサイドでの状態復元

src/entry-client.js(更新):

javascriptimport { createApp } from './main';

const { app, router, store } = createApp();

// サーバーサイドの状態を復元
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
  app.$mount('#app');
});

コンポーネントでの状態管理

src/components/PostList.vue:

vue<template>
  <div class="post-list">
    <div v-if="loading" class="loading">読み込み中...</div>

    <div v-else-if="error" class="error">
      {{ error }}
    </div>

    <div v-else class="posts">
      <article
        v-for="post in posts"
        :key="post.id"
        class="post-item"
      >
        <h2>{{ post.title }}</h2>
        <p>{{ post.excerpt }}</p>
      </article>
    </div>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  computed: {
    ...mapState(['posts', 'loading', 'error']),
  },
  methods: {
    ...mapActions(['fetchPosts']),
  },
  async serverPrefetch() {
    // サーバーサイドでのデータ取得
    await this.fetchPosts();
  },
  mounted() {
    // クライアントサイドでデータがない場合のみ取得
    if (this.posts.length === 0) {
      this.fetchPosts();
    }
  },
};
</script>

パフォーマンス最適化

SSR のパフォーマンスを最適化するためのテクニックを紹介します。

コード分割(Code Splitting)

router/index.js:

javascriptimport Vue from 'vue';
import Router from 'vue-router';

Vue.use(Router);

export function createRouter() {
  return new Router({
    mode: 'history',
    routes: [
      {
        path: '/',
        name: 'Home',
        component: () => import('@/pages/Home.vue'),
      },
      {
        path: '/posts',
        name: 'Posts',
        component: () => import('@/pages/Posts.vue'),
      },
      {
        path: '/posts/:id',
        name: 'PostDetail',
        component: () => import('@/pages/PostDetail.vue'),
      },
    ],
  });
}

プリフェッチング

nuxt.config.js:

javascriptexport default {
  // プリフェッチングの設定
  render: {
    bundleRenderer: {
      shouldPreload: (file, type) => {
        // CSSファイルとJavaScriptファイルをプリロード
        return ['script', 'style'].includes(type);
      },
    },
  },

  // ページのプリフェッチ設定
  router: {
    prefetchLinks: true,
  },
};

キャッシュ戦略

server.js(キャッシュ追加):

javascriptconst LRU = require('lru-cache');

// レンダリング結果のキャッシュ
const microCache = LRU({
  max: 100,
  maxAge: 1000 * 60 * 10, // 10分
});

app.get('*', (req, res) => {
  const cacheKey = req.url;

  // キャッシュから取得を試行
  if (microCache.has(cacheKey)) {
    res.send(microCache.get(cacheKey));
    return;
  }

  const context = {
    url: req.url,
    title: 'Vue SSR App',
  };

  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (err.code === 404) {
        res.status(404).end('Page not found');
      } else {
        res.status(500).end('Internal Server Error');
      }
      return;
    }

    // 結果をキャッシュに保存
    microCache.set(cacheKey, html);
    res.end(html);
  });
});

よくあるパフォーマンス問題

問題 1: メモリリーク

javascript// 間違い
const app = new Vue({
  // グローバルなイベントリスナーを追加
  mounted() {
    window.addEventListener('resize', this.handleResize);
  },
});

// 正しい
const app = new Vue({
  mounted() {
    window.addEventListener('resize', this.handleResize);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.handleResize);
  },
});

問題 2: 不要な再レンダリング

vue<!-- 間違い -->
<template>
  <div>
    <ExpensiveComponent :data="expensiveData" />
  </div>
</template>

<!-- 正しい -->
<template>
  <div>
    <ExpensiveComponent
      :data="expensiveData"
      :key="dataKey"
    />
  </div>
</template>

<script>
export default {
  computed: {
    dataKey() {
      // データが変更された時のみキーを更新
      return JSON.stringify(this.expensiveData);
    },
  },
};
</script>

よくある問題と解決策

SSR 実装時に発生しがちな問題とその解決策を紹介します。

問題 1: ハイドレーションエラー

エラー: The client-side rendered virtual DOM tree is not matching server-rendered content

このエラーは、サーバーサイドとクライアントサイドで異なる HTML が生成された時に発生します。

解決策:

vue<template>
  <div>
    <!-- サーバーサイドとクライアントサイドで異なる可能性がある処理 -->
    <ClientOnly>
      <div>クライアントのみのコンテンツ</div>
    </ClientOnly>

    <!-- 条件付きレンダリングを避ける -->
    <div v-if="isReady">
      {{ dynamicContent }}
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isReady: false,
      dynamicContent: '',
    };
  },
  mounted() {
    // クライアントサイドでのみ実行
    this.isReady = true;
    this.dynamicContent = '動的コンテンツ';
  },
};
</script>

問題 2: メタタグの管理

エラー: SEO 対策でメタタグが正しく設定されない

解決策(Nuxt.js):

vue<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ description }}</p>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios }) {
    const post = await $axios.$get(
      `/api/posts/${this.$route.params.id}`
    );
    return { post };
  },
  head() {
    return {
      title: this.post.title,
      meta: [
        {
          hid: 'description',
          name: 'description',
          content: this.post.excerpt,
        },
        {
          hid: 'og:title',
          property: 'og:title',
          content: this.post.title,
        },
        {
          hid: 'og:description',
          property: 'og:description',
          content: this.post.excerpt,
        },
      ],
    };
  },
};
</script>

問題 3: API エラーハンドリング

エラー: API 呼び出しでエラーが発生した時の処理

解決策:

javascript// plugins/axios.js
export default function ({ $axios, redirect, store }) {
  $axios.onError((error) => {
    const code = parseInt(
      error.response && error.response.status
    );

    if (code === 400) {
      redirect('/400');
    }

    if (code === 500) {
      redirect('/500');
    }
  });
}

問題 4: 環境変数の管理

エラー: サーバーサイドとクライアントサイドで環境変数が異なる

解決策:

javascript// nuxt.config.js
export default {
  publicRuntimeConfig: {
    // クライアントサイドでも利用可能
    apiBaseUrl:
      process.env.API_BASE_URL || 'http://localhost:3000',
  },
  privateRuntimeConfig: {
    // サーバーサイドのみ
    apiSecret: process.env.API_SECRET,
  },
};
vue<template>
  <div>
    <p>API Base URL: {{ $config.apiBaseUrl }}</p>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios, $config }) {
    // サーバーサイドでのみ実行される処理
    const data = await $axios.$get(
      `${$config.apiBaseUrl}/api/data`
    );
    return { data };
  },
};
</script>

問題 5: 静的ファイルの配信

エラー: 画像や CSS ファイルが正しく配信されない

解決策:

javascript// server.js
const express = require('express');
const path = require('path');

const app = express();

// 静的ファイルの配信設定
app.use(
  '/static',
  express.static(path.join(__dirname, 'dist/static'))
);
app.use(
  '/images',
  express.static(path.join(__dirname, 'public/images'))
);
app.use(
  '/css',
  express.static(path.join(__dirname, 'dist/css'))
);
app.use(
  '/js',
  express.static(path.join(__dirname, 'dist/js'))
);

まとめ

Vue.js での SSR 実装は、初めは複雑に感じるかもしれませんが、適切なツールとテクニックを使うことで、効率的に実装できます。

この記事で紹介した内容をまとめると:

  1. SSR の重要性: 初期表示速度と SEO 対策において不可欠
  2. Nuxt.js の活用: 初心者でも安心して SSR を始められる
  3. 手動実装: より細かい制御が必要な場合の選択肢
  4. 状態管理: サーバーサイドとクライアントサイドの同期
  5. パフォーマンス最適化: ユーザー体験の向上
  6. 問題解決: 実装時のトラブルシューティング

SSR を実装することで、ユーザーに素晴らしい体験を提供し、検索エンジンでの露出も向上させることができます。最初は小さなプロジェクトから始めて、徐々にスキルを磨いていくことをお勧めします。

技術の進歩は日々加速しており、SSR はもはやオプションではなく、モダンな Web アプリケーションの標準となっています。この記事が、あなたの Vue.js SSR 実装の一助となれば幸いです。

関連リンク