Tamanyan.net

Firebase + Nuxt.js + TypeScript を用いて、SSR に対応した Web アプリを構築する上での知見を共有する。

この記事でわかる事

  • TypeScript で Nuxt.js を書く方法
  • Jest を利用した Nuxt.js のテスト
  • Firebase で Nuxt.js のアプリを SSR (サーバサイドレンダリング) する方法
  • Firebase の開発環境と本番環境の分け方

今回作成したサンプルは tamanyan/nuxtjs-firebase に公開しておくので、必要であれば見てほしい。

作成したサンプルアプリへのリンク

プロジェクトのディレクトリ構成

> tree -L 2 -I 'node_modules' .
.
├── app # App directory
│   ├── functions # Cloud Functions
│   └── nuxt # Nuxt.js
├── dist
│   └── functions # Output
├── firebase.json
├── package.json
├── public # Firebase Hosting
│   ├── assets # Nuxt.js assets
└── yarn.lock

Vuex からモックのユーザー情報(Username/Email)を取得して表示するアプリを作成した。

Sample App

Nuxt.js の TypeScript テンプレート

今回のサンプルアプリは Nuxt Community の TypeScript で書かれた Nuxt.js のテンプレートを使用した。 なお vue-cil 以下のように作成できる。

vue init nuxt-community/typescript-template nuxtjs-firebase

※ package.json を見ると TypeScript の Version が 2 系になっているので、適宜修正する。

TypeScript での Vuex の書き方

Vuex には既に TypeScript の型定義ファイルが存在するので、特に問題はない。 アプリがスケールする事を考え、profile という module を作成する事にする。

$ tree app/nuxt/store
app/nuxt/store
├── index.ts
├── profile
│   ├── actions.ts
│   ├── getters.ts
│   ├── index.ts
│   ├── mutations.ts
│   └── types.ts
└── types.ts

app/nuxt/store/index.ts

import Vuex, { StoreOptions } from 'vuex';
import { RootState } from './types';
import { profile } from './profile';

const storeOptions: StoreOptions<RootState> = {
  state: {
    version: '1.0.0' // a simple property
  },
  actions: {
    // nuxtServerInit({ commit }, { app }) {
    // }
  },
  modules: {
    profile
  }
};

export default () => new Vuex.Store<RootState>(storeOptions);

ユーザー情報を取得には本来であれば HTTP リクエストを呼ぶところだが、今回はモックデータを使用する。

app/nuxt/store/profile/actions.ts

import { ActionTree, ActionContext } from 'vuex';
import { ProfileState, User } from './types';
import { RootState } from '../types';

export const actions: ActionTree<ProfileState, RootState> = {
  fetchData(context: ActionContext<ProfileState, RootState>): any {
    // Mock data
    const payload: User = {
      firstName: 'Taketo',
      lastName: 'Yoshida',
      email: 'sample@test.com'
    };

    context.commit('profileLoaded', payload);

    // Make http request in order to get user info instead of mock
  }
};

app/nuxt/store/profile/getters.ts

import { GetterTree } from 'vuex';
import { ProfileState } from './types';
import { RootState } from '../types';

export const getters: GetterTree<ProfileState, RootState> = {
  fullName(state: ProfileState): string {
    const { user } = state;

    return `${user.firstName} ${user.lastName}`;
  }
};

app/nuxt/store/profile/mutations.ts

import { MutationTree } from 'vuex';
import { ProfileState, User } from './types';

export const mutations: MutationTree<ProfileState> = {
  profileLoaded(state: ProfileState, payload: User) {
    state.user = payload;
    state.error = false;
    state.isReady = true;
  },
  profileError(state: ProfileState) {
    state.error = true;
    state.isReady = false;
  }
};

ProfileState の user を Optional にする事を最初に考えたが、上手く Observer が動作しないのが悲しい。

app/nuxt/store/profile/types.ts

export interface User {
  firstName: string;
  lastName: string;
  email: string;
  phone?: string;
}

export interface ProfileState {
  user: User;
  isReady: boolean;
  error: boolean;
}

Component と Vuex の書き方

ktsn/vuex-class を使用するとデコレーターをつけて Vue のコンポーネントを作成できる。 慣れると問題はないのだが、これ Angular かな?と想いながら書いていて違和感満載だった。 デコレータを使用せずとも書くことができるので、そこは各々選択ではないかと思う。

app/nuxt/pages/index.vue

<script lang="ts">

import AppLogo from '~/components/AppLogo.vue';
import UserProfile from '~/components/UserProfile.vue';
import { State, Action, Getter } from 'vuex-class';
import { Component, Vue } from 'nuxt-property-decorator';
import { ProfileState, User } from '~/store/profile/types';

const namespace: string = 'profile';

@Component({
  components: {
    AppLogo,
    UserProfile
  }
})
export default class extends Vue {

  @State('profile') profile: ProfileState;

  @Action('fetchData', { namespace }) fetchData: any;

  @Getter('fullName', { namespace }) fullName: string;

  mounted() {
    this.fetchData();
  }

  // computed variable based on user's email
  get email() {
    const { user } = this.profile;

    return user.email;
  }
}

</script>

Nuxt.js のテスト

テストは Jest を使用して書いた。new Builder(nuxt).build() によって毎回ビルドが走るので、テストの実行が遅いのが難点。 なお Testing - Nuxt.js を主に参考にした。

app/nuxt/__tests__/index.test.ts

import { resolve } from 'path';
const { Nuxt, Builder } = require('nuxt');

jest.setTimeout(30000);

let nuxt = undefined;

