前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

React Redux 之 connect 高阶组件原理与源码拆解

首页2018-12-20 17:50:24Front-End
ReactJavaScriptReduxreact-redux

第一次用 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。

fe
  • 一、connect 到底在做什么
  • 二、mapStateToProps 负责读
    • 2.1 从 state 里挑出组件要的那部分
    • 2.2 第二个参数 ownProps
    • 2.3 引用相等这个大坑
  • 三、mapDispatchToProps 负责写
    • 3.1 函数形式
    • 3.2 对象简写
  • 四、mergeProps 和 options
  • 五、Provider 是这一切的前提
  • 六、connect 源码骨架
    • 6.1 更新链路走一遍
    • 6.2 关于这份源码的时效性
  • 七、hooks 时代这套东西还剩什么
  • 总结
  • 参考

← Redux 原理与三大原则,从设计理念到单向数据流浅析Promise原理与手写实现 →