はじめに
前回はconnect関数が返すConnectFunctionのうち、初めに表示される際の処理について見てきました。今回はReduxのStateが更新される際にどのような動作が行われ、表示が更新されるのかについて見ていきましょう。
Reduxの更新処理
Reduxについてはご存知という前提ですが簡単に確認しましょう。Reduxには以下の要素があります。
- Store
Stateを保持するオブジェクト。 - Action
Storeに対する変更要求。Storeのdispatch
メソッドを使って送信する。 - Reducer
現在のStateとActionから次のStateを作成する関数。この記事ではあまり出てこない。
またStoreにはsubscribeメソッドがあり、Actionがdispatchされたら呼び出されるコールバックを登録することができます。このsubscribeが今回の鍵となります。
Provider
前回も少し見たProviderコンポーネントから始めましょう。
function Provider({ store, context, children }) {
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription
}
}, [store])
const previousState = useMemo(() => store.getState(), [store])
useEffect(() => {
const { subscription } = contextValue
subscription.trySubscribe()
if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
return () => {
subscription.tryUnsubscribe()
subscription.onStateChange = null
}
}, [contextValue, previousState])
まずSubscriptionオブジェクトが作られています。この中身はこの後で見ていきます。
その次のuseEffectはまたよくわからない書き方です。このuseEffectはuseMemoと同じようにReactが提供する関数ですが、少し毛色が異なります。useEffectは副作用のある処理を行いたい場合に使うようです。ドキュメントにあるように「データの購読(英語ページだとsubscriptions)」は副作用があるためuseEffectを使う必要があるようです。
useEffectを含めたuseシリーズはReact 16.8で導入されたフックAPIです。フックは「クラスを使わずに関数で、クラスを定義して行っていたState管理(ReduxのではなくReact本体のstate)等を実装する機能」です。
その中でuseEffectはクラス定義のコンポーネントで言うcomponentDidMountとcomponentWillUnmount に相当するものだそうです。「useEffectに渡す関数」がcomponentDidMount、「useEffectに渡す関数がreturnする関数」がcomponentWillUnmount。
「購読の開始と解除を並べて書けるからいいでしょ」とドキュメントに書かれてますが、個人的にはインデントレベルが変わるのが微妙…(それとuseEffectのことを知らない人が見たときに理解するのに時間がかかる)という気がします。
Subscription
さて、Subscriptionに移ります。utils/Subscription.jsに定義されています。
export default class Subscription {
constructor(store, parentSub) {
this.store = store
this.parentSub = parentSub
this.unsubscribe = null
this.listeners = nullListeners
this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
}
trySubscribe() {
if (!this.unsubscribe) {
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: this.store.subscribe(this.handleChangeWrapper)
this.listeners = createListenerCollection()
}
}
handleChangeWrapper() {
if (this.onStateChange) {
this.onStateChange()
}
}
notifyNestedSubs() {
this.listeners.notify()
}
Providerコンポーネントで作られるSubscriptionオブジェクトはparentSubを渡していないのでStoreに対してsubscribeが行われます。Storeからコールバック(handleChangeWrapperメソッド)が呼ばれるとonStateChangeが呼ばれます。今の場合onStateChangeに設定されているのはnotifyNestedSubsです。自身のlistenerに対して変更があったことを伝えるという一般的なPub/Subモデルですね。
ここでnotifyされる対象は誰なのか、その後どう動くのか、ということについて調べるために、ConnectFunction関数の前回読み飛ばした部分に進みましょう。
ConnectFunction再び
ConnectFunctionではchildPropsSelectorを作った後に以下のコードがあります。githubでの表示はこちら。
const [subscription, notifyNestedSubs] = useMemo(() => {
if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY
// This Subscription's source should match where store came from: props vs. context. A component
// connected to the store via props shouldn't use subscription from context, or vice versa.
const subscription = new Subscription(
store,
didStoreComeFromProps ? null : contextValue.subscription
)
// `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in
// the middle of the notification loop, where `subscription` will then be null. This can
// probably be avoided if Subscription's listeners logic is changed to not call listeners
// that have been unsubscribed in the middle of the notification loop.
const notifyNestedSubs = subscription.notifyNestedSubs.bind(
subscription
)
return [subscription, notifyNestedSubs]
}, [store, didStoreComeFromProps, contextValue])
Subscriptionオブジェクトが作られていますが今度は第2引数、つまりparentSubが渡されています(正確にはコンテキストのStoreを使う場合は、ということになりますが通常はコンテキストを使うでしょう)
このsubscriptionがどこで使われているか見ていくと以下のコードがあります。少し読み飛ばしており「この変数何?」というものがいますがそこについては後から説明します。
// Our re-subscribe logic only runs when the store/subscription setup changes
useIsomorphicLayoutEffectWithArgs(
subscribeUpdates,
[
shouldHandleStateChanges,
store,
subscription,
childPropsSelector,
lastWrapperProps,
lastChildProps,
renderIsScheduled,
childPropsFromStoreUpdate,
notifyNestedSubs,
forceComponentUpdateDispatch
],
[store, subscription, childPropsSelector]
)
useIsomorphicLayoutEffectWithArgsはconnectAdvanced.jsの上の方に定義されています。ブラウザでの実行かサーバサイドレンダリング(SSR)かで呼ぶ関数を切り替えるということが行われていますがまあ結局useEffect、つまりrenderした後に実行される関数を登録しているという点ではあまり違いはありません。ということでsubscribeUpdatesに進みます。
subscribeUpdates
subscribeUpdatesはconnectAdvanced.jsの上の方に定義されていますがこれもまた難解です。
function subscribeUpdates(
// 省略。上の配列に入ってるものが渡されてきます
) {
// 省略
// We'll run this callback every time a store subscription update propagates to this component
const checkForUpdates = () => {
// 後で見ます
}
// Actually subscribe to the nearest connected ancestor (or store)
subscription.onStateChange = checkForUpdates
subscription.trySubscribe()
// 省略
}
上から読んでいくとややこしくなるのでまた先に構造を眺めてみました。今度はonStateChangeとしてsubscribeUpdates内に定義されているcheckForUpdatesが設定されています。
その後にtrySubscribeメソッド呼び出し。今度はparentSubがtruthy1なのでparentSubのaddNestedSubが実行されます。addNestedSubの先は淡々と頑張ってるだけなので省略します。
trySubscribe() {
if (!this.unsubscribe) {
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: this.store.subscribe(this.handleChangeWrapper)
this.listeners = createListenerCollection()
}
}
以上のことからStoreにActionがdispatchされると次のように動作することがわかりました。
- Storeにdispatch
- Providerで作ったsubscriptionが呼び出される
- ConnectFunctionで作ったsubscriptionが呼び出される2
checkForUpdates
それではcheckForUpdate関数を見てみましょう。
// We'll run this callback every time a store subscription update propagates to this component
const checkForUpdates = () => {
if (didUnsubscribe) {
// Don't run stale listeners.
// Redux doesn't guarantee unsubscriptions happen until next dispatch.
return
}
const latestStoreState = store.getState()
let newChildProps, error
try {
// Actually run the selector with the most recent store state and wrapper props
// to determine what the child props should be
newChildProps = childPropsSelector(
latestStoreState,
lastWrapperProps.current
)
} catch (e) {
error = e
lastThrownError = e
}
if (!error) {
lastThrownError = null
}
// If the child props haven't changed, nothing to do here - cascade the subscription update
if (newChildProps === lastChildProps.current) {
if (!renderIsScheduled.current) {
notifyNestedSubs()
}
} else {
// Save references to the new child props. Note that we track the "child props from store update"
// as a ref instead of a useState/useReducer because we need a way to determine if that value has
// been processed. If this went into useState/useReducer, we couldn't clear out the value without
// forcing another re-render, which we don't want.
lastChildProps.current = newChildProps
childPropsFromStoreUpdate.current = newChildProps
renderIsScheduled.current = true
// If the child props _did_ change (or we caught an error), this wrapper component needs to re-render
forceComponentUpdateDispatch({
type: 'STORE_UPDATED',
payload: {
error
}
})
}
}
ちょっと長いですが略すところがないので。2行にまとめると以下のようになります。
- StoreからStateを取得してSelectorを実行
- propsに変化があるようならforceComponentUpdateDispatchを実行
次の話題はforceComponentUpdateDispatch(とchildPropsFromStoreUpdateとか)とは何者なのかです。ここで飛ばした部分が出てきます。
三度ConnectFunction - useRefとuseReducer
先にchildPropsFromStoreUpdateから。ConnectFunctionに戻るとこれらは以下のように定義されています。
// Set up refs to coordinate values between the subscription effect and the render logic
const lastChildProps = useRef()
const lastWrapperProps = useRef(wrapperProps)
const childPropsFromStoreUpdate = useRef()
const renderIsScheduled = useRef(false)
useと言ったらReactフック、ということでuseRefもご多分に漏れずReactが提供する関数です。意味合いとしてはクラスにおけるインスタンス変数みたいな機能を提供するもののようです。
forceComponentUpdateDispatchはもう少し上で定義されています。
// We need to force this wrapper component to re-render whenever a Redux store update
// causes a change to the calculated child component props (or we caught an error in mapState)
const [
[previousStateUpdateResult],
forceComponentUpdateDispatch
] = useReducer(storeStateUpdatesReducer, EMPTY_ARRAY, initStateUpdates)
useReducerは少し複雑です。ドキュメントにあるように動作としてはReduxのReducerと同じような感じです。
大事なのは、戻り値の二つ目で返されているdispatch(上のコードではforceComponentUpdateDispatchに代入されている)です。ドキュメントには明言されていませんが、dispatchを呼び出すことによりレンダリングのやり直しが行われるようになっています。
diapatchの先については、いわゆる「本書の範囲を超える」内容、React内部の話となるのでreact-redux読解はこれにて終了となります。
更新時処理のまとめとあとがき
以上、更新時の処理を見てきました。Storeのsubscribeを使い、Reactのフックを駆使し、まさに「間をつなげる」にふさわしい処理が行われていました。プログラミング技術と言うか、「ReactのフックAPIはこう使え!」の見本みたいな感じでしたね。
ちなみに、Reactにフックが導入されたのは本文中にも書いたように16.8、2019/2/6です。当時Twitterで「クラス定義コンポーネントよさようなら」みたいなことを言ってる人を見た気がしてなんのこっちゃと思ってたのですが3こういうことだったんですね。まあでもフックは難しいので初心者はクラスから入るべきだと思います。
react-reduxもReactにフックが入ったことで書き直されたものがv7だということは更新時処理を本格的に眺め始めてから気づきました(ところでReact 16.8より前はどう実装されてたの?と)。いろいろな縁で(?)非常にJavaScriptらしい関数使いまくりなコードに巡り合えた気がします。