• 简体中文
  • 教程:井字棋

    构建一个游戏

    在本教程中,你将构建一个小型的井字棋游戏。本教程假定你已具备 React 基础。教程中介绍的技巧是构建任何 React 应用的基础,彻底理解它们会让你深入理解 React 和 Zustand。

    NOTE

    本教程面向那些通过动手实践学得最好、并希望快速做出可见成果的读者。它的灵感来自 React 的井字棋教程。

    本教程分为几个部分:

    • 教程准备将为你提供一个起点,让你能够跟随教程实践。
    • 概览将介绍 React 的基础知识:组件、props 和状态。
    • 完成游戏将介绍 React 开发中最常用的技巧。
    • 添加时间旅行将让你更深入地理解 React 的独特优势。

    你将构建什么?

    在本教程中,你将使用 React 和 Zustand 构建一个可交互的井字棋游戏。

    完成之后,你可以在这里看到它的样子:

    import { create } from 'zustand'
    import { combine } from 'zustand/middleware'
    
    const useGameStore = create(
      combine(
        {
          history: [Array(9).fill(null)],
          currentMove: 0,
        },
        (set, get) => {
          return {
            setHistory: (nextHistory) => {
              set((state) => ({
                history: typeof nextHistory === 'function' ? nextHistory(state.history) : nextHistory,
              }))
            },
            setCurrentMove: (nextCurrentMove) => {
              set((state) => ({
                currentMove:
                  typeof nextCurrentMove === 'function'
                    ? nextCurrentMove(state.currentMove)
                    : nextCurrentMove,
              }))
            },
          }
        },
      ),
    )
    
    function Square({ value, onSquareClick }) {
      return (
        <button
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            padding: 0,
            backgroundColor: '#fff',
            border: '1px solid #999',
            outline: 0,
            borderRadius: 0,
            fontSize: '1rem',
            fontWeight: 'bold',
          }}
          onClick={onSquareClick}
        >
          {value}
        </button>
      )
    }
    
    function Board({ xIsNext, squares, onPlay }) {
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        onPlay(nextSquares)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((_, i) => (
              <Square key={`square-${i}`} value={squares[i]} onSquareClick={() => handleClick(i)} />
            ))}
          </div>
        </>
      )
    }
    
    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const currentMove = useGameStore((state) => state.currentMove)
      const setCurrentMove = useGameStore((state) => state.setCurrentMove)
      const xIsNext = currentMove % 2 === 0
      const currentSquares = history[currentMove]
    
      function handlePlay(nextSquares) {
        const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]
        setHistory(nextHistory)
        setCurrentMove(nextHistory.length - 1)
      }
    
      function jumpTo(nextMove) {
        setCurrentMove(nextMove)
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>
              {history.map((_, historyIndex) => {
                const description =
                  historyIndex > 0 ? `Go to move #${historyIndex}` : 'Go to game start'
    
                return (
                  <li key={historyIndex}>
                    <button onClick={() => jumpTo(historyIndex)}>{description}</button>
                  </li>
                )
              })}
            </ol>
          </div>
        </div>
      )
    }
    
    function calculateWinner(squares) {
      const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
      ]
    
      for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i]
        if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
          return squares[a]
        }
      }
    
      return null
    }
    
    function calculateTurns(squares) {
      return squares.filter((square) => !square).length
    }
    
    function calculateStatus(winner, turns, player) {
      if (!winner && !turns) return 'Draw'
      if (winner) return `Winner ${winner}`
      return `Next player: ${player}`
    }

    构建棋盘

    让我们从创建 Square 组件开始,它将成为 Board 组件的构建块。这个组件表示游戏中的每个方格。

    Square 组件应接收 value 和 onSquareClick 作为 props。它应返回一个 <button> 元素,并为其设置样式使其看起来像一个方格。按钮会显示 value prop,它可以是 'X'、'O' 或 null,取决于游戏的状态。当按钮被点击时,它会触发作为 prop 传入的 onSquareClick 函数,让游戏能够响应用户输入。

    下面是 Square 组件的代码:

    function Square({ value, onSquareClick }) {
      return (
        <button
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            padding: 0,
            backgroundColor: '#fff',
            border: '1px solid #999',
            outline: 0,
            borderRadius: 0,
            fontSize: '1rem',
            fontWeight: 'bold',
          }}
          onClick={onSquareClick}
        >
          {value}
        </button>
      )
    }

    接下来创建 Board 组件,它由 9 个按网格排列的方格组成。这个组件将作为游戏的主要对弈区域。

    Board 组件应返回一个样式为网格的 <div> 元素。网格布局使用 CSS Grid 实现,包含三列三行,每列每行都占据可用空间的相等份数。网格的整体尺寸由 width 和 height 属性决定,确保它呈正方形且大小合适。

    在网格内部,我们放置九个 Square 组件,每个组件的 value prop 表示其位置。这些 Square 组件最终会保存游戏符号('X' 或 'O')并处理用户交互。

    下面是 Board 组件的代码:

    export default function Board() {
      return (
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(3, 1fr)',
            gridTemplateRows: 'repeat(3, 1fr)',
            width: 'calc(3 * 2.5rem)',
            height: 'calc(3 * 2.5rem)',
            border: '1px solid #999',
          }}
        >
          <Square value="1" />
          <Square value="2" />
          <Square value="3" />
          <Square value="4" />
          <Square value="5" />
          <Square value="6" />
          <Square value="7" />
          <Square value="8" />
          <Square value="9" />
        </div>
      )
    }

    这个 Board 组件通过将九个方格排成 3x3 的网格,为我们的游戏棋盘搭建了基本结构。它把方格整齐地摆放好,为后续添加更多功能和处理玩家交互打下了基础。

    提升状态

    每个 Square 组件都可以维护游戏状态的一部分。要在井字棋游戏中判断胜者,Board 组件需要以某种方式了解 9 个 Square 组件各自的状态。

    你会怎么处理?一开始,你可能会猜 Board 组件需要向每个 Square 组件询问该 Square 的组件状态。虽然这种做法在 React 中技术上是可行的,但我们不推荐,因为代码会变得难以理解、容易出错,也难于重构。更好的做法是把游戏状态存放在父级 Board 组件中,而不是放在每个 Square 组件里。Board 组件可以通过传入 prop,告诉每个 Square 组件该显示什么,就像你之前给每个 Square 组件传入一个数字那样。

    WARNING

    要从多个子组件收集数据,或让两个或多个子组件相互通信,应改为在它们的父组件中声明共享状态。父组件可以通过 props 把该状态向下传回给子组件。这样能让子组件彼此之间以及与父组件保持同步。

    让我们借此机会试一试。修改 Board 组件,让它声明一个名为 squares 的状态变量,默认值为一个包含 9 个 null 的数组,对应 9 个方格:

    import { create } from 'zustand'
    import { combine } from 'zustand/middleware'
    
    const useGameStore = create(
      combine({ squares: Array(9).fill(null) }, (set) => {
        return {
          setSquares: (nextSquares) => {
            set((state) => ({
              squares: typeof nextSquares === 'function' ? nextSquares(state.squares) : nextSquares,
            }))
          },
        }
      }),
    )
    
    export default function Board() {
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
    
      return (
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(3, 1fr)',
            gridTemplateRows: 'repeat(3, 1fr)',
            width: 'calc(3 * 2.5rem)',
            height: 'calc(3 * 2.5rem)',
            border: '1px solid #999',
          }}
        >
          {squares.map((square, squareIndex) => (
            <Square key={squareIndex} value={square} />
          ))}
        </div>
      )
    }

    Array(9).fill(null) 创建一个包含九个元素的数组,并将每个元素都设为 null。useGameStore 声明了一个 squares 状态,初始值就是这个数组。数组中的每一项对应一个方格的值。等你之后填充棋盘时,squares 数组会像这样:

    const squares = ['O', null, 'X', 'X', 'X', 'O', 'O', null, null]

    现在每个 Square 都会接收一个 value prop,值为 'X'、'O',空方格则为 null。

    接下来,你需要改变点击 Square 组件时发生的行为。现在 Board 组件维护着哪些方格已被填充。你需要让 Square 组件能够更新 Board 的组件状态。由于状态对于定义它的组件来说是私有的,你无法直接从 Square 组件更新 Board 的组件状态。

    于是,你可以从 Board 组件向下传递一个函数给 Square 组件,并在点击方格时让 Square 组件调用这个函数。先从 Square 组件被点击时将调用的函数开始,把它命名为 onSquareClick:

    现在,把 onSquareClick prop 连接到 Board 组件中一个名为 handleClick 的函数上。要把 onSquareClick 连接到 handleClick,你可以向第一个 Square 组件的 onSquareClick prop 传入一个内联函数:

    <Square key={squareIndex} value={square} onSquareClick={() => handleClick(i)} />

    最后,在 Board 组件中定义 handleClick 函数,用来更新保存棋盘状态的 squares 数组。

    handleClick 函数应接收要更新的方格索引,并创建 squares 数组的副本(nextSquares)。然后,handleClick 更新 nextSquares 数组:如果指定索引(i)处的方格尚未被填充,就往里放入 X。

    export default function Board() {
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
    
      function handleClick(i) {
        if (squares[i]) return
        const nextSquares = squares.slice()
        nextSquares[i] = 'X'
        setSquares(nextSquares)
      }
    
      return (
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(3, 1fr)',
            gridTemplateRows: 'repeat(3, 1fr)',
            width: 'calc(3 * 2.5rem)',
            height: 'calc(3 * 2.5rem)',
            border: '1px solid #999',
          }}
        >
          {squares.map((square, squareIndex) => (
            <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
          ))}
        </div>
      )
    }
    WARNING

    注意在 handleClick 函数中,你调用 .slice() 来创建 squares 数组的副本,而不是修改现有数组。

    轮流落子

    现在该修复这个井字棋游戏的一个重大缺陷了:'O' 无法在棋盘上使用。

    你将默认让第一步为 'X'。让我们向 useGameStore Hook 中再添加一份状态来跟踪这一点:

    const useGameStore = create(
      combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
        return {
          setSquares: (nextSquares) => {
            set((state) => ({
              squares: typeof nextSquares === 'function' ? nextSquares(state.squares) : nextSquares,
            }))
          },
          setXIsNext: (nextXIsNext) => {
            set((state) => ({
              xIsNext: typeof nextXIsNext === 'function' ? nextXIsNext(state.xIsNext) : nextXIsNext,
            }))
          },
        }
      }),
    )

    每当玩家落子时,xIsNext(一个布尔值)都会翻转,以决定下一位玩家,游戏状态也会被保存。你要更新 Board 的 handleClick 函数,让它翻转 xIsNext 的值:

    export default function Board() {
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
      const player = xIsNext ? 'X' : 'O'
    
      function handleClick(i) {
        if (squares[i]) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        setSquares(nextSquares)
        setXIsNext(!xIsNext)
      }
    
      return (
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(3, 1fr)',
            gridTemplateRows: 'repeat(3, 1fr)',
            width: 'calc(3 * 2.5rem)',
            height: 'calc(3 * 2.5rem)',
            border: '1px solid #999',
          }}
        >
          {squares.map((square, squareIndex) => (
            <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
          ))}
        </div>
      )
    }

    宣布胜者或平局

    既然玩家可以轮流落子,你就要在游戏分出胜负或平局、没有更多可走的棋步时显示出来。为此,你要添加三个辅助函数。第一个辅助函数叫 calculateWinner,它接收一个包含 9 个方格的数组,检查是否有胜者,并视情况返回 'X'、'O' 或 null。第二个辅助函数叫 calculateTurns,它接收同一个数组,通过只筛出 null 项来检查剩余步数,并返回其数量。最后一个辅助函数叫 calculateStatus,它接收剩余步数、胜者和当前玩家('X' or 'O'):

    function calculateWinner(squares) {
      const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
      ]
    
      for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i]
        if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
          return squares[a]
        }
      }
    
      return null
    }
    
    function calculateTurns(squares) {
      return squares.filter((square) => !square).length
    }
    
    function calculateStatus(winner, turns, player) {
      if (!winner && !turns) return 'Draw'
      if (winner) return `Winner ${winner}`
      return `Next player: ${player}`
    }

    你将在 Board 组件的 handleClick 函数中使用 calculateWinner(squares) 的结果,检查是否有玩家获胜。你可以在检查用户是否点击了已有 'X' 或 'O' 的方格时,同时做这个检查。这两种情况下我们都希望提前返回:

    function handleClick(i) {
      if (squares[i] || winner) return
      const nextSquares = squares.slice()
      nextSquares[i] = player
      setSquares(nextSquares)
      setXIsNext(!xIsNext)
    }

    为了让玩家知道游戏已经结束,你可以显示诸如 'Winner: X' 或 'Winner: O' 的文字。要做到这一点,你要给 Board 组件添加一个 status 区域。游戏结束时,status 会显示胜者或平局;游戏进行中,则会显示下一位玩家是谁:

    export default function Board() {
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        setSquares(nextSquares)
        setXIsNext(!xIsNext)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((square, squareIndex) => (
              <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
            ))}
          </div>
        </>
      )
    }

    恭喜!你现在有了一个可以运行的井字棋游戏。而且你刚刚还学会了 React 和 Zustand 的基础知识。所以真正的赢家是你。代码应该像这样:

    import { create } from 'zustand'
    import { combine } from 'zustand/middleware'
    
    const useGameStore = create(
      combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
        return {
          setSquares: (nextSquares) => {
            set((state) => ({
              squares: typeof nextSquares === 'function' ? nextSquares(state.squares) : nextSquares,
            }))
          },
          setXIsNext: (nextXIsNext) => {
            set((state) => ({
              xIsNext: typeof nextXIsNext === 'function' ? nextXIsNext(state.xIsNext) : nextXIsNext,
            }))
          },
        }
      }),
    )
    
    function Square({ value, onSquareClick }) {
      return (
        <button
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            padding: 0,
            backgroundColor: '#fff',
            border: '1px solid #999',
            outline: 0,
            borderRadius: 0,
            fontSize: '1rem',
            fontWeight: 'bold',
          }}
          onClick={onSquareClick}
        >
          {value}
        </button>
      )
    }
    
    export default function Board() {
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        setSquares(nextSquares)
        setXIsNext(!xIsNext)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((square, squareIndex) => (
              <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
            ))}
          </div>
        </>
      )
    }
    
    function calculateWinner(squares) {
      const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
      ]
    
      for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i]
        if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
          return squares[a]
        }
      }
    
      return null
    }
    
    function calculateTurns(squares) {
      return squares.filter((square) => !square).length
    }
    
    function calculateStatus(winner, turns, player) {
      if (!winner && !turns) return 'Draw'
      if (winner) return `Winner ${winner}`
      return `Next player: ${player}`
    }

    添加时间旅行

    作为最后一个练习,让我们实现“回到过去”,重温游戏中之前的落子。

    如果你直接修改 squares 数组,实现这个时间旅行功能会非常困难。然而,由于你在每次落子后都用 slice() 创建了 squares 数组的新副本,把它当作不可变数据,你就可以保存 squares 数组的每个历史版本,并在它们之间切换。

    你会把这些过去的 squares 数组保存在一个名为 history 的新状态变量中。这个 history 数组会存储从第一步到最新一步的所有棋盘状态,看起来大致像这样:

    const history = [
      // First move
      [null, null, null, null, null, null, null, null, null],
      // Second move
      ['X', null, null, null, null, null, null, null, null],
      // Third move
      ['X', 'O', null, null, null, null, null, null, null],
      // and so on...
    ]

    这种做法让你可以轻松地在不同游戏状态之间切换,并实现时间旅行功能。

    再次提升状态

    接下来,你会创建一个名为 Game 的新顶层组件,用来显示过去落子的列表。你将在这里保存包含整个游戏历史的 history 状态。

    把 history 状态放在 Game 组件中后,你就可以从 Board 组件中移除 squares 状态。现在你要把状态从 Board 组件提升到顶层 Game 组件。这一改动让 Game 组件可以完全控制 Board 组件的数据,并指示 Board 组件根据 history 渲染之前的回合。

    首先,添加一个带 export default 的 Game 组件,并从 Board 组件中移除它。代码应该像这样:

    function Board() {
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const squares = useGameStore((state) => state.squares)
      const setSquares = useGameStore((state) => state.setSquares)
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        setSquares(nextSquares)
        setXIsNext(!xIsNext)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((square, squareIndex) => (
              <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
            ))}
          </div>
        </>
      )
    }
    
    export default function Game() {
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>{/* TODO */}</ol>
          </div>
        </div>
      )
    }

    向 useGameStore Hook 添加一些状态,用来跟踪落子历史:

    const useGameStore = create(
      combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
        return {
          setHistory: (nextHistory) => {
            set((state) => ({
              history: typeof nextHistory === 'function' ? nextHistory(state.history) : nextHistory,
            }))
          },
          setXIsNext: (nextXIsNext) => {
            set((state) => ({
              xIsNext: typeof nextXIsNext === 'function' ? nextXIsNext(state.xIsNext) : nextXIsNext,
            }))
          },
        }
      }),
    )

    注意 [Array(9).fill(null)] 创建了一个只包含单个项的数组,而这个项本身是一个包含 9 个 null 值的数组。

    要渲染当前这一步的方格,你需要从 history 状态中读取最近的 squares 数组。你不需要为此增加额外的状态,因为你在渲染期间已经有足够的信息来计算它:

    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const currentSquares = history[history.length - 1]
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>{/*TODO*/}</ol>
          </div>
        </div>
      )
    }

    接下来,在 Game 组件中创建一个 handlePlay 函数,它会被 Board 组件调用以更新游戏。把 xIsNext、currentSquares 和 handlePlay 作为 props 传给 Board 组件:

    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const currentSquares = history[history.length - 1]
    
      function handlePlay(nextSquares) {
        // TODO
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>{/*TODO*/}</ol>
          </div>
        </div>
      )
    }

    让 Board 组件完全受其接收的 props 控制。为此,我们修改 Board 组件,让它接收三个 props:xIsNext、squares,以及一个新的 onPlay 函数;玩家落子时,Board 组件可以用更新后的 squares 数组调用它。

    function Board({ xIsNext, squares, onPlay }) {
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        onPlay(nextSquares)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((square, squareIndex) => (
              <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
            ))}
          </div>
        </>
      )
    }

    现在 Board 组件完全由 Game 组件传给它的 props 控制。要让游戏重新运行起来,你需要在 Game 组件中实现 handlePlay 函数。

    调用 handlePlay 时它应该做什么?之前,Board 组件用更新后的数组调用 setSquares;现在它把更新后的 squares 数组传给 onPlay。

    handlePlay 函数需要更新 Game 组件的状态,以触发重新渲染。你不再使用 setSquares,而是通过把更新后的 squares 数组作为新的 history 条目追加进去,来更新 history 状态变量。你还需要像 Board 组件以前那样翻转 xIsNext。

    function handlePlay(nextSquares) {
      setHistory(history.concat([nextSquares]))
      setXIsNext(!xIsNext)
    }

    此时,你已经把状态移到了 Game 组件中,界面应该完全正常工作,和重构前一样。此时代码应该像这样:

    import { create } from 'zustand'
    import { combine } from 'zustand/middleware'
    
    const useGameStore = create(
      combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
        return {
          setHistory: (nextHistory) => {
            set((state) => ({
              history: typeof nextHistory === 'function' ? nextHistory(state.history) : nextHistory,
            }))
          },
          setXIsNext: (nextXIsNext) => {
            set((state) => ({
              xIsNext: typeof nextXIsNext === 'function' ? nextXIsNext(state.xIsNext) : nextXIsNext,
            }))
          },
        }
      }),
    )
    
    function Square({ value, onSquareClick }) {
      return (
        <button
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            padding: 0,
            backgroundColor: '#fff',
            border: '1px solid #999',
            outline: 0,
            borderRadius: 0,
            fontSize: '1rem',
            fontWeight: 'bold',
          }}
          onClick={onSquareClick}
        >
          {value}
        </button>
      )
    }
    
    function Board({ xIsNext, squares, onPlay }) {
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        onPlay(nextSquares)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(3, 1fr)',
              width: 'calc(3 * 2.5rem)',
              height: 'calc(3 * 2.5rem)',
              border: '1px solid #999',
            }}
          >
            {squares.map((square, squareIndex) => (
              <Square key={squareIndex} value={square} onSquareClick={() => handleClick(squareIndex)} />
            ))}
          </div>
        </>
      )
    }
    
    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const currentSquares = history[history.length - 1]
    
      function handlePlay(nextSquares) {
        setHistory(history.concat([nextSquares]))
        setXIsNext(!xIsNext)
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>{/*TODO*/}</ol>
          </div>
        </div>
      )
    }
    
    function calculateWinner(squares) {
      const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
      ]
    
      for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i]
        if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
          return squares[a]
        }
      }
    
      return null
    }
    
    function calculateTurns(squares) {
      return squares.filter((square) => !square).length
    }
    
    function calculateStatus(winner, turns, player) {
      if (!winner && !turns) return 'Draw'
      if (winner) return `Winner ${winner}`
      return `Next player: ${player}`
    }

    显示过去的落子

    既然你记录了井字棋游戏的历史,现在就可以向玩家显示过去落子的列表。

    你的 store 中已经有了 history 落子数组,现在需要把它转换成 React 元素数组。在 JavaScript 中,要把一个数组转换成另一个数组,可以使用 Array 的 .map() 方法:

    你要用 map 把落子 history 转换成屏幕上代表按钮的 React 元素,并显示一个按钮列表,用于跳转到过去的落子。让我们在 Game 组件中对 history 执行 map:

    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const currentSquares = history[history.length - 1]
    
      function handlePlay(nextSquares) {
        setHistory(history.concat([nextSquares]))
        setXIsNext(!xIsNext)
      }
    
      function jumpTo(nextMove) {
        // TODO
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>
              {history.map((_, historyIndex) => {
                const description =
                  historyIndex > 0 ? `Go to move #${historyIndex}` : 'Go to game start'
    
                return (
                  <li key={historyIndex}>
                    <button onClick={() => jumpTo(historyIndex)}>{description}</button>
                  </li>
                )
              })}
            </ol>
          </div>
        </div>
      )
    }

    在实现 jumpTo 函数之前,你需要让 Game 组件跟踪用户当前查看的是哪一步。为此,定义一个名为 currentMove 的新状态变量,初始值为 0:

    const useGameStore = create(
      combine({ history: [Array(9).fill(null)], currentMove: 0, xIsNext: true }, (set) => {
        return {
          setHistory: (nextHistory) => {
            set((state) => ({
              history: typeof nextHistory === 'function' ? nextHistory(state.history) : nextHistory,
            }))
          },
          setCurrentMove: (nextCurrentMove) => {
            set((state) => ({
              currentMove:
                typeof nextCurrentMove === 'function'
                  ? nextCurrentMove(state.currentMove)
                  : nextCurrentMove,
            }))
          },
          setXIsNext: (nextXIsNext) => {
            set((state) => ({
              xIsNext: typeof nextXIsNext === 'function' ? nextXIsNext(state.xIsNext) : nextXIsNext,
            }))
          },
        }
      }),
    )

    接下来,更新 Game 组件中的 jumpTo 函数,以更新 currentMove。如果你把 currentMove 改成偶数,你还要把 xIsNext 设为 true。

    function jumpTo(nextMove) {
      setCurrentMove(nextMove)
      setXIsNext(currentMove % 2 === 0)
    }

    现在要对 Game 组件中的 handlePlay 函数做两处修改,该函数会在你点击方格时被调用。

    • 如果你“回到过去”,然后从那个时间点走出新的一步,你只想保留到那个时间点为止的历史。不要把 nextSquares 添加到 history 中所有条目之后(使用 Array 的 .concat() 方法),而是添加到 history.slice(0, currentMove + 1) 的所有条目之后,只保留旧历史中的这一部分。
    • 每走一步,你都需要更新 currentMove,让它指向最新的历史条目。
    function handlePlay(nextSquares) {
      const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
      setHistory(nextHistory)
      setCurrentMove(nextHistory.length - 1)
      setXIsNext(!xIsNext)
    }

    最后,修改 Game 组件,让它渲染当前选中的那一步,而不是始终渲染最后一步:

    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const currentMove = useGameStore((state) => state.currentMove)
      const setCurrentMove = useGameStore((state) => state.setCurrentMove)
      const xIsNext = useGameStore((state) => state.xIsNext)
      const setXIsNext = useGameStore((state) => state.setXIsNext)
      const currentSquares = history[currentMove]
    
      function handlePlay(nextSquares) {
        const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
        setHistory(nextHistory)
        setCurrentMove(nextHistory.length - 1)
        setXIsNext(!xIsNext)
      }
    
      function jumpTo(nextMove) {
        setCurrentMove(nextMove)
        setXIsNext(nextMove % 2 === 0)
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>
              {history.map((_, historyIndex) => {
                const description =
                  historyIndex > 0 ? `Go to move #${historyIndex}` : 'Go to game start'
    
                return (
                  <li key={historyIndex}>
                    <button onClick={() => jumpTo(historyIndex)}>{description}</button>
                  </li>
                )
              })}
            </ol>
          </div>
        </div>
      )
    }

    最后的清理

    如果你仔细看代码,会发现当 currentMove 为偶数时 xIsNext 为 true,为奇数时为 false。这意味着只要知道 currentMove 的值,你总能确定 xIsNext 应该是多少。

    没有必要在状态中单独保存 xIsNext。最好避免冗余状态,因为这能减少 bug,也让代码更易理解。你可以改为根据 currentMove 计算 xIsNext:

    export default function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      const currentMove = useGameStore((state) => state.currentMove)
      const setCurrentMove = useGameStore((state) => state.setCurrentMove)
      const xIsNext = currentMove % 2 === 0
      const currentSquares = history[currentMove]
    
      function handlePlay(nextSquares) {
        const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
        setHistory(nextHistory)
        setCurrentMove(nextHistory.length - 1)
      }
    
      function jumpTo(nextMove) {
        setCurrentMove(nextMove)
      }
    
      return (
        <div
          style={{
            display: 'flex',
            flexDirection: 'row',
            fontFamily: 'monospace',
          }}
        >
          <div>
            <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
          </div>
          <div style={{ marginLeft: '1rem' }}>
            <ol>
              {history.map((_, historyIndex) => {
                const description =
                  historyIndex > 0 ? `Go to move #${historyIndex}` : 'Go to game start'
    
                return (
                  <li key={historyIndex}>
                    <button onClick={() => jumpTo(historyIndex)}>{description}</button>
                  </li>
                )
              })}
            </ol>
          </div>
        </div>
      )
    }

    你不再需要 xIsNext 的状态声明,也不再需要调用 setXIsNext。现在,即使你在编写组件时犯了错,xIsNext 也不会与 currentMove 不同步。

    总结

    恭喜!你创建了一个井字棋游戏,它可以:

    • 让你玩井字棋,
    • 指示玩家何时获胜或何时平局,
    • 在游戏过程中保存游戏历史,
    • 让玩家回顾游戏历史,查看棋盘之前的版本。

    干得漂亮!希望你现在对 React 和 Zustand 的工作原理有了不错的掌握。