This is a translation of the original post How to use React Context effectively by Kent C. Dodds.

本篇翻译自Kent C. Dodds 的原作 如何高效地使用React Context Kent C. Dodds

React应用状态管理一文中,我提到过如何混合使用本地状态和React Context可以更好地管理React应用里的状态。在那篇文章中,我也展示了一些例子。今天我想就那些例子强调几点,也想说说如何高效地创建React Context Consumers来避免一些问题,这同时也能帮助你的应用或者库提高开发者体验和Context对象的可维护性。

在那之前,请务必阅读React应用状态管理. 谨记不要在一需要处理状态共享的问题时,就立马使用context来解决。 希望本篇文章能让你更好地了解如何有效地使用context。也请记住,context并不一定要存在于整个应用中的全局空间,context也可以在你的组件树中。在你的应用里,你可以(也许更应该)有多个逻辑分明的context。

首先,让我们来创建一个src/count-context.js,并且如下创建context:

import * as React from 'react'

const CountContext = React.createContext()

一开始我并没有给CountContext定义任何初始值。如果我想有一个初始值,我可以调用React.createContext({count: 0})。不过,我故意不想有个默认值。defaultValue只在如下场景有用:

function CountDisplay() {
  const {count} = React.useContext(CountContext)
  return <div>{count}</div>
}

ReactDOM.render(<CountDisplay />, document.getElementById('⚛️'))

正因为CountContext没有默认值,在高亮的代码里,一旦解构useContext返回的值就会立马报错,因为默认值是undefined,而undefined不能被解构。

运行时错误让人讨厌,所以你一拍脑袋就赶紧加了个默认值。 不过呢,如果一个context里不是一个有用的值,它能有什么用?如果这个context只是一直使用默认值,那它不能发挥多大用途。 当我们想创建并使用context时,百分之99的时候,我们希望让context消费者(那些调用useContext的组件)能够渲染在一个能提供有用值的provider里。

有些时候默认值有用,不过绝大多数的时候不需要或没有太多帮助

React的文档里建议,给context提供一个默认值的用途是”可以在不包裹组件的情况下,进行组件的独立测试“。虽然可以如此做,但我并不觉得这样测试就比直接给组件包裹一层context来得好。记住,当每次你为了测试组件添加改动东西,而这些东西又并不出现在你的实际应用里,那么你就不能很肯定改测试是有效的。 当然context默认值自有用途,只是这上述用例并不是其中之一。

如果你使用ts,在使用React.useContext时,不提供默认值就会很烦。 我等下给你们看看怎么避免这个问题,接着看吧。

自定义Provider组件

如果想让这个context模块有用,我们需要使用一个Provider,并用一个组件提供一个值,这个组件大概这样用:

function App() {
  return (
    <CountProvider>
      <CountDisplay />
      <Counter />
    </CountProvider>
  )
}

ReactDOM.render(<App />, document.getElementById('⚛️'))

如上,我们来造这个具体的组件

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: you *might* need to memoize this value
  // Learn more in http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

export {CountProvider}

这是个过度工程化的例子,目的是想展示一个更贴近现实的场景。 不过这并不意味着你每次都要搞得这么复杂 如果你的场景合适,useState也是可以的。而且,有些providers如上例子一样很简短,但有些将调用多个钩子的providers会非常复杂。

自定义Consumer Hook

绝大数我见到的context都是这么用的:

import * as React from 'react'
import {SomethingContext} from 'some-context-package'

function YourComponent() {
  const something = React.useContext(SomethingContext)
}

我觉得这样子没能很好地为开发者提供一个良好的体验

相反,我觉得应该这样(译者注:我也觉得,感觉比较自然):

import * as React from 'react'
import {useSomething} from 'some-context-package'

function YourComponent() {
  const something = useSomething()
}

这样定义的好处有几点,我等下在实现上说明:

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: you *might* need to memoize this value
  // Learn more in http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

