Todo アプリを firebase にデプロイしたがテストの書き方がわからなかった

ここ最近はブログの交信がさぼり気味だった。というのも、realtime database のテストの仕方がわからずに試行錯誤していたので、成果をあまりだせなかったというのが大きい。

fetch-mock からの脱却

ローカル環境のデータベースへアクセスしていたときは、データベースへのアクセスをREST API経由で行っていたので、クライアント側はfetch関数を使用していたが、realtime database は専用のクライアントAPIによってアクセスするためfetch関数は使えなくなった。それに伴って、fetch-mockを使用してテストを行っていた部分が壊れてしまったので、realtime database 用のモックが必要となった。調べてみると realtime database シミュレーターなるものがあったわけだが、これはデータベースのルールに関してテストするもので、かつ、まだベータ版とのことで使用は控えたほうがよさそうとの結論になった。

結局は jest のモックを使用して firebase.database を偽装することにした。

jest.fn()

jestはjavascript用のテストフレームワークでfecebookが主に開発を行っている模様。fetch-mock を使っているときは sinon というテストフレームワークを使用していたが、Reactを使う上では jest のほうが親和性が高いかな? と思ったので jest に切り替えた。

jestではモックを作成するのに jest.fn() を使う。内部で呼び出された関数に差し替えることで関数の呼び出しを偽装できる。実際には jest.fn().mockImplementation() によって関数を実装することで偽装する。

firebase.database() を偽装する

firebase.database() は sendToApiServer 内部で呼び出しを行っているので、現状のままでは簡単にモック関数と差し替えることはできない。なので、sendToApiServer 関数をやめて、DatabaseBridgeという名前のクラスを作成し、そいつに処理を移した。そして、getDatabase() という関数を作成し、const database = firebase.database() となっている部分を const database = getDatabse() にしたうえで、getDatabase() 関数の中で return firebase.database() として外向けに公開することでテストでも差し替え可能な関数でありつつ、内部処理の影響を最小限にした。

モック関数をネストする

firebase.database() をモック化することには成功したが、そのあと database の関数を使用している処理もモック化しないと、その部分でコケてしまう。具体的には database.ref() や そのあとの ref.on() や ref.once() でコケる。それらを mockImplementation() を組み合わせて作成する。

const bridge = new DatabaseBridge()
const mock = jest.spyOn(bridge, 'getDatabase'); // spyOn() は 第一引数で渡したインスタンスの中の第二引数のメソッドを監視する

const mockDbRemove = jest.fn() // ref.remove() メソッドのモック
const mockDbRef = jest.fn() // database.ref() メソッドのモック
mockDbRef.mockImplementation(() => { // database.ref() メソッドを偽装する
  return { remove: mockDbRemove } } // ネストさせることで内部の関数もモック化する
})
mock.mockImplementation(() => { // getDatabase() メソッドを偽装する
  return { ref: mockDbRef } // database.ref() のモック関数を埋め込み
})

例えば、実装が以下のようになっている場合、getDatabase() で取得したデータベースオブジェクトはモックにさし変わっているので、その次の database.ref() はモックオブジェクトが持つメソッドが呼ばれることになる。同様にその次の ref.remove() もモックオブジェクトのメソッドが呼ばれる。

class DatabaseBridge {
  // 実際は firebase.database が使用されるが、テストでは mock変数に差し替えられる
  getDatabase() {
    return firebase.database();
  }

  remove() {
    const database:any = getDatabaase() // ここでモック(mock)が返される
    const ref = database.ref(`...`); // ネストしたモック(mockDbRef)が実行される
    ref.remove() // mockDbRemove モックが実行される
  }
}

モックオブジェクトのコール情報を確認する

偽装しただけではテストできないので、その時に渡された引数をチェックするテストを書く必要がある。

  • expect(mock.mock.calls.length).toBe(???)

何回モックがコールされたかをチェックする。

  • expect(mock.mock.calls[0][0]).toEqual(???)

コールされたときの引数の値が期待通りかをチェックする。

今のところ、この二つでまかなえているので、それ以外は今後覚えていく。

jest.fn().mockImplementationOnce()

モック関数のコールに対して一度だけ違う値を返したいみたいな場合は、mockImplementationOnce() で実装できる。

const mockDbOnce = jest
  .fn()
  .mockImplementationOnce(() => {
    return false
  })
  .mockImplementationOnce(() => {
    return true
  })

このモックが呼ばれると、一回目はfalseを返すけど、二回目はtrueを返すようになる。

