Nuxt.js で Firebase SDK の自動構成を使おうとしたら死ぬほど苦労した話

はじめに

Nuxt.js + Firebase の自動構成 + ローカルでは固定のFirebaseConfig をやろうとしたらすんごい苦労した & どこにも記事がなかったので、自分への備忘録も含め記事にしてみました。

お詫び

まず最初にお断りしておきますが、本記事では 明確な解決法は出ておりません。 一応、動くところまでは行き着いたのですが、絶対もっといい方法があるよなーというコードになっています。
なので、解決方法を知りたくてこの記事をご覧になった方は残念ながら、あまりお役には立てません。
もし解決方法をご存知の方がいらっしゃいましたら、是非コメントに書いていただけると幸いです。

今、JavaScript界隈でVue.jsがアツいらしいです。そして、Vue.js で Webをやるには Nuxt.js というフレームワークがとても人気らしく、これさえ使えればナウいヤングの間でも一目置かれる存在になれるそうです。アラサーになり、そろそろ 学園物ドラマを見ていると疲れが出てくるようになった私 には朗報です。

さらに、Firebaseというプラットフォームを使いこなせれば人生が変わったという証言まであります。これは億万長者・酒池肉林も夢じゃないです。

「…やらないという手はないやろ」

と言うわけで、この2つを使って勉強がてら Web アプリケーションを作ってみました。

第0形態:一般的な Firebase SDK の呼び出し

Nuxtの構築 や Firebase の初期設定については、検索すれば星の数…とまでは行かなくても 鳥取県の人口くらいの数 の記事が出てくるので割愛します。

今回作ったのは Firebase Auth を使ってユーザー認証を行うページです。
様々なサンプルにあるように、 plugins 配下で以下のように firebase を初期化して

import firebase from 'firebase'

const firebaseConfig = {
     apiKey: "api-key",
     authDomain: "project-id.firebaseapp.com",
     databaseURL: "https://project-id.firebaseio.com",
     projectId: "project-id",
     storageBucket: "project-id.appspot.com",
     messagingSenderId: "sender-id",
     appID: "app-id",
  };

if (!firebase.apps.length) {
  firebase.initializeApp(firebaseConfig)
}

export default firebase

pages 以下の .vue ファイルで import して使います。

<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from '~/plugins/firebase'

export default {
  mounted() {
    let auth = firebase.auth();
 (略)
  }
}
</script>

さて、ここまでは全然難しくありません。
問題はここからです。

第0.5形態: SDK の自動構成を使う

練習がてら作っているこのアプリですが、最終的にはサービスとしてリリースをする予定です。
なので、dev 環境と production 環境の2種類の Firebase プロジェクトを作り、それぞれの Firebase Hosting にデプロイすることを考えました。

すると、上記 firebase.js のような API Key べた書きなコードだと問題が発生します。デプロイした環境に応じて適切な設定で初期化をしたいです。
幸いにも、Firebase はその様な機能を提供しており公式ドキュメントの SDK の自動構成という項目にやり方が書いてあります。

/__/firebase/init.json というスクリプトを Hosting 環境で実行すると、適切な設定ファイルが入手できるそうです。
ドキュメントのやり方に従って firebase.js を書き換えます。

import firebase from 'firebase'

fetch('/__/firebase/init.json').then(async response => {
  firebase.initializeApp(await response.json());
});

export default firebase

めでたしめでたし。

…と思いきや、今度は別の問題が発生します。これでは nuxt コマンドを使った開発ができません。
ローカルでデプロイのテストを行うには firebase serve コマンドで行うことが出来ます。(詳しくはこちら)

つまり、nuxt generate; fireabase serve とやれば一応ローカルでも上記コードで動かすことが出来るのですが、ちょっとの修正でも毎回確認に1分ほど待つ必要があり、まるで Java で大型プロジェクトを開発しているような気分になります。



と言うわけで、 ローカルの時はconfigを指定し、Firebase Hosting 環境では自動構成を使う にはどうしたらいいかを考えました。

第1形態: とりあえず context を呼んでみる

この手のフレームワークには必ずと言っていいほど isDevelop の様な定数が定義されています。
Nuxt にもない訳がありません。

検索してみると、案の定 context と言うオブジェクト内に isDev と言う定数があることが分かりました。
ドキュメントによると…
開発モードであるかどうかを知らせます。
随分ざっくりとした説明ですが、多分、nuxt の時は true になって、nuxt buildnuxt generate の場合は false になる、んだよね?私は信じます。

また、公式ドキュメントの Context のページにはサンプルコードが載っているのですが
function (context) {
  // Universal keys
  const {
    app,
    store,
    route,
    params,
    query,
    env,
    isDev,
    isHMR,
    redirect,
    error
  } = context
  // Server-side
  if (process.server) {
    const { req, res, beforeNuxtRender } = context
  }
  // Client-side
  if (process.client) {
    const { from, nuxtState } = context
  }
}
ちょっと何言ってるか分からないです。

