Reactコンポーネントの抽象化とインターフェースのリファクタリング
記事の概要と動機
Takepepeさんの「AtomicDesign 境界線のひき方」という記事を読んでいて、はたと気づいた。「限定的コンポーネントを横断的なものに移行する」という箇所は、SOLID原則のISPとそのリファクタリングの話だ。ISP(Interface Segregation Principle)とはインターフェース分離原則である。
コンポーネントは、はじめは限定的コンテキストで実装するべきでしょう。共通利用される頃合いに、リファクタリングすれば十分です。その際に忘れてはならないことが「抽象化」です。
この記事は、Takepepeさんの記事中の以下の一文に対して、インターフェースという観点から解説を加えた返歌、つまりアンサーソングである。
コンポーネントのインターフェース
フロントエンドのコンポーネントのインターフェースとは、単純化するとPropsの型である。
type ArticlesListCardProps = {
title: string;
author: string;
description: string;
}
export const ArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
// ...
)
Reactコンポーネントとは、Propsを引数にとり、ReactElementまたはnullを返す関数である。ReactコンポーネントにおいてPropsは可変である一方、返り値の型は不変である。そこで、関心の対象を可変であるPropsに絞ることにする。これが、「コンポーネントのインターフェースとは、単純化するとPropsの型」と述べた理由だ。
具象コンポーネント
Propsの型が定義されていることで、親コンポーネント(依存する側)が子コンポーネント(依存される側)の使い方を知らなくて済む。つまり、ParentComponent
はArticlesListCard
という具象コンポーネントではなく、type ArticlesListCardProps
という抽象に依存する。
(オブジェクト指向言語では抽象クラスと具象クラスの区別が存在する一方、Reactでは抽象コンポーネントは存在しない。以下ではPropsの型という抽象との対比を強調するため、一般的なReactコンポーネントを具象コンポーネントと呼ぶことにする)
import { ArticlesListCard } from './ArticlesListCard'
const ParentComponent = React.FC = () => (
<div>
<ArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
<SomeComponent />
</div>
)
ここでは、要件が加わり、ArticlesListCard
をOtherArticlesListCard
というコンポーネントに変更すると仮定する。DOMの構造は変更されているが、利用するPropsの型は同じだ。
type ArticlesListCardProps = {
title: string;
author: string;
description: string;
}
export const ArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
<div>
<h1>{props.title}</h1>
<p>{props.author}</p>
<p>{props.description}</p>
</div>
)
export const OtherArticlesListCard: React.FC<ArticlesListCardProps> = (props) => (
<div>
<h1>{props.title}</h1>
<p>{props.description}({props.author})</p>
</div>
)
すると、ParentコンポーネントではArticlesListCard
をOtherArticlesListCard
に変更する修正だけで済む。親コンポーネントが渡すPropsに変更がないことに注目頂きたい。
import { OtherArticlesListCard } from './ArticlesListCard'
const ParentComponent = React.FC = () => (
<div>
<OtherArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
</div>
)
Propsの変更が不要である理由は、ParentComponent
は具象コンポーネントArticlesListCard
に依存しており、ArticlesListCard
はArticlesListCardProps
という抽象に依存しているるためだ。
想定通り動作していることを確認したら、OtherArticlesListCard
の名前をArticlesListCard
に変更し、ArticlesListCard
を削除する。これで安全なリファクタリングが可能だ。
ただ、DOMの変更のみであれば直接JSXを書き換えるだけで十分だ。実務でこの手順を踏んでいる方はいないだろう。ここでは説明のためにあえて上記の手順を踏んだ。重要なのは「インターフェースを固定したまま、具体的な実装を差し替える」という意識である。
似て非なるコンポーネントの登場
次に、似て非なるコンポーネントが登場したとする。Propsの型は以下の通りだ。
Takepepeさんの記事を引用すると、ArticlesListCardとほとんど同じである。
「author・publisher」「description・body」は同じ場所にレンダリングされる値です。
type PublishersListCardProps = {
title: string;
publisher: string;
body: string;
}
export const PublishersListCard: React.FC<PublishersListCardProps> = (props) => (
<div>
<p>{props.body}({props.publisher})</p>
<h2>{props.title}</h2>
</div>
)
説明を簡単にするために、PublishersListCard
を先ほどのParentComponent
で利用する。
import { OtherArticlesListCard } from './ArticlesListCard'
import { PublishersListCard } from './PublishersListCard'
const ParentComponent = React.FC = () => (
<div>
<ArticlesListCard title={'タイトル'} author={'著者名'} description={'説明'} />
<PublishersListCard title={'タイトル'} publisher={'出版社名'} body={'本文'} />
</div>
)
コンポーネントを対比すると共通点を可視化できる。ParentComponent
はArticlesListCard
とPublishersListCard
という具象に依存している。
依存関係を図示する
ParentComponentと2つのコンポーネントに対する依存を図示する。
この図を見て、オブジェクト指向言語でのプログラミングに慣れた方であれば、依存の方向を逆転させることを考えるだろう。つまり、ParentComponentは具体的な2つのコンポーネントに依存するのではなく、Propsの型に依存するべきだと思うはずだ(DIP)。
しかし、筆者の知る限りReactにはコンポーネントのDI機構はないため、(1つずつコンポーネントをPropsとして渡せばDI自体は可能だが)具象コンポーネントを利用する方が一般的である。
ただし、TypeScriptやFlowで記述している場合、親コンポーネント側に明示的に子コンポーネントのPropsの型を記述しなくても、親コンポーネント側から渡すPropsの値との整合性をチェックしてくれる。このため、厳密には正確ではないと自覚しつつ、筆者はコンポーネントの依存関係を以下のように理解、解釈している。
コンポーネント同士の依存関係を明示できたため、インターフェースをリファクタリングする必要性を容易に理解できるだろう。
インターフェースをリファクタリングする
もう一度Propsの型定義を見てみよう。プロパティ名以外の違いはないことに気付くはずだ。つまり、2つのPropsの型には共通点をまとめるというリファクタリングの余地があるのだ。
type ArticlesListCardProps = {
title: string;
author: string;
description: string;
}
type PublishersListCardProps = {
title: string;
publisher: string;
body: string;
}
そこで、Takepepeさんは以下のように型を抽象化している。コンポーネントでPropsの使われ方を参照しつつ、プロパティ名が一段階抽象化されていることが読み取れる。
type ListCardProps = {
title: string;
subTitle: string;
main: string;
}
このListCardProps
型を利用して、コンポーネントのリファクタリングを実施する。
コンポーネントをリファクタリングする
ArticlesListCard
とPublishersListCard
をListCardProps
に依存させ、具象コンポーネントをリファクタリングする。まずはコンポーネントが受け取るPropsを変更する。
type ListCardProps = {
title: string;
subTitle: string;
main: string;
}
export const ArticlesListCard: React.FC<ListCardProps> = (props) => (
<div>
<h1>{props.title}</h1>
<p>{props.main}({props.subTitle})</p>
</div>
)
type ListCardProps = {
title: string;
subTitle: string;
main: string;
}
export const PublishersListCard: React.FC<ListCardProps> = (props) => (
<div>
<p>{props.main}({props.subTitle})</p>
<h2>{props.title}</h2>
</div>
)
ParentComponentは以下のようになる。この段階でParentComponentは具象(ArticlesListCard
とPublishersListCard
)に依存している。抽象(ListCardProps
)には、両コンポーネントを通して間接的に依存している。
import { ArticlesListCard } from './ArticlesListCard'
import { PublishersListCard } from './PublishersListCard'
const ParentComponent = React.FC = () => (
<div>
<ArticlesListCard title={'タイトル'} subTitle={'著者名'} main={'説明'} />
<PublishersListCard title={'タイトル'} subTitle={'出版社名'} main={'本文'} />
</div>
)
次に、ListCardProps
という抽象に依存したコンポーネントを同一ファイルListCard
にまとめる。
type ListCardProps = {
title: string;
subTitle: string;
main: string;
}
export const ArticlesListCard: React.FC<ListCardProps> = (props) => (
<div>
<h1>{props.title}</h1>
<p>{props.main}({props.subTitle})</p>
</div>
)
export const PublishersListCard: React.FC<ListCardProps> = (props) => (
<div>
<p>{props.main}({props.subTitle})</p>
<h2>{props.title}</h2>
</div>
)
これによりParentComponentは以下のように書き換えられる。
import { ArticlesListCard, PublishersListCard } from './ListCard'
const ParentComponent = React.FC = () => (
<div>
<ArticlesListCard title={'タイトル'} subTitle={'著者名'} main={'説明'} />
<PublishersListCard title={'タイトル'} subTitle={'出版社名'} main={'本文'} />
</div>
)
これでリファクタリングが完了し、依存関係を整理できた。
改めて依存関係を図示する
リファクタリングの結果、コンポーネントの依存関係は以下のように変化した。
しかし、リファクタリング前に図示した時と同様に、ParentComponentを実装している間、私には依存関係が以下のように見えるのである。
最初の図と比べてみて欲しい。
具象ではなく抽象に依存する
この記事では、Takepepeさんの記事を元に、インターフェースという観点からコンポーネントとPropsのリファクタリングの過程を示した。
実はTypeScriptでPropsの型を記述すると、コンポーネントからインターフェースを分離できているのである。しかし、その分離、つまり抽象化の仕方がまずいとコンポーネントは複雑性を抱えたままになる。
早すぎる抽象化は害悪である。似て非なるコンポーネントが登場した時がリファクタリングのチャンスだ。そして具象は変わりやすい。変わりにくい抽象に依存しよう。
この記事ではISPを取り上げた。しかし、SRP、OCP、DIPなど他の重要な原則はフロントエンドのプログラミングにも通じる箇所がある。
フロントエンドエンジニアの皆さんも、SOLID原則を意識してプログラミングに取り組んでみて欲しい。自分の見てきた世界が一変する感覚を味わった時には、既にあなたのスキルはレベルアップしている。
(ちなみにLSPは探究中、というかReactでは「継承よりコンポジションを好む」ため、特に意識しなくていいのではないかとも思っている。ReactにもLSPが役立つよ、という知見をお持ちの方はぜひコメント欄で教えてください)
Discussion
このケースでインターフェイスをまとめてしまうと、単一責任の原則に違反してしまうと思います。つまり、ArticlesListCardが理由でインターフェイスを変えると、同じインターフェイスに依存しているPublishersListCardも変更の影響を受けます。
まとめる前であればそんなことはなく、ArticlesListCardPropsの変更はPublishersListCardには影響しませんでした。『クリーンアーキテクチャ』でいうところの偶然の重複に該当するかと思います。いかがでしょうか?