フロントエンドにおけるコンポーネント設計について学んだことや私見をまとめてみたいと思います。
大きく以下のセクションに分けました。
参考文献・資料
- Atomic Design ~堅牢で使いやすいUIを効率良く設計する
- 後悔しないためのVueコンポーネント設計
-
りあクト! TypeScriptで始めるつらくないReact開発 第3.1版【② React基礎編】
(リンクは現在第4版になっておりますが、私が読ませて頂いたのは 第3.1版 になります。) - Presentational and Container Components
- Atomic Design Methodology
作成したサンプルコード
trying-nextjs-atomic-design
今回 React を使用してみました。
1. どのようにコンポーネントを設計するべきか
正しく設計する目的
まずは正しく設計する目的について考えてみます。
もっと究極的な目的もありそうですが、以下のあたりを意識しておくと良さそうです。
(内容的に少し被ってるものも含まれています)
- 一貫性のあるUIを提供しユーザー体験を向上させる
- 保守性・拡張性を向上させる
- バグの発生率を抑える
- 開発速度を向上させる
- テスタビリティを向上させる
(補足)
機械的にコンポーネントを完璧に設計していくのは容易ではなさそうです。プロジェクトごとに求められている開発速度や、費用対効果などの都合も相まって、実装時の迷いや妥協が生じることも多いかと思われます。
迷いが生じた際は目的に立ち返って、目的をなるだけ満たすような手段を都度採用できると良さそうです。
目的を達成するためのコンポーネント設計
では次に、上記目的を達成するためにはどのようなコンポーネントを作成すれば良いのかを考えてみます。
- 再利用性の高いコンポーネント
- 機能が少なくシンプルなコンポーネント
- 参照透過性が保証されているコンポーネント
- 副作用のないコンポーネント
- インターフェースから役割を直感的に理解しやすいコンポーネント
あたりが挙がってくるかなと思います。
一方でコンポーネントは組み合わせて使うものです。
コンポーネントを組み合わせた全体がどのような構造になっているべきかについても考える必要がありそうです。
- 副作用が集約されている
- (副作用を扱うレイヤーが限定されている)
- レイヤーごとに振る舞いや持てる知識の範囲、依存方向などに制限がある
- データフローが単方向である
- (疎結合である)
あたりが挙がってきそうです。
2. 具体的なデザインパターン
具体的な実践イメージにいきなり昇華させるのは難しそうなので、既にあるデザインパターンをいくつか確認してみたいと思います。
1. プレゼンテーショナル / コンテナ コンポーネント
原文: Presentational and Container Components
UIコンポーネントを プレゼンテーショナルコンポーネント と コンテナコンポーネント に分類するデザインパターン。
プレゼンテーショナルコンポーネント
- 見た目に関心を持つ
- 独自のマークアップとスタイルを持つ
- props にのみ依存する
- ステートレス(UIの状態は除く)
コンテナコンポーネント
- 振る舞いに関心を持つ
- データと振る舞いをプレゼンテーショナルコンポーネントに提供する
- 一部のラッピング div を除いて独自マークアップとスタイルを持たない
- store に依存しても良い
- ステートフル
解釈
いくつかの文献や資料などを確認した感じ プレゼンテーショナル / コンテナ コンポーネント にはさまざまな解釈があるように思えました。
まず、 Presentational and Container Components には、元となるデザインパターン Container Components があるようです。
Container Components の内容は非常にシンプルです。
ざっくりまとめると、以下のようなデータの取得と表示の両方の責務を請け負っているコンポーネントを、
class CommentList extends React.Component {
this.state = { comments: [] };
ComponentDidMount() {
fetchSomeComments(comments =>
this.setState({ comments: comments }));
}
render() {
return (
<ul>
{this.state.comments.map(c => (
<li>{c.body}—{c.author}</li>
))}
</ul>
);
}
}
データの取得と表示で分け、表示側の再利用性を生み出そうというものです。
class CommentListContainer extends React.Component {
state = { comments: [] };
componentDidMount() {
fetchSomeComments(comments =>
this.setState({ comments: comments }));
}
render() {
return <CommentList comments={this.state.comments} />;
}
}
const CommentList = props =>
<ul>
{props.comments.map(c => (
<li>{c.body}—{c.author}</li>
))}
</ul>
ここにさらにいくつかの具体的なポイントを追加したのが Presentational and Container Components のようです。
上記サンプルコードでいうと、 CommentList
は プレゼンテーショナルコンポーネントの性質 に結びつくこと、 CommentListContainer
は コンテナコンポーネントの性質 に結びつくこと、はなんとなくイメージできるかと思います。
一方で、現在では
Hooks let me do the same thing without an arbitrary division. This text is left intact for historical reasons but don’t take it too seriously.
フックを使用すると、任意の分割を行わずに同じことを行うことができます。このテキストは歴史的な理由からそのまま残されていますが、あまり真剣に受け止めないでください。
と言及されています。
一応上記サンプルコードを見た限りで言うと、確かに Container 側が請け負っている状態の宣言や操作は全てカスタムフック化することができそうです、、
が、一方で依然として カスタムフック を利用する コンテナコンポーネント は必要なようにも思えます🤔。
意外と以下のポイントが重要なのかもしれません。
I found it useful was because it let me separate complex stateful logic from other aspects of the component
これが便利だと感じた主な理由は、複雑なステートフルロジックをコンポーネントの他の側面から分離できるためです
Are usually generated using higher order components
通常、高階コンポーネントを使用して生成されます (コンテナコンポーネント)
HOC との組み合わせでイメージすると、 カスタムフック との比較(置き換え)イメージが鮮明になります。
WithMyCommentList(CommentListPresenter)
() => <CommentListPresenter({...useMyCommentList()}) />
かなり単純化しているので現実的には上のようなコードはあり得ないかもですが、 HOC と フック は任意の Presenter と自由に組み合わせて使用できるという点も相まってかなり似ています。
ひょっとすると Presentational and Container Components で言及されている コンテナコンポーネント は、 HOC や レンダープロップ などと組み合わせたある程度限定的なものを指しているのかもしれません。
そして、 「あまり真剣に受け止めないでください」 の意味としては、 「フック がある今となっては、そこまではやらなくても大丈夫だよ」 ということなのかもしれません。
(補足)
あるいは以下のように考えることもできそう。① ロジックと表示両方の責務を持ったコンポーネントがある
↓
② ロジックをHOCとして、表示をプレゼンテーショナルコンポーネントとして切り出す
↓
③ HOCとプレゼンテーショナルコンポーネントを組み合わせたコンテナコンポーネントを生成する
「arbitrary division - 任意の分割」 とは ② の工程のことを言っている可能性すらある。
一方で、事実こちらのデザインパターンはコンポーネント設計における基礎的な考え方として広く受け入れられております。
関心の分離方法としては、依然として非常に有用だからだと思われます。
メリット
- フック の使用の有無に問わず、そもそも純粋な プレゼンテーショナルコンポーネント そのものにメリットがある
- 各コンポーネントの実装が単純化する
- コンポーネントをレイヤーごとに分類した際に、レイヤーごとの基礎的な役割としても適用することができる
- 不具合の内容に応じた改修範囲の特定が容易になる
など
2. Atomic Design
原文(の一部): Atomic Design Methodology
UIコンポーネントを5つの階層に分類するデザインパターン。
階層に分類することで、コンポーネントごとの依存方向や影響範囲が明確になる。
Atoms, Molecules, Organisms, Templates, Pages の5層から成る。
(Atoms が最も抽象的で Pages が最も具体的)
原文を一読しただけだと実装時に各コンポーネントの分類に迷いが生じることもあるようです。
故に Atomic Design でさまざまな考察がヒットします(Molecules と Organisims の違いとか)。
以下、 私なりに各階層の特徴をまとめていきます。
メインで参考にしたのは Atomic Design ~堅牢で使いやすいUIを効率良く設計する になります。
一方でこちらの書籍は少し古く、 コンテキストプロバイダー や インジェクション などに対する言及がなかったので、その辺との関係性なども考えながらまとめてみます。
各階層の特徴を雑に羅列します
下位から上位にかけて説明します。
Atoms
- UIコンポーネントとしての最小単位
- それ以上機能的に分解できない最小単位
- プラットフォームのデフォルトUIになる
- アプリケーションの中の特定のドメインに依存しない
- 最も抽象的
- 状態を持たない
- 多くの場合単一の要素
例
- ボタン
- テキストインプット
- テキスト
- 画像
- レイアウトパターン
Molecules
- Atoms を使う(組み合わせる)
- Atoms に比べると具体的な役割が見えてくるので、ユーザーに一定の動機を与えることができる、 が、多くの場合特定のドメインに依存せず、単体では機能できない
- あくまで、コンテンツを助けるヘルパー的な役割
- (この辺は少しややこしいので後ほど補足します)
- 状態を持たない
例
- 検索フォーム
- ラベル付きインプット
- 自作プルダウン
Organisms
- Organisms, Molecules, Atoms で構成される
- 一気に具体的になる
- 特定のドメインに依存して良い
- 特定のプロバイダーやコンテキストに依存していい (開発チームの方針によるが)
- 独立してコンテンツを提供できる (スタンドアローンである)
Templates
- コンポーネント(主に Organisms)のレイアウト
- 多くの場合 Pages と1対1になる
- Organisms に依存するという意味では具体的なコンテキストやドメインに依存するが、実際のコンテンツの中身やデータそのものには依存しない
- データの中身には関心がない
Pages
- Templates に具体的なコンテンツ(データ)を流し込む
- 副作用や状態管理をなるべく集約しておく(と良い)
- コンテキストやプロバイダーを初期化する
- (コンテキストやプロバイダーを使用する場合は Templates を経由せずにデータを流し込むこともある)
その他全体的なルールや考え方
- 下位層は上位層の変更の影響を受けない
- 同一階層内での参照は Organisms のみ認められる
- 基本的に、 Templates 以下は大きな意味での プレゼンテーショナルコンポーネント として分類される
- つまり、 Templates 以下はなるべく単純に入力されたデータの表示に特化させる、ということ
Molecules と Organisms の違い
例えば、以下の単純化した YouTube の画面で確認してみます。
こちらの 共有ボタン をコンポーネント化する場合を考えてみます。
共有ボタン は、テキストとアイコンを組み合わせて作られたものですが、こちらは Organisms ではなく、 Molecules になると思います。
というのもこれ単体では、結局ユーザーは何を共有するのか理解できず、ボタンを押すに至らないからです。
「Atoms に比べると具体的な役割が見えてくるので、ユーザーに一定の動機を与えることができる、 が、多くの場合特定のドメインに依存せず、単体では機能できない、あくまで、コンテンツを助けるヘルパー的な役割」
つまり、スタンドアローンではない、ということになります。
一方で、 動画・動画タイトル・共有ボタン を組み合わせたこちらはどうでしょうか。
実際には YouTube の画面上にはヘッダーやナビゲーションなどさまざまな要素が他にも表示されていますが、一応こちらは単体でもユーザーにコンテンツを提供できています。
「特定のドメインに依存して良い、独立してコンテンツを提供できる (スタンドアローンである)」
こちらは Organisms として切り取ると良さそうです。
Instargram のサンプル
ここまでの雑な説明だけだとまだイメージがつきづらいかと思われます。
原文の Instargram のサンプル が分かりやすいと思ったのでそちらを最後に確認してみます。
(ただこれヘッダーとかが Molecules にも Organisms にも両方存在してるのは何故なんでしょう)
- Atoms はUIコンポーネントとしての最小単位になっている
- Molecules は Atoms を組みわせて構成されているがスタンドアローンなコンテンツではない
- Organisms はスタンドアローンなコンテンツになっている
- Templates は Organisms を適切に配置している、が、データそのものには関心がない
- Pages が Templates にデータを提供しコンテンツが完成している
なお、 Atomic Design ~堅牢で使いやすいUIを効率良く設計する では、サービスに合わせて Atomic Design をカスタマイズすることの重要性についても言及されていました。
サービスによって扱いやすいように、 一部の層のみを取り入れたり、関心のグループ分けを変えてみたり、、実践では、上記の通りに実装する必要は必ずしも無いようです。
3. Atomic Design 実践
サンプルコードを作ってみます。
最近何かと批判も多いようですが Atomic Design を採用します。
- Atomic Design を意識して実装する
- スタイルはほとんど Bootstrap に頼る
- ただ単に Bootstrap のクラスを適用していくだけ
- styled-components, css-modules 等はちろんのこと Custom Properties とかですら使わない
- (実際の実践ではスタイル周りの設計やフレームワークの選定は重要になってきそうですが)
- カスタムフック も使わない
- ストア や プロバイダー も使わない
- あくまで原始的なコンポーネント設計のみで頑張る
- (個人学習用のサンプルコードにすぎず、一部コンポーネントの作成を省略している🙏)
サンプルコード 要件
かなりひどい見た目ですが下記のようなタスク管理アプリケーションっぽいものを作ってみます。
- タスクが一覧表示されている
- タスクの タイトル・本文・完了ステータス を更新できる
- タスクの編集途中でサイドバーから他の画面に遷移しようとすると確認アラートが出る
- 「変更内容を破棄してもよろしいですか?」
- いくつかの要素のクリック数を独自分析ツールで計測したい
- 今回は適当にログだけインメモリーで出している
- 今回は
タスク一覧アイテム
とタスク編集保存ボタン
のクリック数を計測したい
ざっくりカンプから読み取れること
とはいえ、カンプなんてものは用意しておりません、、。
本末転倒ですが、上の完成したサンプルのgifをカンプだと思って一部情報をざっくり整理してみます。
- フォントサイズは3種類
- 12px
- 16px
- 20px
- フォント色は3種類
- 黒
#212529
- 白
#ffffff
- グレー
#6c757d
- 黒
- クリックできる要素に対してはカーソルがポインターになる
などなど、、
実装
実装したコンポーネントをいくつかかいつまんで確認していきます。
Atoms
テキストコンポーネント
まずはテキストコンポーネントから作成してみました。
機能的に分解できない最小単位 & プラットフォームのデフォルトUIになる という観点より Atoms に分類してみました。
export interface Props extends React.ComponentPropsWithoutRef<'p'> {
textColor?: TEXT_COLOR;
fontSize?: FONT_SIZE;
children: string;
}
export const Text: React.FC<Props> = ({
textColor,
fontSize,
children,
...attr
}) => {
const fontSizeClassName = (() => {
switch (fontSize) {
case FONT_SIZE.SMALL:
return 'fs-small';
case FONT_SIZE.LARGE:
return 'fs-large';
default:
return 'fs-default';
}
})();
const textColorClassName = (() => {
switch (textColor) {
case TEXT_COLOR.WHITE:
return 'text-white';
case TEXT_COLOR.INFO:
return 'text-muted';
default:
return 'text-dark';
}
})();
return (
<p
{...attr}
className={classNames(
attr.className,
fontSizeClassName,
textColorClassName
)}
>
{children}
</p>
);
};
export enum FONT_SIZE {
DEFAULT,
SMALL,
LARGE,
}
export enum TEXT_COLOR {
DEFAULT,
WHITE,
INFO,
}
解説
まずは、3種類のフォントサイズとフォント色を定義します。
export enum FONT_SIZE {
DEFAULT,
SMALL,
LARGE,
}
export enum TEXT_COLOR {
DEFAULT,
WHITE,
INFO,
}
(enum である必要はありません。文字列とか constアサーション とかでもいいし、そもそも初めから直接cssクラスやプロパティでやるという方法もあると思います。)
これらを利用した props を定義します。
export interface Props extends React.ComponentPropsWithoutRef<'p'> {
textColor?: TEXT_COLOR;
fontSize?: FONT_SIZE;
children: string;
}
- 実際に表示する文字列は children で受け取ります。 単一要素の場合、本来のHTMLタグとインターフェースが統一されていた方が分かりやすいからです。
- (ただ string より
ReactNode
の方が良いかも?)
- (ただ string より
-
extends ComponentPropsWithoutRef
をして、属性を汎用的に受け取れるように一応してみました。- 一般的なUIフレームワークが割と自由に属性を受け取れるようになっていたり、 Vue だとデフォルトで属性が展開されるなど、 その辺を参考にしました。
- Atoms, Molecules など抽象度が高いコンポーネントに関しては今回全てこのような Props 定義になっており汎用的に属性を受け取れるようにしてみました。
(補足)
ComponentPropsWithoutRef
の代替として、 React.HTMLAttributes
や、 ComponentPropsWithRef
などが挙げられると思います。 まず、
React.HTMLAttributes
に関しては、そもそも属性の定義が足りてないことがあるようで、あまりお勧めできません。ComponentPropsWithRef
に関しては、 forwardRef
をする必要性が生まれた際に改修して使用するのでも良いかなと思います。 現時点では不要なリソースの複雑性を生まないために
ComponentPropsWithoutRef
の使用に収めてます。
interface extends
ではなく交差型を使用することも可能ですが、明示的に型を上書きできた方が、交差型よりもシンプルかつ保守性も高まると踏んで interface extends
を採用してみました。
FONT_SIZE
を DEFAULT, SMALL, LARGE
で定義しましたが、拡張性が少し心配です。例えば後から、 18px のフォントサイズが必要になった時、 DEFAULT と LARGE の間は BIG? とかになるんでしょうか。
普通に、
12, 16, 20
とかで持っておく方がまだ無難かもしれません。今回はサンプルコードで確定している仕様を表現する上では分かりやすそうだったので
DEFAULT, SMALL, LARGE
としています。
これらを使用して関数コンポーネントを作成します。
export const Text: React.FC<Props> = ({
textColor,
fontSize,
children,
...attr
}) => {
const fontSizeClassName = (() => {
switch (fontSize) {
case FONT_SIZE.SMALL:
return 'fs-small';
case FONT_SIZE.LARGE:
return 'fs-large';
default:
return 'fs-default';
}
})();
const textColorClassName = (() => {
switch (textColor) {
case TEXT_COLOR.WHITE:
return 'text-white';
case TEXT_COLOR.INFO:
return 'text-muted';
default:
return 'text-dark';
}
})();
return (
<p
{...attr}
className={classNames(
attr.className,
fontSizeClassName,
textColorClassName
)}
>
{children}
</p>
);
};
受け取った props をもとに適切なフォントサイズとフォント色のクラスを抽出し割り当てています。
本来テキストコンポーネントはタグも自由に設定できた方が良いですが、今回はシンプルに pタグ 固定にしています。
メディアオブジェクトレイアウト(リキッドレイアウト ?)コンポーネント
よくある片側固定、もう片側可変のレイアウトです。
こちらも プラットフォームのデフォルトUI として Atoms で実装してみます。
画面でいうと以下になります。
(このように、デザインから各レイヤーごとのコンポーネントを抽出して実装していく形になります)
とはいえこちらはかなりシンプルに収めました。
export interface Props extends React.ComponentPropsWithoutRef<'div'> {
fixedSideWidth: string;
children: React.ReactNode[];
}
export const MediaObject: React.FC<Props> = ({
fixedSideWidth,
children,
...attr
}) => (
<div {...attr} className={classNames(attr.className, 'd-flex')}>
<div
style={{
minWidth: fixedSideWidth,
maxWidth: fixedSideWidth,
}}
>
{children[0]}
</div>
<div className="flex-grow-1">{children[1]}</div>
</div>
);
固定する側を左右選べても良いですが、今回は常に左側固定にしてみました。
フレックスボックスで配置したコンテナに受け取った ReactNode
をそれぞれ配置しているだけです。
左側は props 経由で受け取った横幅を適用しています。
他にも Atoms は作成しましたが、この辺にして Molecules に移ります。
Molecules
バッジコンポーネント
タスクの完了ステータスに使っているこちらになります。
Bootstrap の Badges に頼っていることもあって非常にシンプルです。
export interface Props extends React.ComponentPropsWithoutRef<'span'> {
text: string;
type?: BADGE_TYPE;
}
export const Badge: React.FC<Props> = ({ text, type, ...attr }) => {
const className = (() => {
switch (type) {
case BADGE_TYPE.DANGER:
return 'bg-danger';
default:
return 'bg-success';
}
})();
return (
<span {...attr} className={classNames(attr.className, 'badge', className)}>
<Text textColor={TEXT_COLOR.WHITE} fontSize={FONT_SIZE.SMALL}>
{text}
</Text>
</span>
);
};
export enum BADGE_TYPE {
SUCCESS,
DANGER,
}
- 表示するテキストをテキストコンポーネントを使って表示している
- バッジタイプ(
SUCCESS
orDANGER
) を受け取って、適切な背景色クラスを適用している
Organisms
Organisms を作っていきます。
サイドナビゲーション や タスク一覧 などが該当しますが、
これらは、クリックすることができる要素です。
クリックすることができる要素には考慮すべき要件が存在していました。
いくつかの要素のクリック数を独自分析ツールで計測したい
クリックできる要素に対してはカーソルがポインターになる
Organisms を作る前に、今回これらの要件を満たす HOC を作成していきます(時代遅れかも)。
クリッカブルHOC
(Organisms ではないです)
export const Clickable = <
Props extends React.ComponentPropsWithoutRef<React.ElementType>
>(
Component: React.FC<Props>
) => {
return function Clickable(
props: Props & { onClick: React.MouseEventHandler; clickkey?: string }
) {
const onClick = (event: React.MouseEvent) => {
props.onClick(event);
/**
* もしクリックキーがあれば、クリック数を計測する
*/
if (props.clickkey) {
pushClickCount(props.clickkey);
}
};
return <Component {...props} onClick={onClick} role="button" />;
};
};
コンポーネントを受け取ってコンポーネントを返しますが、 onClick
に少し加工を加えてから注入してあげるのと、 role="button"
という属性を付与しています。
-
clickkey
はクリック数を計測する際の要素IDのようなもの (オプショナル)- これが渡された要素がクリックされた場合、
clickkey XXX
の要素がクリックされたという通知をプッシュします (pushClickCount
)
- これが渡された要素がクリックされた場合、
-
role="button"
は Bootstrap でカーソルがポインターになる属性
※ クリック数の計測という副作用がソース上の随所に散らばってしまう可能性が懸念されますが、ビジネスロジックとは無関係な機能なので問題が発生することはなさそうです。
(補足)
型の定義がかなり緩いです(その分シンプルには作れています)。実際は、そもそもクリックできる要素なのかどうかは渡されるコンポーネント次第なので、いきなり
onClick
で交差するのは良くなさそうです。 多くの
React.ComponentPropsWithoutRef
は onClick
をオプショナルで持っています。 そちらの型を参照しつつ必須に変換してあげる方がベターではありそうです。
type KeyRequired<T, K extends keyof T> = Omit<T, K> & {
[P in K]-?: Exclude<T[P], undefined>
};
props: KeyRequired<Props, 'onClick'> & { clickkey?: string }
今回と似たような機能は HOC 以外の方法でも実現できそうで、 HOC である必要はなさそうです。
例えば、コンポーネントではなく ReactNode そのものを受け取って、属性や props を拡張しながら cloneElement した結果を返すことなどもできます。 Clickableを利用する側は、
<Clickable>
<Component />
</Clickable>
サイドナビゲーションコンポーネント
クリッカブルHOCが完成したのでこれを利用したナビゲーションコンポーネントを作成していきます。
まずは個々のナビゲーションアイテムです。
export interface Props {
navigationIcon: string;
navigationTitle: string;
onClick: React.MouseEventHandler<HTMLDivElement>;
}
export const NavigationItem: React.FC<Props> = ({
navigationIcon,
navigationTitle,
onClick,
}) => {
const ClickableMediaObject = Clickable(MediaObject);
return (
<ClickableMediaObject fixedSideWidth="32px" onClick={onClick}>
<div className="center-block h-100 mr-1">
<i className={navigationIcon}></i>
</div>
<Text fontSize={FONT_SIZE.LARGE}>{navigationTitle}</Text>
</ClickableMediaObject>
);
};
export const NavigationItemRoute: React.FC<
Omit<Props, 'onClick'> & { to: string }
> = (props) => {
const router = useRouter();
return <NavigationItem {...props} onClick={() => router.push(props.to)} />;
};
解説
まずは props として アイコン、テキスト、 クリックイベント を定義します。
ナビゲーションアイテムである以上クリックイベントは必須だと解釈して props に定義して良いかなと思いました。
export interface Props {
navigationIcon: string;
navigationTitle: string;
onClick: React.MouseEventHandler<HTMLDivElement>;
}
受け取ったアイコンやテキストを Clickable
な MediaObject
に配置して描画します。
export const NavigationItem: React.FC<Props> = ({
navigationIcon,
navigationTitle,
onClick,
}) => {
const ClickableMediaObject = Clickable(MediaObject);
return (
<ClickableMediaObject fixedSideWidth="32px" onClick={onClick}>
<div className="center-block h-100 mr-1">
<i className={navigationIcon}></i>
</div>
<Text fontSize={FONT_SIZE.LARGE}>{navigationTitle}</Text>
</ClickableMediaObject>
);
};
(補足)
関数コンポーネントの実行コンテキストでいちいち、ClickableMediaObject
を生成してますが、これだと効率が悪そうです。 実際多くの場合、HOCと組み合わせたコンポーネントは別で定義して使い回すことのほうが多いです。
今回はとりあえずコンパクトに1箇所にまとめた形になります。
これで完成でも良かったのですが、ナビゲーションは固定の画面遷移機能をそのまま内包していたり、リンクタグで作成されることも多いかと思われます。
画面遷移機能まで完結したナビゲーションアイテムも追加で作成します。
export const NavigationItemRoute: React.FC<
Omit<Props, 'onClick'> & { to: string }
> = (props) => {
const router = useRouter();
return <NavigationItem {...props} onClick={() => router.push(props.to)} />;
};
インラインの型がかなり見づらいですね、、。
固定の画面遷移機能があるので onClick
は Omit
します。
代わりに遷移先 { to: string }
を受け取ってクリックされたら画面遷移します。
続けて、サイドナビゲーションを作成します。
export interface Props {
navigationItems?: {
navigationIcon: string;
navigationTitle: string;
to: string;
}[];
onClickItem?: (to: string) => void;
}
export const SideNavigation: React.FC<Props> = ({
navigationItems = DEFAULT_SIDE_NAVIGATION_ITEMS,
onClickItem,
}) => {
return (
<div className="w-100 h-100 py-3 shadow">
<Vertical>
{navigationItems.map((navigationItem) =>
onClickItem ? (
<NavigationItem
{...navigationItem}
key={navigationItem.to}
onClick={() => onClickItem(navigationItem.to)}
/>
) : (
<NavigationItemRoute {...navigationItem} key={navigationItem.to} />
)
)}
</Vertical>
</div>
);
};
表示する navigationItems
を受け取り、 NavigationItem
コンポーネントを map で描画しています。
少し可読性が悪いですが、各アイテムをクリックした時の処理(onClickItem
)はオプショナルになっており、これが注入された時は onClickItem
を実行する NavigationItem
を描画し、 注入されなかった時は固定のルーティング処理で良いと判断し NavigationItemRoute
を描画しています(条件分岐の箇所を別のファクトリー的なものに切り出したり、そもそもコンポーネントを分けるとかもありだと思います)。
タスク一覧コンポーネント
ナビゲーションの時と同様に個々のアイテムをまずは作成します。
export interface Props {
task: Task;
onClickTask: (id: number) => void;
}
export const TaskPreview: React.FC<Props> = ({ task, onClickTask }) => {
const badgeParam = task.isDone
? { text: '完了', type: BADGE_TYPE.SUCCESS }
: { text: '未完了', type: BADGE_TYPE.DANGER };
const onClick = () => {
onClickTask(task.id);
};
const ClickablePresenter = Clickable(Presenter);
return (
<ClickablePresenter
taskTitle={task.title}
taskImage={task.image}
badgeText={badgeParam.text}
badgeType={badgeParam.type}
onClick={onClick}
clickkey="タスク一覧 アイテム"
/>
);
};
interface PresenterProps extends React.ComponentPropsWithoutRef<'div'> {
taskTitle: string;
taskImage: string;
badgeText: string;
badgeType: BADGE_TYPE;
}
const Presenter: React.FC<PresenterProps> = ({
taskTitle,
taskImage,
badgeText,
badgeType,
...attr
}) => (
<MediaObject fixedSideWidth="100px" {...attr}>
<Image src={taskImage} width={88} height={88} alt="" priority />
<Vertical space={VERTICAL_SPACE.SMALL}>
<Badge text={badgeText} type={badgeType} />
<Text fontSize={FONT_SIZE.LARGE}>{taskTitle}</Text>
</Vertical>
</MediaObject>
);
解説
Atomic Design ~堅牢で使いやすいUIを効率良く設計する では、副作用を伴わないロジックや、描画にしか関心のないロジックでも純粋な描画から分離する実装パターンが紹介されていました(1個のコンポーネントを分割するイメージに近い)。
メリットとしては以下になります。
- ソースが単純になる
- 改修箇所の特定が容易になる
- ロジックを含まない純粋な描画コンポーネントを使用した様々な拡張コンポーネントを作ることができる
など、、
今回はコンポーネントの規模や将来の拡張性的にもほとんどメリットは無さそうですが、試験的にタスク一覧アイテムを分割して実装してみます(本書ではHOCと組み合わせて実装していたので微妙に異なります)。
まずは純粋に描画のみに関心を持ったコンポーネントを作成します。
こちらは一切のロジックを含みません。
interface PresenterProps extends React.ComponentPropsWithoutRef<'div'> {
taskTitle: string;
taskImage: string;
badgeText: string;
badgeType: BADGE_TYPE;
}
const Presenter: React.FC<PresenterProps> = ({
taskTitle,
taskImage,
badgeText,
badgeType,
...attr
}) => (
<MediaObject fixedSideWidth="100px" {...attr}>
<Image src={taskImage} width={88} height={88} alt="" priority />
<Vertical space={VERTICAL_SPACE.SMALL}>
<Badge text={badgeText} type={badgeType} />
<Text fontSize={FONT_SIZE.LARGE}>{taskTitle}</Text>
</Vertical>
</MediaObject>
);
タスク名、タスク画像、バッジテキスト、バッジタイプ を受け取って描画するだけのコンポーネントになります。
続いて、こちらを用いたタスク一覧アイテムコンポーネントを作成します。
export interface Props {
task: Task;
onClickTask: (id: number) => void;
}
export const TaskPreview: React.FC<Props> = ({ task, onClickTask }) => {
const badgeParam = task.isDone
? { text: '完了', type: BADGE_TYPE.SUCCESS }
: { text: '未完了', type: BADGE_TYPE.DANGER };
const onClick = () => {
onClickTask(task.id);
};
const ClickablePresenter = Clickable(Presenter);
return (
<ClickablePresenter
taskTitle={task.title}
taskImage={task.image}
badgeText={badgeParam.text}
badgeType={badgeParam.type}
onClick={onClick}
clickkey="タスク一覧 アイテム"
/>
);
};
- 注入するバッジのテキストやタイプを判断している
- タスク一覧アイテムはクリック数計測の対象なため
clickkey
を注入している
今回は旨みが分かりづらいですが、膨大で複雑なロジックを含むコンポーネントの場合このように分割したほうが見やすい、ということもあるかもしれません。
また、共通の Presenter を使用した別の TaskPreviewXXX
を作成したい場合にも役立ちそうです。
個々のアイテムを作成したので一覧を作ります。
とはいえ特筆することろは特にありません。
export interface Props {
tasks: Task[];
onClickTask: (id: number) => void;
}
export const TaskPreviews: React.FC<Props> = ({ tasks, onClickTask }) => (
<Vertical space={VERTICAL_SPACE.LARGE}>
{tasks.map((task) => (
<TaskPreview task={task} onClickTask={onClickTask} key={task.id} />
))}
</Vertical>
);
リストを map で描画しているだけです。
Templates
共通レイアウトテンプレートコンポーネント
Web系MVCフレームワークなどで、共通のレイアウトを採用することは多いかと思われます。
Next.js にもいわゆる Layout の機能はあるようですが、今回は 、 Templates で Layout ライクなコンポーネントを実装してみたいと思います。
export interface Props {
navigationItems?: {
navigationIcon: string;
navigationTitle: string;
to: string;
}[];
onClickNavigationItem?: (to: string) => void;
children: React.ReactNode;
}
export const DefaultLayout: React.FC<Props> = ({
navigationItems,
onClickNavigationItem,
children,
}) => (
<MediaObject fixedSideWidth="240px" className="vh-100">
<SideNavigation
navigationItems={navigationItems}
onClickItem={onClickNavigationItem}
/>
<main className="p-3">{children}</main>
</MediaObject>
);
サイドナビゲーションを配置しつつ、 main 領域に描画するコンポーネントは children 経由で設定できるようにします (よくありそうなパターン)。
その他の props は、サイドナビゲーションの props に必要なものがそのまま引き継がれています。
タスク一覧テンプレートコンポーネント
export interface Props {
tasks: Task[];
onClickTask: (id: number) => void;
}
export const Tasks: React.FC<Props> = ({ tasks, onClickTask }) => (
<DefaultLayout>
<TaskPreviews tasks={tasks} onClickTask={onClickTask} />
</DefaultLayout>
);
DefaultLayout
を利用し、シンプルに収めています。
main 領域にはタスク一覧を渡しておリます。
Atomic Design ~堅牢で使いやすいUIを効率良く設計する では Atomic Design をカスタマイズすることの重要性について言及されていました。
本来、 Templates は同一階層内での参照を許しませんが、今回 templates/Shared
配下のコンポーネントは他の Tempates からも参照して良い、というルールを設けてみました。
Pages
タスク一覧ページコンポーネント
interface Props {
tasks: Task[];
}
const Index: NextPage<
InferGetServerSidePropsType<typeof getServerSideProps>
> = ({ tasks }) => {
const router = useRouter();
const onClickTask = (id: number) => {
router.push(`/tasks/${id}`);
};
return <Tasks tasks={tasks} onClickTask={onClickTask} />;
};
export const getServerSideProps: GetServerSideProps<Props> = async () => ({
props: {
tasks: await fetch('http://localhost:3000/api/tasks').then<Task[]>((res) =>
res.json().then(({ tasks }) => tasks)
),
},
});
export default Index;
- タスク一覧の取得
getServerSideProps
- タスクがクリックされた時の画面遷移
onClickTask
- タスクテンプレートにデータや振る舞いを注入
<Tasks tasks={tasks} onClickTask={onClickTask} />
こちらのレイヤーでデータの取得・注入や、各種イベントの処理などを集約しております。
これでタスク一覧画面が完成しました。
タスク編集ページコンポーネント
一通り各レイヤーのコンポーネントを紹介できました。
配下のコンポーネントについて端折ってますが、最後にタスク編集画面の実装も確認してみます。
interface Params extends ParsedUrlQuery {
id: string;
}
interface Props {
task: Task;
}
const TaskPage: NextPage<
InferGetServerSidePropsType<typeof getServerSideProps>
> = ({ task }) => {
const router = useRouter();
const [taskEdit, setTaskEdit] = useState({ ...task });
const onSubmit = async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task: taskEdit }),
});
router.push('/');
};
const onClickNavigationItem = (to: string) => {
if (
JSON.stringify(task) !== JSON.stringify(taskEdit) &&
!confirm('変更内容を破棄してもよろしいですか?')
) {
return;
}
router.push(to);
};
return (
<TaskTemplate
task={taskEdit}
onTaskChange={setTaskEdit}
onSubmit={onSubmit}
onClickNavigationItem={onClickNavigationItem}
/>
);
};
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({
params,
}) => {
if (!params) throw 'Unexpected';
const task = await fetch(
`http://localhost:3000/api/tasks/${params.id}`
).then<Task>((res) => res.json().then(({ task }) => task));
return {
props: {
task,
},
};
};
export default TaskPage;
解説
一つ一つの処理は単純ですが、ごちゃごちゃしてて見辛い(🙏) です。
まずは編集対象のタスクのデータが必要なので取得します。
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({
params,
}) => {
if (!params) throw 'Unexpected';
const task = await fetch(
`http://localhost:3000/api/tasks/${params.id}`
).then<Task>((res) => res.json().then(({ task }) => task));
return {
props: {
task,
},
};
};
次にこちらはユーザーの入力に応じて編集をする必要があるので新しい参照を切りながら状態として保持するようにし、
テンプレートにはタスクの編集データと、タスク編集の処理を注入します。
(useState
の戻り値の setTaskEdit
をそのまま注入するのはアンチパターンかも?)
const [taskEdit, setTaskEdit] = useState({ ...task });
return (
<TaskTemplate
task={taskEdit}
onTaskChange={setTaskEdit}
/>
);
保存ボタン押下時の最終的な保存処理も注入します。
const onSubmit = async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task: taskEdit }),
});
router.push('/');
};
return (
<TaskTemplate
task={taskEdit}
onTaskChange={setTaskEdit}
onSubmit={onSubmit}
/>
);
そして、タスク編集画面には以下の要件がありました。
タスクの編集途中でサイドバーから他の画面に遷移しようとすると確認アラートが出る
こちらの実装も単純です。
サイドナビゲーションの仕様は、
各アイテムをクリックした時の処理(
onClickItem
)はオプショナルになっており、これが注入された時はonClickItem
を実行するNavigationItem
を描画し、 注入されなかった時は固定のルーティング処理で良いと判断しNavigationItemRoute
を描画しています
になります。
今回は、ナビゲーションアイテムをクリックした時の挙動として、確認アラートを挟んだ固有の処理を注入すれば良いだけになります。
const onClickNavigationItem = (to: string) => {
if (
JSON.stringify(task) !== JSON.stringify(taskEdit) &&
!confirm('変更内容を破棄してもよろしいですか?')
) {
return;
}
router.push(to);
};
return (
<TaskTemplate
task={taskEdit}
onTaskChange={setTaskEdit}
onSubmit={onSubmit}
onClickNavigationItem={onClickNavigationItem}
/>
);
ポイントとしては以下になります。
- Templates 以下のレイヤーがプレゼンテーショナルコンポーネントとして実装されており、状態や固定のイベント処理などを持たないようになっている
- (あるいは、固定のイベント処理をしてもらうかどうかをコンポーネント利用側が選べるように実装されている)
- 対して Pages の方に状態や振る舞いが集約されている
- そのおかげで、Templates 以下のレイヤーのシンプルさをそのままに、コンポーネントを横断した複雑な要件も楽に実装できる
(補足)
実際のところ画面離脱時のアラートは、フレームワークやブラウザが持つナビゲーションガード系のリスナーを使用した方が機能として強力なものを提供できるかもしれません(ブラウザを閉じる時とかにも反応する、など)。今回の実装方法は、あくまで状態や副作用の集約によるメリットをイメージする上での1サンプルにすぎません。
最後にディレクトリ構成を確認
一応私が採用したディレクトリ構成についても確認してみます。
悩ましいところなので、1案を考えてみたいです。
components
├── atoms
│ ├── Buttons
│ ├── Images
│ ├── Layouts
│ └── Texts
├── molecules
│ ├── Badges
│ └── FormItems
├── organisms
│ ├── Navigation
│ └── Task
└── templates
├── Shared
└── Task
(配下の tsxファイル まで書き出すとかなり見辛くなってしまったので省略しています)
Atoms と Molecules は抽象的なパーツになることが多いと思いますので水平にディレクトリを切るので良いと思います。
もし、特定のドメインに依存したこれらが必要になった場合は、 Organisms に配置するのもありだと思いますし、純粋関数として実装できていれば Molecules などに配置することがあっても良いと思います。
例: molecules/Basges/FooBadge.tsx
, molecules/FormItems/BarFormItem.tsx
みたいな。
Organisms は垂直に切っていますが、水平に切ることも不可能ではないと思っています。
例: organisms/Tables/FooTable.tsx
, organisms/Lists/BarList.tsx
みたいな。
(ただやはり Organisms の性質上垂直の方が分かりやすい、、。)
Organisms を垂直に切った際の問題点は、 Task
や User
などが垂直の概念である一方で、 Navigation
や Header
などは水平っぽいという点です。
スタンドアローンなUIとして Header
もフロントではドメインみたいなものである、という認識も可能ですが、現実問題、 UserHeader
なるのものを作りたいとなった際に User
か Header
どちらに所属させれば良いのか、みたいな悩みが発生してしまうこともあるかと思われます。
これについては、チーム内でルールを導入するしかなさそうです。
例: UserHeader
は User
に配置する(迷ったら垂直の概念の方に配置する)。
Templates は垂直に切っています(逆に Templates における水平の概念があまり思いつかない)。
どういう単位で切るかはなんでも良さそうです。
例: 最初から Pages と1対1に実装すると決め打って Pages と統一したディレクトリ構成にする、 ドメイン単位で切る など、、
(補足)
Buttons や Badges が水平の概念かと言われると少し微妙なところです。一般的には、垂直がドメインやフィーチャーで区切るのに対比して、水平というと技術的ないしはアーキテクチャー的な関心領域などで区切ることが多いので、 Buttons や Badges などが水平な区切り方かと言われると少し言葉に誤りがあるかもしれません。
サンプルコードを作ってみて
今回作ったサンプルコードは現実的にはあり得ないような単純で変な要件だったため一定シンプルに収めることができました、、
が、実際にはこんな簡単にはいかなそうです。
一般的にコンポーネントは抽象的であるべきだといわれています。
しかし、プロダクトの規模や複雑さ、または求められる開発速度などが絡み、逆に具体的なコンポーネントが必要になったり(作らざるを得なかったり)することなどもありそうです。
ただ、基礎的な設計パターンとそのメリットを体感しておくことは重要そうです。
実装に応用を効かせたり、妥協したりする際は、常に基礎と比較してメリット・デメリットを検討していくと良いのかなと思いました。