Firebase JS SDK v9 へのアップデート備忘録

とあるプロジェクトの dependabot の firebase の PR がコケてました。

```
Cannot find module 'firebase' or its corresponding type declarations.
```

と思ったらいつのまにか v9 が出ていて、調べてみたら書き方がえらい変わっていたので備忘録を書きます。

公式の移行ガイド：https://firebase.google.com/docs/web/modular-upgrade

## v9 の利点

**Modular** といわれる使い方をすれば「必要なものだけ読み込む」ことができて、アプリのサイズを減らすことができるようです（[Tree Shaking](https://qiita.com/soarflat/items/755bbbcd6eb81bd128c4)）。

## 段階的な変更

v9 を導入したからといって全部コードを書き直さないといけないかというとそういうわけでもなく、

1. `import` 文の部分だけ変える
1. v8 の書き方と v9 の Modular の書き方を共存させる
1. v9 の Modular に完全移行

の3つのオプションが状況に応じてとれるようです。

### 1. import 文の部分だけ変える

公式の移行ガイドにもありますが

```js
import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';
```

を

```js
import firebase from 'firebase/compat/app';
import 'firebase/compat/auth';
import 'firebase/compat/firestore';
```

に変えればとりあえずは動くらしいです。Moduler に対して **Compat** （Compatibleの略）という書かれ方をしています。時間がない人はこちらを。

### 3. v9 の Modular に完全移行

便宜上、先に 3. を。Modular の書き方は公式の移行ガイドによると

```js
import firebase from "firebase/compat/app";
import "firebase/compat/auth";

const auth = firebase.auth();
auth.onAuthStateChanged(user => { 
  // Check for user status
});
```

を

```js
import { getAuth, onAuthStateChanged } from "firebase/auth";

const auth = getAuth(firebaseApp);
onAuthStateChanged(auth, user => {
  // Check for user status
});
```

に書き直して、とあります。

- `firebase.auth()` -> `getAuth(firebaseApp)`
- `auth.onAuthStateChanged(user => {})` -> `onAuthStateChanged(auth, user => {})`

ドット・チェーンで呼び出していたメソッドをすべて、それぞれのモジュールから直で import して書き直すのを地道にやっていけばいいです。


### 2. v8 の書き方（Compat）と v9 の Modular の書き方を共存させる

共存も可能で、例えば v8 に依存しているライブラリを使っている場合、そのライブラリには Compat のモジュールを渡して、それ以外では Modular を渡すなどできます。

色々省略していますが、私は以下のように使っています。

```js
import firebase from 'firebase/compat/app'
import 'firebase/compat/firestore'
import { getFirestore } from 'firebase/firestore'

const config = {
  apiKey: ...
}

const getApp = () => (firebase.apps && firebase.apps[0]) || firebase.initializeApp(config)

getApp()

const firestore = getFirestore(getApp())
const oldFirestore = firebase.firestore()

export { firestore, oldFirestore }
```

`firestore` は Modular の書き方をして、 `oldFirestore` は v8 にしか対応していないライブラリに渡します。

共存のときに注意しないといけないのは、全部 Modular にしない場合は初期化コードは Compat のものを使わないといけないようです。

```js:compat/共存
import firebase from "firebase/compat/app"

firebase.initializeApp({ /* config */ });
```


```js:modularのみ
import { initializeApp } from "firebase/app"

const firebaseApp = initializeApp({ /* config */ });
```


## 変更したところ・注意点

実際に中身をどう変更したか、基本 `firestore.doc('posts/1').get()` -> `getDoc(doc(firestore, 'posts/1'))` とか単純な変換なので、つまずいたところ・気になったところだけ。TypeScript 使用の場合です。

### 型

```js:変更前
import firebase from 'firebase'

const hoge = (timestamp: firebase.firestore.Timestamp) => {}
```

```js:変更後
import { Timestamp } from 'firebase/firestore'

const hoge = (timestamp: Timestamp) => {}
```

みたいに直で型を import できるようになった気がします。

### Functions

```js:変更前
const deleteUser = functions.httpsCallable('deleteUser')
```

```js:変更後
import { httpsCallable } from 'firebase/functions'

const deleteUser = httpsCallable<{ email: string }, { userId: string }>(
  functions,
  'deleteUser'
)
```

```js
httpsCallable<{ パラメーターの型 }, { レスポンスの型 }>()
```

のように型を指定できるようになった気がします、v8から出来てたらすみません。[他にも型が強化されてるらしいです](https://zenn.dev/arark/articles/60927323db8697)。

### Firestore

#### 自動生成IDを先に作るやつ

```js:変更前
const postRef = firestore.collection(POSTS).doc()
```

```js:変更後
import { collection, doc } from 'firebase/firestore'

const postRef = doc(collection(firestore, POSTS))
```

#### FieldValue

```js:変更前
const postRef = firestore.doc("posts/1")
postRef.update({
  hage: firebase.firestore.FieldValue.arrayUnion('foo'),
  hoge: firebase.firestore.FieldValue.delete()
})
```

```js:変更後
import { doc, updateDoc, arrayUnion, deleteField } from 'firebase/firestore'

const postRef = doc(firestore, "posts/1"))
updateDoc(postRef, {
  hage: arrayUnion('foo'),
  hoge: deleteField()
})
```

予約語衝突回避のために `delete()` が `deleteField()` になっていたり、若干の変更がありました。

#### exists

```js:変更前
firestore.doc('posts/1').get()
  .then((snap: DocumentSnapshot) => {
    if (snap.exists) {
    }
  })
```

```js:変更後
import { doc, getDoc } from 'firebase/firestore'

getDoc(doc(firestore, 'posts/1'))
  .then((snap: DocumentSnapshot) => {
    if (snap.exists()) {
    }
  })
```

地味に `exists` が関数になってました。

#### batch

```js:変更前
const batch = firestore.batch()
batch.set(postRef, { hoge: "foo" })
batch.commit()
```

```js:変更後
import { writeBatch } from 'firebase/firestore'

const batch = writeBatch(firestore)
batch.set(postRef, { hoge: "foo" })
batch.commit()
```

#### クエリ

公式にも例が出てましたが。

```js:変更前
firestore
  .collection('posts')
  .where('created', '<', endsWith.toDate())
  .orderBy('created', 'desc')
  .limit(20)
  .get()
```

```js:変更後
import {
  query,
  orderBy,
  limit,
  where,
  collection,
  getDocs,
} from 'firebase/firestore'

getDocs(
  query(
    collection(firestore, 'posts'),
    where('created', '<', endsWith.toDate()),
    orderBy('created', 'desc'),
    limit(20)
  )
)
```

### Auth

#### ソーシャルログイン

実際に使ってるコードは違いますが、同じメールアドレスで、Google でログインしていたユーザーが Twitter でログインしたみたいなときの処理の例。結構色々変えないとでした。

```js:変更前
auth.signInWithPopup(provider)
  .then((result) => {
    // 変更なし
  })
  .catch(function(error) {
  if (error.code === 'auth/account-exists-with-different-credential') {
    const pendingCred = error.credential;
    const email = error.email;
    auth.fetchSignInMethodsForEmail(email).then(function(methods) {
      const provider = getProviderForProviderId(methods[0]); // これは自前実装
      auth.signInWithPopup(provider).then(function(result) {
        result.user.linkAndRetrieveDataWithCredential(pendingCred).then(function(usercred) {
          goToApp(); // これは自前実装
        });
      });
    });
  }
});
```

```js:変更後
import { getAuth, signInWithPopup, GoogleAuthProvider, fetchSignInMethodsForEmail, linkWithCredential } from "firebase/auth";

const auth = getAuth();

signInWithPopup(auth, provider)
  .then((result) => {
    // 変更なし
  }).catch((error) => {
    if (error.code === 'auth/account-exists-with-different-credential') {
      const pendingCred = GoogleAuthProvider.credentialFromError(error)
      const email = error.customData?.email as string
      fetchSignInMethodsForEmail(auth, email).then(function(methods) {
        const provider = getProviderForProviderId(methods[0]); // これは自前実装
        signInWithPopup(auth, provider).then(function(result) {
          linkWithCredential(result.user, pendingCred).then(function(usercred) {
            goToApp(); // これは自前実装
          });
        });
      });
    }
  });
```

クレデンシャルの取得、メールアドレスの場所、リンクするメソッドが主に変わってました。

#### ログインのコールバック

```js:変更前
auth.signInAnonymously().then((user) => {
  console.log(user.uid)
})
```

```js:変更後
import { signInAnonymously } from "firebase/auth";

signInAnonymously(auth).then((userCredential) => {
  console.log(userCredential.user.uid)
})
```

地味に user を挟まないといけなくなってました。


### Storage

```js:変更前
task = storage.ref().child(`hoge.jpg`).put(file)

task.on(
      'state_changed',
      () => {},
      (e) => {
        console.log(e)
      },
      () => {
        task.snapshot.ref.getDownloadURL().then((url) => {
          console.log(url)
        })
      }
  )
```

```js:変更後
import {
  getDownloadURL,
  ref,
  uploadBytes,
} from 'firebase/storage'

uploadBytes(ref(storage, `hoge.jpg`), file)
  .then((result) => {
     getDownloadURL(result.ref).then((url) => {
       console.log(url)
     })
  })
```

基本が UploadTask ではなく Promise を返すようになったようです。

`uploadBytesResumable` を使えば、従来通り UploadTask が返ってくるくるようです。 `uploadStringResumable` はないようですが...