blog icon indicating copy to clipboard operation
blog copied to clipboard

Redux:自问自答

Open MrErHu opened this issue 6 years ago • 8 comments

  前段时间看了Redux的源码,写了一篇关于Redux的源码分析: Redux:百行代码千行文档,没有看的小伙伴可以看一下,整篇文章主要是对Redux运行的原理进行了大致的解析,但是其实有很多内容并没有明确地深究为什么要这么做本篇文章的内容主要就是我自己提出一些问题,然后试着去回答这个问题,再次做个广告,欢迎大家关注我的掘金账号和我的博客。   

为什么createStore中既存在currentListeners也存在nextListeners?

  看过源码的同学应该了解,createStore函数为了保存store的订阅者,不仅保存了当前的订阅者currentListeners而且也保存了nextListenerscreateStore中有一个内部函数ensureCanMutateNextListeners:   

function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
}

  这个函数实质的作用是确保可以改变nextListeners,如果nextListenerscurrentListeners一致的话,将currentListeners做一个拷贝赋值给nextListeners,然后所有的操作都会集中在nextListeners,比如我们看订阅的函数subscribe:

function subscribe(listener) {
// ......
    let isSubscribed = true

    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
        // ......
        ensureCanMutateNextListeners()
        const index = nextListeners.indexOf(listener)
        nextListeners.splice(index, 1)
}

  我们发现订阅和解除订阅都是在nextListeners做的操作,然后每次dispatch一个action都会做如下的操作:

function dispatch(action) {
    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }
    // 相当于currentListeners = nextListeners const listeners = currentListeners
    const listeners = currentListeners = nextListeners
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }
    return action
  }

  我们发现在dispatch中做了const listeners = currentListeners = nextListeners,相当于更新了当前currentListenersnextListeners,然后通知订阅者,到这里我们不禁要问为什么要存在这个nextListeners?      其实代码中的注释也是做了相关的解释:

The subscriptions are snapshotted just before every dispatch() call.If you subscribe or unsubscribe while the listeners are being invoked, this will not have any effect on the dispatch() that is currently in progress.However, the next dispatch() call, whether nested or not, will use a more recent snapshot of the subscription list. 

  来让我这个六级没过的渣渣翻译一下: 订阅者(subscriptions)在每次dispatch()调用之前都是一份快照(snapshotted)。如果你在listener被调用期间,进行订阅或者退订,在本次的dispatch()过程中是不会生效的,然而在下一次的dispatch()调用中,无论dispatch是否是嵌套调用的,都将使用最近一次的快照订阅者列表。用图表示的效果如下:         我们从这个图中可以看见,如果不存在这个nextListeners这份快照的话,因为dispatch导致的store的改变,从而进一步通知订阅者,如果在通知订阅者的过程中发生了其他的订阅(subscribe)和退订(unsubscribe),那肯定会发生错误或者不确定性。例如:比如在通知订阅的过程中,如果发生了退订,那就既有可能成功退订(在通知之前就执行了nextListeners.splice(index, 1))或者没有成功退订(在已经通知了之后才执行了nextListeners.splice(index, 1)),这当然是不行的。因为nextListeners的存在所以通知订阅者的行为是明确的,订阅和退订是不会影响到本次订阅者通知的过程。

  这都没有问题,可是存在一个问题,JavaScript不是单线程的吗?怎么会出现上述所说的场景呢?百思不得其解的情况下,去Redux项目下开了一个issue,得到了维护者的回答:

  

  得了,我们再来看看测试相关的代码吧。看完之后我了解到了。的确,因为JavaScript是单线程语言,不可能出现出现想上述所说的多线程场景,但是我忽略了一点,执行订阅者函数时,在这个回调函数中可以执行退订或者订阅事件。例如:

const store = createStore(reducers.todos)
const unsubscribe1 = store.subscribe(() => {
    const unsubscribe2 = store.subscribe(()=>{})
})

  这不就实现了在通知listener的过程中混入订阅subscribe与退订unsubscribe吗?   

