Skip to content
This page has been auto-translated and may contain errors.View in English

Context

状态提升在共享值的组件彼此靠近时效果很好。但当它们相距很远时就变得很痛苦。一个位于树顶部附近、却需要在下方六层深处使用的值,必须逐级传递给中间的每个组件,而且这些组件都得接收并转发一个它们从不使用的 prop。这种中转过程叫做**prop drilling**,这正是复合组件遇到的问题,用 Children.map 无法解决。Context 是 React 内置的解决方案:一个组件提供一个值,它下方的任何组件都可以直接读取该值,不管中间隔了多少层。

可以把它想象成在传送数据。中间层的组件看不到它,也不用转发它;它们的 prop 列表只需关心自己的事。

创建 context 并提供值

Context 有三个部分:创建它、提供值、读取值。创建发生一次,在文件顶部、任何组件外面。你通常也会导出它,因为读取它的组件往往在其他文件中。

jsx
import { createContext } from 'react'

export const ThemeContext = createContext()

export default function App() {
  return (
    <ThemeContext value="light">
      <Header />
      <Button />
    </ThemeContext>
  )
}

把 context 对象本身作为组件渲染,使其内部的所有内容都成为等待消费的对象。value prop 是有效载荷:这里是字符串 "light",但可以是任何 JavaScript 值、对象、数组,甚至函数。你放在那里的任何东西,下方的读取者都会收到。这个 prop 必须拼写为 value;这个名字是 API 的一部分。

位置很重要。provider 不一定要包裹整个应用,通常也不应该。把它放在覆盖所有需要该值的组件的最小子树周围。如果只有 UI 的一个角落关心主题,就在该角落的公共祖先处提供主题,让树的其余部分不涉及它。

版本说明

React 19 之前,context 无法直接渲染;你要渲染 <ThemeContext.Provider value="light"> 代替。React 19 让 context 对象本身可以作为 provider 渲染。.Provider 形式仍然有效,是你在大多数现有代码库和课程课时中看到的形式,但 React 计划在未来版本中弃用它,并附带一个代码迁移工具,所以在新代码中使用裸露的 <ThemeContext> 形式。

用 useContext 读取值

provider 下的任何组件都用 useContext 读取值,这是钩子中首次介绍的钩子,传递给它 context 对象,这样 React 就知道要读哪个 context。这就是为什么 context 要被导出。

jsx
import { useContext } from 'react'
import { ThemeContext } from './App'

function Header() {
  const theme = useContext(ThemeContext)

  return (
    <header className={`${theme}-theme`}>
      <h1>{theme === 'light' ? '亮色' : '暗色'}主题</h1>
    </header>
  )
}

useContext(ThemeContext) 返回这个组件上方最近的 ThemeContext provider 当前持有的值:这里是字符串 "light"。在 AppHeader 之间没有 prop 传递,在更大树中位于它们之间的组件根本不会提到主题。一个应用可以同时有多个 context,每个都分别创建,每个都通过把自己的对象传递给 useContext 来读取。

让它活起来:状态加 context

硬编码的 value="light" 永远不会改变。让 context 有用的模式是把它和状态配对:状态拥有值并更新它,context 传递它。在对象中传递当前值和改变它的函数,每个消费者都可以从 provider 下方任何地方读取或更新主题。

jsx
export const ThemeContext = createContext()

export default function App() {
  const [theme, setTheme] = useState('light')

  function toggleTheme() {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      <Header />
      <Button />
    </ThemeContext>
  )
}

function Button() {
  const { theme, toggleTheme } = useContext(ThemeContext)

  return (
    <button className={`${theme}-theme`} onClick={toggleTheme}>
      切换主题
    </button>
  )
}

点击按钮时,toggleTheme 运行,App 中的状态更新,App 重新渲染,provider 把新对象传递下去。每个读取 context 的组件都用新值重新渲染。消费者只解构它们需要的属性。

这个作用域技巧在小范围内也有效。像下拉菜单这样的组件可以在自己的子组件周围渲染一个 provider,给其内部的部分一个共享状态,不会泄漏到页面的其余部分。这是复合组件中的菜单的完整版本:

jsx
const MenuContext = createContext()

