Express による SSR から Eleventy(SSG) による静的サイトジェネレートに変更した話

はじめに

Enjoy SFV More のフレームのページを Express による サーバーサイドレンダリング(SSR) から Eleventy という静的サイトジェネレーター(SSG)による静的サイトジェネレートに変更しました。

※静的サイトジェネレートとは言わない・・・?

下記のような各キャラクターのフレームのページが Eleventy を利用して生成しているページです。

enjoy-sfv-more.com

今回は主に Eleventy を解説していきたいと思います。

Enjoy SFV More については下記記事で技術解説をしているのでこちらもどうぞ。

tiwu.hatenablog.com

Express による SSR

先に変更前の構成を解説していきます。

変更前は Firebase FunctionsExpress を動かし、EJSレンダリングを行っていました。

基本的に Enjoy SFV More はクライアントでのレンダリングのページがほとんどです。

実装当初はフレームのページもクライアントでのレンダリングをしていたのですが、AMP 対応で <link rel="amphtml" href=""> を実装する必要がありました。

今まで通りクライアントサイドで <link rel="amphtml" href="">レンダリングしていたのですが、どうやら Google が認識してくれなかったので、SSR で実装をしました。

Express

ExpressNode.js で動く Web アプリケーションフレームワークです。

expressjs.com

EJS

EJSNode.js などで動くテンプレートエンジンです。

ejs.co

Embedded JavaScript 略して EJS っぽいです(今知りました)

コード

Enjoy SFV More では下記のように SSR を行っていました。

index.js

app.get("/frames/:characterEnName", (req, res) => {
  const character = characters.getCharacterByEnName(req.params.characterEnName);
  if (character) {
    res.status(200).render("frame", {
      characterEnName: character.enName,
      characterName: character.name
    });
  } else {
    res.status(404);
  }
});

まず Express では URL からキャラクター名を元に、キャラクターのデータを取得して、テンプレートにデータを渡します。

frame.ejs

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title><%= characterName %> - フレーム表&対策メモ【ストリートファイター V (スト5, ストV)】 - Enjoy SFV More</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="<%= characterName %>のフレーム表です。技の種類で検索ができたり、フレームの有利不利で並び替えができたり、フレームを見ながらリュウの対策のメモを取ったりすることができます。Enjoy SFV More はストリートファイター V (スト5, ストV) のラウンジを募集したり、キャラやLPやルールでラウンジを探したり、フレーム表や確定反撃(確反)を見ながら対策のメモができたりする、上達のためのサイトです。">
    <link rel="amphtml" href="https://enjoy-sfv-more.com/amp/frames/<%= characterEnName %>">
  </head>
  <body>
    <enjoy-sfv-more-body header-back="/frames">
      <enjoy-sfv-more-frame></enjoy-sfv-more-frame>
    </enjoy-sfv-more-body>
    <script src="/src/js/index.js" async></script>
  </body>
</html>

EJS では受け取ったデータを元に titledescriptionamphtmlレンダリングします。

Eleventy による静的サイトジェネレート

SSR でも問題はないのですが表示速度と、https://web.devEleventy を利用しているのを知りリファクタリングすることにしました。

下記はweb.devEleventy の解説記事です。

web.dev

Eleventy

Eleventy は静的サイトジェネレーターで、特徴は

  • シンプル
  • 好きなクライアントサイドの JavaScript が使える
  • データによる生成

といったところです。

www.11ty.dev

使用方法はインストールして実行すれば、コンフィグ無しで HTML がジェネレートされます。

$ npm install --save-dev @11ty/eleventy
$ echo '# Page header' > README.md
$ npx @11ty/eleventy

サーバーを起動させたり

$ npx @11ty/eleventy --serve

ファイルの変更を監視したりできます

$ npx @11ty/eleventy --watch

Nunjucks

NunjucksMozilla が作っているテンプレートエンジンです。

mozilla.github.io

Webpack との連携

Enjoy SFV More では webpack を利用しています。

