Spec-Zone.ru › React 18

Учебник: Крестики-нолики

В этом руководстве вы создадите небольшую игру в крестики-нолики. Данный учебник не предполагает никаких предварительных знаний React. Технические приёмы, которые вы изучите в этом руководстве, являются основополагающими для создания любого приложения React, и полное понимание их позволит вам глубоко изучить React.

Примечание

Этот учебник предназначен для людей, предпочитающих изучать на практике и быстро создавать нечто осязаемое. Если вы предпочитаете изучение каждого концепции пошагово, начните с Описания пользовательского интерфейса.

Учебник разделён на несколько разделов:

  • Настройка для учебника предоставит вам начальную точку для следования учебнику.
  • Обзор научит вас основам React: компонентам, пропса и состоянию.
  • Завершение игры научит вас наиболее распространённым техникам разработки приложений с React.
  • Добавление путешествий во времени позволит вам глубоко понять уникальные сильные стороны React.

Что вы будете создавать?

В этом руководстве вы создадите интерактивную игру в крестики-нолики с использованием React.

Вы можете увидеть, как это будет выглядеть по завершении здесь:

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  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);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</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;
}

Если код покажется вам непонятным или вы не знакомы с синтаксисом кода, не волнуйтесь! Цель этого руководства — помочь вам понять React и его синтаксис.

Мы рекомендуем ознакомиться с игрой в крестики-нолики выше перед продолжением учебника. Одним из особенностей, которую вы заметите, является нумерованный список справа от игрового поля. Этот список предоставляет вам историю всех произошедших ходов в игре и обновляется по мере её продвижения.

После того, как вы поиграете с готовой игрой в крестики-нолики, продолжайте просмотр. В этом руководстве вы начнете с более простого шаблона. Следующим шагом является подготовка к началу создания игры.

Настройка для учебника

В редакторе кода ниже, нажмите Вилка в верхнем правом углу, чтобы открыть редактор в новой вкладке с использованием веб-сайта CodeSandbox. CodeSandbox позволяет вам писать код в браузере и просматривать, как пользователи увидят созданное вами приложение. В новой вкладке должен отображаться пустой квадрат и стартовый код для этого учебника.

export default function Square() {
  return <button className="square">X</button>;
}

Примечание

Вы также можете следовать этому учебнику, используя свою локальную среду разработки. Для этого вам нужно:

  1. Установить Node.js
  2. В открытой ранее вкладке CodeSandbox нажмите кнопку в верхнем левом углу, чтобы открыть меню, а затем выберите Скачать песочницу в этом меню, чтобы загрузить архив файлов локально
  3. Разархивируйте архив, затем откройте терминал и cd в каталог, который вы разархивировали
  4. Установить зависимости с помощью npm install
  5. Запустить npm start для запуска локального сервера и следовать подсказкам, чтобы просмотреть код, работающий в браузере

Если у вас возникнут трудности, не позволяйте этому вас остановить! Следуйте онлайн-инструкции и попробуйте локальную установку позже.

Обзор

Теперь, когда вы настроены, давайте рассмотрим React!

Просмотр начального кода

В CodeSandbox вы увидите три основных раздела:

CodeSandbox with starter code
  1. Раздел Файлы со списком файлов, таких как App.js, index.js, styles.css и папкой public
  2. Редактор кода, где вы увидите исходный код выбранного файла
  3. Раздел Браузер, где вы увидите, как будет отображаться написанный вами код

Файл App.js должен быть выбран в разделе Файлы. Содержимое этого файла в Редакторе кода должно быть:

export default function Square() {
  return <button className="square">X</button>;
}

Раздел Браузер должен отображать квадрат с X в нём, как показано ниже:

x-filled square

Теперь давайте посмотрим на файлы в начальном коде.

App.js

Код в App.js создаёт компонент. В React, компонент — это фрагмент повторно используемого кода, представляющий часть пользовательского интерфейса. Компоненты используются для рендеринга, управления и обновления элементов пользовательского интерфейса в вашем приложении. Давайте рассмотрим компонент построчно, чтобы понять, что происходит:

export default function Square() {
  return <button className="square">X</button>;
}

Первая строка определяет функцию под названием Square. Ключевое слово export JavaScript делает эту функцию доступной за пределами этого файла. Ключевое слово default сообщает другим файлам, использующим ваш код, что это главная функция в вашем файле.

export default function Square() {
  return <button className="square">X</button>;
}

Вторая строка возвращает кнопку. Ключевое слово return JavaScript означает, что всё, что следует за ним, возвращается в качестве значения вызывающему функцию. <button> — это элемент JSX. Элемент JSX — это комбинация кода JavaScript и HTML-тегов, описывающая то, что вы хотите отобразить. className="square" — это свойство кнопки или проп, сообщающее CSS, как стилизовать кнопку. X — это текст, отображаемый внутри кнопки, а </button> закрывает элемент JSX, указывая, что любой последующий контент не должен располагаться внутри кнопки.

styles.css

Нажмите на файл под названием styles.css в разделе Файлы CodeSandbox. Этот файл определяет стили для вашего приложения React. Первые два селектора CSS (* и body) определяют стиль больших частей вашего приложения, в то время как селектор .square определяет стиль любого компонента, где свойство className установлено в значение square. В вашем коде это соответствует кнопке из вашего компонента Square в файле App.js.

index.js

Нажмите на файл под названием index.js в разделе Файлы CodeSandbox. Вы не будете редактировать этот файл в ходе учебника, но он является мостом между компонентом, который вы создали в файле App.js и веб-браузером.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';

