NHN Cloud Meetup 編集部
React HOC集中探求(1)
2017.12.22
503
React HOC集中探求(1)
Reactが正式リリースされてから4年が経過しました。この間リリースされた16.0バージョンに至るまで内部的にも外部的にも多くの発展がありました。利点は4年以上経っても重要な概念やAPIがほぼそのまま維持されているということです。これはReactが初期から追求している価値観が、時間が経過しても色褪せていないことを証明しています。
Reactの重要な概念には大きな変化がなかったが、使用方法には多くの変化がありました。特に2015年に登場したReduxにより、単一不変ストアの使用、コンポーネントとコンテナの区分、Redux-Thunk / Redux-Sagaなどを利用した非同期処理などの概念は、今ではほぼ慣用的なパターンとして定着しています。
もう1つの重要な概念が、Higher Order Component(以下HOC)を使ったコードの再利用パターンです。既存のmixinを使用したパターンが徐々にアンチパターンとして認識され、ReactやReduxを作った有名プログラマからの支持も得て、今やHOCはコードを再利用するためのReactの標準方式として定着しています。しかし、他のパターンと同様に、HOCはすべてに効く万能薬ではなく、間違って使われると既存のミックス方式より改悪なコードが作成されます。
ここでは、HOCの概要、使用上の注意点、他パターンとの相違点などについて紹介したいと思います。
関数型プログラミング
Reactの最も重要な概念の1つであるにもかかわらず、開発者が見過ごしがちなのが、Reactは関数型プログラミングを目指している、ということです。Reactの公式ホームページに関数型プログラミングという言葉が明示されていないので、反問する方もいるでしょう。しかし、Reactの多くの場所に関数型プログラミングの哲学が埋め込まれており、最も人気のあるストア管理ライブラリであるReduxでも純粋関数と不変ストアを強調し、関数型に近いプログラミングスタイルを推奨しています。
Reactはジョーダン・ワーク(Jordan Walke)が開発しましたが、最初作ったときは、関数型言語であるMLベースの言語から多くの影響を受けたと言われています。Facebookで最近発表された言語であるReasonも、ML言語であるOcamlを基盤にJavaScriptと親和性のある形式で発展しました。事実、Reasonの開発がReactよりも先に開始され、その過程でReactが開発されました。ある意味、Reactは関数型言語でフロントエンド開発を行うための中間過程でできたJavaScriptライブラリとも言えます。
どの部分が関数型に近いか、ここで1つ1つ説明するのは難しいですが、HOCには必ずおさえておくべき特徴があります。Angularやビュー(Vue)など他のライブラリと最も差別化されている部分で、コンポーネントが(純粋)関数であるという点です。
コンポーネントは、(純粋)関数である
Reactのコンポーネントは基本的に関数です。それも純粋関数です。さらに詳しくいえば、入力値としてpropsを受け取り、ReactElementツリーを返却する純粋関数です。Reactは純粋関数でコンポーネントを作成できます。React公式ホームページでも太字で、次のように明示しています。
All React components must act like pure functions with respect to their props.
「React.Componentを継承するクラスのコンポーネントとは何か」と疑問に思われるかも知れません。もちろん、コンポーネントの状態を管理したり、ライフサイクル(Life Cycle)にフック(Hook)をかけて、任意の時点で特定の関数を実行するには、純粋な関数だけで実装するには厳しいでしょう。そのため最初にReactがリリースされたときは、createClass関数を使ってコンポーネントを作成するようにAPIが設計されました。
しかし、コンポーネントの状態管理は関数のクロージャを使って管理ができ(この場合、純粋関数ではない)、ライフサイクルに関連する関数はマップ(Map)の形で囲むか、コンポーネント関数のプロパティとして管理できます。つまりコンポーネントが関数と呼ばれる基本的な概念を維持したままで、いくらでもアプリケーションを作ることができるのです。ただし、既存のオブジェクト指向の開発者は関数型プログラミングに慣れていないので、馴染みのあるクラス形態のAPIを提供しています。クラスコンポーネントの場合にも、renderメソッド自体をコンポーネントと考えるなら、コンポーネントが関数という概念は依然として有効です。
実際にReactのコンポーネントは、他のオブジェクト指向方式のコンポーネントとは異なる動作をします。オブジェクト間の直接参照やメソッドの呼び出しを通して相互にメッセージを送受信するものではなく、データの流れは関数の呼び出しのように、ただ親(呼び出す関数)から子(呼び出される関数)に向かって一方向に進行します。つまり、複雑なReactElementツリーを構成するために、コンポーネント内部で他のコンポーネントを関数のように呼び出し、結果値(ReactElement)を受け取った後、組み合わせて返すだけです。
実際に、これまで使われていたmixinに対する批判は、単にReactでの問題だけではありません。mixinを通じたコードの再利用の問題点は、Reactに言及せずとも、既存のオブジェクト指向の方式でも、存在していました。もちろん、オブジェクト指向でこれを解決する方法がないわけではありませんが、そもそも関数型の方式を目指すライブラリが既存のオブジェクト指向の問題まで抱えて解決しようとするのは、遠回りになるでしょう。それぞれに見合った服があるように、関数型の世界では、その世界に合った解決方法があります。
Higher Order Function
関数型プログラミングに慣れていれば、HOCがどのような役割をするか、すぐに推測できたでしょう。関数型プログラミングでは、Higher Order Function(以下、HOF)という非常に類似した概念があるためです。名前が示すようにHOCはHOFに由来します。したがって、HOCについて学ぶ前に、まずHOFについて調べてみよう。
HOFは関数を引数として受け取り、新しい関数を返します。
const fy = HOF(fx);
fxを引数として受け取り、fyを返却する関数であることが分かります。JavaやC#のように関数が第一級オブジェクトではない言語では、理解しにくい概念かもしれませんが、JavaScriptのように、関数が第一級オブジェクトである言語では、おなじみのパターンです。Lodashのような関数型ライブラリに慣れていれば、_.throttle、_.debounce、_.partial、_.flip、_.onceなどの関数もすべてHOFです。たとえば_.partial関数は、次のように既存の関数の引数を固定させた新しい関数を返却します。
const add = (v1, v2) => v1 + v2; const add3 = _.partial(add, 3); const add5 = _.partial(add, 5); console.log(add3(10)); // 13 console.log(add5(10)); // 15
HOFの利点は、関数に機能を追加するコードを再利用できるというものです。もし、add関数を使ってpartial関数なしで、add3とadd5関数を作成する場合、次のように直接2つの関数を作成する必要があります。
const add3 = v => add(v + 3); const add5 = v => add(v + 5);
しかし、HOFを利用すれば、機能単位で新しい関数を作り出すコードを再利用できます。
理解しやすいように、上記の_.partial関数を実装してみよう。簡単に実装するために、2つのうち最初の引数だけを固定して制限します。シンプルな形では、より直感的に矢印の関数を使って実装してみよう。
const partial = (f, v1) => v2 => f(v1, v2); const add3 = partial(add, 3); const add5 = partial(add, 5); console.log(add3(10)); // 13 console.log(add5(10)); // 15
上記_.partialの例と同様に動作することが分かります。
次に進む前に、さらに便利なHOFを作ってみよう。上述した関数のクロージャを使うと、内部状態を持つ関数を作成できます。簡単な例として、関数の戻り値を0から累積して適用された値を返すようにするHOFを作ってみよう。ここからはfunctionを使って説明します。
function acc(f) { let v = 0; return function() { v = f(v); return v; } } const acc3 = acc(add3); console.log(acc3()); // 3 console.log(acc3()); // 6 console.log(acc3()); // 9
HOFを使うと付随効果を作り出せます。簡単な例として、結果の値を返す前に、console.logコンソールにログを出力させるHOFを作ってみよう。今回も簡単に実装できるように、引数を1つ受け取る関数のみ使用して制限します。
function logger(f) { return function(v) { const result = f(v); console.log(result); return result; } } const add3Log = logger(add3); console.log(add3Log(10)); // 13, 13 console.log(add3Log(15)); // 18, 18
実行結果のログが2回ずつ表示されました。つまり内部的にログを残した後、結果の値も正常に返してくれます。
これまでに3つのHOFを作成しました。これらのHOFを一度に組み合わせて新しい関数を作ってみよう。partial、acc、loggerを使用すると、次のように0から始めて3ずつ加算した結果の値を累積させながらログを残す関数を作成できるでしょう。
const acc3Log = logger(acc(partial(add, 3))); acc3Log(); // 3 acc3Log(); // 6 acc3Log(); // 9
関数型プログラミングは、基本的に小さな単位の汎用的な関数を作成し、これらを組み合わせてプログラムを作る方式をとります。HOFは、これらの機能ユニットの関数を組み合わせて再利用できる1つのパターンを提供します。関数型プログラミングでは非常に重要な役割を果たしています。
Higher Order Component
前にも述べましたが、HOCはHOFから由来した単語です。コンポーネントを引数として受け取り、コンポーネントを返す関数を意味します。HOFの定義と同様に表現すると、次のようになります。
const compY = HOC(compX);
実のところHOCという名前には若干の弱みがあります。HOFが関数を引数として受け取り、関数を返す関数(文章に関数が3回入っていることに注目)であるなら、HOCはコンポーネントを引数として受け取り、コンポーネントを返すコンポーネントである、というのが自然でしょう。しかし実際にHOCはコンポーネントではなく、関数を指します。この名前に対して批判の声もありますが、HOFと類似した概念だということが強調される効果もあるので、寛大に受け止めてほしいです。
HOCはどのように使用できるでしょう。せっかくHOFから始まったので、前の例をそのままコンポーネントに適用してみよう。まずpartial関数のように、Propsを固定した形態のコンポーネントを返却させることができそうです。
function withProps(Comp, props) { return function(ownProps) { return <Comp {...props} {...ownProps} /> } }
partialの例と異なる点がいくつかありますが、まずコンポーネントは列挙された引数ではなく、propsというオブジェクトを入力値として受け取ります。特定のpropsを固定したい場合は、当該propsをオブジェクトとして受け取った後、返却されるコンポーネントのpropsに合わせるといった具合に実装する必要があります。もう1つの違いは、コンポーネントは単に値を返すのではなく、ReactElementを返すという点です。したがって返されるコンポーネントは、引数として渡されたコンポーネントをReactElement化させて(JSX利用)を返却しなければなりません。
withPropsHOCを使った例を見てみよう。
function Hello(props) { return <div>Hello, {props.name}. I am {props.myName}</div> } const HelloJohn = withProps(Hello, {name: 'John'}); const HelloMary = withProps(Hello, {name: 'Mary'}); const App = () => ( <div> <HelloJohn myName="Kim" /> <HelloMary myName="Lee" /> </div> )
Helloコンポーネントは、propsにnameとmyNameが入力されます。withPropsを使ってnameを固定した形態のコンポーネントであるHelloJohnとHelloMaryコンポーネントを作り出せば、これらのコンポーネントはmyNameさえ渡しても、あらかじめ固定されたname値を利用できるようになります。
上記のloggerもHOCに合わせて再実装してみよう。コンポーネントは、結果の値をログに残すことに大きな意味はないので、渡されたpropsをコンソールに出力するようにします。withPropsを作るときと同様にpropsと戻り値のみに注意すればよいでしょう。loggerはあえてpropsを受け取る必要がないので、さらに簡単に実装できます。
function logger(Comp) { return function(props) { console.log(props); return <Comp {...props} /> } }
HOFと同様にHOCも複数のHOCを同時に組み合わせて1つのコンポーネントを作成できます。上記の2つのHOCを組み合わせて使用してみよう。
const HelloJohn = withProps(logger(Hello), {name: 'John'}); const HelloMary = withProps(logger(Hello), {name: 'Mary'});
HelloJohnとHelloMaryはレンダリングのたびに、受け取ったpropsをコンソールに出力することになります。上記のAppコンポーネントをレンダリングしてみると、各コンポーネントのnameとmyNamepropsがすべてコンソールに出力されることを確認できるでしょう。
HOCでできること
ここまでHOCの概念を学ぶために簡単なHOCを作成してみましたが、このような単純な形のHOCだけでは有用性があまり感じられないでしょう。しかしHOCでできることは、これよりはるかに多様で、既存のミックス方式でできるほぼすべてを行うことができます。
最もよく使われる形態が、おそらくストアとコンポーネントを接続させるHOCでしょう。よく使われるreact-reduxのconnect関数も、このような役割をしますが、厳密にはHOCを生成するヘルパー関数と言えます。connect関数は、ストアの状態をpropsに注入するmapStateToProps
とアクションを作成する関数をストアのdispatchと連結させてpropsに注入するmapDispatchToProps
を引数として受け取り、新しいHOCを返します。
import {connect} from 'react-redux'; import {PersonComponent} from './person'; const mapStateToProps = state => ({ name: state.name, age: state.age }); const mapDispatchToProps = { setName: (name) => {type: 'SET_NAME', name}, setAge: (age) => {type: 'SET_AGE', age} }; // HOC生成 const connectHOC = connect(mapStateToProps, mapDispatchToProps); // HOCが適用されたコンポーネント生成 const ConnectedComponent = connectHOC(PersonComponent);
この他に、HOCでできる重要な機能は次のようなものがあります。
- ライフサイクルメソッド注入
- State及びイベントハンドラ注入
- Props変換と注入
- Render関数の拡張
ライフサイクルメソッドやStateを扱うには、関数、コンポーネントではなく、クラスのコンポーネントを利用すべきですが、次回これらを使ってさらに便利なHOCを作成します。関数コンポーネントだけで上記の機能を実装したい場合は、Recomposeライブラリを確認してください。Recomposeは自称ReactコンポーネントのLodashとして、汎用的に使われるHOCのコレクションと言えます。RecomposeのwithStateやlifecycle関数を使うと、関数のコンポーネントだけでStateやライフサイクルの関数を注入できます。その他にも有用なHOCヘルパーを提供しているので、APIに目を通すもだけでも、HOCがどのように使用できるか把握するのに役立つでしょう。
1部まとめ
HOCの基本的な概念と使い方について説明しました。通常は既存のmixinで実装したコードをどのようにHOCに変更するか説明するのだが、ここでは意図的に、関数型プログラミングの説明から始めてHOF、HOCの概念へと拡張しました。個人的にはReactが関数型プログラミングを目指していることを念頭に置き、さらに自然なコードを記述する方が役立つと思います。