webpack/src/js/index.js?hash=1fc568a7c0940a33ee7c といったようにハッシュをつけてバンドルしています。

Eleventy でこのハッシュ付きのバンドルされたスクリプトを読み込むためには一工夫必要です。

まず webpack-manifest-plugin を利用してハッシュとのマッピングが記述された manifest.json を出力します。

github.com

出力される manifest.json は下記のようになっています。

manifest.json

{
  "main.js": "/src/js/index.js?hash=1fc568a7c0940a33ee7c"
}

次に、Eleventy に用意されている Shortcodes という機能を利用して、 manifest.json を読み込んでハッシュ付きのスクリプトを返す処理を実装します。

www.11ty.dev

.eleventy.js

const path = require("path");
const fs = require("fs");

const manifestPath = path.resolve(__dirname, "public/src/js/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, { encoding: "utf8" }));

module.exports = eleventyConfig => {
  eleventyConfig.addShortcode("webpackAsset", name => {
    return manifest[name];
  });
};

上記コードでは webpackAsset という名前で Shortcodes を定義しています。

最後に定義した Shortcodes をテンプレートエンジンで利用しスクリプトを読み込みます。

default.njk

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>{{ character.title or title }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="{{ character.description or description }}">
  <body>
    <enjoy-sfv-more-body>
      {{ content | safe }}
    </enjoy-sfv-more-body>
    <script src="{% webpackAsset 'main.js' %}" async></script>
  </body>
</html>

Nunjucks を利用しているので {% webpackAsset 'main.js' %} といった書き方で Shortcodes を呼び出しています。

データを元に HTML を生成する

Eleventy ではデータを元に HTML を出力できます。

www.11ty.dev

_data/possums.json

[
  {
    "name":"Fluffy",
    "age":2
  },
  {
    "name":"Snugglepants",
    "age":5
  },
  {
    "name":"Lord Featherbottom",
    "age":4
  },
  {
    "name":"Pennywise",
    "age":9
  }
]

possum-pages.njk

---
pagination:
    data: possums
    size: 1
    alias: possum
permalink: "possums/{{ possum.name | slug }}.html/"
---

{{ possum.name }} is {{ possum.age }} years old

pagination.data に指定されたデータを元にループ処理が行われて HTML を出力します。

この例では pagination.datapossums.json のファイル名を指定しています。

※これは後述する Global Data Files という仕組みを利用しているのでファイル名の指定になります

pagination.size は指定された数値でデータをチャンクします。

pagination.alias はループする際のデータの変数名です。

この例では possum = { "name":"Fluffy", "age":2 }, possum = { "name":"Snugglepants", "age":5 } ... となります。

permalink は生成される HTML 名です。

slug フィルターは URL で利用可能な文字列に変換されます。

www.11ty.dev

{{ "My Title" | slug }} -> "my-title"

Enjoy SFV More での生成

Eleventy では6つの方法でデータを利用できます。

  • Computed Data
  • Front Matter Data in a Template
  • Front Matter Data in Layouts
  • Template Data Files
  • Directory Data Files (and ascending Parent Directories)
  • Global Data Files

www.11ty.dev

とても簡単に言うとテンプレートファイルに書かれたデータか、ディレクトリに置かれたデータか、そしてどのディレクトリまで有効になるかという6つになります。

最初6つ目の Global Data Files という、どこでもデータを使える方法を利用しようとしました。

www.11ty.dev

_data ディレクトリ(デフォルトの設定)に置かれた *.json.js のデータが利用できるのですが、なぜか Enjoy SFV More では利用できませんでした(ディレクトリの構成が悪いのか原因不明です)

そのため5つ目の Directory Data Files という一部のディレクトリでデータが利用できる方法を採用しました。

Enjoy SFV More ではテンプレートファイルを views ディレクトリに置いてます。

.eleventy.js

module.exports = () => {
  return {
    dir: {
      input: "views",
      output: "public",
    }
  };
};

そのためキャラクターのデータを views ディレクトリに views.11tydata.js という名前で置いてます。

views.11tydata.js

const characters = require("street-fighter-v-data/dist/data/character");

const characterMetadata = [];

characters.characters.characterForEach(character => {
  characterMetadata.push({
    title: `${character.name} - フレーム表 & 対策メモ【ストリートファイターV(ストV,スト5,SFV,SF5)】 - Enjoy SFV More`,
    description: `${character.name}のフレーム表です。技の種類で検索ができたり、フレームの有利不利で並び替えができたり、フレームを見ながら対策のメモを取ったりすることができます!【ストリートファイターV(ストV,スト5,SFV,SF5)】 - Enjoy SFV More`,
    enName: character.enName
  });
});

module.exports = {
  characterMetadata: characterMetadata
};

views.11tydata.js ではキャラクターのデータを取得して、 characterMetadata という配列を生成します。

frame.njk

---
layout: ja/default.njk
pagination:
    data: characterMetadata
    size: 1
    alias: character
permalink: "ja/frame/{{ character.enName | slug }}.html"
---
<enjoy-sfv-more-frame></enjoy-sfv-more-frame>

フレームのテンプレートファイルでは pagination.datacharacterMetadata を指定しています。

Directory Data Filesではファイル名ではなく module.exports で定義された key の名前になります。

layout を利用して、HTML の雛形を継承しています。

ja/default.njk

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>{{ character.title or title }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="{{ character.description or description }}">

    {% if character %}
      <link rel="amphtml" href="https://enjoy-sfv-more.com/amp/frames/{{ character.enName }}">
    {% endif %}
  </head>
  <body>
    <enjoy-sfv-more-body>
      {{ content | safe }}
    </enjoy-sfv-more-body>
    <script src="{% webpackAsset 'main.js' %}" async></script>
  </body>
</html>

<title>{{ character.title or title }}</title> では、 character.title があれば表示してなければ title を表示するようにしています。

{{ content | safe }}<enjoy-sfv-more-frame></enjoy-sfv-more-frame> が代入される仕組みになっています。

これで eleventy を実行すればキャラクター数分 HTML が生成されます。

f:id:tiwu:20200803225039p:plain
40対キャラクターがいるので一部のスクショです

この ja/default.njk は他のテンプレートでも利用をしています。

index.njk

---
layout: ja/default.njk
title: Enjoy SFV More - ストリートファイターV(ストV,スト5,SFV,SF5)をより楽しむためのサイト
description: Enjoy SFV More はストリートファイターV(ストV,スト5,SFV,SF5)をより楽しむため、ラウンジの募集や検索、コンボの作成や検索、配信の検索などができるサイトです!
---
<enjoy-sfv-more-top></enjoy-sfv-more-top>

例えばトップページではテンプレートファイル内で、titledescription を定義しています。

Firebase Hosting との連携

連携というほど何かしているわけではないのですが、Firebase Hostingrewrite では動的に sourcedestinationマッピングができません。

どういうことかというと、https://enjoy-sfv-more.com/frames/dhalsim という URLpublic/frame/dhalsim.html を表示するのを変数など使って簡単に書くことができません。

firebase.json

"rewrites": [
  {
    "source": "/frames/:name",
    "destination": "/ja/frame/:name.html"
  }
]

上記のようなパスを変数名に定義して、動的に HTML を表示することができません😭

そのため Enjoy SFV More では40体全ての URL を下記のように書いています。

firebase.json

"rewrites": [
  {
    "source": "/frames/ryu",
    "destination": "/ja/frame/ryu.html"
  },
  {
    "source": "/frames/chunli",
    "destination": "/ja/frame/chunli.html"
  },
  {
    "source": "/frames/nash",
    "destination": "/ja/frame/nash.html"
  }
]

firebase.google.com

まとめ

Global Data Files が何故か動かなかった問題さえ除けば、公式サイトを見ながらスムーズに実装ができました!

もっとゴリゴリレンダリングできるのですが、body 内は Web Componetsレンダリングに任せようと思います(昨今のテンプレートエンジンのつらみが伺えるので)