成果物

GitHub - bamchoh/react-study at 7c28892ea0cac3b23af46d18be7968fbdc86b2db

Firebase 入門(4) ~ Todo アプリをデプロイできるのか? (2)

この数日、Firebase Authentication と格闘してた。一応目処が立って Firebase realtime database を使ってのTodoアプリも完成したんだけど、FIrebase Authentication は 「Todo アプリをデプロイできるのか?」というとこの本質ではなかったなぁと思った。ただ、ちゃんとしたアプリを作りたい場合は必須の知識なので無駄ではなかったと信じたい。

typescript との連携

typescript だからと言って特に何もすることはなかった。

realtime database reference

database.ref(アドレス) で Reference オブジェクトが取得できる。このReference オブジェクトを使って値の取得だったり、値の追加だったりをする。

on() メソッド

on() メソッドは値をサブスクライブするのに使用する。逐一値をポーリングすることが不要になるので便利。

value

第一引数に"value" を指定すると、アドレスで指定した場所以下すべてのデータが返ってくる。一部を変更したとしても全部が返ってくる。

database.ref(`todos/${uid}`).on("value", function(snapshot) => {
  // todos/$uid 以下のデータがすべて送られてくる
  // {
  //    "a": {
  //      "id": "a",
  //      "text": "todo 1",
  //      "completed": false
  //    },
  //    "b": {
  //      "id": "b",
  //      "text": "todo 2",
  //      "completed": false
  //    }
  // }
  // 値は snapshot!.val() で取れる
  console.log(snapshot!.val())
})

child_added

child_added は項目の追加があった場合に追加部分だけが送られてくるので、データ量を少なくしたい場合に便利。

ref.on("child_added", function(snapshot) => {
  // todos/$uid/変更のあったデータ 以下のデータがすべて送られてくる
  // "value" の時とフォーマットが少し異なるので注意が必要
  // {
  //   "id": "a",
  //   "text": "todo 1",
  //   "completed": false
  // }
  // 値は snapshot!.val() で取れる
})

child_removed

child_removed は項目の削除があった場合に削除された項目部分だけが送られてくる コードは child_added と変わらないので省略

child_changed

child_changed は項目に更新があった場合に更新があった箇所を含むブロックが送られてくる。例えば、以下のような構造があったとして

{
  "a": {
    "id": "a",
    "text": "todo 1",
    "completed": false
  },
  "b": {
    "id": "b",
    "text": "todo 2",
    "completed": false
  }
}

"completed" を true にするように変更したとしたら、"b" 以下のデータブロックが送られてくる。child_added, child_removed 以外のなにがしかの交信がここで捕捉され通知されるといった感じだろうか。

ref.on("child_changed", function(snapshot) {
  // true になって送られてくる
  // { "id": "b", "text": "todo 2", "completed": true }
  console.log(snapshot!.val())
})

completed のトグル

先ほどの例のように "completed" を true にする。みたいな場合は以下のようにonceと組み合わせてsetを行う。

const database = firebase.database();

const ref = database.ref(`todos/${uid}/${id}/completed`)
ref.once('value').then((snapshot) => {
  ref.set(!snapshot.val())
})

Action と Action Dispatcher

firebase realtime database が on() メソッドによってデータベースの交信をリアルタイムに通知してくれる関係で、それをトリガにアクションを作成し、dispatcher に投げられるようになった。ただ、Viewからのアクションをaction_dispatcherで捕捉しないとdatabaseへアクセスできなくなるので、View → Action Dispatcher の流れと Firebase → Dispatcher の2種類の流れができてしまう。これはもう仕方ないのかな?と割り切っているが、どうにかできるんだろうか。

View → Action Dispatcher の流れは今までと変わらないので省略。

Firebase → Dispatcher の流れは基本的に Firebase の on() メソッドで捕捉したデータをもとにアクションを作成して dispatcher に渡しているだけだ。Reducer が type によって action と state をマージするという流れは変わらない。

  // Firebase Realtime Database 側
  todosref.on('child_added', function(snapshot) {
    if(snapshot!.val() !== null) {
      dispatch({
        type: 'todos/child_added',
        payload: snapshot!.val()
      })
    }
  }, function(error:any) {
    console.log(error)
  })
  // reducer 側
  case 'todos/child_added':
    if(action.payload === undefined || action.payload === null) {
      return state
    }
    return [
      ...state,
      action.payload
    ]

まとめ

