Reactのアーキテクチャを知る useState編2

こんにちは、noyanです。React内部実装アドベントカレンダーの2日目です。

引き続きReactのuseStateフックの内部実装を読み解いていきます。

目的

更新系を理解する準備として、dispatch関数が呼ばれたときの処理を知る。

Hookが単連結リスト構造だと知る。

準備運動

Reactのアーキテクチャに特徴的な概念を、ここで予告的に提示しておきます。どうせコードを見るうちに覚えるので覚えなくていいですが、わからなくなったらここに戻って参照してください。

ダブルバッファリング

Reactはレンダリングツリーのインスタンスを二重に管理しています。1インスタンスを書き換えつつ表示すると、書き換え処理が重くなるにつれ変更反映途中の画面が表示されてしまうことがあります。これに対し2つのインスタンスをもち高速に入れ替え続けることで、画面のちらつきをなくすことができます。ユーザーからは常に完成された画面だけが見えるようにするこの手法をダブルバッファリングと呼びます。

Fiber

FiberとはReactの各コンポーネントに対応するデータを保持する内部インスタンスです。各Fiberは自身のchildren / parentへの参照をもっているため、これを辿ることでDOMツリー全体をレンダリングすることができます。

ダブルバッファリングのため、各コンポーネントは対応する2つのFiberを持ちます。ひとつは(webであれば)DOMに反映済みの情報をもつFiberで、もうひとつはこれからDOMを反映されるFiberです。Reactの内部ではこのふたつのFiberの名前を、それぞれcurrent, workInProgressと呼称します。

ただし、hooksのデータを更新する文脈ではworkInProgresscurrentlyRenderingFiberと呼ばれます。

Hookのデータ構造

Hookには5つのプロパティがあります。(今回はmemoizedStateとnextが出てきます)

hook.memoizedState: メモリに保持されているローカルな状態。

hook.baseState: hook.baseQueue内のすべてのアップデートオブジェクトがマージされた後の状態。

hook.baseQueue: 現在のレンダリング優先度よりも高いものだけを含む、アップデートオブジェクトの循環的なチェーン。

hook.queue: 優先度の高いすべてのアップデートオブジェクトを含む、アップデートオブジェクトの循環的なチェーン。

hook.next: 次のポインタ、チェーンの次のフックを指します。

https://github.com/7kms/react-illustration-series/blob/5f6c3d002480a0bafd2a7690ac67b72fae71ab36/docs/main/hook-summary.md

最初のものが状態、次の3つが更新キュー、最後が次のhookへの参照です。

さて、hookを作成する関数が今回の記事のスコープなので、先に見ておきましょう。この関数はhookを新規作成し、現在更新中のhook(workInProgressHook)に代入します。

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

 // 現在作成中のFiber: currentlyRenderingFiber
 // 現在作成中のHook : workInProgressHook
  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

これは以下の処理と同じです。

if (workInProgressHook === null) {
  currentlyRenderingFiber.memoizedState = workInProgressHook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
} else {
  workInProgressHook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: hook, //前のhookに自分をつなげている。
  };
}

return workInProgress

この関数はuseStateが呼ばれるたびに実行されます。すでにworkInProgressが存在する場合には、.nextに自身をつなげるため、数珠つなぎ状にhookが連結されます。この繋がり方を連結リスト(単連結リスト)と呼ぶこともあります。

たとえば、以下のコードでcount1のnextはcount2となります。

const Count = () => {
    const [count1, setCount1] = useState(0);
    const [count2, setCount2] = useState(0);
    
    return (<div/>)
}

「ReactのHooksは連結リストだ!」という主張の根拠がどこにあるか、わかったのではないでしょうか。

コードリーディング

さて、体もあったまったところで、コードリーディングを再開しましょう。前回の記事ではuseStateのマウント時のコールスタックを辿りました。最終的に、useStateが間接的に呼び出したmountStatereturn [hook.memoizedState, dispatch];という部分がuseStateの返り値になっていました。

前回はmountStateの返り値を見たところで終わってしまって、関数の中身については精査できていませんでした。今回はmountStateの関数から辿り、dispatch関数、すなわちsetStateがどのような処理を行うのか理解します。

MountState

