v19.2Latest

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

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

Примечание

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

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

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

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

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

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

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

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

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

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

Примечание

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

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

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

Обзор

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

Изучение стартового кода

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

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

ФайлApp.jsдолжен быть выбран в разделеFiles. Содержимое этого файла вcode editorдолжно быть следующим:

В разделеbrowserдолжен отображаться квадрат с буквой X внутри, вот так:

квадрат с буквой X

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

App.js

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

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

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

styles.css

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

index.js

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

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

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

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

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

Вернёмся к файлуApp.js. Здесь вы проведёте остаток руководства.

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

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

Консоль

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

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

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

две клетки, заполненные крестиками

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

девять клеток, заполненных крестиками, в одну линию

О нет! Клетки расположены в одну линию, а не в сетку, как требуется для игрового поля. Чтобы исправить это, вам нужно сгруппировать клетки в строки с помощьюdivи добавить несколько CSS-классов. Заодно вы присвоите каждой клетке номер, чтобы точно знать, где какая клетка отображается.

В файлеApp.jsобновите компонентSquare, чтобы он выглядел так:

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

поле для игры в крестики-нолики, заполненное числами от 1 до 9

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

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

Примечание

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

Передача данных через пропсы

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

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

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

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

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

доска, заполненная единицами

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

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

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

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

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

доска, заполненная словом value

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

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

пустая доска

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

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

доска для игры в крестики-нолики, заполненная числами от 1 до 9

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

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

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

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

Примечание

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

Следующим шагом вы хотите, чтобы компонент Square «запоминал», что по нему кликнули, и заполнялся меткой «X». Чтобы «запоминать» вещи, компоненты используютсостояние.

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

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

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

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

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

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

добавление крестиков на доску

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

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

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

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

React DevTools в CodeSandbox

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

Выбор компонентов на странице с помощью React DevTools
Примечание

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

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

На данный момент у вас есть все основные строительные блоки для игры в крестики-нолики. Чтобы завершить игру, теперь нужно чередовать размещение «X» и «O» на доске, а также нужен способ определения победителя.

Поднятие состояния вверх

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

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

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

Поднятие состояния в родительский компонент — обычная практика при рефакторинге React-компонентов.

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

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

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

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

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

пустое поле

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

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

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

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

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

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

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

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

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

Примечание

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

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

Далее вам нужно передать этотi в handleClick. Можно попробовать установить пропсonSquareClickдля квадрата напрямую в JSX какhandleClick(0), но это не сработает:

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

Консоль

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

Почему этой проблемы не было раньше?

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

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

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

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

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

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

заполнение доски крестиками

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

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

Теперь, когда управление состоянием находится в компоненте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после нажатия на него.

Примечание

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

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

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

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

А вот как это выглядело бы, если бы вы изменили данные без мутации массиваsquares:

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

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

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

Очерёдность ходов

Теперь пора исправить серьёзный недостаток в этой игре «крестики-нолики»: «O» нельзя поставить на доске.

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

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

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

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

O перезаписывает X

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

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

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

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

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

Примечание

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

На этом этапе вы переместили состояние в компонентGame, и пользовательский интерфейс должен полностью работать, как и до рефакторинга. Вот как должен выглядеть код на данном этапе:

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

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

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

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

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

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

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

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

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

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

Выбор ключа

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

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

к

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

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

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

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

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

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

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

Реализация перемещения во времени

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

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

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

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

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

  • Если вы «вернётесь назад во времени» и затем сделаете новый ход с этой точки, вы захотите сохранить историю только до этого момента. Вместо добавленияnextSquaresпосле всех элементов (синтаксис...spread) вhistory, вы добавите его после всех элементов вhistory.slice(0, currentMove + 1), чтобы сохранить только эту часть старой истории.
  • Каждый раз при совершении хода вам нужно обновлятьcurrentMove, чтобы он указывал на последнюю запись в истории.

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

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

Финальная чистка

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

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

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

Подведение итогов

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

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

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

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

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

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

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