使用React Hooks的状态还原器模式(附代码示例)

80 阅读8分钟

一些历史

不久前,我开发了一种新的模式来增强你的React组件,叫做状态还原器模式。我把它用在downshift中,为那些想改变 downshift 内部状态更新方式的人提供了一个很棒的 API。

如果你不熟悉downshift,只知道它是一个 "增强型输入 "组件,允许你建立像可访问的自动完成/键入/下拉组件这样的东西。重要的是要知道它管理着以下的状态项目。isOpen,selectedItem,highlightedIndex, 和inputValue

Downshift目前是作为一个渲染道具组件实现的,因为在当时,渲染道具是制作"无头UI组件"(通常通过 "渲染道具 "API实现)的最佳方式,这使得你可以在不对UI有意见的情况下分享逻辑。这也是降频器如此成功的主要原因。

但是今天,我们有了React钩子,而钩子在这方面比渲染道具做得更好。 所以我想我应该给大家一个更新,说明这种模式是如何转移到React团队给我们的这个新的API上的。(注:Downshift已经计划实现一个钩子)

作为提醒,状态还原器模式的好处在于它允许"控制反转",这基本上是一种机制,API的作者允许API的用户控制事情的内部运作。关于这个问题,我强烈建议你看一下我的React Rally 2018演讲,这是一个基于例子的演讲。

也可以在我的博客上阅读。"控制的反转"

所以在降档的例子中,我已经做出决定,当终端用户选择一个项目时,isOpen 应该被设置为false (并且菜单应该被关闭)。有人正在建立一个带有下移功能的多选,并希望在用户选择了菜单中的一个项目后保持菜单打开(这样他们可以继续选择更多的项目)。

通过反转对状态更新的控制,我能够启用他们的用例,以及任何其他人们可能想要的用例,当他们想要改变downshift的内部操作方式时。反转控制是计算机科学的一个原则,而状态还原器模式是这个想法的一个很好的实现,它在钩子上的表现甚至比在普通组件上的表现更好。

使用带有钩子的状态减速器

好的,那么这个概念是这样的:

  1. 终端用户做一个动作
  2. 开发人员调用分配器
  3. 钩子决定了必要的变化
  4. 钩子调用开发人员的代码进行进一步的修改,这是控制的倒置部分。
  5. 钩子进行状态改变

警告:前面的例子很牵强。为了使事情简单,我将使用一个简单的useToggle 钩子和组件作为起点。这会让人感觉很做作,但我不想让你在我教你如何用钩子使用这种模式时被一个复杂的例子所干扰。你只需知道,这个模式在应用于复杂的钩子和组件(如降档)时效果最好:

function useToggle() {
  const [on, setOnState] = React.useState(false)

  const toggle = () => setOnState(o => !o)
  const setOn = () => setOnState(true)
  const setOff = () => setOnState(false)

  return {on, toggle, setOn, setOff}
}

function Toggle() {
  const {on, toggle, setOn, setOff} = useToggle()

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch on={on} onClick={toggle} />
    </div>
  )
}

function App() {
  return <Toggle />
}

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

现在,假设我们想调整<Toggle /> 组件,这样用户就不能连续点击<Switch /> 超过4次,除非他们点击一个 "复位 "按钮:

function Toggle() {
  const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
  const tooManyClicks = clicksSinceReset >= 4

  const {on, toggle, setOn, setOff} = useToggle()

  function handleClick() {
    toggle()
    setClicksSinceReset(count => count + 1)
  }

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch on={on} onClick={handleClick} />
      {tooManyClicks ? (
        <button onClick={() => setClicksSinceReset(0)}>Reset</button>
      ) : null}
    </div>
  )
}

很好,所以解决这个问题的一个简单方法是在handleClick 函数中添加一个if语句,如果tooManyClicks 为真,就不调用toggle ,但为了这个例子,我们还是继续下去。

我们怎样才能改变useToggle ,在这种情况下反转控制呢? 让我们先考虑一下API,然后再考虑实现。作为一个用户,如果我可以在每一个状态更新发生之前钩住它,并对它进行修改,就像这样,那就很酷了:

function Toggle() {
  const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
  const tooManyClicks = clicksSinceReset >= 4

  const {on, toggle, setOn, setOff} = useToggle({
    modifyStateChange(currentState, changes) {
      if (tooManyClicks) {
        // other changes are fine, but on needs to be unchanged
        return {...changes, on: currentState.on}
      } else {
        // the changes are fine
        return changes
      }
    },
  })

  function handleClick() {
    toggle()
    setClicksSinceReset(count => count + 1)
  }

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch on={on} onClick={handleClick} />
      {tooManyClicks ? (
        <button onClick={() => setClicksSinceReset(0)}>Reset</button>
      ) : null}
    </div>
  )
}

