避免不必要的Context重新渲染:切分Providers
除了所有上下文(Context)消费者组件会在值发生变化时重新渲染这一事实之外,不仅要着重强调 “值发生变化” 这一点,还要强调所有消费者组件都会如此进行重新渲染这一点,这很重要。如果我给我们的导航 API 引入实际上并不依赖状态的打开和关闭功能,如下情况:
const SomeComponent = () => {
// no dependencies, open won't change
const open = useCallback(() => setIsNavExpanded(true), []);
// no dependencies, open won't change
const close = useCallback(() => setIsNavExpanded(false), []);
const value = useMemo(() => {
return { isNavExpanded, open, close };
}, [isNavExpanded, open, close]);
return ...
}
SomeComponent组件会在Context的Provider发生变化时,而进行重新渲染,尽管open函数并没有发生任何变化。
并且无论进行多少记忆化(memoization)操作都无法阻止它。例如,下面这种做法是行不通的:
const SomeComponent = () => {
const { open } = useNavigation();
return useCallback(open, []);
}
不过,我们可以使用一种名为 “拆分提供者(splitting providers)” 的有趣技术来达成期望的结果。
代码是下面这样的。我们不再把所有内容都放在一个Context里面,我们可以生成两个Context:一个Context用于存储isNavExpanded值,另一个存储其他值。
// store the state here
const ContextData = React.createContext({
isNavExpanded: false,
});
// store the open/close functions here
const ContextApi = React.createContext({
open: () => {},
close: () => {},
});
然后,我们不再使用单一的Provider,而是使用两个:
const NavigationController = ({ children }) => {
...
return (
<ContextData.Provider value={data}>
<ContextApi.Provider value={api}>
{children}
</ContextApi.Provider>
</ContextData.Provider>
)
}
我们会把已经缓存的data和api传递给对应的Provider
const NavigationController = ({ children }) => {
...
const data = useMemo(() => { isNavExpanded }, [isNavExpanded]);
const api = useMemo(() => { open, close }, [open, close]);
return (
<ContextData.Provider value={data}>
<ContextApi.Provider value={api}>
{children}
</ContextApi.Provider>
</ContextData.Provider>
)
}
很遗憾,我们不得不在这里去掉切换(toggle)功能。它依赖于状态,所以我们没办法把它放进 API(相关模块或组件)里,而且把它包含在数据部分也没什么实际意义。
现在,我们只需要引入两个钩子(hooks)来对上下文(Context)进行抽象处理即可:
const useNavigationData = () => useContext(ContextData);
const useNavigationApi = () => useContext(ContextApi);
之后,SomeComponent可以自由地使用open函数了。它可以自由地调用展开/折叠 函数,但SomeComponent的一些子组件不会因此而重新渲染:
const SomeComponent = () => {
const { open } = useNavigationApi();
return ...
}
我们之前是使用useNavigation钩子来获取isNavExpanded,而现在我们使用useNavigationData,不用改变其他代码:
const AdjustableColumnsBlock = () => {
cosnt { isNavExpanded } = useNavigationData();
return isNavExpanded ? <TwoColumns /> : <ThreeColumns />
}
代码示例: advanced-react.com/examples/08…
当然,我们可以根据自身需求尽可能细致地拆分这些提供者(providers)。这完全取决于什么做法对您的应用程序来说是合理的,以及因上下文(Context)导致的重新渲染是否确实有害。
Reducers 与 切分Providers
正如你可能从上面内容注意到的那样,我不得不从我们的应用程序中去掉切换功能。遗憾的是,这个切换功能依赖于状态,所以如果我把它添加到 API 提供者中,它也会开始依赖状态,这样一来,之前所做的分离就没什么意义了:
const NavigationController = ({ children }) => {
...
// depends on isNavExpanded
const toggle = useCallback(() => setIsNavExpanded(!isNavExpanded), [isNavExpanded]);
// with toggle it has to depend on isNavExpanded through toggle function
// so will change with every state update
const api = useMemo(() => ({ open, close, toggle}), [open, close, toggle]);
return (
<ContextData.Provider value={data}>
<ContextApi.Provider value={api}>
{children}
</ContextApi.Provider>
</ContextData.Provider>
)
}
这不是最理想的方式。任何使用这个状态的人,需要自己实现一个切换函数:
const ExpandButton = () => {
const { isNavExpanded, open, close } = useNavigation();
return (
<button onClick={isNavExpanded ? close: open}>
{isNavExpanded ? 'Collpase' : 'Expand'}
</button>
);
};
这不是最理想的情况。理想情况是,这个钩子可以自动处理这些常见情况。
好在,我们可以用useReducer来实现这个。
useReducer是另一种管理组件状态的方法。在useReducer中,我们不再手动式的有意思的操作状态,reducers模式允许我们派发不同的“actions”。这个模式在处理复杂的状态,或者复杂的状态操作时,是非常有用的。
在这个例子中,手动操作数据的代码是这样的:
const [isNavExpanded, setIsNavExpanded] = useState();
const toggle = () => setIsNavExpanded(!isNavExpanded);
const open = () => setIsNavExpanded(true);
const close = () => setIsNavExpanded(false);
当我们引入reducer:
const [state, dispatch] = useReducer(reducer, {
isNavExpanded: true,
})
我们这样声明操作函数:
const toggle = () => dispatch({ type: 'toggle-sidebar' });
const open = () => dispatch({ type: 'open-sidebar' });
const close = () => dispatch({ type: 'close-sidebar' });
注意,现在没有一个函数是依赖状态了,它们仅仅是在分发一个action。
之后,我们引入reducer函数。我们需要在reducer函数里面实现所有类型的action的数据操作:
const reducer = (state, action) => {
...
}
为了实现它,我们使用了简单的 switch/case 操作:
const reducer = (state, action) => {
switch(action.type) {
case 'open-sidebar':
retrun { ...state, isNavExpanded: true };
case 'close-sidebar':
retrun { ...state, isNavExpanded: false };
case 'toggle-siderbar:
return {
...state,
isNavExpanded: !state.isNavExpanded,
};
}
}
之后,我们需要做的是把这些函数加到api里面:
const NavigationController = () => {
// state and dispatch are returned from the useReducer
const [state, dispatch] = useReducer(reducer, { ... });
const api = useMemo(() => {
return {
open: () => dispatch({ type: 'open-sidebar' }),
close: () => dispatch({ type: 'close-sidebar' }),
toggle: () => dispatch({ type: 'toggle-sidebar' }),
}
// don't depend on the state directly anymore!
}, []);
}
现在,当我们传递api给Provider时,没有一个Context消费者会因为状态变化而重新渲染:value(这几个函数的索引值)并没有变化!我们可以放心四处使用toggle函数了,不用担心相关的性能问题。
代码示例: advanced-react.com/examples/08…
这个reducer模式在处理复杂状态操作时,是非常有用的。但从重新渲染器的角度,它和useState是一样的:通过dispatch触发的状态更新,一样会导致组件的重新渲染。
Context选择器
但是,如果你不想本状态迁移到reducer或者切分Provider,该怎么办?如果你只是需要在一个性能敏感的地方,使用Context的数据怎么办?此时切分Provdier是不是工作量太繁重了?
在Redux中,我们会针对这类场景使用状态选择器。不幸的是,对于Context,这是不起作用的 -- 任何一个Context的值的变化,都会触发这个context的每一个消费者的重新渲染。
const useOpen = () => {
const { open } = useContext(Context);
// even if we additionally memoize it here, it won't help
// change in Context value will trigger re-render of the component that uses useOpen
return useMemo(() => open, []);
};
好在我们有一个技巧可以模拟状态选择器的行为,让我们可以使用一个Context的一个状态而不触发重新渲染。我们可以通过高级组件来实现:
// it's a HOC, so it accepts a component and returns another component
const withNavigationOpen = (AnyComponent) => {
return (props) => <AnyComponent {...props} />;
};
之后,我们从Context获取open函数,比把这个open函数当作属性传给prop参数:
const withNavgationOpen = (AnyComponent) => {
return (props) => {
// access Context - it's just another component
const { open } = useContext(Context);
return <AnyComponent {...props} openNav={open} />
}
}
如此一来,每一个传入该HOC的组件都会有一个openNav属性:
// openNav is coming from HOC
const SomeHeavyComponent = withNavigationOpen(
({ openNav }) => {
return <button onClick={openNav} />
}
);
但是,这样还解决不了问题:这个SomeHeavyComponent依然会在Context的值变动时重新渲染。我们需要做最后一件事:我们要把传入HOC的组件参数进行缓存:
const withNavigationOpen = (AnyComponent) => {
// wrap the component from the arguments in React.memo here
const AnyComponentMemo = React.memo(AnyComponent);
return (props) => {
const { open } = useContext(Context);
// return memoized component here
// now it won't re-render because of Context changes
// make sure that whatever is passed as props here don't change between re-renders!
return <AnyComponentMemo {...props} openNav={open} />;
};
};
现在,当Context的值发生变化时,使用了Context的值的组件都会重新渲染:通过withNavigationOpen返回的未命名组件也一样会重新渲染。但是这个为名组件渲染的是另一个被缓存的组件。所以,如果这个被缓存的组件的属性没有发生变化,这个被缓存的组件并不会因为Context的变化而重新渲染。而那些属性并不会变化,他们来自“外部”,所以并不会因Context的变化而受影响。而open函数是通过Context的Provider进行缓存的。
如此一来,SomeHeavyComponent组件可以安全地使用openNav函数:SomeHeavyComponent不会因为Context的值变化而重新渲染。
代码示例: advanced-react.com/examples/08…
知识概要
我希望这个章节能让你明白,在涉及重新渲染器时,Context能在多大程度上发挥作用,以及Context如何减少组件的属性。当然了,我并不推荐四处使用Context:使用它有太多注意事项了。在开发大型应用时,使用支持选择context的第三方状态管理库是更好的。对于小型应用,使用Context还是可行的。
然后别忘记:
- 使用Context(或者任何类似Context的状态管理库),我们可以无需通过属性来把一个组件的数据传到其孙节点(甚至更深的节点)。
- 通过这个方式来传递数据可以提升应用的性能,避免了一些不必要的重新渲染。
- 但是,Context也有其风险:所有使用了Context数据的组件都会因Context的值变化而重新渲染。这种重新渲染是无法通过普通的缓存技巧解决的。
- 为了最大程度上减少因Context引起的重新渲染,我们应该缓存传递给Provider的数据。
- 为了最大程度上减少因Context引起的重新渲染,我们可以切分Context为好几个不同的Context。或者,使用useReducer。
- 即便我们没有Context选择器,我们可以通过高阶组件和React.memo来模拟这个选择器。