import App from './App';

Строки с 1 по 5 объединяют все необходимые части:

  • React
  • Библиотека React для общения с веб-браузерами (React DOM)
  • стили для ваших компонентов
  • компонент, который вы создали в App.js.

Остальная часть файла объединяет все части и вставляет конечный продукт в index.html в папке public.

Создание игрового поля

Давайте вернёмся к App.js. Здесь вы и проведете остальную часть учебника.

В настоящее время поле состоит только из одного квадрата, но вам нужно девять! Если вы просто скопируете и вставите свой квадрат, чтобы создать два квадрата, как показано ниже:

export default function Square() {
  return <button className="square">X</button><button className="square">X</button>;
}

Вы получите эту ошибку:

Консоль
/src/App.js: Смежные элементы JSX должны быть заключены в окружающий тег. Вы хотели фрагмент JSX <>...</>?

Компоненты React должны возвращать один элемент JSX, а не несколько смежных элементов JSX, таких как две кнопки. Для исправления этого можно использовать фрагменты (<> и </>) для упаковки нескольких смежных элементов JSX, как показано ниже:

export default function Square() {
  return (
    <>
      <button className="square">X</button>
      <button className="square">X</button>
    </>
  );
}

Теперь вы должны увидеть:

two x-filled squares

Отлично! Теперь вам просто нужно несколько раз скопировать и вставить, чтобы добавить девять квадратов и…

nine x-filled squares in a line

Упс! Квадраты находятся в одной строке, а не в сетке, как нужно для нашего поля. Для исправления этого вам нужно сгруппировать квадраты в строки с помощью div и добавить некоторые классы CSS. Пока вы этим занимаетесь, вы присвоите каждому квадрату номер, чтобы убедиться, что вы знаете, где отображается каждый квадрат.

В файле App.js обновите компонент Square следующим образом:

export default function Square() {
  return (
    <>
      <div className="board-row">
        <button className="square">1</button>
        <button className="square">2</button>
        <button className="square">3</button>
      </div>
      <div className="board-row">
        <button className="square">4</button>
        <button className="square">5</button>
        <button className="square">6</button>
      </div>
      <div className="board-row">
        <button className="square">7</button>
        <button className="square">8</button>
        <button className="square">9</button>
      </div>
    </>
  );
}

CSS, определённый в styles.css, стилизует div с классом className в board-row. Теперь, когда вы сгруппировали свои компоненты в строки с помощью стилизованных div , у вас есть игровое поле крестики-нолики:

tic-tac-toe board filled with numbers 1 through 9

Но у вас теперь есть проблема. Ваш компонент под названием Square, на самом деле больше не квадрат. Давайте исправим это, изменив название на Board:

export default function Board() {
  //...
}

На этом этапе ваш код должен выглядеть примерно так:

export default function Board() {
  return (
    <>
      <div className="board-row">
        <button className="square">1</button>
        <button className="square">2</button>
        <button className="square">3</button>
      </div>
      <div className="board-row">
        <button className="square">4</button>
        <button className="square">5</button>
        <button className="square">6</button>
      </div>
      <div className="board-row">
        <button className="square">7</button>
        <button className="square">8</button>
        <button className="square">9</button>
      </div>
    </>
  );
}

Примечание

Ой, это много ввода! В порядке копировать и вставлять код с этой страницы. Однако, если вы готовы к небольшому вызову, мы рекомендуем копировать только тот код, который вы сами набирали на клавиатуре хотя бы один раз.

Передача данных через props

Далее, вы хотите изменить значение квадрата с пустого на «X», когда пользователь нажимает на квадрат. С тем, как вы построили доску до этого момента, вам нужно было бы копировать и вставлять код, который обновляет квадрат девять раз (по одному для каждого квадрата)! Вместо копирования и вставки, архитектура компонентов React позволяет вам создавать повторно используемые компоненты, чтобы избежать беспорядочного, дублированного кода.

Сначала вы скопируете строку, определяющую ваш первый квадрат (<button className="square">1</button>), из вашего компонента Board в новый компонент Square:

function Square() {
  return <button className="square">1</button>;
}

export default function Board() {
  // ...
}

Затем вы обновите компонент Board для рендеринга этого компонента Square с использованием синтаксиса JSX:

// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

Обратите внимание, что, в отличие от веб-браузеров div, ваши собственные компоненты Board и Square должны начинаться с большой буквы.

Давайте посмотрим:

one-filled board

Упс! Вы потеряли нумерованные квадраты, которые были раньше. Теперь каждый квадрат говорит «1». Чтобы исправить это, вы будете использовать props для передачи значения, которое каждый квадрат должен иметь от родительского компонента (Board) к своему дочернему (Square).

Обновите компонент Square для чтения пропса value, который вы передадите из компонента Board:

function Square({ value }) {
  return <button className="square">1</button>;
}

function Square({ value }) указывает, что компонент Square может получить проп с именем value.

Теперь вы хотите отобразить этот проп value вместо 1 внутри каждого квадрата. Попробуйте сделать это так:

function Square({ value }) {
  return <button className="square">value</button>;
}

Упс, это не то, что вы хотели:

value-filled board

Вы хотели отобразить JavaScript-переменную с именем value из вашего компонента, а не слово «значение». Чтобы «войти в JavaScript» из JSX, вам нужны фигурные скобки. Добавьте фигурные скобки вокруг value в JSX, как показано ниже:

function Square({ value }) {
  return <button className="square">{value}</button>;
}

Сейчас вы должны увидеть пустое поле:

