React Redux 之 connect 高阶组件原理与源码拆解
第一次用 react-redux 的时候我有个很实在的疑惑:组件里明明没写任何订阅代码,只是在导出的时候套了一层 connect(mapStateToProps)(MyComp),为什么 store 一变它就重渲了?后来项目里出了个更奇怪的问题,某个组件死活不更新,reducer 里打印能看到新 state,组件的 props 就是老的。排查了一下午,最后发现是 mapStateToProps 返回的引用没变。
那次之后我把 connect 的实现翻了一遍,才算真正理解它在干什么。这篇就把这层壳拆开:connect 的四个参数各管什么,Provider 为什么必须存在,订阅和更新的链路是怎么走的,以及为什么「返回同一个引用」会让组件不更新。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
connect的四个参数mapStateToProps/mapDispatchToProps/mergeProps/options各自的职责mapStateToProps的两个形参,以及它什么时候会被重新调用mapDispatchToProps的函数形式和对象简写,各自适合什么场景Provider通过 context 往下传 store 的这条链路connect源码骨架:两层高阶函数、订阅、浅比较、卸载清理- 为什么「不更新」的问题九成出在引用相等上
- hooks 时代
useSelector/useDispatch取代它之后,这套原理还剩下什么价值
# 一、connect 到底在做什么
一句话说,connect 负责把 React 组件和 Redux store 连起来。
它的完整签名长这样:
connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])
四个参数全是可选的。前两个最常用,一个负责「读」,一个负责「写」。第三个 mergeProps 决定读到的、写出的、组件自己的三部分 props 怎么合并,第四个 options 调优化行为。
注意 connect(...) 的返回值不是组件,而是一个函数。你得再调用一次,把真正的组件传进去,才拿到最终那个被包裹的组件:
// 第一步:传配置,拿到一个「组件生产函数」
const enhancer = connect(mapStateToProps, mapDispatchToProps)
// 第二步:传组件,拿到被包裹后的组件
const ConnectedComp = enhancer(MyComp)
// 平时都是连起来写
export default connect(mapStateToProps, mapDispatchToProps)(MyComp)
这个两层调用的结构就是高阶组件的典型写法。分成两层的好处是配置可以复用,同一份 mapStateToProps 能套给多个组件,也方便和别的 HOC 组合。
被包裹出来的这个组件,react-redux 内部叫 Connect。它自己不渲染任何 UI,只干四件事:从上层拿到 store,算出要传下去的 props,订阅 store 的变化,在合适的时机决定要不要重渲。你的业务组件被它包在里面,对这一切毫无感知,收到的就是一堆普通 props。
这种「容器组件负责取数据、展示组件只管渲染」的拆法,就是当年很流行的 container / presentational 分层。
# 二、mapStateToProps 负责读
# 2.1 从 state 里挑出组件要的那部分
这个函数允许我们把 store 中的数据作为 props 绑定到组件上:
const mapStateToProps = (state) => {
return {
count: state.count
}
}
第一个参数就是 Redux 的 state,我们从中摘出了 count 属性。
这里有个原则要强调:你不必把 state 原封不动地传进组件,而应该根据 state 里的数据,动态算出组件需要的那个最小属性集。这不是洁癖,是性能问题。传得越多,组件被无关变化牵连重渲的概率就越大。
举个具体的,一个订单列表页只需要列表和加载状态,那就别把整个 state.order 塞进去:
// 不推荐:把整个 slice 摊进来,slice 里任何字段变了都会重新计算
const mapStateToProps = (state) => ({
order: state.order
})
// 推荐:只挑要用的
const mapStateToProps = (state) => ({
list: state.order.list,
loading: state.order.loading
})
# 2.2 第二个参数 ownProps
mapStateToProps 还能接第二个参数 ownProps,指的是组件自己身上的 props,也就是父组件传给 ConnectedComp 的那些。
它的用处是根据组件自身的属性去 state 里做筛选:
// <UserCard userId="42" /> 这样用的时候
const mapStateToProps = (state, ownProps) => ({
user: state.users.byId[ownProps.userId]
})
这里有个坑要注意。一旦你声明了第二个形参,mapStateToProps 的调用时机就变了:不光 state 变化时会调,ownProps 变化时也会调一次。如果你写的是 (state) => ({...}),react-redux 检测到函数只有一个形参,就会跳过 ownProps 变化触发的那次重算。这个行为是靠 Function.length 判断的,所以别为了「统一风格」给用不到的地方硬加第二个参数。
state 变化或者 ownProps 变化的时候,mapStateToProps 都会被调用,算出一个新的 stateProps,和 ownProps 合并之后更新给组件。
# 2.3 引用相等这个大坑
回到开头那个「组件死活不更新」的问题。
connect 判断要不要重渲,用的是浅比较:拿新算出的 stateProps 和上一次的逐个字段比引用。所以下面这种写法会出事:
// 有坑:每次调用都产生一个新数组,浅比较永远认为「变了」
const mapStateToProps = (state) => ({
activeList: state.list.filter(item => item.active)
})
filter 每次都返回新数组,引用必然不同,结果是 store 里任何无关的字段变化都会导致这个组件重渲。反过来,如果你在 reducer 里直接 state.list.push(item) 然后 return state,引用没变,connect 就认为什么都没发生,组件不更新。
我当年踩的正是后面这个。reducer 里改了数组又把原对象返回出去,console.log 打印的确实是新数据,因为打印的就是那个被改过的对象,但引用从头到尾是同一个。
这两个坑其实是同一条规则的两面:Redux 靠引用变化判断状态变化。前者的解法是用 reselect 这类库给派生数据做缓存,后者的解法是老老实实返回新对象。关于状态只读和纯函数修改这两条为什么必须守住,我在 Redux 原理与三大原则 里单独展开过。
# 三、mapDispatchToProps 负责写
connect 的第二个参数是 mapDispatchToProps,它的功能是把 action 作为 props 绑定到组件上,同样会成为被包裹组件的 props。
mapDispatchToProps(dispatch, ownProps): dispatchProps
# 3.1 函数形式
写成函数时,第一个参数是 dispatch,你自己决定往下传什么:
const mapDispatchToProps = (dispatch) => ({
increase: () => dispatch({ type: 'INCREMENT' }),
setName: (name) => dispatch({ type: 'SET_NAME', payload: name })
})
组件里就可以直接 this.props.increase(),完全不用知道 dispatch 的存在。这是 connect 设计里我觉得最舒服的一点,展示组件对 Redux 零依赖,单独拿去测试或者换状态管理方案都不用改。
同样支持 ownProps 作为第二个参数,行为和 mapStateToProps 一致,形参写了才会在 props 变化时重算。
# 3.2 对象简写
如果你的 action creator 本来就返回 action 对象,可以直接传一个对象,react-redux 会自动用 bindActionCreators 包一层:
import { increase, setName } from './actions'
export default connect(mapStateToProps, { increase, setName })(MyComp)
日常我几乎只用这个写法,短、不容易写错。函数形式留给需要在 dispatch 前后做点别的事情的场景,比如合并多个 action、或者根据 ownProps 决定 dispatch 什么。
还有个细节:如果你完全不传第二个参数,connect 会把 dispatch 本身作为 prop 传给组件。所以有些老代码里能看到组件里直接 this.props.dispatch({ type: 'XXX' }),那不是魔法,就是这个默认行为。
# 四、mergeProps 和 options
这两个参数用得少,但知道它们存在有好处。
mergeProps(stateProps, dispatchProps, ownProps) 决定最终传给组件的 props 长什么样。不传的话默认就是 { ...ownProps, ...stateProps, ...dispatchProps }。需要自定义的典型场景是让 dispatch 函数能拿到 state:
const mergeProps = (stateProps, dispatchProps, ownProps) => ({
...ownProps,
...stateProps,
...dispatchProps,
// 提交时自动带上当前表单里的值
submit: () => dispatchProps.submitForm(stateProps.formValues)
})
options 里比较有用的是 areStatePropsEqual 这类比较函数,可以把默认的浅比较换成自定义逻辑。不到有明确性能问题的时候不建议动它,自定义比较函数本身也有开销,而且写错了就是「该更新的不更新」,比多渲染几次难查得多。
# 五、Provider 是这一切的前提
connect 之所以能拿到 store,是因为外面还有一层 Provider。
它做的事就两件:在原应用组件上包裹一层,让整个应用成为 Provider 的子组件;接收 Redux 的 store 作为 props,通过 context 对象传递给子孙组件上的 connect。