避免不必要的Context重新渲染:切分Providers
除了所有上下文(Context)消费者组件会在值发生变化时重新渲染这一事实之外,不仅要着重强调 "值发生变化" 这一点,还要强调所有消费者组件都会如此进行重新渲染这一点,这很重要。如果我给我们的导航 API 引入实际上并不依赖状态的打开和关闭功能,如下情况:
js
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)操作都无法阻止它。例如,下面这种做法是行不通的:
js
const SomeComponent = () => {
const { open } = useNavigation();
return useCallback(open, []);
}
不过,我们可以使用一种名为 "拆分提供者(splitting providers)" 的有趣技术来达成期望的结果。
代码是下面这样的。我们不再把所有内容都放在一个Context里面,我们可以生成两个Context:一个Context用于存储isNavExpanded
值,另一个存储其他值。
js
// store the state here
const ContextData = React.createContext({
isNavExpanded: false,
});
// store the open/close functions here
const ContextApi = React.createContext({
open: () => {},
close: () => {},
});
然后,我们不再使用单一的Provider,而是使用两个:
js
const NavigationController = ({ children }) => {
...
return (
<ContextData.Provider value={data}>
<ContextApi.Provider value={api}>
{children}
</ContextApi.Provider>
</ContextData.Provider>
)
}
我们会把已经缓存的data和api传递给对应的Provider
js
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)进行抽象处理即可:
js
const useNavigationData = () => useContext(ContextData);
const useNavigationApi = () => useContext(ContextApi);
之后,SomeComponent
可以自由地使用open
函数了。它可以自由地调用展开/折叠 函数,但SomeComponent
的一些子组件不会因此而重新渲染:
js
const SomeComponent = () => {
const { open } = useNavigationApi();
return ...
}
我们之前是使用useNavigation
钩子来获取isNavExpanded
,而现在我们使用useNavigationData
,不用改变其他代码:
js
const AdjustableColumnsBlock = () => {
cosnt { isNavExpanded } = useNavigationData();
return isNavExpanded ? <TwoColumns /> : <ThreeColumns />
}
代码示例: advanced-react.com/examples/08...
当然,我们可以根据自身需求尽可能细致地拆分这些提供者(providers)。这完全取决于什么做法对您的应用程序来说是合理的,以及因上下文(Context)导致的重新渲染是否确实有害。
Reducers 与 切分Providers
正如你可能从上面内容注意到的那样,我不得不从我们的应用程序中去掉切换功能。遗憾的是,这个切换功能依赖于状态,所以如果我把它添加到 API 提供者中,它也会开始依赖状态,这样一来,之前所做的分离就没什么意义了:
js
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>
)
}
这不是最理想的方式。任何使用这个状态的人,需要自己实现一个切换函数:
js
const ExpandButton = () => {
const { isNavExpanded, open, close } = useNavigation();
return (
<button onClick={isNavExpanded ? close: open}>
{isNavExpanded ? 'Collpase' : 'Expand'}
</button>
);
};
这不是最理想的情况。理想情况是,这个钩子可以自动处理这些常见情况。
好在,我们可以用useReducer
来实现这个。
useReducer
是另一种管理组件状态的方法。在useReducer
中,我们不再手动式的有意思的操作状态,reducers模式允许我们派发不同的"actions"。这个模式在处理复杂的状态,或者复杂的状态操作时,是非常有用的。
在这个例子中,手动操作数据的代码是这样的:
js
const [isNavExpanded, setIsNavExpanded] = useState();
const toggle = () => setIsNavExpanded(!isNavExpanded);
const open = () => setIsNavExpanded(true);
const close = () => setIsNavExpanded(false);
当我们引入reducer:
js
const [state, dispatch] = useReducer(reducer, {
isNavExpanded: true,
})
我们这样声明操作函数:
js
const toggle = () => dispatch({ type: 'toggle-sidebar' });
const open = () => dispatch({ type: 'open-sidebar' });
const close = () => dispatch({ type: 'close-sidebar' });
注意,现在没有一个函数是依赖状态了,它们仅仅是在分发一个action。
之后,我们引入reducer函数。我们需要在reducer函数里面实现所有类型的action的数据操作:
js
const reducer = (state, action) => {
...
}
为了实现它,我们使用了简单的 switch/case 操作:
js
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里面:
js
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
触发的状态更新,一样会导致组件的重新渲染。