empty board

Это потому, что компонент Board еще не передал свойство value каждому компоненту Square, который он отображает. Чтобы исправить это, вы добавите свойство value к каждому компоненту Square, отображаемому компонентом Board:

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="1" />
        <Square value="2" />
        <Square value="3" />
      </div>
      <div className="board-row">
        <Square value="4" />
        <Square value="5" />
        <Square value="6" />
      </div>
      <div className="board-row">
        <Square value="7" />
        <Square value="8" />
        <Square value="9" />
      </div>
    </>
  );
}

Теперь вы снова должны увидеть сетку чисел:

tic-tac-toe board filled with numbers 1 through 9

Ваш обновлённый код должен выглядеть так:

function Square({ value }) {
  return <button className="square">{value}</button>;
}

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="1" />
        <Square value="2" />
        <Square value="3" />
      </div>
      <div className="board-row">
        <Square value="4" />
        <Square value="5" />
        <Square value="6" />
      </div>
      <div className="board-row">
        <Square value="7" />
        <Square value="8" />
        <Square value="9" />
      </div>
    </>
  );
}

Создание интерактивного компонента

Давайте заполним компонент Square символом X при нажатии на него. Объявите функцию с именем handleClick внутри компонента Square. Затем добавьте onClick в свойства элемента JSX кнопки, возвращаемого компонентом Square:

function Square({ value }) {
  function handleClick() {
    console.log('clicked!');
  }

  return (
    <button
      className="square"
      onClick={handleClick}
    >
      {value}
    </button>
  );
}

Если вы сейчас нажмете на квадрат, вы увидите в вкладке Консоль в нижней части раздела Браузер в CodeSandbox сообщение "clicked!". Нажатие на квадрат более одного раза приведет к выводу "clicked!" снова. Повторные логи консоли с одним и тем же сообщением не будут создавать новые строки в консоли. Вместо этого вы увидите увеличивающийся счётчик рядом с вашим первым сообщением в "clicked!".

Примечание

Если вы следуете этому руководству, используя локальную среду разработки, вам необходимо открыть консоль браузера. Например, если вы используете браузер Chrome, вы можете открыть консоль с помощью сочетания клавиш Shift + Ctrl + J (Windows/Linux) или Option + ⌘ + J (macOS).

Следующим шагом является то, что компонент Квадрат должен «запоминать», что его нажали, и заполнять его меткой «Х». Для «запоминания» вещей компоненты используют состояние.

React предоставляет специальную функцию, называемую useState, которую вы можете вызвать из своего компонента, чтобы позволить ему «запоминать» вещи. Давайте сохраним текущее значение Square в состоянии и изменим его при нажатии на Square.

Импортируйте useState в начало файла. Удалите свойство value из компонента Square . Вместо этого добавьте новую строку в начало компонента Square, которая вызывает useState. Пусть она возвращает переменную состояния, называемую value:

import { useState } from 'react';

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    //...

value хранит значение, а setValue — функция, которая может использоваться для изменения значения. Переданное в useState значение null используется в качестве начального значения для этой переменной состояния, поэтому value здесь изначально равно null.

Поскольку компонент Square больше не принимает свойства, вы удалите свойство value из всех девяти компонентов Квадрат, созданных компонентом Доска:

// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

Теперь вы измените Square на отображение «Х» при нажатии. Замените обработчик события console.log("clicked!"); на setValue('X');. Теперь ваш компонент Square выглядит так:

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    setValue('X');
  }

  return (
    <button
      className="square"
      onClick={handleClick}
    >
      {value}
    </button>
  );
}

Вызывая эту функцию set из обработчика onClick, вы говорите React перерисовать этот компонент Square всякий раз, когда нажимается его <button> . После обновления состояние Square компонента value будет равно 'X', поэтому вы увидите «Х» на игровой доске. Нажмите на любой квадрат, и «Х» должен появиться:

adding xes to board

Каждый Квадрат имеет своё состояние: состояние value в каждом Квадрате полностью независимо от других. Когда вы вызываете функцию set в компоненте, React автоматически обновляет и дочерние компоненты внутри него.

После внесения вышеуказанных изменений ваш код будет выглядеть следующим образом:

import { useState } from 'react';

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    setValue('X');
  }

  return (
    <button
      className="square"
      onClick={handleClick}
    >
      {value}
    </button>
  );
}

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

Инструменты разработчика React

Инструменты разработчика React позволяют проверить свойства и состояние ваших компонентов React. Вы можете найти вкладку Инструменты разработчика React в нижней части раздела браузер в CodeSandbox:

React DevTools in CodeSandbox

Чтобы просмотреть определенный компонент на экране, используйте кнопку в верхнем левом углу Инструментов разработчика React:

Selecting components on the page with React DevTools

Примечание

Для локальной разработки React DevTools доступен в качестве расширения браузера Chrome, Firefox и Edge. Установите его, и вкладка Компоненты появится в инструментах разработчика браузера для сайтов, использующих React.

Завершение игры

К этому моменту у вас есть все основные компоненты для вашей игры крестики-нолики. Теперь вам нужно по очереди размещать «Х» и «О» на доске, а также нужен способ определить победителя.

Поднятие состояния

В настоящее время каждый компонент Square поддерживает часть состояния игры. Чтобы проверить победителя в игре крестики-нолики, компоненту Board нужно как-то узнать состояние каждого из 9 компонентов Square.

