使用React的useState 钩子建立一个井字游戏(基于React官方教程)

86 阅读10分钟

我的Learn React Hooks工作坊材料中,我们有一个练习,使用React的useState 钩子建立一个井字游戏(基于React官方教程)。以下是该练习的完成版本的Github文件

我们有几个状态的变量。有一个squares 状态变量,通过React.useState 。还有nextValue,winner, 和status 都是通过调用函数calculateNextValue,calculateWinner, 和calculateStatus 来决定的。squares 是常规的组件状态,但是nextValue,winner, 和status 是所谓的 "派生状态"。这意味着它们的值可以根据其他值推导(或计算),而不是自己管理。

我这样写是有原因的。让我们通过用一种更天真的方法重新实现,来看看派生状态比状态同步的好处。事实上,这四个变量在技术上都是状态,所以你可能会自动认为你需要为它们使用useStateuseReducer

让我们从useState 开始:

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
  const [winner, setWinner] = React.useState(calculateWinner(squares))
  const [status, setStatus] = React.useState(calculateStatus(squares))

  function selectSquare(square) {
    if (winner || squares[square]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square] = nextValue
    const newNextValue = calculateNextValue(squaresCopy)
    const newWinner = calculateWinner(squaresCopy)
    const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
    setSquares(squaresCopy)
    setNextValue(newNextValue)
    setWinner(newWinner)
    setStatus(newStatus)
  }

  // return beautiful JSX
}

所以,这并不是什么坏事。如果我们在井字游戏中增加一个可以同时选择两个方块的功能,那就会成为一个真正的问题。 我们要怎么做才能使之发生呢?

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
  const [winner, setWinner] = React.useState(calculateWinner(squares))
  const [status, setStatus] = React.useState(calculateStatus(squares))

  function selectSquare(square) {
    if (winner || squares[square]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square] = nextValue

    const newNextValue = calculateNextValue(squaresCopy)
    const newWinner = calculateWinner(squaresCopy)
    const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
    setSquares(squaresCopy)
    setNextValue(newNextValue)
    setWinner(newWinner)
    setStatus(newStatus)
  }

  function selectTwoSquares(square1, square2) {
    if (winner || squares[square1] || squares[square2]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square1] = nextValue
    squaresCopy[square2] = nextValue

    const newNextValue = calculateNextValue(squaresCopy)
    const newWinner = calculateWinner(squaresCopy)
    const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
    setSquares(squaresCopy)
    setNextValue(newNextValue)
    setWinner(newWinner)
    setStatus(newStatus)
  }

  // return beautiful JSX
}

这方面最大的问题是有些状态可能会与真正的组件状态不同步(squares )。例如,它可能会因为我们忘记更新复杂的交互序列而失去同步性。如果你已经构建了一段时间的React应用,你就知道我在说什么。 东西不同步可不是什么好玩的事。

有一件事可以帮助你,那就是减少重复,让所有相关的状态更新发生在一个地方:

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
  const [winner, setWinner] = React.useState(calculateWinner(squares))
  const [status, setStatus] = React.useState(calculateStatus(squares))

  function setNewState(newSquares) {
    const newNextValue = calculateNextValue(newSquares)
    const newWinner = calculateWinner(newSquares)
    const newStatus = calculateStatus(newWinner, newSquares, newNextValue)
    setSquares(newSquares)
    setNextValue(newNextValue)
    setWinner(newWinner)
    setStatus(newStatus)
  }

  function selectSquare(square) {
    if (winner || squares[square]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square] = nextValue
    setNewState(squaresCopy)
  }

  function selectTwoSquares(square1, square2) {
    if (winner || squares[square1] || squares[square2]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square1] = nextValue
    squaresCopy[square2] = nextValue
    setNewState(squaresCopy)
  }

  // return beautiful JSX
}

这确实改善了我们的代码重复,而且说实话,这并不是什么大问题。但这是一个相当简单的例子。有时候,派生状态是基于在不同情况下更新的多个状态变量,我们需要确保每当源状态被更新时,我们所有的状态都会被更新。

解决方案

如果我告诉你有更好的东西呢?如果你已经读过上面的codeandbox实现,你就知道这个解决方案是什么,但现在让我们把它放在这里。

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const nextValue = calculateNextValue(squares)
  const winner = calculateWinner(squares)
  const status = calculateStatus(winner, squares, nextValue)

  function selectSquare(square) {
    if (winner || squares[square]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square] = nextValue
    setSquares(squaresCopy)
  }

  // return beautiful JSX
}