一応、Firebase realtime database と連携させることで Todo アプリをデプロイすることができたが、色々コード的に汚い部分があるので、今後、思いついたタイミングで編集していこうと思う。

成果物

Merge pull request #19 from bamchoh/firebase · bamchoh/react-study@69cbf7b · GitHub

Firebase 入門(3) ~ セキュリティの向上

Authentication

Database ルール修正

今のままだと、データベースがどのユーザーでもアクセスできてしまうので、アクセス制限をする。まず、Database のルールを以下のように変更。内容としては、認証されたuidと同じ場合のみアクセスを許可している。

{
  "rules": {
    "users": {
      "$uid": {
        ".read": "$uid === auth.uid",
        ".write": "$uid === auth.uid"
      }
    }
  }
}

Firebase Authentication

Firebase には認証を管理する Authentication というサービスがある。それを使ってユーザーを認証し、uidを取得し、データベースにアクセスできるようにする。今回は Authentication を有効にして、「ログイン方法」の「メール/パスワード」のみを有効にした。

firebaseui

Firebase はデフォルトでログインフォームUIを提供している。それが firebaseui だ。まずは npmモジュールをインストールする。

$ npm i firebaseui --save

firebaseui 自体の使い方は簡単で、FirebaseUI インスタンスを Auth インスタンスで初期化して、インスタンスを設定オブジェクトでスタートしてやるだけでいい。

import firebase from 'firebase/app';
import 'firebase/database';
import 'firebase/auth';
import firebaseui from 'firebaseui'

const config = {
  apiKey: "<API_KEY>",
  authDomain: "<PROJECT_ID>.firebaseapp.com",
  databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
  projectId: "<PROJECT_ID>",
  storageBucket: "<BUCKET>.appspot.com",
  messagingSenderId: "<SENDER_ID>",
};

firebase.initializeApp(config);

var uiConfig = {
  callbacks: {
    signInSuccessWithAuthResult: function(authResult, redirectUrl) {
      return true;
    },
    uiShown: function() {
      document.getElementById('loading').style.display = 'none';
    }
  },
  signInFlow: 'popup',
  signInSuccessUrl: '/',
  signInOptions: [
    firebase.auth.EmailAuthProvider.PROVIDER_ID
  ],
  tosUrl: '/term_of_service',
  privacyPolicyUrl: '/privacy_policy'
};

var ui = new firebaseui.auth.AuthUI(firebase.auth());

ui.start('#firebaseui-auth-container', uiConfig);

ui.start() の第一引数で指定したIDの要素にログインフォームが表示される。

React で使う

React で使うなら、以下のような感じだろうか? LoginUI 関数の中が本体。多重読み込みを防ぐために、useEffect の 第二引数にダミーのstateを突っ込んでいるのがちょっとダサい。あと、この後データベースとの連携もあるので useReducer を使ってアクションを作っているが、なくても動く。

import * as React from 'react';
const { useState, useEffect, createContext, useReducer, useContext } = React;
import firebase from "firebase/app";
import 'firebase/database';
import 'firebase/auth';
import firebaseui from 'firebaseui';

const DispatchContext = createContext(() => {});

const reducer = (state, action) => {
  switch(action.type) {
    case 'change_user':
      if(state.uid === action.payload.uid) {
        return state
      }
      return { username: action.payload.username, uid: action.payload.uid }
    case 'change_username':
      if(state.username === action.payload.username) {
        return state
      }
      return { username: action.payload.username, uid: state.uid }
  }
};

const LoginUI = () => {
  const dispatch = useContext(DispatchContext);

  const [temp, _] = useState(0);

  useEffect(() => {
    const deffer = () => {}

    firebase.auth().onAuthStateChanged(function(user) {
      if(firebase.auth().currentUser !== null) {
        dispatch({type: 'change_user', payload: { username: user.displayName, uid: user.uid }})
        return deffer
      }

      var uiConfig = {
        callbacks: {
          signInSuccessWithAuthResult: function(authResult, redirectUrl) {
            return true;
          },
          uiShown: function() {
            // document.getElementById('loading').style.display = 'none';
          }
        },
        signInFlow: 'popup',
        signInSuccessUrl: '/',
        signInOptions: [
          firebase.auth.EmailAuthProvider.PROVIDER_ID
        ],
        tosUrl: '/term_of_service',
        privacyPolicyUrl: '/privacy_policy'
      };

      var ui = new firebaseui.auth.AuthUI(firebase.auth());

      ui.start('#firebaseui-auth-container', uiConfig);

      return deffer
    });
  }, [temp]);

  return (
    <>
      <div id="firebaseui-auth-container"></div>
    </>
  );
}

