Vue3をExperssでSSRしてMetaを更新したりOGPを設定した（Githubにスケルトンあり）

# はじめに
久しぶりに時間できたので調査&記事投稿です。

## 概要
作り方ではなく、スケルトンのソース解説をしていきます。
Githubに公開しているので触ってみたい方はどうぞ（もしよければstarやissueください）
- スケルトン https://github.com/aocm/vue3-express-ssr-sample
- ちょい肉付けした https://github.com/aocm/mini-memo-app

色々試してみたくて作ったものなので、導入した要素のひとつでもなにか参考になれば幸いです。

## 想定読者
- Vue3をSSRしてみたい方→【本題】へ
- SEO対策やOGPのためにVueでもメタを更新したい人→【本題】へ
- 開発するとき役立つかもしれないツールを知りたい方→【おまけ】へ

## なぜ作ったか、なにが嬉しいか
SPAでSEO対策でつらい目にあったことがあり、Vue3＋SSRをとにかくやってみたかったのです。
Vue3の公式にはあまり情報がなく、Vue2系はNuxtとQuasarが候補にありました。（この辺りも時間たてばVue3に対応した情報が増えそう）

少し視点を変えて[Viteの公式](https://ja.vitejs.dev/guide/ssr.html)をみると解説とサンプルアプリがあったのでそれを試してみたのがスタート地点です。

Viteの公式はExpressを使っており、拡張フレームワークを用いずに自作することで好みにチューニングができるのでは？
**Dockerイメージにしてデプロイするときに、サイズをかなり小さくできそう！（嬉しいこと①）**
(※似た様なことを試した方もおられ、参考にしました。https://zenn.dev/niaeashes/articles/9cf121b46af2a0 )


そして作ってて途中で気づいたのですが、Expressで通常のルーティングができるならば**AWSのAPIGatewayとLambdaとも相性がいい！（嬉しいこと②）**

# 【本題】 ViteでVue3をビルドしてExperssでSSR

## ディレクトリやファイルの説明
```text
（一部省略しています）
.
├── Dockerfile.local // ローカル開発用のDockerfileです
├── Dockerfile.prod  // プロダクションビルドをDockerイメージにする際のDockerfileです。
├── LICENSE.md
├── README.md
├── docker-compose.yml
├── docs             // ドキュメントを保存するディレクトリです。
│   └── adr          // ADRを保存しています
├── e2e              // cypressのテストや設定ファイルが入っています
├── package-lock.json
├── package.json
├── sls              // slsでデプロイ、オフラインでのテストをするためのディレクトリです
│   ├── handler.js
│   └── serverless.yml
├── sls-offline.sh
├── sls-prepare.sh
└── ssr-server       // メインアプリ
    ├── __tests__    // Jestのテストが入っています
    ├── dist         // build実行後ここに出力されます
    ├── index.html   // ssr前のベースとなるHTMLです
    ├── index.js     // server.jsを起動するための処理です ※1
    ├── nodemon.json 
    ├── package.json
    ├── public
    ├── server.js    // Expressのapp.useのVue3のSSR処理を書いています(Vite公式を参考) ※1
    ├── src          // Vue3とExpress両方入っています(分けてもよかったかも・・・)
    │   ├── App.vue  
    │   ├── api      // Expressのルーティング・UseCase
    │   ├── assets
    │   ├── components // Vueのコンポーネント
    │   ├── domain   // ドメインオブジェクト
    │   ├── entry-client.ts // SSR用ファイル
    │   ├── entry-server.ts // SSR用ファイル
    │   ├── env.d.ts
    │   ├── express-base.js // ExpressのAPI系 ※1
    │   ├── infra
    │   ├── log
    │   ├── main.ts   // Vueのエントリーファイル
    │   ├── metas     // SSR時のメタ
    │   ├── pages     // Vueのページ
    │   ├── router    // VueのRouter（SSR時もRouter.pushのCSR時も両方使う）
    │   └── utils     // ExpressとVue両方で使う想定のロジック集
    ├── tsconfig.json
    └── vite.config.ts

```
※1もともと`index.js`と`server.js`と`src/express-base.js`は一つのファイルでしたが、巨大になってしまったことと、lambda起動やJestのSupertest時に困ってしまったので分解しました。`index.js`→`server.js`→`src/express-base.js`という依存関係です。

## MetaやOGPタグの更新
### 該当ソースコード
```js:server.js (一部抜粋)
export async function createDevServer(root = process.cwd()) {
  const app = createExpressApp() // express()＋apiルーティングの前処理ずみ
  if (isTest) return { app }// Jest実行時はvite/ssr処理をテストしない

  // 以下DEV用SSR処理
  const manifest ={}
  const vite = await require('vite').createServer({
    root,
    logLevel: isTest ? 'error' : 'info',
    server: {
      middlewareMode: 'ssr',
      watch: {
        usePolling: true,
        interval: 100
      }
    }
  })
  app.use(vite.middlewares)

  app.use('*', async (req, res, next) => {
    const url = req.originalUrl
    const {htmlTitle, htmlDescription} = getMeta(req.baseUrl)
    try {
      const template = await vite.transformIndexHtml(url, fs.readFileSync(resolve('index.html'), 'utf-8'))
      const render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
      const [appHtml, preloadLinks] = await render(url, manifest, req.session)
      const html = template // ★
        .replace('<!--preload-links-->', preloadLinks)
        .replace('<!--app-html-->', appHtml)
        .replace('<title>vue3-express-ssr-sample</title>', `<title>${htmlTitle}</title>`)
        .replace('<!-- meta-description-space -->', `<meta name="description" content="${htmlDescription}" />`)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      vite.ssrFixStacktrace(e)
      next(e)
    }
  })
  return { app, vite }
}

```
★で書き換えています。

### titleやdescriptionが書き換わっていることの確認
実際にアクセスしてみると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417134/b8ae1960-5f1a-6909-9e61-afd72b769a7d.png)
この画面が下のようにtitleやdescriptionが書き換えられているのを確認できます。
```html:ChromeのDevtoolのネットワークタブで確認したhtml
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="top" />
    <title>top</title>
    <script type="module" crossorigin src="/assets/index.8eed76bd.js"></script>
    <link rel="stylesheet" href="/assets/index.5069e297.css">
  </head>
  <body>
    <div id="app"><div><div id="nav"><a aria-current="page" href="/" class="router-link-active router-link-exact-active" id="toTopLink">Top</a> | <a href="/about" class="" id="toAboutLink">About</a> | <a href="/yamabiko" class="" id="toYamabikoLink">Yamabiko</a> | <a href="/history" class="" id="toHistoryLink">History</a></div><section class="hero is-fullheight"><div class="hero-body"> home </div></section></div></div>
    
    
  </body>
</html>

```

### OGPの書き換え確認
次のようにTwitterなどのOGPタグも書き足すことができます。
以下に肉付けサンプルコードを記載します。
https://github.com/aocm/mini-memo-app

```html:index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- meta-description-space -->
    <!-- meta-twitter-ogp-space -->
    <title>vue3-express-ssr-sample</title>
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <!--preload-links-->
    <script type="module" src="/src/entry-client.ts"></script>
  </body>
</html>
```

```javascript:server.js
  app.use('*', async (req, res, next) => {
    // ここから共通処理
    const url = req.originalUrl
    const {htmlTitle, htmlDescription} = getMeta(req.baseUrl)
    if (req.baseUrl==='/memo/view'){
      const memo = memoService.findById(req.query.id)
      twitterOGP = `<meta property="og:title" content="${memo.result.title}">
<meta property="og:type" content="article">
<meta property="og:url" content="http://localhost:3000/memo/view?id=2">`
    // TODO urlは環境変数等で調整
    }
    // ここまで共通処理
    try {
      const template = await vite.transformIndexHtml(url, fs.readFileSync(resolve('index.html'), 'utf-8'))
      const render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
      const [appHtml, preloadLinks] = await render(url, manifest, req.session)
      const html = template
        .replace('<!--preload-links-->', preloadLinks)
        .replace('<!--app-html-->', appHtml)
        .replace('<title>vue3-express-ssr-sample</title>', `<title>${htmlTitle}</title>`)
        .replace('<!-- meta-description-space -->', `<meta name="description" content="${htmlDescription}" />`)
        .replace('<!-- meta-twitter-ogp-space -->', twitterOGP)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      vite.ssrFixStacktrace(e)
      next(e)
    }
  })
```

先ほどのソースコードに`.replace('<!-- meta-twitter-ogp-space -->', twitterOGP)`を付け足しました。

デフォルトのTwitterOGPタグをcontent=siteにして、if文で特定のURLだけarticleとして上書きするなども応用が利くと思います。

実際動作確認すると下記の画像左のような、タイトルとJSONを表示する画面のメタタグを更新すると画像右のように https://cards-dev.twitter.com/validator でタイトルを正しく書き換えられているのを確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417134/6ebbdf6b-850d-9da0-54b3-fbf0159d36e9.png)

無理やり感はありますがやりたいことはできました

OGPの動作確認はこちらを参考にさせていただきました
https://qiita.com/TeruhisaFukumoto/items/6032efde115a17b45637


## CSRは全くないのか？

そんなことなく、初回アクセスがSSRなだけで基本はCSRになります。
Vue3のcreateSSRAppで起動していたとしても、ハイドレーションが行われてSSRした内容とCSRの内容の差分検証が行われます。
なのでサーバーサイドでDBの値取得してhtml更新したとしても、クライアントではその値を取得できなければハイドレーションでDOMの不一致として再描画されてしまいます。

基本的にクライアントで正しい描画するように、fetchやaxiosなどAPIコールして同じ結果になるように書く必要があります。

また、よく使うrouter-linkタグやrouter.pushなどを使った場合はクライアント内での描画処理になります。
明示的にサーバーに処理させたい場合はaタグやwindow.location.hrefなどでサーバーにリクエスト送る必要がありました。


## ビルド・デプロイ方法
2パターン用意してあります
- ServerlessFramework(以降slsと記載)を用いたLambdaデプロイ
- Dockerイメージにビルドしてコンテナとしてデプロイ

現状のソースでは各種データの永続化を行っていなく、インメモリで書いています。もちろんLambdaで使えば消し飛ぶし、
Dockerで使ったとしてもメモリリークするので何かDBを用意してつかってください。
Expressで書いているため、ServerlessFrameworkにそのままデプロイできて便利でした。

Dockerイメージのほうはマルチステージビルドを使ったことでそこそこ小さくつくれたと思います。

```Dockerfile:Dockerfile.prod
# ビルド環境
FROM node:17-slim AS build-env
WORKDIR /usr/src/app
COPY ./ssr-server /usr/src/app
COPY package-lock.json /usr/src/app/package-lock.json

RUN npm ci && npm run build

# 実行環境（prod想定）
FROM node:17-alpine as runner
WORKDIR /usr/src/app
COPY --from=build-env /usr/src/app/dist /usr/src/app/dist
COPY --from=build-env /usr/src/app/package.json /usr/src/app/package.json
COPY --from=build-env /usr/src/app/package-lock.json /usr/src/app/package-lock.json
RUN npm ci --production
EXPOSE 3000

CMD  ["npm", "run", "serve"]

```
```
REPOSITORY                                      TAG           IMAGE ID       CREATED         SIZE
mini-memo-app-image                             latest        c1e79112abd7   44 hours ago    254MB
```


# 【おまけ】その他スケルトンに試しているもの
- ExpressでAPI
    - express-session(in memoryな状態なので拡張して好きなDBに保存OK)
    - logger(log4js)
    - esmoduleで作成してbabelでトランスパイル
- 動作確認
    - Jest+SupertestでのExpressのAPIテスト
    - CypressでのE2Eテスト
    - Storybookでコンポーネント確認
    - serverless-offlineで動作確認
- 開発環境の工夫
    - dockerコンテナの利用（[以前の記事](https://qiita.com/aocm/items/6c525ff54167717c5e3b)と考えは同じで、なるべくローカルを汚したくない）
    - npm workspacesの利用
    - nodemonの利用（VueはViteでホットリロードされており、Expressでもやりたかったため。）
    - eslintでフォーマット
    - ADRを用いた意思決定管理（また、導入にあたって参考にした記事も記載。）

## 逆に導入していないこと
### Githookでフォーマットの強制（huskyなどの導入）
dockerコンテナ内で開発用としたので、その副作用ですね。
作り込んでもいいかもしれませんが、属人的な構造になりそうだったのでやめました。

### Express部分をTypeScriptにする
viteで全部ビルドできれば便利では？とおもったりして試したのですがうまくいかず心折れたので保留です。

### UIコンポーネントやライブラリ
ご自由に拡張してください。
[肉付けしたほう](https://github.com/aocm/mini-memo-app)ではElement Plusを使ってみました。
https://element-plus.org/en-US/

# おわりに
何回か放置しかけましたが、ある程度まとまったものが作れて満足です。
ただ、ある程度気楽に触れるものを作りたかったのですが、思ったより複雑なものになってしまいました。
もし興味もっていただけて気になる部分がございましたら、コメントやTwitterのほうにご連絡いただけると幸いです。