很好!我们不需要担心更新派生的状态值,因为它们只是在每次渲染时被计算出来。很好。让我们加入一次两个方块的功能。

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const nextValue = calculateNextValue(squares)
  const winner = calculateWinner(squares)
  const status = calculateStatus(winner, squares, nextValue)

  function selectSquare(square) {
    if (winner || squares[square]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square] = nextValue
    setSquares(squaresCopy)
  }

  function selectTwoSquares(square1, square2) {
    if (winner || squares[square1] || squares[square2]) {
      return
    }
    const squaresCopy = [...squares]
    squaresCopy[square1] = nextValue
    squaresCopy[square2] = nextValue
    setSquares(squaresCopy)
  }

  // return beautiful JSX
}

很好!以前,我们不得不关注每一次更新squares ,以确保我们也正确地更新了所有其他的状态。但现在我们根本不需要担心这个问题。它只是在工作。不需要一个花哨的函数来处理更新所有的衍生状态。我们只是即时计算。

useReducer 呢?

useReducer 就没有这么严重的问题了。下面是我如何使用 来实现这一点。useReducer

function calculateDerivedState(squares) {
  const winner = calculateWinner(squares)
  const nextValue = calculateNextValue(squares)
  const status = calculateStatus(winner, squares, nextValue)
  return {squares, nextValue, winner, status}
}

function ticTacToeReducer(state, square) {
  if (state.winner || state.squares[square]) {
    // no state change needed.
    // (returning the same object allows React to bail out of a re-render)
    return state
  }

  const squaresCopy = [...state.squares]
  squaresCopy[square] = state.nextValue

  return {...calculateDerivedState(squaresCopy), squares: squaresCopy}
}

function Board() {
  const [{squares, status}, selectSquare] = React.useReducer(
    ticTacToeReducer,
    Array(9).fill(null),
    calculateDerivedState,
  )

  // return beautiful JSX
}

这不是唯一的方法,但重点是,虽然我们仍然 "派生 "winnernextValuestatus 的状态,但我们在还原器中管理所有的状态,这是唯一可以发生状态更新的地方,所以不同步的可能性比较小。

尽管如此,我发现这比我们其他的解决方案要复杂一些(特别是如果我们想增加 "一次两格 "的功能)。因此,如果我在生产应用中构建和运送这个,我会选择我在codeandbox中得到的东西。

通过道具派生的状态

状态不一定要在内部管理,也不一定要受到状态同步问题的影响。如果我们有来自父级组件的squares 状态呢?我们将如何同步该状态?

function Board({squares, onSelectSquare}) {
  const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
  const [winner, setWinner] = React.useState(calculateWinner(squares))
  const [status, setStatus] = React.useState(calculateStatus(squares))

  // ... hmmm... we're no longer managing updating the squares state, so how
  // do we keep these variables up to date? useEffect? useLayoutEffect?
  // React.useEffect(() => {
  //   setNextValue... etc... eh...
  // }, [squares])
  //
  // Just call the state updaters when squares change
  // right in the render method?
  // if (prevSquares !== squares) {
  //   setNextValue... etc... ugh...
  // }
  //
  // I've seen people do all of these things... And none of them are great.

  // return beautiful JSX
}

更好的方法是在运行中计算。

function Board({squares, onSelectSquare}) {
  const nextValue = calculateNextValue(squares)
  const winner = calculateWinner(squares)
  const status = calculateStatus(squares)

  // return beautiful JSX
}

简单,而且效果很好。

P.S. 还记得getDerivedStateFromProps 吗?你可能不需要它,但如果你需要,而且你想用钩子来做,那么在渲染过程中调用状态更新器函数实际上是正确的方法。从React钩子FAQ中了解更多

性能如何?

我知道你一直在等我解决这个问题......情况是这样的。JavaScript真的很快。我在calculateWinner 函数上做了一个基准测试,结果是每秒有1500万个操作。所以,除非你的井字游戏玩家点击速度非常快,否则这不可能成为一个性能问题(即使他们能玩得那么快,我向你保证,你会有其他的性能问题,而这些问题对你来说是较低的悬念)。

好吧,我在我的手机上试了一下,每秒只有430万个操作,然后我在我的笔记本电脑上用CPU 6倍的速度试了一下,只有200万个...我想我们还是不错的。

这就是说,如果你碰巧有一个计算成本高的函数,那么这就是useMemo

function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null))
  const nextValue = React.useMemo(() => calculateNextValue(squares), [squares])
  const winner = React.useMemo(() => calculateWinner(squares), [squares])
  const status = React.useMemo(
    () => calculateStatus(winner, squares, nextValue),
    [winner, squares, nextValue],
  )

  // return beautiful JSX
}

所以,你去那里。一旦你确定某些代码对你的用户来说实际上是计算成本很高的,你就可以使用这个逃生舱口。请注意,这并不能神奇地使这些函数运行得更快。它所做的只是确保它们不会被不必要地调用。如果这是我们的整个应用程序,应用程序重新渲染的唯一方法是squares ,在这种情况下,所有这些函数都会被运行,所以我们实际上没有通过这种 "优化 "取得多少成就。这就是为什么我说。"先测量!"

