React + FirestoreでInfinit Scrollによるページングはよくやるのですが、[次へ], [戻る]で遷移する普通の?ページングをやってみたら以外としんどかったのでメモしておきます。
検索機能等を実装した応用編もどうぞ。
完成品
下記のような感じです。わかりにくいですが下の方に[<],[>]ボタンが付いてます。
前提条件
- Firesbase(Firestore)の利用準備はできている(firestoreが使える状態)。
- 秘密鍵等をダウンロードできる/できている(この意味がわかる)。
- create-react-appを利用できる状態になっている。
- 私はMacで作業してますがWindowsでもあまり変わらないと思います。
秘密鍵のダウンロードについてはこちらが参考になります(最初の方だけ)。
テストデータの生成
まず、ページングをテストするために表示用のテストデータを作成します。これはReactと関係ありません。
下記のような感じでフォルダ、ファイルを作ります。ローカル(node.js)でfirestoreを使うためにfirebase-adminを使います。
mkdir generate
cd generate
touch index.js
npm install --save firebase-admin
index.jsを下記のようにします。とりあえず100件のデータを生成してみます。
項目とかは最低限のテストができる内容ですが、お好みで変更するといいと思います。
1つポイント?があるとするなら、検索機能実装用にkeywordsという各ドキュメント内容を配列で格納するフィールドを作成しているところでしょうか。これをどう使うかは後々(応用編で利用)わかります。
/path/to/key.jsonは各自の環境に合わせた秘密鍵ファイルを取得・利用してください。FirebaseのWeb画面で取得します。
const admin = require('firebase-admin');
const serviceAccount = require('/path/to/key.json');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: 'https://project-id.firebaseio.com',
});
const db = admin.firestore();
//emitt warning防止
require('events').EventEmitter.defaultMaxListeners = 0;
//データ生成
for (let i = 1; i <= 100; i++) {
const docId = db.collection('members').doc().id;
const memberId = ('000000000' + i).slice(-10);
const name = "user" + i;
const email = "user" + i + "@test.com";
const age = Math.floor(Math.random() * 60) + 15;
const areas = ['東京', '大阪', '福岡', '仙台', '札幌'];
const address = areas[Math.floor(Math.random() * areas.length)];
db.collection('members').doc(docId).set({
docId: docId,
memberId: memberId,
name: name,
email: email,
age: age,
address: address,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
keywords: [docId, memberId, name, email, age.toString(), address],
id: i,
});
console.log("create data no" + i);
}
実行して生成します。
node index.js
データが正しく生成されているかどうかFirestoreのWeb consoleで確認してみてください。
開発前の準備
React雛形の作成 + モジュールのインストール
とりあえず雛形を作ります。
Firebaseを使うのでfirebaseもインストールします。
またレイアウトにreactstrapを使うのでインストールします。
時間の表示形式を整えるためにmomentもインストールします。
create-react-app pagination
cd pagination
yarn add firebase bootstrap reactstrap moment
yarnでない場合はnpm install --save bootstrap reactstrap firebase moment としてください。また、スタイルにreactstrapを使うのはCoreUI for React等でのスタイリングに利用されているからです(ノウハウの汎用化のため)。
Firebase機能を使うための準備
ReactでFirebaseの機能を利用するためにFirebaseの設定・生成ファイルを作成しておきます。
なお、create-react-appでは標準でdotenvを利用できるので、設定項目は.envに記述することにします。
まず、それぞれのファイルを作ります。
touch .env
touch src/Firebase.js
.envをgithubとかに公開しないように.gitignoreで.envを登録しておきましょう。
.env
xxxxに相当する値はFirebase Web Consoleで確認してください。
REACT_APP_API_KEY=xxxxx
REACT_APP_AUTH_DOMAIN=xxxx
REACT_APP_DATABASE_URL=xxxx
REACT_APP_PROJECT_ID=xxxx
REACT_APP_STORAGE_BUCKET=xxxx
REACT_APP_MESSAGING_SENDER_ID=938033440605
REACT_APP_APP_ID=xxxx
src/Firebase.js
.envの内容を利用してconfig内容を記述します。
import firebase from 'firebase/app';
import "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.REACT_APP_API_KEY,
authDomain: process.env.REACT_APP_AUTH_DOMAIN,
databaseURL: process.env.REACT_APP_DATABASE_URL,
projectId: process.env.REACT_APP_PROJECT_ID,
storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_APP_ID
};
firebase.initializeApp(firebaseConfig);
export const db = firebase.firestore();
export default firebase;
これでfirebaseの機能を呼び出して利用する準備が整いました(そのはず)。
bootstrap cssの適用
index.jsでbootstrapのcssを読み込んでおきます。
import React from 'react';
import ReactDOM from 'react-dom';
+import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
Paginationの実装方針(いいわけ)
Paginationの実装は、firestoreのクエリでLimitやStartAfter(), StartAt()等を組み合わせて実現するのですが、なかなか良い方法が見つからず冗長なコードになっています。
今回の実装では、初期データの取得、「次」のデータの取得、「前」のデータの取得ごとに専用の関数を作成しました。
- getData()で初期に表示するデータを取得(表示)
- getNext()で次のページのデータを取得(表示)
- getPrev()で前のページのデータを取得(表示)
もちろんgetData(start位置,end位置)みたいに1つの関数にしたかったのですが、なかなか良い方法が見つかりませんでした。
随時改良・更新したいと思います。
実装1:ひとまず情報を表示してみる
実装はApp.jsに対して行ってみます。
では、ひとまず表示してみます。
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';
import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';
class App extends React.Component {
state = {
items: [], //検索結果のいれもの
}
componentDidMount = () => {
this.getData();
}
//初期データ取得用
getData = async () => {
//クエリ
const initialQuery = db.collection('members')
.orderBy('createdAt', 'desc')
.limit(10);
//取得
const snapshot = await initialQuery.get();
//各行を取得
const docs = snapshot.docs.map(doc => doc.data());
//state更新
this.setState({
items: docs,
});
}
render() {
return (
<div className="container">
<h3 className="my-4">Pagination sample.</h3>
<Table striped>
<thead>
<tr>
<th>ID</th>
<th>UID</th>
<th>Name</th>
<th>Age</th>
<th>Address</th>
<th>CreatedAt</th>
</tr>
</thead>
<tbody>
{
this.state.items.map(doc => (
<tr key={doc.docId}>
<td>{doc.id}</td>
<td>{doc.docId}</td>
<td>{doc.name}</td>
<td>{doc.age}</td>
<td>{doc.address}</td>
<td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr>
))
}
</tbody>
</Table>
<Pagination>
<PaginationItem>
<PaginationLink previous className="mr-3"/>
</PaginationItem>
<PaginationItem>
<PaginationLink next />
</PaginationItem>
</Pagination>
</div>
);
}
}
export default App;
確認してみます。
yarn start
npmの人はnpm start。
実装2:Pagination(Next:次へ)を実装する
次にNextボタンを機能させてみたいと思います。
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';
import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';
class App extends React.Component {
state = {
items: [], //検索結果
+ limit: 10,
+ lastVisible: null, //次のページの表示位置を保存するいれもの
}
componentDidMount = () => {
this.getData();
}
//初期データ取得用
getData = async () => {
//クエリ
const initialQuery = db.collection('members')
.orderBy('createdAt', 'desc')
+ .limit(this.state.limit); //limit値をsatteから取得
//取得
const snapshot = await initialQuery.get();
//各行を取得
const docs = snapshot.docs.map(doc => doc.data());
//最後の行(オブジェクトを記憶しておく)
+ const lastVisible = snapshot.docs[docs.length - 1];
//state更新
this.setState({
items: docs,
+ lastVisible: lastVisible,
});
}
+ //次のデータを取得(以下、関数全体を追加)
getNextData = async () => {
const nextQuery = db.collection('members')
.orderBy('createdAt', 'desc')
.startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
.limit(this.state.limit);
const snapshot = await nextQuery.get();
//データが1つ以上なければ何もしない(最後のページ対策)
if (snapshot.size < 1) {
return null;
}
const docs = snapshot.docs.map(doc => doc.data());
const lastVisible = snapshot.docs[docs.length - 1];
this.setState({
items: docs,
lastVisible: lastVisible,
});
}
+ //nextがクリックされたとき(以下、関数全体を追加)
handleNext = () => {
this.getNextData();
}
render() {
return (
<div className="container">
<h3 className="my-4">Pagination sample.</h3>
<Table striped>
<thead>
<tr>
<th>ID</th>
<th>UID</th>
<th>Name</th>
<th>Age</th>
<th>Address</th>
<th>CreatedAt</th>
</tr>
</thead>
<tbody>
{
this.state.items.map(doc => (
<tr key={doc.docId}>
<td>{doc.id}</td>
<td>{doc.docId}</td>
<td>{doc.name}</td>
<td>{doc.age}</td>
<td>{doc.address}</td>
<td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr>
))
}
</tbody>
</Table>
<Pagination>
<PaginationItem>
<PaginationLink previous className="mr-3" />
</PaginationItem>
<PaginationItem>
+ <PaginationLink next onClick={this.handleNext} />
</PaginationItem>
</Pagination>
</div>
);
}
}
export default App;
nextボタンが機能するようになりました。
実装3:Pagination(Prev:戻る)を実装する
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';
import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';
class App extends React.Component {
state = {
items: [], //検索結果
limit: 10,
lastVisible: null,
+ history: [], //戻る履歴を入れる
}
componentDidMount = () => {
this.getData();
}
//初期データ取得用
getData = async () => {
//クエリ
const initialQuery = db.collection('members')
.orderBy('createdAt', 'desc')
.limit(this.state.limit);
//取得
const snapshot = await initialQuery.get();
//各行を取得
const docs = snapshot.docs.map(doc => doc.data());
//最後の行(オブジェクトを記憶しておく)
const lastVisible = snapshot.docs[docs.length - 1];
+ //Prevでの戻り先をhistoryに記憶
+ const startVisible = snapshot.docs[0];
+ let history = [...this.state.history];
+ history.push(startVisible);
//state更新
this.setState({
items: docs,
lastVisible: lastVisible,
+ history: history,
});
}
//次のデータを取得
getNextData = async () => {
const nextQuery = db.collection('members')
.orderBy('createdAt', 'desc')
.startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
.limit(this.state.limit);
const snapshot = await nextQuery.get();
//データが1つ以上なければ何もしない(最後のページ対策)
if (snapshot.size < 1) {
return null;
}
const docs = snapshot.docs.map(doc => doc.data());
const lastVisible = snapshot.docs[docs.length - 1];
+ //Prevでの戻り先をhistoryに記憶
+ const startVisible = snapshot.docs[0];
+ let history = [...this.state.history];
+ history.push(startVisible);
this.setState({
items: docs,
lastVisible: lastVisible,
+ history: history,
});
}
+ //前のデータを取得(関数全体を追加)
getPrevData = async () => {
if (this.state.history.length <= 1) {
return null;
}
const prevQuery = db.collection('members')
.orderBy('createdAt', 'desc')
.startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
.limit(this.state.limit);
const snapshot = await prevQuery.get();
if (snapshot.size < 1) {
return null;
}
const docs = snapshot.docs.map(doc => doc.data());
const lastVisible = snapshot.docs[docs.length - 1];
//戻る際に最後のhistoryを削除
const history = [...this.state.history];
history.pop();
this.setState({
items: docs,
lastVisible: lastVisible,
history: history,
});
}
//nextがクリックされたとき
handleNext = () => {
this.getNextData();
}
+ //prevがクリックされたとき(関数全体を追加)
handlePrev = () => {
this.getPrevData();
}
render() {
return (
<div className="container">
<h3 className="my-4">Pagination sample.</h3>
<Table striped>
<thead>
<tr>
<th>ID</th>
<th>UID</th>
<th>Name</th>
<th>Age</th>
<th>Address</th>
<th>CreatedAt</th>
</tr>
</thead>
<tbody>
{
this.state.items.map(doc => (
<tr key={doc.docId}>
<td>{doc.id}</td>
<td>{doc.docId}</td>
<td>{doc.name}</td>
<td>{doc.age}</td>
<td>{doc.address}</td>
<td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr>
))
}
</tbody>
</Table>
<Pagination>
<PaginationItem>
+ <PaginationLink previous className="mr-3" onClick={this.handlePrev} />
</PaginationItem>
<PaginationItem>
<PaginationLink next onClick={this.handleNext} />
</PaginationItem>
</Pagination>
</div>
);
}
}
export default App;
とりあえずPaginationは一旦動きます。
考察
検索ヒット数やページ数を表示すべきか否か?
全体の検索ヒット数やページ数を表示するためには全てのレコードを一度取得する必要がある(全てをメモリにロードする)ため、Firebase等のNoSQLを利用する場合は極力「表示なし」にしたほうがいいかなという感じ(特にログ系とか)。
一方、レコード数が1000件以下程度であることが明確なコレクション(テーブル)に関してはヒット数やページ数を計算・表示してもいいかもしれません。
参考:https://www.sukerou.com/2019/08/firestore.html
応用編
- 実装4:検索機能をつける
- 実装5:CSVダウンロード機能をつける