Vue SSR 環境構築メモ

Vue SSR ガイド, vuejs/vue-hackernews-2.0 を参考に環境構築を試してみた時のメモです。

サーバサイドで Vue をコンパイルする

https://github.com/ryotah/vue-ssr/compare/step-0...step-1-render-to-string

やっていること:

  • vue-server-renderer からレンダラーを作成
  • リクエスト毎に vue インスタンスを生成
  • HTML に描画して返信

コード抜粋:

server.js

const Vue = require("vue");
const server = require("express")();
// ステップ 1: レンダラを作成
const renderer = require("vue-server-renderer").createRenderer({
  template: require("fs").readFileSync("./index.template.html", "utf-8")
});

server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  });

  // ステップ 3: Vue インスタンスを HTML に描画
  renderer
    .renderToString(app)
    .then(html => res.end(html))
    .catch(err => {
      res.status(500).end("Internal Server Error");
    });
});

server.listen(8080);

参考:

クライアントとサーバのコードをわける (Webpack)

https://github.com/ryotah/vue-ssr/compare/step-1-render-to-string...step-2-webpack

やっていること:

  • クライアントとサーバー両方をバンドルするために webpack を導入
  • entry-client.js, entry-server.js, app.js を用意
    • entry-*.jsapp.js (アプリケーションのユニバーサルエントリー) を読み込む

コード抜粋:

entry-client.js

import { createApp } from "./app";

export default context => {
  const { app } = createApp(context);
  return app;
};

server.js

// ...
const createApp = require("./dist/entry").default;
server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = createApp({ url: req.url });

  // ステップ 3: Vue インスタンスを HTML に描画
  // ...
});

server.listen(8080);

webpack.config.js

module.exports = {
  entry: "./entry-server.js",
  output: {
    filename: "entry.js",
    path: path.resolve(__dirname, "dist"),
    libraryTarget: "commonjs2"
  }
  // ...
};

参考:

f:id:ryotah:20181127023444p:plain

ルーティング

https://github.com/ryotah/vue-ssr/compare/step-2-webpack...step-3-router

やっていること:

  • vue-router を導入
  • entry-server.js にルーティングロジックを実装

コード抜粋:

entry-server.js

import { createApp } from "./app";

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();

    // set server-side router's location
    router.push(context.url);

    // wait until router has resolved possible async components and hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        reject({ code: 404 });
      }
      resolve(app);
    }, reject);
  });
};

参考:

プリフェッチを実現

https://github.com/ryotah/vue-ssr/compare/step-3-router...step-4-store

やっていること:

  • サーバサイドとクライアントサイドで同じデータを利用可能にするために Vuex を導入
  • ルートコンポーネントにプリフェッチ用メソッド asyncData を実装
  • entry-client.js にサーバからのデータを受け取る store.replaceState(window.__INITIAL_STATE__) を追加

コード抜粋:

entry-server.js

// ...
router.onReady(() => {
  const matchedComponents = router.getMatchedComponents();
  // ...

  // call `asyncData()` on all matched route components
  Promise.all(
    matchedComponents.map(
      ({ asyncData }) =>
        asyncData &&
        asyncData({
          store,
          route: router.currentRoute
        })
    )
  )
    .then(() => {
      // すべてのプリフェッチのフックが解決されると、ストアには、
      // アプリケーションを描画するために必要とされる状態が入っています。
      // 状態を context に付随させ、`template` オプションがレンダラに利用されると、
      // 状態は自動的にシリアライズされ、HTML 内に `window.__INITIAL_STATE__` として埋め込まれます
      context.state = store.state;
      resolve(app);
    })
    .catch(reject);
}, reject);
// ...

参考:

サーバとクライアント、それぞれをビルドするために個別の Webpack 設定ファイルを用意

https://github.com/ryotah/vue-ssr/compare/step-4-store...step-5-hydration

バンドルレンダラ

https://github.com/ryotah/vue-ssr/compare/step-5-hydration...step-6-bundle-renderer

やっていること:

  • バンドルレンダラを利用
  • JSON 形式でサーバサイドのコードをバンドル

参考:

クライアントマニフェストを生成

https://github.com/ryotah/vue-ssr/compare/step-6-bundle-renderer...step-7-client-manifest

参考:

ビルド設定をもうちょい

https://github.com/ryotah/vue-ssr/compare/step-7-client-manifest...step-8-build

やっていること:

  • webpack-dev-middleware を導入
  • コードに変更があったら、サーバとクライアント両方をビルドするように設定

コード抜粋:

server.js

const path = require('path');
const express = require('express');
const MemoryFS = require('memory-fs');
const app = express();
const webpack = require('webpack');

let clientManifest;
let serverBundle;
let renderer;

function update() {
  if (!clientManifest || !serverBundle) {
    return;
  }
  // create a renderer
  const { createBundleRenderer } = require('vue-server-renderer');
  const template = require('fs').readFileSync('./index.template.html', 'utf-8');
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // recommended
    template,
    clientManifest,
  });
}

// dev middleware
const clientConfig = require('./build/webpack.client.config.js');
const clientCompiler = webpack({
  ...clientConfig,
  // TODO:
  mode: 'development',
});
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
});
app.use(devMiddleware);
clientCompiler.plugin('done', stats => {
  try {
    clientManifest = JSON.parse(
      devMiddleware.fileSystem.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// watch and update server renderer
const serverConfig = require('./build/webpack.server.config.js');
const serverCompiler = webpack({
  ...serverConfig,
  // TODO:
  mode: 'development',
});
const mfs = new MemoryFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
  try {
    serverBundle = JSON.parse(
      mfs.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-server-bundle.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// ...