beforeAll(async () => {
  const config = require('../nuxt.config.js');
  config.dev = false;
  config.rootDir = resolve(__dirname, '..');
  config.ssr = true;

  nuxt = new Nuxt(config);

  await new Builder(nuxt).build();
});

describe('Rendering test', () => {
  // Example of testing

  test('Route / exits and render HTML without error', async () => {
    const context = {};
    const { error } = await nuxt.renderRoute('/', context);

    expect(error).toBe(null);
  });
});

// Close server and ask nuxt to stop listening to file changes
afterAll(() => {
  nuxt.close();
});

Cloud Functions を使用した Nuxt.js の SSR(サーバサイドレンダリング)

Cloud Functions 上に ssr という関数を作成する。SSR をするには Nuxt.js で作られたアプリをビルドし、出力したディレクトリを buildDir として設定する。 デプロイする JavaScript ファイルなどのプログラムは全て dist/functions 以下に出力するように設定しておく。

app/functions/src/ssr.ts

import * as express from 'express';
const { Nuxt } = require('nuxt');

const app = express();
const nuxt = new Nuxt({
    dev: false,
    buildDir: 'nuxt',
    build: {
      publicPath: '/assets/'
    }
});

app.use(nuxt.render);

export = app;

app/functions/src/index.ts

import * as functions from 'firebase-functions';

export const ssr = functions.https.onRequest(require('./ssr'));

Firebase Hosting の設定で全てのリクエストで ssr を呼ぶようにする。

firebase.json

{
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [{
      "source": "**",
      "function": "ssr"
    }],
    "headers": [
      {
        "source" : "**/*.@(jpg|jpeg|gif|png|ico|webp)",
        "headers" : [ {
        "key" : "Cache-Control",
        "value" : "max-age=604800"
        } ]
      }, {
        "source" : "**/*.@(js|css)",
        "headers" : [ {
        "key" : "Cache-Control",
        "value" : "max-age=604800"
        } ]
      }
    ]
  },
  "functions": {
    "source": "dist/functions",
    "predeploy": "yarn build"
  }
}

開発の流れ

Nuxt.js には開発用のコマンドが存在するので、Cloud Functions は基本的に開発時に使用する事はない。 以下のコマンドを入力すれば、フロントエンドのアプリが立ち上がるので、http://localhost:3000 にアクセスすれば良い。

$ yarn dev

しかし SSR の挙動を確かめたい場合には、一度 Nuxt.js 側をビルドして dist/functions に吐き出し、Cloud Functions Emulator を立ち上げる。 後は http://localhost:5000 アクセスすれば良い。

$ yarn build # Build Nuxt app and Cloud Functions
$ yarn serve # Launch local emulator
$ firebase serve --only hosting,functions

=== Serving from '<path_to_proj>/nuxtjs-firebase'...

i  functions: Preparing to emulate functions.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000

開発・本番環境の分け方

基本的に同一アカウント内に開発環境 dev-sample-app と本番環境 sample-app の両方を作成している。 他にも環境なども必要であれば適宜用意する。

{
  "projects": {
    "default": "dev-sample-app",
    "production": "sample-app"
  }
}

CI/CD の時に環境を分けてテスト・デプロイできるようにしておく。

{
  ...
  "scripts": {
    ...
    "use_dev": "firebase use default --token $FIREBASE_TOKEN",
    "use_prod": "firebase use production --token $FIREBASE_TOKEN"
  }
}

各環境ごとの環境変数は .env.local .env.dev .env.prod と .env ファイルを用意して対応した。

require('dotenv').config()

module.exports = {
  env: {
    APP_ENV: process.env.APP_ENV || 'development',
    APP_BASE_URL: process.env.APP_BASE_URL || 'http://localhost:3000'
  },
  ...
}

悩みどころ

Cloud Functions はアクセスが少ないと Cold Start から起動するので遅い

Cloud Functions は開発中だと起動が遅く SSR に時間がかかっていた。「本番で大丈夫なのかな?」と心配になっていたが、アクセスが増えると解決された。

Blue-Green Deployment

デプロイ時のダウンタイムはできれば無くしたい。しかしバージョンニングができないところが辛い。AWS Lambda だとできるみたいだが、その辺はきっと開発してくれると信じている。

AWS Lambda Function Versioning and Aliases

Google Cloud Functions Emulator が Node 6 しか対応していない

Nuxt.js は version 1.0.0 以降から Node 8 以降しか対応していない。Cloud Functions は Node 8 に対応できるようになった。しかし、ローカルでの開発をする上での Google Cloud Functions Emulator が Node 6 しか対応していない(※ 2018/09/12 時点)。

The Emulator only supports Node v6.x.x. It does not support Node v8.x.x or Python

今の所構わず使用しているが、一応ちゃんと動作している。デプロイ後は Emulator を使用しないので、目をつむっている。

Cloud Firestore との連携

Cloud Firestore との連携を考えるのであれば、おそらく Vuex の中で使用する事になるだろう。Cloud Firestore は非常に強力だが、マルチプラットフォームが当たり前になっている現代ではビジネスロジックを何度も書く事になる大変だ。直接の使用は一部に留め、 API にラップして使用した方が良いかもしれない。私の場合は Cloud Functions の中で呼ぶことが多い。バックエンドだけでしか呼ばないのであれば、もはや Cloud Datastore で良いのではないかと思うこともある。

参考URL


たまにゃん・バンコクで働くエンジニア🇹🇭
バンコク(タイ)のフルスタックエンジニア。前職では日本経済新聞社で iOS エンジニアとして働き、2018 年からタイの自動車系の会社に転職する。一からデジタル組織を作るべく奔走中。タイの事、エンジニアの事のついて情報発信する。