唐突に出てくる関数定義、全く見えないコードの全体像、サンプルコードの次の項目を見てもそれぞれの定数の意味の解説しかありません。
中々に玄人志向なドキュメントですね。

分かんないときは とりあえず動かします。
context.isDev に必要な値が入っていることは分かったので、それを使って分岐させてみましょう。
import firebase from 'firebase'

if (context.isDev) {
  const firebaseConfig = {
     apiKey: "api-key",
     authDomain: "project-id.firebaseapp.com",
     databaseURL: "https://project-id.firebaseio.com",
     projectId: "project-id",
     storageBucket: "project-id.appspot.com",
     messagingSenderId: "sender-id",
     appID: "app-id",
   };
  
  if (!firebase.apps.length) {
    firebase.initializeApp(firebaseConfig)
  }
} else {
  fetch('/__/firebase/init.json').then(async response => {
    firebase.initializeApp(await response.json());
  });
}

export default firebase
<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from '~/plugins/firebase'

export default {
  mounted() {
    let auth = firebase.auth();
 (略)
  }
}
</script>
さて、実行させてみると…



ですよねー
context is not defined と言われてしまいました。

第2形態: サンプルコードに従って context を使う

ちゃんとドキュメントを読んでみましょう。

Nuxt公式ドキュメントのプラグインのページにコンテキストを使ったサンプルコードが載っていました
export default ({ app }, inject) => {
  // context.app オブジェクトへ関数を直接セットします
  app.myInjectedFunction = string => console.log('Okay, another function', string)
}
コレを見るに、plugin では export default の関数の場合、 context が引数として渡されるみたいです。
(他にも渡される条件があるのかもしれませんが、ドキュメントを読んでも分かりませんでした。。知ってる方がいましたら、教えてください!)

ちなみに、 { app } というナウい書き方は 分割代入 (Destructuring assignment) 構文 というらしいです。詳しくはMDNのこのページを見てください。

import firebase from 'firebase'

export default (context) => {
  if (context.isDev) {
    const firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id",
    };
    
    if (!firebase.apps.length) {
      firebase.initializeApp(firebaseConfig)
    }
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      firebase.initializeApp(await response.json());
    });
  }

  return firebase
}
<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from '~/plugins/firebase'

export default {
  mounted() {
    let auth = firebase.auth();
 (略)
  }
}
</script>
実行してみます。


_plugins_firebase__WEBPACK_IMPORTED_MODULE_6__.default.auth is not a function と言われてしまいました。
これは__想定内__です。
今までは login.vuefirebase 変数には値が入っていましたが、今回の書き方では関数が入っています。

なので、 firebase.auth() としても firebase の中に auth() なんてものがないと言われてしまうのです。
と言う訳で、firebase を実行してあげましょう。
firebase().auth() と書き換えます。

<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from '~/plugins/firebase'

export default {
  mounted() {
    let auth = firebase().auth();
 (略)
  }
}
</script>

これで動くはずです。実行してみます。


!?


なんでや!! contextundefined になっています。
この後、ドキュメントのサンプルコードにあった ctx-inject.js を実行してみたところ、やはり同じエラーが出ました。

Nuxt のバグでも踏んだか…?

…改めて、 Nuxt のプラグインのドキュメント を読んでみます。
それから nuxt.config.js の plugins キー内にファイルパスを追加します:

あ、これやってねぇ

いや、知ってましたよ。自分、ドキュメントを上から下まで全部読まないと先に進まない派なんで、知ってましたよ。
ドキュメントも禄に読まずにエラー起こしてパニックなってたのが俺なんやとしたら、俺なんで今こんな堂々としてる?


…ということで、 nuxt.config.js も書き直しまして
(略)
  plugins: [
    '@/plugins/firebase.js'
  ],
(略)
改めて実行してみます。


ッッッッッ!!!!!


…一旦冷静になりましょう。
あくまでも個人的な推測なのですが、plugins のファイルがロードされる最初のタイミング以外では、 context が undefined になってしまうのではないかと考えられます。

これは、context が asyncData、fetch、plugins、middleware、そして nuxtServerInit のような特別な nuxt ライフサイクル内でしか利用可能でないことから予想されます。

何より動かないのでこの方法は没です。

第2.5形態:即時関数で書いてみる

第2形態の反省を踏まえ、 login.vue 内で firebase を実行するのはやめます。
となると、JavaScript をやっているとこう書きたくなってしまいます。
import firebase from 'firebase'

export default (context) => {
  if (context.isDev) {
    const firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id",
    };
    
    if (!firebase.apps.length) {
      firebase.initializeApp(firebaseConfig)
    }
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      firebase.initializeApp(await response.json());
    });
  }

  return firebase
}()
関数定義して最後に () 付ければ値が返ってくれるのではないかという希望にかけて実行してみます。

nuxt コマンドでビルドしてみると

ERROR  Failed to compile with 1 errors
ERROR  in ./plugins/firebase.js
Syntax Error: Unexpected token, expected ";" (26:1)

…シンタックスエラーになりました。残念。

第3形態: FirebaseConfig を Vue オブジェクトに渡す