MountStateは長いので、まず概略コードを見ていきましょう。hookの5プロパティを埋めて、dispatch関数をつくります。useState()の返り値として、配列を返します。配列の中身はinitialStateとbindされた関数です。

function mountState(initialState) {
    const queue =  //
    const hook = {
        memoizedState:initialState,
        baseState: initialState,
        baseQueue: null
        queue: queue
        next: null
    }
    const dispatch =dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  return [hook.memoizedState, dispatch];
}

さて実装の詳細を見ていきましょう。hookのデータ構造のセクションで登場したmountWorkInProgressHook関数で初期化されたhookを取得します。dispatchSetState関数にqueueとfiberをbindします。

概要をぱっと掴むのは難しいですが、hookの5つあるプロパティを準備しているだけのようです。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  
  // memoizedState, baseState
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState(); // Lazy initial state mountWorkInProgressHook
  }
  hook.memoizedState = hook.baseState = initialState;
  
  // queue
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  
  // dispatch
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  
  return [hook.memoizedState, dispatch];
}

dispatchSetStateをみれば、マウント時の挙動はすべてわかりそうです。

dispatchSetState

updateオブジェクトを作成し、enqueueしているだけです。

この前の記事によるとsetState(action)dispatchSetState(fiber, queue, action)と同値だったので、第三引数のactionは1prev => prev + 1が入ることになります。これがupdate.actionに格納されて、queueが処理される際に実行されることになります。

laneやら、renderPhaseUpdateなど謎の概念はいずれ説明するので無視しましょう。

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    enqueueUpdate(fiber, queue, update, lane);

    // ... some optimization
    // これ以降は無視してよい
    const eventTime = requestEventTime();
    const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
    if (root !== null) {
      entangleTransitionUpdate(root, queue, lane);
    }
  }

  markUpdateInDevTools(fiber, lane, action);
}

バインドするのは、関数をFiberと紐つけることで各コンポーネントの更新を区別できるようにするためです。

enqueueRenderPhaseUpdateとenqueueUpdate

今後更新系を見ていくうえで、queueの中身がどうなっているか知っておく必要があるので、これだけ確認しておきましょう。

コンセプトとしては、updateで循環参照のリストをつくり、それをqueueに入れています。

 const update: Update<S, A> = {
    // made by dispatchSetState
      lane,
      action,
      hasEagerState: false,
      eagerState: null,
      next: (null: any),
  };
  
 function enqueueRenderPhaseUpdate(queue, update){
     didScheduleRenderPhaseUpdateDuringThisPass = true
     if(firstRender){
         update.next = update
     } else {
         update.next = pending.next;
         pneding.next = update;
         // append it to list, and make it circular!
     }
         queue.pending = update
 }
  1. レンダリングフェーズに再度コンポーネントを描画するようフラグをオンにする。

  2. queue.pendingにupdateを代入する。

    の2つです。if-else節で循環参照を作っていることに気づかれたかもしれません。

    イメージとしては、queueの連結リストが[3,1,2]でupdateが1であれば[update,1,2,3]となるイメージです。循環リストなので3.nextはupdate, update.nextは1にになります。queue.pendingが一番新しく、queue.nextが一番古いqueueであることだけ覚えておきましょう。

    このqueueはhook.queueと同じアドレスなので、hook.queueから辿ることができます。

    enqueueUpdateはこれとだいたい同じなので省略します。

まとめ

最終的にenqueueの処理がわかりました。基本的にqueueにupdateを追加しているだけです。

ここまで呼んで、hookの値更新queueが積まれたときの処理は出てきませんでした。queue処理を担当するコードを見れば、値を反映するために起きている処理がわかるはずです。次回はそれを探していきましょう。

Reactのアーキテクチャを知る useState編1

こんにちは、noyanです。React内部実装アドベントカレンダーの1日目です。

この記事ではReactのuseStateフックの内部実装を読み解いていきます。

概要

Reactを使っている人のほとんどがuseStateを使ったことがあるでしょう。一方で内部実装を知っているひとはかなり限られるように思います。

このシリーズでは、useStateのコードリーディングを行います。useState HookのAPIは便利で直感的ですが、その実装は複雑で難解です。useStateを我々が呼び出す時、裏でどのような処理が行われているのでしょうか。useState編では関連する内部処理を概覧し、メンタルモデルを構築することを試みます。

「でも役にたたないでしょ?」。たしかにuseStateを実際に使う役には立たないかもしれません。ですが、Reactの内部実装のなかで比較的閉じたスコープのAPIを読解できるようになれば、あなたは次から自力でコードを読む手がかりを得るでしょう。Server ComponentsやConcurrent Renderingについて他人の寸評で耳学問するより、コードを知って判断するほうがずっとクールだと思いませんか。

コードリーディング

useState編1ではconst [count, setCount] = useState(0)というありふれたコードを題材に、useStateがマウント時にどう処理されているか理解します。

useState: エントリーポイント

さて、useStateを呼んでみましょう。

const [count, setCount] = useState(0)

useState関数はこのように定義されています。

 export function useState<S>(
   initialState: (() => S) | S,
 ): [S, Dispatch<BasicStateAction<S>>] {
   const dispatcher = resolveDispatcher();
   return dispatcher.useState(initialState);
 }

dispatcherのuseStateメソッドを使用しているようです。

 function resolveDispatcher() {
   const dispatcher = ReactCurrentDispatcher.current;
   return ((dispatcher: any): Dispatcher);
 }

resolveDispatcherはほとんどなにもしていません。ReactCurrentDispatcherに直接さわらないことで、実装修正の影響スコープを狭めるという見方が正しそうです。

ここまで見て、useState(0)ReactCurrentDispatcher.useState(0)を返していることがわかりました。

renderWithHooks

次にReactCurrentDispatcherに代入を行う部分を見る必要があります。その場所とはrenderWithHooksです。実際のコードは多少長いですが、概略的には次の処理をしています。

export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRanderLane):any {
    // Dispatcherを付与
    ReactCurrentDispatcher.current =
        current === null || current.memoizedState === null
          ? HooksDispatcherOnMount
          : HooksDispatcherOnUpdate;
         
    // 更新がある限り計算を続ける
    let children
    do {children = Component(props)} while (hasUpdate)
    
    return children
 } 

Dispatcherを割り当てた後、コンポーネントレンダリングしています。たとえば次のGreetコンポーネントがあるとします。

const Greet= ({name}) => (<div>Hello, {name}!</div>)
// or
// const Greet = ({ name  })=>React.createElement("div", null, "Hello, ", name, "!")

renderWithHooksGreetコンポーネントが与えられるとGreet({name})を計算して、その計算結果をchildrenとして返しているわけです。実際にレンダリングの計算をしている部分という感じがします。

さて、上の4行目でReactCurrentDIspatcherHooksDispatcherOnMountHooksDispatcherOnUpdateを割り当てています。

代入されている変数の接尾語には、それぞれonMountとonUpdateとあります。我々はいまuseStateのマウント時の挙動に興味があるので、onMountを見ることにします。更新時の挙動を調べるときには、またここから始めることになるでしょう。

HooksDispatcherOnMount

このDispatcherはオブジェクトで、キーに各Hooks、値にmount時に呼ぶべき関数が定義されています。

これまでuseStateReactCurrentDispatcher.useState(initialState)を返す関数だとわかっているので、useState(0)HooksDispatcherOnMount.useState(0)、つまりmountState(0)を実行していることになります。

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  // ...
  useState: mountState,
  useReducer: mountReducer,
  useRef: mountRef,
  // ...
};

mountState

まず戻り値だけ見てみましょう。mountState[hook.memoizedState, dispatch]を返します。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // ...
  return [hook.memoizedState, dispatch];
}

つまり、useStatemountStateの実行結果を戻り値としているわけです。マウント時には実質的に次のコードが等価であることがわかりました。

const [count, setCount] = useState(0);
const [count, setCount] = mountState(0)

おわり

「さて、コードを詳しくみていこう!」といきたいのですが、これには少し道具立てが必要です。また明日トライしてみることにしましょう。気になる人のためにmountStateのコードを貼っておきます。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

備忘録:TypeScriptにおける環境構築 Linter編

最近、Next.js * TypeScriptに@swc-node/jestを入れたり、適当にやっていたeslintの設定を40行くらいのちょっとしたものに直す機会があった(執筆中に見直して10行ぐらいへった)。

Linter周りは作るたびに同じことを調べている気がするので、多少まとめることにする。

Eslint

module.exports = {
    env: {
        browser: true,
        es2021: true,
        mocha: true,
        node: true,
        'jest/globals': true,
    },
    extends: [
        'eslint:recommended',
        'plugin:@typescript-eslint/recommended',
        'plugin:jest/recommended',
        'plugin:jest/style',
        'next/core-web-vitals',
        'prettier',
    ],
    plugins: ['testing-library'],

    parser: '@typescript-eslint/parser',
    parserOptions: {
        ecmaFeatures: {
            jsx: true,
        },
        ecmaVersion: 12,
        sourceType: 'module',
    },
};

env

envにmochaやjest/globalsを設定すると、jestでテストコードを書くときitdescribeが未定義だと怒られなくなる。nodeは覚えていない。コミットログをみると同タイミングだから@swc-node/jest関連か。

plugin と extends

pluginはルールセットを追加しextendsはルールセットのオンオフなどを行う、これが基本。

しかしextendsでpluginの役割を兼ねることができるため、実際には各configごとに設定方法が違うため、ライブラリごとに公式サイトを見ることになる。 ESLint の Plugin と Extend の違い | blog.ojisan.io

next lint

Next.jsはv11からlintがついてくる。この構成は既存のlintルールの共存が難しい。

自分は主にairbnbとの共存方法を調べていた。自分の結論から言うと、airbnbを捨て、eslint-config-nextに乗り換えることにした。理由は、airbnbの非合理なルールと、next lintによる高速化だった。

まず、airbnbはprefer default export や use before define など時代に合わないルールを採用している。これらを修正するのは、開発効率のためにならない。

次に、next lintの高速化により、2xから3xの高速化ができた。自分は型情報が必要なルールセットをオンにしているため、lintの処理が少し重い。実はeslint-config-nextはルールセット以外にパーサーなども用意していて、これを使うことでかなり高速化された。

パーサなどを変更せずnext.js用のルールセットだけ既存の構成に付け加えることも可能。このためには、extends: ['plugin:@next/next/recommended']を使うとnext.jsの設定だけを追加する。extends:'next'で入る設定は以下で確認できる。

github.com

.eslintignore

**/build/
**/public/
**/coverage/
**/node_modules/
**/*.min.js
# **/*.config.js
**/.*rc.json
**/dist/

重いと感じるときは、不要なファイルを見ていることを疑う。eslint は--verboseオプションがなく、DEBUG=eslint:* yarn eslintでログを出力する。

eslintrc自体のlinterがあればとよく思う。

prettierrc.json

{
    "semi": true,
    "singleQuote": true,
    "trailingComma": "es5",
    "tabWidth": 4
}

自動セミコロン挿入の弊害を知ってから、セミコロンは入れている。

.prettierignore

build
coverage
.next
.vscode
public
node_modules
yarn.lock

jest.config.js

今回はじめて書いた。特徴は@swc-node/jestを使うことで、かなり高速化されていること。最近chakra-uiのtest環境がts-jestからこれになり、テストが1/4の時間で終わるようになった。、個人的には、ts-jest時代はWSL環境で動かすとOSが落ちる状態だったので、かなり助かっている。

Next.js + Jest のテスト環境を @swc/jest を使って高速化する を参考にして書いた部分が多い。uttkさんの記事は@swc/jest、この記事は@swc-node/jestで異なるライブラリを採用しているため注意が必要。tsc-nodeはtscの非公式版なのだが、最近next.jsにも採用されている期待株。

module.exports = {
    testEnvironment: 'jsdom',

    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
    modulePathIgnorePatterns: [
        '<rootDir>/website/.cache',
        '<rootDir>/examples',
        '<rootDir>/tooling/cra-template*',
    ],

    transform: {
        '^.+\\.(ts|tsx)?$': [
            '@swc-node/jest',
            {
                sourceMaps: true, // エラーを見やすくする( 有効じゃないと内容がズレて表示されます )

                module: {
                    type: 'commonjs', // 出力するファイルをcommonjsとする
                },

                jsc: {
                    parser: {
                        syntax: 'typescript', // ソースコードをtypescriptとしてパースする
                        tsx: true, // jsx記法を許可する
                    },

                    transform: {
                        react: {
                            // 必須。省略すると "ReferenceError: React is not defined" が発生します
                            runtime: 'automatic',
                        },
                    },
                },
            },
        ],
    },

    testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
    transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'],
    setupFilesAfterEnv: [
        '@testing-library/jest-dom/extend-expect',
        '<rootDir>/jest.setup.ts',
    ],

    watchPlugins: [
        'jest-watch-typeahead/filename',
        'jest-watch-typeahead/testname',
    ],

    collectCoverageFrom: [
        'pages/**/*.{js,ts,tsx}',
        'components/**/*.{js,tsx,ts}',
        'theme/**/*.{js,tsx,ts}',
    ],
};

tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "react-jsx"
    },
    "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
    "exclude": ["node_modules"]
}

Next.jsのjsxはpreserveになっているが、vscodeのauto-importができなくなる問題がある。そのため、開発中はjsxをreact-jsxに変更している。つい10日前ほどにVsCodeに修正PRが出ていたのでいずれ改善されるだろう。

package.json > scripts

    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "lint-v": "DEBUG=eslint:* next lint",
        "test": "jest --coverage",
        "test:watch": "jest --watch",
    },

テストのカバレッジ・ウォッチと、eslintのverboseを登録した。

ふりかえって

Next.js用の環境をつくるのをいい機会にLinter周辺の知識を学んだ。Next.jsのlinterやテストまわりの体験はかなり優れていたので、今後は積極的にNext.jsを使うだろうと思う。Vercelロックインの批判も多いが、的を射た批判はあまり多くないので、気にせず使っていきたい。

今後husky / lint-stagedを追加するかもしれない。

「Firebase Auth使いたいけど、どう実装するの?」という人のために

セキュリティ、難しいですね。認証を自前で実装するには膨大な知識が必要で、一度実装しても新たな脆弱性発見のニュースに常に気を配る必要があります。フロントエンドエンジニアがセキュリティの分野まですべて責任をもつことは現実的ではないです。

そのなかで、認証サービス Firebase Authenticationは基本無料*1で、認証を委託するサービスとして魅力的です。ただしドキュメントが不親切だったり、初心者の実装してみた記事ばかりヒットしていて参考にしづらいなど、実装するためのハードルがあります。 本記事では、Firebase Auth & 認証がはじめてという人を念頭に、信頼できるリソースがどこにあるかを示すことで、導入初期のハードルを低くすることをねらっています。

この記事を書いているのは最近フロントエンドを学び始めた人で、鵜呑みにすると危険な可能性があります。念の為。

以下常体。

執筆時のバージョン

Firebase Authを実装する

認証の実装には、基本的に次の3つの要素が必要となる

  • ①認証に使うサービス(provider)を選ぶ
  • ②サインインの方法
  • ③サインイン成功・失敗時に呼ばれる処理

とりあえずこの3つが行われていればよいと考えていい。

①は任意、②はSPAならポップアップ一択、③は必要な情報(アカウント名など)をHooksに渡すなど。 3要素と考えるとシンプルだが、公式の実装例を見てもコード量が多い。各メソッドを自前関数でラップするか逐次ベタ書きする必要があり、APIが使いづらい印象をうける。

Firebase Authのつらさ

  • 情報を探す際に、欲しい情報がどこにあるのかわからないことが多い

  • なので頑張って探す必要がある

  • 基本的にはauth | JavaScript SDK  |  Firebaseから探す

  • Reactなどにどう導入するかわからない

Firebase UI makes things easy

ひとつの解決策として、FirebaseUIを使うことで認証機能を見通し良いコードで実装することができる。FirebaseUIは認証画面に特化したUIコンポーネントだが、API が生Firebase Auth に比べて優れており、導入するとログイン処理部分の実装もシンプルさをもたらしてくれる。

React の場合は FirebaseUI React Components を使うのだが、生 Firebase Auth の公式実装例と以下のコードを見比べてほしい。

// Import FirebaseAuth and firebase.
import React from 'react';
import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth';
import firebase from 'firebase';

// Configure Firebase.
const config = {
  apiKey: 'AIzaSyAeue-AsYu76MMQlTOM-KlbYBlusW9c1FM',
  authDomain: 'myproject-1234.firebaseapp.com',
  // ...
};
firebase.initializeApp(config);

// Configure FirebaseUI.
const uiConfig = {
  // Popup signin flow rather than redirect flow.
  signInFlow: 'popup',
  // Redirect to /signedIn after sign in is successful. Alternatively you can provide a callbacks.signInSuccess function.
  signInSuccessUrl: '/signedIn',
  // We will display Google and Facebook as auth providers.
  signInOptions: [
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    firebase.auth.FacebookAuthProvider.PROVIDER_ID,
  ],
};

function SignInScreen() {
  return (
    <div>
      <h1>My App</h1>
      <p>Please sign-in:</p>
      <StyledFirebaseAuth uiConfig={uiConfig} firebaseAuth={firebase.auth()} />
    </div>
  );
}

export default SignInScreen

github.com

設定情報がuiConfigに一本化されていて見通しがよくなったと実感できると思う。

また、各メソッドが何をするのかドキュメントが公式より遥かにまとまっているため、FirebaseUIを使わない場合でもまずUIのconfigurationを見て設定方法のアタリをつけたほうが良い。

github.com

ただしこのライブラリはSSRに対応していないので、SSRする場合には別のライブラリを入れる必要があるようだ(試していない)*2

Firebase Auth のconfig, 初期化

APIキーなどすべて環境変数化し、初期化が一度だけ行われるように変更する。 公式:Add Firebase to your JavaScript project

import firebase from 'firebase/app';


const firebaseConfig = {
 apiKey: REACT_APP_API_KEY,
  authDomain: REACT_APP_AUTH_DOMAIN,
  databaseURL: REACT_APP_DATABASE_URL,
  projectId: REACT_APP_PROJECT_ID,
  // ... 省略([ウェブアプリに Firebase を追加]ボタンの情報をそのまま)
};

// Initialize Firebase
// 初期化が何度も呼ばれることを防ぐ
!firebase.apps.length ? firebase.initializeApp(firebaseConfig) : firebase.app();

環境変数を書いたファイルはプロジェクトのルートディレクトリに配置する。

create-react-app:REACT_APP_で始まる環境変数

next.js:NEXT_PUBLIC_で始まる環境変数

参考情報

型情報・プロパティ・メソッドの調べ方

ここで頑張って調べる。一番使う型だろうユーザーの型がfirebase.Userだと暗記するだけでもいい。

firebase.google.com

React で書く

少し古いがuseAuthがよくまとまっている。セキュリティの分野で初心者の記事を参考にするのは微妙なので、そういう必要がないように他にもいくつか紹介する。 useAuth React Hook - useHooks

next.js/examples/with-firebase at 0af3b526408bae26d6b3f8cab75c4229998bf7cb · vercel/next.js · GitHub

next-firebase-auth/_app.js at main · gladly-team/next-firebase-auth · GitHub

りあクト! Firebaseで始めるサーバーレスReact開発 - くるみ割り書房 ft. React - BOOTH

(最後の書籍はGitHubリポジトリを眺めてみるとよい)

TypeScript

リファレンスを頑張って読む必要がある。

auth | JavaScript SDK  |  Firebase

おわりに

いかがでしたか?

オーバーロードをアロー関数で書かないほうがいい理由[TypeScript]

  update(21/05/23): ・コード例が正しくなかったので修正しました。 ・ジェネリクスについて追記しました。

結論

TypeScript で関数をアロー関数で多重定義すると型推論がうまくいかないので、function キーワードのオーバーロードを使いましょう。

 

オーバーロード

TypeScript のオーバーロードは、引数と戻り値の個数や型が柔軟な関数に型をつける構文です。関数をオーバーロード(多重定義)することで、引数と戻り値 の組み合わせを複数定義できます。

declare function getWidget(n: number): Widget;
declare function getWidget(s: string): Widget[];

関数のオーバーロードをつかうことで、数値が与えられたら値の2乗を返し、数値の配列が与えられたら2乗の配列を返す関数を簡単に定義できます。

オーバーロードをアロー関数で書きたい

ところで、この構文のオーバーロードは関数宣言を使うことを強制しますが、関数宣言は var 同様巻き上げの恐れがあるため可能な限り避けたいです。解決策として、呼び出し可能オブジェクト (callable object) を使うことで、アロー関数でオーバーロードすることができます。

 

interface Add {
    (a:number):number;
    (a:number[]):number;
}

