MDN Express チュートリアルを MERN スタック化 Pt.2: フロントエンドのリファクタリング

2021-05-20ReactExpress

概要

前回の記事 で 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 コンポーネント ← アイテム表示

/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 と共有
  • Create コンポーネント
    • Form コンポーネント ← 同一サービスの Update と共有
  • Update コンポーネント
    • Form コンポーネント ← 同一サービスの Create と共有
  • Delete コンポーネント
    • DeleteView コンポーネント ← 各サービス間で共有
      • DependingList コンポーネント ←DetailView と共有

コンポーネントの共有化がかなり進み、冗長さが削減できた事が見て取れる。

まとめ

  • リファクタリングをして実施して、コードの冗長さがかなり削減できた。
  • リファクタリングはかなり大規模な作業になり、多くの労力を要した。
  • 工程の 早期段階 から、整理整頓 を心がける事の大切さを痛感した。