第八章 React Context 与 性能 上

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

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

另一个经常影响重新渲染的点,就是Context了。在讨论重新渲染时,Context时常有不好的名声。我觉得,人们常常觉得Context就是在app四处作怪的葛雷姆林,导致一些自发的、无法停止的重新渲染。结果,开发者不得不尽可能避开Context。

Context的一些恶名是确有其实的:Context确实有些问题。然而,常常被低估或者根本不为人知的一点是,只要运用得正确且谨慎,Context 能够防止不必要的重新渲染,进而显著提升我们应用程序的性能。

但最重要的是,理解Context有助于我们理解类似Redux这样的外部状态管理库。它们的心智模型是一样的。如果你学会了Context,你可以轻松的把任何状态管理库用出最佳性能。

所以,让我实现一个有context和无context的app,并在作怪过程中学到:

  • Context能够提供的那种性能提升。
  • 使用context的注意事项。
  • 如何充分利用context并防止由它导致的不必要的重新渲染。

问题

想象一下,你要实现一个两列布局的页面:左边是侧边栏,右边是主要内容。左边的侧边栏3⃣️可折叠的:通过一个按钮触发折叠。

从代码的视角,代码结构如下:

js 复制代码
const Page = () => {
    return (
        <Layout>
            <MainPart />
        </Layout>
    )
}

Siderbar组件,则是这样:

js 复制代码
const Sidebar = () => {
    return (
        <div className="sidebar">
            {/* this one will control the expand/collapse */}
            <ExpandButton />
            {/* ... other sidebar stuff */}
            <Link ... />
            <Plugin ... />
        </div>
    )
}

而这,是MainPart组件的内容:

js 复制代码
const MainPart = () => {
    return (
        <>
            <VerySlowComponent />
            <AnotherVerySlowComponent />
            {/* this one needs to know whether the sidebar is expanded or collapsed */}
            {/* it will render two or three columns, depending on this
           information */}
            <AdjustableColumnsBlock />
        </>
    );
   };

那么,我们该如何实现 隐藏/显示 的功能?我们需要引入isNavExpanded状态。而Sidebar组件的 ExpandButtonMainPart组件的AdjustableColumnBlock都需要访问这个状态。考虑到这一点,如果我们要以一种简单直接的方式来实现这个功能,那我们就别无选择,只能将那个状态存储在这两个组件(前文提及的相关组件)共同的最近的父组件 ------ 也就是 Page 组件中。

js 复制代码
const Page = () => {
    const [isNavExpanded, setIsNavExpanded] = useState();
    
    return ...
}

之后,把这个状态和其setter函数通过属性传递给SidebarMainPart组件:

js 复制代码
const Sidebar = ({ isNavExpanded, toggleNav }) => {
    return (
        <div className="sidebar">
            {/* pass the props here */}
            <ExpandButton
                isExpanded={isNavExpanded}
                onClick={toggleNav}
            />
            {/* ... // the rest of the stuff */}
        </div>
    );
};

这是AdjustableColunmsBlock的代码:

js 复制代码
const MainPart = ({ isNavExpanded }) => {
    return (
        <>
            <VerySlowComponent />
            <AnotherVerySlowComponent />
            <AdjustableColunmsBlock
                isNavExpanded={isNavExpanded}
            />
        </>
    )
}

Page组件的完整代码是这样的:

js 复制代码
const Page = () => {
    const [isNavExpanded, setIsNavExpanded] = useState();
    
    return (
        <Layout>
            <Sidebar
                isNavExpanded={isNavExpanded}
                toggleNav={() => setIsNavExpanded(!isNavExpanded)}
            />
            <MainPart isNavExpanded={isNavExpanded}/>
        </Layout>
    )
}

尽管技术上而言,这段代码是能跑起来的,但这不是最好的解决方案。首先,SidebarMainPart组件有一些仅仅通过它们传递,而并不会用到的属性 - 它们的代码变得更加臃肿和难以阅读。

其次,这样会拖累代码的性能。从重新渲染器的角度而言,这其中发生了什么?每次按钮被点击,导航栏会被放大或者隐藏,所以Page组件中的状态会发生变化。而我们从第一章可知,一个状态的变化,会导致该组件,及其子孙组件的重新渲染。SidebarMainPart也有很多组件,而其中一些组件是很慢的。所以重新渲染整个页面是很慢的。

代码示例: advanced-react.com/examples/08...

更不幸的是,我们无法使用之前章节的技巧来避免这个问题:它们都依赖于会导致重新渲染的状态。我们也许可以缓存那些慢速运行又不依赖状态的组件。但是这样代码会变得更加臃肿。