const add:Add =  (a:number|number[]):number => {
    if(Array.isArray(a)){
        return a.reduce((prev,curr) => prev + curr);
    }
    return a
}

上のコードのような素朴な例では問題ないですが、アロー関数のオーバーロードは関数宣言と異なる挙動を見せることがあります。 

 

戻り値が異なるオーバーロード型推論が意味をなさない

アロー関数で戻り値の型が2種類以上あるオーバーロードはエラーが出ます。

 

interface Add {
    (a:number, b:number):number;
    (a:string,b:string):string;
}

const add:Add =  (a:number|string, b:number|string) => {
//       ^ ここがError
    if (typeof a === 'string' && typeof b === 'string') {
      return a + b
      }

    return a * b;
}

Type '(a: number | string, b: number | string) => string | number' is not assignable to type 'Add'. Type 'string | number' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.(2322)

   これは TypeScript がオーバーロード関数の戻り値を number & string として認識しているせいで起こるエラーです。このエラーはたとえば戻り値が number と string の2種類であれば、型推論は number & string (= never) になり、どの戻り値も許容しないことを意味します。 この挙動は戻り値を number | string とアノテートしても変わりません。これをアロー関数で書く唯一の解決策はanyとアノテーションすることです。そこまでしてアロー関数にこだわる必要はないでしょう。  

関数宣言とアロー関数で挙動が違う理由

なぜアロー関数で書いたオーバーロードはこのような型推論をし、なぜ関数宣言と異なるのでしょうか?

githubのissueを読んだ限りでは次のような経緯でした。オーバーロードされた関数の戻り値は本来、取りうるすべての戻り値 (a, b, c...) の条件を満たさなければならないため、交差型 (a & b & c) になります。しかし一般的なオーバーロードの使用法は戻り値を引数に応じて変えることであり、そのためには戻り値が合併型 (union type: a | b ) である必要があります。 このissueを受けて関数宣言のオーバーロードは実用性のために合併型を許容するよう実装されましたが、その他の形で書かれたオーバーロードをもつ関数に拡張しても大丈夫か明確でないので適用しなかったようです。 github.com https://github.com/microsoft/TypeScript/pull/6075 https://github.com/microsoft/TypeScript/issues/37824

実装した人(一番上の記事とは別の人)のその後の反応をみるに、特段事情がなければアロー関数で実装するモチベーションはなさそうです。

結論

したがって、アロー関数のオーバーロードは本来の挙動ですが不便すぎるので、複数の戻り値を組み合わせる必要がある際には、関数宣言を使いましょう。  

(追記) ジェネリクス

ところでジェネリクスを知っているなら、ジェネリクスで書いてみたくなるかもしれません。 ジェネリクスは簡潔に書けることが多いですが、この場合はアサーションを使う必要があり、narrowingや推論もあまり期待通りに動いてくれません。

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

const isString = (X:unknown):X is string => typeof X === 'string';

function add<T extends string | number>(a: T, b:T) {
    if (typeof a ==='string' && typeof b === 'string') {
        return a  + b; 
    }
    // return a + b;     //Operator '+' cannot be applied to types 'T' and 'T'.(2365)
    // 上のコードだと戻り値の推論がanyになる
    return (a as number) + (b as number); //正しく推論されるにはアサーションが必要
}

const a = add('a', 'b'); // a はnumber | stringになり、stringと絞り込んでくれない
add(2, 3); // correct usage
add(1, 'a'); // should be error
add('a', 2); // should be error
const res = add('a', 'b') // correct usage

この解決方法はあるにはあるんですが、uglyなので参考リンクを紹介するにとどめておきます。 参考:Advanced TypeScript Exercises - Answer 3 - DEV Community

調べてみると、つい一月前に関連するPRがマージされていて、これにより引数が一つの場合には上記のコードでアサーションが必要だった部分が解消できるらしいです。 残念ながら引数が複数の今回のものはエラーが出たままのように見えます(あるいはv4.3.0-betaにも乗っていないPR?)。

Improve narrowing of generic types in control flow analysis by ahejlsberg · Pull Request #43183 · microsoft/TypeScript · GitHub

戻り値も同じ推論かとおもうのですが、型の絞り込みは効いてません。 TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

いずれにせよ便利なことに違いはないです。いずれこのPRが複数の引数に一般化されるといいですね。