Как вы бы это сделали? Сначала вы можете предположить, что компоненту Board нужно «спросить» у каждого компонента Square состояние этого компонента Square. Хотя этот подход технически возможен в React, мы его не рекомендуем, потому что код становится трудным для понимания, подверженным ошибкам и сложным для рефакторинга. Лучший подход — хранить состояние игры в родительском компоненте Board вместо каждого компонента Square . Компонент Board может указать каждому компоненту Square что отображать, передавая свойство, как вы это делали, когда передавали число каждому компоненту Квадрат.

Чтобы собирать данные от нескольких дочерних компонентов или чтобы два дочерних компонента могли взаимодействовать друг с другом, объявляйте общее состояние в их родительском компоненте. Родительский компонент может передать это состояние обратно дочерним компонентам через свойства. Это сохраняет синхронизацию дочерних компонентов друг с другом и с их родительским компонентом.

Поднятие состояния в родительский компонент — распространённая операция при рефакторинге компонентов React.

Давайте воспользуемся этим случаем и попробуем это. Измените компонент Board таким образом, чтобы он объявлял переменную состояния с именем squares, которая по умолчанию представляет собой массив из 9 null, соответствующих 9 квадратам:

// ...
export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    // ...
  );
}

Array(9).fill(null) создает массив с девятью элементами и устанавливает каждое из них в значение null . Вызов useState() вокруг него объявляет переменную состояния squares, которая изначально устанавливается в этот массив. Каждый элемент массива соответствует значению квадрата. Когда вы заполните доску в дальнейшем, массив squares будет выглядеть так:

['O', null, 'X', 'X', 'X', 'O', 'O', null, null]

Теперь ваш компонент Board должен передать свойство value каждому компоненту Square, который он отображает:

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} />
        <Square value={squares[1]} />
        <Square value={squares[2]} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} />
        <Square value={squares[4]} />
        <Square value={squares[5]} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} />
        <Square value={squares[7]} />
        <Square value={squares[8]} />
      </div>
    </>
  );
}

Далее вы измените компонент Square таким образом, чтобы он получал свойство value от компонента Доска. Это потребует удаления собственного отслеживания состоянием компонента Квадрат value и свойства кнопки onClick:

function Square({value}) {
  return <button className="square">{value}</button>;
}

На этом этапе вы должны увидеть пустую доску для игры крестики-нолики:

empty board

И ваш код должен выглядеть так:

import { useState } from 'react';

function Square({ value }) {
  return <button className="square">{value}</button>;
}

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} />
        <Square value={squares[1]} />
        <Square value={squares[2]} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} />
        <Square value={squares[4]} />
        <Square value={squares[5]} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} />
        <Square value={squares[7]} />
        <Square value={squares[8]} />
      </div>
    </>
  );
}

Теперь каждый Квадрат получит свойство value , которое будет либо 'X', либо 'O', либо null для пустых квадратов.

Далее, вам нужно изменить то, что происходит при нажатии на Square . Компонент Board теперь отслеживает, какие квадраты заполнены. Вам понадобится способ, которым компонент Square сможет обновить состояние компонента Board. Поскольку состояние является внутренним для компонента, который его определяет, вы не можете напрямую обновить состояние компонента Board из компонента Square.

Вместо этого вы передадите функцию из компонента Board в компонент Square, и компонент Square будет вызывать эту функцию при нажатии на квадрат. Вы начнете с функции, которую вызовет компонент Square при нажатии. Вы назовёте эту функцию onSquareClick:

function Square({ value }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

Далее, вы добавите функцию onSquareClick в свойства компонента Square:

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

Теперь вы подключите свойство onSquareClick к функции в компоненте Board , которую вы назовёте handleClick . Чтобы подключить onSquareClick к handleClick, вы передадите функцию в свойство onSquareClick первого компонента Square:

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={handleClick} />
        //...
  );
}

Наконец, вы определите функцию handleClick внутри компонента Доска, чтобы обновить массив squares , содержащий состояние вашей доски:

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick() {
    const nextSquares = squares.slice();
    nextSquares[0] = "X";
    setSquares(nextSquares);
  }

  return (
    // ...
  )
}

Функция handleClick создаёт копию массива squares (nextSquares) с помощью метода JavaScript slice() массива. Затем handleClick обновляет массив nextSquares , добавляя X в первый ([0] индекс) квадрат.

Вызов функции setSquares сообщает React, что состояние компонента изменилось. Это вызовет перерисовку компонентов, использующих состояние squares (Board) , а также его дочерних компонентов (компонентов Square, составляющих доску).

Примечание

JavaScript поддерживает замыкания, что означает, что внутренняя функция (например, handleClick) имеет доступ к переменным и функциям, определённым во внешней функции (например, Board). Функция handleClick может читать состояние squares и вызывать метод setSquares, потому что они оба определены внутри функции Board.

Теперь вы можете добавлять «Х» на доску… но только в верхний левый квадрат. Ваша функция handleClick жёстко закодирована для обновления индекса верхнего левого квадрата (0). Давайте обновим handleClick , чтобы она могла обновлять любой квадрат. Добавьте аргумент i в функцию handleClick , который принимает индекс квадрата для обновления:

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    nextSquares[i] = "X";
    setSquares(nextSquares);
  }

  return (
    // ...
  )
}

Далее, вам нужно будет передать этот i в handleClick. Вы можете попробовать установить свойство onSquareClick квадрата в значение handleClick(0) непосредственно в JSX, но это не сработает:

<Square value={squares[0]} onSquareClick={handleClick(0)} />

