LoginSignup
15

More than 3 years have passed since last update.

Firebase & JavaScript/TypeScript を使った開発で役立つTips15選

Last updated at Posted at 2020-04-18

やまいも (@yarnaimo) です。

Firebase と JavaScript/TypeScript を使って Web サービスをいくつか作っているうちに得られた知見をメモ程度にまとめてみました。

W: がついているものは Web、A: は Admin です。

W: SSR 環境などでインスタンスが複数個作られないようにする

export const app = firebase.apps.length
    ? firebase.app()
    : firebase.initializeApp(firebaseConfig)

🔥 Firestore

R:firestore.rulesI:firestore.indexes.json です

W: React Hooks でいい感じに使いたい

react-firebase-hooks を使うとこのように書けます ↓

type IPost = {
    title: string
}

const Post = () => {
    const [post, loading, error] = useDocumentData<IPost>(
        db.collection('posts').doc('id')
    )
    return <div>{post.title}</div>
}

W&A: [TS] データに型を付けたい

a. データを取得・保存するときに変換したい場合 → withConverter を使う (詳細はこちら)

b. データを取得・保存するときに変換しなくていい場合

type IPost = {
    title: string
}

const postCollection = db.collection('posts') as firestore.CollectionReference<
    IPost
>

const snapshot = await postCollection.doc('id').get()
snapshot.data().title // => string

A: ローカルエミュレータを使ったテストを書きたい

@firebase/testing を使います

import {
    clearFirestoreData,
    firestore,
    initializeAdminApp,
} from '@firebase/testing'
import * as admin from 'firebase-admin'

const projectId = 'project-id'
const app = initializeAdminApp({ projectId })

export const db = app.firestore()

afterEach(async () => {
    await clearFirestoreData({ projectId })
})

afterAll(async () => {
    await app.delete()
})

R: ユーザーが管理者かどうか判定する

admins/{管理者のuid} に空のドキュメントを作っておくと isAdmin() でそのユーザーが管理者かどうか判定できます

firestore.rules
function adminPath() {
  return /databases/$(database)/documents/admins/$(request.auth.uid)
}

function isAdmin() {
  return exists(adminPath());
}

R: ユーザードキュメント以下でそのユーザーのみアクセスを許可する

firestore.rules
match /users/{uid} {
  function isCurrentUser() {
    return request.auth.uid == uid;
  }

  match /posts/{postId} {
    allow read, write: if isCurrentUser();
  }
}

I: collectionGroup の単一フィールドのインデックスを作りたい

単一フィールドのインデックスは collection だとデフォルトで作成されるようになっていますが、collectionGroup だと作成されません。

fieldOverrides を使うと collectionGroup で単一フィールドのインデックスを作ることができます。

posts という collectionGroup で tags フィールドの単一フィールドインデックスを作りたい場合:

firestore.indexes.json
{
  "fieldOverrides": [
    {
      "collectionGroup": "posts",
      "fieldPath": "tags",
      "indexes": [
        {
          "order": "ASCENDING",
          "queryScope": "COLLECTION"
        },
        {
          "order": "DESCENDING",
          "queryScope": "COLLECTION"
        },
        {
          "arrayConfig": "CONTAINS",
          "queryScope": "COLLECTION"
        },
        {
          "order": "ASCENDING",
          "queryScope": "COLLECTION_GROUP"
        },
        {
          "order": "DESCENDING",
          "queryScope": "COLLECTION_GROUP"
        },
        {
          "arrayConfig": "CONTAINS",
          "queryScope": "COLLECTION_GROUP"
        }
      ]
    }
  ]
}

🔥 Cloud Functions

W: 開発環境のブラウザからローカルエミュレータ上の関数を呼び出したい

// ↓ WebPack の EnvironmentPlugin などで環境変数を使えるようにしておく
const isDev = process.env.NODE_ENV !== 'production'

const functions = app.functions()

if (isDev) {
    functions.useFunctionsEmulator(`http://localhost:5000`)
}

// 開発環境だとローカルエミュレータ上の関数が呼び出される
functions.httpsCallable('function-name')(data)

A: Pub/Sub 関数の実行日時のタイムゾーンを指定したい

日本時間の毎日 0:00 に実行したい場合

const timezone = 'Asia/Tokyo'