还有一个更好的办法:Context。

Context如何发挥作用

在这个场景中,Context(或者任何类似context的状态管理库)的是很有用的。它们能够帮助我们逃离组件树,不再依赖通过属性来传递状态,而是可以从组件树的最顶端传到最低端。

代码会是下面这样。我们可以把展示、隐藏的功能从Page组件中抽离出来:

js 复制代码
const NavigationController = () => {
    cont [isNavExpanded, setIsNavExpanded] = useState();
    
    cosnt toggle = () => setIsNavExpanded(!isNavExpanded);
}

之后,我们把在Page组件中要渲染的子组件传递进去:

js 复制代码
const NavigationController = ({ children }) => {
    cont [isNavExpanded, setIsNavExpanded] = useState();
    
    cosnt toggle = () => setIsNavExpanded(!isNavExpanded);
    
    return children;
}

这是"子组件作为属性"模式:

js 复制代码
const Page = () => {
    return (
        <NavigationController>
            <Layout>
                <Siderbar />
                <MainPart />
            </Layout>
        </NavigationController>
    )
}

所有的属性都消失了,而且最重要的是,在Page中的LayoutSidebar组件不会因NavigationController中的状态变化,而重新渲染。 如第二章所言,children也不过是一个属性,而属性不会被状态影响。

最后,我们可以在NavigatonController中引入Context。Context里维护了关于导航的状态,以及改变这个状态的API:

js 复制代码
// creating context with default values
const Context = React.createContext({
    isNavExpanded: true,
    toggle: () => {}
});

在渲染时,需要调用Provider:

js 复制代码
const NavigationController = ({ children }) => {
    cont [isNavExpanded, setIsNavExpanded] = useState();
    
    cosnt toggle = () => setIsNavExpanded(!isNavExpanded);
    
    return <Context.Provider>{children}</Context.Provider>
}

而最后,让这个组件能够运用的关键是:把value属性传递给Context。

js 复制代码
const NavigationController = ({ children }) => {
    cont [isNavExpanded, setIsNavExpanded] = useState();
    
    cosnt toggle = () => setIsNavExpanded(!isNavExpanded);
    
    return (
        <Context.Provider value={value}>
            {children}
        </Context.Provider>
    )
}

现在,渲染在这个provider下面所有子组件都可以通过useContext钩子来访问value的值:

js 复制代码
// pass that Context to the useContext hook
const useNavigation = () => useCOntext(Context);

之后,任何需要相关信息的地方,直接调用钩子就可以得到相关信息了:

js 复制代码
const ExpandButton = () => {
    const { isNavExpanded, toggle } = useNavigation();
        return (
            <button onClick={toggle}>
                {isNavExpanded ? 'Collapse' : 'Expand'}
            </button>
    );
};

需要依据isNavExpanded来动态渲染列数的AdjustableColumnsBlock组件:

js 复制代码
const AdjustableColumnsBlock = () => {
     const { isNavExpanded } = useNavigation();

     return isNavExpanded ? <TwoColumns /> : <ThreeColumns />;
};

这样一来,就不用四处传送属性来。当Context中的状态发生变化时,调用了useNavigation钩子的组件会在Context的状态变化时而重新渲染。反之,没有调用useNavigation钩子的组件不会因Context的状态变化时而重新渲染。有了Context,我们可以极大的提升整个应用的性能。

但是呢,Context并不是这么好使用的,不然Context也不会有这么差的名声。在使用Context时,有三个点需要关心:

  • 当提供者(Provider)上的值发生变化时,上下文(Context)的消费者组件将会重新渲染。
  • 所有的消费者组件都会重新渲染,即便它们并没有使用到实际已发生变化的那部分值也不例外。
  • 而且这些重新渲染很难(轻易地)通过记忆化(memoization,如 React.memo 等技术手段)来避免。

代码示例: advanced-react.com/examples/08...

让我们更仔细地研究一下这些问题以及如何缓解它们。

当context的值发生变化

每当提供者(Provider)上的值发生变化时,使用了这个上下文的组件会因此而重新渲染。

让我们再看看代码:

js 复制代码
const NavigationController = ({ children }) => {
    cont [isNavExpanded, setIsNavExpanded] = useState();
    
    cosnt toggle = () => setIsNavExpanded(!isNavExpanded);
    
    return (
        <Context.Provider value={value}>
            {children}
        </Context.Provider>
    )
}

const useNavigation = () => useContext(Context);

