技術と魚

雑感と備忘録

React Componentのコード共通化方法まとめ

f:id:norainu234:20181120141240j:plain

Reactでの開発をしていくと、共通のメソッドを持つようなコンポーネントが増えてくる。一般にJavaScriptではクラスのメソッドを共通化する方法は、歴史的な事情も相まって色々な方法がある。チームで開発をする場合などにおいては、方法について一定の規約を持ちたいところだと思う。そこで、各種の方法と、そのメリット・デメリットについてまとめた。

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 {
  :
}