すごくカンタンなSSR with Vue.js - 実践3. SSR

「御託はいいからやり方だけ教えてくれ」というあなたは　→　[実践 - Vue.jsでSSR](https://github.com/nkzttt/vue-ssr-test/blob/master/guide/ja.md)

# この記事は

別途私が書いている[実践 - Vue.jsでSSR](https://github.com/nkzttt/vue-ssr-test/blob/master/guide/ja.md)に、割とどうでもいい私の感情を交えながら、いくつかのパートに分けて書いていく実践形式のSSR with Vue.js説明記事です。
実際の開発フローに沿って、なるべく予備知識がなくても読み進められるように心がけています。

[実践1. ビルド環境](https://qiita.com/bgn_nakazato/items/e57a7a293c3bb23de5e6)
[実践2. SPA](https://qiita.com/bgn_nakazato/items/bcf64f56900d699508b0)
実践3. SSR　←　当記事

# 誰に役立つ？

私のような

- フロントエンジニアなりにSSRの実際のやり方を知りたくてググったのに概念的なものしか見つけられない~~検索能力の低い~~人
- 意を決して[サーバサイドレンダリング — Vue.js](https://jp.vuejs.org/v2/guide/ssr.html)を通してやったのに、途中から「あれ、なんか違う」ってなって結局出来なかった~~理解力の低い~~人
- 概要をザクッと知ってあとは自分流にやっていきたい人

# ゴール

**SSR環境を構築でき、拡張することができる**

- 極シンプルなSSR環境を構築します
- ジェネレータ的なものは使わず、全体の概要を理解しながら進めていきます

# 実践3. SSR

サーバーとSSRを実装していきます。

## サーバー

Node.jsとフレームワーク`Express`を使用してサーバーを実装していきます。

必要パッケージをインストールします。

```bash
npm i -S express
```

`app.js`を作成し、サーバーのコードを書いていきます。

**app.js**

```js
const express = require('express');

const app = express();
app.use('/', express.static('public'));
app.use('/dist', express.static('dist'));

app.listen(8080);
```

これにより`localhost:8080`にアクセスできるようになります。
また、ディレクトリ`public`に配置したファイルはパス`/{fileName}`で、ディレクトリ`dist`に配置したファイルはパス`/dist/{fileName}`でアクセスが可能になります。

では、ディレクトリ`public`を作成し`index.html`をそこに移動しましょう。
続いてスクリプトの読み込みパスをルートパスに変更します。

**public/index.html**

```html
<head>
  ...
  <script src="/dist/main.js" defer></script>
</head>
```

サーバーを実行しましょう。
サーバーの実行には`node`コマンドを使用します。

```bash
node app
```

`node`コマンドでは拡張子`.js`を省略できます。
上のコマンドは次のコマンドと同じ結果になります。

```bash
node ./app.js
```

`localhost:8080`にアクセスし、次のように表示されれば成功です。

![4.png](https://qiita-image-store.s3.amazonaws.com/0/100188/e5fc0f05-fb7a-14ab-650a-2a61b9604580.png)

サーバーの実行コマンドをnpm scriptsに登録しておきましょう。

現在起動中のサーバーを`ctrl` + `c`で終了します。
`package.json`の`scripts`に以下を追記します。

**package.json**

```js
{
  ...,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    
    // 以下を追加
    "start": "node app",
    
    "build:prod": "webpack -p",
    "build:dev": "webpack --mode=development"
  },
  ...
}
```

`start`は特別なscriptで、`npm start`で実行が可能です。
`ctrl` + `c`で終了します。

現在のディレクトリ構成は以下のようになっています。

```
root
|- /dist
  |- main.js
|- /node_modules
  |- /some_modules
|- /public
  |- index.html
|- /src
  |- /components
    |- Page.vue
    |- Top.vue
  |- App.vue
  |- index.js
  |- router.js
|- .babelrc
|- app.js
|- package.json
|- package-lock.json
|- webpack.config.js
```

## SSR

いよいよSSRの実装に入ります。

はじめに完成形をイメージしましょう。

現在は`vue-router`をモード`hash`に設定しています。
URLに`/#/`が付くのはこのためです。

モード`hash`では下層ページにも直接アクセスが可能です。
`npm start`でサーバーを立ち上げ、`localhost:8080/#/page`に直接アクセスしてみましょう。

![5.png](https://qiita-image-store.s3.amazonaws.com/0/100188/0460dc27-a7d1-0ad1-d7a8-2b5f9211fb63.png)

問題なく表示されます。

次にモード`history`に変更します。
`src/router.js`に設定を追加します。

**src/router.js**

```js
...
const router = new Router({
  mode: 'history',
  routes
});
```

ビルドし、サーバーを起動後、`localhost:8080`にアクセスしてみましょう（`/page`ではありません）。

![6.png](https://qiita-image-store.s3.amazonaws.com/0/100188/eb3fdbeb-6fd6-c9f3-b3eb-8a31c9a37528.png)

URLに`/#/`が付きません。

トップからアクセスした場合、下層ページへもアクセスが可能です。
リンクをクリックして動作を確認してみてください。

![7.png](https://qiita-image-store.s3.amazonaws.com/0/100188/1c180d9d-812d-64a5-b6f4-4326a52d8ea9.png)

では、`localhost:8080/page`に直接アクセスしてみましょう。

![8.png](https://qiita-image-store.s3.amazonaws.com/0/100188/f47c978e-6c1a-58e8-7a1f-9a73dea46025.png)

モード`hash`の時とは違い、エラーになります。

SSRを行うことでこれを正常に表示させ、またSPAのコンテンツ切り替えを可能にしていきます。

### パッケージインストール

必要パッケージをインストールします。

```bash
npm i -S vue-template-compiler
```

パッケージ`vue`と`vue-template-compiler`は同じバージョンである必要があります。
`package.json`でそれぞれのバージョンを確認しておきましょう。

```js
...,
  "dependencies": {
    ...,
    "vue": "^2.5.17",
    "vue-template-compiler": "^2.5.17",
    ...
```

### webpackの分割

SSRでは、クラアントとサーバーでそれぞれvueをコンパイルする必要があります。
そのために`webpack.config.js`を以下の３つに分割していきます。

- `webpack.base.config.js`
- `webpack.client.config.js`
- `webpack.server.config.js`

**webpack.base.config.js**

```js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

const config = {
  entry: path.join(__dirname, 'src/entry-client.js'),
  output: {
    filename: 'main.js',
    path: path.join(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
};

module.exports = config;
```

元の`webpack.config.js`を基準とするため、内容は同じです。
この後でエントリーファイルもクライアントとサーバー用に分割していくため、`entry`の設定が若干変わっています。
ソースマップの設定部分は次の`webpack.client.config.js`に移動します。

**webpack.client.config.js**

```js
const baseConfig = require('./webpack.base.config');

const config = { ...baseConfig };

module.exports = (env, argv) => {
  switch (argv.mode) {
    case 'production':
      // expand config for production
      break;
    case 'development':
    default:
      // expand config for development
      config.devtool = 'inline-source-map';
      break;
  }

  return config;
};
```

`webpack.base.config.js`の読み込みと、元の`webpack.config.js`にあったソースマップの設定を切り出したのみで、新たな記述はありません。

**webpack.server.config.js**

```js
const path = require('path');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const baseConfig = require('./webpack.base.config');

const config = {
  ...baseConfig,
  entry: path.join(__dirname, 'src/entry-server.js'),
  target: 'node',
  output: {
    ...baseConfig.output,
    libraryTarget: 'commonjs2'
  },
  plugins: [
    ...baseConfig.plugins,
    new VueSSRServerPlugin()
  ]
};

module.exports = config;
```

`webpack.base.config.js`の内容をサーバー用に上書きしています。
設定内容について詳しく知りたい場合は[Vue SSR ガイド：ビルド設定](https://ssr.vuejs.org/ja/guide/build-config.html#server-%E8%A8%AD%E5%AE%9A)を参照してください。

### エントリーファイルの分割

エントリーファイルをクライアントとサーバー用に分割していきます。
`src/index.js`を以下の３つに分割していきます。

- `src/entry-base.js`
- `src/entry-client.js`
- `src/entry-server.js`

また、この過程で`src/router.js`にも変更を加えます。

**src/entry-base.js**

```js
import Vue from 'vue';
import App from './App.vue';
import { createRouter } from './router';

const createApp = () => {
  const router = createRouter();

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

  return { app, router };
};

export { createApp };
```

単にVueインスタンスを返すのではなく、Vueインスタンスを生成するための関数を返しています。
これはサーバーではリクエストごとにVueインスタンスを生成する必要があるためです。

同様に`src/router.js`もRouterインスタンスを生成する関数を返すように変更します。

**src/router.js**

```js
import Vue from 'vue';
import Router from 'vue-router';
import Top from './components/Top.vue';
import Page from './components/Page.vue';

Vue.use(Router);

const routes = [
  { path: '/', component: Top },
  { path: '/page', component: Page }
];

const createRouter = () => {
  return new Router({
    mode: 'history',
    routes
  });
};

export { createRouter };
```

**src/entry-client.js**

```js
import { createApp } from './entry-base';

const { app } = createApp();
app.$mount('#app');
```

クライアントではVueインスタンスを生成し`#app`にマウントするのみです。

**src/entry-server.js**

```js
import { createApp } from './entry-base';

export default ctx => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();
    router.push(ctx.url);

    const matchedComponents = router.getMatchedComponents();
    if (!matchedComponents) return reject({ code: 404 });
    resolve(app);
  });
}
```

リクエストに対するルーティングを解決し、エラーまたはアプリケーションインスタンスを返します。

この設定は以下の公式ドキュメントを参考に、必要な部分のみを設定しています。

- [Vue SSR ガイド：ソースコードの構成](https://ssr.vuejs.org/ja/guide/structure.html#webpack%E3%81%AB%E3%82%88%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%89%E6%A7%8B%E9%80%A0)
- [Vue SSR ガイド：ルーティングとコード分割](https://ssr.vuejs.org/ja/guide/routing.html#vue-router-%E3%81%AB%E3%82%88%E3%82%8B%E3%83%AB%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0)

### ビルド

クライアント、サーバーそれぞれのビルドコマンドをnpm scriptsに登録しましょう。
`package.json`の`scripts`を変更します。

**package.json**

```js
{
  ...,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node app",
    
    // 以下を変更
    "build:prod": "webpack -p --config=webpack.client.config.js && webpack -p --config=webpack.server.config.js",
    "build:dev": "webpack --mode=development --config=webpack.client.config.js && webpack --mode=development --config=webpack.server.config.js"
  },
  ...
}
```

`webpack`コマンドに`--config`オプションで設定ファイルを指定しています。
`&&`で２つのコマンドを繋いでいるため、１度のscript実行で２つのビルドが行われます。

それではビルドを実行してみましょう。

```bash
npm run build:dev
```

ディレクトリ`dist`に以下の２つのファイルが生成されれば成功です。

- `main.js`
- `vue-ssr-server-bundle.json`

`vue-ssr-server-bundle.json`の`output`設定は行なっていませんが、プラグイン`new VueSSRServerPlugin()`によって生成されます。

現在のディレクトリ構成は以下のようになっています。

```
root
|- /dist
  |- main.js
  |- vue-ssr-server-bundle.json
|- /node_modules
  |- /some_modules
|- /public
  |- index.html
|- /src
  |- /components
    |- Page.vue
    |- Top.vue
  |- App.vue
  |- entry-base.js
  |- entry-client.js
  |- entry-server.js
  |- router.js
|- .babelrc
|- app.js
|- package.json
|- package-lock.json
|- webpack.base.config.js
|- webpack.client.config.js
|- webpack.server.config.js
```

### サーバー実装

サーバーでvueをレンダリングし、結果をhtmlとしてクライアントに返します。

`app.js`を変更していきます。

**app.js**

```js
const path = require('path');
const fs = require('fs');
const express = require('express');
const VueServerRenderer = require('vue-server-renderer');

const app = express();
app.use('/', express.static('public'));
app.use('/dist', express.static('dist'));

const template = fs.readFileSync(path.join(__dirname, 'src/index.template.html'), 'utf-8');
const renderer = VueServerRenderer.createBundleRenderer(path.join(__dirname, 'dist/vue-ssr-server-bundle.json'), { template });

app.get('*', (req, res) => {
  const ctx = { url: req.url };
  renderer.renderToString(ctx, (err, html) => {
    if (err) return res.status(500).end('Interval Server Error');
    res.end(html);
  });
});

app.listen(8080);
```

大きく分けて２つの追加を行なっています。

```js
const path = require('path');
const fs = require('fs');
const express = require('express');
const VueServerRenderer = require('vue-server-renderer');

...

const template = fs.readFileSync(path.join(__dirname, 'src/index.template.html'), 'utf-8');
const renderer = VueServerRenderer.createBundleRenderer(path.join(__dirname, 'dist/vue-ssr-server-bundle.json'), { template });
```

必要モジュールの読み込みと、レンダーに使うテンプレートファイル`src/index.template.html`の読み込み、さらにレンダラにサーバー用にビルドしたファイル`dist/vue-ssr-server-bundle.json`のパスを渡しています。
テンプレートファイル`src/index.template.html`はこの後で作っていきます。

```js
app.get('*', (req, res) => {
  const ctx = { url: req.url };
  renderer.renderToString(ctx, (err, html) => {
    if (err) return res.status(500).end('Interval Server Error');
    res.end(html);
  });
});
```

`Express`のルーティング設定です。
全てのパス（`*`）でルーティングを行い、対応するURL（`req.url`）でレンダリング、結果のhtmlをクライアントに返しています。

続いて`src/index.template.html`を作っていきます。
`public/index.html`をディレクトリ`src`に移動し、ファイル名を`index.template.html`に変更します。
さらに１箇所だけコードを変更します。

**src/index.template.html**

```html
...
<body>
<!--vue-ssr-outlet-->
</body>
```

`<div id="app"></div>`を`<!--vue-ssr-outlet-->`に変更しています。
`<!--vue-ssr-outlet-->`がhtmlが挿入される箇所です。

最後に、`src/App.vue`も１箇所だけ変更します。

**src/App.vue**

```html
<template>
  <div id="app">
    ...
```

ルートの`div`要素に属性`id="app"`を追加しています。

それでは、ビルド、サーバーを起動後、`localhost:8080/page`にアクセスしてみましょう。

```bash
# せっかくなのでproductionでビルドしてみます
npm run build:prod

npm start

# localhost:8080/page にアクセス！
```

正常に表示されたら、ソースコードを確認してみましょう。

![9.png](https://qiita-image-store.s3.amazonaws.com/0/100188/b825e398-4ee1-244b-a1a7-379dc3f1f57c.png)

`<div id="app" data-server-rendered="true" data-v-68ab05c0><p class="message" data-v-68ab05c0>message...`

おめでとうございます！
無事にSSRを実装することができました！

クライアントではSPAとして動作するか、リンクをクリックして確認してみてください。

最終的なディレクトリ構成は以下のようになっています。

```
root
|- /dist
  |- main.js
  |- vue-ssr-server-bundle.json
|- /node_modules
  |- /some_modules
|- /src
  |- /components
    |- Page.vue
    |- Top.vue
  |- App.vue
  |- entry-base.js
  |- entry-client.js
  |- entry-server.js
  |- index.template.html
  |- router.js
|- .babelrc
|- app.js
|- package.json
|- package-lock.json
|- webpack.base.config.js
|- webpack.client.config.js
|- webpack.server.config.js
```

# おわりに

今回は必要最低限のSSRを実装しましたが、やりたいことはまだまだあるでしょう。

- サーバーからクライアントにデータを渡したい
- Vuexを使いたい
- ルーティングをしっかり行いたい
- パフォーマンスを上げたい
- etc...

また、ジェネレータで生成された環境の上に実装したいケースや、Nuxt.jsのSSR環境を拡張したい、といったこともあるでしょう。

私もこれからこれらに挑戦していく予定ですが、基礎はすでに身につけたはずです。
この基礎の上に新しい技術を積み重ね、更に発展させていくつもりです。

次は → [Vue SSR ガイド](https://ssr.vuejs.org/ja/)