Published on
1633

由一次react重复渲染问题来看看react-redux中的useSelector实现思路

Authors
  • avatar
    Name
    小辉辉
    Twitter

前言

有次项目上遇到个问题,用户反应页面上某个操作点击后就卡死了,我们知道卡死这种问题大部分都是死循环造成的。自己在本地调试页面后就发现了浏览器输出了Maximum Update Depth Exceeded错误提示。

有经验的react开发者都知道这个提示表示当前有组件一直在调用setState。

看着错误堆栈很容易就定位到了是组件X内部导致的,我看了看组件代码,很快就定位到了调用setState的逻辑,具体是组件在render过程中有直接调用setState的操作。

但是问题也来了,这段setState的代码是两个月前就写了,为什么之前没有遇到循环更新问题呢?

对比着两个月前的版本,在调试的过程中明显发现当前的版本X组件每次都多调用了一次渲染。再对比当前文件的修改记录,我发现了多余的这一段代码:

const valueA = useSelector(A)

就是有个获取react-redux中定义的store值的语句,而这个A的值也是比较特殊,组件X内部在每次渲染的时候还会去更新它的值(又是开发的一个bug)。那这样重复渲染的问题就很清晰了,就是因为多加了这个useSelector hook导致的。

果然,在去除了这段代码后,Maximum Update Depth Exceeded错误提示消失了。

接着我们再来好好回顾下useSelector的用法,看官方文档的说明:

The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside state, or deriving new values. The return value of the selector will be used as the return value of the useSelector() hook.

The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). useSelector() will also subscribe to the Redux store, and run your selector whenever an action is dispatched.

When an action is dispatched, useSelector() will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. useSelector() uses strict === reference equality checks by default, not shallow equality (see the following section for more details).

The selector is approximately equivalent to the mapStateToProps argument to connect conceptually.

You may call useSelector() multiple times within a single function component. Each call to useSelector() creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple useSelector()s in the same component to return new values should only result in a single re-render.

上面的说明我总结如下:

  1. selector在每次组件render的时候都会执行并返回store中的值
  2. selector在运行的时候会同步将当前组件和store进行绑定
  3. 每次selectore关联的store更新时会触发组件的render

理论上到这里已经算是可以解决问题了,但是我还是想着借着这个机会粗略看看redux内部的实现是怎么样的,尤其是store更新会导致组件自动渲染这一块。

实现原理分析

通过调试,我按照先后顺序结合代码说明如下:

  • 首先是首次调用useSelector,调用的代码如下:
const selectedState = useSyncExternalStoreWithSelector(
  subscription.addNestedSub,
  store.getState,
  getServerState || store.getState,
  wrappedSelector,
  equalityFn
)

React.useDebugValue(selectedState)

return selectedState

上述代码看到内部调用了react的useSyncExternalStorehook,这个hook我们在平时用的不多,这里大致说下功能和出入参。

该hook主要用于订阅一个外部的store,入参有三个,分别是subscribe订阅器,类型为函数,用于在store变化时触发的操作,react会将更新方法传入该方法的入参,这样我们就可以响应后续的store变化;第二个参数getSnapshot,类型为函数,用于告诉react当前store的值,当最新的返回值和上次的返回值不一样时,就会触发subscribe更新操作;第三个参数getServerSnapshot,用法同参数二,只是用于服务端。

更详细的用法可以见官方说明:https://react.dev/reference/react/useSyncExternalStore

上面比较重要的是subscription.addNestedSub方法,这里主要就是订阅方法,这个订阅方法会在运行useSelector调用用于响应后续的store更新操作。

  • addNestedSub方法实现逻辑

useSelector之所以能实现store变化时能够自动更新组件就和这个方法很重要,这里我们详细看下它的实现代码

function addNestedSub(listener: () => void) {
  trySubscribe()

  const cleanupListener = listeners.subscribe(listener)

  // cleanup nested sub
  let removed = false
  return () => {
    if (!removed) {
      removed = true
      cleanupListener()
      tryUnsubscribe()
    }
  }
}

首先会通过trySubscribe创建listeners,通过调用listeners的subscribe方法将react传入的listener方法进行订阅。关于详细subscribe方法的实现这里不做详细说明。

到这里首次绑定已经完成,接下去再让我们来看看store变化后的操作。

  • dispatch操作

redux都是通过dispath来实现store更新,我们来看看dispatch是怎么和上面的listener关联起来的,因为只要listener被调用,组件才会被更新。

通过调试发现,react-redux是在trySubscribe方法调用时,完成了绑定关系,见下方代码:

function notifyNestedSubs() {
  listeners.notify()
}

function handleChangeWrapper() {
  if (subscription.onStateChange) {
    // 此处的onStateChange即notifyNestedSubs方法
    subscription.onStateChange()
  }
}

function isSubscribed() {
  return selfSubscribed
}

function trySubscribe() {
  subscriptionsAmount++
  if (!unsubscribe) {
    unsubscribe = parentSub
      ? parentSub.addNestedSub(handleChangeWrapper)
      : store.subscribe(handleChangeWrapper)

    listeners = createListenerCollection()
  }
}

关于onStateChange的绑定关系,通过查看源码可知,是在Provider.tsx文件中实现的。

useIsomorphicLayoutEffect(() => {
  const { subscription } = contextValue
  // 这里
  subscription.onStateChange = subscription.notifyNestedSubs
  subscription.trySubscribe()

  if (previousState !== store.getState()) {
    subscription.notifyNestedSubs()
  }
  return () => {
    subscription.tryUnsubscribe()
    subscription.onStateChange = undefined
  }
}, [contextValue, previousState])

现在listener已经被调用,那最后react又时怎么完成最后的组件重新渲染呢,这时候就又必要去看看listener里面到底是什么了。

  • listener内部实现逻辑

同样通过调试找到了react中的下述代码:

function forceStoreRerender(fiber: Fiber) {
  const root = enqueueConcurrentRenderForLane(fiber, SyncLane)
  if (root !== null) {
    scheduleUpdateOnFiber(root, fiber, SyncLane)
  }
}

从上述代码我们不难看出主要做了两件事情,第一件是将当前组件对应的fiber追加到并发更新逻辑中(区别于常规的同步更新,会将多次更新进行合并,以提高渲染性能),并且调整为最高优先级,即第二个参数SyncLane;第二个事情是发起fiber的更新调度逻辑。

至此,整个流程终于介绍完毕。