了解更多关于useMemouseCallback

哦,我想说的是,派生状态有时甚至可以比状态同步更快,因为它将导致更少的不必要的重读,这有时会成为一个问题

那MobX/Reselect呢?

Reselect(如果你使用Redux,你绝对应该使用它)内置了memoization,这很酷。MobX也有这样的功能,但他们还通过"计算值 "更进一步,这基本上是一个API,可以为你提供记忆化和优化的衍生状态值。让它比我们已经有的更好的是,计算只在被访问时被处理。

对于(臆造的)例子。

function FavoriteNumber() {
  const [name, setName] = React.useState('')
  const [number, setNumber] = React.useState(0)
  const numberWarning = getNumberWarning(number)
  return (
    <div>
      <label>
        Your name: <input onChange={e => setName(e.target.value)} />
      </label>
      <label>
        Your favorite number:{' '}
        <input
          type="number"
          onChange={e => setNumber(Number(e.target.value))}
        />
      </label>
      <div>
        {name
          ? `${name}'s favorite number is ${number}`
          : 'Please type your name'}
      </div>
      <div>{number > 10 ? numberWarning : null}</div>
      <div>{number < 0 ? numberWarning : null}</div>
    </div>
  )
}

注意,我们正在调用getNumberWarning ,但我们只在数字过高或过低时才使用其结果,所以我们实际上可能根本不需要调用那个函数。现在,这不太可能有问题,但为了争论起见,我们假设调用getNumberWarning 是一个应用程序的瓶颈。 这就是计算值功能的用武之地。

如果你在你的应用程序中经常遇到这种情况,那么我建议你直接跳到使用MobX(MobX的人将告诉你还有很多其他的理由来使用它),但是我们可以很容易地自己解决这个特定的情况。

function FavoriteNumber() {
  const [name, setName] = React.useState('')
  const [number, setNumber] = React.useState(0)
  const numberIsTooHigh = number > 10
  const numberIsTooLow = number < 0
  const numberWarning =
    numberIsTooHigh || numberIsTooLow ? getNumberWarning(number) : null
  return (
    <div>
      <label>
        Your name: <input onChange={e => setName(e.target.value)} />
      </label>
      <label>
        Your favorite number:{' '}
        <input
          type="number"
          onChange={e => setNumber(Number(e.target.value))}
        />
      </label>
      <div>
        {name
          ? `${name}'s favorite number is ${number}`
          : 'Please type your name'}
      </div>
      <div>{numberIsTooHigh ? numberWarning : null}</div>
      <div>{numberIsTooLow ? numberWarning : null}</div>
    </div>
  )
}

很好!现在我们不需要担心在不需要的时候调用numberWarning 。但是,如果这对你的情况来说效果不好,那么我们可以做一个自定义的钩子为我们做这个魔法。这并不完全简单,而且有点像黑客(说实话可能有更好的方法),所以我只是把这个放在codeandbox里,如果你想的话,让你去探索。

只要说自定义钩子允许我们这样做就可以了。

function FavoriteNumber() {
  const [name, setName] = React.useState('')
  const [number, setNumber] = React.useState(0)
  const numberWarning = useComputedValue(
    () => getNumberWarning(number),
    [number],
  )
  return (
    <div>
      <label>
        Your name: <input onChange={e => setName(e.target.value)} />
      </label>
      <label>
        Your favorite number:{' '}
        <input
          type="number"
          onChange={e => setNumber(Number(e.target.value))}
        />
      </label>
      <div>
        {name
          ? `${name}'s favorite number is ${number}`
          : 'Please type your name'}
      </div>
      <div>{number > 10 ? numberWarning.result : null}</div>
      <div>{number < 0 ? numberWarning.result : null}</div>
    </div>
  )
}

而我们的getNumberWarning 函数只有在实际使用result 的时候才会被调用。把它想象成一个useMemo ,只有在返回值被渲染时才运行回调。

我认为这可能有完善和开源的空间。欢迎你这样做,然后给这篇博文做一个PR,在你发布的包上添加一个链接😉。

同样,在正常情况下,真的没有什么理由为这种事情担心。但是,如果你确实有性能瓶颈,而且useMemo对你来说是不够的,那么可以考虑做这样的事情或者使用MobX。

结论

好吧,我们有点分心,过度考虑了性能问题。 事实上,你可以通过考虑状态是否需要自己管理或者是否可以派生,来真正简化你的应用程序的状态。我们了解到,派生状态可以是单一状态变量的结果,也可以是由多个状态变量派生出来的(其中一些也可以是派生状态本身)。

因此,当你在维护你的应用程序的状态并试图找出一个同步错误时,想想你如何能让它在飞行中派生。而在你遇到性能问题的少数情况下,你可以通过一些优化策略来帮助减轻一些痛苦。祝您好运!