ReactのHigher Order Components詳解 : 実装の2つのパターンと、親Componentとの比較

(編注:2016/7/27、いただいたフィードバックをもとに記事を修正いたしました。)

概要

この投稿は、HOCパターンを利用してみたいという上級ユーザ向けの記事です。もしReactが初めての方は、まずReactのドキュメントを読むところから始めるとよいでしょう。

Higher Order Componentsは、さまざまなReactライブラリにとって価値があることがわかっている素晴らしいパターンです。この投稿で、HOCとは何か、できることは何か、制約は何か、どのように実装するのか……という点について詳細に見ていきます。

付録として、関連トピックについても見ていきます。それらは、HOCを学ぶ上での中核にはならないものの、カバーしておくべきだと私が思っているものです。

この投稿を網羅的なものにしたいと考えているため、もし何か私が見落としている点を見つけた場合は是非教えてください。必要な変更を加えようと思います。

この投稿はES6に関する知識があるものとして進めていきます。

それでは始めましょう!

Higher Order Componentsとは何か?

Higher Order Componentとは、単に他のコンポーネントをラップするReactコンポーネントのことです。

このパターンは通常は関数として、基本的にはclass factoryとして実装されて(そうです、class factoryです!)、Haskell風の擬似コードで書くと、以下のようなシグネチャを持ちます。

hocFactory:: W: React.Component => E: React.Component

W (WrappedComponent)はラップされるReact.Componentで、E (Enhanced Component)は新しいHOC、つまり返されるReact.Componentとなります。

この「ラップする」という単語の定義は、意図的にあいまいにしておきます。なぜなら、これには2つの可能性があるからです。

  1. Props Proxy: WrappedComponentであるWに渡されるpropsをHOCが操作する。
  2. Inheritance Inversion: WrappedComponentであるWをHOCが継承する。

この2つのパターンについて、より詳細に見ていきましょう。

HOCでできることは何か?

高レベルでのHOCにより、以下のことが可能になります。

  • コードの再利用、ロジックやブートストラップの抽象化
  • Render Hijacking(レンダリング・ハイジャック)
  • Stateの抽象化と操作
  • Propsの操作

これらの項目についてはすぐに詳細に見ていきますが、まずはHOCをどのように実装するのかを見ていきます。なぜなら、この実装がHOCで実際にできることを制限したり可能にしたりするからです。


HOC factory の実装

この節では、ReactでHOCを実装する2つの主な方法、Props Proxy (PP)とInheritance Inversion (II)について学んでいきます。どちらも違った方法でWrappedComponentの操作を可能にしてくれます。

Props Proxy

Props Proxy (PP)は以下のようにして簡単に実装できます。

ここで重要なのは、HOCのrenderメソッドがWrappedComponent型のReact Elementを返すことです。HOCが受け取ったpropsをそのまま渡していますが、これを今後Propx Proxyと呼びます。

注記:

<WrappedComponent {...this.props}/>
// これは以下と等しい
React.createElement(WrappedComponent, this.props, null)

これらは両方とも、調停(Reconciliation)プロセスにおいてReactが何をレンダリングすべきかを説明するReact Elementを生成します。React ElementとComponentの比較についてもっと知りたい場合は、このDan Abramovによる投稿を見て、調停プロセスに関するドキュメントをお読みください。

Props Proxyでできることは何か?

  • propsの操作
  • 参照を経由したインスタンスへのアクセス
  • Stateの抽象化
  • WrappedComponentを他の要素でラップする

propsの操作

WrappedComponentへ渡されるpropsに対して、読んだり追加したり変更したり消去したりといった操作ができます。

重要なpropsを消去したり変更したりする場合は注意してください。HOCのpropsがWrappedComponentを壊さないよう適切に命名しなければなりません。

例:新しいpropsを追加する。アプリケーションで現在ログインしているユーザが、WrappedComponent内ではthis.props.userで利用可能になります。

参照を経由したインスタンスへのアクセス

参照を用いて、this (WrappedComponentインスタンス)にアクセスすることができます。しかし、参照が計算されるためには、WrappedComponentの完全な通常のrenderプロセスが最初から必要となります。つまり、HOCのrenderメソッドでWrappedComponentを返し、Reactに調停プロセスを行わせることが必要になり、そうすることでWrappedComponentインスタンスへの参照が得られるようになる、ということです。

例:以下の例では、WrappedComponentのインスタンスメソッドとインスタンス自身に参照を経由してアクセスする方法を試しています。

WrappedComponentがレンダリングされると、refコールバックが実行され、WrappedComponentインスタンスへの参照が得られるようになります。これは、インスタンスのpropsの読込/追加や、インスタンスメソッドの呼び出しに用いられます。

Stateの抽象化

WrappedComponentにpropsとコールバックを渡すことで、Stateを抽象化することができます。これは、Smart componentがDumb componentを扱う方法に似ています。Dumb/Smart componentについてはより詳細を見てみてください。

例:以下のState抽象化の例では名前のinputフィールドのvalueonChangeハンドラを素朴に抽象化しています。”素朴に”というのは、このやり方は普遍的でないものの、要点を理解してもらうにはよいと考えたからです。

以下のように使うことができます。

このinputはなんと自動的に制御されたinputになります。

より一般的な2wayのデータバインディングHOCについては、こちらをご覧ください : link

WrappedComponentを他の要素でラップする

スタイルやレイアウト、その他の目的のために、WrappedComponentを他のcomponentや要素でラップすることができます。基本的な使い方のうちいくつかは通常の親componentで達成できます(付録Bをご覧ください)が、前述したとおりHOCではよりフレキシブルにできます。

例:スタイリング目的でのラップ


Inheritance Inversion

Inheritance Inversion (II)は以下のようにして簡単に実装できます。

ご覧のとおり、返されたHOC class(Enhancer)はWrappedComponent継承します。WrappedComponentがEnhancer classを継承するのではなく、逆に受動的にEnhancerによって継承されることから、これをInheritance Inversionと呼びます。このやり方では、これらの関係は逆転しているように見えます。

Inheritance Inversionにより、HOCはWrappedComponentインスタンスにthisでアクセスできるようになります。つまり、State、props、コンポーネントのライフサイクルのフック、そしてrenderメソッドにアクセスできるのです。

ライフサイクルのフックで何ができるか、ということについて深入りはしません。というのも、これはReactに特有の話であり、HOCに特有の話ではないからです。しかし、IIパターンによってWrappedComponentに新たなライフサイクル・フックを作成できる、ということを付記しておきます。WrappedComponentを壊さないよう、常にsuper.[lifecycleHook]を呼び出すことを忘れないようにしてください。

調停プロセス

詳細に見ていく前に、いくつかの理論をまとめておきましょう。

React Elementは、「Reactが調停(reconciliation)プロセスを実行するときに何がレンダリングされるか」を説明します。

React Elementには文字列型のものと関数型のものがあります。文字列タイプのReact Element(String Type React Elements = STRE)はDOMノードを表し、関数タイプのReact Element(Function Type React Elements = FTRE)はReact.Componentを継承することで作られるComponentを表します。ElementとComponentについてより詳細を知るには、この投稿を読んでください。

FTREは、Reactの調停プロセスで完全なSTREツリーとして解決されます(最終結果は常にDOM要素です)。

これはとても重要です。Inheritance InversionによるHigh Order Componentは解決された完全な子ツリーを持つとは限らないということを意味するのです。

Inheritance InversionによるHigh Order Componentは解決された完全な子ツリーを持つとは限らない。

Render Hijackingを学ぶ際に、このことが重要であるとわかります。

Inheritance Inversionでできることは何か

  • Render Hijacking
  • Stateの操作

Render Hijacking

HOCがWrappedComponentのレンダリング結果のコントロール等をすべてできることから、これはRender Hijackingと呼ばれます。

Render Hijackingにより、以下のことが可能です。

  • renderで出力されるあらゆるReact Elementについて、propsの読込/追加/変更/消去
  • renderで出力されるReact Elementツリーの読み込みと変更
  • 条件に応じた要素ツリーの表示
  • 要素のツリーをスタイリング目的でラッピング(Props Proxyと同様)

*renderWrappedComponent.renderメソッドを指します。

WrappedComponentインスタンスのpropsを変更したり作成したりすることはできません。これは、React Componentは自身が受け取ったpropsを変更することができませんが、renderメソッドで出力された要素のpropsを変更することはできるからです。

