react-beautiful-dnd を触ってみた
TODO アプリのTODOをドラッグ&ドロップで移動させたいなぁ。という欲求が高まってきたので、そういうライブラリがないか探してみたら、Atlassianが提供してる react-beautiful-dnd
というライブラリがあるようだったので使ってみた。
GitHub - atlassian/react-beautiful-dnd: Beautiful and accessible drag and drop for lists with React
使い方は動画になっていて、ステップバイステップで教えてくれるので良い。
Beautiful and Accessible Drag and Drop with react-beautiful-dnd from @alexandereardon on @eggheadio
今のところ、第5回まで見て、それの通りに作ってみてドラッグアンドドロップでリストが変えられるようになったので
最小構成のコード片が作れたかなと思って記事にしてみた。
動画の途中で コンポーネントの innerRef 属性に対して設定している箇所があったんだけど、うまく動かなくて色々調べてたら、styled-components v4
から innerRef が削除されて、React の ref 属性をそのまま使うようになったとかで、最新のコードでは innerRef 属性の代わりに ref 属性を使って設定する必要があるみたいだった。
詳しくは、ここらへん に書いてあるので読んでみるといいかも。
使い勝手としては、まぁ今のところは最小構成なのでもうちょっとインタラクティブに動いてほしいなぁと思うところはあれど、期待していたような動作をしてくれるので満足している。スマホからでも動かせるみたいだし、調整次第では使えるものになるかなぁと思っている。
あとは、Material-UI と併用できるのか?というところが気になるので、そのあたりを今後調べていきたい。
成果物はここ に上げた。
(2019/02/21 追記)
material-ui と styled-component を共存させることでドラッグアンドドロップが可能なリストを作成することができた。material-ui のみだと ref が参照できなくて react-beautiful-dnd がエラーを出してしまうようだった。
成果物はここ に上げた。
TODOアプリにfavicon を設定する
TODOアプリに favicon を設定する。ただ、Reactだとbody部分の要素しかいじれないので webpack で設定する。 色々試したけど、favicons-webpack-plugin が最終的にやりたいことにマッチしたので、それにした。 まず、プラグインをインストール。
$ npm i --save favicons-webpack-plugin
次に webpack.config.js
を編集
const FaviconsWebpackPlugin = require("favicons-webpack-plugin") module.exports = { plugins: [ new FaviconsWebpackPlugin('./src/favicon.png'), ] };
./src/favicon.png
をもとに Android / iPhone / Web 用の favicon が自動生成され index.html に自動でリンクされる。
ただ、このままだと、webpack-dev-server
ではちゃんと表示されなかったので、以下の設定を webpack.config.js
に追加した。
module.exports = { devServer: { contentBase: [path.resolve(__dirname, "public/")], }, }
以上!!
webpackでビルドしたjsファイル名にコンテンツハッシュを追加する
ローカルで開発をしていると jsファイルがブラウザでキャッシュされていて「あれ?動かないぞ?」という場面が何度かあった。Webブラウザ上だったら強制更新(Chrome だったら Shift + Ctrl + R) で解決できるんだけど、iPhoneとかのモバイルブラウザだとそういうわけにもいかないので対策が必要だった。
Cache Busting
というわけで、Cache Busting を導入する。Rails のアセットパイプラインにも導入されている方式でこれをwebpackにも導入できるはず。と予想していたが、やはりあった。
Cache Busting とは jsファイルのパス名に適当にクエリ文字列をつけ、別のファイル名として認識させることでキャッシュを別に行うという手法だと認識してる。クエリ文字列をビルド毎に別にしておけばキャッシュも別になるから、いつも最新のファイルが読み込まれるし、プロダクションだとクエリ文字さえ固定であれば同じキャッシュが呼ばれるので、みんな幸せ。
Webpack で Cache Busting
公式のドキュメント には 設定ファイル(webpack.config.js) に以下のように記述すれば解決すると記載されているし、実際その通りにしたらできた。
module.exports = { // 一部省略 output: { filename: '[name].[contenthash].js' } };
WebpackのCache Busting はクエリ文字列を付加するのではなくて、ファイル名をビルド毎に違う名前にすることでキャッシュされるのを回避してるようだ。この [contenthash]
の部分が実際にはハッシュ文字列に置換されてファイル名を構成する。
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 Firestore
と Realtime 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
を修正。
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...
を表示させずにユーザー名を表示させるのは今後の課題にする。