Вот почему это не работает. Вызов handleClick(0) будет частью процесса отрисовки компонента доски. Поскольку handleClick(0) изменяет состояние компонента доски, вызывая setSquares, весь компонент доски будет перерисован заново. Но это снова запускает handleClick(0), что приводит к бесконечному циклу:

Консоль
Слишком много перерисовок. React ограничивает количество перерисовок, чтобы предотвратить бесконечный цикл.

Почему эта проблема не возникла ранее?

Когда вы передавали onSquareClick={handleClick}, вы передавали функцию handleClick в качестве свойства. Вы её не вызывали! Но сейчас вы вызываете эту функцию сразу — обратите внимание на скобки в handleClick(0) — и поэтому она выполняется слишком рано. Вам не нужно вызывать handleClick до тех пор, пока пользователь не нажмёт!

Вы могли бы исправить это, создав функцию, такую как handleFirstSquareClick, которая вызывает handleClick(0), функцию, такую как handleSecondSquareClick, которая вызывает handleClick(1), и так далее. Вы бы передавали (а не вызывали) эти функции в качестве свойств, как в onSquareClick={handleFirstSquareClick}. Это решило бы проблему бесконечного цикла.

Однако определение девяти различных функций и присвоение им каждого имени слишком громоздко. Вместо этого давайте сделаем так:

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        // ...
  );
}

Обратите внимание на новый () => синтаксис. Здесь () => handleClick(0) — это стрелочная функция, которая является более кратким способом определения функций. При нажатии на квадрат код после => «стрелки» будет выполнен, вызвав handleClick(0).

Теперь вам нужно обновить остальные восемь квадратов, чтобы они вызывали handleClick из стрелочных функций, которые вы передаёте. Убедитесь, что аргумент для каждого вызова handleClick соответствует индексу правильного квадрата:

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
};

Теперь вы снова можете добавлять X на любую клетку на доске, нажав на неё:

filling the board with X

Но на этот раз все управление состоянием выполняется компонентом Board!

Вот как должен выглядеть ваш код:

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    nextSquares[i] = 'X';
    setSquares(nextSquares);
  }

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

Теперь, когда обработка состояния находится в компоненте Board, родительский компонент Board передаёт свойства дочерним компонентам Square, чтобы они могли отображаться правильно. При нажатии на Square, дочерний компонент Square теперь просит родительский компонент Board обновить состояние доски. Когда состояние Board изменяется, компонент Board и все дочерние компоненты Square автоматически перерисовываются. Сохранение состояния всех квадратов в компоненте Board позволит ему определить победителя в будущем.

Давайте подведём итог тому, что происходит, когда пользователь нажимает на верхний левый квадрат на доске, чтобы добавить X на него:

  1. Нажатие на верхний левый квадрат запускает функцию, полученную компонентом button в качестве свойства onClick от компонента Square. Компонент Square получил эту функцию в качестве свойства onSquareClick от компонента Board. Компонент Board определил эту функцию непосредственно в JSX. Он вызывает handleClick с аргументом 0.
  2. handleClick использует аргумент (0) для обновления первого элемента массива squares со значения null на X.
  3. Состояние squares компонента Board было обновлено, поэтому компонент Board и все его дочерние элементы перерисовываются. Это приводит к изменению свойства value компонента Square с индексом 0 со значения null на X.

В итоге пользователь видит, что верхний левый квадрат изменился с пустого на содержащий X после нажатия на него.

Примечание

Элемент DOM <button> имеет специальное значение для React, потому что это встроенный компонент. Для пользовательских компонентов, таких как Квадрат, наименование зависит от вас. Вы можете присвоить любое имя свойству Square или функции Board , и код будет работать так же. В React принято использовать имена onSomething для свойств, которые представляют события, и handleSomething для определений функций, обрабатывающих эти события.

Почему неизменяемость важна

Обратите внимание, как в handleClick, вы вызываете .slice() для создания копии массива squares вместо изменения существующего массива. Для объяснения этого нам нужно обсудить неизменяемость и почему важно её изучать.

Существует в целом два подхода к изменению данных. Первый подход — это изменение данных путём непосредственного изменения значений данных. Второй подход — это замена данных новой копией, содержащей желаемые изменения. Вот как бы выглядело изменение массива squares:

const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Now `squares` is ["X", null, null, null, null, null, null, null, null];

А вот как бы выглядело изменение данных без непосредственного изменения массива squares:

const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null`

Результат одинаков, но, не изменяя (не изменяя сами данные), вы получаете несколько преимуществ.

Неизменяемость упрощает реализацию сложных функций. Позже в этом руководстве вы реализуете функцию «путешествие во времени», которая позволит вам просмотреть историю игры и «вернуться» к предыдущим ходам. Эта функциональность не специфична для игр — возможность отмены и повтора определённых действий — это обычное требование для приложений. Избегание непосредственного изменения данных позволяет сохранить предыдущие версии данных и использовать их позже.

Ещё одно преимущество неизменяемости заключается в том, что все дочерние компоненты автоматически перерисовываются при изменении состояния родительского компонента. Это включает в себя даже те дочерние компоненты, которые не были затронуты этим изменением. Хотя перерисовка сама по себе не заметна пользователю (не следует активно пытаться этого избегать!), вы можете захотеть пропустить перерисовку части дерева, которая явно не затронута ею по соображениям производительности. Неизменяемость позволяет компонентам очень быстро сравнивать, изменились ли их данные или нет. Вы можете узнать больше о том, как React выбирает, когда перерисовать компонент, в справочнике по API memo.

Ход за ходом

Сейчас самое время исправить серьёзный недостаток в этой игре крестики-нолики: «О» не могут быть отмечены на доске.

Вы установите первый ход в «X» по умолчанию. Давайте отслеживаем это, добавив ещё одно состояние в компонент Доски:

function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  // ...
}

Каждый раз, когда игрок делает ход, xIsNext (булево значение) будет переключаться, чтобы определить, чей ход следующий, и состояние игры будет сохранено. Вы обновите функцию Board компонента handleClick для переключения значения xIsNext:

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  return (
    //...
  );
}

Теперь, когда вы нажимаете на различные квадраты, они будут чередоваться между X и O, как и должно быть!

Но подождите, есть проблема. Попробуйте нажать на один и тот же квадрат несколько раз:

O overwriting an X

X перезаписывается O! Хотя это добавило бы очень интересный поворот в игру, мы пока будем придерживаться первоначальных правил.

При пометке квадрата X или O вы не проверяете сначала, не содержит ли квадрат уже значение X или O. Вы можете исправить это, возвращаясь рано. Вы будете проверять, не содержит ли квадрат уже X или O. Если квадрат уже заполнен, вы вернётесь return в функции handleClick раньше — до того, как она попытается обновить состояние доски.

function handleClick(i) {
  if (squares[i]) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

Теперь вы можете добавлять только X или O в пустые квадраты! Вот как должен выглядеть ваш код на данный момент:

import { useState } from 'react';

function Square({value, onSquareClick}) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    if (squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

Объявление победителя

Теперь, когда игроки могут делать ходы по очереди, вы захотите показать, когда игра выиграна и больше нет возможности сделать ход. Для этого вы добавите вспомогательную функцию calculateWinner, которая принимает массив из 9 квадратов, проверяет победителя и возвращает 'X', 'O' или null соответственно. Не беспокойтесь слишком сильно о функции calculateWinner; она не специфична для React:

export default function Board() {
  //...
}

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;
}

Примечание

Не имеет значения, определяете ли вы calculateWinner до или после Board. Давайте поместим её в конец, чтобы вам не приходилось прокручивать её каждый раз при редактировании компонентов.

Вы вызовете calculateWinner(squares) в функции Board компонента handleClick для проверки, выиграл ли игрок. Вы можете выполнить эту проверку одновременно с проверкой, нажал ли пользователь на квадрат, который уже содержит X или O . В обоих случаях хотелось бы вернуться пораньше:

function handleClick(i) {
  if (squares[i] || calculateWinner(squares)) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

Чтобы сообщить игрокам, когда игра окончена, можно отобразить текст, такой как «Победитель: X» или «Победитель: О». Для этого вы добавите раздел status в компонент Board . Статус будет отображать победителя, если игра окончена, а если игра продолжается, вы отобразите чей ход следующий:

export default function Board() {
  // ...
  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        // ...
  )
}

Поздравляем! У вас теперь есть работающая игра крестики-нолики. И вы только что изучили основы React. Так что вы настоящий победитель. Вот как должен выглядеть код:

import { useState } from 'react';

function Square({value, onSquareClick}) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </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;
}

Добавление путешествия во времени

В качестве заключительного упражнения давайте сделаем возможность «вернуться назад во времени» к предыдущим ходам в игре.

Сохранение истории ходов

Если бы вы изменяли массив squares, реализация путешествия во времени была бы очень сложной.

Однако вы использовали slice() для создания новой копии массива squares после каждого хода и рассматривали её как неизменяемую. Это позволит вам хранить каждую прошлую версию массива squares, и переходить между уже произошедшими ходами.

Вы будете хранить прошлые массивы squares в другом массиве, названном history, который вы будете хранить в качестве новой переменной состояния. Массив history представляет все состояния доски, от первого до последнего хода, и имеет форму:

[
  // Before first move
  [null, null, null, null, null, null, null, null, null],
  // After first move
  [null, null, null, null, 'X', null, null, null, null],
  // After second move
  [null, null, null, null, 'X', null, null, null, 'O'],
  // ...
]

Поднятие состояния вверх ещё раз

Теперь вы напишите новый компонент верхнего уровня, называемый Game, для отображения списка прошлых ходов. Именно туда вы поместите состояние history, содержащее всю историю игры.

Размещение состояния history в компоненте Game позволит вам удалить состояние squares из его дочернего компонента Board. Как вы «поднимали состояние вверх» из компонента Square в компонент Board, так вы теперь поднимите его из компонента Board в компонент верхнего уровня Game. Это даст компоненту Game полный контроль над данными компонента Board и позволит ему инструктировать компонент Board отображать предыдущие ходы из history.

Сначала добавьте компонент Game с export default. Пусть он отображает компонент Board и некоторые разметки:

function Board() {
  // ...
}

export default function Game() {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}

Обратите внимание, что вы удаляете ключевые слова export default перед объявлением function Board() { и добавляете их перед объявлением function Game() {. Это указывает вашему файлу index.js использовать компонент Game в качестве компонента верхнего уровня вместо компонента Board. Дополнительные div, возвращаемые компонентом Game, создают место для информации об игре, которую вы добавите на доску позже.

Добавьте некоторое состояние в компонент Game для отслеживания следующего игрока и истории ходов:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  // ...

Заметьте, как [Array(9).fill(null)] — это массив с одним элементом, который сам по себе является массивом из 9 null.

Чтобы отобразить квадраты для текущего хода, вам нужно будет прочитать последний массив квадратов из history. Вам не понадобится useState для этого — у вас уже достаточно информации, чтобы вычислить это во время рендеринга:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];
  // ...

Далее, создайте функцию handlePlay внутри компонента Game, которая будет вызываться компонентом Board для обновления игры. Передайте xIsNext, currentSquares и handlePlay в качестве свойств компоненту Board:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    // TODO
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
        //...
  )
}

Давайте сделаем компонент Board полностью управляемым свойствами, которые он получает. Измените компонент Board таким образом, чтобы он принимал три свойства: xIsNext, squares, и новую функцию onPlay, которую Board может вызвать с обновлённым массивом квадратов, когда игрок делает ход. Затем удалите две первые строки функции Board, которые вызывают useState:

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    //...
  }
  // ...
}

Теперь замените вызовы setSquares и setXIsNext в handleClick в компоненте Board единственным вызовом новой функции onPlay, чтобы компонент Game мог обновить Board при нажатии пользователем на квадрат:

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }
  //...
}

Компонент Board полностью контролируется свойствами, переданными ему компонентом Game. Вам нужно реализовать функцию handlePlay в компоненте Game для того, чтобы игра заработала снова.

Что должен делать handlePlay при вызове? Помните, что Board раньше вызывал setSquares с обновлённым массивом; теперь он передаёт обновлённый массив squares в onPlay.

Функция handlePlay должна обновить состояние Game для запуска повторного рендеринга, но у вас больше нет функции setSquares, которую можно вызвать — теперь вы используете переменную состояния history для хранения этой информации. Вы хотите обновить history, добавив обновлённый массив squares в качестве новой записи истории. Вы также хотите переключить xIsNext, как это делал ранее Board:

export default function Game() {
  //...
  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }
  //...
}

Здесь [...history, nextSquares] создаёт новый массив, который содержит все элементы из history, а затем nextSquares. (Вы можете прочитать ...history оператор spread как «перечислить все элементы в history».)

Например, если history равно [[null,null,null], ["X",null,null]], а nextSquares равно ["X",null,"O"], то новый массив [...history, nextSquares] будет [[null,null,null], ["X",null,null], ["X",null,"O"]].

На этом этапе вы перенесли состояние в компонент Game, и пользовательский интерфейс должен работать в полном объёме, как и раньше.

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

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

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <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;
}

Отображение прошлых ходов

Поскольку вы записываете историю игры в крестики-нолики, теперь вы можете отобразить список прошлых ходов игроку.

Элементы React, такие как <button>, — это обычные JavaScript-объекты; вы можете передавать их по вашему приложению. Чтобы отобразить несколько элементов в React, вы можете использовать массив элементов React.

У вас уже есть массив history ходов в состоянии, поэтому теперь вам нужно преобразовать его в массив элементов React. В JavaScript для преобразования одного массива в другой можно использовать метод массива map:

[1, 2, 3].map((x) => x * 2) // [2, 4, 6]

Вы будете использовать map для преобразования вашего массива history ходов в элементы React, представляющие кнопки на экране, и отображать список кнопок для перехода к прошлым ходам. Давайте пройдёмся по history в компоненте Game:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

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

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

Вы можете увидеть, как должен выглядеть ваш код ниже. Обратите внимание, что в консоли разработчика будет отображаться ошибка:

Консоль
Предупреждение: Каждый дочерний элемент в массиве или итераторе должен иметь уникальное свойство «ключ». Проверьте метод рендеринга `Game`.

Вы исправите эту ошибку в следующем разделе.

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

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

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</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;
}

Когда вы итерируетесь по массиву history внутри функции, которую вы передали map, аргумент squares проходит по каждому элементу массива history, а аргумент move проходит по каждому индексу массива: 0, 1, 2, …. (В большинстве случаев вам потребуются сами элементы массива, но для отображения списка ходов вам понадобятся только индексы.)

Для каждого хода в истории игры в крестики-нолики вы создаёте элемент списка <li>, который содержит кнопку <button>. Кнопка имеет обработчик onClick, который вызывает функцию под названием jumpTo (которую вы ещё не реализовали).

Пока вы должны увидеть список совершенных ходов в игре и ошибку в консоли разработчика. Давайте обсудим, что значит ошибка «ключ».

Выбор ключа

Когда вы отображаете список, React сохраняет некоторую информацию о каждом элементе списка. Когда вы обновляете список, React нужно определить, что изменилось. Вы могли добавить, удалить, переместить или обновить элементы списка.

Представьте переход от

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

к

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

Помимо обновлённых подсчётов, человек, читающий это, вероятно, сказал бы, что вы поменяли порядок Alexa и Ben, и вставили Claudia между Alexa и Ben. Однако React — это компьютерная программа, и он не знает, что вы имели в виду, поэтому вам нужно указать свойство key для каждого элемента списка, чтобы различать каждый элемент списка от его братьев и сестёр. Если ваши данные были из базы данных, идентификаторы Alexa, Ben и Claudia из базы данных могут быть использованы в качестве ключей.

<li key={user.id}>
  {user.name}: {user.taskCount} tasks left
</li>

Когда список перерисовывается, React берёт ключ каждого элемента списка и ищет в предыдущем списке элемент с совпадающим ключом. Если в текущем списке есть ключ, которого не было раньше, React создаёт компонент. Если в текущем списке отсутствует ключ, который существовал в предыдущем списке, React уничтожает предыдущий компонент. Если два ключа совпадают, соответствующий компонент перемещается.

Ключи сообщают React об идентичности каждого компонента, что позволяет React поддерживать состояние между перерисовками. Если ключ компонента меняется, компонент будет уничтожен и пересоздан с новым состоянием.

key — это специальное и зарезервированное свойство в React. При создании элемента React извлекает свойство key и хранит ключ непосредственно в возвращаемом элементе. Несмотря на то, что key может выглядеть как передаваемое свойство, React автоматически использует key для определения, какие компоненты нужно обновить. Компонент не может спросить, какой ключ key указал его родитель.

Настоятельно рекомендуется при создании динамических списков назначать соответствующие ключи. Если у вас нет подходящего ключа, возможно, стоит перестроить ваши данные.

Если ключ не указан, React сообщит об ошибке и по умолчанию будет использовать индекс массива в качестве ключа. Использование индекса массива в качестве ключа проблематично при попытке переупорядочить элементы списка или вставить/удалить элементы из списка. Явное указание key={i} подавляет ошибку, но имеет те же проблемы, что и индексы массивов, и не рекомендуется в большинстве случаев.

Ключи не должны быть уникальными во всём глобальном масштабе; они должны быть уникальными только среди компонентов и их братьев и сестёр.

Реализация путешествия во времени

В истории игры в крестики-нолики у каждого прошлого хода есть уникальный идентификатор: это порядковый номер хода. Ходы никогда не будут переупорядочены, удалены или вставлены посередине, поэтому безопасно использовать индекс хода в качестве ключа.

В функции Game вы можете добавить ключ в качестве <li key={move}>, и если вы перезагрузите рендеринг игры, ошибка React «ключ» должна исчезнуть:

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});
import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

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

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</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;
}

Прежде чем вы сможете реализовать jumpTo, компоненту Game необходимо отслеживать, какой шаг пользователь в данный момент просматривает. Для этого определите новую переменную состояния под названием currentMove, по умолчанию равную 0:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
  //...
}

Далее обновите функцию jumpTo внутри Game для обновления этого currentMove. Вы также установите xIsNext в true в том случае, если номер, в который вы меняете currentMove , является чётным.

export default function Game() {
  // ...
  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }
  //...
}

Теперь вы внесёте два изменения в функцию Game компонента handlePlay, которая вызывается при нажатии на квадрат.

  • Если вы «вернетесь назад во времени» и затем сделаете новый ход из этой точки, вам нужно сохранить историю только до этой точки. Вместо добавления nextSquares после всех элементов (... синтаксис расширения) в history, вы добавите его после всех элементов в history.slice(0, currentMove + 1), чтобы сохранить только эту часть старой истории.
  • Каждый раз, когда делается ход, необходимо обновить currentMove для указания на последнюю запись истории.
function handlePlay(nextSquares) {
  const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
  setHistory(nextHistory);
  setCurrentMove(nextHistory.length - 1);
  setXIsNext(!xIsNext);
}

Наконец, вы измените компонент Game, чтобы отображать текущий выбранный ход, а не всегда отображать последний ход:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  // ...
}

Если вы нажмете на любой шаг в истории игры, игровое поле крестики-нолики должно немедленно обновиться, отображая вид поля после этого шага.

import { useState } from 'react';

function Square({value, onSquareClick}) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
    setXIsNext(!xIsNext);
  }

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

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</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;
}

Окончательная очистка

Если вы внимательно посмотрите на код, вы можете заметить, что xIsNext === true когда currentMove чётное, и xIsNext === false когда currentMove нечётное. Другими словами, если вам известна величина currentMove, вы всегда можете определить, чему должно быть равно xIsNext.

Нет причины хранить оба этих значения в состоянии. На самом деле, всегда старайтесь избегать избыточного состояния. Упрощение хранимых в состоянии данных снижает количество ошибок и делает ваш код более понятным. Измените Game так, чтобы оно не хранило xIsNext как отдельную переменную состояния, а вместо этого определяло её на основе currentMove:

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  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);
  }
  // ...
}

Вам больше не нужен объявление состояния xIsNext или вызовы setXIsNext. Теперь нет возможности для xIsNext выйти из синхронизации с currentMove, даже если вы сделаете ошибку при кодировании компонентов.

Заключение

Поздравляем! Вы создали игру «крестики-нолики», которая:

  • Позволяет играть в крестики-нолики,
  • Указывает, когда игрок выиграл игру,
  • Хранит историю игры по мере её развития,
  • Позволяет игрокам просмотреть историю игры и увидеть предыдущие версии игрового поля.

Отличная работа! Мы надеемся, что теперь вы хорошо понимаете, как работает React.

Посмотрите окончательный результат здесь:

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  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);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</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;
}

Если у вас есть свободное время или вы хотите практиковать свои новые навыки React, вот несколько идей для улучшения игры «крестики-нолики», упорядоченных по возрастанию сложности:

  1. Для текущего хода покажите «Вы находитесь на ходе #…», а не кнопку.
  2. Перепишите Board для использования двух циклов для создания квадратов вместо их жёсткого кодирования.
  3. Добавьте переключатель, позволяющий сортировать ходы по возрастанию или убыванию.
  4. Когда кто-то выигрывает, выделите три квадрата, которые привели к победе (и когда никто не выигрывает, отобразите сообщение об ничьей).
  5. Отобразите координаты каждого хода в формате (строка, столбец) в списке истории ходов.

В этом руководстве вы познакомились с концепциями React, включая элементы, компоненты, пропсы и состояние. Теперь, когда вы увидели, как эти концепции работают при создании игры, ознакомьтесь с Размышления о React, чтобы узнать, как те же концепции React работают при построении пользовательского интерфейса приложения.

© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://18.react.dev/learn/tutorial-tic-tac-toe

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API