function Menu({ children }) {
  const [open, setOpen] = useState(false)
  const toggle = () => setOpen(prevOpen => !prevOpen)

  return <MenuContext value={{ open, toggle }}>{children}</MenuContext>
}

function MenuButton({ children }) {
  const { toggle } = useContext(MenuContext)

  return <button onClick={toggle}>{children}</button>
}

Menu 拥有 open 并把它和 toggle 都放在 context 上;MenuButtonuseContext 向上获取 toggle,同样编写的 MenuDropdown 读取 open 来决定是否渲染。中间没有任何东西转发东西,所以调用者可以在按钮外包裹任意多的布局 div,菜单仍然工作。

导出裸露的 context 可以,但大多数代码库会把这个模式包裹在两个部分中:拥有状态的 provider 组件和一个读取 context 并在滥用时失败大声报错的自定义钩子。

jsx
const ThemeContext = createContext(null)

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  function toggleTheme() {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (context === null) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}

现在消费者写 const { theme } = useTheme() 并根本不导入 context 对象,这使得模块的公共接口正好是两个东西,provider 和钩子。createContext 的参数是当没有 provider 位于消费者上方时 React 返回的回退值,所以这里的 null 意味着"没人提供这个",这是检查测试的内容。没有它消费者在解构一个 null 值时会崩溃,堆栈跟踪指向消费者而不是缺失的 provider。

当 provider 的 value 改变时,React 重新渲染读取该 context 的每个组件,不管它坐得多深。"改变"意味着一次失败的 Object.is 比较,这正是对象字面量值的问题所在:上方代码中传递给 value 的对象在 provider 所有者的每次渲染上都会构建一个新对象,所以那个所有者的每次渲染都会重新渲染每个消费者,无论 theme 是否真的改变了。

当所有者仅因为值改变而重新渲染时,如 App 这里所做的,memoizing 没有帮助。当所有者因无关原因重新渲染并拖拽每个消费者一起时,它就有收益了。用 useMemo 包裹对象是一半的修复:toggleTheme 在每次渲染上都是新函数,所以 memo 除非那个函数也被稳定下来,用 useCallback 或通过传递已经稳定的 setTheme 代替包装器,否则每次都会重新计算。

两个结构工具比 memoization 更重要。首先,按更新频率分割 context:一个一个会话中改变一次的值和一个在每次按键时改变的值不应该在同一个 provider 中,因为快的那个会拖拽慢的那个的消费者一起。

其次,记住 context 是一个交付机制而不是状态管理器。它解决"这个值在很远的地方需要"。它对更新如何批处理、派生或持久化没有作用。当应用的共享状态增长超过主题和用户对象时,这时一个像 Redux Toolkit 或 Zustand 这样的专用存储就有价值了。

每个存储如何到达你的组件

两者以不同方式传递存储:Redux Toolkit 应用包裹树在 react-redux 中的 <Provider store={store}>,它通过 context 把存储传递给下方的钩子,而 Zustand 的默认设置导出一个模块级钩子,任何组件直接调用它,不涉及 provider。

JunoContext 跳过中间层 当一个值必须在树中传很远时,通过每个组件传递它很快就会很累。

Context 让靠近顶部的一个组件说"这是值",让下方任何组件用 useContext 直接向上获取它。中间层的组件从不涉及它。

把它和状态配对,值也可以改变:在 provider 中把值和它的更新函数放在一起,任何消费者都可以读取或改变它。

JunoContext 跳过中间层 在模块级创建一个 context,把它作为 provider 用 value 渲染,在下方用 useContext 读取。通过在一个对象中传递状态和它的更新器使它活起来。

在真实代码中,把 provider 包裹在自己的组件中,暴露一个 useTheme 式的钩子,在 provider 外抛错;消费者得到清洁的 API 和可读的错误。

JunoContext 跳过中间层 每当提供的值失败 Object.is 时每个消费者都重新渲染,一个内联对象字面量在每次 provider 渲染时都失败它,所以在更新频率重要的地方 memoize 值。分割不同更新速率的 context,作用域 provider 到需要它们的最小子树。

把 context 当作远方值的交付;一旦共享状态需要真实管理,就用一个存储,不管它骑在 Redux Toolkit 这样的 context 上还是跳过它像 Zustand 一样。

接下来:Render props 和无头组件,一个组件提供行为并让你提供标记。