Preface
react-redux, as a glue-like thing, seems unnecessary to understand in depth, but in fact, as the connection point between the data layer (redux) and the UI layer (react), its implementation details have a decisive impact on overall performance. The cost of random updates in the component tree is much higher than the cost of running through the reducer tree a few more times, so it's necessary to understand its implementation details.
One benefit of carefully understanding react-redux is gaining a basic understanding of performance. Consider a question:
dispatch({type: 'UPDATE_MY_DATA', payload: myData})
This line of code in some corner of the component tree, what is its performance impact? Several sub-questions:
-
- Which reducers are recalculated?
-
- From which component does the triggered view update start?
-
- Which components' render methods are called?
-
- Is every leaf component affected by diff? Why?
If you can't accurately answer these questions, you're definitely not confident about performance.
I. Purpose
First, clarify that redux is just a data layer, and react is just a UI layer; there is no connection between the two.
If you're holding redux and react in your left and right hands respectively, then the actual situation should be like this:
-
redux has defined the data structure (state) and the calculation methods (reducer) for each field
-
react renders the initial page based on the view description (Component)
It might look like this:
redux | react
myUniversalState | myGreatUI
human | noOneIsHere
soldier |
arm |
littleGirl |
toy |
ape | noOneIsHere
hoho |
tree | someTrees
mountain | someMountains
snow | flyingSnow
Everything is in redux on the left, but react doesn't know, only displaying default elements (no data). There are some component local states and scattered props passing, the page is like a static frame, and the component tree looks like just a big framework connected by some pipes.
Now let's consider adding react-redux into the mix, then it becomes like this:
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
Note that Arm interaction is relatively complex, not suitable to be controlled by the upper level (HumanContainer), so nested Containers appear.
Container passes the state from redux to react, so the initial data is there. But what about updating the view?
Arm.dispatch({type: 'FIRST_BLOOD', payload: warData})
Someone fired the first shot, causing a soldier to die (state change), then these parts need to change:
react-redux
redux -+- react
myNewUniversalState | myUpdatedGreatUI
HumanContainer
human -+- humans
soldier | soldiers
| diedSoldier
ArmContainer
arm -+- arm
| inactiveArm
A dead soldier and an arm dropped on the ground appear on the page (update view), other parts (ape, scene) are all fine.
The above describes the purpose of react-redux:
-
Pass state from redux to react
-
And be responsible for updating react view after redux state change
So you can guess that the implementation is divided into 3 parts:
-
Add small water sources to the big framework connected by pipes (inject state as props into the view below through Container)
-
Let the small water sources flow (listen to state change, update the view below through Container's setState)
-
Don't let small water sources flow randomly (built-in performance optimization, compare cached state and props to see if update is necessary)
II. Key Implementation
The key parts of the source code are as follows:
// from: src/components/connectAdvanced/Connect.onStateChange
onStateChange() {
// Recalculate props when state changes
this.selector.run(this.props)
// If the current component doesn't need to update, notify the container below to check for updates
// If it needs to update, setState with empty object to force update, delay notification to didUpdate
if (!this.selector.shouldComponentUpdate) {
this.notifyNestedSubs()
} else {
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
// Notify the view below Container to update
//!!! This is the key to connecting redux with react
this.setState(dummyState)
}
}
The most important setState is here. The secret of view update after dispatch action is like this:
1. dispatch action
2. redux calculates reducer to get newState
3. redux triggers state change (calls state change listeners registered via store.subscribe before)
4. react-redux top-level Container's onStateChange triggers
1. Recalculate props
2. Compare new value with cached value to see if props changed and whether to update
3. If yes, force react update through setState({})
4. Notify subscriptions below, trigger onStateChange of Containers below that care about state change, check if view update is needed
In step 3, react-redux's action of registering store change listeners with redux happens at connect()(myComponent). In fact, react-redux only directly listens to redux's state change for the top-level Container; lower-level Containers pass notifications internally, as follows:
// from: src/utils/Subscription/Subscription.trySubscribe
trySubscribe() {
if (!this.unsubscribe) {
// If no parent observer, directly listen to store change
// If there is one, add under parent, pass changes from parent
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.onStateChange)
: this.store.subscribe(this.onStateChange)
}
}
Not directly listening to redux's state change here, but maintaining Container's state change listener ourselves, is to achieve controllable order, for example as mentioned above:
// If needs to update, delay notification to didUpdate
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
This ensures the listener trigger order follows the component tree hierarchy, first notify the large subtree to update, after the large subtree finishes updating, then notify the small subtree to update.
The entire update process is like this. As for the step "inject state as props into the view below through Container", nothing special to say, as follows:
// from: src/components/connectAdvanced/Connect.render
render() {
return createElement(WrappedComponent, this.addExtraProps(selector.props))
}
Based on the state fields needed by WrappedComponent, create a set of props and inject them through React.createElement. When ContainerInstance.setState({}), this render function is called again, new props are injected into the view, view will receive props... the view update truly begins.
III. Techniques
Let Pure Functions Have State
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
}
Wrapping a pure function with an object gives it local state, similar to creating a new Class Instance. This separates the pure part from the impure part; the pure remains pure, the impure is outside. Classes are not as clean as this.
Default Parameters and Object Destructuring
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 = {
// Spread and restore back
...connectOptions,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
}
}
Can be simplified to:
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});
// output
// 1 "b" {c: 2, f: 0}
// {c: 2, f: 0, a: 1, b: "b", s: "s"}
Three es6+小技巧 are used here:
-
Default parameters. Prevent undefined errors during destructuring
-
Object destructuring. Wrap remaining properties into the others object
-
Spread operator. Spread others and merge properties into the target object
Default parameters are an es6 feature, nothing special to say. Object destructuring is a Stage 3 proposal, ...others is its basic usage. The spread operator spreads objects and merges them into the target object, not complex either.
What's interesting is combining object destructuring with the spread operator here, achieving scenarios that require packaging-restoring parameters. Without these 2 features, it might need to be done like this:
function connectAdvanced(
selectorFactory,
connectOpts,
otherOpts
) {
const selectorFactoryOptions = extend({},
otherOpts,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
)
}
Need to clearly distinguish connectOpts and otherOpts, implementation would be more troublesome. Combining these techniques makes the code quite concise.
There's also 1 es6+小技巧:
addExtraProps(props) {
//! Technique: shallow copy ensures least knowledge
//! Shallow copy props, don't pass things others don't need, otherwise affects GC
const withExtras = { ...props }
}
One more reference means one more risk of memory leak. What's not needed shouldn't be given (least knowledge).
Parameter Pattern Matching
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}.`)
}
}
Where factories is like this:
// mapDispatchToProps
[
whenMapDispatchToPropsIsFunction,
whenMapDispatchToPropsIsMissing,
whenMapDispatchToPropsIsObject
]
// mapStateToProps
[
whenMapStateToPropsIsFunction,
whenMapStateToPropsIsMissing
]
Establish a series of case functions for various parameter situations, then let parameters flow through all cases sequentially, return the result if any matches, enter error case if none match.
Similar to switch-case, used for parameter pattern matching. This way various cases are decomposed, each with clear responsibilities (each case function's naming is very accurate).
Lazy Parameters
function wrapMapToPropsFunc() {
// Calculate props once immediately after guessing
let props = proxy(stateOrDispatch, ownProps)
// mapToProps supports returning function, guess again
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
}
Where lazy parameters refer to:
// Use return value as parameter, calculate props again
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
This implementation is related to the scenarios react-redux faces. Supporting return of function is mainly to support component instance-level (default is component-level) fine-grained mapToProps control. This enables different mapToProps for different component instances, supporting further performance improvement.
From the implementation perspective, it's like deferring actual parameters, supporting passing a parameter factory as a parameter. The first time passes the external environment to the factory, then the factory creates actual parameters based on the environment. Adding this factory link refines the control granularity one level further (component-level refined to component instance-level, external environment is component instance information).
P.S. Related discussion about lazy parameters see https://github.com/reactjs/react-redux/pull/279
IV. Questions
1. Where does the default props.dispatch come from?
connect()(MyComponent)
Without passing any parameters to connect, MyComponent instances can still get a prop called dispatch. Where is it secretly attached?
function whenMapDispatchToPropsIsMissing(mapDispatchToProps) {
return (!mapDispatchToProps)
// It's attached here. If mapDispatchToProps is not passed, default to attach dispatch to props
? wrapMapToPropsConstant(dispatch => ({ dispatch }))
: undefined
}
By default, there's a built-in mapDispatchToProps = dispatch => ({ dispatch }), so component props have dispatch. If mapDispatchToProps is specified, it won't be attached.
2. Will multi-level Containers face performance issues?
Consider this scenario:
App
HomeContainer
HomePage
HomePageHeader
UserContainer
UserPanel
LoginContainer
LoginButton
Nested containers appear. When state watched by HomeContainer changes, will it go through many view updates? For example:
HomeContainer update-didUpdate
UserContainer update-didUpdate
LoginContainer update-didUpdate
If so, a light dispatch causes 3 subtree updates, feels like performance will explode.
Actually it's not like this. For multi-level Containers, the situation of going through twice does exist, but this going through twice doesn't refer to view updates, but rather state change notifications.
The upper Container notifies Containers below to check for updates after didUpdate, which may go through once more in the small subtree. But during the large subtree update process, when reaching the lower Container, the small subtree starts updating at this timing. The notification after large subtree's didUpdate only makes the lower Container go through an empty check, no actual update.
The specific cost of checking is doing === comparison on state and props separately and shallow reference comparison (also === comparison first). If no change is found, it ends. So each lower Container's performance cost is two === comparisons, no problem. That is, don't worry about performance overhead from using nested Containers.
V. Source Code Analysis
Github address: https://github.com/ayqy/react-redux-5.0.6
P.S. Comments are still detailed enough.
No comments yet. Be the first to share your thoughts.