Reactのコンポーネントアーキテクチャを深く掘り下げ、コンポジションと継承を比較します。Reactがコンポジションを重視する理由を学び、スケーラブルで再利用可能なコンポーネントを構築するためのHOC、Render Props、Hooksなどのパターンを探求します。
Reactコンポーネントアーキテクチャ:なぜコンポジションが継承に勝るのか
ソフトウェア開発の世界では、アーキテクチャが最も重要です。コードの構造化方法は、スケーラビリティ、保守性、再利用性を決定します。Reactを使用する開発者にとって、最も基本的なアーキテクチャ上の決定の1つは、コンポーネント間でロジックとUIを共有する方法です。これにより、オブジェクト指向プログラミングにおける古典的な議論が、Reactのコンポーネントベースの世界向けに再構築されます。コンポジション対継承。
JavaやC++のような古典的なオブジェクト指向言語のバックグラウンドをお持ちの場合、継承は自然な最初の選択肢のように感じられるかもしれません。これは、「is-a」関係を作成するための強力な概念です。ただし、公式のReactドキュメントには、明確で強力な推奨事項が記載されています。"Facebookでは、数千のコンポーネントでReactを使用していますが、コンポーネント継承階層を作成することを推奨するユースケースは見つかりませんでした。"
この記事では、このアーキテクチャ上の選択について包括的に探求します。Reactのコンテキストにおける継承とコンポジションの意味を解き明かし、コンポジションが慣用的で優れたアプローチである理由を実証し、高階コンポーネントから最新のHooksまで、強力なパターンを探求します。これにより、コンポジションはグローバルな視聴者向けの堅牢で柔軟なアプリケーションを構築するための開発者の親友になります。
旧衛兵を理解する:継承とは何か?
継承は、オブジェクト指向プログラミング(OOP)の中核となる柱です。これにより、新しいクラス(サブクラスまたは子)が既存のクラス(スーパークラスまたは親)のプロパティとメソッドを取得できます。これにより、密結合の「is-a」関係が作成されます。たとえば、GoldenRetriever
はDog
であり、Animal
です。
React以外のコンテキストでの継承
概念を確実にするために、簡単なJavaScriptクラスの例を見てみましょう。
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Calls the parent constructor
this.breed = breed;
}
speak() { // Overrides the parent method
console.log(`${this.name} barks.`);
}
fetch() {
console.log(`${this.name} is fetching the ball!`);
}
}
const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // Output: "Buddy barks."
myDog.fetch(); // Output: "Buddy is fetching the ball!"
このモデルでは、Dog
クラスはname
プロパティとspeak
メソッドをAnimal
から自動的に取得します。また、独自のメソッド(fetch
)を追加したり、既存のメソッドをオーバーライドしたりすることもできます。これにより、厳格な階層が作成されます。
Reactでの継承の失敗の理由
この「is-a」モデルは一部のデータ構造では機能しますが、ReactのUIコンポーネントに適用すると重大な問題が発生します。
- 密結合:コンポーネントがベースコンポーネントから継承すると、親の実装に密結合されます。ベースコンポーネントの変更により、チェーン内の複数の子コンポーネントが予期せず破損する可能性があります。これにより、リファクタリングとメンテナンスが脆弱なプロセスになります。
- 柔軟性のないロジック共有:同じ「is-a」階層に適合しないコンポーネントと、データフェッチなどの特定の機能を共有したい場合はどうでしょうか。たとえば、
UserProfile
とProductList
はどちらもデータをフェッチする必要があるかもしれませんが、共通のDataFetchingComponent
から継承するのは意味がありません。 - プロップドリル地獄:深い継承チェーンでは、トップレベルコンポーネントから深くネストされた子にプロップを渡すのが難しくなります。使用しない中間コンポーネントを介してプロップを渡す必要がある場合があり、混乱して肥大化したコードにつながります。
- 「ゴリラバナナ問題」:OOPの専門家であるジョーアームストロングからの有名な引用は、この問題を完全に説明しています。"バナナが欲しかったのに、手に入れたのはゴリラがバナナとジャングル全体を持っているものでした。"継承では、必要な機能の一部を取得することはできません。スーパークラス全体を一緒に持ち込むことを余儀なくされます。
これらの問題のため、Reactチームは、より柔軟で強力なパラダイムであるコンポジションを中心にライブラリを設計しました。
React Wayを受け入れる:コンポジションの力
コンポジションは、「has-a」または「uses-a」の関係を優先する設計原則です。コンポーネントが別のコンポーネントであるのではなく、他のコンポーネントを持っているか、その機能を使用しているかです。コンポーネントは、LEGOブロックのように、さまざまな方法で組み合わせて、厳格な階層にロックされることなく複雑なUIを作成できるビルディングブロックとして扱われます。
Reactのコンポジションモデルは非常に用途が広く、いくつかの主要なパターンで表れます。最も基本的なものから最もモダンで強力なものまで、それらを探求してみましょう。
テクニック1:`props.children`を使用した封じ込め
コンポジションの最も簡単な形式は封じ込めです。これは、コンポーネントが汎用コンテナまたは「ボックス」として機能し、そのコンテンツが親コンポーネントから渡される場所です。Reactには、これに対する特別な組み込みプロップがあります:props.children
。
一貫した境界線とシャドウでコンテンツをラップできる`Card`コンポーネントが必要だと想像してください。継承を通じて`TextCard`、`ImageCard`、`ProfileCard`バリアントを作成する代わりに、1つの汎用`Card`コンポーネントを作成します。
// Card.js - 汎用コンテナコンポーネント
function Card(props) {
return (
<div className="card">
{props.children}
</div>
);
}
// App.js - Cardコンポーネントの使用
function App() {
return (
<div>
<Card>
<h1>ようこそ!</h1>
<p>このコンテンツはCardコンポーネントの中にあります。</p>
</Card>
<Card>
<img src="/path/to/image.jpg" alt="An example image" />
<p>これはイメージカードです。</p>
</Card>
</div>
);
}
ここで、Card
コンポーネントは、何が含まれているかを認識または気にしません。単にラッパースタイリングを提供するだけです。開始タグと終了タグ<Card>
の間のコンテンツは、自動的にprops.children
として渡されます。これは、疎結合と再利用性の美しい例です。
テクニック2:小道具による特殊化
コンポーネントに、他のコンポーネントによって埋められる複数の「穴」が必要になる場合があります。`props.children`を使用できますが、より明示的で構造化された方法は、コンポーネントを通常のプロップとして渡すことです。このパターンは、特殊化と呼ばれることがよくあります。
`Modal`コンポーネントを考えてみましょう。モーダルには通常、タイトルセクション、コンテンツセクション、およびアクションセクション(「確認」や「キャンセル」などのボタン付き)があります。これらのセクションをプロップとして受け入れるように`Modal`を設計できます。
// Modal.js - より特殊なコンテナ
function Modal(props) {
return (
<div className="modal-backdrop">
<div className="modal-content">
<div className="modal-header">{props.title}</div>
<div className="modal-body">{props.body}</div>
<div className="modal-footer">{props.actions}</div>
</div>
</div>
);
}
// App.js - 特定のコンポーネントを使用したモーダルの使用
function App() {
const confirmationTitle = <h2>アクションの確認</h2>;
const confirmationBody = <p>このアクションを続行してもよろしいですか?</p>;
const confirmationActions = (
<div>
<button>確認</button>
<button>キャンセル</button>
</div>
);
return (
<Modal
title={confirmationTitle}
body={confirmationBody}
actions={confirmationActions}
/>
);
}
この例では、Modal
は高度に再利用可能なレイアウトコンポーネントです。`title`、`body`、`actions`に特定のJSX要素を渡すことで特殊化します。これは、`ConfirmationModal`および`WarningModal`サブクラスを作成するよりもはるかに柔軟です。必要に応じて、さまざまなコンテンツで`Modal`を構成するだけです。
テクニック3:高階コンポーネント(HOC)
データフェッチ、認証、ロギングなどのUI以外のロジックを共有するために、React開発者は歴史的に高階コンポーネント(HOC)と呼ばれるパターンに目を向けました。最新のReactではHooksに大きく置き換えられていますが、Reactのコンポジションストーリーにおける主要な進化のステップを表しており、多くのコードベースにまだ存在しているため、それらを理解することが重要です。
HOCは、コンポーネントを引数として受け取り、新しい強化されたコンポーネントを返す関数です。
更新されるたびにコンポーネントのプロップをログに記録する`withLogger`というHOCを作成してみましょう。これはデバッグに役立ちます。
// withLogger.js - HOC
import React, { useEffect } from 'react';
function withLogger(WrappedComponent) {
// 新しいコンポーネントを返します...
return function EnhancedComponent(props) {
useEffect(() => {
console.log('コンポーネントが新しいプロップで更新されました:', props);
}, [props]);
// ...元のコンポーネントを元のプロップでレンダリングします。
return <WrappedComponent {...props} />;
};
}
// MyComponent.js - 強化されるコンポーネント
function MyComponent({ name, age }) {
return (
<div>
<h1>こんにちは、{name}!</h1>
<p>あなたは{age}歳です。</p>
</div>
);
}
// 強化されたコンポーネントのエクスポート
export default withLogger(MyComponent);
`withLogger`関数は`MyComponent`をラップし、`MyComponent`の内部コードを変更せずに新しいロギング機能を提供します。この同じHOCを他のコンポーネントに適用して、同じロギング機能を提供できます。
HOCの課題:
- ラッパー地獄:単一のコンポーネントに複数のHOCを適用すると、React DevToolsで深くネストされたコンポーネント(例:`withAuth(withRouter(withLogger(MyComponent)))`)が発生し、デバッグが困難になる可能性があります。
- プロップの名前の衝突:HOCがラップされたコンポーネントですでに使用されているプロップ(例:`data`)を挿入すると、誤って上書きされる可能性があります。
- 暗黙的なロジック:コンポーネントのコードから、そのプロップがどこから来ているかが常に明確であるとは限りません。ロジックはHOC内に隠されています。
テクニック4:Render Props
Render Propパターンは、HOCの欠点のいくつかを解決するために登場しました。より明示的なロジック共有方法を提供します。
レンダープロップを持つコンポーネントは、プロップとして関数(通常は`render`という名前)を受け取り、その関数を呼び出して、レンダリングするものを決定し、状態またはロジックを引数として渡します。
マウスのX座標とY座標を追跡し、それらを使用したいコンポーネントで使用できるようにする`MouseTracker`コンポーネントを作成してみましょう。
// MouseTracker.js - レンダープロップを持つコンポーネント
import React, { useState, useEffect } from 'react';
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (event) => {
setPosition({ x: event.clientX, y: event.clientY });
};
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
// 状態を使用してrender関数を呼び出す
return render(position);
}
// App.js - MouseTrackerの使用
function App() {
return (
<div>
<h1>マウスを動かしてください!</h1>
<MouseTracker
render={mousePosition => (
<p>現在のマウスの位置は({mousePosition.x}、{mousePosition.y})です</p>
)}
/>
</div>
);
}
ここで、`MouseTracker`はマウスの動きを追跡するためのすべてのロジックをカプセル化します。それ自体では何もレンダリングしません。代わりに、レンダリングロジックを`render`プロップに委任します。JSX内で`mousePosition`データがどこから来ているかを正確に確認できるため、これはHOCよりも明示的です。
`children`プロップも関数として使用できます。これは、このパターンの一般的でエレガントなバリエーションです。
// childrenを関数として使用する
<MouseTracker>
{mousePosition => (
<p>現在のマウスの位置は({mousePosition.x}、{mousePosition.y})です</p>
)}
</MouseTracker>
テクニック5:Hooks(最新で推奨されるアプローチ)
React 16.8で導入されたHooksは、Reactコンポーネントの記述方法に革命をもたらしました。これにより、関数コンポーネントで状態やその他のReact機能を使用できます。最も重要なことは、カスタムHooksは、コンポーネント間でステートフルロジックを共有するための最もエレガントで直接的なソリューションを提供します。
Hooksは、HOCとRender Propsの問題をよりクリーンな方法で解決します。`MouseTracker`の例を`useMousePosition`というカスタムフックにリファクタリングしてみましょう。
// hooks/useMousePosition.js - カスタムフック
import { useState, useEffect } from 'react';
export function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
setPosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []); // 空の依存関係配列は、このエフェクトが1回だけ実行されることを意味します
return position;
}
// DisplayMousePosition.js - フックを使用するコンポーネント
import { useMousePosition } from './hooks/useMousePosition';
function DisplayMousePosition() {
const position = useMousePosition(); // フックを呼び出すだけ!
return (
<p>
マウスの位置は({position.x}、{position.y})です
</p>
);
}
// 別のコンポーネント、おそらくインタラクティブな要素
import { useMousePosition } from './hooks/useMousePosition';
function InteractiveBox() {
const { x, y } = useMousePosition();
const style = {
position: 'absolute',
top: y - 25, // カーソルにボックスを中央に配置する
left: x - 25,
width: '50px',
height: '50px',
backgroundColor: 'lightblue',
};
return <div style={style} />;
}
これは大幅な改善です。「ラッパー地獄」も、プロップの名前の衝突も、複雑なレンダープロップ関数もありません。ロジックは完全に再利用可能な関数(`useMousePosition`)に分離され、任意のコンポーネントが単一の明確なコード行でそのステートフルロジックに「フックイン」できます。カスタムHooksは、最新のReactにおけるコンポジションの究極の表現であり、再利用可能なロジックブロックの独自のライブラリを構築できます。
簡単な比較:Reactにおけるコンポジション対継承
Reactコンテキストでの主な違いを要約するために、直接的な比較を次に示します。
アスペクト | 継承(Reactのアンチパターン) | コンポジション(Reactで推奨) |
---|---|---|
関係 | 「is-a」関係。特殊化されたコンポーネントは、ベースコンポーネントのバージョンです。 | 「has-a」または「uses-a」関係。複雑なコンポーネントは、より小さなコンポーネントを持っているか、共有ロジックを使用しています。 |
結合 | 高い。子コンポーネントは、親の実装に密結合されています。 | 低い。コンポーネントは独立しており、変更なしにさまざまなコンテキストで再利用できます。 |
柔軟性 | 低い。厳格なクラスベースの階層により、異なるコンポーネントツリー間でロジックを共有することが困難になります。 | 高い。ロジックとUIは、ビルディングブロックのように、無数の方法で組み合わせて再利用できます。 |
コードの再利用性 | 定義済みの階層に限定されます。「バナナ」だけが欲しいのに、全体の「ゴリラ」を手に入れます。 | 優れています。小さく焦点を絞ったコンポーネントとフックは、アプリケーション全体で使用できます。 |
Reactイディオム | 公式のReactチームによって推奨されていません。 | Reactアプリケーションを構築するための推奨される慣用的なアプローチ。 |
結論:コンポジションで考える
コンポジションと継承の間の議論は、ソフトウェア設計における基本的なトピックです。継承には古典的なOOPでの場所がありますが、UI開発の動的なコンポーネントベースの性質により、Reactには適していません。ライブラリは基本的にコンポジションを受け入れるように設計されました。
コンポジションを優先することで、次のメリットが得られます。
- 柔軟性:必要に応じてUIとロジックを組み合わせて一致させる機能。
- 保守性:疎結合されたコンポーネントは、理解、テスト、および個別にリファクタリングが容易です。
- スケーラビリティ:コンポジションの考え方は、大規模で複雑なアプリケーションを効率的に構築するために使用できる、小さく再利用可能なコンポーネントとフックのデザインシステムの作成を奨励します。
グローバルなReact開発者として、コンポジションを習得することは、単にベストプラクティスに従うことだけではありません。Reactをそのような強力で生産的なツールにする中核となる哲学を理解することです。小さく焦点を絞ったコンポーネントを作成することから始めます。汎用コンテナには`props.children`を使用し、特殊化にはプロップを使用します。ロジックを共有するには、最初にカスタムHooksを使用します。コンポジションで考えることで、時の試練に耐えるエレガントで堅牢でスケーラブルなReactアプリケーションを構築するのに役立ちます。