export default () => {
  const [state, dispatch] = useReducer(reducer, {
    username: "",
    uid: "",
  });

  const config = {
    apiKey: "<API_KEY>",
    authDomain: "<PROJECT_ID>.firebaseapp.com",
    databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
    projectId: "<PROJECT_ID>",
    storageBucket: "<BUCKET>.appspot.com",
    messagingSenderId: "<SENDER_ID>",
  };

  if (!firebase.apps.length) {
    firebase.initializeApp(config);
  }

  return (
    <>
      <div>{state.username}</div>
      <DispatchContext.Provider value={dispatch}>
        <LoginUI />
      </DispatchContext.Provider>
    </>
  );
};

Database と連携

App コンポーネントレンダリングされたタイミングでデータベースにアクセスしてユーザー情報を取得するようなものを作成した。先ほどの例の default 関数に useEffect Hook で実装する。users/<uid> のパスにアクセスして何も取得できない場合は、stateの情報をもとに箱を作成する。state が更新されるたびにデータベースにアクセスしにいかないように、state.uidが更新されたタイミングでアクセスするようにもした。

  useEffect(() => {
    if(state.uid =="") {
      return () => {}
    }

    database.ref('users/' + state.uid).once('value').then((snapshot) => {
      if(snapshot.val() === null) {
        database.ref('users/' + state.uid).set({
          username: state.username
        });
        dispatch({type: 'change_username', payload: { username: state.username}})
      } else {
        const newUsername = snapshot.val().username;
        dispatch({type: 'change_username', payload: { username: newUsername}})
      }
    })

  }, [state.uid])

おまけ (Signout ボタン)

デバッグで必要だったのでSignoutボタンをつけた。

export default 関数に Logout コンポーネントを追加して、Logoutコンポーネント側でボタンを作って onClickイベント関数の中で firebase.auth().signOut() メソッドを読んでいるだけ。

const LogoutUI = () => {
  const signOut = () => {
    firebase.auth().signOut().then(function() {
      console.log("signed out")
    }).catch(function(error) {
      console.log(error)
    });
  }
  return (
    <button onClick={signOut}>Sign out</button>
  )
}

export default () => {
  // 省略
  return (
    <>
    <div>{state.username}</div>
    <DispatchContext.Provider value={dispatch}>
      <LoginUI />
      <LogoutUI />
    </DispatchContext.Provider>
    </>
  );
}

Firebase 入門(2) ~ Firebase Realtime Database

作ったTodo アプリではサーバー側をgoで作成していたが、Firebaseにはサーバーを置くような機能はないので、Firebase Realtime Database でどうにかしたい。(できるのかもわからない)

Database を作成

todo プロジェクトから Database を選択し、データベースを作成する。ロックモードで作成した。

Firebase JavaScript クライアント SDK を追加

ここ を参考に以下のindex.html を作成。API_KEY とかは、Firebase のダッシュボードの Authentication から移動した先の「ウェブ設定」ボタンを押したところに記載されているので、自身のキーに置き換えること。

<html>
    <head>
       <title>Firebase Database Test</title>
   </head>
    <body>
        <div id="name"></div>
        <div id="email"></div>

        <script src="https://www.gstatic.com/firebasejs/5.8.2/firebase.js"></script>
        <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-app.js"></script>
        <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-database.js"></script>

        <script>
           var config = {
               apiKey: "<API_KEY>",
               authDomain: "<PROJECT_ID>.firebaseapp.com",
               databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
               projectId: "<PROJECT_ID>",
               storageBucket: "<BUCKET>.appspot.com",
               messagingSenderId: "<SENDER_ID>",
           };

           firebase.initializeApp(config);
       </script>
    </body>
</html>

Firebase Database にアクセス

以下のように index.html を修正。ただ、このままだとアクセスできないので先ほどロックモードで作成したデータベースを公開モードに修正する必要がある。公開モードにするには、Firebase Database の ダッシュボードから「ルール」を選択し、.read.write の設定を true にする。Firebase Database には Could FirestoreRealtime Database があり、デフォルトでは Could Firestore になっているので、Realtime Database に変更してから「ルール」の設定を行うこと。

