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 のメリット
-
初期表示速度の向上
- JavaScript の実行を待たずにコンテンツが表示される
- ユーザー体験の大幅な改善
-
SEO 対策
- 検索エンジンがコンテンツを正しく認識できる
- SNS でのシェア時に適切なプレビューが表示される
-
アクセシビリティの向上
- JavaScript が無効な環境でもコンテンツが表示される
- スクリーンリーダーなどの支援技術との相性が良い
Vue.js で SSR が重要な理由
Vue.js は、その軽量さと学習曲線の緩やかさから、多くの開発者に愛されています。しかし、SPA として実装すると、上記のような課題に直面することになります。
特に、以下のようなケースでは SSR が必須となります:
- ブログやニュースサイトなど、SEO が重要なコンテンツサイト
- 初期表示速度がユーザー離脱に直結する EC サイト
- モバイル環境での利用が多いアプリケーション
Vue.js SSR の基本概念
Vue.js で SSR を実装する際の基本概念について理解しましょう。
レンダリングプロセス
SSR のレンダリングプロセスは以下のようになります:
-
サーバーサイドレンダリング
- サーバーで Vue アプリケーションを実行
- コンポーネントツリーを HTML に変換
- 完全な HTML をクライアントに送信
-
クライアントサイドハイドレーション
- ブラウザで 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 では、asyncData
やfetch
フックを使ってサーバーサイドでデータを取得できます。
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 実装は、初めは複雑に感じるかもしれませんが、適切なツールとテクニックを使うことで、効率的に実装できます。
この記事で紹介した内容をまとめると:
- SSR の重要性: 初期表示速度と SEO 対策において不可欠
- Nuxt.js の活用: 初心者でも安心して SSR を始められる
- 手動実装: より細かい制御が必要な場合の選択肢
- 状態管理: サーバーサイドとクライアントサイドの同期
- パフォーマンス最適化: ユーザー体験の向上
- 問題解決: 実装時のトラブルシューティング
SSR を実装することで、ユーザーに素晴らしい体験を提供し、検索エンジンでの露出も向上させることができます。最初は小さなプロジェクトから始めて、徐々にスキルを磨いていくことをお勧めします。
技術の進歩は日々加速しており、SSR はもはやオプションではなく、モダンな Web アプリケーションの標準となっています。この記事が、あなたの Vue.js SSR 実装の一助となれば幸いです。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来