useSelector是如何触发更新的

7,761 阅读6分钟

useSelector是如何只在我们想要的时候触发更新的 

一个似乎无法实现的hook的内部工作原理深入研究 

当一个react context更新的时候,所有使用到该context的组件也会更新。但如果每次 redux store一有变化,所有用到react-redux的useSelector的组件就重新render,这就会导致一个很大的性能问题。那么useSelector是如何做到的呢?

我是React context api的忠实粉丝,但是当context中的数据由两部分组成,其中一个部分更新频繁,而另一部分不常更新时,性能问题就出现了。即使我的组件只用到了不常更新的那部分数据,组件还是会在另一部分数据更新的时候进行重新render。

react-redux的useSelector hook就没有这样的问题——组件只会在其所选择到的(selected)数据更新的情况下才会重新render,即使在store中的其他数据被更新的情况下也是这样。那么这是什么原理呢?它是以某种方式绕过了context的规则?

当然,答案是否定的,但是useSelector 用到了一些有创意的手段达到了效果。在我们开始之前,让我们先进步看下问题。这是一个有点牵强的例子,context中的数据由2部分组成,clicks 和 time :

const initialState = {
  clicks: 0,
  time: 0
};

// Create our context
const MyContext = React.createContext(initialState);

// Increment either the click counter or the timer
const reducer = (state, action) => {
  switch (action.type) {
    case "CLICK":
      return {
        ...state,
        clicks: state.clicks + 1
      };
    case "TIME":
      return {
        ...state,
        time: state.time + 1
      };
    default:
      return state;
  }
};

const MyProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Kick off a setInterval that increments our timer each second
  useEffect(() => {
    const interval = setInterval(() => dispatch({ type: "TIME" }), 1000);
    return () => clearInterval(interval);
  }, []);

  // Memoize our context value
  const value = useMemo(
    () => ({
      ...state,
      onClick: () => dispatch({ type: "CLICK" })
    }),
    [state]
  );

  return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
};

一部分的state(time)追踪app打开后经过的秒数,另一部分(clicks)追踪用户点击了多少次button。我们可能在app中这么使用它:

const Clicker = () => {
  console.log("render clicker");
  const { clicks, onClick } = useContext(MyContext);
  return (
    <div>
      <span>{`Clicks: ${clicks}`}</span>
      <button onClick={onClick}>Click me</button>
    </div>
  );
};

const Timer = () => {
  console.log("render timer");
  const { time } = useContext(MyContext);
  return (
    <div>
      <span>{`Time: ${time}`}</span>
    </div>
  );
};

export default function App() {
  return (
    <MyProvider>
      <Clicker />
      <Timer />
    </MyProvider>
  );
}

timer每秒钟都会递增,click计数器只会在button被点击后递增。然而,因为click组件与timer组件都是用到了同一个context,所以每次timer递增的时候,click组件也会重新render,即使click状态没有变化!这很不好!

比较容易让人接受的解决方法是“把状态分割到不同的context”,但制造这种人为的边界感觉并不好。如果我在开发一个音频播放器,我可不想被迫的将isPlayingcurrentTime拆开,只是因为currentTime常变而isPlaying不是。

现在我们理解了这个问题,让我们再来看看useSelector。如果我从头实现react-redux,代码可能会长这样:

// Some method to return the initial state of your redux store
const initialState = getInitialState() 
 // Returns your top-level redux reducer 
const myReducer = getCombinedReducers()

const MyContext = React.createContext(initialState)

const MyProvider = ({ children }) => {
  const [store, dispatch] = useReducer(myReducer, initialState)
  
  // Memoize our context value. getState will be updated each time the store updates.
  const value = useMemo(() => ({
    getState: () => store
  }), [store])
   
  return (
    <MyContext.Provider value={value}>
      {children}
    </MyContext.Provider>
  )
}

const useSelector = (selector) => {
  const store = useContext(MyContext)
  return selector(store.getState())
}

虽然我的useSelector能正确的返回store中的state,但它还是有着上述的问题。每次 timer更新的时候<Clicker />也会重新render。那么useSelector是怎么解决这个问题的呢。

我不是react/redux的作者或者专家,但在翻看了源码后,我认为我找到了答案: react-redux的context从没有真正的改变。

让我澄清下。Redux有一个一直在变化的store,但我们是通过它的getState方法来获取store中的state的。getState方法的引用没有变化。getState()中返回的state可能改变了,但getter方法本身在整个app的生命周期中都没有变化。

这在react领域中通常是不允许的--我们一般是希望我们的组件在他们所依赖的状态发生变化的时候能够重新render。然而,就像我们刚才所见,这会导致不预期的render。

那么在不能依赖其props/context的变化的情况下,一个组件如何知道该何时更新呢。另一种设计模式被使用:订阅(subscription)。

useSelector注册了一个订阅,每当redux store更新时,该订阅就会被调用,如果这次的更新导致了被选择state的改变,就会触发一次重新render并返回一个新值。订阅是发生在这:

subscription.onStateChange = checkForUpdates

See where this happens in the react-redux code on GitHub

subscription会调用checkForUpdates,它会检查对store的更新是否导致对选定状态的更改。如果状态改变了,一次重新render就会被forceRender({})触发:

See where this happens in the react-redux code on GitHub

forceRender的唯一职责就是:强制重新render。 他是通过递增一个本地状态来实现的,方法有些hacky但是很简单,每次forceRender被调用,它便会递增一个内部计数器,计数器本身没有被任何地方用到,但能达到预期效果。

const [, forceRender] = useReducer(s => s + 1, 0)

See where this happens in the react-redux code on GitHub

这个re-render反过来会导致useSelector从store中选择出合适的状态,然后将其返回到我们的组件中:

selectedState = selector(store.getState())
//...
return selectedState

See where this happens in the react-redux code on GitHub

总之,设置了一个每当redux state改变时就会被触发的订阅。当订阅被触发的时候,他会调用每一个useSelector中传入的方法,然后判断改方法使用(旧)store选择出来的属于与使用(新)store选择出来的数据的内部引用是否相等。如果不等,就会强制render,同时返回新的状态。这样对我来说感觉并不是很“reacty”,但它确实能达到效果。

你可能已经看出了个问题。如果我的选择器返回的是一个多部分的组合状态,那么新创建的对象的引用永远不会等于上次的引用,便又会回到过多重新render的老路上去。

useSelector(state => {
  // Each time this runs it returns a brand new object
  return {
    thingOne: state.thingOne
    thingTwo: state.thingTwo
  }
})

这个问题可以通过给useSelector传第二个参数(相等判断方法),如来shallowEqual,来解决。参阅 该文档

为了更好的衡量, 这里有个从零创建的,只有当它的选择状态改变时才会触发更新的,useSelectorhook 的最小实现:

codesandbox.io/s/peaceful-… 注意:虽然实现自己的useSelector是个有趣的练习,但我不提倡在生产环境中使用


备注1:我觉得订阅者模式不“reacty”的原因是组件不在只是其“props and state“的结果。相反的,父级context必须保留每个的useSelector实例中的selector函数引用。

备注2:我一直在用Kent C. Dodds的context模式并且非常喜欢--它对我们上述讨论的问题没有帮助,但是可以让context的使用更友好。参考