<html>
    <head>
       <title>Firebase Database Test</title>
   </head>
    <body>
        <div id="name"></div>

        <script src="https://www.gstatic.com/firebasejs/5.8.2/firebase.js"></script>
        <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-app.js"></script>
        <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-database.js"></script>

        <script>
           var config = {
               apiKey: "<API_KEY>",
               authDomain: "<PROJECT_ID>.firebaseapp.com",
               databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
               projectId: "<PROJECT_ID>",
               storageBucket: "<BUCKET>.appspot.com",
               messagingSenderId: "<SENDER_ID>",
           };

           firebase.initializeApp(config);

           var database = firebase.database();

           database.ref('users/1').set({
               username: "bamchoh"
           });
           
           var username = "Anonymouse";
           database.ref('users/1').once('value').then(function(snapshot) {
               username = (snapshot.val() && snapshot.val().username);
               document.getElementById("name").innerHTML = username;
           });
           
           document.getElementById("name").innerHTML = username;
       </script>
    </body>
</html>

index.html をブラウザで開くと、bamchoh と表示される。

React で firebase

database へのアクセスはできるようになったので、React でアクセスできるようにする。まずは npm パッケージのインストール。

npm install --save firebase

次に src/index.js を修正。 コンポーネント側で Firebase の処理を行うことにする。

import React from 'react'
import ReactDOM from 'react-dom'

import App from './components/App'

ReactDOM.render(
  <App />,
  document.getElementById("root")
)

src/components/App.js を作成。React16.8 で入った Hooks を使用していることに注意。関数内にべた書きになっているのをもうちょっとどうにかしたい。

import * as React from 'react';
const { useState, useEffect } = React;
import firebase from "firebase/app";
import 'firebase/database';

export default () => {
  const [username, setState] = useState('Now loading...');

  const config = {
    apiKey: "<API_KEY>",
    authDomain: "<PROJECT_ID>.firebaseapp.com",
    databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
    projectId: "<PROJECT_ID>",
    storageBucket: "<BUCKET>.appspot.com",
    messagingSenderId: "<SENDER_ID>",
  };

  if (!firebase.apps.length) {
    firebase.initializeApp(config);
  }

  const database = firebase.database();

  const init = () => {
    database.ref('users/1').once('value').then((snapshot) => {
      const newUsername = (snapshot.val() && snapshot.val().username);
      setState(newUsername)
    })
  }

  init()

  return (
    <>
    username: {username}
    </>
  );
};

サーバーを起動してアクセスすると。Now Loading... の後に ユーザー名が表示される。Now Loading... を表示させずにユーザー名を表示させるのは今後の課題にする。

Firebase 入門(1) ~ Todo アプリをデプロイできるのか? (1)

今まで作ってきた Todo アプリを FIrebase にデプロイしてみる。

まず、新たにプロジェクトを作成して、Hosting に js をデプロイしてみる。作成したときの Project ID を覚えておく。あとで使う。

まず、Firebase ツールをインストールする

$ npm install -g firebase-tools

続いて Google にログイン

$ firebase login

んで、プロジェクトを初期化

$ firebase init

設定は以下

? Are you ready to proceed? Yes
? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then Enter to confirm your choices. Hosting: Configure and deploy Firebase Hosting sites
=== Project Setup
? Select a default Firebase project for this directory: [create a new project]
=== Hosting Setup
? What do you want to use as your public directory? public
? Configure as a single-page app (rewrite all urls to /index.html)? No

はじめに作成したプロジェクトのプロジェクトIDを今回作成したプロジェクトに追加

$ firebase use --add <Project ID>

デプロイしてみる。

$ firebase deploy

http://.firebaseapp.com にアクセスすると、public フォルダにある静的コンテンツがデプロイされていることがわかる。


ローカルで確認するには firebase serve を使用する。

$ firebase serve

http://localhost:5000 にアクセスすると、やはり public フォルダにある静的コンテンツがデプロイされている。


webpack でビルドしたファイルをデプロイする

React 入門 (1) ~ 基本環境構築 ~ を同フォルダに展開。

webpack.config.jsoutput の項目を足す