今までは割と「こんな風に書いたら動くやろ」で書いて動いてくれてたんですが、今回はちっとも上手くいきません。
どうしよう。俺、こんな状況 生まれて初めてだ

もう、若干コードが汚くなっても動く事だけを考えます。

今までは firebase.js で初期化した firebase オブジェクトを渡すことを考えていましたが、全く別のアプローチで行きます。
firebase.js では 設定だけを定義して、それぞれの使用するファイル内で initializeApp するという方法です。

まず、 firebase .js で環境毎の設定を定義し、それを Vue オブジェクトに入れます。

import Vue from 'vue'

export default (context) => {
  if (context.isDev) {
    Vue.prototype.$firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id",
    }; 
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      Vue.prototype.$firebaseConfig = await response.json();
    });
  }
}

そして、pages 以下のファイルでは Vue.$firebaseConfig を使って初期化を行います。

<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from 'firebase'

export default {
  mounted() {
    firebase.initializeApp(this.$firebaseConfig);
    let auth = firebase().auth();
 (略)
  }
}
</script>
実行してみます。

おぉー、やっと動きました。36時間ぶりに見るログイン画面です。

しかしながら、この方法だと firebase を使うページ(≑全ページ)で initializeApp する必要があり、非常に面倒です。

の再来です。本当にこれでいいんでしょうか…?

世界に助けを求めてみる

自分の知識量と発想力ではここが限界点だと感じました。
というか、こんな作業に既に1日半かけていました。こんなことしてる場合ではないのです。

と言う訳で、StackOverflow で質問をしてみました。
第2形態や第3形態を書くと「何言ってんだコイツ」となりそうだったので、一番シンプルな第1形態の状態で質問を投げて、詳しい人がやり方をレクチャーしてくれることに期待します。
ちなみに、前に質問をしたときは 30分でCloseされました。今回はどうでしょうか。

5日ほど寝かせて改めて見に行ってみました。








お口チャックマンか!

母さん、インターネットは冷たいです。

第4形態: plugin では初期化だけを行う

数日寝かせてコードを改めて見ていると、ある仮説が頭をよぎりました。
これ、 initializeApp した firebase オブジェクトを pages に渡す必要あるのか?

つまり、plugins/firebase.js では初期化だけを行い、
pages/* では素の import firebase from 'firebase' を使うという方法です。
試しに書いてみました。

import firebase from 'firebase'

export default (context) => {
  if (context.isDev) {
    const firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id",
    };

    if (!firebase.apps.length) {
      firebase.initializeApp(firebaseConfig)
    }
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      firebase.initializeApp(await response.json());
    });
  }
}
<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from 'firebase'

export default {
  mounted() {
    let auth = firebase().auth();
 (略)
  }
}
</script>
実行してみます。


うわ、動いちゃったよ

コードとしては今までで一番すっきりしているんですが、この時 firebase オブジェクトの状態がどうなってるのかちっとも分かりません。
これ、アレですね。

いや、ヨシじゃねぇ!これ本番で使うの怖すぎる。

まだ冗長でも、動く原理が分かる第3形態の方がましです。
この辺りに詳しい方いらっしゃいましたら、これでいいのか、それともこれじゃマズいのか教えていただけると有り難いです…。

暫定結論

Nuxt.js + Firebase の自動構成 + ローカルでは固定の設定 としたい場合、とりあえずこうやれば動きます。
…冗長ですが。

(略)
  plugins: [
    '@/plugins/firebase.js'
  ],
(略)
import Vue from 'vue'

export default (context) => {
  if (context.isDev) {
    Vue.prototype.$firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id", 
    }; 
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      Vue.prototype.$firebaseConfig = await response.json();
    });
  }
}
<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from 'firebase'

export default {
  mounted() {
    firebase.initializeApp(this.$firebaseConfig);
    let auth = firebase().auth();
 (略)
  }
}
</script>

こうやっても動きますが、本当にこれでいいのかは…

(略)
  plugins: [
    '@/plugins/firebase.js'
  ],
(略)
import firebase from 'firebase'

export default (context) => {
  if (context.isDev) {
    const firebaseConfig = {
      apiKey: "api-key",
      authDomain: "project-id.firebaseapp.com",
      databaseURL: "https://project-id.firebaseio.com",
      projectId: "project-id",
      storageBucket: "project-id.appspot.com",
      messagingSenderId: "sender-id",
      appID: "app-id",
    };

    if (!firebase.apps.length) {
      firebase.initializeApp(firebaseConfig)
    }
  } else {
    fetch('/__/firebase/init.json').then(async response => {
      firebase.initializeApp(await response.json());
    });
  }
}
<template>
<h1>Login Form</h1>
(略)
</template>

<script>
import firebase from 'firebase'

export default {
  mounted() {
    let auth = firebase().auth();
 (略)
  }
}
</script>

おわりに

と言う訳で、この記事を見てくださった方の中で、詳しい方がいらっしゃいましたら、どうか正解を教えてください(泣)

参考

コメント