プログラマーであれば誰でも一度は掲示板なるものを作ってみたいと思うはず。
React勉強中ということもあり、試しにFirebaseと連携して掲示板を作ってみることにした。もし、同士の方がいれば参考にしてほしい。
コードを少しずつ書いて、丁寧に説明するつもりはない。完成形のコードを貼り付けて、参考になりそうなところだけ見て欲しい。適宜解説は入れている。
デモはこちらから。
React×Firebase掲示板作成。大まかな設計
関数コンポーネントの使い分けは適宜、自由に設定してもらって構わない。要はformボタンが押されたらfirebaseのfirestore(データベース)に接続して、そのデータを取得したり格納したりするだけ。
Reduxも多少は使っているが、そこまで複雑なことはしていない。
実際のコーディング内容
それではさらっとコードを見ていこう。
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { Provider } from "react-redux";
import { store } from "./app/store";
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
reportWebVitals();
index.jsは外せない。Providerというタグを使っているが、これはRedux用に設定したもの。storeデータをグローバルにしようするためにタグをつけた。とりあえずAppタグにstoreデータを使いたいだけ。という意味。
それではAppコンポーネントの中身は何かみてみよう。
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Header from "./components/Header";
import Main from "./components/Main";
import "./style.css";
function App() {
return (
<Router>
<div className="App">
<Header />
<Switch>
<Route exact path="/">
<Main />
</Route>
</Switch>
</div>
</Router>
);
}
export default App;
ルーティング設定をしてはいるが、今回はメインページだけで完結するから設置しなくても良い。後にページネーションも実装するが、ページネーションではルーティングせず、表示するデータを変更させるだけにしている。すべては非同期で処理する。
Main.jsの中身は何か。
import React, { useRef, useState } from "react";
import ThreadArea from "./ThreadArea";
import TopButtons from "./buttons/TopButtons";
import NewPostThread from "./NewPostThread";
import ReactPaginate from "react-paginate";
import { db } from "../firebase";
import { useCollection } from "react-firebase-hooks/firestore";
function Main() {
const perPage = 5;
const threadAddRef = useRef(null);
const newThreadAddRef = useRef(null);
const [threadInfo, loading] = useCollection(
db.collection("threads").orderBy("timestamp", "desc")
);
const [pagenateInfoList, setPagenateInforList] = useState({
offset: 0, //始まりの位置
perPage: perPage, //1ページに表示するスレッド数
});
const handlePageClick = (data) => {
/* slice a list five thread and update offset */
let pageNumber = data["selected"]; //2を押したら1が返る。
//始まりの位置が変更されるだけ
setPagenateInforList({
offset: pageNumber * perPage, //2番のリンクをクリックしたら1*5=5になる。
perPage: perPage,
});
//pageのトップに自動でスクロールする。
threadAddRef?.current?.scrollIntoView({
behavior: "smooth",
});
};
var Spinner = require("react-spinkit");
if (loading) {
return (
<div className="loadingArea">
<div className="loadingInnerArea">
<div className="loadingText">Now Loading...</div>
<Spinner name="ball-spin-fade-loader" color="purple" fadeIn="none" />
</div>
</div>
);
}
return (
<div>
<div ref={threadAddRef} style={{ paddingTop: 10 }}></div>
<TopButtons newThreadAddRef={newThreadAddRef} />
<ReactPaginate
pageCount={Math.ceil(threadInfo?.size / perPage)}
marginPagesDisplayed={2}
pageRangeDisplayed={3}
onPageChange={handlePageClick}
containerClassName="paginateContainer"
pageClassName="pageItem"
pageLinkClassName="pageLink"
activeClassName={"active"}
previousLabel={"<<"}
nextLabel={">>"}
previousClassName="pageItem" // '<'の親要素(li)のクラス名
nextClassName="pageItem"
disabledClassName="disabled" //先頭or末尾に行ったときにそれ以上戻れ(進め)なくするためのクラス
breakLabel={"..."}
breakClassName="pageItem" // 上記の「…」のクラス名
breakLinkClassName="pageIink" // 「…」の中のリンクにつけるクラス
/>
<ThreadArea
threadInfo={threadInfo}
offset={pagenateInfoList.offset}
perPage={pagenateInfoList.perPage}
/>
{/* paginationArea */}
<ReactPaginate
pageCount={Math.ceil(threadInfo?.size / perPage)}
marginPagesDisplayed={2}
pageRangeDisplayed={3}
onPageChange={handlePageClick}
containerClassName="paginateContainer"
pageClassName="pageItem"
pageLinkClassName="pageLink"
activeClassName={"active"}
previousLabel={"<<"}
nextLabel={">>"}
previousClassName="pageItem" // '<'の親要素(li)のクラス名
nextClassName="pageItem"
disabledClassName="disabled" //先頭or末尾に行ったときにそれ以上戻れ(進め)なくするためのクラス
breakLabel={"..."}
breakClassName="pageItem" // 上記の「…」のクラス名
breakLinkClassName="pageIink" // 「…」の中のリンクにつけるクラス
/>
<NewPostThread
threadAddRef={threadAddRef}
setPagenateInforList={setPagenateInforList}
/>
<div style={{ paddingBottom: 10 }} ref={newThreadAddRef}></div>
</div>
);
}
exportdefaultMain;
なるほどMainページともあれば膨大な量のコードになるのは致し方ない。firestoreに保存してあるデータをuseStateとして格納し、データ数獲得のために利用している。firebaseとの連携、接続に関してはドキュメントを読めばいい。詳しくfirebase公式にチュートリアルが書かれている。
あとは基本となるHTMLレイアウト。そこから関数処理。特にページネーションはライブラリを使って実装している。ページネーションさせるためのoffsetやらperPage設定は役に立つだろう。1画面表示にどれだけスレッドを表示させるか。それさえ分かれば簡単に計算ができる。
ちなみに、スレッドを投稿した際は自動でホームページトップ遷移させるJsも実装している。
TopButtonコンポーネントは以下の通り。
import React from "react";
import ContactButton from "./ContactButton";
import NewThreadButton from "./NewThreadButton";
function TopButtons({ newThreadAddRef }) {
return (
<div className="topButtonsArea">
<NewThreadButton newThreadAddRef={newThreadAddRef}>
新規スレッド
</NewThreadButton>
<ContactButton>連絡</ContactButton>
</div>
);
}
export default TopButtons;
NewTheradボタンとContactボタンでコンポーネントで分けているが、無理に分ける必要はない。ここら辺はお好みでいいだろう。好きにレイアウトすればいい。
続いてはTheadArea.jsxの中身を覗いてみる。
import React from "react";
import Thread from "./Thread";
function ThreadArea({ threadInfo, offset, perPage }) {
return (
<div className="threadArea">
{threadInfo?.docs.slice(offset, offset + perPage).map((thread) => {
const { name, threadFirstComment, title, timestamp } = thread.data();
return (
<Thread
key={thread.id}
id={thread.id}
name={name}
threadFirstComment={threadFirstComment}
title={title}
timestamp={timestamp}
/>
);
})}
</div>
);
}
export default ThreadArea;
ページネーションで表示するデータを制限し、うまい具合にmap関数でその分だけデータを取り出している。これは我ながらうまくやってのけたと思う。useStateをつかった配列からmapで情報を取り出すことは読者もやったことがあるだろう。イメージはその配列をfirestoreからとってきたデータに置き換えるだけ。それをただmapをつかって取り出して表示しているだけである。
肝となるThead.jsxを見てみよう。
import React, { useRef } from "react";
import { useState } from "react";
import { db } from "../firebase";
import firebase from "@firebase/app-compat";
import { useDispatch, useSelector } from "react-redux";
import { selectThread, selectThreadId } from "../features/appSlice";
import { useCollection } from "react-firebase-hooks/firestore";
// import { collection, addDoc, setDoc, doc } from "firebase/firestore";
function Thread({ id, name, threadFirstComment, title, timestamp }) {
const [inputName, setInputName] = useState("");
const [inputTextArea, setInputTextArea] = useState("");
const [replyToName, setReplyToName] = useState("");
const [nameErrors, setNameErrors] = useState([]);
const [textAreaErrors, setTextAreaErrors] = useState([]);
const replyFormRef = useRef(null);
const dispatch = useDispatch();
const threadId = useSelector(selectThreadId); //フォーカスしたときにしか呼ばれないよね。
const [replyInfo] = useCollection(
id &&
db
.collection("threads")
.doc(id)
.collection("reply")
.orderBy("timestamp", "asc")
);
const handleChange = (e) => {
setInputName(e.target.value);
};
const handleTextAreaChange = (e) => {
setInputTextArea(e.target.value);
};
const formVailed = () => {
if (inputName.length === 0 && inputTextArea.length !== 0) {
setNameErrors({
errorMessage: "※未入力エラーです。",
});
setTextAreaErrors([]);
} else if (inputName.length !== 0 && inputTextArea.length === 0) {
setTextAreaErrors({
errorMessage: "※未入力エラーです。",
});
setNameErrors([]);
} else if (inputName.length === 0 && inputTextArea.length === 0) {
setNameErrors({
errorMessage: "※未入力エラーです。",
});
setTextAreaErrors({
errorMessage: "※未入力エラーです。",
});
return false;
} else if (inputName.length !== 0 && inputTextArea.length !== 0) {
setNameErrors([]);
setTextAreaErrors([]);
return true;
}
return false;
};
const handleSubmit = (e) => {
e.preventDefault();
if (formVailed()) {
/* データベースに返信したデータを送る */
/* 返信先を指定しているときはreplyToName: >>>宛名とともに保存する。 */
if (replyToName.length > 0) {
console.log(replyToName);
threadId &&
db
.collection("threads")
.doc(threadId)
.collection("reply")
.add({
name: inputName,
replyAddress: ">>" + replyToName,
replyComment: inputTextArea,
timestamp: firebase.firestore.FieldValue.serverTimestamp(),
});
setInputName("");
setInputTextArea("");
setReplyToName("");
return;
}
threadId &&
db.collection("threads").doc(threadId).collection("reply").add({
name: inputName,
replyComment: inputTextArea,
timestamp: firebase.firestore.FieldValue.serverTimestamp(),
});
setInputName("");
setInputTextArea("");
setReplyToName("");
}
};
const handleFocus = () => {
/* どのthreadのなのかを指定 */
if (id) {
dispatch(
selectThread({
threadId: id,
})
);
}
};
const handleReplyButton = (name) => {
/* 返信先宛名を設定 */
setReplyToName(name);
/* 自動で返信欄へスクロール */
replyFormRef?.current?.scrollIntoView({
behavior: "smooth",
});
};
const replyCancel = () => {
/* 返信を解除 */
console.log("a");
setReplyToName("");
};
return (
<div>
<div className="thread">
<strong>
<span id="threadnumber">お題:</span>
{title}
</strong>
<div className="threadComment">
<p id="username">
1 名前: <b> {name}</b>
<span className="threadInfo">
<span className="dateText">
{new Date(timestamp?.toDate()).toLocaleString()}
</span>
<button
className="replyButton"
onClick={() => handleReplyButton(name)}
>
[返信]
</button>
</span>
</p>
<p id="threadContentArea">
<span id="threadContent">{threadFirstComment}</span>
</p>
{/* threadIdによって表示させる内容を変える必要がある。 */}
{/* threadId4ならthreadId4のreplyInfoをmapで繰り返し表示させる */}
{id &&
replyInfo?.docs.map((reply, index) => {
const { name, replyComment, timestamp, replyAddress } =
reply.data();
return (
<div key={reply.id}>
<p id="username">
{index + 2} 名前: <b> {name}</b>
<span className="threadInfo">
<span className="dateText">
{new Date(timestamp?.toDate()).toLocaleString()}
</span>
<button
className="replyButton"
onClick={() => handleReplyButton(name)}
>
[返信]
</button>
</span>
</p>
<div id="threadContentArea" ref={replyFormRef}>
<span id="threadContent">
{/* 送信ボタンを押したかつreplyToNameに名前があれば、改行してリプライする */}
{replyAddress && (
<div
className="replyAddress"
style={{ marginBottom: 2 }}
>
{replyAddress}
</div>
)}
{replyComment}
</span>
</div>
</div>
);
})}
</div>
{/* 返信用フォーム */}
<form onSubmit={handleSubmit}>
<table className="replyTable">
<tbody>
<tr>
<th>おなまえ</th>
<td>
<input
onChange={handleChange}
value={inputName}
type="name"
name="name"
maxLength="12"
className="nameInput"
onFocus={handleFocus}
/>
{nameErrors && (
<span id="errorMessage">{nameErrors.errorMessage}</span>
)}
</td>
</tr>
<tr>
<th colSpan="2">
このスレッドに書き込む
<span className="replyToName">
{replyToName ? "(To:" + replyToName + "さんへ)" : ""}
</span>
<br />
<textarea
placeholder={replyToName && `>>${replyToName}さんへの返信`}
onChange={handleTextAreaChange}
name="comment"
className="textArea"
value={inputTextArea}
></textarea>
{textAreaErrors && (
<span id="errorMessage">{textAreaErrors.errorMessage}</span>
)}
<div className="replyButtons">
<button className="submitButton">投稿</button>
<button
type="button"
className="replyCancel"
onClick={replyCancel}
>
返信解除
</button>
</div>
</th>
</tr>
</tbody>
</table>
</form>
</div>
</div>
);
}
export default Thread;
theadIdによってスレッド毎に表示させるデータを変更させる。そのためにReduxでthreadIDをグローバルに共有できるように設定しているのである。useRefやらuseDispatch,useSelectorなど難しいHooksが登場しているが、すべてはReduxのために使用している。
この掲示板は返信機能も実装している。これを作成したのが1か月前だから詳しいことは覚えていない。コメントアウトの部分を読んで理解してほしい。正直ここのコンポーネントはリファクタの余地が往々にしてある。コード量が長すぎるから各自リファクタしてほしい。
次回はfirebaseの設定コーディングを見てみることにする。