为什么Reducer中不能进行dispatch操作?

  我们知道在reducer函数中是不能执行dispatch操作的。一方面,reducer作为计算下一次state的纯函数是不应该承担执行dispatch这样的操作。另一方面,即使你尝试着在reducer中执行dispatch,也并不会成功,并且会得到"Reducers may not dispatch actions."的提示。因为在dispatch函数就做了相关的限制:   

function dispatch(action) {
    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }
    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    //...notice listener
}

  在执行dispatch时就会将标志位isDispatching置为true。然后如果在currentReducer(currentState, action)执行的过程中由执行了dispatch,那么就会抛出错误('Reducers may not dispatch actions.')。之所以做如此的限制,是因为在dispatch中会引起reducer的执行,如果此时reducer中又执行了dispatch,这样就落入了一个死循环,所以就要避免reducer中执行dispatch

为什么applyMiddleware中middlewareAPI中的dispathch要用闭包包裹?

  关于Redux的中间件之前我写过一篇相关的文章Redux:Middleware你咋就这么难,没有看过的同学可以了解一下,其实文章中也有一个地方没有明确的解释,当时初学不是很理解,现在来解释一下:   

export default function applyMiddleware(...middlewares) {            
    return (next)  => 
        (reducer, initialState) => {

              var store = next(reducer, initialState);
              var dispatch = store.dispatch;
              var chain = [];

              var middlewareAPI = {
                getState: store.getState,
                dispatch: (action) => dispatch(action)
              };

              chain = middlewares.map(middleware =>
                            middleware(middlewareAPI));
              dispatch = compose(...chain, store.dispatch);
              return {
                ...store,
                dispatch
              };
           };
}

  这个问题的就是为什么middlewareAPI中的dispathc要用闭包包裹,而不是直接传入呢?首先用一幅图来解释一下中间件:            如上图所示,中间件的执行过程非常类似于洋葱圈(Onion Rings),假设我们在函数applyMiddleware中传入中间件的顺序分别是mid1、mid2、mid3。而中间件函数的结构类似于:

export default function createMiddleware({ getState }) {
    return (next) => 
        (action) => {
            //before
            //......
            next(action)
            //after
            //......
        };
}

  那么中间件函数内部代码执行次序分别是:   

  但是如果在中间件函数中调用了dispatch(用mid3-before中为例),执行的次序就变成了:

  

  所以给中间件函数传入的middlewareAPIdispatch函数是经过applyMiddleware改造过的dispatch,而不是redux原生的store.dispatch。所以我们通过一个闭包包裹dispatch:

(action) => dispatch(action)

  这样我们在后面给dispatch赋值为dispatch = compose(...chain, store.dispatch);,这样只要 dispatch 更新了,middlewareAPI 中的 dispatch 应用也会发生变化。如果我们写成:

var middlewareAPI = {
    getState: store.getState,
    dispatch: dispatch
};

那中间件函数中接受到的dispatch永远只能是最开始的redux中的dispatch

  最后,如果大家在阅读Redux源码时还有别的疑惑和感受,欢迎大家在评论区相互交流,讨论和学习。

MrErHu avatar Jul 19 '17 15:07 MrErHu

你好,文章的第三部分有一个问题。闭包不是保存的本地变量吗?为什么middlewareAPI中的dispatch会是获取最后增强的dispatch呢?因为dispatch = store.dispatch吗?但是store在该上下文中不是由createStore()创建的吗?为什么获取的是最终store

GeekaholicLin avatar Aug 08 '17 04:08 GeekaholicLin

@GeekaholicLin 我理解了一下你的问题,你是问为什么middlewareAPI 中的dispatch是增强的dispatch吗? 看代码:

var middlewareAPI = {
                getState: store.getState,
                dispatch: (action) => dispatch(action)
};
chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain, store.dispatch);

传递给middlewares的API中的dispatch是一个匿名函数对吧。匿名函数通过闭包去访问变量dispatch。 可是后面又有dispatch = compose(...chain, store.dispatch);这不就把变量的值改变了吗,以后通过闭包访问的变量肯定是增强的函数。

MrErHu avatar Aug 08 '17 05:08 MrErHu

😭 好像是的。光顾着看middlewareAPI之前的代码了。谢谢解答~

GeekaholicLin avatar Aug 08 '17 06:08 GeekaholicLin

