Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Firebase と Nuxt.js で個人ブログを作った話

こんにちは、 2018年モバイルファクトリーアドベントカレンダー 12/7担当の id:yunagi_n です。


はじめに

個人的な趣味で、 Firebase と Nuxt.js でブログを作っています。
そのことについて、いろいろ話します。
なお、会社で開発しているアプリ・プロジェクトとは一切関係ありません

前提

以下の環境で開発、動作確認を行っています。

  • Firebase (Blaze プラン)
  • Node.js 8.12.0
  • Windows Subsystem for Linux (WSL) Ubuntu 16.04
  • Nuxt.js 2.3.4
  • TypeScript 3.2.1
  • Firebase と Nuxt.js のプロジェクトは作成済み

本題のその前に

なぜ nuxt generate で生成した静的ファイルを使わないのか、についてです。
あくまで個人的な思いなのですが、

  • 記事を Git 管理したくない
  • 記事を publish するために、いちいち (たとえ CI であろうとも ) ビルド & デプロイを行いたくない

という理由がありました。
とくにビルド時間というのはそこそこかかるので、やらなくても良いのならば、やらないに越したことはないです。

作るブログ

ブログとして最低限の機能のみの実装です。
Firestore の制約上、記事の検索は出来ないので要件に含めていません。

  • 記事を投稿できる
    • URL は ID ではなく、yyyy/MM/slug 形式で自由に設定できる
      • 例: 2018/12/my-favorite-music
    • 記事にはカテゴリを複数個付けられる
  • カテゴリで絞り込みが出来る
  • 投稿月で絞り込みが出来る
  • 投稿数が分かる
    • カテゴリ名 (投稿記事数)yyyy/MM (投稿記事数) の形式で表示する

実装

Firestore の設計

Cloud Firestore は NoSQL データベースのため、 RDB のような SELECT COUNT を用いたカウントは行えません。
また、読み取ったドキュメント数によって課金が行われるだけではなく、読み取る数が多くなるとレスポンスも悪化するので、
一度の操作で読み取るドキュメント数は、できる限り抑えるべきです。
そのため、投稿数などは次のようなドキュメントに記録しました。

// カテゴリー
export interface Category {
  name: string;
  count: number;
}

ドキュメントへの記事数の記録は、後述する Cloud Functions の Cloud Firestore トリガー を使って行います。

ブログ記事は単純な、以下のドキュメントにしました。

export interface Entry {
  slug: string;
  title: string;
  body: string;
  created_at: Date; // 実際は秒数が降ってきます
  categories: string[];
}

Cloud Functions

Cloud Functions では、記事の投稿・更新・削除をトリガーに集計を行う処理と、ページの描画処理を登録します。
例えば記事を投稿したときの処理は

import * as firebase from "firebase-admin";
import * as functions from "firebase-functions";

module.exports = functions.runWith({
  memory: "256MB",
  timeoutSeconds: 30
}).firestore.document("entries/{entryId}").onCreate(async (snapshot) => {
  const entry = snapshot.data() as Entry;

  for (let name of entry.categories) {
    const category = await firebase.firestore().collection("categories")
      .where("name", "==", name)
      .limit(1)
      .get().docs.shift();

    if (category) {
      // すでにあればカウントアップ
      await category.ref.update({
        count: (category.data() as Category).count + 1
      });
    } else {
      // 無ければ作る
      await admin.firestore().collection("categories").doc().set({
        name,
        count: 1
      });
    }
  }
});

となります。
更新・削除も同様の処理を記述していけば OK です。

Nuxt.js と Cloud Functions での SSR は以下の通り。
buildDir プロパティは、うっかりソースコードからの相対パスで書いてしまうと、何も返ってこなくなります。

import * as express from "express";
import * as functions from 'firebase-functions';
import { Nuxt } from "nuxt";

const app = express();

const nuxt = new Nuxt({
  dev: false,
  buildDir: "./lib/.nuxt", // package.json がある場所からのパス
  build: {
    publicPath: "/assets/"
  }
});

async function handleRequest(req, res) {
  return await nuxt.render(req, res);
}

app.use(handleRequest);

module.exports = functions.runWith({
  memory: "256MB",
  timeoutSeconds: 20
}).https.onRequest(app);

Firebase Hosting

firebase.json を編集して、 Firebase Hosting のリライトルールを変更します。
基本的には全てのリクエストを Cloud Functions の関数を呼び出すように設定します。

{
  "hosting": {
    "public": "dist/client",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "render"
      }
    ]
  }
}

Nuxt.js

npx create-nuxt-app で作ったものをそのまま使います。
プリセットにある Tailwind CSS を使うと、CSS が苦手でも悩まされることなく UI を作れるのでオススメです。

Nuxt.js の Firebase の初期化は、 plugins/firebase.ts で行います。
これは Firebase の初期設定ページに表示されているものをそのままコピペしてきます。

import * as firebase from "firebase";

if (!firebase.apps.length) {
  const config = {
    apiKey: "xxxxxxxxxx",
    authDomain: "xxxxxxxxxx.firebaseapp.com",
    databaseURL: "https://xxxxxxxxxx.firebaseio.com",
    projectId: "xxxxxxxxxx",
    storageBucket: "xxxxxxxxxx.appspot.com",
    messagingSenderId: "1234567890"
  };
  firebase.initializeApp(config);
}

export { firebase };

そして、 nuxt.config.jsplugins プロパティに plugins/firebase.ts へのパスを追加しておきます。

plugins: [
  "~/plugins/firebase.ts"
]

Firestore からデータを持ってきて、 SSR もしくはページ遷移時に取得するには、 asyncData メソッドを使用します。
ファイル名・ディレクトリ名を /_year/_month/_slug.vue のようにすることで、 引数として context が渡されるので、
その中の params プロパティ経由で、 year, month, slug といったパラメータを受け取ることが出来ます。

// SFC の script 部分のみ
import { Component, Vue } from "nuxt-property-decorator";
import { firebase } from "~/plugins/firebase";

@Component
export default class extends Vue {
  public async asyncData({ params }) {
    // const { year, month, slug } = params; でパラメータ取得できます。
    const entry = await firebase.firestore()...; // 記事取得
    return { entry };
  }
}

asyncData メソッドの返り値は data メソッドの返り値とマージされるので、あとは通常の Vue SFC のように
テンプレートや他のメソッドなどを書くだけで OK です。

ブログサイドメニューにあるような、カテゴリ一覧や月別アーカイブは、 store でデータを取得しました。

// 必要な部分のみ

const actions = {
  // ページ遷移でサイドメニュー更新しなくても良いので nuxtServerInit メソッドを使っています
  async nuxtServerInit ({ dispatch }, context) {
    await dispatch("fetchArchives");
    // ...
  }
};

export default () => new Vuex.Store({
  actions,
  // ...
})

あとは、この値を Getter や store プロパティ経由で取得・設定することで、サイドメニューの表示が実装できます。

まとめ

簡単にですが、 Firebase と Nuxt.js を使うことで、個人でもブログのような Web アプリケーションを作る事を紹介しました。
明日は @umaaaaa さんの記事です!