Введение: знакомство с React
These docs are old and won’t be updated. Go to react.dev for the new React docs.
The updated Tutorial teaches modern React and includes live examples.
Вам не обязательно знать React, чтобы проходить это введение.
Прежде чем начать
В этом введении мы будем постепенно создавать небольшую игру. Возможно, вы захотите пропустить его, потому что не разрабатываете игры, но вам стоит попробовать. Подходы, которые вы изучите в этом введении, являются основополагающими для создания любого React-приложения. Их освоение даст вам глубокое понимание React.
Совет
Это введение рассчитано на людей, которые предпочитают учиться на практике. Если вам больше нравится изучать предмет от начала до конца, то начните с пошагового руководства. Введение и руководство в чём-то дополняют друг друга.
Введение состоит из нескольких разделов:
- Настройка окружения поможет подготовиться к практической части.
- Обзор научит вас основам React: компонентам, пропсам и состоянию.
- Создание игры научит вас наиболее распространённым подходам в React-разработке.
- Добавление путешествия во времени даст вам более глубокое понимание особенностей и сильных сторон React.
Вам не обязательно проходить все части сразу, чтобы получить пользу от этого введения. Изучите столько, сколько можете, даже если это будет один или два раздела.
Что мы собираемся писать?
В этом введении мы покажем как создать интерактивную игру крестики-нолики на React.
Результат вы можете посмотреть здесь — готовая игра. Если вы не очень хорошо понимаете код, или вы не знакомы с синтаксисом, не беспокойтесь. Цель этого введения — помочь разобраться с React и его синтаксисом.
Мы советуем вам поиграть в крестики-нолики, прежде чем продолжите чтение. Одна из особенностей, которую вы можете заметить — это нумерованный список справа от игрового поля. Этот список отображает историю всех игровых ходов и обновляется по мере игры.
Вы можете закрыть игру в крестики-нолики, как только познакомитесь с ней. Мы начнём с простой заготовки. Следующим шагом мы настроим окружение, чтобы вы могли начать создание игры.
Предварительные требования
Мы предполагаем, что вы немного знакомы с HTML и JavaScript. Однако, вы сможете изучать введение, даже если знакомы только с другими языками программирования. Мы также полагаем, что вы знакомы с такими понятиями программирования как функции, объекты, массивы и, в меньшей степени, классы.
Если вам нужно повторить основы JavaScript, мы рекомендуем прочитать вот этот учебник. Обратите внимание, что мы используем некоторые особенности из ES6 (последней версии JavaScript), такие как стрелочные функции, классы, операторы let
и const
. Вы можете воспользоваться Babel REPL, чтобы узнать во что компилируется ES6-код.
Настройка окружения
Есть два варианта прохождения практической части — вы можете писать код в браузере, либо настроить окружение для разработки на компьютере.
Вариант 1: Пишем код в браузере
Это самый быстрый способ для старта.
Для начала откройте эту Заготовку в новой вкладке. Вы увидите пустое поле для игры в крестики-нолики и код на React, который мы будем постепенно изменять.
Можете пропустить следующую часть и перейти к Обзору React.
Вариант 2: Локальное окружение для разработки
Это не является обязательным и не требуется этим вводным руководством!
Опционально: Инструкции для написания кода в вашем любимом редакторе
Эти настройки потребуют больше работы, но позволят продолжить разработку с использованием вашего любимого редактора. Вот что нужно сделать:
- Убедиться, что у вас установлена последняя версия Node.js.
- Выполнить инструкции по установке Create React App для создания нового проекта.
npx create-react-app my-app
- Удалить все файлы в папке
src/
нового проекта.
Примечание:
Не удаляйте саму папку
src
, только файлы внутри неё. Следующим шагом мы заменим стандартные файлы нашими примерами.
cd my-app
cd src
# Для пользователей Mac или Linux:
rm -f *
# Для пользователей Windows
del *
# Затем вернёмся в папку проекта
cd ..
- Создайте файл с именем
index.css
в папкеsrc/
и добавьте в него вот этот CSS код. - Создайте файл с именем
index.js
в папкеsrc/
и добавьте в него этот JS код. - В начало файла
index.js
в папкеsrc/
добавьте следующие три строчки:
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
Теперь, когда вы запустите npm start
в папке проекта и откроете http://localhost:3000
в браузере, вы должны увидеть пустое поле для крестиков-ноликов.
Мы рекомендуем выполнить эти инструкции для настройки подсветки синтаксиса в вашем редакторе.
Помогите, я застрял!
Если вы застряли — обратитесь к сообществу. В частности Чат Reactiflux — это прекрасное место, где вам помогут. Если вы не дождались ответа или всё ещё не решили своей проблемы, пожалуйста, задайте вопрос, и мы вам поможем.
Обзор
Теперь, когда вы готовы, перейдём к рассмотрению React!
Что такое React?
React — это декларативная, эффективная и гибкая JavaScript-библиотека для создания пользовательских интерфейсов. Она позволяет вам собирать сложный UI из маленьких изолированных кусочков кода, называемых «компонентами».
React имеет несколько разных видов компонентов, но мы начнём с подклассов React.Component
:
class ShoppingList extends React.Component {
render() {
return (
<div className="shopping-list">
<h1>Список покупок для {this.props.name}</h1>
<ul>
<li>Instagram</li>
<li>WhatsApp</li>
<li>Oculus</li>
</ul>
</div>
);
}
}
// Пример использования: <ShoppingList name="Марк" />
Скоро мы перейдём к этим забавным, похожим на XML, тегам. Мы используем компоненты, чтобы сообщить React, что мы хотим увидеть на экране. Каждый раз, когда наши данные меняются, React эффективно обновляет и повторно рендерит наши компоненты.
ShoppingList
является примером классового компонента React. Компонент принимает параметры, которые называются пропсами (props
, сокращение от properties
— свойства), и возвращает из метода render()
иерархию представлений для отображения.
Метод render
возвращает описание того, что вы хотите увидеть на экране. React берёт это описание и отображает результат. Если точнее, render
возвращает React-элемент, который является легковесным описанием того, что нужно отрендерить. Большинство React-разработчиков используют специальный синтаксис под названием «JSX» для упрощения описания структуры. Во время компиляции синтаксис <div />
преобразовывается в React.createElement('div')
. Пример выше равнозначен вот этому:
return React.createElement('div', {className: 'shopping-list'},
React.createElement('h1', /* ... h1 children ... */),
React.createElement('ul', /* ... ul children ... */)
);
Если вам интересно, то createElement()
более подробно описан в справочнике API, однако, мы не будем им пользоваться в этом введении. Вместо этого мы продолжим использовать JSX.
JSX обладает всей мощью JavaScript. В JSX вы можете использовать любые JavaScript-выражения внутри фигурных скобок. Каждый React-элемент является JavaScript-объектом, который можно сохранить в переменную или использовать внутри программы.
Приведённый выше компонент ShoppingList
рендерит только встроенные DOM-компоненты вроде <div />
или <li />
. Но вы также можете создавать и рендерить собственные компоненты. Например, теперь вы можете ссылаться на весь компонент со списком покупок, написав <ShoppingList />
. Каждый React-компонент инкапсулирован и может использоваться независимо. Это позволяет создавать сложный UI из простых компонентов.
Разберёмся со стартовым кодом
Если вы собираетесь работать над практической частью в вашем браузере, откройте этот код в новой вкладке начальный код. Если вы собираетесь работать над практикумом локально, откройте src/index.js
в папке вашего проекта (вы уже использовали этот файл в разделе настройки).
Этот стартовый код — база, с которой мы начнём разработку. Мы будем использовать готовые CSS-стили, чтобы сосредоточиться на изучении React и написании игры крестики-нолики.
Изучив код, вы обнаружите три React-компонента:
- Square (Клетка)
- Board (Поле)
- Game (Игра)
Компонент Board
рендерит 9 компонентов Square
, каждый из которых рендерит по одному элементу <button>
. Компонент Game
рендерит поле c заглушками, которые мы изменим позже. Пока у нас нет ни одного интерактивного компонента.
Передача данных через пропсы
Для начала давайте попробуем передать некоторые данные из нашего компонента Board
в компонент Square
.
Настоятельно рекомендуем набирать код самостоятельно, а не копировать его и вставлять. Это упрощает понимание и развивает мышечную память.
class Board extends React.Component {
renderSquare(i) {
return <Square value={i} />; }
}
Изменим метод render
внутри Square, заменив {/* TODO */}
на {this.props.value}
:
class Square extends React.Component {
render() {
return (
<button className="square">
{this.props.value} </button>
);
}
}
До:
После: Вы должны увидеть число внутри каждого отрендеренного квадрата.
Посмотреть полный код этого шага
Поздравляем! Вы только что «передали проп» из родительского компонента Board в дочерний компонент Square. Передача пропсов это то, как данные в React-приложениях «перетекают» от родительских компонентов к дочерним.
Добавим взаимодействие с компонентом
Попробуем при клике на компонент Square ставить «X».
Вначале изменим тег кнопки, который возвращается из метода render()
компонента Square:
class Square extends React.Component {
render() {
return (
<button className="square" onClick={function() { console.log('клик'); }}> {this.props.value}
</button>
);
}
}
Теперь, если вы кликнете по Square
, то увидите «клик» в консоли инструментах разработки браузера.
Примечание
Чтобы избежать странного поведения
this
, мы будем использовать синтаксис стрелочных функций для обработчиков событий (здесь и ниже):class Square extends React.Component { render() { return ( <button className="square" onClick={() => console.log('клик')}> {this.props.value} </button> ); } }
Обратите внимание что в
onClick={() => console.log('клик')}
, мы передаём функцию в качестве значения пропаonClick
. React вызовет эту функцию при клике. Пропуск() =>
и записьonClick={console.log('клик')}
довольно распространённая ошибка. Такой код будет выдавать сообщение при каждом перерендере.
Следующим шагом мы хотим, чтобы компонент Square «запоминал», что по нему кликнули и поставили «X». Для «запоминания» компоненты используют состояние.
Компоненты React могут получить состояние, устанавливая this.state
в конструкторе. this.state
должно рассматриваться как приватное свойство React-компонента, определяемое им самим. Давайте сохраним текущее значение Square в this.state
и изменим его при клике.
Сперва добавим конструктор к классу, чтобы инициализировать состояние:
class Square extends React.Component {
constructor(props) { super(props); this.state = { value: null, }; }
render() {
return (
<button className="square" onClick={() => console.log('клик')}>
{this.props.value}
</button>
);
}
}
Примечание
В JavaScript-классах вам всегда нужно вызывать
super
при объявлении конструктора подкласса. Все классовые React-компоненты, у которых естьconstructor
, должны начинаться с вызоваsuper(props)
.
Теперь изменим метод render
компонента Square для отображения текущего значения из состояния при клике:
- Заменим
this.props.value
наthis.state.value
внутри тега<button>
. - Заменим обработчик
onClick={...}
наonClick={() => this.setState({value: 'X'})}
. - Поместим свойства
className
иonClick
на разных строчках для лучшей читабельности.
После этих изменений тег <button>
, возвращаемый из метода render
компонента Square, будет выглядеть так:
class Square extends React.Component {
constructor(props) {
super(props);
this.state = {
value: null,
};
}
render() {
return (
<button
className="square" onClick={() => this.setState({value: 'X'})} >
{this.state.value} </button>
);
}
}
Вызывая this.setState
из обработчика onClick
(как это описано в методе render
), мы говорим React, что нужно перерендерить Square при каждом клике по <button>
. После обновления, this.state.value
внутри Square станет X
, поэтому на игровом поле мы увидим X
. Если вы кликнете по любому Square, должен появиться X
.
Когда вы вызываете setState
внутри компонента, React так же автоматически обновляет дочерние компоненты.
Посмотреть полный код этого шага
Инструменты разработки
Расширение React Devtools для Chrome и Firefox позволяет вам изучать дерево React-компонентов внутри панели инструментов разработчика вашего браузера.
Расширение React DevTools позволяет просматривать пропсы и состояние ваших React-компонентов.
После установки React DevTools, вы можете кликнуть правой кнопкой мыши на любой элемент страницы и нажать Inspect
(Просмотреть код
), чтобы открыть инструменты разработчика. Вкладки React («⚛️ Components» и «⚛️ Profiler») появятся справа. Используйте вкладку «⚛️️ Components» для просмотра дерева компонентов.
Внимание, чтобы это работало на CodePen нужно сделать ещё несколько действий:
- Войти или зарегистрироваться и подтвердить вашу электронную почту (требуется для защиты от спама).
- Нажать кнопку «Fork».
- Нажать «Change View» и выбрать «Debug mode».
- В открывшейся новой вкладке у вас должны появиться инструменты разработчика.
Создание игры
Теперь у нас есть базовые элементы для создания игры крестики-нолики. Для полноценной игры нам необходимо реализовать поочерёдное размещение «X» и «O», а также способ определения победителя.
Подъём состояния
Сейчас каждый компонент Square хранит в себе состояние игры. Для выявления победителя мы будем хранить значение всех 9 клеток в одном месте.
Возможно, вы предполагали, что Board
просто запросит у каждого Square
его состояние. Хотя такой подход в React возможен, мы его не одобряем. Из-за этого код становится трудным, провоцирует ошибки и усложняет рефакторинг. Вместо этого, лучшим решением будет хранение состояния игры в родительском компоненте Board, а не в каждом отдельном Square. Компонент Board может указывать каждому Square, что именно нужно отобразить, передавая проп. Мы так уже делали, когда передавали число в каждую клетку.
Чтобы собрать данные из нескольких дочерних элементов, или чтобы дать возможность двум компонентам общаться, вам нужно объявить общее состояние внутри родительского компонента. Родительский компонент может передать состояние обратно дочерним элементам с помощью пропсов. Это поддерживает синхронизацию дочерних компонентов друг с другом и с родительским компонентом.
Подъём состояния в родительский компонент — обычное дело при рефакторинге React-компонентов. Давайте воспользуемся случаем и попробуем это сделать.
Добавим конструктор к компоненту Board и установим начальное состояние в виде массива из 9 элементов, заполненного значениями null. Эти 9 элементов соответствуют 9 квадратам:
class Board extends React.Component {
constructor(props) { super(props); this.state = { squares: Array(9).fill(null), }; }
renderSquare(i) {
return <Square value={i} />;
}
Позже, при заполнении поля, массив this.state.squares
будет выглядеть примерно так:
[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]
Метод renderSquare
внутри Board сейчас выглядит так:
renderSquare(i) {
return <Square value={i} />;
}
Вначале мы передали из Board проп value
вниз, чтобы отобразить номера от 0 до 8 внутри каждого Square. На другом шаге мы заменили числа знаком «X», определённом в собственном состоянии Square. Именно поэтому сейчас Square игнорирует проп value
, переданный в него из Board.
Мы снова воспользуемся механизмом передачи пропсов. Изменим Board, чтобы передать каждому Square его текущее значение ('X'
, 'O'
или null
). Мы уже определили массив squares
в конструкторе Board. Давайте изменим метод renderSquare
, чтобы читать данные из этого массива:
renderSquare(i) {
return <Square value={this.state.squares[i]} />; }
Посмотреть полный код этого шага
Теперь каждый Square получает проп value
, который будет, либо 'X'
или 'O'
, либо null
для пустых клеток.
Дальше нам нужно поменять то, что происходит при клике на Square. Теперь компонент Board хранит информацию о заполненных клетках. Нам нужен способ, которым Square сможет обновлять состояние Board. Поскольку состояние является приватным для компонента, где оно определено, мы не можем обновить состояние Board напрямую из Square.
Вместо этого, давайте передадим из Board в Square функцию, и будем её вызывать из Square, когда по тому кликнули. Изменим метод renderSquare
в Board-компоненте на:
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)} />
);
}
Примечание
Мы разбили возвращаемый элемент на несколько строк для удобства чтения и добавили скобки, чтобы JavaScript не вставлял точку с запятой после
return
и не ломал наш код.
Теперь мы передаём вниз два пропса из Board в Square: value
и onClick
. Проп onClick
— это функция, которую Square вызывает при клике. Внесём следующие изменения в компонент Square:
- Заменим
this.state.value
наthis.props.value
внутри методаrender
. - Заменим
this.setState()
наthis.props.onClick()
внутри методаrender
. - Удалим
constructor
из Square, потому что компонент больше не отвечает за хранение состояния игры.
После этих изменений компонент Square выглядит так:
class Square extends React.Component { render() { return (
<button
className="square"
onClick={() => this.props.onClick()} >
{this.props.value} </button>
);
}
}
При клике на Square вызывается функция onClick
, которая была передана из Board. Вот как это происходит:
- Проп
onClick
на встроенном DOM-компоненте<button>
указывает React установить обработчик события. - При клике по кнопке, React вызовет обработчик
onClick
, который определён в методеrender()
Square. - Этот обработчик вызовет
this.props.onClick()
. ПропonClick
определён для Square внутри Board. - Т.к. Board передаёт в Square
onClick={() => this.handleClick(i)}
, Square при клике вызываетhandleClick(i)
у Board. - Мы пока не определили метод
handleClick()
, поэтому наш код сломается. Если вы сейчас кликнете на клетку, вы увидите красный экран с ошибкой вродеthis.handleClick is not a function
.
Примечание
Атрибут
onClick
DOM-элемента<button>
имеет для React особое значение, потому что это встроенный компонент. Для обычных компонентов вроде Square вы можете называть пропсы как угодно. Мы можем назвать проп SquareonClick
и метод для BoardhandleClick
любым именем, и они будут работать так же. Но в React есть соглашение об именах —on[Имя события]
для пропсов, отвечающих за события, иhandle[Имя события]
для методов обрабатывающих события.
При клике на Square мы должны получить ошибку, потому что метод handleClick
ещё не определён. Давайте добавим его в класс Board:
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
};
}
handleClick(i) { const squares = this.state.squares.slice(); squares[i] = 'X'; this.setState({squares: squares}); }
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}
render() {
const status = 'Следующий ход: X';
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
);
}
}
Посмотреть полный код этого шага
После этих изменений мы снова можем заполнять клетки по клику. Однако теперь состояние хранится внутри компонента Board, а не в разрозненных компонентах Square. При изменении состояния Board произойдёт повторный рендер компонентов Square. Хранение состояния всех клеток внутри компонента Board позволит в будущем определить победителя.
Поскольку компоненты Square больше не содержат состояния, они получают все значения из Board и уведомляют его при кликах. В терминах React компонент Square теперь является управляемым. Им полностью управляет Board.
Обратите внимание, что внутри handleClick
мы вызвали .slice()
для создания копии массива squares
вместо изменения существующего массива. В следующей части мы объясним зачем создавать копию массива squares
.
Почему иммутабельность так важна?
В последнем примере мы советовали использовать метод .slice()
для создания и последующего копирования копии массива squares
вместо изменения существующего. Теперь мы обсудим иммутабельность и почему её так важно изучить.
В целом есть два подхода к изменению данных. Первый подход — мутировать(изменять) данные, напрямую устанавливая новые значения. Второй подход — заменять данные новой копией, которая содержит изменения.
Мутирующее изменение данных
var player = {score: 1, name: 'Джефф'};
player.score = 2;
// Теперь player имеет значение {score: 2, name: 'Джефф'}
Изменение данных без мутаций
var player = {score: 1, name: 'Джефф'};
var newPlayer = Object.assign({}, player, {score: 2});
// Здесь `player` не изменился, а в `newPlayer` находится {score: 2, name: 'Джефф'}
// Или, если вы пользуетесь синтаксисом расширения объектов, вы можете написать:
// var newPlayer = {...player, score: 2};
Конечный результат будет тот же, но без мутации (т.е. изменения) исходных данных напрямую. Ниже описаны преимущества такого подхода.
Сложное становится простым
Иммутабельность делает реализацию сложной функциональности проще. Ниже мы реализуем функциональность «путешествие во времени», которая позволит хранить историю игры и «возвращаться» к прошлым ходам. Эта функциональность не характерна для игр, однако, возможность отменять и заново применять действия часто встречается в приложениях. Избежание прямой мутации данных позволяет сохранять предыдущие состояния игры без изменений и обращаться к ним позже.
Обнаружение изменений
Работая с мутируемыми объектами довольно сложно обнаружить изменения, потому что они изменяются напрямую. В таком случае нам требуется сравнивать объект как со своей последней копией, так и со всем деревом объектов.
Обнаружение изменений в иммутабельных объектах намного проще. Если неизменяемый объект, на который ссылаются, отличается от предыдущего, то объект изменился.
Как React понимает, когда нужно перерендерить
Основным преимуществом иммутабельности является то, что она помогает создавать в React чистые компоненты. Неизменяемые данные позволяют легко определить наличие изменений и момент, когда компонент нужно перерендерить.
Вы можете узнать больше о shouldComponentUpdate()
и как создавать чистые компоненты в статье про оптимизацию производительности.
Функциональные компоненты
Давайте сделаем Square функциональным компонентом.
В React функциональные компоненты являются более простым способом написания компонентов, которые содержат только метод render
и не имеют собственного состояния. Вместо определения класса, который наследуется от React.Component
, мы можем написать функцию, которая принимает на вход props
и возвращает то, что должно быть отрендерено. Функциональные компоненты проще писать, чем классы, и многие компоненты могут быть оформлены таким образом.
Заменим класс Square следующей функцией:
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
Мы заменили this.props
на props
оба раза, когда обращались к ним.
Посмотреть полный код этого шага
Примечание
Когда мы заменили Square на функциональный компонент, мы также изменили
onClick={() => this.props.onClick()}
на более короткоеonClick={props.onClick}
(обратите внимание на отсутствие скобок с обеих сторон).
Очерёдность
Нам нужно исправить одну очевидную проблему в нашей игре — на поле нельзя поставить «O».
По-умолчанию установим первый ход за «X». Мы можем сделать это, изменяя начальное состояние внутри конструктора Board:
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
xIsNext: true, };
}
Каждый раз, когда игрок делает ход, xIsNext
(булево значение) будет инвертироваться, чтобы обозначить, какой игрок ходит следующим, а состояние игры будет сохраняться. Мы обновим метод handleClick
класса Board, для инверсии значения xIsNext
:
handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = this.state.xIsNext ? 'X' : 'O'; this.setState({
squares: squares,
xIsNext: !this.state.xIsNext, });
}
После этих изменений «X» и «O» будут чередоваться. Попробуйте.
Давайте также изменим текст «status» в методе render
класса Board так, чтобы он отображал какой игрок ходит следующим:
render() {
const status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O');
return (
// остальное не изменилось
После этих изменений наш Board-компонент должен выглядеть так:
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
xIsNext: true, };
}
handleClick(i) {
const squares = this.state.squares.slice(); squares[i] = this.state.xIsNext ? 'X' : 'O'; this.setState({ squares: squares, xIsNext: !this.state.xIsNext, }); }
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}
render() {
const status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O');
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</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;
}
Функция получает массив из 9 клеток, проверяет победителя и возвращает 'X'
, 'O'
или null
.
Будем вызывать calculateWinner(squares)
внутри метода render
класса Board, чтобы проверять, выиграл ли игрок. Если у нас есть победитель, мы покажем сообщение «Выиграл X» или «Выиграл O». Заменим объявление status
в render
следующим кодом:
render() {
const winner = calculateWinner(this.state.squares); let status; if (winner) { status = 'Выиграл ' + winner; } else { status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O'); }
return (
// остальное не изменилось
Теперь мы можем изменить метод handleClick
класса Board для выхода из функции и игнорировании клика, если кто-то уже победил или если клетка уже заполнена:
handleClick(i) {
const squares = this.state.squares.slice();
if (calculateWinner(squares) || squares[i]) { return; } squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
});
}
Посмотреть полный код этого шага
Поздравляем! Теперь у вас есть работающая игра в крестики-нолики. И кроме этого вы только что изучили основы React. Похоже, настоящий победитель здесь это вы.
Добавление путешествия во времени
В качестве последнего упражнения давайте добавим возможность «вернуться в прошлое» — к прошлым ходам игры.
Сохраняем историю ходов
Если бы мы изменяли массив squares
, реализация такой задачи была бы очень трудной.
Но мы использовали slice()
для создания новой копии массива squares
после каждого хода и работали с ним, не изменяя оригинала. Это позволит нам хранить каждую версию массива squares
и перемещаться по ходам, которые уже были сделаны.
Сохраним массивы squares
в другом массиве и назовём его history
. Этот массив history
будет хранить все состояния поля. От первого до последнего хода. У него будет такая структура:
history = [
// Перед первым ходом
{
squares: [
null, null, null,
null, null, null,
null, null, null,
]
},
// После первого хода
{
squares: [
null, null, null,
null, 'X', null,
null, null, null,
]
},
// После второго хода
{
squares: [
null, null, null,
null, 'X', null,
null, null, 'O',
]
},
// ...
]
Осталось решить, какой компонент будет отвечать за состояние history
.
Подъём состояния. Снова
Мы хотим, чтобы вышележащий компонент Game отображал список последних ходов. Для этого ему понадобится доступ к history
, поэтому мы поместим history
в состояние родительского компонента Game.
Размещение history
в состоянии компонента Game позволяет нам удалить squares
из состояния его дочернего компонента Board. Так же, как мы уже «поднимали состояние» из компонента Square в Board, мы теперь поднимем его из Board в компонент-родитель Game. Это даст компоненту Game полный контроль над данными Board и позволит отдавать команду для Board на рендеринг прошлых ходов из history
:
Для начала зададим начальное состояние компонента Game внутри конструктора:
class Game extends React.Component {
constructor(props) { super(props); this.state = { history: [{ squares: Array(9).fill(null), }], xIsNext: true, }; }
render() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<div>{/* status */}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
);
}
}
После этого нужно, чтобы Board получил пропсы squares
и onClick
из компонента Game. Поскольку внутри Board у нас один обработчик кликов для всех Squares, нам достаточно передать позицию для каждого Square в обработчик onClick
, чтобы показать по какой клетке мы кликнули. Для изменения компонента Board нам нужно выполнить следующие шаги:
- Удалить
constructor
в Board. - Заменить
this.state.squares[i]
наthis.props.squares[i]
в методеrenderSquare
Board. - Заменить
this.handleClick(i)
наthis.props.onClick(i)
в методеrenderSquare
Board.
Теперь компонент Board должен выглядеть вот так:
class Board extends React.Component {
handleClick(i) {
const squares = this.state.squares.slice();
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
});
}
renderSquare(i) {
return (
<Square
value={this.props.squares[i]} onClick={() => this.props.onClick(i)} />
);
}
render() {
const winner = calculateWinner(this.state.squares);
let status;
if (winner) {
status = 'Выиграл ' + winner;
} else {
status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O');
}
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
);
}
}
Давайте обновим метод render
компонента Game, чтобы использовать последнюю запись из истории для определения и отображения статуса игры:
render() {
const history = this.state.history; const current = history[history.length - 1]; const winner = calculateWinner(current.squares); let status; if (winner) { status = 'Выиграл ' + winner; } else { status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O'); }
return (
<div className="game">
<div className="game-board">
<Board squares={current.squares} onClick={(i) => this.handleClick(i)} /> </div>
<div className="game-info">
<div>{status}</div> <ol>{/* TODO */}</ol>
</div>
</div>
);
}
Поскольку компонент Game теперь рендерит статус игры, мы можем убрать соответствующий код из метода render
внутри Board. После изменений метод render
компонента Board выглядит так:
render() { return ( <div> <div className="board-row"> {this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
);
}
Наконец, нужно перенести метод handleClick
из компонента Board в компонент Game. Мы также должны изменить handleClick
, потому что состояние компонента Game имеет другую структуру. В методе handleClick
компонента Game мы добавим новые записи истории в history
.
handleClick(i) {
const history = this.state.history; const current = history[history.length - 1]; const squares = current.squares.slice(); if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
history: history.concat([{ squares: squares, }]), xIsNext: !this.state.xIsNext,
});
}
Примечание
В отличие от метода массива
push()
, с которым вы должно быть знакомы, методconcat()
не изменяет оригинальный массив, поэтому мы предпочтём его.
Теперь компоненту Board нужно только два метода — renderSquare
и render
. Состояние игры и handleClick
должны находиться внутри компонента Game.
Посмотреть полный код этого шага
Показываем прошлые ходы
Поскольку мы записываем ход игры, мы теперь можем показать игроку список предыдущих ходов.
Ранее мы узнали, что React-элементы — это обычные объекты JavaScript. Мы можем передавать их внутри нашего приложения. Для рендера нескольких записей в React мы можем использовать массив React-элементов.
В JavaScript у массивов есть метод map()
, который обычно используется для преобразования данных. Например:
const numbers = [1, 2, 3];
const doubled = numbers.map(x => x * 2); // [2, 4, 6]
Используя метод map
, мы можем отобразить историю наших ходов в React-элементы, представленные кнопками на экране, и отрисовать список кнопок для «перехода» к прошлым ходам.
Давайте применим map
к history
внутри метода render
Game-компонента:
render() {
const history = this.state.history;
const current = history[history.length - 1];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => { const desc = move ? 'Перейти к ходу #' + move : 'К началу игры'; return ( <li> <button onClick={() => this.jumpTo(move)}>{desc}</button> </li> ); });
let status;
if (winner) {
status = 'Выиграл ' + winner;
} else {
status = 'Следующий ход: ' + (this.state.xIsNext ? 'X' : 'O');
}
return (
<div className="game">
<div className="game-board">
<Board
squares={current.squares}
onClick={(i) => this.handleClick(i)}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol> </div>
</div>
);
}
Посмотреть полный код этого шага
При итерации по массиву history
, переменная step
содержит текущее значение элемента history
, а move
— текущий индекс элемента history
. Поскольку нам нужен только move
, то step
не используется.
Для каждого хода в истории игры мы создаём элемент списка <li>
, который содержит кнопку <button>
. У кнопки есть обработчик onClick
, который вызывает метод this.jumpTo()
. Мы ещё не реализовали этот jumpTo()
метод. Пока что мы должны видеть только список ходов, которые были сделаны в игре и вот такое предупреждение в инструментах разработчика:
Warning: Each child in an array or iterator should have a unique «key» prop. Check the render method of «Game».
Что переводится как:
Предупреждение: Каждый элемент в массиве или итераторе должен иметь уникальный проп «key». Проверьте метод render в Game
Давайте обсудим, что это предупреждение значит.
Выбор ключа
Когда мы рендерим список, React хранит информацию о каждом отрендеренном элементе списка. Если мы обновляем список, React должен понять, что в нём изменилось. Мы могли добавить, удалить, поменять порядок или обновить элементы списка.
Представим изменения от
<li>Алекс: 7 задач осталось</li>
<li>Бен: 5 задач осталось</li>
к
<li>Бен: 9 задач осталось</li>
<li>Клава: 8 задач осталось</li>
<li>Алекс: 5 задач осталось</li>
В дополнении к изменённым цифрам, человек, читающий это, вероятно, сказал бы что Алекс и Бен поменялись местами, а между ними вставили Клаву. Но React — это компьютерная программа, и она не знает чего мы хотим, поэтому нам нужно указать свойство key для каждого элемента списка, чтобы отличать каждый элемент от остальных. Один из вариантов — использовать строки Алекс
, Бен
, Клава
. Если мы показываем информацию из базы данных, то в качестве ключей мы могли бы использовать идентификаторы из базы.
<li key={user.id}>{user.name}: {user.taskCount} задач осталось</li>
При повторном рендеринге списка, React берёт у каждого элемента списка ключ и ищет его в элементах прошлого списка. Если в новом списке есть ключ, которого раньше не было, React создаёт новый компонент. Если в текущем списке отсутствует ключ, который был в прошлом списке, React уничтожает предыдущий компонент. Если два ключа совпадают, соответствующий компонент перемещается. Ключи в React работают как идентификаторы для каждого компонента, что помогает React поддерживать состояние между повторными рендерингами. Если у компонента меняется ключ, компонент будет уничтожен и создан вновь с новым состоянием.
key
— это специальное зарезервированное свойство в React (наряду с ref
, более продвинутой функциональностью). Когда элемент создан, React извлекает свойство key
и сохраняет этот ключ непосредственно в возвращаемом элементе. Хотя key
выглядит как пропс, к нему нельзя обращаться через this.props.key
. React автоматически использует key
для определения того, какой компонент должен обновиться. Компонент не может узнать о своём ключе.
Настоятельно рекомендуется использовать правильные ключи каждый раз, когда вы строите динамические списки. Если у вас нет подходящего ключа, можно подумать о реструктуризации ваших данных, чтобы он у вас появился.
Если ключ не был определён, React покажет предупреждение и по умолчанию использует индекс элемента в массиве в качестве ключа. Использование индексов массива может вызвать проблемы при попытке отсортировать элементы списка или при вставке/удалении элементов. Явная передача key={i}
отключает предупреждение, но вызывает те же проблемы, связанные с индексами массивов, так что в большинстве случаев лучше так не делать.
Ключи не обязательно должны быть уникальными глобально. Они должны быть уникальными только между компонентами и их братьями и сёстрами.
Реализация путешествия во времени
В истории игры крестики-нолики каждый прошлый ход имеет уникальный идентификатор: это номер хода в последовательности. Ходы никогда не меняют свой порядок, не удаляются и не добавляются в середину последовательности, так что вполне безопасно пользоваться индексом в качестве ключа.
В методе render
компонента Game мы можем добавить ключ следующим образом <li key={move}>
и предупреждения от React об отсутствующих ключах должны пропасть:
const moves = history.map((step, move) => {
const desc = move ?
'Перейти к ходу #' + move :
'К началу игры';
return (
<li key={move}> <button onClick={() => this.jumpTo(move)}>{desc}</button>
</li>
);
});
Посмотреть полный код этого шага
Клик на любой кнопке из списка выбросит ошибку, потому что метод jumpTo
не определён. Прежде чем реализовывать jumpTo
, мы добавим stepNumber
в состояние компонента Game, для указания номера хода, который сейчас отображается.
Сначала добавим stepNumber: 0
в начальное состояние Game внутри constructor
:
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [{
squares: Array(9).fill(null),
}],
stepNumber: 0, xIsNext: true,
};
}
Далее, мы определим метод jumpTo
в компоненте Game для обновления stepNumber
. Мы также установим xIsNext
в true
, если номер хода, на который мы меняем stepNumber
, чётный:
handleClick(i) {
// Этот метод не изменялся
}
jumpTo(step) { this.setState({ stepNumber: step, xIsNext: (step % 2) === 0, }); }
render() {
// Этот метод не изменялся
}
Обратите внимание, что в методе jumpTo
мы не обновили свойство history
состояния. Это потому, что обновления состояния объединяются или, проще говоря, React обновит только те свойства, которые были указаны в методе setState
без изменения остальных свойств. Подробнее об этом читайте в документации.
Мы сделаем ещё несколько изменений в методе Game handleClick
, который выполняется когда вы кликаете на клетки.
Добавленный нами stepNumber
указывает номер хода, отображающегося пользователю. Когда мы делаем очередной ход, нам нужно обновить stepNumber
используя stepNumber: history.length
как часть аргумента для this.setState
. Это гарантирует, что мы не застрянем, показывая одно и то же после того, как был сделан новый ход.
Мы также заменим чтение this.state.history
на this.state.history.slice(0, this.state.stepNumber + 1)
. Это гарантирует, что если мы «вернёмся назад», а затем сделаем новый шаг из этой точки, мы удалим всю «будущую» историю, которая перестала быть актуальной.
handleClick(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1); const current = history[history.length - 1];
const squares = current.squares.slice();
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
history: history.concat([{
squares: squares
}]),
stepNumber: history.length, xIsNext: !this.state.xIsNext,
});
}
Наконец, мы изменим метод render
для Game, чтобы вместо рендера последнего хода он рендерил ход, соответствующий stepNumber
:
render() {
const history = this.state.history;
const current = history[this.state.stepNumber]; const winner = calculateWinner(current.squares);
// Остальной код не менялся
Если мы кликнем на любой ход в игровой истории, поле должно немедленно обновиться, показывая как оно выглядело после этого хода.
Посмотреть полный код этого шага
Подводя итоги
Поздравляем! Вы только что создали игру в крестики-нолики, которая:
- Позволяет вам играть в крестики нолики,
- Показывает, когда один из игроков выиграл,
- Хранит историю игры,
- Позволяет игрокам просматривать историю игры и видеть прошлые состояния игрового поля.
Хорошая работа! Мы надеемся, вы чувствуете уверенность в своих знаниях о том, как работает React.
Посмотреть готовую игру вы можете здесь: Готовый результат.
Если у вас есть дополнительное время или вы хотите попрактиковать свои новые навыки в React, вот пара идей для улучшений, которые вы можете внедрить в крестики-нолики (перечислены в порядке увеличения сложности):
- Отобразите позицию для каждого хода в формате (колонка, строка) в списке истории ходов.
- Выделите выбранный элемент в списке ходов.
- Перепишите Board, используя вложенные циклы для создания клеток, вместо их жёсткого кодирования.
- Добавьте переключатель, который позволит вам сортировать ходы по возрастанию или по убыванию.
- Когда кто-то выигрывает, подсветите три клетки, которые привели к победе.
- Когда победителя нет, покажите сообщение о том, что игра окончилась вничью.
В этом вводном руководстве мы затронули концепции React, включая элементы, компоненты, пропсы и состояние. Для более детального ознакомления с каждой из этих тем обратитесь к остальной документации. Чтобы узнать больше про объявление компонентов изучите Документацию по API React.Component
.