@MrErHu 关于nextListener的理解好像有点问题,源码中的那段注释并不是说明增加一个nextListeners的原因的,增加nextListener这个副本是为了避免在遍历listeners的过程中由于subscribe或者unsubscribe对listeners进行的修改而引起的某个listener被漏掉了。比如你在遍历到某个listener的时候,在这个listener中unsubscribe了一个在当前listener之前的listener,这个时候继续i ++的时候就会直接跳过当前listener的下一个listener,不知道有没有描述清楚

lakb248 avatar Nov 09 '17 11:11 lakb248

@lakb248 受教了,明白了!感谢୧(๑•̀◡•́๑)૭

MrErHu avatar Oct 23 '18 15:10 MrErHu

爱了爱了

bigHippoZz avatar Aug 11 '20 08:08 bigHippoZz

@MrErHu 关于nextListener的理解好像有点问题,源码中的那段注释并不是说明增加一个nextListeners的原因的,增加nextListener这个副本是为了避免在遍历listeners的过程中由于subscribe或者unsubscribe对listeners进行的修改而引起的某个listener被漏掉了。比如你在遍历到某个listener的时候,在这个listener中unsubscribe了一个在当前listener之前的listener,这个时候继续i ++的时候就会直接跳过当前listener的下一个listener,不知道有没有描述清楚

如果是为了避免这种case的话,也没有必要增加nextListeners呀,通过listeners = currentListeners就能解决问题。 即便是在某个listener执行中出现了subscribe/unsubscribe , 只要对currentListeners进行新的赋值操作即可:或使currentListeners数组+1,或使currentListeners数组-1。可是无论如何,listeners依旧保持着循环之前的订阅数组的引用,也就是说用于循环的这个数组在这种情况下是稳定的,也就不会发生“跳过”。

xuzeshen avatar Apr 22 '21 09:04 xuzeshen

@MrErHu 关于nextListener的理解好像有点问题,源码中的那段注释并不是说明增加一个nextListeners的原因的,增加nextListener这个副本是为了避免在遍历listeners的过程中由于subscribe或者unsubscribe对listeners进行的修改而引起的某个listener被漏掉了。比如你在遍历到某个listener的时候,在这个listener中unsubscribe了一个在当前listener之前的listener,这个时候继续i ++的时候就会直接跳过当前listener的下一个listener,不知道有没有描述清楚

如果是为了避免这种case的话,也没有必要增加nextListeners呀,通过listeners = currentListeners就能解决问题。 即便是在某个listener执行中出现了subscribe/unsubscribe , 只要对currentListeners进行新的赋值操作即可:或使currentListeners数组+1,或使currentListeners数组-1。可是无论如何,listeners依旧保持着循环之前的订阅数组的引用,也就是说用于循环的这个数组在这种情况下是稳定的,也就不会发生“跳过”。

进行新的赋值开始for循环会引起重复执行订阅回调。

为什么需要 nextListeners ? 因为 订阅回调可以产生多个订阅或者嵌套订阅,而任何一个订阅回调中又可以取消平级或外层订阅,这会导致什么问题呢,for循环 遍历时 订阅数组 listeners 长度改变,遍历到的索引位置 index 没变。 如果取消的订阅索引位置在当前索引之后,说明此时被取消的订阅回调尚未执行,属于成功退订。 可是如果取消的订阅索引位置在当前索引之前呢,说明此时被取消的订阅回调已经执行,并且,此时由于listeners数组长度改变,所有数组元素下标发生 -1,当前索引 index却没改变,导致跳过当前索引后的下个订阅回调,执行了下下个订阅回调,发生了错误的退订。

所以,重点来了,通过引入 nextListeners,订阅或退订操作的是 nextListeners 数组,而每次dispacth action后 执行的订阅回调数组是 currentListeners,也就是先获取 nextListener的一份快照,再遍历执行订阅回调数组的过程中发生的 订阅/退订 影响的都是nextListeners 数组,只需要在下一次 dispacth action 后遍历执行订阅回调数组前再获取一次 nextListeners 的快照,就可以保证 在订阅回调中订阅和退订的正确性。

iolh avatar Sep 03 '21 04:09 iolh