Firebase、Flamelink、Nuxt、Netlify、PWAを使ってJAMstackなブログを作る


長々としたタイトル通りのブツを作りました。
その技術内容を紹介をします。

ブログサイト

STUDIO Blog | Webデザインのための、クリエイティブメディア

Studio Designのマーケティングブログです。
元々Mediumを使っていたところから新規に作ることになったのですが、「じゃあ当然headless CMSでCDNでPWAなサイトですよね!?」と自分なりのフロント技術観を振りかざしてしまいました。

パフォーマンス

キャッシュなしのファーストページはまあまあ及第点でしょうか。
遷移時や再訪時だとキャッシュ優先でネットワークを待たないのでより速くなります。

技術構成

Firebase

まずはコンテンツのデータベースを選びます。
これまでリポジトリ内のmarkdownファイルや、GitHubのIssuesなどをコンテンツに使ったりしましたが、今回はみんな大好きfirebaseを使っていきます。

Flamelink CMS

次はコンテンツの編集管理画面です。
「A Realtime Headless CMS for Firebase!」と謳っているFlamelinkというサービスを使ってみました。
というか、Firebase用のCMSがあるという話を聞いて使ってみたいと思ったのでむしろこちらが先です。
ただしβ版のため今後どうなるのか分かりません。
まあデータ自体は自分のFirebaseにあるので、たとえサービスが無くなっても頑張ればどうにかなるでしょう。そういう気持ちが大事です。

Nuxt

フロントは静的に書き出せればなんでもよいです。実質reactかvue製の何かだと思います。
静的サイト生成のパフォーマンスにかける意気込みはReact製のGatsbyなどのほうがやたらに強いのですが、今回は使い慣れているNuxtにしました。

Netlify

みんな大好きNetlifyです。
サイトをビルドしてCDN化してくれます。CDNに載せるために全てのコンテンツを静的に書き出すわけです。そういうのをJAMstackと言います。ハッピー・ジャムジャム!

PWA

CDN化したとはいえ、やはりネットワークを介するのは時間がかかってしまいます。
オフラインキャッシュの管理をちゃんとやりましょう。

つまりどうなっているのか

図です。だいたいこんな感じです

要点

  • データベース、編集画面、フロントが分離されています。
  • リポジトリ、またはコンテンツの編集によりビルドがトリガされます
  • firebaseに接続するのは開発中~静的コンテンツ化するところまでで、ビルドしてCDNに載せる時点ではAPIレスポンスも含めて全て静的化しています。クライアントが実際のAPIを叩くことはありません。
  • 訪問者は初回訪問時にCDNのコンテンツを取りに行きますが、取得したコンテンツはservice workerによりキャッシュされ、二度目以降の表示ではレスポンスを待つこと無くキャッシュで見るようになります。
  • サイト内容が更新され再生成されれば新しくコンテンツを取得してキャッシュします。

ややこしいのではないか

  • 作っていたら、複雑だと言われました。

