(给前端大全加星标,提升前端技能)
一个似乎无法实现的 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 中的数据由两部分组成,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”,但制造这种人为的边界感觉并不好。如果我在开发一个音频播放器,我可不想被迫的将isPlaying
和currentTime
拆开,只是因为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[1]
subscription会调用checkForUpdates
,它会检查对store的更新是否导致对选定状态的更改。如果状态改变了,一次重新render就会被forceRender({})
触发:
See where this happens in the react-redux code on GitHub[2]
forceRender
的唯一职责就是:强制重新render。他是通过递增一个本地状态来实现的,方法有些hacky但是很简单,每次forceRender
被调用,它便会递增一个内部计数器,计数器本身没有被任何地方用到,但能达到预期效果。
const [, forceRender] = useReducer(s => s + 1, 0)
See where this happens in the react-redux code on GitHub[3]
这个re-render反过来会导致useSelector从store中选择出合适的状态,然后将其返回到我们的组件中:
selectedState = selector(store.getState())
//...
return selectedState
See where this happens in the react-redux code on GitHub[4]
总之,设置了一个每当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
,来解决。参阅 该文档。
为了更好的衡量, 这里有个从零创建的,只有当它的选择状态改变时才会触发更新的,useSelector
hook 的最小实现:
https://codesandbox.io/s/peaceful-river-dj5ps
注意:虽然实现自己的useSelector
是个有趣的练习,但我不提倡在生产环境中使用
备注1:我觉得订阅者模式不“reacty”的原因是组件不在只是其“props and state“的结果。相反的,父级context必须保留每个的useSelector实例中的selector函数引用。
备注2:我一直在用Kent C. Dodds的context模式并且非常喜欢--它对我们上述讨论的问题没有帮助,但是可以让context的使用更友好。参考[5]
参考资料
See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L72
[2]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L69
[3]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L15
[4]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L33
[5]参考: https://kentcdodds.com/blog/how-to-use-react-context-effectively
- EOF -
觉得本文对你有帮助?请分享给更多人
推荐关注「前端大全」,提升前端技能
点赞和在看就是最大的支持❤️
文章评论