所以这很好,只是它阻止了人们点击 "关闭 "或 "打开 "按钮时发生的变化,而我们只想阻止<Switch /> 来切换状态。

嗯......。如果我们把modifyStateChange 改为reducer ,并接受一个action 作为第二个参数,会怎么样?然后,action 可以有一个type 来决定发生什么类型的变化,我们可以从toggleReducer 得到changes ,它将被我们的useToggle钩子导出。我们就说,点击开关的typeTOGGLE

function Toggle() {
  const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
  const tooManyClicks = clicksSinceReset >= 4

  const {on, toggle, setOn, setOff} = useToggle({
    reducer(currentState, action) {
      const changes = toggleReducer(currentState, action)
      if (tooManyClicks && action.type === 'TOGGLE') {
        // other changes are fine, but on needs to be unchanged
        return {...changes, on: currentState.on}
      } else {
        // the changes are fine
        return changes
      }
    },
  })

  function handleClick() {
    toggle()
    setClicksSinceReset(count => count + 1)
  }

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch on={on} onClick={handleClick} />
      {tooManyClicks ? (
        <button onClick={() => setClicksSinceReset(0)}>Reset</button>
      ) : null}
    </div>
  )
}

很好!这给了我们各种各样的控制。最后一件事,让我们不要为类型使用字符串'TOGGLE' 。相反,我们将有一个所有变化类型的对象,人们可以参考。这将有助于避免打字错误和改善编辑器的自动完成(对于不使用TypeScript的人来说):

function Toggle() {
  const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
  const tooManyClicks = clicksSinceReset >= 4

  const {on, toggle, setOn, setOff} = useToggle({
    reducer(currentState, action) {
      const changes = toggleReducer(currenState, action)
      if (tooManyClicks && action.type === actionTypes.toggle) {
        // other changes are fine, but on needs to be unchanged
        return {...changes, on: currentState.on}
      } else {
        // the changes are fine
        return changes
      }
    },
  })

  function handleClick() {
    toggle()
    setClicksSinceReset(count => count + 1)
  }

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch on={on} onClick={handleClick} />
      {tooManyClicks ? (
        <button onClick={() => setClicksSinceReset(0)}>Reset</button>
      ) : null}
    </div>
  )
}

用Hooks实现一个状态还原器

好了,我对我们在这里展示的API很满意。让我们来看看我们如何用我们的useToggle 钩子来实现它。如果你忘记了,这里有相关的代码:

function useToggle() {
  const [on, setOnState] = React.useState(false)

  const toggle = () => setOnState(o => !o)
  const setOn = () => setOnState(true)
  const setOff = () => setOnState(false)

  return {on, toggle, setOn, setOff}
}

我们可以在每一个辅助函数中加入逻辑,但我只是要跳过,并告诉你这将是非常烦人的,即使在这个简单的钩子中。相反,我们要把这个从useState 改写成useReducer ,这将使我们的实现变得容易得多:

function toggleReducer(state, action) {
  switch (action.type) {
    case 'TOGGLE': {
      return {on: !state.on}
    }
    case 'ON': {
      return {on: true}
    }
    case 'OFF': {
      return {on: false}
    }
    default: {
      throw new Error(`Unhandled type: ${action.type}`)
    }
  }
}

function useToggle() {
  const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})

  const toggle = () => dispatch({type: 'TOGGLE'})
  const setOn = () => dispatch({type: 'ON'})
  const setOff = () => dispatch({type: 'OFF'})

  return {on, toggle, setOn, setOff}
}

好的,很好。非常快,让我们把types 属性添加到我们的useToggle ,以避免字符串的问题。我们将导出这个属性,这样我们的钩子的用户就可以引用它们:

const actionTypes = {
  toggle: 'TOGGLE',
  on: 'ON',
  off: 'OFF',
}
function toggleReducer(state, action) {
  switch (action.type) {
    case actionTypes.toggle: {
      return {on: !state.on}
    }
    case actionTypes.on: {
      return {on: true}
    }
    case actionTypes.off: {
      return {on: false}
    }
    default: {
      throw new Error(`Unhandled type: ${action.type}`)
    }
  }
}

function useToggle() {
  const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})

  const toggle = () => dispatch({type: actionTypes.toggle})
  const setOn = () => dispatch({type: actionTypes.on})
  const setOff = () => dispatch({type: actionTypes.off})

  return {on, toggle, setOn, setOff}
}

export {useToggle, actionTypes}

酷,所以现在,用户将把reducer 作为配置对象传递给我们的useToggle 函数,所以让我们接受它:

function useToggle({reducer}) {
  const [{on}, dispatch] = React.useReducer(toggleReducer, {on: false})

  const toggle = () => dispatch({type: actionTypes.toggle})
  const setOn = () => dispatch({type: actionTypes.on})
  const setOff = () => dispatch({type: actionTypes.off})

  return {on, toggle, setOn, setOff}
}

很好,那么现在我们有了开发者的reducer ,我们如何把它和我们的还原器结合起来呢?好吧,如果我们真的要为我们的钩子的用户反转控制,我们不希望调用我们自己的reducer 。相反,让我们公开我们自己的减速器,如果他们愿意,他们可以自己使用它,所以让我们导出它,然后我们使用他们给我们的reducer ,而不是我们自己的:

function useToggle({reducer}) {
  const [{on}, dispatch] = React.useReducer(reducer, {on: false})

  const toggle = () => dispatch({type: actionTypes.toggle})
  const setOn = () => dispatch({type: actionTypes.on})
  const setOff = () => dispatch({type: actionTypes.off})

  return {on, toggle, setOn, setOff}
}

export {useToggle, actionTypes, toggleReducer}

很好,但是现在每个使用我们组件的人都必须提供一个减速器,这并不是我们真正想要的。我们想为那些想要控制人启用反转控制,但对于更常见的情况,他们不应该做任何特别的事情,所以让我们添加一些默认值。

function useToggle({reducer = toggleReducer} = {}) {
  const [{on}, dispatch] = React.useReducer(reducer, {on: false})

  const toggle = () => dispatch({type: actionTypes.toggle})
  const setOn = () => dispatch({type: actionTypes.on})
  const setOff = () => dispatch({type: actionTypes.off})

  return {on, toggle, setOn, setOff}
}

export {useToggle, actionTypes, toggleReducer}

很好,所以现在人们可以将我们的useToggle 钩子与他们自己的减速器一起使用,也可以将它与内置的减速器一起使用。无论哪种方式,都是很好的。

总结

这是最终的版本:

import * as React from 'react'
import ReactDOM from 'react-dom'
import Switch from './switch'

const actionTypes = {
  toggle: 'TOGGLE',
  on: 'ON',
  off: 'OFF',
}

function toggleReducer(state, action) {
  switch (action.type) {
    case actionTypes.toggle: {
      return {on: !state.on}
    }
    case actionTypes.on: {
      return {on: true}
    }
    case actionTypes.off: {
      return {on: false}
    }
    default: {
      throw new Error(`Unhandled type: ${action.type}`)
    }
  }
}

function useToggle({reducer = toggleReducer} = {}) {
  const [{on}, dispatch] = React.useReducer(reducer, {on: false})

  const toggle = () => dispatch({type: actionTypes.toggle})
  const setOn = () => dispatch({type: actionTypes.on})
  const setOff = () => dispatch({type: actionTypes.off})

  return {on, toggle, setOn, setOff}
}

// export {useToggle, actionTypes, toggleReducer}

function Toggle() {
  const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
  const tooManyClicks = clicksSinceReset >= 4

  const {on, toggle, setOn, setOff} = useToggle({
    reducer(currentState, action) {
      const changes = toggleReducer(currentState, action)
      if (tooManyClicks && action.type === actionTypes.toggle) {
        // other changes are fine, but on needs to be unchanged
        return {...changes, on: currentState.on}
      } else {
        // the changes are fine
        return changes
      }
    },
  })

  return (
    <div>
      <button onClick={setOff}>Switch Off</button>
      <button onClick={setOn}>Switch On</button>
      <Switch
        onClick={() => {
          toggle()
          setClicksSinceReset(count => count + 1)
        }}
        on={on}
      />
      {tooManyClicks ? (
        <button onClick={() => setClicksSinceReset(0)}>Reset</button>
      ) : null}
    </div>
  )
}

function App() {
  return <Toggle />
}

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

这是在codesandbox中运行的。

请记住,我们在这里所做的是让用户能够钩住我们的还原器的每一个状态更新来对其进行修改。这使得我们的钩子更加灵活,但这也意味着我们更新状态的方式现在是API的一部分,如果我们对其进行修改,那么对用户来说可能是一个破坏性的改变。对于复杂的钩子/组件来说,这是完全值得权衡的,但记住这一点是很好的。

我希望你觉得这样的模式很有用。感谢useReducer ,这个模式只是有点落空(感谢React!)。所以,在你的代码库上试一试吧!

祝您好运!