module.exports = {
  // ここから
  output: {
    filename: "bundle.js",
    path: __dirname + "/public"
  },
  // ここまでを追加
  module: {

npm run build すると public フォルダにデプロイされる

この状態で firebase serve をしたが、前のページが表示される。firebase deploy しないとダメみたい。

$ firebase deploy
$ firebase serve

localhost:5000Hello React が表示されていれば成功。


というところで今日のところは終了。明日以降に完全なTodoアプリのデプロイをしていく。あと、サーバ-サイドをどうするか考えないといけないので、このシリーズはもうあと数回続くと思う。


成果物

GitHub - bamchoh/firebase-study at 1c2c12978a1bbafa4416781d5288e9255a0b793c

React16.8 というものがリリースされたらしい

React16.8 がリリースされたらしい。巷で噂になっている。 なんでも Hooks という新しい機能が入ってそれが便利なんだとか。 ということで、もう一度環境構築から初めて Hooks がどういうものなのか 使ってみようとおもう。 ベースは ここ をベースにした。ただ、reactreact-dom はバージョンを指定してインストールし、src/index.js も修正する。

パッケージの再インストール

$ npm i --save react@16.8 react-dom@16.8

src/index.js の作成

import React from 'react'
import ReactDOM from 'react-dom'

import App from './components/App'

ReactDOM.render(
  <App />,
  document.getElementById("root")
)

参考サイト

【React】新機能hooks - Qiita

🎉React 16.8: 正式版となったReact Hooksを今さら総ざらいする - Qiita

Hooks API Reference – React

useState

現在のstateとsetState関数を返す関数らしい。

src/components/App.js の作成

import React, { useState } from 'react';

export default () => {
  const [a, b] = useState(0);
  return (
    <div>
      <button onClick={() => { b(a + 1); }}>
        \ovo/ {"<"}{a} times!!
      </button>
    </div>
  )
};

実行

npx webpack-dev-server --open --mode production

f:id:bamch0h:20190207014403g:plain
useState_example1

所感

普通なら function で state は管理できないけど、useSate() を使うことで管理できるようになって便利ってことなんだと思う。 たしかに class を書くよりも記述量が減ってると思うし、ちょっとしたコンポーネントで且つ state が必要そうな場合に重宝するのかもしれない。

後、初期値として渡している引数は初回呼び出しの一回だけしか有効ではないらしい。実際にクリックしても値がカウントするということはそういうことなんだろう。なぜそうなるのかは全く分からないけど。。。

useState(Functional updates)

setState の引数に関数を渡すと、前回値を受けて現在値を作成することが可能らしい。

src/components/App.js の修正

import React, { useState } from 'react';

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
    </>
  );
}

export default () => {
  const [a, b] = useState(0);
  return (
    <Counter initialCount={10} />
  )
};

実行

npx webpack-dev-server --open --mode production

f:id:bamch0h:20190207205903g:plain
Functional Updates

所感

前回値を保持していなくても関数で引き回せるっていうのがメリットなんだろうか?でも setCount(count + 1) とかでも同じ動作するし、使いどころがいまいちわからない。

あと、ここ の注意書きに、useState はオブジェクトのマージを自動的には行ってくれないから自前でやってね。的なことが書いてる。setCount(prev => { return { ...prev, ...updated }; }); みたいにするか、useReducer を使えばいいみたい。

useState(Lazy initial state)

initialState には関数も指定できるようだ。しかもその関数は初回のみ実行される。

src/components/App.js の修正

今回はページを更新する度に値がランダムに変化するようにしてみた。

import React, { useState } from 'react';

function getRandomInit(max) {
  return Math.floor(Math.random() * Math.floor(max));
}

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </>
  );
}

export default () => {
  const [a, b] = useState(0);
  return (
    <Counter initialCount={() => {
      return getRandomInit(100)
    }} />
  )
};

実行

f:id:bamch0h:20190207211533g:plain
lazy_initial_state

所感

複雑な初期値を要求されるケースには重宝するんだろうなー。という感じだけど、リアルユースケースとしてはパッと思いつかないので、忘れそう。あと、Resetは普通に動作するので、初回実行時の値を保持しておかないと、リセット的な動作は実現できなそうだった。

useState(Bailing out of a state update)

同じ値を現在値として使って state を更新しようとしても、state は更新されないしレンダリングもしませんよ。的なことが書いてあるみたい。

useEffect

レンダリングが完了した後に呼び出される関数らしい。再描画時も完了すると呼び出される。

useEffect(Cleaning up an effect)

return で返す関数によってクリーンナップができるとのこと。go言語言うところのdefer みたいな使い方かな?

import * as React from 'react';
const { useState, useEffect } = React;

export default () => {
  const [a, b] = useState(0);

  useEffect(() => {
    const timerid = setTimeout(() => {
      console.log("triggered!!"); // レンダリングが完了してから1秒後に表示される
    }, 1000);
    console.log("timer"); // レンダリングが完了した時点で表示される。

    return () => {
      clearTimeout(timerid);
      console.log("clean up useEffect") // ボタンをクリックして state が更新されることによって描画がリセットされるので、そのタイミングで表示される(?)
    }
  }, [a]);

  return (
    <>
      time: <b>{a}</b>
      <button onClick={() => b(a+1)}>+</button>
    </>
  )
};