functions.pubsub
    .schedule('0 0 * * *')
    .timeZone(timezone)
    .onRun(async () => {
        // ...
    })

A: 実行環境のタイムゾーンを指定したい

functions のエントリーポイントの最初にこれを書く

const timezone = 'Asia/Tokyo'
process.env.TZ = timezone

A: ローカルでも環境変数を使いたい

プロジェクトルートの .runtimeconfig.json に環境変数を書いておくとローカルでも functions.config() で取得できます

runtimeconfig.json
{
  "a": {
    "key": "value"
  }
}

A: 一部のファイル・フォルダをデプロイから除外したい

デフォルトではおそらく node_modules のみ除外されるようです

firebase.json
{
  "functions": {
    "source": ".",
    "ignore": [
      ".git",
      "node_modules",
      "tmp"
    ]
  }
}

W&A: [TS] Callable 関数の Request/Response に型を付けたい

Admin

1. functions.https.onCall のラッパーを作る (type-fest を使用)

(認証ユーザーでない場合は unauthenticated を返すようにしています)

onCall.ts
import { https } from 'firebase-functions'
import { JsonObject, Merge, SetRequired } from 'type-fest'

type CallableData = {
    request: JsonObject
    response: JsonObject
}

export type Callable<T extends CallableData> = (
    request: T['request'],
    context: SetRequired<https.CallableContext, 'auth'>,
) => Promise<T['response']>

export type CallableDataType<
    C extends Callable<any>,
    T extends 'request' | 'response'
> = C extends Callable<infer Rs> ? Rs[T] : never

export const onCall = <T extends CallableData>(handler: Callable<T>) => {
    const wrapped = async (
        request: T['request'],
        context: https.CallableContext,
    ): Promise<T['response']> => {
        if (!context.auth) {
            throw new https.HttpsError('unauthenticated', '認証が必要です')
        }

        const response = await handler(request, {
            ...context,
            auth: context.auth,
        })
        return response
    }

    const runnable = https.onCall(wrapped)

    return runnable as Merge<typeof runnable, { run: typeof handler }>
}

2. ↑ のラッパーを使って Callable 関数を記述する

callable/saveMessage.ts
import { onCall } from '../onCall'

export type CFSaveMessage = {
    request: {
        text: string
    }
    response: {
        result: boolean
    }
}

export const saveMessage = onCall<CFSaveMessage>(
    async (requestData, context) => {
        context.auth.uid // string
        requestData.text // string

        // ...
        return { result: true }
    },
)

3. Callable 関数を 1 つのオブジェクトにまとめる

callable/index.ts
import { saveMessage } from './saveMessage'
// ...

export const callable = {
    saveMessage,
    // ...
}

4 . functions のエントリーポイントで ↑ の callable を import & export する

import { callable } from './path-to-callable'
export { callable }

こうすると callable-saveMessage のように Callble 関数の名前に prefix が付きます。

Web

1. 呼び出し用のラッパーを作る

call.ts
import { callable } from './callable'
import { CallableDataType } from './onCall'

const functions = app.functions()

type Callables = typeof callable

export const call = async <
    N extends keyof Callables,
    C extends Callables[N] = Callables[N]
>(
    name: N,
    requestData: CallableDataType<C['run'], 'request'>,
) => {
    const result = await functions
        .httpsCallable(`callable-${name}`)({
            ...requestData,
        })
        .catch((error) => console.error(error))

    return result && (result.data as CallableDataType<C['run'], 'response'>)
}

2. 実際に呼び出してみる

app.ts
const res = await call('saveMessage', { text: 'test' })

call の第 1 引数に指定した関数名に応じて Request/Response にも自動的に型が付きます。


🔥 Authentication

W: パスワードの再設定メールの言語をブラウザの言語に合わせる

export const auth = app.auth()
auth.useDeviceLanguage()

🔥 Storage

A: 保存したファイルを Public にして URL を取得したい

PNG 画像を保存する例:

export const savePNGImage = async (path: string, buffer: Buffer) => {
    const file = bucketAdmin.file(path)
    await file.save(buffer, { contentType: 'image/png' })
    await file.makePublic()
    const url = `https://storage.googleapis.com/${bucketAdmin.name}/${
        file.name
    }`
    return url
}

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15