每次我们改变状态,value对象就会发生变化,所以每个通过useNavigation使用了这个Context的组件都会随之重新渲染。这也是符合直觉的:我们希望每个组件都可以直接访问这个上下文,而在React中唯一的更新组件的方式就是重新渲染了。

那么,NavigationController因为其他原因重新渲染,会发生什么?比如说,如果NavigationController因其父组件而重新渲染。此时,value对象会被重新创建,React的重新渲染器会前后比对这个value对象。在第六章我们已经说过,对象是基于引用进行比较的,我们就不赘述了。所以,传递给Provider的value对象发生了变化,所以任何使用了这个Context的组件,都会重新渲染。

在这个例子中的小应用中,NavigationController倒真不是个问题:这个Provider位于render tree的最上层,所以没有什么父组件可以触发它重新渲染。然而,在复杂的大型应用中,很有可能某一天有人会引入某些东西,从而触发那个提供者(Provider)的重新渲染。

例如,在我们的Page组件中,说不定哪天我可能会决定将那个提供者(Provider)移到Layout组件内部,以便简化Page组件,具体如下:

js 复制代码
const Page = () => {
    return (
        <Layout>
            <Sidebar />
            <MainPart />
        </Layout>
    )
}

然后,由Layout来渲染context的内容:

js 复制代码
const Layout = ({ children }) => {
    return (
        <NavigationController>
            <div className="layout">{children}</div>
        </NavigationController>
    )
}

这样一来,代码和之前运行的一样,就是代码看起来更干净了。但是,如果我在Layout组件中再引入一些其他状态,会发生什么呢?也许我需要追踪页面的滑动状态:

js 复制代码
const Layout = ({ children }) => {
    const [scroll, setScroll] = useState();
    
    useEffect(() => {
        window.addEventLinstener('scroll', () => {
            setScroll(window.scrollY);
        })
    },[])
    return (
        <NavigationController>
            <div className="layout">{children}</div>
        </NavigationController>
    )
}

通常来说,这不会是个问题:这属于 "子组件作为属性(children as props)" 的模式,状态仅局限于Layout组件内,页Page上的其他组件不会受到影响。

但在这种情况下,NavigationController组件也是在其内部进行渲染的。所以滚动时的状态变化会导致它重新渲染,提供者(Provider)中的值将会改变,并且所有使用该上下文(Context)的组件都会重新渲染。

而且,如果该上下文被用在一个比较复杂的组件或者一个包含众多子组件的组件中,那么 ------ 哎呀,每次滚动时应用程序的一半都会重新渲染,然后所有操作都会变得极其缓慢。

代码示例: advanced-react.com/examples/08...

幸运的是,这个问题是很好处理的。我们只要用好useMemouseCallback来缓存传递到Provider的内容即可:

js 复制代码
const NavigationController = ({ children }) => {
    const [isNavExpanded, setIsNavExpanded] = useState();
    
    const toggle = useCallback(() => {
        setIsNavExpanded(!isNavExpanded);
    }, [isNavExpanded]);
    
    const value = useMemo(() => {
        return { isNavExpanded, toggle }
    }, [isNavExpanded, toggle]);
    
    return (
        <Context.Provider value={value}>
            {children}
        </Context.Provider>
    )
}

代码示例: advanced-react.com/examples/08...

这属于少数几种情况之一,即在默认情况下始终进行记忆化(memoization)操作实际上并非过早优化。它能够预防未来几乎不可避免会出现的更为严重的问题。

相关推荐
哈罗哈皮几秒前
龙虾(openclaw)本地快速安装及使用教程
前端·aigc·ai编程
用户23115444530581 分钟前
React中实现“双向绑定”效果的几种方式
前端
HelloReader2 分钟前
Flutter Sliver 高级滚动打造 iOS 通讯录体验(十三)
前端
a11177634 分钟前
程序化几何背景生成器(html 开源)
前端·开源·html
浮笙若有梦1 小时前
我开源了一个比 Ant Design Table 更好用的高性能虚拟表格
前端·vue.js
一只程序熊1 小时前
vite-cool-unix-ctx] Unexpected token l in JSON at position 0
java·服务器·前端
张元清1 小时前
React Hooks vs Vue Composables:2026 年全面对比
前端·javascript·面试
yuki_uix1 小时前
从三个自定义 Hook 看 React 状态管理的设计思想
前端·javascript
大漠_w3cpluscom1 小时前
如何在 clamp() 中使用 auto 值
前端·css·html
Younglina1 小时前
🏸 从零打造一个羽毛球球线追踪网站:纯前端实战指南
前端