所感

上の例ではあまりメリットがないような感じだけど、再描画に伴って何か処理を行うときに前回のオブジェクトを破棄しないといけない場合に重宝しそう

useEffect(Timing of effects)

特記することはなさそうだったのでスキップします。

useEffect(Conditionally firing an effect)

useEffectの第二引数に配列を渡すとその中のオブジェクトに変化があったときのみuseEffect()関数が呼び出されるようになるらしい。

useContext

React.createContext() で作ったコンテキストを取得できるようになるらしい。コンポーネント間の値のやり取りに使うと便利で、コンポーネント間の距離が遠いほどメリットを享受できるとかなんとか。

詳しい説明は こちら が詳しい。

useReducer

Reduxみたいなことができるらしい。

import * as React from 'react';
const { useReducer } = React;

const initialState = {count: 0};

function reducer(state, action) {
  switch(action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
  );
}

export default () => {
  return (
    <Counter initialCount={10} />
  );
};

実行

f:id:bamch0h:20190207223506g:plain
useReducer

所感

今作ってる Todo アプリくらいであれば置き換えられそう。Redux 使わずに React だけで完結できるから覚えることが減ってよさそうではある。

useMemo

値がメモ化できるらしい。複雑な計算を必要とする関数があり、でも前回値と同じ場合に計算をスキップして結果を再利用することができるとのこと。

import * as React from 'react';
const { useState, useMemo } = React;

function Counter({a, b}) {
  const [cv, setState] = useState(0);

  const add = useMemo(() => {
    console.log("test")
    return a + b;
  }, [a,b]);

  const add2 = () => {
    console.log("test2")
    return a + b;
  }
  return (
    <>
      <p>Count1: {add}</p>
      <p>Count2: {add2()}</p>
      <p>Count3: {cv}</p>
      <button onClick={() => setState(prev => prev + 1)}>+</button>
    </>
  );
}

export default () => {
  const [a, setA] = useState(1);
  const [b, setB] = useState(2);
  return (
    <>
    <Counter a={a} b={b}/>
    <button onClick={() => setA(prev => prev + 1)}>a++</button>
    <button onClick={() => setB(prev => prev + 1)}>b++</button>
    </>
  );
};

所感

上記の例では、+ボタンを押すたびに、"test2"がコンソール上に表示されるが、メモ化された関数は1回しか呼ばれない。でも、a++やb++ボタンを押すと"test"も表示され、値も更新されるので値が更新される場合に限り再メモ化がなされて便利。という感じらしい。確かにパフォーマンスを気にしないといけないような処理の場合にはメモ化は便利かもしれない。

useCallback

useMemo(() => fn, inputs) と同等の処理を別名にして、すこし書きやすくしている感じらしい。

詳しくは こちら

useRef

React.createRef のオブジェクトを返すものらしい。

import * as React from 'react';
const { useRef } = React;

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

export default () => {
  return (
    <>
      <TextInputWithFocusButton />
    </>
  );
};

所感

React.createRef と何が違うのだろう? という疑問もありつつ、こちら によると、レンダリング間の変数として使用することで今までにないrefの使い方ができるらしい。

useLayoutEffect

useEffect のようなフックで、レンダリングが完了して描画をする前に呼び出される関数。useEffect では、useEffectフック内で描画に関係するような処理を行っていた場合はuseEffectより先に描画がなされるので一瞬ちらついてしまうが、useLayoutEffectだと描画する前に実行されるので、そのようなことにはならない。ただ、useLayoutEffectは描画をブロックするので描画が遅れてしまう。不必要に使うべきではないフックらしい。

以下に こちら から引用したコードを載せておく。useLayoutEffect の部分を useEffect にすると確かに一瞬ちらついて見える。

import * as React from 'react';
const { useRef, useEffect, useLayoutEffect } = React;

const UseLayoutEffectSample = () => {
  const displayAreaRef = useRef();
  const renderCountRef = useRef(0);

  useLayoutEffect(() => {
    renderCountRef.current++;
    displayAreaRef.current.textContent = String(renderCountRef.current);
  });

  return (
    <p>
      このコンポーネントは
      <b ref={displayAreaRef} />
      回描画されました。
    </p>
  );
};

export default () => {
  return (
    <>
      <UseLayoutEffectSample />
    </>
  );
};

