概要
前回の記事 で MDN のチュートリアルに React を適用して MERN スタックの SPA (Single-Page Application) にする事が出来た。
フロントエンドの実装では、 Create コンポーネントと Update コンポーネントの共通部分を、独立した Form コンポーネントとして共有 する 実装を効率化(コード量を削減) は図っている事にも触れた。しかし、現状では素朴に React の枠組みはめ込んだだけで、ちょっと見ただけでも コードの冗長さ が気になる。
まず、バックエンドからリストや詳細情報を取得する クエリー処理 が、あらゆるコンポーネントで繰り返されている。これらは React の カスタムフック にまとめて、共有すれば実装がスッキリしそうだ。
次に サービス横断的に コンポーネントを見てみると、例えば 一覧表示(*List コンポーネント) は、どれを見ても「見出し → リスト」という 基本構成は同じ だ。詳細表示(*Detail コンポーネント)も、やや複雑になるが「見出し → 詳細 → 依存リスト → 更新・削除へリンク」という構成は同じだ。何となく 共通コンポーネントが抽出 できそうだ。
リファクタリングの方針
- バックエンドへのクエリー処理 → カスタムフック
- コンポーネントの共通部分 → 共通コンポーネント
というわけで、今回はフロントエンドのリファクタリングを試みる。
リファクタリング 1: クエリー処理をカスタムフックにする
クエリー処理は、useState フック を利用して ローカル状態変数 を定義し、useEffect フックでクエリーがレンダリング後に一度だけ実行されるように指定する、定型的なパターンで書かれている。これらを カスタムフック にまとめて、共有する。
段階 1: カスタムフック
例えば本の情報を取得するカスタムフックを定義すると、
function useBookGet(id) {
const [book, setBooks] = useState();
useEffect(() => {
getBook();
}, []);
async function getBook() {
try {
const res = await BookService.get(id);
setBook(res.data);
} catch (err) {
console.log(err);
}
return book;
}
BookDetail コンポーネントの クエリー処理が一行で完結 するようになる。
export default function BookDetail() {
const { id } = useParams();
const book = useBookGet(id);
// 省略
BookUpdate コンポーネントや BookDelete コンポーネントも、同様に一行で書けるようになる。
export default function BookUpdate() {
const { id } = useParams();
const book = useBookGet(id);
// 省略
カスタムフックは BookHook.js などにひとまとめにして共有する。これでコードの冗長さがかなり削減できそうだ!!
段階 2: 依存性の注入
それでもまだ、AuthorHook.js, GenreHook.js, BookHook.js, BookInstanceHook.js と、サービス毎に作業を繰り返す必要 がある。また、今後ユーザ管理など新たにサービスが追加されたときにも手間が増える。そこで、もうひと工夫したい。
データサービスが 共通のインターフェイス をもつ事に注目して、依存性の注入 を利用してカスタムフックを統一する。つまり、カスタムフックの引数にデータサービスのオブジェクトを渡すように変更を加える。
/react/src/hook/ServiceHooks.js
export function useGetAll(service) {
const [itemList, setItemList] = useState();
useEffect(() => {
getItemList();
}, []);
async function getItemList() {
try {
const res = await service.getAll();
setItemList(res.data);
} catch (err) {
console.log(err);
}
}
return itemList;
}
例えば BookList コンポーネントでは、
export default function BookList() {
const books = useGetAll(BookService);
// 省略
AuthorList コンポーネントでも、
export default function AuthorList() {
const authors = useGetAll(AuthorService);
// 省略
と同じのカスタムフックが使える。これで 横断的にサービスを網羅 するカスタムフックが出来た!
ただし BookService.findByAuthorId()
など、依存関係のクエリー に関しては、サービス間で統一したインターフェイス を提供するように更なる工夫が必要。まず BookService.findByAuthorId()
や BookService.findByGenreId()
と個別に定義していた関数を、BookService.findBy(service, query)
に統一する。AuthorId や GenreId は、{authorId: 123}
のようにオブジェクトで引数に渡すようにすれば良い。
/react/src/services/BookService.js
// query: クエリーオブジェクト
// 例: {authorId: id1, genreId: id2}
const findBy = (query) => {
// クエリーオブジェクトをクエリー文字列に変換
// 例: authorId=<id>&genreId=<id>
const queryString = Object.keys(query).reduce((acc, key, i, keys) => {
acc += `${key}=${query[key]}` + (i < keys.length - 1 ? '&' : '');
return acc;
}, '');
return http.get(`${urlPrefix}?${queryString}`);
};
そして、この findBy()
関数に対応するカスタムフック useFindBy()
を、これまでと同様に定義すれば完成!
/react/src/book/BookDetail.js
export default function BookDetail() {
const { id } = useParams();
const book = useGet(BookService, id);
// depending list
const dependings = useFindBy(BookInstanceService, { bookId: id });
// 省略
/react/src/book/BookDetail.js
export default function AuthorDetail() {
const { id } = useParams();
const { item: author } = useGet(AuthorService, id);
const author = useGet(AuthorService, id);
// depending list
const dependings = useFindBy(BookService, { authorId: id });
// 省略
リファクタリング 2: 共通コンポーネントの抽出
次に サービス横断的 にコンポーネントを分析して、共通コンポーネントの抽出 をしていく。
一覧表示 List コンポーネント
一番簡単そうな、一覧表示(List コンポーネント)から始める。一覧表示は「見出し → リスト」という構成になっている事を冒頭で述べた。この 一覧表示という共通機能 を持つ ListView コンポーネント を考えたい。ただし、見出しとリストのアイテム表示に違いがあるので、それらを 動的 に生成する必要がある。これは、リストアイテムを表示するコンポーネントを個別に定義して、依存性の注入 で ListView コンポーネントに渡すようにすれば実現できる。各コンポーネントの親子関係を整理すると次の様になる。
- BookList コンポーネント
- ListView コンポーネント ← 共通コンポーネント
- BookListItemInline コンポーネント ← アイテム表示
- ListView コンポーネント ← 共通コンポーネント
/react/src/book/BookListItemInline.js
const BookListItemInline = (props) => {
return (
<li>
<Link to={`/book/${props.item._id}`}>{props.item.title}</Link>
<span> </span>({props.item.author.name})
</li>
);
};
BookListItemInline.propTypes = {
item: PropTypes.object.isRequired,
};
export default BookListItemInline;
そして核心の ListView コンポーネントを書く。プロパティとしてリストとリストアイテムコンポーネントを受け取る。更に、その リストアイテムコンポーネントに対して、アイテムをプロパティで渡したい のだけれども、JSX で表現できない ので React.createElement()
メソッド を利用している。これが最大のポイント。この辺の実装は 【React】子コンポーネントを動的に指定する方法 - Yohei Isokawa を参考にさせて頂きました。
/react/src/ListView.js
import React from 'react';
import PropTypes from 'prop-types';
import { proper } from './utils';
const ListView = (props) => {
const list =
props.list.length === 0 ? (
<li>There are no {props.context}s.</li>
) : (
props.list.map((item) => {
return React.createElement(props.listItemElement, {
item,
key: item._id,
});
})
);
return (
<>
<h1>{proper(props.context)} List</h1>
<ul>{list}</ul>
</>
);
};
ListView.propTypes = {
context: PropTypes.string.isRequired,
list: PropTypes.array.isRequired,
listItemElement: PropTypes.elementType.isRequired,
};
export default ListView;
この共通コンポーネントを使うと、それぞれの 各種 ListView コンポーネントが凄くシンプルに書けるようになった!!
/react/src/book/BookList.js
export default function BookList() {
const books = useGetAll(BookService);
return (
<ListView
context="book"
list={books}
listItemElement={BookListItemInline}
/>
);
}
/react/src/author/AuthorList.js
export default function AuthorList() {
const { itemList: authors } = useGetAll(AuthorService);
return (
<ListView
context="author"
list={authors}
listItemElement={AuthorListItem}
/>
);
}
詳細表示 Detail コンポーネント
次に詳細表示のコンポーネントを分析していく。段階的に見ていくと、本が依存 する著者とジャンルのコンポーネントでは、
AuthorDetail と GenreDetail の構成
- 見出し ← 個別
- 詳細 ← 個別
- 依存する本リスト
- 更新・削除へリンク ← 共通
となる。次に、本の現物が依存 する本のコンポーネントでは、
BookDetail の構成
- 見出し ← 個別
- 詳細 ← 個別
- 依存する本の現物のリスト
- 更新・削除へリンク ← 共通
となり、依存リストが本から本の現物に変化 する。最後に、本の現物のコンポーネントでは、
BookInstanceDetail の構成
- 見出し
- 詳細
- 更新・削除へリンク ← 共通
と依存リストが消える。これらを集約すると最大公約数的に、詳細表示の共通部分 DetailView コンポーネント として
DetailView の構成
- [依存するリスト] ← 対象と表示方法が入れ替わる!
- 更新・削除へリンク
のような構成が考えられる。サービス横断的に使えるコンポーネントを作るには、結構沢山のプロパティを指定する必要がある。DetailView コンポーネントの propTypes プロパティ(プロパティの型定義)を示しておこう。
DetailView.propTypes = {
// 表示する対象: "author" など
context: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
// 依存関係の対象: "book" など ←context: "bookinstance"(本の現物)のときは空
depending: PropTypes.string,
// 依存関係クエリーのデータサービス ←context: "bookinstance"(本の現物)のときは空
dependingSearchService: PropTypes.func,
// 依存リストの見出し・依存リストが空の場合のメッセージ(BookInstance 用)
dependingAlias: PropTypes.string,
noDependingMessage: PropTypes.string,
// リストタイプ: ul, ol, dl
listType: PropTypes.string,
// リストアイテムの出力方法
listItemElement: PropTypes.elementType,
};
BookDetail(本の現物)コンポーネントでは、BookInstance(本の現物)の依存リストを表示するときに、見出しに加えて、依存リストが空の場合のメッセージを変更したいので、デフォルトの表示を上書きできる noDependingMessage プロパティ を用意している。
DetailView の大まかな流れは、データサービスを使って依存リスト(depndings 変数)を取得して、そのリストからリスト表示(dependingList 変数)を生成していく。リストが空の場合は、依存関係なしのメッセージを出力する。
/react/src/DetailView.js
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useFindBy } from './hooks/Service';
import { proper } from './utils';
import './DependingList.css';
const DetailView = (props) => {
// 依存リストを取得
let dependingQuery = new Object();
dependingQuery[`${props.context}Id`] = props.id;
const { result: dependings } = useFindBy(
props.dependingSearchService,
dependingQuery
);
// 依存リストの表示を作成していく
let dependingList = '';
if (props.dependingSearchService) {
if (dependings.length === 0) {
// 依存リストが空の場合は、依存なしのメッセージ
const message = props.noDependingMessage
? props.noDependingMessage
: `This ${props.context} has no ${props.depending}s.`;
dependingList = <p>{message}</p>;
} else {
// 指定された listItemElement でリストを生成
dependingList = dependings.map((item) => {
return React.createElement(props.listItemElement, {
item,
key: item._id,
});
});
}
// リストにリストタイプの要素を追加
switch (props.listType) {
case 'dl': {
dependingList = <dl>{dependingList}</dl>;
break;
}
default: {
dependingList = <ul>{dependingList}</ul>;
}
}
// リストに見出しを追加して完成
dependingList = (
<div className="list">
<h4>
{props.dependingAlias
? proper(props.dependingAlias)
: proper(`${props.depending}s`)}
</h4>
{dependingList}
</div>
);
}
const url = `/${props.context}/${props.id}`;
const CapContext = proper(props.context);
return (
<>
{dependingList}
<hr />
<p>
<Link to={`${url}/delete`}>Delete {CapContext}</Link>
</p>
<p>
<Link to={`${url}/update`}>Update {CapContext}</Link>
</p>
</>
);
};
DetailView.propTypes = {
// author or genre
context: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
depending: PropTypes.string,
dependingAlias: PropTypes.string,
dependingSearchService: PropTypes.object,
noDependingMessage: PropTypes.string,
listType: PropTypes.string,
listItemElement: PropTypes.elementType,
};
DetailView.defaultTypes = {
listType: 'li',
};
export default DetailView;
BookDetail コンポーネントを例に、この共通コンポーネント DetailView の使い方を見てみよう。context プロパティに “book”、depending プロパティに “bookinstance”を、そのほかメッセージやリストスタイルなどをプロパティで渡している。
/react/src/book/BookDetail.js
export default function BookDetail() {
const { id } = useParams();
// 本の詳細情報を取得
const { item: book } = useGet(BookService, id);
if (!book) return <></>;
// 本の詳細情報の表示を生成
const bookDetail = book ? (
<>
<h1>Title: {book.title}</h1>
<p>
<strong>Author:</strong>
<Link to={`/author/${book.author._id}`}> {book.author.name}</Link>
</p>
<p>
<strong>Summary:</strong> {book.summary}
</p>
<p>
<strong>ISBN:</strong> {book.isbn}
</p>
<p>
<strong>Genre:</strong> <InlineGenreList genre={book.genre} />
</p>
</>
) : (
''
);
return (
<>
{bookDetail}
<DetailView
context="book"
id={id}
depending="bookinstance"
dependingSearchService={BookInstanceService}
dependingAlias="Copies"
noDependingMessage="There are no copies of this book in the library."
listType="dl"
listItemElement={BookInstanceListItemBox}
/>
</>
);
}
削除 Delete コンポーネント
最後に削除のコンポーネントを分析していく。Detail と同様に見ていくと、本が依存 する著者とジャンルのコンポーネントでは、
AuthorDelete と GenreDelete の構成
- 見出し ← 個別
- 詳細 ← 個別
- ケース 1: 依存する本がある場合
- 先に本の削除を促すメッセージ ← 共通
- 依存する本リスト ← 共通
- ケース 2: 依存する本が無い場合
- 削除ボタン ← 共通
となる。次に、本の現物が依存 する本のコンポーネントでは、
BookDelete の構成
- 見出し ← 個別
- 詳細 ← 個別
- ケース 1: 依存する本の現物がある場合
- 先に本の現物の削除を促すメッセージ ← 共通
- 依存する本の現物のリスト ← 共通
- ケース 2: 依存する本の現物が無い場合
- 削除ボタン ← 共通
となり、依存リストが本から本の現物に変化 する。最後に、本の現物のコンポーネントでは、
BookInstanceDetail の構成
- 見出し
- 詳細
- 削除ボタン ← 共通
と依存リストが消える。これらを集約すると最大公約数的に、削除の共通部分 DeleteView コンポーネント として
DeleteView の構成
- ケース 1: 依存するリストがある場合
- 先に依存する対象の削除を促すメッセージ
- 依存する本の現物のリスト ← 対象と表示方法が入れ替わる!
- ケース 2: 依存するリストが無い場合
- 削除ボタン ← 共通
のような構成が考えられる。一覧表示同様に、まず DeleteView コンポーネントの propTypes プロパティ(プロパティの型定義)を確認しておこう。削除するデータサービス deleteService が加わった 以外、DetailView コンポーネントと共通している。
DeleteView.propTypes = {
// 削除する対象: "author" など
context: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
// 削除対象のデータサービス
deleteService: PropTypes.func.isRequired,
// 依存関係の対象: "book" など ←context: "bookinstance"(本の現物)のときは空
depending: PropTypes.string,
dependingSearchService: PropTypes.func,
// 依存リストの見出し・依存リストが空の場合のメッセージ(BookInstance 用)
dependingAlias: PropTypes.string,
// ul, ol, dl
listType: PropTypes.string, // NOT Required
// リストアイテムの出力方法
listItemElement: PropTypes.elementType, // NOT Required
};
DeleteView コンポーネントの実装は、データサービスを使って依存リスト(depndings 変数)を取得して、そのリストからリスト表示(dependingList 変数)を生成していくところまでは、 DetailView と同じ。DetailView との違いは、依存リストが空の場合、見出しとメッセージも含めてリストは表示されずに、削除ボタンだけ表示される点にある。
/react/src/DeleteView.js
import React from 'react';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { proper } from './utils';
import { useFindBy } from './hooks/Service';
import './DependingList.css';
const DeleteView = (props) => {
// 依存リストを取得
let dependingQuery = new Object();
dependingQuery[`${props.context}Id`] = props.id;
const { result: dependings } = useFindBy(
props.dependingSearchService,
dependingQuery
);
const history = useHistory();
// クリックイベントハンドラ(削除)
async function handleClick() {
try {
await props.deleteService(props.id);
console.log('removeAuthor');
history.push(`/${props.context}s`);
} catch (err) {
console.log(err);
}
}
// 依存リストの表示を作成していく
let dependingList;
if (dependings.length === 0) {
// 依存リストが空の場合は、依存なしのメッセージ
const message = `This ${props.context} has no ${props.depending}s.`;
dependingList = <p>{message}</p>;
} else {
// 指定された listItemElement でリストを生成
dependingList = dependings.map((item) => {
return React.createElement(props.listItemElement, {
item,
key: item._id,
});
});
}
// リストにリストタイプの要素を追加
switch (props.listType) {
case 'dl': {
dependingList = <dl>{dependingList}</dl>;
break;
}
default: {
dependingList = <ul>{dependingList}</ul>;
}
}
// リストに見出しを追加して完成
dependingList = (
<div className="list">
<h4>
{props.dependingAlias
? proper(props.dependingAlias)
: proper(`${props.depending}s`)}
</h4>
{dependingList}
</div>
);
return (
<>
{dependings.length > 0 ? (
<>
<p>
<strong>
Delete the following{' '}
{props.dependingAlias
? props.dependingAlias
: `${props.depending}s`}{' '}
before attempting to delete this {props.context}.
</strong>
</p>
{dependingList}
</>
) : (
<>
<p>Do you really want to delete this {props.context}?</p>
<button
type="button"
onClick={handleClick}
className="btn btn-primary"
>
Delete
</button>
</>
)}
</>
);
};
DeleteView.propTypes = {
// author or genre
context: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
deleteService: PropTypes.func.isRequired,
depending: PropTypes.string,
dependingAlias: PropTypes.string,
dependingSearchService: PropTypes.object,
listType: PropTypes.string,
listItemElement: PropTypes.elementType,
};
DeleteView.defaultTypes = {
listType: 'li',
};
export default DeleteView;
BookDelete コンポーネントを例に、共通コンポーネント DeleteView の使い方を見てみる。context プロパティに “book”、depending プロパティに “bookinstance”を、そして deleteService プロパティに BookService.remove を渡している。
/react/src/book/BookDelete.js
import React from 'react';
import { useParams } from 'react-router-dom';
import { useGet } from '../hooks/Service';
import BookService from '../services/BookService';
import BookInstanceService from '../services/BookInstanceService';
import DeleteView from '../DeleteView';
import BookInstanceListItemInlineOnlyTitle from '../bookinstance/BookInstanceListItemInlineOnlyTitle';
export default function BookDelete() {
const { id } = useParams();
const { item: book } = useGet(BookService, id);
if (!book) return <></>;
return (
<>
<h1>Delete Book: {book.title}</h1>
<DeleteView
context="book"
id={id}
deleteService={BookService.remove}
depending="bookinstance"
dependingSearchService={BookInstanceService}
dependingAlias="copies"
listType="li"
listItemElement={BookInstanceListItemInlineOnlyTitle}
/>
</>
);
}
DetailView コンポーネントと DeleteView コンポーネント
これまでサービス横断的にコンポーネントを分析して、共通コンポーネントの抽出をして来た。しかし、感の良い方は既にお気づきかもしれない、最後の 2 つの DetailView コンポーネントと DeleteView コンポーネントが、依存リストを含む点で極めて類似 していることに。そこで最後に、依存リストを生成する DependingList コンポーネント を独立・共有する。
/react/src/DependingList.js
import React from 'react';
import PropTypes from 'prop-types';
import { proper } from './utils';
import './DependingList.css';
const DependingList = (props) => {
let listView;
if (props.deps.length === 0) {
// No Deps Message
const message = props.noDependingMessage
? props.noDependingMessage
: `This ${props.context} has no ${props.depType}s.`;
listView = <p>{message}</p>;
} else {
// Item List with listItemElement
listView = props.deps.map((item) => {
return React.createElement(props.listItemElement, {
item,
key: item._id,
});
});
}
switch (props.listType) {
case 'dl': {
listView = <dl>{listView}</dl>;
break;
}
default: {
listView = <ul>{listView}</ul>;
}
}
return (
<div className="list">
<h4>
{props.depAlias ? proper(props.depAlias) : proper(`${props.depType}s`)}
</h4>
{listView}
</div>
);
};
DependingList.propTypes = {
context: PropTypes.string.isRequired,
depType: PropTypes.string.isRequired,
depAlias: PropTypes.string,
deps: PropTypes.array.isRequired,
noDependingMessage: PropTypes.string,
// ul, ol, dl
listType: PropTypes.string.isRequired,
// the way to display each item
listItemElement: PropTypes.elementType.isRequired,
};
DependingList.defaultProps = {
listType: 'ul',
};
export default DependingList;
伴って DetailView コンポーネントと DeleteView コンポーネントを変更する。例えば DetailView は次のようになる。
/react/src/DeleteView.js
const DetailView = (props) => {
// depending list
let dependingQuery = new Object();
dependingQuery[`${props.context}Id`] = props.id;
const { result: dependings } = useFindBy(
props.dependingSearchService,
dependingQuery
);
const dependingList = props.dependingSearchService ? (
<DependingList
context={props.context}
depType={props.depending}
depAlias={props.dependingAlias}
deps={dependings}
noDependingMessage={props.noDependingMessage}
listType={props.listType}
listItemElement={props.listItemElement}
/>
) : (
''
);
const url = `/${props.context}/${props.id}`;
const CapContext = proper(props.context);
return (
<>
{dependingList}
<hr />
<p>
<Link to={`${url}/delete`}>Delete {CapContext}</Link>
</p>
<p>
<Link to={`${url}/update`}>Update {CapContext}</Link>
</p>
</>
);
};
まとめ
Pt.1 の内容も含めて、これまでの結果をまとめると次のようになる。
- List コンポーネント
- ListView コンポーネント ← 各サービス間で共有
- Detail コンポーネント
- DetailView コンポーネント ← 各サービス間で共有
- DependingList コンポーネント ←DeleteView と共有
- DetailView コンポーネント ← 各サービス間で共有
- Create コンポーネント
- Form コンポーネント ← 同一サービスの Update と共有
- Update コンポーネント
- Form コンポーネント ← 同一サービスの Create と共有
- Delete コンポーネント
- DeleteView コンポーネント ← 各サービス間で共有
- DependingList コンポーネント ←DetailView と共有
- DeleteView コンポーネント ← 各サービス間で共有
コンポーネントの共有化がかなり進み、冗長さが削減できた事が見て取れる。
まとめ
- リファクタリングをして実施して、コードの冗長さがかなり削減できた。
- リファクタリングはかなり大規模な作業になり、多くの労力を要した。
- 工程の 早期段階 から、整理整頓 を心がける事の大切さを痛感した。