ここまで学んできたように、II HOCは解決された完全な子ツリーを持つとは限らないのですが、それによってRender Hijackingテクニックにいくらかの制約が生じます。経験則として、Render Hijackingで操作できるのはWrappedComponentのrenderメソッドの出力となっている要素のツリーであり、それ以上でもそれ以下でもありません。もしその要素のツリーが関数タイプのReact Componentを含む場合、そのcomponentの子を計算することはできません(スクリーンに実際にレンダリングされるまでReactの調停プロセスによって遅らせられます)。

例1:条件に応じたレンダリング。このHOCは、this.props.loggedInがtrueでない限りはWrappedComponentのレンダリングするものをそのままレンダリングします。(HOCがloggedIn propを受け取るとします)

例2:renderで出力されたReact Elementツリーの変更

この例では、WrappedComponentのレンダリング結果がトップレベルの要素としてinputを持つ場合、そのvalueを“may the force be with you”に変更します。

こういったあらゆる操作をここで行うことができます。要素ツリー全体をトラバースしたり、ツリー内のどんな要素についてもpropsを変更したりできます。Radiumがやっていることというのはまさにこれです(ケーススタディとしてRadiumについてはより詳細に述べます)。

注記:Render Hijackingは、Props Proxyで行うことはできません。

WrappedComponent.prototype.renderを通じてrenderメソッドにアクセスすることはできるものの、WrappedComponentインスタンスとそのpropsをモックする必要があり、またReactではなく自分自身がcomponentのライフサイクルをハンドリングしなければならなくなる可能性があります。私の経験からいうと、これを行う価値はありませんし、Render HijackingをしたいのであればProps ProxyでなはくInheritance Inversionを使うべきです。Reactは内部的にcomponentのインスタンスをハンドリングしており、あなたがインスタンスを扱う方法はthisか参照を経由するかだけである、というのを忘れないでください。

Stateの操作

HOCは、WrappedComponentインスタンスのStateを読込/変更/消去することができ、必要に応じてさらにStateを追加することもできます。WrappedComponentのStateに干渉すると、何かが壊れる結果に至る可能性があることを忘れないでください。ほとんどの場合、HOCはStateを読む/追加するのみに制限されるべきであり、後者についてはWrappedComponentのStateに干渉しないように名前空間を保つべきです。

例:WrappedComponentのpropsとStateにアクセスすることでのデバッグ

このHOCは、WrappedComponentを他の要素でラップしており、また、WrappedComponentのインスタンスのpropsとStateを表示します。JSON.stringifyによるトリックはRyan FlorenceMichael Jacksonが教えてくれました。このデバッガの完全に動作する実装はこちらでご覧いただけます。


ネーミング

HOCでComponentをラップする時は、元のWrappedComponentの名前を失うことになり、開発とデバッグに影響が出るかもしれません。

これについてふつう用いられるのが、WrappedComponentの名前を取ってきて、その頭に何かを付け加えることでHOCの名前をカスタマイズする方法です。以下の例はReact-Reduxからのものです。

HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`

//or

class HOC extends ... {
  static displayName = `HOC(${getDisplayName(WrappedComponent)})`
  ...
}

getDisplayNameは以下のように定義されています。

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName ||
         WrappedComponent.name ||
         ‘Component’
}

実のところ、recomposeライブラリがすでにこの機能を提供しているため、これを自分自身で書き直す必要はありません。


ケーススタディ

React-Redux

React-Reduxは、ReactのためのRedux公式バインディングです。機能の一つとして、storeをリスニングしたのちにクリーニングするのに必要なすべてのブートストラップを扱うconnectを提供しています。これはProps Proxyでの実装により実現されています。

純粋なFluxを使ったことがあればご存知かと思いますが、1つあるいは複数のstoreに紐付けられているReact Componentには、storeリスナーの追加/削除や必要なStateの部分の選択のため、多くのブートストラッピングが必要になります。React-Reduxの実装が大変素晴らしいのは、これらのブートストラップをすべて抽象化してくれるからです。基本的に、自分自身でそれを書く必要がなくなるのです!

Radium

Radiumは、インラインスタイルでのCSS擬似セレクタの利用を可能にすることで、インラインスタイルの効力を高めるライブラリです。「なぜインラインスタイルが良いのか?」というのは別の議題になってしまいますが、多くの人はそれをし始めていますし、Radiumのようなライブラリはそのゲームを実にステップアップしてくれます。インラインスタイルについてより詳しく知りたい場合は、Vjeuxによるこのプレゼンテーションから始めてみてください。

それでは、RadiumはどのようにしてhoverのようなCSS擬似セレクタをインラインで使用可能にしているのでしょうか?ここでは、hoverなどのCSS擬似セレクタをシミュレートするために適切なイベントリスナ(新たなprops)を注入するため、Render Hijackingを用いており、そのためにInheritance Inversionをパターンを実装しています。イベントリスナはReact Elementのpropsのハンドラとして注入されます。このために、RadiumはWrappedComponentのrenderメソッドで出力される要素ツリーの全てを読む必要とがあり、style propを持つ要素を見つけ次第イベントリスナのpropsを付与します。単純に言うと、Radiumは要素ツリーのpropsを変更するのです(Radiumが実際に行っていることはもう少し複雑ですが、理解はして頂けたかと思います)。

RadiumはとてもシンプルなAPIを提供しています。ユーザに気付かれさえせずに全ての仕事をしてくれることを考えると、非常に印象的です。HOCの力を垣間見ることができます。


付録A: HOCとパラメータ

以下の内容は必須ではないので、スキップしてもらっても構いません。

HOCにパラメータを利用することは時に有用です。これまで示した例では暗黙のうちに使っていましたし、中級のJavaScript開発者にとっては極めて自然なものでしょうが、この投稿の網羅性のため、簡単にカバーしていきましょう。

例: 簡単なProps ProxyでのHOCパラメータ。重要なのはHOCFactoryFactoryです。

これを以下のように使うことができます。

HOCFactoryFactory(params)(WrappedComponent)
//or
@HOCFatoryFactory(params)
class WrappedComponent extends React.Component{}

付録B: 親Componentとの違い

以下の内容は必須ではないので、スキップしてもらっても構いません。

親Componentは、単に子を持つReact Componentのことです。ReactにはComponentの子を操作したりアクセスしたりするためのAPIがあります。

例:親Componentが子にアクセスする

それでは、親Componentができること/できないことについて、HOCとの比較や重要な詳細を見つつ検討していきましょう。

  • Render Hijacking(Inheritance Inversionで見てきました)
  • 内部propsの操作 (Inheritance Inversionで見てきました)
  • Stateの抽象化。しかし、これには弱点があります。明示的にフックを作らない限り、外側から親ComponentのStateにアクセスすることができません。これにより、有用性が制限されてしまいます。
  • 新たなReact Elementでのラップ。親ComponentがHOCよりも人間工学的に感じられる唯一のユースケースかもしれません。HOCでもこれは可能です。
  • 子の操作について、いくつか問題があります。例えば、もし子が単一のルート要素を持たない場合(第1階層の子が2つ以上)、すべての子要素をラップするために余計に要素を追加しなければなりません。これはマークアップにおいて少し厄介になります。HOCでは、React/JSXの制約により、トップレベルの子要素が単一であることが保証されます。
  • 親Componentは要素ツリーの中で自由に使うことができ、HOCのようにComponent class中で1度のみという制約を受けません。

一般的に、もしやりたいことが親Componentでできるのであれば、HOCほどハック的でないのでそちらを用いるべきです。しかし、上記のリストに示した状況であれば、HOCに比べて自由度に欠けます。

終わりに

この記事を読んだあと、あなたのReact HOCに関する知識が増えていれば幸いです。React HOCは高い表現力があり、別のライブラリでもとても良いということが分かっています。

Reactは多くのイノベーションをもたらしましたし、RadiumやReact-Redux、React-Router、その他のプロジェクトを動かしている人たちがそのよい証拠です。

もし私とコンタクトを取りたければ、ぜひ私をtwitterでフォローしてください。 @franleplant

この記事で説明したいくつかのパターンを実験するために私の遊んだコードで遊べますので、こちらのリポジトリも見てみてください。

Credits

React-Redux, Radium, Sebastian Markbågeによるこちらのgist、それと私自身の実験に帰するものです。