function useCount() {
  const context = React.useContext(CountContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}

首先useCount 这个自定义hook使用了 React.useContext 来获取最近的CountProvider提供的值。不过,如果没有值,那就丢出一个有用的错误信息,提示这个渲染在CountProvider里的功能是组件里的hook并没有被调用。这很明显是个错误,提供一个错误信息很有帮助。安全地处理错误。

自定义Consumer组件

如果你可以使用hook,那你可以跳过这一节。不过如果你需要支持React<16.8.0,或者你觉得Context应该被一个类组件使用,那可以参考下面这个调用渲染prop的API(render-prop based API)的例子。

function CountConsumer({children}) {
  return (
    <CountContext.Consumer>
      {context => {
        if (context === undefined) {
          throw new Error('CountConsumer must be used within a CountProvider')
        }
        return children(context)
      }}
    </CountContext.Consumer>
  )
}

类组件可以这么用:

class CounterThing extends React.Component {
  render() {
    return (
      <CountConsumer>
        {({state, dispatch}) => (
          <div>
            <div>{state.count}</div>
            <button onClick={() => dispatch({type: 'decrement'})}>
              Decrement
            </button>
            <button onClick={() => dispatch({type: 'increment'})}>
              Increment
            </button>
          </div>
        )}
      </CountConsumer>
    )
  }
}

这是我在hooks出来前的用法,如果你可以使用hooks了,那我推荐你就不用对上述内容太上心。Hooks更美好。

TypeScript

我答应过会说如何避免因没提供defaultValue而导致的TS报错。如果照我建议的做,这个问题自然而然就不存在。如下:

import * as React from 'react'

type Action = {type: 'increment'} | {type: 'decrement'}
type Dispatch = (action: Action) => void
type State = {count: number}
type CountProviderProps = {children: React.ReactNode}

const CountStateContext = React.createContext<
  {state: State; dispatch: Dispatch} | undefined
>(undefined)

function countReducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}: CountProviderProps) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: you *might* need to memoize this value
  // Learn more in http://kcd.im/optimize-context
  const value = {state, dispatch}
  return (
    <CountStateContext.Provider value={value}>
      {children}
    </CountStateContext.Provider>
  )
}

function useCount() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}

这样一来,大家都可以使用useCount 也无需在做undefined效验,因为我们已经处理好了。

看看这个 codesandbox

那如果dispatch type 手抖打错怎么办?

现在redux的狂热粉应该会吼你一脸,“我的action creators去哪了?!” 如果你想添加action creators我也不会拦着你,不过我从来就不喜欢action creators。我总感觉他们是一层多余的抽象,而且如果你使用TS并好好给你的action添加type的话,你应该不需要action creators。你还能有自动补全和代码行里的type报错。

dispatch type 自动补全

dispatch type的拼写错误type提示

我很喜欢这样传递dispatch,还有个优势就是这样的dispatch在创建他的组件里很稳定,并不需要额外把他们放进useEffect的依赖数组里(因为放不放都没差,不会变)。

如果你没有给你的JavaScript添加typing(也许你该考虑下),那我们丢出缺失的action types就可以早点发现问题。继续看下面的内容, 下面这节也能帮到你:

那异步actions咋办呢?

好问题,如果你需要做异步请求,并且在这个请求里,你需要dispatch很多东西,该怎么办呢? 诚然, 你可以直接在调用的组件里处理,但每个组件你都要手动把这些步骤组合起来,那一定很烦。

我建议就在context模块里做个helper函数,这个函数接受一个dispatch和所有的数据,然后让这个helper专门来处理异步请求的逻辑,参考我的进阶React模式workshop::

async function updateUser(dispatch, user, updates) {
  dispatch({type: 'start update', updates})
  try {
    const updatedUser = await userClient.updateUser(user, updates)
    dispatch({type: 'finish update', updatedUser})
  } catch (error) {
    dispatch({type: 'fail update', error})
  }
}

export {UserProvider, useUser, updateUser}

然后这样用:

import {useUser, updateUser} from './user-context'

function UserSettings() {
  const [{user, status, error}, userDispatch] = useUser()

  function handleSubmit(event) {
    event.preventDefault()
    updateUser(userDispatch, user, formState)
  }

  // more code...
}

我很满意这个模式,如果你想让我教你的公司如何使用这个模式 让我晓得 (或者 添加自己到愿望表 以便知悉我下次举办的workshop)!

结语

这就是最后版本的代码

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: you *might* need to memoize this value
  // Learn more in http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

function useCount() {
  const context = React.useContext(CountContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}

这是codesandbox.

注意我没有导出CountContext,这是故意的。我只想有唯一一种提供context值和唯一一种消费它的方式。这样确保了使用context值的人正确地使用它,也确保了我能为消费者们提供实用的工具。

我希望这点对你有帮助,记住:

  1. 不要试图用context来解决所有状态共享的问题
  2. context也不需要非得在整个应用里的全局空间,它可以使用在局部的组件树里
  3. 在你的应用里,可以(也许更应该)有多个逻辑分明的context

Good luck!