📝

Vue3 without Nuxt.js で SSR やってみた.log

2021/12/29に公開

https://v3.vuejs.org/guide/ssr/introduction.html

Nuxt.js + Vue.js での SSR は経験がある(しほとんど手でやるようなことはない)ので、Vue.js のみで SSR を試してみた。Nuxt.js は便利だけど親切すぎて鬱陶しいと思うことも多かったため、Vue.js のみでフロントエンドを構築できないかなということをいつも考えていたのである。

ちなみに Vue3 を使うのは初めて。TypeScript との親和性が上がったみたいな噂を聞いたので TypeScript も使ってみることにする。普段は型が邪魔で使ってないんだけど、頑張ってみるかという気持ちになった。

やってみた

Vue3 のプロジェクトをセットアップ

とりあえずプロジェクトを作るところから始める。

yarn global add @vue/cli @vue/cli-service-global

これで vuevue-cli-service が使えるようになる。

vue create vue-sample

で対話的にいい感じに作る。詳細は https://cli.vuejs.org/guide/creating-a-project.html#vue-create ここにある。

Manually select features -> ✔ TypeScript / ✔ Router / ✔ Vuex

entry-client.ts / entry-server.ts を作る

作ったばかりのプロジェクトだと、 src/main.ts がエントリポイントになる。SSRをやるので、サーバーとクライアント(ブラウザ)でエントリポイントを分割し、それぞれを Webpack でビルドしてやるのが良いらしい。

SSR + SPA はいわゆる Universal JavaScript (Isomorphic JavaScript) をやるということなので、ほとんどのコードは Node.js でも Browser の JavaScript エンジンでも動作するものを書くことになる。.vue ファイルで表現される Vue の Single File Component はサーバーでもクライアントでも結局は仮想DOMをレンダリングするだけなので、動作は変わらない。サーバーとクライアントで異なるのは、レンダリングされた仮想DOMを「生きているHTMLに適用する」か「文字列化して HTTP Response Body として送信する」かだ。

レンダリングされた仮想DOMの利用方法が異なるので、この部分はサーバーとクライアントでそれぞれ書いてやる必要がある。といっても、ほとんどは Vue.js 本体に準備されていて、フレームワーク利用者である我々はちょっとの処理を記述すればいいようになっている。この記事の冒頭で紹介したドキュメントに丁寧に書いてあるので、そちらを参照すれば良い。

https://v3.vuejs.org/guide/ssr/structure.html

https://v3.vuejs.org/guide/ssr/routing.html

このあたりのページが参考になる。ただ、ドキュメントで例示されているのは JavaScript のコードなので、TypeScript でどうなるかをやってみた。

entry-client.ts
import { createSSRApp } from 'vue'
import { createWebHistory } from 'vue-router'
import App from './App.vue'
import createRouter from './router'
import store from './store'

const app = createSSRApp(App)
const router = createRouter(createWebHistory())

app.use(store)
app.use(router)

router.isReady().then(() => {
  app.mount('#app')
})
entry-server.ts
import { createSSRApp, App } from 'vue'
import { createMemoryHistory, Router } from 'vue-router'
import createRouter from './router'

import AppComponent from './App.vue'

export interface ServerEntryPoint {
  app: App;
  router: Router;
}

export default function (): ServerEntryPoint {
  const app = createSSRApp(AppComponent)
  const router = createRouter(createMemoryHistory())

  app.use(router)

  return { app, router }
}

加えて、デフォルトで作成される src/router/index.ts は createWebHistory を前提に書かれているので、RouterHistory を外部から注入するように書き換える必要がある。(サーバーでは createWebHistory ではなく createMemoryHistory を使うので、エントリポイントによって RouterHistory が異なる)

router/index.ts
import { createRouter, RouteRecordRaw, RouterHistory, Router } from 'vue-router'
import Home from '../views/Home.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = function (history: RouterHistory): Router {
  return createRouter({ history, routes })
}

export default router

ファイル冒頭で RouterHistory, Router を import しないと型がないって怒られる。なにか IDE を使っていれば自動的に追加してくれるのかもしれないが、vim で書いてるので vue-router-next のコードを見にいって型の名称を調べていた。vim でもなにかプラグインとかありそうだけどあまり入れる習慣がない(というか入れたくない)ので、VS Code などで環境を作ったほうが良いかもしれない。

やっぱ JavaScript に型とかいらなくない?

余談:vue-cli-service のエラー

TypeScript に限らずなのか TypeScript のエラーの場合なのか定かではない(TypeScript のエラー以外のエラーは今の所発生していないため)が、vue-cli-service build でエラーが出た時、なにもメッセージが無くて意味不明だった。

 ERROR  Build failed with errors.

これだけ。なんで? vue-cli-service serve すると詳細が見えたけど、これだと entry-server.ts のビルドエラーは見えない……。

https://cli.vuejs.org/guide/cli-service.html#vue-cli-service-build

エラーの詳細を表示する、みたいなログもないし。

流石に詳細を表示する機能がないってことはないと思うので、なにかすればいいんだろうけど、ドキュメントを漁るのは面倒なのであとでコードを読んでみる。

vue.config.js の修正

https://v3.vuejs.org/guide/ssr/build-config.html

ここに書いてあることをやるだけです。

補足すると、webpack-manifest-pluginwebpack-node-externals はそれぞれ yarn add -D してやる必要がある。

yarn add -D webpack-manifest-plugin webpack-node-externals

