はじめに
状態管理ライブラリであるReduxとUIライブラリであるReactをつなぐreact-redux、その中でも特にconnect関数はJavaScriptに慣れている人でも「何これ( ゚Д゚)」という書き方であり、やってることもかなり黒魔術的です。この記事ではそんなconnect関数の中身に踏み込みいつものように「明日使えるかもしれないプログラミング技術」を学ぶことを目的とします。
なお一記事で書こうと思ったのですが予想以上に長くなったので記事を分けます。
- 初めに表示されるときの動き(この記事)
- 状態が更新されるときの動き
記事中で参照・引用しているreact-reduxのバージョンは7.2.0です。
react-reduxのconnect関数
公式ドキュメントのQuick Startを見るとconnect関数の使い方は以下のようになっています。
export default connect(
mapStateToProps,
mapDispatchToProps
)(Counter)
connect(mapStateToProps, mapDispatchToProps)
で一度カッコが閉じられ、改めて(Counter)
となっています。connect(mapStateToProps, mapDispatchToProps, Counter)
ではありません。
何故このようなややこしい書き方になっているかについてはAPIリファレンスのconnect関数の戻り値に書かれているサンプルコードを見ると納得できます。
// first call: returns a hoc that you can use to wrap any component
const connectUser = connect(
mapState,
mapDispatch
)
// second call: returns the wrapper component with mergedProps
// you may use the hoc to enable different components to get the same behavior
const ConnectedUserLogin = connectUser(Login)
const ConnectedUserProfile = connectUser(Profile)
つまりconnect関数で作ったhocを使って異なるコンポーネントに対して同じようにstateとdispatchのマッピングをpropsとして設定することができるようです。
なるほど完全に理解した。
ところでhocって何。
Reactの方のドキュメントより。
Higher-Order Component(高階コンポーネント)
高階コンポーネントとは、あるコンポーネントを受け取って新規のコンポーネントを返すような関数です。
コンポーネントがpropsをUIに変換するのに対して、高階コンポーネントはコンポーネントを別のコンポーネントに変換します。
高階関数のように「コンポーネントを受け取るコンポーネント」といった感じでしょうか。ともかくconnect関数は「関数を返す関数」のようです。
connect関数
それではconnect関数の中身に踏み込んでいきましょう。
ルートディレクトリのindex.jsによるとconnect関数が定義されているのはconnect/connect.jsのようです。
export default /*#__PURE__*/ createConnect()
export function createConnect({
connectHOC = connectAdvanced,
mapStateToPropsFactories = defaultMapStateToPropsFactories,
mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
mergePropsFactories = defaultMergePropsFactories,
selectorFactory = defaultSelectorFactory
} = {}) {
return function connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
{
// 省略
} = {}
) {
// 省略
return connectHOC(selectorFactory, {
// 省略
})
}
}
初見殺しにもほどがあります_:(´ཀ`」 ∠):_
整理しましょう。
- connect関数はcreateConnect関数により作られる(返される)
- 作られるconnect関数はconnectHOC呼び出し(デフォルトはconnectAdvanced)結果を返す
connectAdvanced
connect関数が呼ばれたときの動作を詳しく見ていく前にまずは概要をつかみましょう。というわけでデフォルトのconnectHOCであるconnectAdvancedを見てみます。こちらはcomponents/connectAdvanced.jsに書かれています。
export default function connectAdvanced(
selectorFactory,
{
// 省略
} = {}
) {
// 省略
return function wrapWithConnect(WrappedComponent) {
// 省略
function ConnectFunction(props) {
// 省略
const renderedWrappedComponent = useMemo(
() => <WrappedComponent {...actualChildProps} ref={forwardedRef} />,
[forwardedRef, WrappedComponent, actualChildProps]
)
const renderedChild = useMemo(() => {
if (shouldHandleStateChanges) {
return (
<ContextToUse.Provider value={overriddenContextValue}>
{renderedWrappedComponent}
</ContextToUse.Provider>
)
}
return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])
return renderedChild
]
const Connect = pure ? React.memo(ConnectFunction) : ConnectFunction
// 省略
return hoistStatics(Connect, WrappedComponent)
}
}
関数内関数内関数で頭が痛くなりそうですが整理します。
- connectAdvancedはwrapWithConnect関数1を返す。これはconnect関数呼び出しの結果できるHOCである。
- wrapWithConnect関数は内部でConnectFunction関数を定義している。これはやっていることを見ると(JSXでレンダリングするまでの処理は長いが)普通のコンポーネントのようだ。
- つまり、HOC内で、HOCの引数(connectしたいコンポーネント)をレンダリングするコンポーネントを作って返している。これにより、元の(connectされていない)コンポーネントにmapStateToProps、mapDispatchToPropsで定義されたpropsが渡されるようになる。
ここまでがconnect関数およびconnectAdvancedコンポーネントの概要です。では詳細に踏み込みましょう。
・・・、connect関数を作るcreateConnectを先頭から読んでみるといきなりselectorだとかproxyだとか出てくるので「どう動くか(どう呼び出されるのか)」がよくわかりません。そこで、先頭から読むのではなく実際にコンポーネントのレンダリングを行っているConnectFunction関数から見ていくことにしましょう。
ConnectFunction関数
useMemo(メモ化)の利用
ConnectFunction関数を見ると冒頭のコードは以下のようになっています。
function ConnectFunction(props) {
const [propsContext, forwardedRef, wrapperProps] = useMemo(() => {
// Distinguish between actual "data" props that were passed to the wrapper component,
// and values needed to control behavior (forwarded refs, alternate context instances).
// To maintain the wrapperProps object reference, memoize this destructuring.
const { forwardedRef, ...wrapperProps } = props
return [props.context, forwardedRef, wrapperProps]
}, [props])
useMemoはReactが提供する関数で「第2引数の配列要素のいずれかが変化した場合のみ第1引数の処理(計算)をやり直す」というもののようです。
このような処理をメモ化と言います。ある処理(計算)がある引数のみに依存している場合(ここ超重要です)、引数の値が変わらなければ結果は変わりません。つまり、再計算を行うのは無駄になります。これを避けるのがメモ化と呼ばれるテクニックです。文章が重複してしまいますが、「引数が変わらないのであれば前に計算した値を使う。変わったのであれば再計算を行う」ということが行われます。個人的にはメモ化してる内容がそんなに重い処理には思いませんが。
ともかくこのような最適化の取り組みが入っていると読みにくいので逆最適化すると以下のようになります。
なお単にコピペしただけでこのコードは実際には動かないのであしからず(同じ名前の定数2回宣言してるので文法エラーになります)
const { forwardedRef, ...wrapperProps } = props
const [propsContext, forwardedRef, wrapperProps] = [props.context, forwardedRef, wrapperProps]
Context
ConnectFunction関数では上記のようにメモ化を使いつつ徐々にコンポーネント(ReduxのStateを使ってレンダリングするコンポーネント)のためのデータを用意しています。
その中でまず大事なのはContextです。Contextはpropsでも指定できるようですがまあ普通はその名の通り「コンテキスト(現在の文脈)」を使うでしょう。つまり三項演算子のelseの方のContext
が使われます。
const ContextToUse =
propsContext && propsContext.Consumer && isContextConsumer(<propsContext.Consumer />)
? propsContext
: Context
このContext
が何者なのかを調べるためにConnectFunction関数の外側にさかのぼります。すると以下のコードが見つかります。
つまり特に指定をしなければReactReduxContextが使われます。
export default function connectAdvanced(
selectorFactory,
// options object:
{
// 省略
// the context consumer to use
context = ReactReduxContext,
} = {}
) {
// 省略
const Context = context
ReactReduxContextはcomponents/Context.jsで定義されています。
export const ReactReduxContext = /*#__PURE__*/ React.createContext(null)
上記のようにコンテキストはReactの機能です。ドキュメントはコンテクストになってるな。コンテクストの方が一般的?まあこの記事ではコンテキストで行きます。
Reactのドキュメントにあるようにデータはprops(引数)で渡すべきです。グローバル変数駄目絶対です。
しかし原則はわかるが階層が深くなると「書くのがめんどくさい」「間のコンポーネントで渡し忘れる」「途中に自作でないコンポーネントが挟まれててデータを流してくれない」などなど様々な問題があります。
これを解決してくれるのがコンテキストです。コンテキストは「グローバル変数のようなもの」ですが、大きく異なる点として「特定の状況のみでグローバル」となります。「特定の状況」というものが具体的になんなのかはそれこそ「場合(どのようなところで使われるプログラムなのか)による」ということになりますが2、Reactについていえば「あるコンポーネントの子孫」となります。
Providerコンポーネント
ところでreact-reduxを使う場合はProviderコンポーネントでStoreを指定するのがお決まりです。
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
rootElement
)
予測はつくと思いますがProviderコンポーネントではコンテキストを使って子孫のコンポーネントにStoreを受け渡すようになっています。subscriptionについては更新時処理編で見ていきます。
function Provider({ store, context, children }) {
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription
}
}, [store])
// 省略
const Context = context || ReactReduxContext
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
Selector
話をConneectFunction関数に戻しましょう。上記のようにコンテキストに設定されたStoreが取り出されます。実際にはStoreがpropsで渡されてるか確認されていますがさくっと省略。
const contextValue = useContext(ContextToUse)
const store = contextValue.store
次にStoreを使ってSelectorが作られています。はい出てきました謎の単語Selector。
const childPropsSelector = useMemo(() => {
// The child props selector needs the store reference as an input.
// Re-create this selector whenever the store changes.
return createChildSelector(store)
}, [store])
createChildSelectorはConnectFunction関数の親(関数内関数を定義している関数)のwrapWithConnect関数で定義されています。
wrapWithConnect関数とはconnect関数が返す関数の実体でした(関数と書きすぎてややこしい)
return function wrapWithConnect(WrappedComponent) {
// 省略
const selectorFactoryOptions = {
...connectOptions,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
displayName,
wrappedComponentName,
WrappedComponent
}
function createChildSelector(store) {
return selectorFactory(store.dispatch, selectorFactoryOptions)
}
selectorFactory
コードを見ていく前にとりあえずファクトリというものについての一般知識を。
「あるインターフェース」に対して複数の実装がある場合、「インターフェースを実装するオブジェクトの作成処理」を分けておけば(作成を行うオブジェクトを用意しておけば)実装を切り替えやすくなります。これがファクトリと呼ばれるパターンです。
これからいろいろなファクトリが出てきますが、react-reduxの場合、「渡された引数に対して呼び出し側が想定するオブジェクトを返す関数」をファクトリを使って作成しています。
さてselectorFactoryとして渡される関数はconnect/selectorFactory.jsに定義されていますが、はっきり言って難解です。
export default function finalPropsSelectorFactory(
dispatch,
{ initMapStateToProps, initMapDispatchToProps, initMergeProps, ...options }
) {
const mapStateToProps = initMapStateToProps(dispatch, options)
const mapDispatchToProps = initMapDispatchToProps(dispatch, options)
const mergeProps = initMergeProps(dispatch, options)
const selectorFactory = options.pure
? pureFinalPropsSelectorFactory
: impureFinalPropsSelectorFactory
return selectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
options
)
}
雰囲気はわかるものの、とりあえずここまでで引数がどう渡されてきたのかを振り返ってみましょう。
- createConnect関数
connect関数に渡されるmapStateToProps等を使ってinitMapStateToProps等を定義。connectAdvancedにオブジェクトとして渡す3。 - connectAdvanced関数
initMapStateToProps等は分割代入のその他(レストパラメータ)であるconnectOptionsに格納される。それがそのままselectorFactoryOptionsに渡される。 - finalPropsSelectorFactory関数
分割代入を使って取り出す。
このような形でfinalPropsSelectorFactoryにmapStateToProps等が渡されてきます。自分が使うパラメータのみ取り出し、残りはその他大勢として下位の関数に渡すというのは便利な反面、「なんか取り出してるけど、これどこで設定されたものだっけ」ということにもなるなと思いました。
initMapStateToProps
次にinitMapStateToPropsについて見ていきましょう。今まで読み飛ばしていた部分に目を向ける必要があります。
export function createConnect({
connectHOC = connectAdvanced,
mapStateToPropsFactories = defaultMapStateToPropsFactories,
mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
mergePropsFactories = defaultMergePropsFactories,
selectorFactory = defaultSelectorFactory
} = {}) {
return function connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
{
// 省略
} = {}
) {
const initMapStateToProps = match(
mapStateToProps,
mapStateToPropsFactories,
'mapStateToProps'
)
// 省略
}
}
function match(arg, factories, name) {
for (let i = factories.length - 1; i >= 0; i--) {
const result = factories[i](arg)
if (result) return result
}
return (dispatch, options) => {
throw new Error(
`Invalid value of type ${typeof arg} for ${name} argument when connecting component ${
options.wrappedComponentName
}.`
)
}
}
defaultMapStateToPropsFactoriesはconnect/mapStateToProps.jsに定義されています。mapDispatchToProps.jsの方がもう少しおもしろいのですが「connect関数に渡された引数をチェックして後の処理のところで場合分けしなくて済むように適切な関数」が設定されるようになっています。
export function whenMapStateToPropsIsFunction(mapStateToProps) {
return typeof mapStateToProps === 'function'
? wrapMapToPropsFunc(mapStateToProps, 'mapStateToProps')
: undefined
}
export function whenMapStateToPropsIsMissing(mapStateToProps) {
return !mapStateToProps ? wrapMapToPropsConstant(() => ({})) : undefined
}
export default [whenMapStateToPropsIsFunction, whenMapStateToPropsIsMissing]
さて、wrapMapToPropsFuncはconnect/wrapMapToProps.jsに書かれています。
export function wrapMapToPropsFunc(mapToProps, methodName) {
return function initProxySelector(dispatch, { displayName }) {
const proxy = function mapToPropsProxy(stateOrDispatch, ownProps) {
return proxy.dependsOnOwnProps
? proxy.mapToProps(stateOrDispatch, ownProps)
: proxy.mapToProps(stateOrDispatch)
}
// allow detectFactoryAndVerify to get ownProps
proxy.dependsOnOwnProps = true
proxy.mapToProps = function detectFactoryAndVerify(
stateOrDispatch,
ownProps
) {
proxy.mapToProps = mapToProps
proxy.dependsOnOwnProps = getDependsOnOwnProps(mapToProps)
let props = proxy(stateOrDispatch, ownProps)
// 省略
return props
}
return proxy
}
}
何これ(。´・ω・)?
このような関数内関数内関数に出くわしたときはconnectAdvanced概観のときにも示したように各関数がいつ呼ばれるのか整理しましょう。
- wrapMapToPropsFunc
connect関数の初めに呼び出される。initProxySelectorを返す(これがinitMapStateToPropsに代入される) - initProxySelector
finalPropsSelectorFactory関数の初めに呼び出される。mapToPropsProxy関数を返す(これがfinalPropsSelectorFactory関数でのmapStateToPropsに代入される) - mapToPropsProxy
今まで見てきたところではまだ呼ばれていない。
というわけでmapToPropsProxy関数はまだ呼ばれていませんがmapStateToPropsに代入されることを考えるとconnect関数に自分が渡したmapStateToPropsを呼び出すこととほぼ同じような振る舞いをすると推測されます。ただしこのproxyがどう動いているのか、言語的な意味で難解です。
const proxy = function mapToPropsProxy(stateOrDispatch, ownProps) {
return proxy.dependsOnOwnProps
? proxy.mapToProps(stateOrDispatch, ownProps)
: proxy.mapToProps(stateOrDispatch)
}
// allow detectFactoryAndVerify to get ownProps
proxy.dependsOnOwnProps = true
proxy.mapToProps = function detectFactoryAndVerify(
stateOrDispatch,
ownProps
) {
proxy.mapToProps = mapToProps
proxy.dependsOnOwnProps = getDependsOnOwnProps(mapToProps)
let props = proxy(stateOrDispatch, ownProps)
// 省略
return props
}
特にここ
proxy.mapToProps = function detectFactoryAndVerify(
stateOrDispatch,
ownProps
) {
proxy.mapToProps = mapToProps
proxy.dependsOnOwnProps = getDependsOnOwnProps(mapToProps)
let props = proxy(stateOrDispatch, ownProps)
答えとしては、このproxyは以下のように動きます。
- mapToPropsProxyが呼び出される。
- mapToPropsProxyのmapToPropsプロパティとして設定されているdetectFactoryAndVerifyが呼び出される。
- mapToPropsProxyのmapToPropsプロパティが自分が渡したmapStateToPropsに置き換えられる。
- mapToPropsProxyが呼び出される。
- mapToPropsProxyのmapToPropsプロパティが呼び出されるが今度は自分が渡したmapStateToPropsが呼び出される。
2回目以降は2~4の動作はなく自分が渡したmapStateToPropsが直接呼び出されます。
何故このようなややこしいことをしているかと言うと省略しているところで追加の処理をしているからなわけですが(これがproxyが存在する主な理由)、その処理が行われるのは一般的な(基本レベルでの)使い方ではないので初めに見るときはさくっと読み飛ばしてしまうのがいいと思います。また整理のところで書いたように「代入される変数名からするとこれはこういう動作をするのだろう」と推測するのもいいと思います。
initMapStateToPropsの中身を見ていくのがだいぶ長くなってしまいましたが、finalPropsSelectorFactory関数に戻るとpureかどうか(デフォルトはtrue)に応じてfactoryを切り替え、selectorを作成しています。selectorとはまたしても関数です。
というわけでselectorの中身に踏み込むのは呼ばれるところでやることにしてConnectFunction関数に戻りましょう。
actualChildPropsの作成
selectorを作成後、subscriptionの設定が行われていますがsubscriptionについては更新時処理編後で見るので読み飛ばします。
とすると結局以下のactualChildPropsを作っているところまで進みます。usePureOnlyMemoはpureがtrueの場合はuseMemoと同様です(falseの場合は常に第1引数の関数が実行されます)
const actualChildProps = usePureOnlyMemo(() => {
return childPropsSelector(store.getState(), wrapperProps)
}, [store, previousStateUpdateResult, wrapperProps])
childPropsSelectorの実体はpureFinalPropsSelectorFactory関数が返す関数です(キーワード引数のpureがtrueの場合)。pureの方が処理がシンプルなのかと思ったらimpureの方がわかりやすいですね。というわけでimpureFinalPropsSelectorFactoryの方を見てみます。
export function impureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch
) {
return function impureFinalPropsSelector(state, ownProps) {
return mergeProps(
mapStateToProps(state, ownProps),
mapDispatchToProps(dispatch, ownProps),
ownProps
)
}
}
やっていることは簡単です。mapStateToPropsを呼び出して、mapDispatchToPropsを呼び出して、結果をマージしたpropsを返す。当たり前と言えば当たり前の操作が行われています。
pureFinalPropsSelectorFactoryはどうなっているかと言うと例に寄って初見ではどう動くのかわかりにくいのですが落ち着いて見ていけば読み解けます。ちょっと順番を入れ替えて示すと以下のようになります。
export function pureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
// ↓これらはconnectから渡されてきたもの
{ areStatesEqual, areOwnPropsEqual, areStatePropsEqual }
) {
// mapStateToProps等の呼び出し結果を保存しておくための変数
let hasRunAtLeastOnce = false
let state
let ownProps
let stateProps
let dispatchProps
let mergedProps
// 一回目に呼ばれる。mapStateToProps等の呼び出し結果をキャッシュしておく
function handleFirstCall(firstState, firstOwnProps) {
state = firstState
ownProps = firstOwnProps
stateProps = mapStateToProps(state, ownProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
hasRunAtLeastOnce = true
return mergedProps
}
// 二回目以降に呼ばれる。変更がなければmapStateToProps等の呼び出しは行わない
function handleSubsequentCalls(nextState, nextOwnProps) {
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}
// Stateに変更があった場合に呼び出される
// mapStateToPropsが返すオブジェクトが変わらなければマージし直さない
// ただしオブジェクト比較はデフォルトではシャロ―比較
function handleNewState() {
const nextStateProps = mapStateToProps(state, ownProps)
const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps)
stateProps = nextStateProps
if (statePropsChanged)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
// これがchildPropsSelectorとして呼び出される関数
return function pureFinalPropsSelector(nextState, nextOwnProps) {
return hasRunAtLeastOnce
? handleSubsequentCalls(nextState, nextOwnProps)
: handleFirstCall(nextState, nextOwnProps)
}
}
つまり、pureである(コンポーネントに指定されているpropsもしくはReduxのStateのみに依存する)場合は可能な限り余計な処理は行わないようになっています。
コンポーネントの描画
actualChildProps作成後、Store更新に対するsubscriptionの設定がされていますが読み飛ばします。とするとConnectFunction関数の残りは以下となります。
// Now that all that's done, we can finally try to actually render the child component.
// We memoize the elements for the rendered child component as an optimization.
const renderedWrappedComponent = useMemo(
() => <WrappedComponent {...actualChildProps} ref={forwardedRef} />,
[forwardedRef, WrappedComponent, actualChildProps]
)
// If React sees the exact same element reference as last time, it bails out of re-rendering
// that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate.
const renderedChild = useMemo(() => {
if (shouldHandleStateChanges) {
// If this component is subscribed to store updates, we need to pass its own
// subscription instance down to our descendants. That means rendering the same
// Context instance, and putting a different value into the context.
return (
<ContextToUse.Provider value={overriddenContextValue}>
{renderedWrappedComponent}
</ContextToUse.Provider>
)
}
return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])
return renderedChild
初めに確認したJSXにたどり着きました!これにて長かったConnectFunction関数は終了です!
なおshouldHandleStateChangesはmapStateToPropsが渡されてたらtrue、渡されてなかったらfalseです。
ここまでのまとめ
まず言語によらないプログラミング技術としては以下のものがありました。4
- メモ化
- 時間のかかる処理について、引数が変わらなければ結果は変わらないとして処理結果をキャッシュする手法
- コンテキスト
- 「ある状況」においてグローバルな変数を定義する手法(引数を渡していく手間が省ける)
- ファクトリ
- 「あるインターフェース」に対する実装を作成するオブジェクトを用意し実装を切り替えやすくする手法
JavaScript的なプログラミング技術としてはキーワード引数、特に分割代入との組み合わせて渡された値を受け取るということが行われていました。
コードリーディングの観点では以下のような事項がありました。
- 関数内関数や関数内関数内関数について見る際にそれらがいつ呼ばれるのか整理する
- やけにややこしいことをしているところはあまり気にしない。「こう動くのだろう」という推測も大事
- 同じインターフェースの複雑な処理と簡単な処理があったらまず簡単な処理の方を見てインターフェースに対する理解を深めてから複雑な処理に挑む
react-reduxを読んでみようとしたきっかけとして、クロージャがどういう場合に使われるかの実例紹介という目的があったのですがこれは実例として紹介するのはやめておいた方がいいですね(笑)
さて、更新時処理に続きます。