React ソースコードバージョン: v16.9.0
ソースコード注釈ノート:airingursb/react
React コンポーネントの状態ロジックを再利用し、拡張するにはどうすればよいでしょうか?具体的には、以下の 5 つの方法があります:
- Mixins
- クラス継承
- 高階コンポーネント
- レンダープロップ
- React フック
以下で、5 つの方法の実装について一つずつ紹介します。
1. Mixins#
Mixins は、あるオブジェクトのプロパティを別のオブジェクトにコピーするもので、実際にはオブジェクトの融合です。その目的は主にコードの再利用の問題を解決するためです。
拡張:オブジェクトの融合について言及すると、
Object.assign
も一般的な方法です。Mixins との大きな違いは、Mixins がプロトタイプチェーン上のプロパティもコピーするのに対し(for...in
のため)、Object.assign
はそうしないことです。
現在、React は Mixins をサポートしていないため、ここではその使用方法については触れません。以前の React での Mixin の使用方法については、こちらの記事を参照してください:React Mixin の使用 | segmentfault
Mixins はコードの再利用の問題を解決できますが、多くの問題を引き起こし、利益よりも害が大きくなることがあります。そのため、React は現在 Mixins をサポートしていません。具体的には、以下のいくつかの欠点があります:
- コードが過度に結合される:Mixins は隠れた依存関係を導入し、コード間で相互依存や相互結合が生じ、コードの保守が困難になります。
- 同名の Mixin を同時に使用できない:例えば、
FluxListenerMixin
がhandleChange()
を定義し、WindowSizeMixin
がhandleChange()
を定義している場合、同時に使用することはできず、自分のコンポーネントにこの名前のメソッドを定義することもできません。 - 雪だるま効果の複雑さ:Mixins の数が多くなると、コンポーネントはそれを認識し、コンポーネントコード内で関連処理を行うためにハックロジックを追加する必要があり、コードに雪だるま式の複雑さをもたらします。
2. クラス継承#
クラスコンポーネントのコードロジックの再利用について言及すると、OOP に精通している人はすぐにクラスの継承を思い浮かべるでしょう。A コンポーネントは B コンポーネントを継承することで、親クラスのメソッドを再利用できます。しかし、React を使用している人は、コンポーネントのロジックを再利用するために継承の方法を使わないと信じています。
ここでの主な考慮事項はコードの品質の問題です。もし 2 つのコンポーネントがそれぞれ複雑なビジネスロジックを持っている場合、継承の方法は良くありません。子コンポーネントのコードを読む際に、明示的に宣言されていないメソッドを親コンポーネントに探しに行く必要があり、React はコンポーネントが一つのことに専念することを望んでいます。
また、子コンポーネントのライフサイクルをオーバーライドすると、親コンポーネントのライフサイクルが上書きされるため、これも開発中に避けたい状況です。
Facebook は React での継承の使用を「深く嫌悪」しており、公式サイトの Composition vs Inheritance で次のように述べています。「Facebook では、数百から数千のコンポーネントで React を使用していますが、コンポーネント階層を構築するために継承を使用する必要があるとは感じていません。」
確かに、関数型プログラミングとコンポーネント型プログラミングの思想は、ある意味で一致しています。どちらも「組み合わせの芸術」であり、大きな関数は複数の単一責任の関数の組み合わせで構成されます。同様に、コンポーネントもそうです。React 開発を行う際には、常にコンポーネントを計画し、大きなコンポーネントを子コンポーネントに分割し、コンポーネントに対してより細かい制御を行い、コンポーネントの純粋性を確保し、コンポーネントの責任をより単一で独立させます。組み合わせの利点は再利用性、テスト可能性、予測可能性です。
したがって、まずは組み合わせを考慮し、その後に継承を考えるべきです。また、Facebook の公式記事では、コンポーネントのロジック再利用を実現するために HOC の使用を推奨しています(詳細は《Higher-Order Components》を参照)。それでは、HOC が具体的に何であるかを見ていきましょう。
3. HOC(高階コンポーネント)#
HOC、すなわち高階コンポーネントです。名前は高級ですが、実際には高階関数と同じように特別なものではありません。
高階関数の定義を振り返ってみましょう:
- 関数は引数として渡すことができる
- 関数は戻り値として出力できる
実際、高階コンポーネントは関数であり、その関数はコンポーネントを引数として受け取り、新しいコンポーネントを返します。注意が必要なのは、高階コンポーネントは関数であり、コンポーネントではないということです。HOC は実際にはデコレーターであるため、ES 7 のデコレーター構文を使用することもできますが、この記事ではコードの直観性のためにデコレーター構文は使用しません。
拡張読書:デコレーター提案 proposal-decorators | GitHub
高階コンポーネントには 2 つの実装があります:
- 継承型 HOC:すなわち逆継承(Inheritance Inversion)
- プロキシ型 HOC:すなわちプロパティプロキシ(Props Proxy)
継承は公式には推奨されていないため、継承型 HOC は元のコンポーネントのロジックを変更する可能性があり、単純な再利用や拡張ではなくなります。そのため、継承型 HOC には多くの欠点があります。ここでは一部のコードを示しますが、詳細には触れません。
// 継承型 HOC
import React, { Component } from 'react'
export default const HOC = (WrappedComponent) => class NewComponent extends WrappedComponent {
componentWillMount() {
console.log('ここで元のコンポーネントのライフサイクルを変更します')
}
render() {
const element = super.render()
const newProps = { ...this.props, style: { color: 'red' }}
return React.cloneElement(element, newProps, element.props.children)
}
}
継承型 HOC は確かに元のコンポーネントのロジックを再利用し、拡張することができます。一方、プロキシ型 HOC はさらにシンプルです。次に、具体的なプロジェクトコードを見てみましょう。以下のボタンをクリックしてデバッグに入ることができます:
ここには Profile と Home の 2 つのコンポーネントがあり、両方のコンポーネントは Container にラップされています。各 Container のスタイルは同じで、タイトルもあります。ここで、Profile と Home の両方が Container のスタイルと構造を再利用できるように、HOC を使って実装してみましょう:
// app.js
import React from "react";
import ReactDOM from "react-dom";
import Profile from "./components/Profile";
import Home from "./components/Home";
import "./styles.css";
function App() {
return (
<div className="App">
<Profile name={"Airing"} />
<Home />
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
// Container.js
import React, { Component } from "react";
import "../styles.css";
export default title => WrappedComponent =>
class Container extends Component {
render() {
return (
<div className="container">
<header className="header">
{title}
</header>
<div>
<WrappedComponent url={"https://me.ursb.me"} {...this.props} />
</div>
</div>
);
}
};
// Profile.js
import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";
class Profile extends Component {
render() {
return (
<>
<p>著者: {this.props.name}</p>
<p>ブログ: {this.props.url}</p>
<p>コンポーネント A</p>
</>
);
}
}
export default WrappedComponent("Profile")(Profile);
// Home.js
import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";
class Home extends Component {
render() {
return (
<>
<p>コンポーネント B</p>
</>
);
}
}
export default WrappedComponent("Home")(Home);
ここでの HOC は実際には元のコンポーネントのプロキシであり、新しいコンポーネントの render 関数内でラップされたコンポーネントをレンダリングします。HOC 自身が行う作業を除いて、他の機能はすべてラップされたコンポーネントに引き渡されます。
また、Redux の connect
関数も HOC の一例です。
ConnectedComment = connect(mapStateToProps, mapDispatchToProps)(Component);
これは次のように等しいです:
// connect は関数を返す関数(高階関数)
const enhance = connect(mapStateToProps, mapDispatchToProps);
// 返された関数は高階コンポーネントであり、その高階コンポーネントは Redux ストアに
// 関連付けられた新しいコンポーネントを返します
const ConnectedComment = enhance(Component);
さらに、antd の Form も HOC を使用して実装されています。
const WrappedNormalLoginForm = Form.create()(NormalLoginForm);
HOC はコンポーネントのロジック再利用に多くの便利さを提供しますが、多くのプロジェクトでこのパターンが使用されている一方で、HOC にはいくつかの欠点もあります:
- Wrapper Hell、コンポーネントの階層が過度にネストされる(Redux をデバッグしたことがある人は必ず実感しているでしょう)、これによりデバッグが非常に困難になります。
- デバッグ中にコンポーネント名を表示するために、コンポーネントの
displayName
を明示的に宣言する必要があります。 - Typescript の型付けがあまり親切ではありません。
- ref を完全に使用できない(注:React 16.3 では React.forwardRef が提供され、ref を転送できるようになり、この問題が解決されました)。
- 静的プロパティを手動でコピーする必要があります:HOC を使用して別のコンポーネントを強化する際、実際に使用するコンポーネントは元のコンポーネントではないため、元のコンポーネントの静的プロパティを取得できません。HOC の最後で手動でコピーすることができます。
- 無関係な props を透過してしまう:HOC は props を奪取でき、約束を守らない場合に透過された props を上書きすることができます。これにより、中間コンポーネントも無関係な props を受け取ることになり、コードの可読性が低下します。
/**
* 高階コンポーネントを使用すると、すべての props を代理できますが、特定の HOC は通常その中の1つまたは数個の props のみを使用します。
* 他の無関係な props を元のコンポーネントに透過させる必要があります。
*/
function visible(WrappedComponent) {
return class extends Component {
render() {
const { visible, ...props } = this.props;
if (visible === false) return null;
return <WrappedComponent {...props} />;
}
}
}
下の図は、Mixin と HOC の違いを比較したものです:(図源:【React 深入】から Mixin から HOC へ、さらに Hook へ)
4. レンダープロップ#
レンダープロップは非常に一般的です。例えば、React Context API:
class App extends React.Component {
render() {
return (
<ThemeProvider>
<ThemeContext.Consumer>
{val => <div>{val}</div>}
</ThemeContext.Consumer>
</ThemeProvider>
)
}
}
React の props は型を制限しておらず、関数であることができます。これにより、レンダープロップというパターンが生まれました。このパターンは非常に一般的で、実装の考え方はシンプルです。元々コンポーネントが置かれるべき場所をコールバックに置き換えることで、現在のコンポーネントは子コンポーネントの状態を取得して使用できます。
しかし、これも HOC と同様の Wrapper Hell の問題を引き起こす可能性があります。
5. React フック#
上記の問題は、Hooks を使用することで解決できます。Hooks はコンポーネントのロジック再利用と拡張の完璧なソリューションと言えます。具体的には、以下の利点があります:
- 名前の衝突を避ける:Hook と Mixin は使用法において一定の類似性がありますが、Mixin が導入するロジックと状態は相互に上書き可能ですが、複数の Hook は互いに影響を及ぼしません。
- Wrapper Hell を避ける:原理は async + await におけるコールバック地獄に似ています。
- Hooks は Functional Component のすべての利点を持ち(このシリーズの最初の記事をお読みください)、useState、useEffect、useRef などの Hook を使用することで、Functional Component 内で状態、ライフサイクル、ref を使用でき、Functional Component 固有の欠点を回避できます。
Hooks の具体的な実装については、次回の記事で詳しくお話しします。