なぜ複雑にするのか

  • とにかく全部CDNに載せるためです
  • サーバーアプリケーションを使う場合に生じるいろいろな問題を無くしてパフォーマンスを高めることができます。(参照:The JAM Stack - Speaker Deck

JAMstack

日本ではあまり知名度が無いタームなのですが、おそらく最近のフロントエンドとしては当たり前にやってるようなことです。

(追記)
Netlify Meetup Tokyo #2でJAMstackについて調べてLTしました。
JAMstackってなんなのか問題に立ち向かう

さらに技術書典5でJAMstack本を書きました。
JAMstack 完全入門 ハイパフォーマンス Web サイト構築 - soussune - BOOTH(同人誌通販・ダウンロード)

サーバーアプリケーションで構成する場合

普通なやり方ですが、モニタリングやメンテナンス、脆弱性対応などが大変です。

JAMstackなやり方

事前ビルドしてサイト内容を全て静的コンテンツとしてCDNに置いてしまえばそうした問題からは解放されます。

パーフェクトJAMstack(コンテンツデータの静的化)

  • なにがパーフェクトなのかという定義は無いのですが、Nuxtで作る場合に注意しないといけない点があります
  • Nuxtの場合、pageコンポーネントのasyncDataでそのrouteに応じたコンテンツデータをAPIから取得してページを静的書き出しすることが多いと思います
  • ただしクライアントサイドルーティングで遷移する際にもasyncDataが呼ばれるので、毎回APIとの通信が発生しますし、async処理が完了しないとページ遷移もされず体験性が悪くなります
  • そのためAPIレスポンスを静的アセットとして書き出し、クライアントからは直接APIを呼ばないようにするというのが重要なところだと思います
  • Gatsbyであればコンテンツデータの取得は標準でGraphQL層を通してAPIにアクセスする方式なので、ビルドするとデータは自動的に静的化されます

Flamelinkはどうなんですか

  • 開発中のβ版なのでいろいろ厳しいところはある
  • データベースはCloud FirestoreではなくRealtime Databaseを使用。メディアはFirebase Storage
  • 画像を上げると設定したサイズでサムネイルを作ってくれる機能などがある
  • webhook無いの?って聞いたら開発ロードマップにあるけど4,5ヶ月かかるって言われた(ので、functionsでビルドをトリガするようにした)
  • コンテンツのテキストエディタが3種類あるけどちょっといまいち。bookmarkletで無理やり拡張したりした。
  • コンテンツ一覧画面が新着順じゃない。ソート機能が無い。記事が増えるとつらそう
  • 編集機能で言えばやっぱりWordPressは強いと思う
  • せっかくフロントやってるんだから今後はコンテンツ管理画面も自分で作っていきたい感が高まってしまう
  • 参考:OSSのCMSフレームワーク Canner/canner: Universal Content Management System(CMS) framework using React & Apollo GraphQL, for GraphQL and Restful API

編集プレビュー機能

  • わりとheadless CMSの課題なんじゃないかと思いますが、静的に書き出す場合のプレビューは開発サーバーで見るのが前提のようなものなので、開発者以外の人用に編集内容をプレビューする環境が必要になります
  • 今回は結局静的な専用ページを作って対応。Realtime DatabaseをListenしてプレビュー画面に自動反映されるように設定しました。

Nuxt

APIを静的化する

modules/api.js
module.exports = async function apiModule(moduleOptions) {

  // nuxtのビルド前
  this.nuxt.hook('build:before', async ({ isStatic, isDev }) => {

    // 必要なデータを全て取得
    console.log('**[generate]** fetch all data')
    const app = createApp()
    const posts = await app.content.get('posts', { //...略

    // jsonに書き出す
    this.options.build.plugins.push({
      apply(compiler) {
        compiler.plugin('emit', (compilation, cb) => { //...略


    // プリフェッチを設定
    console.log('**[generate]** add prefetch link')
    this.options.head.link = [
      ...this.options.head.link,
      ...refsPath.map(path => ({
        rel: 'prefetch',
        href: `${this.options.build.publicPath}${path}`
      }))
    ]

    // dev時はここで終了
    if (!isStatic) return

    // routesを生成
    console.log('**[generate]** generate routes')
    const routes = [
      ...posts.map(post => `/posts/${post.slug || post.id}`) //...略

    // generate時にexpress立ててhttpでjson取得できるようにする
    this.requireModule(['@nuxtjs/axios'])
    this.nuxt.hook('build:done', generator => {
      console.log('**[generate]** opening server connection')
      const app = express()
      app.use(express.static(this.options.generate.dir))
      const server = app.listen(process.env.PORT || 3000)

      this.nuxt.hook('generate:done', () => {
        console.log('**[generate]** closing server connection')
        server.close()
      })
    })
  })
}
  • モジュールにこんな感じに書いて、API内容をjsonファイルとroutesに書き出します
  • コンポーネントからはAPI直接ではなく、emitされたjsonファイルにアクセスします
  • たぶんGatsby使ったほうが楽…

PWA

nuxt.config.js
  workbox: {
    _runtimeCaching: [
      {
        urlPattern: '/_nuxt/contents/.*',
        handler: 'staleWhileRevalidate'
      },
      {
        urlPattern: '/_nuxt/.*',
        handler: 'cacheFirst'
      }
    ],
    runtimeCaching: [
      // Cache the Google Fonts stylesheets with a stale while revalidate strategy.
      {
        urlPattern: 'https://fonts.googleapis.com/.*',
        handler: 'staleWhileRevalidate',
        strategyOptions: {
          cacheName: 'google-fonts-stylesheets'
        }
      },
      // Cache the Google Fonts webfont files with a cache first strategy for 1 year.
      {
        urlPattern: 'https://fonts.gstatic.com/.*',
        handler: 'cacheFirst',
        strategyOptions: {
          cacheName: 'google-fonts-webfonts',
          cacheableResponse: {
            statuses: [0, 200]
          },
          cacheExpiration: {
            maxEntries: 100,
            maxAgeSeconds: 60 * 60 * 24 * 365
          }
        }
      }
    ]
  },
  • nuxtのPWAモジュール使用
  • PWAモジュールはデフォルトで、/_nuxt/.*をcacheFirst、/.*をnetworkFirstにする設定。
  • /_nuxt/.*内の一部を別設定に変えたい場合は、runtimeCachingで設定しても優先度が負けてしまうので、隠しプロパティの_runtimeCachingで設定する。
  • Common Recipes  |  Workbox  |  Google Developers に、いろいろ設定例あり。google fontsなどもキャッシュできます

mount時の画面サイズに応じて画像サイズ出し分け

flamelinkでサイズ別画像作ってくれるので、せっかくなので3種類ほど出し分けしてます

まとめ

ブログはフロント技術を試すのにはちょうどいい場だと思います。
データベース、管理画面、フロントをそれぞれ好きな技術を組み合わせて最強のブログを作りましょう!