Redux 设计思想与工作原理(下)

揭秘 Redux 设计思想与工作原理(下)

  针对 dispatchsubscribe这两个具体的方法进行分析,分别认识 Redux工作流中最为核心的dispatch动作,以及 Redux自身独特的 “发布-订阅”模式。

Redux 工作流的核心:dispatch 动作

  dispatch应该是大家在使用 Redux的过程中最为熟悉的 API 了。结合前面对设计思想的解读,我们已经知道,在 Redux中有这样 3 个关键要素:

  • action
  • reducer
  • store

  之所以说 dispatch是 Redux 工作流的核心,是因为dispatch 这个动作刚好能把 actionreducerstore这三位“主角”给串联起来。dispatch的内部逻辑,足以反映了这三者之间“打配合”的过程。

  这里把 dispatch的逻辑从 createStore中提取出来,请看相关源码:

function dispatch(action) {
  // 校验 action 的数据格式是否合法
  if (!isPlainObject(action)) {
    throw new Error(
      'Actions must be plain objects. ' +
      'Use custom middleware for async actions.'
    )
  }
  // 约束 action 中必须有 type 属性作为 action 的唯一标识 
  if (typeof action.type === 'undefined') {
    throw new Error(
      'Actions may not have an undefined "type" property. ' +
      'Have you misspelled a constant?'
    )
  }
  // 若当前已经位于 dispatch 的流程中,则不允许再度发起 dispatch(禁止套娃)
  if (isDispatching) {
    throw new Error('Reducers may not dispatch actions.')
  }
  try {
    // 执行 reducer 前,先"上锁",标记当前已经存在 dispatch 执行流程
    isDispatching = true
    // 调用 reducer,计算新的 state
    currentState = currentReducer(currentState, action)
  } finally {
    // 执行结束后,把"锁"打开,允许再次进行 dispatch
    isDispatching = false
  }
  // 触发订阅
  const listeners = (currentListeners = nextListeners);
  for (let i = 0; i < listeners.length; i++) {
    const listener = listeners[i];
    listener();
  }
  return action;
}

  结合源码,将 dispatch的工作流程提取如下:

在这里插入图片描述

  在这段工作流中,有两个点值得细细回味。

1. 通过“上锁”避免“套娃式”的 dispatch

  dispatch工作流中最关键的就是执行 reducer这一步,它对应的是下面这段代码:

try {
  // 执行 reducer 前,先“上锁”,标记当前已经存在 dispatch 执行流程
  isDispatching = true
  // 调用 reducer,计算新的 state 
  currentState = currentReducer(currentState, action)
} finally {
  // 执行结束后,把"锁"打开,允许再次进行 dispatch 
  isDispatching = false
}

  reducer 的本质是 store的更新规则,它指定了应用状态的变化如何响应 action并发送到 store。这段代码中调用 reducer,传入 currentStateaction,对应的正是 action → reducer → store 这个过程,如下图标红处所示:

在这里插入图片描述

  在调用 reducer之前,Redux首先会将 isDispatching变量置为 true,待 reducer执行完毕后,再将 isDispatching变量置为 false。这个操作应该不陌生,因为在``setState` 的“批处理”也是用类似的“上锁”方式来实现的。

  这里之所以要用 isDispatchingdispatch的过程锁起来,目的是规避“套娃式”的 dispatch。更准确地说,是为了避免开发者在 reducer中手动调用 dispatch

  “禁止套娃”用意何在?首先,从设计的角度来看,作为一个“计算 state 专用函数”,Redux 在设计 reducer 时就强调了它必须是“纯净”的,它不应该执行除了计算之外的任何“脏操作”,dispatch调用显然是一个“脏操作”;其次,从执行的角度来看,若真的在 reducer中调用 dispatch,那么 dispatch又会反过来调用 reducerreducer又会再次调用 dispatch…这样反复相互调用下去,就会进入死循环,属于非常严重的误操作。

  因此,在 dispatch的前置校验逻辑中,一旦识别出 isDispatchingtrue,就会直接 throw Error(见下面代码),把死循环扼杀在摇篮里:

if (isDispatching) {
  throw new Error('Reducers may not dispatch actions.')
}

2. 触发订阅的过程

  在 reducer执行完毕后,会进入触发订阅的过程,它对应的是下面这段代码:

// 触发订阅
const listeners = (currentListeners = nextListeners);
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i];
  listener();
}

  在阅读这段源码的过程中,疑问点主要在两个方面:

  1. 之前并没有介绍 subscribe这个 API,也没有提及 listener相关的内容,它们到底是如何与 Redux 主流程相结合的呢?
  2. 为什么会有 currentListenersnextListeners这两个 listeners数组?这和我们平时见到的“发布-订阅”模式好像不太一样。

  要弄明白这两个问题,我们需要先了解 subscribe这个 API。

Redux 中的“发布-订阅”模式:认识 subscribe

  dispatch中执行的 listeners数组从订阅中来,而执行订阅需要调用 subscribe。在实际的开发中,subscribe并不是一个严格必要的方法,只有在需要监听状态的变化时,我们才会调用 subscribe

  subscribe接收一个 Function类型的 listener作为入参,它的返回内容恰恰就是这个 listener对应的解绑函数。你可以通过下面这段示例代码简单把握一下 subscribe的使用姿势:

function handleChange() {
  // 函数逻辑
}
const unsubscribe = store.subscribe(handleChange)
unsubscribe()

  subscribe在订阅时只需要传入监听函数,而不需要传入事件类型。这是因为 Redux 中已经默认了订阅的对象就是“状态的变化(准确地说是 dispatch 函数的调用)”这个事件。

  到这里,就可以回答上面提出的第一个关于 subscribe的问题了:subscribe 是如何与 Redux 主流程结合的呢?首先,可以在 store 对象创建成功后,通过调用 store.subscribe来注册监听函数,也可以通过调用 subscribe的返回函数来解绑监听函数,监听函数是用 listeners数组来维护的;当dispatch action 发生时,Redux 会在 reducer执行完毕后,将 listeners数组中的监听函数逐个执行。这就是 subscribe与 Redux 主流程之间的关系。

  接下来我们结合源码来分析一下 subscribe的内部逻辑,subscribe源码提取如下

function subscribe(listener) {
  // 校验 listener 的类型
  if (typeof listener !== 'function') {
    throw new Error('Expected the listener to be a function.')
  }
  // 禁止在 reducer 中调用 subscribe
  if (isDispatching) {
    throw new Error(
      'You may not call store.subscribe() while the reducer is executing. ' +
      'If you would like to be notified after the store has been updated, subscribe from a ' +
      'component and invoke store.getState() in the callback to access the latest state. ' +
      'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
    )
  }
  // 该变量用于防止调用多次 unsubscribe 函数
  let isSubscribed = true;
  // 确保 nextListeners 与 currentListeners 不指向同一个引用
  ensureCanMutateNextListeners(); 
  // 注册监听函数
  nextListeners.push(listener); 
  // 返回取消订阅当前 listener 的方法
  return function unsubscribe() {
    if (!isSubscribed) {
      return;
    }
    isSubscribed = false;
    ensureCanMutateNextListeners();
    const index = nextListeners.indexOf(listener);
    // 将当前的 listener 从 nextListeners 数组中删除 
    nextListeners.splice(index, 1);
  };
}

  结合这段源码,可以将 subscribe的工作流程提取如下:

在这里插入图片描述

  这个工作流中有一个步骤让人很难不在意,那就是对 ensureCanMutateNextListeners的调用。结合前面整体源码的分析,我们已经知道 ensureCanMutateNextListeners的作用就是确保 nextListeners不会和 currentListener指向同一个引用。 那么为什么要这样做呢?这里就引出了之前提出的关于 subscribe的第二个问题:为什么会有 currentListeners 和 nextListeners 两个 listeners 数组

  要理解这个问题,首先要搞清楚 Redux 中的订阅过程和发布过程各自是如何处理 listeners数组的。

1. 订阅过程中的 listeners 数组

  两个 listeners之间的第一次“交锋”发生在 createStore的变量初始化阶段,nextListeners会被赋值为 currentListeners(见下面代码),这之后两者确实指向同一个引用。

 let nextListeners = currentListeners

  但在 subscribe第一次被调用时,ensureCanMutateNextListeners就会发现这一点,然后将 nextListeners纠正为一个内容与 currentListeners一致、但引用不同的新对象。对应的逻辑如下面代码所示:

function ensureCanMutateNextListeners() {
  // 若两个数组指向同一个引用
  if (nextListeners === currentListeners) {
    // 则将 nextListeners 纠正为一个内容与 currentListeners 一致、但引用不同的新对象
    nextListeners = currentListeners.slice()
  }
}

  在 subscribe的逻辑中,ensureCanMutateNextListeners每次都会在 listener注册前被无条件调用,用以确保两个数组引用不同。紧跟在 ensureCanMutateNextListeners之后执行的是 listener的注册逻辑,我们可以对应源码中看到 listener最终会被注册到 nextListeners数组中去:

nextListeners.push(listener);

2. 发布过程中的 listeners 数组

  触发订阅这个动作是由 dispatch来做的,相关的源码如下:

// 触发订阅
const listeners = (currentListeners = nextListeners);
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i];
  listener();
}

  这段源码告诉我们,在触发订阅的过程中,currentListeners会被赋值为 nextListeners,而实际被执行的 listeners数组又会被赋值为 currentListeners。因此,最终被执行的 listeners 数组,实际上和当前的 nextListeners 指向同一个引用

  注册监听也是操作 nextListeners,触发订阅也是读取 nextListeners(实际上,取消监听操作的也是 nextListeners 数组)。既然如此,要 currentListeners 有何用

3. currentListeners 数组用于确保监听函数执行过程的稳定性

  正因为任何变更都是在 nextListeners上发生的,我们才需要一个不会被变更的、内容稳定的 currentListeners,来确保监听函数在执行过程中不会出幺蛾子。

  举个例子,下面这种操作在 Redux 中完全是合法的:

// 定义监听函数 A
function listenerA() {

}
// 订阅 A,并获取 A 的解绑函数
const unSubscribeA = store.subscribe(listenerA)
// 定义监听函数 B
function listenerB() {
  // 在 B 中解绑 A
  unSubscribeA()
}
// 定义监听函数 C
function listenerC() {

}
// 订阅 B
store.subscribe(listenerB)
// 订阅 C
store.subscribe(listenerC)

  在这个 Demo 执行完毕后,nextListeners数组的内容是 A、B、C 3 个 listener

[listenerA,  listenerB, listenerC]

  接下来若调用 dispatch,则会执行下面这段触发订阅的逻辑:

// 触发订阅
const listeners = (currentListeners = nextListeners);
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i];
  listener();
}

  当 for 循环执行到索引 i = 1 处,也就是对应的 listenerlistenerB时,问题就会出现:listenerB中执行了 unSubscribeA这个动作。而结合我们前面的分析,监听函数注册、解绑、触发这些动作实际影响的都是 nextListeners。为了强化对这一点的认知,复习一下 unsubscribe的源码:

return function unsubscribe() {
  // 避免多次解绑
  if (!isSubscribed) {
    return;
  }
  isSubscribed = false;
  // 熟悉的操作,调用 ensureCanMutateNextListeners 方法
  ensureCanMutateNextListeners();
  // 获取 listener 在 nextListeners 中的索引
  const index = nextListeners.indexOf(listener);
  // 将当前的 listener 从 nextListeners 数组中删除 
  nextListeners.splice(index, 1);
};

  假如说不存在 currentListeners,那么也就意味着不需要 ensureCanMutateNextListeners这个动作。若没有 ensureCanMutateNextListenersunsubscribeA() 执行完之后,listenerA会同时从 listeners数组和 nextListeners数组中消失(因为两者指向的是同一个引用),那么 listeners数组此时只剩下两个元素 listenerBlistenerC,变成这样:

[listenerB, listenerC]

  listeners数组的长度改变了,但 for 循环却不会感知这一点,它将无情地继续循环下去。之前执行到 i = 1处,listener = listeners[1] ,也就是说 listener === listenerB;下一步理应执行到 i = 2处,但此时 listeners[2]已经是 undefined了,原本应该出现在这个索引位上的 listenerC,此时因为数组长度的变化,被前置到了 i = 1处!这样一来,undefined 就会代替 listenerC 被执行,进而引发函数异常

  这可怎么办呢?答案当然是将 nextListeners 与当前正在执行中的 listeners 剥离开来,将两者指向不同的引用。这也正是 ensureCanMutateNextListeners所做的事情。

  在示例的这种场景下,ensureCanMutateNextListeners执行前,listenerscurrentListenersnextListeners之间的关系是这样的:

listeners === currentListeners === nextListeners

  而 ensureCanMutateNextListeners执行后,nextListeners就会被剥离出去:

nextListeners = currentListeners.slice()
listeners === currentListeners !== nextListener

  这样一来,nextListeners上的任何改变,都无法再影响正在执行中的 listeners了。currentListeners 在此处的作用,就是为了记录下当前正在工作中的 listeners 数组的引用将它与可能发生改变的 nextListeners 区分开来,以确保监听函数在执行过程中的稳定性

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值