webpack-manifest-plugin は、manifest.json を生成してくれる Webpack Plugin です。ドキュメントで例示されている vue.config.js では、build:server 時のみ ssr-manifest.json を生成するようになっている。

ssr-manifest.json
{
  "app.css": "/css/app.8a184b2c.css",
  "app.js": "/js/app.baf28ace.js",
  "app.css.map": "/css/app.8a184b2c.css.map",
  "app.js.map": "/js/app.baf28ace.js.map",
  "favicon.ico": "/favicon.ico",
  "img/logo.png": "/img/logo.82b9c7a5.png",
  "index.html": "/index.html"
}

見れば用途はなんとなく想像できますね。

https://webpack.js.org/concepts/manifest/

Webpack では importrequire__webpack_require__ に置き換えられ、Manifest を参照して実際にロードすべきモジュールの識別子と紐付けられるらしい。

例えば require("app.js") は実際には __webpack_require__("app.js") に置き換えられ、ランタイムで Manifest が参照されるということだろう。たぶん。

webpack-node-externals は、Webpack でなんかファイルを処理しないようにするものらしいです。例えば .css ファイルなんかは、サーバー側でバンドルする必要はない(クライアントをビルドしたときにバンドルされたファイルをサーバーから参照してレスポンスしてやればよい)ので、サーバービルド時には外部依存のままにしておく。抽象的な部分しか理解していない。

server.js を作る

Vue.js は HTML をレンダリングする機能しかないので、HTTP サーバーとしての機能は別途準備する必要がある。みんな大好き express.js を使う。

yarn add express
server.js
const path = require('path')
const express = require('express')
const fs = require('fs')
const { renderToString } = require('@vue/server-renderer')
const manifest = require('./dist/server/ssr-manifest.json')

const server = express()

const appPath = path.join(__dirname, 'dist', 'server', manifest['app.js'])
const createApp = require(appPath).default

server.use('/img', express.static(path.join(__dirname, './dist/client', 'img')))
server.use('/js', express.static(path.join(__dirname, './dist/client', 'js')))
server.use('/css', express.static(path.join(__dirname, './dist/client', 'css')))
server.use(
  '/favicon.ico',
  express.static(path.join(__dirname, './dist/client', 'favicon.ico'))
)

server.get('*', async (req, res) => {
  const { app, router } = createApp()

  await router.push(req.url)
  await router.isReady()

  const appContent = await renderToString(app)

  fs.readFile(path.join(__dirname, '/dist/client/index.html'), (err, html) => {
    if (err) {
      throw err
    }

    html = html
      .toString()
      .replace('<div id="app">', `<div id="app">${appContent}`)
    res.setHeader('Content-Type', 'text/html')
    res.send(html)
  })
})

console.log('You can navigate to http://localhost:8080')

server.listen(8080)

Vue.js のドキュメントの例とほとんど同じ。

Static File は ./dist/client からロードしているが、実際にサーバーにデプロイする時はなにか修正が必要かもしれない。また、Vue.js のレンダリング結果を .replace しているところには力技を感じる。

server.ts にしないのかって? めんどいので嫌です。

クソ真面目な話をすると、このコードは一番クライアント側のコードで、このコードを利用する他のコードはないので、TypeScript で書いて無くても別に良いんじゃない? という気持ちはあります。中身もシンプルだし。

画面のちらつきを改善する

↑で提示した entry-client.ts ではすでに改善済みだが、SSRされたHTMLをロードした後に app.mount("#app") が動作すると DOM ツリーを全部リフレッシュしてしまい、画面がちらついてしまう。

これは、createApp の代わりに createSSRApp をクライアント側でも利用することで改善する。

https://v3.vuejs.org/guide/ssr/hydration.html

Nuxt.js などでもたまに遭遇するが、SSR された HTML とクライアントでレンダリングされた仮想 DOM の間に差分があるとトラブルが発生するのである。Vue.js の場合は、単純にクライアントの仮想 DOM を使って DOM ツリーを再構成する。

createApp を使っていればこの検証が行われず、したがってオーバーヘッドが発生しないということだろうか。確かにサーバーから空のHTMLが返ってくるなら、検証は必ず失敗するので、検証する必要がないと言えそう。このあたりは実際の Vue.js のコードを読むといろいろ発見があるかもしれない。

Next

ここまででだいたい SSR できる Vue.js のプロジェクトができた。Nuxt.js などと比べると不便だが、その分取り回しが良いし、基礎が薄く依存が少ないということはそれはそれでメリットがある。具体的に言えば NODE_ENV=production yarn install 後の node_modules が 18.4 MB しかないとか。(AWS Lambda に乗せやすい!)

しかしながら不便なこともある。

Nuxt.js では pages 以下に .vue を配置すればルーティングも勝手に定義してくれたが、シンプルすぎるこのプロジェクトだと ./router/index.ts に自前で書く必要がある。まあ書いても良いんだけど、それぞれのファイルごとに書けてもいいんじゃない? そこで、 <route> ディレクティブを追加して似たようなことができないか試してみたい。

それと、コンポーネント単位でのプレビューをしたいので、Storybook なるものも試したいと前々から思っていた。

GraphQL API からのレスポンスに TypeScript の型をあてるというか、TypeScript の型から GraphQL API のクエリを生成するというか、そういうこともやってみたい。APIレスポンス型の定義をして、それを API 通信を行うオブジェクトに渡せば、通信を行ってくれる。みたいな。

いずれ気が向いたらやります。

(追記)やった

https://zenn.dev/niaeashes/articles/0e624fc986888d

Discussion