Reactでの開発をしていくと、共通のメソッドを持つようなコンポーネントが増えてくる。一般にJavaScriptではクラスのメソッドを共通化する方法は、歴史的な事情も相まって色々な方法がある。チームで開発をする場合などにおいては、方法について一定の規約を持ちたいところだと思う。そこで、各種の方法と、そのメリット・デメリットについてまとめた。
- I. Higher Order Component(HOC)
- I-B. 定義したコンポーネントを継承する形 (Inheritance Inversion)
- I-C. 継承済みのコンポーネントを継承して定義する形 (Inheritance)
- II. 他のコンポーネントを継承する
- III. prototype拡張を行う 💣
- まとめ
- オマケ: recompose + decorator
I. Higher Order Component(HOC)
HOCは、Reactコンポーネントを再利用するためのテクニックの一つで、Reactの公式ドキュメントにも書かれているように一般的な方法の一つ。 公式ドキュメントにあるように、HOCはComponentからComponentへの関数であると解釈され、一口にHOCと言っても実装方法にはそれ以上具体的な決まりはない。そこで、まずはHOCの実装パターンを分類した。
I-A. 定義したコンポーネントに依存するコンポーネントを返す形 (Dependency)
HOCは新しいコンポーネントを返す。そのコンポーネントのrender内で引数のコンポーネントが使用される。
// 共通コードの記述 export function withBehavior(TargetComponent, ...args) { return class extends React.Component { : render() { return ( : <TargetComponent .. {...this.props} /> : ); } } } // 利用者の記述 class Target extends React.Component { : } export default withBehavior(Target)
メリット
- 堅牢性
- 引数のコンポーネントは、与えられるpropsや返されるノードツリーが変化すること以上の影響を受けない
- 利用者は記述したコンポーネントがstateやメソッドのレベルで変更されている可能性を想定しなくてよい
デメリット
- 拡張性
- stateの扱いやメソッドの共通化には利用できない
I-B. 定義したコンポーネントを継承する形 (Inheritance Inversion)
HOCを適用することで引数のコンポーネントを継承させる。
この記事では、Inheritance Inversionと呼んでいるので、そう呼ぶことにする。
// 共通コードの記述 function withBehavior(TargetComponent, ...args) { return class extends TargetComponent { : } } // 利用する側の記述 class Target extends React.Component { : } export default withBehavior(Target)
メリット
- 拡張性
- 共通コードはstateの扱いやメソッドのレベル読み書きできるので、メソッドの共通化をしたい場合に使える
- 堅牢性
- 利用者はオーバーライドすることができないので、共通コードの提供者が期待しない方法による不用意な実装を防ぎやすい。
デメリット
- 拡張性
- 利用者は、共通コードの想定を超える例外的な実装が必要な際にオーバーライドで自由に拡張できない。
- 堅牢性
- 利用者は、共通コード内でメソッドがoverrideされる可能性を想定しなければならない
- ※ privateメソッドが使えるようになれば、明示することで緩和できるかも?
I-C. 継承済みのコンポーネントを継承して定義する形 (Inheritance)
HOCを適用した基底クラスを継承してコンポーネントを定義する。 利用者側の記述がI-Bとは異なっていることに注意。 この書き方は、GoogleのJustin FagnaniによるMixinに関する記事でのmixinの書き方と等しい。
function withBehavior(TargetComponent, ...args) { return class extends TargetComponent { : } } // 利用する側の記述 export default class Target extends withBehavior(React.Component) { : }
メリット
- 拡張性
- 共通コードはstateの扱いやメソッドのレベル読み書きできるので、メソッドの共通化をしたい場合に使える
- 共通コードを利用する側は、共通コードのI/Fを必要に応じて拡張できる(=overrideできる)
デメリット
- 堅牢性
- 利用者によるoverrideによって例外的拡張が許容されるため、共通コードがメンテナンスしにくい状況になりうる。
II. 他のコンポーネントを継承する
HOCを使わず、既存のコンポーネントを継承して新たなコンポーネントを生成する。
// 共通コードの記述 export class BaseComponent extends ReactComponent { : } // 共通コード利用者の記述 export class SpecialComponent extends BaseComponent { : }
メリット
- シンプル
- 共通コード側自身がコンポーネントになるので、"基本形となるコンポーネントが存在し、それを少し拡張したバージョンを作る必要がある" というケースにおいては必要なコードが少なく済む
デメリット
- 拡張性
- mixinとは違い、多重継承できない
- 堅牢性
- 親クラスは、拡張されることを想定しなければならないので、子クラスが増えてくるとメンテナンスが難しくなる。
III. prototype拡張を行う 💣
prototypeを直接書き換える。
// 共通コードの記述 export default { // example method() { .. }, : }; // 共通コード利用者の記述 class SpecialComponent extends BaseComponent { : } Object.assign(SpecialComponent.prototype, mixin) // example
メリット
- 何でもできる
- prototypeチェーンをたどるステップが短くなって速いかもしれない(未検証)
デメリット
- 堅牢性
- 書き換えの発生のためにコードが追いにくく、prototypeの拡張を使った自由な実装を許容してしまう
まとめ
メリット・デメリットを考えると以下の表のようになる。
# | 方法 | 拡張性 | 堅牢性 |
---|---|---|---|
I-A | HOC/Dependency | ★ | ★★★ |
I-B | HOC/Inheritance Inversion | ★★ | ★★ |
I-C | HOC/Inheritance | ★★★ | ★ |
II | 継承 | ★★ | ★ |
III | prototype拡張 | ★★★★ | :bomb: |
上から順番に使えないか検討するのが良さそう。
オマケ: recompose + decorator
I-A, I-Bのように、作ったコンポーネントをあとで関数で包むのは結構抵抗があるかもしれない。 まずコードが長くなると、末尾にこんな大事なものがあるなどと気づかない可能性も高い。 またexport文の位置についても考え直さなければならなくなる。
class Foo extends React.Component { : } export default withBehavior(Foo)
recompose というHOCを扱いやすくするライブラリと、ES7のdecoratorを使うとこの辺が簡単にかけるため気に入っている。
// 共通コード (withBehavior.js) import { compose } from 'recompose' export default compose(base => { // I-AまたはI-B return class extends base { : } }) // 利用者コード import withBehavior from './withBehavior' @withBehavior export default class Foo extends React.Component { : }