另一个经常影响重新渲染的点,就是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
组件的 ExpandButton
和MainPart
组件的AdjustableColumnBlock
都需要访问这个状态。考虑到这一点,如果我们要以一种简单直接的方式来实现这个功能,那我们就别无选择,只能将那个状态存储在这两个组件(前文提及的相关组件)共同的最近的父组件 ------ 也就是 Page 组件中。
js
const Page = () => {
const [isNavExpanded, setIsNavExpanded] = useState();
return ...
}
之后,把这个状态和其setter函数通过属性传递给Sidebar
和MainPart
组件:
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>
)
}
尽管技术上而言,这段代码是能跑起来的,但这不是最好的解决方案。首先,Sidebar
和MainPart
组件有一些仅仅通过它们传递,而并不会用到的属性 - 它们的代码变得更加臃肿和难以阅读。
其次,这样会拖累代码的性能。从重新渲染器的角度而言,这其中发生了什么?每次按钮被点击,导航栏会被放大或者隐藏,所以Page
组件中的状态会发生变化。而我们从第一章可知,一个状态的变化,会导致该组件,及其子孙组件的重新渲染。Sidebar
和MainPart
也有很多组件,而其中一些组件是很慢的。所以重新渲染整个页面是很慢的。
代码示例: 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
中的Layout
和Sidebar
组件不会因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...
幸运的是,这个问题是很好处理的。我们只要用好useMemo
和useCallback
来缓存传递到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)操作实际上并非过早优化。它能够预防未来几乎不可避免会出现的更为严重的问题。