第八章 React Context 与 性能 下

203 阅读8分钟

文章出处:www.advanced-react.com/

专栏地址:juejin.cn/column/7443…

避免不必要的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来模拟这个选择器。