🤯 一行代码,优雅的终结 React Context 嵌套地狱!

什么是 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 问题的方案还有 memouseMemo 等,就不列举了,一方面是本文重点在于介绍嵌套地狱,另一方面在于众所周知 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 />
  </>)
}

它的特性:

  1. 代码结构清晰,无嵌套结构
  2. 拆分 context,状态隔离,避免非必要渲染

总结

  • 总体思路:多个状态合并到一个 Context 管理会导致非必要更新问题 => 拆分 Context、Provider 避免非必要更新 => 存在嵌套地狱问题 => 组件的嵌套实际上是子孙组件的不断创建 => React.cloneElement + reduceRight 循环创建子孙组件

  • 当然了,如果是较为复杂的状态管理,最直接的解决方式是不使用 Context,根据具体情况可使用 React.useReducer 和 Redux/Mobx/Valtio/Jotai/Zustand 等状态管理库。

参考链接:

相关推荐
练习两年半的工程师2 小时前
使用React和google gemini api 打造一个google gemini应用
javascript·人工智能·react.js
姑苏洛言4 小时前
30天搭建消防安全培训小程序
前端
左钦杨5 小时前
Nuxt2 vue 给特定的页面 body 设置 background 不影响其他页面
前端·javascript·vue.js
yechaoa5 小时前
【揭秘大厂】技术专项落地全流程
android·前端·后端
逛逛GitHub5 小时前
推荐 10 个受欢迎的 OCR 开源项目
前端·后端·github
_xaboy6 小时前
开源 FormCreate 表单设计器配置组件的多语言
前端·vue.js·低代码·开源·可视化表单设计器
uglyduckling04126 小时前
小程序构建NPM失败
前端·小程序·npm
草原上唱山歌6 小时前
C/C++都有哪些开源的Web框架?
前端·c++·开源
烛阴6 小时前
JavaScript 调度:setTimeout 和 setInterval
前端·javascript