什么是 Context 嵌套地狱?
React Context 嵌套地狱是指 React Context Provider 的多层嵌套 ,如下图所示,类似于前端问题中的 回调地狱 一样,越来越多的 Context 会导致嵌套层数越来越大,导致代码阅读性极差。
为什么会写出 Context 嵌套地狱这种代码?
Context 是 React 的上下文状态管理 API,允许我们跨组件实现状态透传,从而达到状态共享的目的。但是 Context 存在性能问题,就是 当 Context 包含多个状态属性时,当修改了其中的状态,由于 React 的 re-render 特性, 所有依赖该 Context 的组件都会重新渲染,即使某些组件所依赖该 Context 的状态值并没有变。代码示例如下所示:
定义 Context(context.ts):
tsx
import { createContext } from 'react'
export const AppContext = createContext<{
theme: 'dark' | 'light',
count: number,
increase: () => void
}>({
theme: 'dark',
count: 0,
increase() {}
})
提供和消费 Context(page.tsx):
tsx
import { use, useState } from "react"
import { AppContext } from "./context"
export default function App() {
const [count, setCount] = useState(0)
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
return (
<AppContext.Provider value={{ count, theme, increase: () => setCount(count + 1) }}>
<Header />
<Button />
</AppContext.Provider>
)
}
function Header() {
const { theme } = use(AppContext)
console.log('Header rendered')
return (
<header className={theme}>Header rendered, cur theme: {theme}</header>
)
}
function Button() {
const { count, increase } = use(AppContext)
console.log('Button rendered')
return (
<button onClick={increase}>you click me {count} times.</button>
)
}
上面代码账号,每次点击 Button 修改 AppContext 中的 { count: 0}
状态,即使 Header 并没有消费 count,但也都会重新渲染一遍,这是因为 re-render 自上而下执行,根据状态是否发生改变判断是否需要重新渲染。
当状态 count
改变时,触发 re-render,传入子组件中的 "context" 状态也都是新的对象,即使 Header 组件消费的状态其实没有发生改变,但由于浅比较,React 认为变了,因此所有子组件都会重新渲染。
那么如何解决呢?关键在于状态是否改变,我们要让不需要更新的组件的状态保持不变。为此我们需要将 context 拆分到各自独立的 Provider
组件中,当状态在各自的 Provider 中发生改变时,React 会自上而下进行更新,并且只会将消费了这个 context 的组件进行重新渲染,相当于做到了状态隔离。
示例代码如下:
(1)拆分 context(context.ts):
ts
import { createContext } from 'react'
// 定义 CounterContext
export const CounterContext = createContext<{
count: number,
increase: () => void
}>({
count: 0,
increase() {}
})
// 定义 ThemeContext
export type Theme = 'dark' | 'light'
export const ThemeContext = createContext<{
theme: Theme,
toggle: () => void
}>({ theme: 'dark', toggle() {} })
(2)拆分 Provider,状态隔离:
tsx
import { use, useState } from "react"
import { CounterContext, Theme, ThemeContext } from "./context"
// 提供 ThemeContext
function ThemeContextProvider({ children } : { children ?: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark')
const toggle = () => setTheme(theme === 'dark' ? 'light' : 'dark')
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
// 提供 CounterContext
function CounterContextProvider({ children } : { children ?: React.ReactNode }) {
const [count, setCount] = useState(0)
const increase = () => setCount(count + 1)
return (
<CounterContext.Provider value={{ count, increase }}>
{children}
</CounterContext.Provider>
)
}
export default function App() {
return (
<ThemeContextProvider>
<CounterContextProvider>
<Header />
<Button />
</CounterContextProvider>
</ThemeContextProvider>
)
}
// 消费 ThemeContext
function Header() {
const { theme, toggle } = use(ThemeContext)
console.log('Header rendered')
return (
<header className={theme} onClick={toggle}>Header rendered, cur theme: {theme}</header>
)
}
// 消费 CounterContext
function Button() {
const { count, increase } = use(CounterContext)
console.log('Button rendered')
return (
<button onClick={increase}>you click me {count} times.</button>
)
}
然后随着 context 越来越多,嵌套层数也会越来越大,这就形成了 Context 嵌套地狱问题。
tsx
export default function Page() {
return (
<ThemeContextProvider>
<CounterContextProvider>
<OtherContextProvider>
// ... 更多嵌套 context
<App />
</OtherContextProvider>
</CounterContextProvider>
</ThemeContextProvider>
)
}
备注:这里解决 context 的 re-render 问题的方案还有
memo
、useMemo
等,就不列举了,一方面是本文重点在于介绍嵌套地狱,另一方面在于众所周知 React 哲学之一就是能不上优化相关的 hooks 就不上,提高代码可维护性和阅读性。
一行代码优雅的解决 Context 嵌套地狱
我们 观察上面多层嵌套的组件形式,可以看到其实就是从最上层组件一直往里塞子组件,子组件里再塞子组件,就是不断创建子组件套娃的过程。 有什么可以手动往组件中嵌入子组件的方法?答案是 React.cloneElement
,它用于克隆一个已有的 React 元素(ReactElement
),并可以为其添加新的 props
或修改现有的 props
,并可以添加子组件。
下面是 React.cloneElement
方法定义:
ts
React.cloneElement(element, [props], [...children])
element
: 被克隆的 React 元素props
: 可选参数,一个对象,包含要添加到克隆元素的新属性或要覆盖的现有属性children
: 可选参数,新的子元素(组件),会替换原先的子元素
那么原来的多层嵌套代码就可以被打平了:
tsx
export default function Page() {
const comp1 = React.cloneElement(<OtherContextProvider />, {}, <><Header /><Button /></>)
const comp2 = React.cloneElement(<CounterContextProvider />, {}, comp1)
const comp3 = React.cloneElement(<ThemeContextProvider />, {}, comp2)
return comp3
}
可以看出,上面的代码可以用 reduceRight
方法优化:
tsx
export default function Page() {
return [
<ThemeContextProvider />,
<CounterContextProvider />,
<OtherContextProvider />,
<><Header /><Button /></>
].reduceRight((pre, cur) => React.cloneElement(pre, {}, cur))
}
我们继续封装得到 MultiProviders 函数,终于点题了!这个方法核心只有简单一行代码,这就是解决方案 🎉🎉🎉!
tsx
/**
* 封装多层 Provider
*
* @param providers
* @returns 组件树
*/
type MultiProvidersPropsType = { providers: React.ReactElement[], children: React.ReactNode}
function MultiProviders({ providers, children } : MultiProvidersPropsType) {
return (<>
{ providers.reduceRight((pre, cur) => React.cloneElement(cur, {}, pre), children) }
</>)
}
export default function Page() {
return (
<MultiProviders providers={[
<ThemeContextProvider />,
<CounterContextProvider />,
<OtherContextProvider />
]}>
<App />
</MultiProviders>
)
}
function App() {
return (<>
<Header />
<Button />
</>)
}
它的特性:
- 代码结构清晰,无嵌套结构
- 拆分 context,状态隔离,避免非必要渲染
总结
-
总体思路:多个状态合并到一个 Context 管理会导致非必要更新问题 => 拆分 Context、Provider 避免非必要更新 => 存在嵌套地狱问题 => 组件的嵌套实际上是子孙组件的不断创建 =>
React.cloneElement
+reduceRight
循环创建子孙组件 -
当然了,如果是较为复杂的状态管理,最直接的解决方式是不使用 Context,根据具体情况可使用
React.useReducer
和 Redux/Mobx/Valtio/Jotai/Zustand 等状态管理库。
参考链接: