应该使用State还是使用Reducer(附代码示例)

148 阅读9分钟

每当有两种东西做同样的事情时,人们难免会问:"什么时候我应该用一个而不是另一个?"有多种方式做同一件事,可能有两个原因:

  1. 一种是 "老方法",另一种是 "新(和改进)方法"。通常情况下,老方法是为了向后兼容的原因而保留的,而新方法是新代码的前进方向。例如:类组件(老方法)与函数组件(新方法)。
  2. 它们有不同的权衡,应该被考虑,因此应该在更适合它们的情况下应用(有时意味着你会在一个特定的应用中使用不止一个)。例如:useEffect vsuseLayoutEffect静态测试 vs 单元测试 vs 集成测试 vs E2E 测试"控制道具 "vs "状态降低器"

useState 和 属于这里的第二类。因此,让我们来谈谈权衡的问题。useReducer

(不,这不是一个骗人的问题😂)。

例子

我认为讨论这些权衡的最好方式是通过例子的视角。我们来看看两个例子。一个是更适合useState ,另一个是更适合useReducer 。这并不足以涵盖所有的权衡,但希望能成为我们的一个好的起点。

自定义useDarkMode 钩子

我最近为我的工作室项目写了这个(例如)。它相当有趣,所以让我们来比较一下useStateuseReducer 的实现。

useState 实现

我将强调我们与状态交互的地方:

function useDarkMode() {
  const preferDarkQuery = '(prefers-color-scheme: dark)'
  const [mode, setMode] = React.useState(
    () =>
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  )

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  return [mode, setMode]
}

希望这有意义。基本上我们是说,如果用户将他们的偏好设置为黑暗模式,那么我们将初始化我们的模式为dark ,否则我们将初始化为light ,然后返回modesetMode ,当模式改变时(无论是直接调用setMode 还是用户改变他们的系统偏好),我们将保持在localStorage 中设置的值,以供将来使用。

useReducer 实施。

有几种方法可以用useReducer 。我先说说大多数人写还原器的典型方法:

const preferDarkQuery = '(prefers-color-scheme: dark)'

function darkModeReducer(state, action) {
  switch (action.type) {
    case 'MEDIA_CHANGE': {
      return {...state, mode: action.mode}
    }
    case 'SET_MODE': {
      // make sure to spread that state just in case!
      return {...state, mode: action.mode}
    }
    default: {
      // helps us avoid typos!
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

// use the init function to lazily initialize state so we don't read into
// localstorage or call matchMedia every render
function init() {
  return {
    mode:
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  }
}

function useDarkMode() {
  const [state, dispatch] = React.useReducer(
    darkModeReducer,
    {mode: 'light'},
    init,
  )
  const {mode} = state

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () =>
      dispatch({
        type: 'MEDIA_CHANGE',
        mode: mediaQuery.matches ? 'dark' : 'light',
      })
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  // We like the API the way it is, so instead of returning the state object
  // and the dispatch function, we'll return the `mode` property and we'll
  // create a setMode helper (which we have to memoize in case someone wants
  // to use it in a dependency list):
  const setMode = React.useCallback(
    newMode => dispatch({type: 'SET_MODE', mode: newMode}),
    [],
  )

  return [mode, setMode]
}

哇,我想我们都同意,useState 的版本要简单得多!但是等等!我们可以通过反其道而行之,不写典型的redux风格的reducer,来大幅简化useReducer 版本。让我们试试吧:

function useDarkMode() {
  const preferDarkQuery = '(prefers-color-scheme: dark)'
  const [mode, setMode] = React.useReducer(
    (prevMode, nextMode) =>
      typeof nextMode === 'function' ? nextMode(prevMode) : nextMode,
    'light',
    () =>
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  )

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  return [mode, setMode]
}

这比我们用useReducer 的其他尝试要好得多。但我们基本上useReducer 实现了useState即使如此,它仍然不如我们的useState 版本清晰。所以在最后,useState 在这种情况下是一个更好的解决方案。

当它只是你要管理的一个独立的状态元素的时候。useState

自定义useUndo 钩子

GitHub上有一个很棒的useUndo 钩子。在这个例子中,我从它那里得到了灵感。谢谢你,Homer Chen!

(对于这些,几乎所有的东西都是与状态交互的,所以......没有亮点......)

useState 执行

function useUndo(initialPresent) {
  const [past, setPast] = React.useState([])
  const [present, setPresent] = React.useState(initialPresent)
  const [future, setFuture] = React.useState([])

  const canUndo = past.length !== 0
  const canRedo = future.length !== 0

  const undo = React.useCallback(() => {
    if (!canUndo) return

    const previous = past[past.length - 1]
    const newPast = past.slice(0, past.length - 1)

    setPast(newPast)
    setPresent(previous)
    setFuture([present, ...future])
  }, [canUndo, future, past, present])

  const redo = React.useCallback(() => {
    if (!canRedo) return

    const next = future[0]
    const newFuture = future.slice(1)

    setPast([...past, present])
    setPresent(next)
    setFuture(newFuture)
  }, [canRedo, future, past, present])

  const set = React.useCallback(
    newPresent => {
      if (newPresent === present) {
        return
      }
      setPast([...past, present])
      setPresent(newPresent)
      setFuture([])
    },
    [past, present],
  )

  const reset = React.useCallback(newPresent => {
    setPast([])
    setPresent(newPresent)
    setFuture([])
  }, [])

  return [    {past, present, future},    {set, reset, undo, redo, canUndo, canRedo},  ]
}

看起来很OK吧?可能是的,但实际上有一种情况,这可能是很有问题的。但首先,我想解决人们对依次调用多个状态更新器(就像我们在每个函数中所做的那样)的一个常见误解。

通常人们认为这意味着你每次调用都会触发一次重新渲染(所以,他们认为调用reset 会触发三次重新渲染)。首先,请记住,在修复重渲染之前,先关注修复缓慢的渲染,但其次,请记住React有一个批处理系统,所以如果你从一个事件处理程序或在useEffect 回调中调用reset ,它将只触发一次重渲染。

也就是说,如果我们在一个异步函数中调用reset (比如在发出一个HTTP请求后),那么这导致三次重新渲染。然而,在未来的并发模式中,这些也将被分批进行。因此,我主要关心的不是重读问题。

我关心的是我们的代码中隐蔽的陈旧闭合错误!你能发现吗?你能发现它们吗?有三个!我给你一个提示,在undoredoset 中各有一个,但在reset 中没有一个。

这里有一个精心设计的例子,可以揭示这个错误:

function Example() {
  const [state, {set}] = useUndo('first')

  React.useEffect(() => {
    set('second')
  }, [])

  React.useEffect(() => {
    set('third')
  }, [])

  return <pre>{JSON.stringify(state, null, 2)}</pre>
}

这里的打印结果将是:

{
  "past": ["first"],
  "present": "third",
  "future": []
}

它应该是:

{
  "past": ["first", "second"],
  "present": "third",
  "future": []
}

那么,在我们的情况下,"second" 发生了什么?啊!原来我们在效果依赖数组中缺少对set 的依赖。愚蠢的鹅。让我们添加这些:

function Example() {
  const [state, {set}] = useUndo('first')

  React.useEffect(() => {
    set('second')
  }, [set])

  React.useEffect(() => {
    set('third')
  }, [set])

  return <pre>{JSON.stringify(state, null, 2)}</pre>
}

很好,保存,重新加载...等等,什么?哦,不!这就是我添加这些东西时发生的事情:

{
  "past": [
    "first",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "... this goes on forever..."
  ],
  "present": "third",
  "future": []
}

但是,等一下!我们不是要把?我们不是在对set 函数进行记忆吗?除非它的依赖关系改变,否则它不应该改变......等等......。这包括pastpresent 的值......而且,哎呀!当我们调用 时,这些值就会被删除。当我们调用set ,这些值被改变了,这导致了我们的无限循环!

现在,这可能看起来很牵强,但如果你根据网络事件更新状态,而它们回来的顺序与发出的顺序不同,就会出现类似的错误。不管怎么说,你都不想考虑这种事情,对吗?是的。

因此,我们可以用useReducer 来解决这个问题,但实际上我们可以改变我们的useState 实现,来回避这个问题,我想你会喜欢的,所以就在这里:

function useUndo(initialPresent) {
  const [state, setState] = React.useState({
    past: [],
    present: initialPresent,
    future: [],
  })

  const canUndo = state.past.length !== 0
  const canRedo = state.future.length !== 0

  const undo = React.useCallback(() => {
    setState(currentState => {
      const {past, present, future} = currentState

      if (past.length === 0) return currentState

      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      }
    })
  }, [])

  const redo = React.useCallback(() => {
    setState(currentState => {
      const {past, present, future} = currentState

      if (future.length === 0) return currentState

      const next = future[0]
      const newFuture = future.slice(1)

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      }
    })
  }, [])

  const set = React.useCallback(newPresent => {
    setState(currentState => {
      const {present, past} = currentState
      if (newPresent === present) return currentState

      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      }
    })
  }, [])

  const reset = React.useCallback(newPresent => {
    setState(() => ({
      past: [],
      present: newPresent,
      future: [],
    }))
  }, [])

  return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

我做了几件事来解决这个问题:

  1. 在调用状态更新器函数时,我使用了状态更新器的回调,所以我可以接收currentState 作为参数。这意味着我不需要把状态作为一个依赖项列出。
  2. 我把所有的状态合并成一个对象。我必须这样做,因为有些情况下你需要一个值来决定另一个值。例如,在redo ,我需要present 的值来更新past ,需要future 的值来更新present
  3. 我根据从参数中得到的currentState ,在状态更新器的回调中对canUndocanRedo 进行了计算,所以我不需要在依赖数组中列出这些。

这就解决了我们的问题,而且做得很好,但让我们用useReducer 来做同样的事情来比较。

useReducer 实施

const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'

function undoReducer(state, action) {
  const {past, present, future} = state
  const {type, newPresent} = action

  switch (type) {
    case UNDO: {
      if (past.length === 0) return state

      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      }
    }

    case REDO: {
      if (future.length === 0) return state

      const next = future[0]
      const newFuture = future.slice(1)

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      }
    }

    case SET: {
      if (newPresent === present) return state

      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      }
    }

    case RESET: {
      return {
        past: [],
        present: newPresent,
        future: [],
      }
    }
  }
}

function useUndo(initialPresent) {
  const [state, dispatch] = React.useReducer(undoReducer, {
    past: [],
    present: initialPresent,
    future: [],
  })

  const canUndo = state.past.length !== 0
  const canRedo = state.future.length !== 0
  const undo = React.useCallback(() => dispatch({type: UNDO}), [])
  const redo = React.useCallback(() => dispatch({type: REDO}), [])
  const set = React.useCallback(
    newPresent => dispatch({type: SET, newPresent}),
    [],
  )
  const reset = React.useCallback(
    newPresent => dispatch({type: RESET, newPresent}),
    [],
  )

  return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

哇,useUndo 这个东西本身现在其实很简单。如果我们从一开始就用useReducer ,我们甚至不会考虑给我们的依赖数组添加任何东西,因为那些函数非常简单,不需要任何东西。所有的逻辑都住在我们的还原器中。这有助于我们自然地避免这个问题。

你可能会发现一个有趣的现象,在我们的还原器中的开关情况基本上正是我们的函数在做出改变之前的内容。

当你的状态中的一个元素依赖你的状态中的另一个元素的值来更新时。useReducer

结论

所以,如果你想要一些 "规则"(不是ESLINT规则),它们就在这里:

  • 当你管理的只是一个独立的状态元素的时候。useState
  • 当你的状态的一个元素依赖于你的状态的另一个元素的值来更新时。useReducer

在这些 "规则 "之外,其他的东西都是非常主观的。老实说,即使是 "规则 "也是主观的,因为正如我所展示的,你可以用其中任何一个做你想做的事情。

另外,请注意,这是在个案的基础上适用的。你绝对可以在使用useReducer 的同一个组件或钩子中使用useState 。而且你可以在一个钩子或组件中使用多个useState和多个useReducer。那是没有问题的。按领域逻辑地分离状态。如果它是一起变化的,那么最好在一个还原器中保持在一起。如果某些东西与钩子/组件中的其他状态元素相当独立,那么把它与其他状态元素放在一起,只是给减速器增加了不必要的复杂性和噪音,你最好把它留在自己的外面。

所以这不仅仅是 "当我有超过X个useState,我就切换到useReducer"。它比这更微妙。但希望这篇文章能帮助你理解这些细微的差别,并找到对你的情况最有效的工具来进行权衡。一般来说,我建议从useState ,当你注意到需要一起改变的状态元素时,再转移到useReducer

祝您好运!