実行

f:id:bamch0h:20190207234805g:plain
useLayoutEffect

f:id:bamch0h:20190207234828g:plain
useEffect

useDebugValue

React DevTools 内にカスタムフックに関するラベル付きデバッグメッセージを表示できるフックらしい。カスタムフックがよくわからないけど、まぁ、デバッグメッセージがだせるのかな。程度に思っておく。

useImperativeHandle

親から渡ってきたref に対してメソッドをはやすことができるようになる?

import * as React from 'react';
const { useRef, forwardRef, useImperativeHandle } = React;

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} />;
}

FancyInput = forwardRef(FancyInput);

export default () => {
  const inputRef = useRef();

  const focus = () => {
    inputRef.current.focus();
  }

  return (
    <>
      <FancyInput ref={inputRef} />
      <button onClick={focus} >Focus</button>
    </>
  );
};

所感

コンポーネントが簡素化されて、子コンポーネントに責務を移譲させることができる。っていうのがメリットなのかなぁ??

まとめ

useReducer は使いどころがあるかなぁという感じで、それ以外はまだReactに慣れてからもう一度再勉強かなという感じ。

いつものようにここに載せたコードは Github にもアップしている。

GitHub - bamchoh/react16_8_study: My study repo for react 16.8

React 入門 (14) ~ TODOアプリを作る(9) ~ sendToApiServer のテスト

今日は sendToApiServer のテスト。fetch が絡むので mock ライブラリを導入する。

$ npm i --save fetch-mock
$ npm i --save @types/fetch-mock

fetchMock は import * as fetchMock from 'fetch-mock' でインポート可能。

一旦以下のように作ってnpm test を実行してみる。

import sendToApiServer from './sendToApiServer'

describe('sendToApiServer', () => {
  it('ADD_TODO', () => {
    const action = {
      type: 'ADD_TODO',
      text: "test",
    }
    sendToApiServer({}, action)
  })
})

すると、以下のようなエラーが表示された。

 FAIL  src/utils/sendToApiServer.test.tsx
  ● Test suite failed to run

    TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
    src/utils/sendToApiServer.test.tsx:9:21 - error TS2345: Argument of type '{}' is not assignable to parameter of type 'Dispatch<any>'.
      Type '{}' provides no match for the signature '<T extends any>(action: T): T'.

    9     sendToApiServer({}, action)
                          ~~

どうも型が一致していないようだ。ここはいったん sendToApiServer.tsx 側の型を any にして無理やり通すことにした。


続いて、fetchMock を足してテストを通す。

import * as fetchMock from 'fetch-mock'
import sendToApiServer from './sendToApiServer'

describe('sendToApiServer', () => {
  it('ADD_TODO', async () => {
    const action = {
      type: 'ADD_TODO',
      text: "test",
    }

    fetchMock.post('/api/add_todo', {body: {'type':'ADD_TODO', 'text':'test'}, status: 200});

    const mockDispatch = jest.fn()

    await sendToApiServer(mockDispatch, action)

    expect(mockDispatch.mock.calls.length).toBe(1)
    expect(mockDispatch.mock.calls[0][0]).toEqual(action)
  })
})

sendToApiServer() 自体は非同期で動作するので、await をつける。つけないと mockDispatch が呼ばれる前にexpect() の評価がされてしまう。あと、await をつけるなら テスト関数自体を async にしないと怒られるのでつけておくこと。

fetchMock は post 関数を呼ぶことで post がきたときのみ動作するようになる。body には返り値を指定する。

私の設計では、渡したアクションをそのまま返すので、はじめはactionのオブジェクトをbodyに設定していたが、元のアクションが間違っていた場合にテストが通ってしまうな。と思ったので、めんどくさいが分けて書くことにした。


COMPLETE_TODODELETE_TODO に関しても同様にテストを記載した。ADD_TODO と変わるところはないので、省略


FETCH_TODO もテストを追加する。FETCH_TODO は返り値がDBの状況によって変化するため、いくつかテストパターンを書いたほうがいいと思い、describe でネストしてその下にテストを追加した。そうした場合に fetchMock がエラーを出力するようになった。原因はテストケースをまたいでfetchMock が使用されてしまい、関数が再定義されてしまうためのようだ。各テストケースの終わりに fetchMock.restore() で解放してやることで問題なく動作するようになった。


成果物は以下。

GitHub - bamchoh/react-study at c711a96976f8f9df82f0b17bacd334535c339cb0


次回は サーバーサイドのテストを書く予定。