React 的 Context API 存在的问题
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
原文发表于leewarrick.com/blog
React 的 Context API 太棒了。我以前刚入行的时候,一看到 Redux 就觉得束手无策,后来学到 Context 之后简直如释重负。我把它用在自己的应用里,很快就把 Redux 抛到脑后,再也没用过 Redux。
直到我听说 Context API 存在性能问题,情况才有所改变。React 社区的大佬们会告诉你,除非遇到问题,否则不用担心性能。然而,我却不断听到其他开发者抱怨 Context 的问题。甚至有人提到,他的老板已经禁止在他们的项目中使用 Context。
在讨论 Context API 的问题之前,让我们先回顾一下它,以防您不熟悉它。
为什么要使用上下文 API?
Context API 可用于在组件之间共享那些无法通过 props 轻松共享的状态。以下是一个按钮组件需要设置其远祖组件状态的示例:
(注:如需查看这些片段的完整版本,请访问原文)
const { useState } = React
function CountDisplay({ count }) {
return <h2>The Count is: {count}</h2>
}
function CountButton({ setCount }) {
return (
<button onClick={() => setCount(count => count + 1)}>
Increment
</button>
)
}
const OuterWrapper = ({setCount}) => <InnerWrapper setCount={setCount}/>
const InnerWrapper = ({setCount}) => <CountButton setCount={setCount}/>
function App() {
const [count, setCount] = useState(0)
return (
<div>
<CountDisplay count={count} />
<OuterWrapper setCount={setCount}/>
</div>
)
}
render(App)
按钮组件位于组件树的更下层,但仍然需要访问应用上层组件的状态。因此,我们必须setCount逐个组件传递状态,最终才能将状态传递给我们的CountButton组件。这被戏称为“prop-drilling”(属性传递),曾经是 React 的一个痛点。
值得庆幸的是,Context API 可以轻松解决这类问题。
如何使用 Context API
Kent C. Dodds 写了一篇非常棒的博文,每次我实现 Context API 时都会参考它。如果您没时间阅读全文,这里是简短版本:Context 是一种在不相关或距离较远的组件之间共享状态的方法。您只需将组件包裹在 `<Context>` 标签中,Context.Provider然后useContext(Context)在组件内部调用 `Context.getState()` 即可访问状态和辅助函数。
以下是我们附带上下文的反例:
const {useContext, useState, createContext} = React
const AppContext = createContext()
function AppProvider(props) {
const [count, setCount] = useState(0)
const value = { count, setCount }
return (
<AppContext.Provider value={value}>
{props.children}
</AppContext.Provider>
)
}
function CountDisplay() {
const { count } = useContext(AppContext)
return <h2>The Count is: {count}</h2>
}
function CountButton() {
const { setCount } = useContext(AppContext)
return (
<button onClick={() => setCount(count => count + 1)}>
Increment
</button>
)
}
const OuterWrapper = () => <InnerWrapper />
const InnerWrapper = () => <CountButton />
function App() {
return (
<div>
<AppProvider>
<CountDisplay/>
<OuterWrapper/>
</AppProvider>
</div>
)
}
render(App)
这里我们有CountDisplay两个CountButton组件,它们都需要与上下文中的上层count状态进行交互。首先,我们使用 `context` 创建一个上下文createContext,然后在 `context` 中创建一个提供程序组件AppProvider来包装依赖组件,最后useContext在每个组件中调用 `getState()` 方法来获取所需的值。只要组件被提供程序包装,它们之间的距离就无关紧要。
很棒吧?
Kent C. Dodd 的优化方案📈
我们可以通过借鉴肯特在其关于状态管理的文章中的一些内容来改进这一点。让我们来看一下:
const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()
// instead of calling useContext directly in our components,
// we make our own hook that throws an error if we try to
// access context outside of the provider
function useAppContext() {
const context = useContext(AppContext)
if (!context)
throw new Error('AppContext must be used with AppProvider!')
return context
}
function AppProvider(props) {
const [count, setCount] = useState(0)
// here we use useMemo for... reasons.
// this says don't give back a new count/setCount unless count changes
const value = useMemo(() => ({ count, setCount }), [count])
return <AppContext.Provider value={value} {...props} />
}
function CountDisplay() {
const { count } = useAppContext()
return <h2>The Count is: {count}</h2>
}
function CountButton() {
const { setCount } = useAppContext()
return (
<button onClick={() => setCount(count => count + 1)}>
Increment
</button>
)
}
const OuterWrapper = () => <InnerWrapper />
const InnerWrapper = () => <CountButton />
function App() {
return (
<div>
<AppProvider>
<CountDisplay />
<OuterWrapper />
</AppProvider>
</div>
)
}
render(App)
我们做的第一件事就是,如果尝试在提供程序之外访问上下文,就抛出一个错误。这对于改善应用程序的开发者体验来说是个好主意(换句话说:当你忘记上下文的工作原理时,控制台会发出警告)。
第二点是缓存上下文值,这样只有在值发生变化时才重新渲染count。这useMemo可能有点难理解,但基本原理是,缓存某个值就相当于声明,除非指定的值发生变化,否则不会再次返回该值。肯特也写过一篇很棒的文章,如果你想了解更多,可以去看看。
我感觉不出使用useMemo和不使用记忆化有什么区别,但我认为,如果你在上下文提供程序中执行大量运算,启用记忆化可能会有所帮助。如果你读过 Kent 的文章useMemo,useCallback他建议除非性能开始下降,否则不要使用记忆化。(坦白说:我从未需要使用过记忆化。)
Kent 还把他的收益分散props到提供商那里,而不是使用props.children,这是一个巧妙的技巧,所以我也把它加了进去。
Context API 的不可告人的秘密🤫
哇,Context API 真棒!它比 Redux 好用得多,而且需要的代码也少得多,所以你有什么理由不用它呢?
上下文的问题很简单:每次上下文状态改变时,所有使用上下文的内容都会重新渲染。
这意味着,如果你在应用程序的各个地方都使用上下文,或者更糟糕的是,使用一个上下文来管理整个应用程序的状态,那么你就会在各个地方造成大量的重新渲染!
让我们用一个简单的应用程序来形象化地说明这一点。我们创建一个包含计数器和消息的上下文。消息的值永远不会改变,但会被三个组件读取,每个组件在每次渲染时都会以随机颜色显示消息。计数器的值会被一个组件读取,并且是唯一会改变的值。
这听起来像是一道初中数学题,但如果你看一下这段代码和最终生成的应用程序,问题就显而易见了:
const {useContext, useState, createContext} = React
const AppContext = createContext()
function useAppContext() {
const context = useContext(AppContext)
if (!context)
throw new Error('useAppContext must be used within AppProvider!')
return context
}
function AppProvider(props) {
// the count for our counter component
const [count, setCount] = useState(0)
// this message never changes!
const [message, setMessage] = useState('Hello from Context!')
const value = {
count,
setCount,
message,
setMessage
}
return <AppContext.Provider value={value} {...props}/>
}
function Message() {
const { message } = useAppContext()
// the text will render to a random color for
// each instance of the Message component
const getColor = () => (Math.floor(Math.random() * 255))
const style = {
color: `rgb(${getColor()},${getColor()},${getColor()})`
}
return (
<div>
<h4 style={style}>{message}</h4>
</div>
)
}
function Count() {
const {count, setCount} = useAppContext()
return (
<div>
<h3>Current count from context: {count}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
function App() {
return (
<div>
<AppProvider>
<h2>Re-renders! 😩</h2>
<Message />
<Message />
<Message />
<Count />
</AppProvider>
</div>
)
}
render(App)
点击递增按钮后,所有内容都会重新渲染😱。
消息组件甚至没有使用count我们上下文中的元素,但它们仍然重新渲染了。糟糕!
那么,记忆化呢?
也许我们只是忘记useMemo像肯特在他的例子中那样使用了。让我们记忆一下上下文,看看会发生什么:
const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()
function useAppContext() {
const context = useContext(AppContext)
if (!context) throw new Error('useAppContext must be used within AppProvider!')
return context
}
function AppProvider(props) {
const [count, setCount] = useState(0)
const [message, setMessage] = useState('Hello from Context!')
// here we pass our value to useMemo,
// and tell useMemo to only give us new values
// when count or message change
const value = useMemo(() => ({
count,
setCount,
message,
setMessage
}), [count, message])
return <AppContext.Provider value={value} {...props}/>
}
function Message() {
const { message } = useAppContext()
const getColor = () => (Math.floor(Math.random() * 255))
const style = {
color: `rgb(${getColor()},${getColor()},${getColor()})`
}
return (
<div>
<h4 style={style}>{message}</h4>
</div>
)
}
function Count() {
const {count, setCount} = useAppContext()
return (
<div>
<h3>Current count from context: {count}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
function App() {
return (
<div>
<AppProvider>
<h2>Re-renders! 😩</h2>
<Message />
<Message />
<Message />
<Count />
</AppProvider>
</div>
)
}
render(App)
不!使用记忆化useMemo完全没用!
对于不使用 Context 的组件,它们会重新渲染吗?
这是一个很好的问题,让我们用一个不消耗上下文的 Message 组件来测试一下:
const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()
function useAppContext() {
const context = useContext(AppContext)
if (!context) throw new Error('useAppContext must be used within AppProvider!')
return context
}
function AppProvider(props) {
const [count, setCount] = useState(0)
const [message, setMessage] = useState('Hello from Context!')
const value = useMemo(() => ({
count,
setCount,
message,
setMessage
}), [count, message])
return <AppContext.Provider value={value} {...props}/>
}
// this component does NOT consume the context
// but is still within the Provider component
function IndependentMessage() {
const getColor = () => (Math.floor(Math.random() * 255))
const style = {
color: `rgb(${getColor()},${getColor()},${getColor()})`
}
return (
<div>
<h4 style={style}>I'm my own Independent Message!</h4>
</div>
)
}
function Message() {
const { message } = useAppContext()
const getColor = () => (Math.floor(Math.random() * 255))
const style = {
color: `rgb(${getColor()},${getColor()},${getColor()})`
}
return (
<div>
<h4 style={style}>{message}</h4>
</div>
)
}
function Count() {
const {count, setCount} = useAppContext()
return (
<div>
<h3>Current count from context: {count}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
function App() {
return (
<div>
<AppProvider>
<h2>Re-renders! 😩</h2>
<Message />
<Message />
<Message />
<IndependentMessage />
<Count />
</AppProvider>
</div>
)
}
render(App)
嗯,这是目前唯一的好消息。只有那些useContext在上下文状态改变时会重新渲染的组件才会出现这种情况。
但这对于我们的应用来说是个坏消息。我们不希望在所有使用上下文的地方都触发大量不必要的重新渲染。
想象一下,如果这些消息组件要执行大量工作,例如计算动画,或者如果我们有一个庞大的 React 应用,其中包含许多依赖于上下文的组件,那可能会导致非常严重的性能问题,对吧?
我们应该停止使用 Context 吗?
我先声明一下:不,这并不是停止使用上下文的理由。市面上有很多应用都在使用上下文,而且运行良好,包括我自己开发的很多应用。
不过,性能确实非常重要。我不想让你们晚上睡不着觉,担心 Context API 的那些不可告人的秘密。所以,我们来谈谈处理重新渲染问题的几种方法。
方案一:别担心。继续像往常一样做 Context'n。人生苦短,及时行乐🤪!
我基本上在很多不同的应用中都大量使用了 Context,而且没有启用 memoization,它被放在应用的顶层,并被很多组件调用,但我完全没有注意到任何性能下降。就像我之前说的,很多 React 开发者都认为,在看到性能影响之前,你根本不需要担心性能优化。
然而,这种策略并非适用于所有人。您的应用可能已经存在性能问题,或者如果您的应用需要处理大量逻辑或动画,随着应用规模的扩大,您可能会遇到性能问题,最终不得不进行一些大规模的重构。
方案二:使用 Redux 或 Mobx
Redux 和 Mobx 都使用了 Context API,那么它们是如何发挥作用的呢?这些状态管理库与 Context 共享的 store 与直接与 Context 共享状态略有不同。使用 Redux 和 Mobx 时,会有一个 diffing 算法在运行,确保只重新渲染真正需要重新渲染的组件。
然而,上下文原本是为了让我们免于学习 Redux 和 Mobx!使用状态管理库涉及大量的抽象和样板代码,这使得它对某些人来说缺乏吸引力。
另外,将我们所有州都保持在全局状态难道不是一种不好的做法吗?
选项 3:使用多个上下文,并将状态保持在与其依赖组件接近的位置
这种方案需要最精妙的技巧才能实现,但它能在不使用 Redux 和 Mobx 的情况下提供最佳性能。它依赖于对状态管理策略的巧妙运用,并且仅在需要在不同组件间共享状态时才将其传递给上下文。
该策略包含以下几个关键原则:
- 如果可以,让组件自行管理状态。无论你选择哪种状态管理方式,这都是一个值得遵循的良好实践。例如,如果你有一个模态框需要跟踪其打开/关闭状态,但其他组件不需要知道该模态框是否打开,那么就将该打开/关闭状态保存在模态框中。如果没有必要,就不要将状态推送到上下文(或 Redux)中!
- 如果你的状态需要在父组件和几个子组件之间共享,那就直接向下传递。这是共享状态的传统方法。只需将状态作为 props 传递给需要它的子组件即可。对于嵌套很深的组件,传递 props 或“prop 层层传递”可能会很麻烦,但如果你只向下传递几层状态,那么这样做就足够了。
- 如果前两种方法都失败了,可以使用上下文,但要确保它与依赖它的组件保持紧密联系。这意味着,如果您需要共享某些状态(例如与多个组件共享表单),请为表单创建一个单独的上下文,并将表单组件包装在您的上下文提供程序中。
最后一点需要举个例子。让我们把它应用到之前的问题应用中。我们可以通过将 ` messageand`分离count到各自的上下文中来解决重新渲染的问题。
const { useContext, useState, createContext } = React
const CountContext = createContext()
// Now count context only worries about count!
function useCountContext() {
const context = useContext(CountContext)
if (!context)
throw new Error('useCountContext must be used within CountProvider!')
return context
}
function CountProvider(props) {
const [count, setCount] = useState(0)
const value = { count, setCount }
return <CountContext.Provider value={value} {...props}/>
}
// And message context only worries about message!
const MessageContext = createContext()
function useMessageContext() {
const context = useContext(MessageContext)
if (!context)
throw new Error('useMessageContext must be used within MessageProvider!')
return context
}
function MessageProvider(props) {
const [message, setMessage] = useState('Hello from Context!')
const value = { message, setMessage }
return <MessageContext.Provider value={value} {...props}/>
}
function Message() {
const { message } = useMessageContext()
const getColor = () => (Math.floor(Math.random() * 255))
const style = {
color: `rgb(${getColor()},${getColor()},${getColor()})`
}
return (
<div>
<h4 style={style}>{message}</h4>
</div>
)
}
function Count() {
const {count, setCount} = useCountContext()
return (
<div>
<h3>Current count from context: {count}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
function App() {
return (
<div>
<h2>No Unnecessary Re-renders! 😎</h2>
<MessageProvider>
<Message />
<Message />
<Message />
</MessageProvider>
<CountProvider>
<Count />
</CountProvider>
</div>
)
}
render(App)
现在,我们的状态仅与关心该状态的组件共享。当我们递增状态时,消息组件的颜色保持不变,因为count它们位于外部messageContext。
最后想说的话
尽管这篇文章的标题可能有点耸人听闻,而且上下文的“问题”或许不像某些人想象的那样可怕,但我仍然认为值得一提。React 的灵活性使其既是初学者的理想框架,也可能成为那些不了解其内部机制的人的致命陷阱。我预计不会有很多人会在这个细节上栽跟头,但如果你在使用上下文时遇到了性能问题,那么了解这一点就很有帮助了!
文章来源:https://dev.to/leewarrickjr/the-problem-with-react-s-context-api-3dn0


