ElevenBack LLC. Engineering

ElevenBack LLC. の開発者ブログです。

inject と Headless Vue インスタンスを活用したリアクティブな認証管理

この記事は Nuxt.js アドベントカレンダー 24 日目の記事です。

ここ一年ほどほとんど Nuxt.js で Vue.js を単体で使うことがめっきり減った @potato4d です。

今回はニッチな話題として、「JavaScript の世界のオブジェクトに Vue.js のリアクティブ機構をもたせる」という話をしたいと思います。

なお、今回はややこしいコードベースを省く意味でも Nuxt.js 環境を前提とします。

実現したい要件

まずは実現したい要件を定義します。
今回は、現在弊社にて開発中の Web サービスのシステムをベースとして考案します。

  1. Firebase Authentication を使ってユーザー情報をやりとりする
  2. データベースヘの取得・保存操作では、 Firebase 側のユーザーの uid を利用するためグローバルから認証情報にアクセスできてほしい
  3. できればその他の Firebase の機能も、一度初期化した情報を使いまわしたい

と、たったこれだけです。

つまりは /users/:uid/resources のようなエンドポイントへのアクセスを可能にする為にも uid はグローバルで引き回せると嬉しい。というわけですね。

Vuex を利用した認証設計の落とし穴

さて、こういった要件を実現する際、みなさんはどういった形で設計するでしょうか。

真っ先に考えられるのが、~/sdk/firebase.js のようなファイルを作った上で、 ~/store/user.js といった形で Vuex の名前空間を用意する方式ではないでしょうか?以下のようなコードです。

import * as firebase from '~/sdk/firebase'

export const state = () => ({
  user: null
})

export const getters = {
  user: state => state.user
}

export const mutations = {
  userUpdated(state, { user }) {
    state.user = user
  }
}

export const actions = {
  applicationInit({ commit }) {
    firebase.auth().onAuthStateChanged((user) => {
      commit('userUpdated', { user } )
    })
  }
}

確かにこれ自体も要件は実現できますし、ポピュラーな実装なのでわかりやすいかもしれません。一方で、実装が Firebase に大きく依存していること、Vuex 内で独自のライフサイクルが生まれており関係を考慮しづらいことなど、いくつか実装上の課題点があります。

また、こういった外部由来のものが Vuex に入ってくるとテスタビリティの観点でも微妙ですね。Nuxt.js の場合、store ディレクトリ内で分割もしづらいのでなおさらです。

inject による依存性注入を実現した明快な状態設計

そこで今回は、inject と Headless な Vue インスタンスを使って、 Vuex に依存しない明快な認証の管理を実現しています。

inject については、 Nuxt.js を使っていても知らない人も多い機能ではないでしょうか。これはその名の通り、 Vue インスタンスへの注入が可能な関数であり、現状 plugins 内にて利用が可能です。

直接使ったことがない人でも、 axios-module を入れると this.$axios や app.$axios が使えるようになることで、多くの人が体験はしているはずです。inject は、任意の Javascript のオブジェクトや値を、Vue のプロトタイプおよび Nuxt.js の Context オブジェクトへと注入してくれます。

React でいう Context のような概念となり、外部注入可能なグローバルオブジェクトというような認識で利用が可能となります。

もちろん、アプリケーション全体で利用可能であるため細心の注意を払う必要はありますが、認証情報を管理する上では非常に有用です。

実際に Nuxt.js で書く場合はこんな感じでしょうか。

import Vue from 'vue'
import * as Firebase from '~/sdk/firebase'

const FirebasePlugin = (context, inject) => {
  inject('firebase', Firebase.firebase)
  inject('app', Firebase.app)
  inject('firestore', Firebase.firestore)
  inject('storage', Firebase.storage)
  inject('functions', Firebase.functions)
  inject('auth', Firebase.auth)
  inject('currentUser', Firebase.auth.currentUser)
}

export default FirebasePlugin

これで this.$currentUser が firebase.auth.currentUser に繋がってくれました。これを利用することで、アプリケーションのコンテキストとして認証情報を利用することが可能となります。

inject の穴を埋める Headless Vue インスタンス

inject も利用できて一件落着……かと思うと、最後に一つ課題が残っています。実は、inject したデータはいずれも Vue インスタンスのプロパティではないため、自動的にリアクティブにはなってくれないのです。

つまり、 this.$currentUser は SPA の初期化時にだけ動くようになっていしまいます。

Firebase Authentication のような SDK で遅延認証するタイプの認証基盤の場合、SPA の起動時はユーザー情報が null であり、ひと呼吸おいてから認証が完了することが少なくありません。そして、そういった場合にユーザーがずっと空のままになってしまうのはアプリケーションとして必要な要件を満たせていないことになります。

こういった時に使える便利なテクニックが、 Getter を利用した、 render を伴わない Vue インスタンスの組み合わせです。Vue 2.5+ の Vue.observable でも類似機能は実装可能ですが、こちらがよりわかりやすく記述できるのではないでしょうか。

import Vue from 'vue'
import { Plugin } from '@nuxt/types'
import * as Firebase from '~/sdk/firebase'

const _auth = (Firebase.auth as any) as firebase.auth.Auth & {
  __defineGetter__: any
  _vm: any
}
if (!_auth._vm) {
  _auth._vm = new Vue({
    data() {
      return {
        currentUser: _auth.currentUser !== null ? _auth.currentUser : undefined
      }
    },
    created() {
      _auth.onAuthStateChanged((user: any) => {
        this.currentUser = user
      })
    }
  })
  _auth.__defineGetter__('currentUser', () => {
    return _auth._vm.$data.currentUser
  })
  _auth.__defineGetter__('user', () => {
    return _auth._vm.$data.currentUser
  })
}

const FirebasePlugin: Plugin = (context, inject) => {
  inject('firebase', Firebase.firebase)
  inject('app', Firebase.app)
  inject('firestore', Firebase.firestore)
  inject('storage', Firebase.storage)
  inject('functions', Firebase.functions)
  inject('auth', _auth as firebase.auth.Auth)
}

export default FirebasePlugin

ポイントは _vm を持つ部分です。 Vue.js のインスタンスとして認証情報を管理し、そのうえで auth 自体の Getter として currentUser を追加。そこから Vue インスタンスの状態を参照指定ます。

こうすることで、認証情報そのものが Vue インスタンスによってリアクティブな値となり、同じく Vue インスタンスとなるコンポーネントともリアクティブに連携できます。

つまり、 this.$currentUser が自動で更新されるようになるわけです。

これで独立したライフサイクルを、最終的な値にだけ関心を持って取り扱えるようになりました。

こうすることで、Vuex に影響を与えずクリーンかつ、テスト時は this.$currentUser が目的のデータとなるようにモックするだけで OK な、シンプルな構造が完成します。

実際に、弊社 Candy ではこの方式で認証情報を管理していますが、トリッキーな記述に抵抗がない場合、 Vuex と比較して取り回しやすさが段違いでおすすめできます。

まとめ

まとめると

  1. グローバル管理といえば Vuex になりがちだけど意外と不要なシチュエーションも多い
  2. グローバルかつ実行時の環境に依存するものは inject で prototype を拡張してやるとテストしやすくて便利
  3. Headless な Vue インスタンスを作ることで、ただの JavaScript のオブジェクトやインスタンスにリアクティブ性を付与できる

というところでしょうか。

認証情報など、微妙なグローバルさによって成り立っているものはハンドリングが難しいところですが、うまく扱ってやることでより効果的なコード設計の発見に繋がりやすい部分でもあるため、特定のやり方に固執せず、良い塩梅を探すことが大切だと考えています。