前置き
react-redux は接着剤のようなもので、深く理解する必要はないように思えますが、実際には、データ層(redux)と UI 層(react)の接続点として、その実装の詳細は全体のパフォーマンスに決定的な影響を与えます。コンポーネントツリーがむやみに update するコストは、reducer ツリーを数回多く実行するコストよりもはるかに高いため、その実装詳細を理解する必要があります
react-redux を詳しく理解する利点の 1 つは、パフォーマンスについて基本的な認識を持てることです。次の問題を考慮してください:
dispatch({type: 'UPDATE_MY_DATA', payload: myData})
コンポーネントツリーのどこかの隅にあるこのコードがもたらすパフォーマンスへの影響は何でしょうか?いくつかのサブ問題:
-
1.どの reducer が再計算されましたか?
-
2.引き起こされたビュー更新はどのコンポーネントから始まりますか?
-
3.どのコンポーネントの render が呼び出されましたか?
-
4.すべてのリーフコンポーネントが diff の影響を受けましたか?なぜですか?
これらの問題に正確に答えられない場合、パフォーマンスについて確信が持てないでしょう
一.役割
まず、redux は単なるデータ層であり、react は単なる UI 層であることを明確にします。両者の間には関連がありません
左右の手でそれぞれ redux と react を持っている場合、実際の状況は以下のようになります:
-
redux はデータ構造(state)と各フィールドの計算方法(reducer)をすべて決定します
-
react はビューの説明(Component)に基づいて初期ページをレンダリングします
このような状態かもしれません:
redux | react
myUniversalState | myGreatUI
human | noOneIsHere
soldier |
arm |
littleGirl |
toy |
ape | noOneIsHere
hoho |
tree | someTrees
mountain | someMountains
snow | flyingSnow
左側の redux にはすべてありますが、react は知らず、デフォルト要素のみを表示します(データがありません)。一部のコンポーネントにはローカル state と散在する props の受け渡しがあり、ページは 1 フレームの静的な図のようであり、コンポーネントツリーはいくつかのパイプで接続された大きな枠組みのように見えます
ここで react-redux を追加することを検討すると、このようになります:
react-redux
redux -+- react
myUniversalState | myGreatUI
HumanContainer
human -+- humans
soldier | soldiers
ArmContainer
arm -+- arm
littleGirl | littleGirl
toy | toy
ApeContainer
ape -+- apes
hoho | hoho
SceneContainer
tree -+- Scene
mountain | someTrees
snow | someMountains
flyingSnow
注意:Arm のインタラクションは複雑で、上位(HumanContainer)による制御に適さないため、ネストされた Container が現れています
Container は redux が持つ state を react に渡し、これで初期データが得られます。では、ビューを更新するにはどうすればよいでしょうか?
Arm.dispatch({type: 'FIRST_BLOOD', payload: warData})
誰かが最初の銃声を鳴らし、soldier が 1 人倒れた(state change)ため、これらの部分に変化が生じます:
react-redux
redux -+- react
myNewUniversalState | myUpdatedGreatUI
HumanContainer
human -+- humans
soldier | soldiers
| diedSoldier
ArmContainer
arm -+- arm
| inactiveArm
ページ上に倒れた soldier と地面に落ちた arm が現れます(update view)。他の部分(ape, scene)はすべて正常です
上記が react-redux の役割です:
-
state を redux から react に渡す
-
redux state change 後に react view を update する責任を負う
推測するに、実装は 3 つの部分に分かれます:
-
パイプで接続された大きな枠組みに小さな水源を一つずつ追加(Container を通じて state を props として下の view に注入)
-
小さな水源に水を噴出させる(state change を監視し、Container の setState を通じて下の view を更新)
-
小さな水源にむやみに噴出させない(パフォーマンス最適化を内蔵し、キャッシュされた state、props を比較して更新の必要性を確認)
二.重要な実装
ソースコードの重要な部分は以下の通りです:
// from: src/components/connectAdvanced/Connect.onStateChange
onStateChange() {
// state change 時に props を再計算
this.selector.run(this.props)
// 現在のコンポーネントが更新不要の場合、下の container に更新チェックを通知
// 更新が必要な場合、setState で空オブジェクトを強制更新し、通知を didUpdate に延期
if (!this.selector.shouldComponentUpdate) {
this.notifyNestedSubs()
} else {
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
// Container の下の view に更新を通知
//!!! ここが redux と react を接続する鍵
this.setState(dummyState)
}
}
最も重要なsetStateがここにあります。dispatch action 後のビュー更新の秘密は以下の通りです:
1.dispatch action
2.redux が reducer を計算して newState を取得
3.redux が state change をトリガー(store.subscribe で登録された state 変化リスナーを呼び出す)
4.react-redux のトップレベル Container の onStateChange がトリガー
1.props を再計算
2.新しい値とキャッシュ値を比較し、props が変化したか、更新が必要かを確認
3.必要な場合、setState({}) で react に強制更新
4.下の subscription に通知し、state change を監視する下の Container の onStateChange をトリガーして、view の更新が必要かを確認
ステップ 3 で、react-redux が redux に store change リスナーを登録する動作はconnect()(myComponent)時に発生します。実際には react-redux はトップレベル Container だけが redux の state change を直接監視し、下の Container は内部で通知を渡しています。以下の通り:
// from: src/utils/Subscription/Subscription.trySubscribe
trySubscribe() {
if (!this.unsubscribe) {
// 親レベルのオブザーバーがない場合、store change を直接監視
// ある場合、親の下に追加し、親から変化を渡す
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.onStateChange)
: this.store.subscribe(this.onStateChange)
}
}
ここで redux の state change を直接監視せず、Container の state change listener を自分で維持するのは、順序を制御可能にするためです。例えば上記で言及した:
// 更新が必要な場合、通知を didUpdate に延期
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
これによりlistener のトリガー順序が保証され、コンポーネントツリーの階層順序に従って、まず大きなサブツリーの更新を通知し、大きなサブツリーの更新完了後に、小さなサブツリーの更新を通知します
更新の全過程はこの通りです。至于「Container を通じて state を props として下の view に注入する」ステップについては、言うことはありません。以下の通り:
// from: src/components/connectAdvanced/Connect.render
render() {
return createElement(WrappedComponent, this.addExtraProps(selector.props))
}
WrappedComponent が必要な state フィールドに基づいて props を作成し、React.createElementを通じて注入します。ContainerInstance.setState({})時、このrender関数が再呼び出しされ、新しい props が view に注入され、view will receive props...ビュー更新が実際に開始されます
三.テクニック
純粋関数に状態を持たせる
function makeSelectorStateful(sourceSelector, store) {
// wrap the selector in an object that tracks its results between runs.
const selector = {
run: function runComponentSelector(props) {
try {
const nextProps = sourceSelector(store.getState(), props)
if (nextProps !== selector.props || selector.error) {
selector.shouldComponentUpdate = true
selector.props = nextProps
selector.error = null
}
} catch (error) {
selector.shouldComponentUpdate = true
selector.error = error
}
}
}
return selector
}
純粋関数をオブジェクトで包むことで、ローカル状態を持たせることができます。new Class Instance と同様の役割です。これにより純粋な部分と不純な部分を分離できます。純粋な部分は依然として純粋で、不純な部分は外側にあります。class はこれほどクリーンではありません
デフォルトパラメータとオブジェクト分割代入
function connectAdvanced(
selectorFactory,
// options object:
{
getDisplayName = name => `ConnectAdvanced(${name})`,
methodName = 'connectAdvanced',
renderCountProp = undefined,
shouldHandleStateChanges = true,
storeKey = 'store',
withRef = false,
// additional options are passed through to the selectorFactory
...connectOptions
} = {}
) {
const selectorFactoryOptions = {
// 展開 元に戻す
...connectOptions,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
}
}
このように簡素化できます:
function f({a = 'a', b = 'b', ...others} = {}) {
console.log(a, b, others);
const newOpts = {
...others,
a,
b,
s: 's'
};
console.log(newOpts);
}
// test
f({a: 1, c: 2, f: 0});
// 出力
// 1 "b" {c: 2, f: 0}
// {c: 2, f: 0, a: 1, b: "b", s: "s"}
ここで 3 つの es6+ の小技巧を使用しています:
-
デフォルトパラメータ。分割代入時に右側が undefined でエラーになるのを防止
-
オブジェクト分割代入。残りの属性をすべて others オブジェクトに包む
-
展開演算子。others を展開し、属性をターゲットオブジェクトにマージ
デフォルトパラメータは es6 の機能で、言うことはありません。オブジェクト分割代入は Stage 3 proposal で、...othersはその基本的な使い方です。展開演算子はオブジェクトを展開し、ターゲットオブジェクトにマージします。複雑ではありません
興味深いのは、ここでオブジェクト分割代入と展開演算子を組み合わせて使用し、パラメータに対してパック-復元を行う必要があるシナリオを実現していることです。これら 2 つの機能を使用しない場合、このようにする必要があるかもしれません:
function connectAdvanced(
selectorFactory,
connectOpts,
otherOpts
) {
const selectorFactoryOptions = extend({},
otherOpts,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
)
}
connectOptsとotherOptsを明確に区別する必要があり、実装は少し面倒です。これらのテクニックを組み合わせると、コードは非常に簡潔になります
さらに 1 つの es6+ の小技巧があります:
addExtraProps(props) {
//! テクニック 最小知識を確保するための浅いコピー
//! props を浅くコピーし、必要ないものを渡さない。否则影响 GC
const withExtras = { ...props }
}
参照が 1 つ増えるとメモリリークのリスクが 1 つ増えます。必要ないものは渡すべきではありません(最小知識)
パラメータパターンマッチング
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}.`)
}
}
ここでfactoriesはこのようになっています:
// mapDispatchToProps
[
whenMapDispatchToPropsIsFunction,
whenMapDispatchToPropsIsMissing,
whenMapDispatchToPropsIsObject
]
// mapStateToProps
[
whenMapStateToPropsIsFunction,
whenMapStateToPropsIsMissing
]
パラメータの様々な状況に対して一連の case 関数を作成し、パラメータをすべての case に順次流し、いずれかにマッチすればその結果を返し、どれもマッチしなければエラー case に入ります
switch-case に似ており、パラメータのパターンマッチングに使用されます。これにより様々な case が分解され、それぞれ責任が明確になります(各 case 関数の命名は非常に正確です)
遅延パラメータ
function wrapMapToPropsFunc() {
// 推測後すぐに props を 1 回計算
let props = proxy(stateOrDispatch, ownProps)
// mapToProps は function の返却をサポートし、もう 1 回推測
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
}
ここで、遅延パラメータとは:
// 戻り値をパラメータとして、props をもう 1 回計算
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
この実装は react-redux が直面するシナリオに関連しています。function の返却をサポートするのは、主にコンポーネントインスタンスレベル(デフォルトはコンポーネントレベル)の細かい粒度の mapToProps 制御をサポートするためです。これにより異なるコンポーネントインスタンスに対して異なる mapToProps を与えることができ、パフォーマンスをさらに向上させることができます
実装から見ると、実際のパラメータを遅延させており、パラメータファクトリをパラメータとして传入することをサポートしています。1 回目に外部環境をファクトリに渡し、ファクトリが環境に基づいて実際のパラメータを作成します。ファクトリという環節を追加することで、制御粒度を 1 レベル細かくしています(コンポーネントレベルからコンポーネントインスタンスレベルに、外部環境はコンポーネントインスタンス情報)
P.S.遅延パラメータに関する議論はhttps://github.com/reactjs/react-redux/pull/279を参照
四.疑問
1.デフォルトの props.dispatch はどこから来るのか?
connect()(MyComponent)
connect にパラメータを何も渡さなくても、MyComponent インスタンスはdispatchという prop を取得できます。どこでこっそり取り付けられているのでしょうか?
function whenMapDispatchToPropsIsMissing(mapDispatchToProps) {
return (!mapDispatchToProps)
// ここに取り付けられている。mapDispatchToProps を渡さない場合、デフォルトで dispatch を props に取り付ける
? wrapMapToPropsConstant(dispatch => ({ dispatch }))
: undefined
}
デフォルトでmapDispatchToProps = dispatch => ({ dispatch })が内蔵されているため、コンポーネントの props にdispatchがあります。mapDispatchToPropsを指定した場合、取り付けられません
2.多级 Container はパフォーマンス問題に直面���るか?
このようなシナリオを考慮:
App
HomeContainer
HomePage
HomePageHeader
UserContainer
UserPanel
LoginContainer
LoginButton
ネストされた container が現れています。HomeContainer が注目する state が変化した時、ビュー更新を何度も通るのでしょうか?例えば:
HomeContainer update-didUpdate
UserContainer update-didUpdate
LoginContainer update-didUpdate
この場合、軽い 1 回の dispatch で 3 つのサブツリーが更新され、パフォーマンスが爆発する感觉です
実際にはそうではありません。多级 Container に対して、2 回通る状況は確かに存在しますが、ここでの 2 回通るとはビュー更新を指すのではなく、state change 通知を指します
上位 Container は didUpdate 後に下の Container に更新チェックを通知し、小さなサブツリーでもう 1 回通る可能性があります。しかし大きなサブツリーの更新過程中、下の Container に到達した時、小さなサブツリーはこのタイミングで更新を開始します。大きなサブツリーの didUpdate 後の通知は下の Container に空で 1 回チェックさせるだけで、実際の更新はありません
チェックの具体的なコストは state と props に対してそれぞれ===比較と浅い参照比較(これも先に===比較)を行い、変化がなければ終了します。したがって、各下の Container のパフォーマンスコストは 2 つの===比較であり、問題ありません。つまり、ネストされた Container の使用によるパフォーマンスオーバーヘッドを心配する必要はありません
五.ソースコード分析
Github アドレス:https://github.com/ayqy/react-redux-5.0.6
P.S.注釈は依然として十分に詳細です。
コメントはまだありません