Учебник: введение в React
Данный учебник не предполагает каких-либо знаний React.
В данном разделе мы создадим небольшую игру. У вас может возникнуть соблазн пропустить его, так как вы не пишете игры - не делайте такое поспешное решение. Методы, которые вы здесь изучите, имеют основополагающее значение для создания любых React приложений, а их освоение даст вам глубокое понимание React.
Подсказка
Этот учебник предназначен для людей, которые желают учиться на практике. Если же вы предпочитаете изучать теоретические концепции с нуля, ознакомьтесь с нашим пошаговым руководством. Также вы можете найти пошаговое руководство и данный учебник дополняющими друг друга.Учебник состоит из нескольких разделов:
-
Установка. Даст вам отправную точку, чтобы следовать учебнику.
-
Обзор. Познакомит вас с основами React: компонентами, свойствами и состоянием.
-
Завершение игры. Научит вас наиболее распространенным методам разработки в React.
-
Добавление Time Travel. Даст вам более глубокое понимание уникальных преимуществ React.
Вам не обязательно изучать полностью все разделы, чтобы извлечь пользу из этого учебника. Постарайтесь добраться как можно дальше - даже если это одна или две главы.
Во время изучения можно копировать и вставлять код, но мы рекомендуем набирать его вручную. Это поможет вам развить мышечную память и получить более глубокое понимание происходящего.
Что мы разрабатываем?
В этом учебнике мы покажем, как создать интерактивную игру в крестики-нолики используя React.
Вы можете увидеть, что именно мы будем разрабатывать здесь: Окончательный результат. Если код для вас непонятен, или если вы не знакомы с синтаксисом, не беспокойтесь! Цель учебника - помочь вам понять React и его синтаксис.
Мы рекомендуем вам ознакомиться с игрой в крестики-нолики, прежде чем продолжить обучение. Одна из особенностей, которую вы заметите, это то, что справа от игрового поля есть нумерованный список. Данный список содержит историю всех ходов, произошедших в игре, обновляясь по ходу игры.
Наш следующий шаг - произвести необходимые настройки, чтобы вы могли начать создавать игру.
Предварительные требования
Мы предполагаем, что вы немного знакомы с HTML и JavaScript. Но даже если вы переходите с другого языка программирования, вы должны быть способны понимать то, о чем идет речь в этих главах. Мы также предполагаем, что вы знакомы с такими понятиями программирования, как функции, объекты, массивы и, возможно в меньшей степени, классы.
Если вам нужно повторить JavaScript, можно использовать данное руководство (хотя лично я предпочитаю это руководство). Обратите внимание, что в данном учебнике мы используем некоторые функции ES6 - недавней версии JavaScript: функции-стрелки, классы, операторы let и const. Вы можете использовать Babel REPL, чтобы проверить, во что компилируется код ES6.
Освоить данное руководство можно двумя способами: вы можете либо писать код в своем браузере, либо настроить локальную среду разработки на своем компьютере.
1-й вариант установки
Это самый быстрый способ начать работу!
Сначала откройте этот стартовый код в новой вкладке. Новая вкладка должна отображать пустую игровую доску в крестики-нолики и код React. В этом учебнике мы будем редактировать код React.
Теперь вы можете пропустить второй вариант установки и перейти к разделу «Обзор», чтобы приступить к обзору React.
2-й вариант: локальная среда разработки
Это исключительно по желанию и совершенно не обязательно для данного учебника!
Необязательно: инструкции для разработки локально с помощью предпочитаемого вами текстового редактора.
Данная установка потребует больше времени и сил, но позволяет вам освоить учебник, используя предпочитаемый вами редактор (например WebStorm). Выполните следующие шаги:
-
Убедитесь, что у вас установлена последняя версия Node.js.
-
Следуйте инструкциям по установке Create React App, чтобы создать новый проект.
Кодnpx create-react-app my-app
-
Удалите все файлы в папке
src/
нового проекта.
Внимание!
Не удаляйте всю папкуsrc
, только файлы c кодом внутри нее. На следующем шаге мы заменим эти файлы примерами, необходимыми для нашего проекта.
Код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'; import './index.css';
Теперь, если вы запустите npm start
в папке проекта и откроете http://localhost:3000
в браузере, вы должны увидеть пустое поле крестики-нолики.
Также мы рекомендуем следовать данным инструкциям, чтобы настроить подсветку синтаксиса в вашем редакторе.
Помогите, я застрял!
Если вы застряли, посетите ресурсы сообщества поддержки. В частности, Reactiflux Chat - отличный способ быстро получить помощь. Если же вы не получили ответа или зашли в тупик, пожалуйста, сообщите нам в Git о проблеме, и мы вам поможем.
Теперь, когда вы произвели всю необходимую установку, давайте познакомимся с React!
Что такое React?
React - это декларативная, эффективная и гибкая библиотека JavaScript для создания пользовательских интерфейсов (UI). Она позволяет вам создавать сложные 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="Mark" />
Cкоро мы перейдем к забавным XML-подобным тегам. Мы используем компоненты, чтобы сообщить React, что именно мы хотим видеть на экране. Когда наши данные изменятся, React будет эффективно обновлять и повторно отрисовывать наши компоненты.
Здесь ShoppingList
- это класс компонента React или тип компонента 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 ... */),
React.createElement('ul', /* ... потомки ul ... */)
);
Смотрите полную версию примера здесь.
Если вам интересно, createElement()
более подробно
описан в справочнике по API,
но мы не будем пользоваться им в этом учебнике. Вместо него мы
будем продолжать использовать JSX.
JSX включает в себя JavaScript. Вы можете поместить любые
выражения JavaScript в фигурные скобки внутри JSX. Любой React элемент
представляет собой объект JavaScript, который вы можете сохранить в
переменной или передать куда-либо в своей программе.
Компонент ShoppingList
выше отрисовывает только нативные
компоненты DOM, такие как <div />
и <li />
. Но вы также можете
создавать и отрисовывать пользовательские компоненты React. Например,
теперь мы можем ссылаться на весь список покупок, написав <ShoppingList />
. Каждый
компонент React инкапсулирован и может работать независимо; это позволяет
создавать сложные пользовательские интерфейсы из простых компонентов.
Если вы собираетесь работать с учебником в своем браузере,
откройте этот код в новой вкладке: стартовый код. Если вы
собираетесь работать над учебником в локальной среде,
откройте src/index.js
в папке вашего проекта (вы уже
коснулись этого файла во время установки).
Этот стартовый код является основой того, что мы строим. Мы предоставили стили CSS, так что вам нужно сосредоточиться только на изучении React и программировании игры в крестики-нолики.
Изучив код, вы заметите, что у нас есть три компонента React:
-
Square
-
Board
-
Game
Компонент Square
отображает одиночную кнопку <button>
, а Board
отображает 9
квадратов. Компонент Game
отображает Board
со значениями чисел-заполнителей,
которые мы изменим позже. В настоящее время интерактивные компоненты отсутствуют.
Передача данных с помощью props
Для эксперимента, давайте попробуем передать некоторые
данные из нашего компонента Board
в компонент Square
.
В методе renderSquare
компонента Board
измените
код, чтобы передать свойство с именем value
в компонент 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() { alert('click'); }}>
{this.props.value}
</button>
);
}
}
Если мы сейчас нажмем на Square, то должны получить предупреждение в нашем браузере.
Внимание!
Чтобы сохранить типизацию и избежать запутанного поведения, мы будем использовать синтаксис функции-стрелки для обработчиков событий здесь и далее в коде:
class Square extends React.Component {
render() {
return (
<button className="square" onClick={() => alert('click')}>
{this.props.value}
</button>
);
}
}
Обратите внимание, что с помощью onClick = {() => alert ('click')}
мы
передаем функцию в качестве свойства onClick
. Она срабатывает только
после щелчка. Пропуск () =>
и запись onClick = {alert ('click')}
-
является распространенной ошибкой, которая генерирует предупреждение
каждый раз, когда компонент перерисовывается.
Следующим шагом мы хотим, чтобы компонент Square
«запомнил», что
на него щелкнули, и заполнил себя знаком «X
». Чтобы «запоминать»
вещи, компоненты используют состояние.
Компоненты React могут иметь состояние, инициализируя this.state
в своих конструкторах. Состояние this.state
следует рассматривать
как приватное для компонента React, в котором оно определено.
Давайте сохраним текущее значение Square
в this.state
и изменим
его при нажатии Square
.
Сначала мы добавим конструктор в класс для инициализации состояния:
class Square extends React.Component {
constructor(props) {
super(props);
this.state = {
value: null,
};
}
render() {
return (
<button className="square" onClick={() => alert('click')}>
{this.props.value}
</button>
}
}
Внимание!
В классах JavaScript вам всегда нужно вызыватьsuper
при определении
конструктора подкласса. Все классы компонентов React, имеющие конструктор,
должны начинат его с вызова super(props)
.
Теперь мы изменим метод render
компонента Square
для
отображения значения текущего состояния при нажатии:
-
Замените
this.props.value
наthis.state.value
внутри тега<button>
. -
Замените обработчик события
() => alert()
на() => 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
компонента Square
, мы говорим React повторно отрисовывать этот
Square
при каждом нажатии на его кнопку <button>
. После обновления
свойство this.state.value
компонента Square
будет иметь значение «X
»,
поэтому мы увидим X
на игровом поле. Если вы нажмете на любой
квадрат, в нём должен появиться X
.
Когда вы вызываете setState
в компоненте, React
автоматически обновляет и дочерние компоненты внутри него.
Инструменты разработчика
Расширение React Devtools для Chrome и Firefox позволяет вам просматривать дерево компонентов React с помощью инструментов разработчика в вашем браузере.
React DevTools позволяет вам проверять свойства и состояние ваших компонентов React.
После установки React DevTools вы можете щелкнуть правой кнопкой мыши по любому элементу на странице, нажать «Inspect», чтобы открыть инструменты разработчика. Вкладка React появится последней справа.
Однако обратите внимание, что необходимо сделать несколько дополнительных шагов, чтобы заставить его работать с CodePen:
-
Войдите или зарегистрируйтесь и подтвердите свой адрес электронной почты (необходим для предотвращения спама).
-
Нажмите кнопку «Fork».
-
Нажмите «Change View», а затем выберите «Debug mode».
-
В открывшейся новой вкладке инструменты разработчика теперь должны иметь вкладку React.
Теперь у нас есть основные строительные блоки для нашей игры в
крестики-нолики. Чтобы завершить игру, нам необходимо чередовать размещение
«X
» и «O
» на доске, а также нам нужен способ определить победителя.
Поднятие состояния вверх
В настоящее время каждый компонент Square
поддерживает состояние игры.
Чтобы определить победителя, мы будем хранить значение каждого из 9 квадратов в одном месте.
Можно предположить, что Board
должен просто попросить у каждого
Square
значение его состояния. Хотя такой подход и возможен в React,
мы его не одобряем, потому что код становится сложным для понимания,
восприимчивым к ошибкам и трудным для рефакторинга. Вместо этого лучше
всего хранить состояние игры в родительском компоненте Board
, а не в
каждом Square
. Компонент Board
может указать каждому Square
, что отображать,
передавая свойство, точно так же, как мы это делали, когда передавали
число в каждый Square
.
Общее правило:
Чтобы собрать данные из нескольких дочерних элементов или обеспечить взаимодействие двумя дочерними компонентами, вам нужно объявить общее состояние в их родительском компоненте. Родительский компонент может передать состояние обратно дочерним компонентам, используя свойства
props
; это
синхронизирует дочерние компоненты между собой и с родительским компонентом.
Поднятие состояния в родительский компонент является обычным
явлением при рефакторинге компонентов React - давайте воспользуемся
этой возможностью. Мы добавим конструктор в Board
и установим его
начальное состояние так, чтобы оно содержало массив с 9 нулями.
Эти 9 нулей соответствуют 9 квадратам:
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null)
};
}
renderSquare(i) {
return <Square value={i} />;
}
render() {
const status = 'Next player: 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
, игровая доска будет выглядеть примерно так:
[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]
В настоящее время метод renderSquare
в Board
выглядит следующим образом:
renderSquare(i) {
return <Square value={i} />;
}
В начале мы передали свойство value вниз по иерархии компоненту Square
из Board
,
чтобы показывать числа от 0 до 8 в каждом Square
. В другом предыдущем
шаге мы заменили числа знаком «X
», определяемым собственным состоянием Square.
Вот почему Square
в настоящее время игнорирует свойство value
,
переданное ему компонентом Board
.
Теперь мы снова будем использовать механизм передачи свойств. Мы
изменим Board
, чтобы проинструктировать каждый отдельный Square
о его
текущем значении («X
», «O
» или null
). У нас уже определен массив squares
в конструкторе Board
. Давайте изменим метод renderSquare
в Board
,
чтобы читать значения из массива:
renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}
Каждый Square
теперь получит свойство value
,
которое будет либо «X
»/«O
», либо null
для пустых квадратов.
Далее нам нужно изменить то, что происходит при нажатии на квадрат.
Компонент Board
теперь знает, какие квадраты заполнены. Нам нужно
создать для Square
способ обновить состояние Board
. Поскольку состояние
считается приватным по отношению к компоненту, который его определяет,
мы не можем обновлять состояние Board
напрямую из Square
.
Чтобы сохранить состояние Board
приватным, мы передадим функцию из
компонента Board
компоненту 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
компонентаSquare
-
Заменим
this.setState()
наthis.props.onClick()
в методеrender
компонентаSquare
-
Удалим конструктор из
Square
, потому что он больше не отслеживает состояние игры
После этих изменений компонент Square выглядит следующим образом:
class Square extends React.Component {
render() {
return (
<button
className="square"
onClick={() => this.props.onClick()}
>
{this.props.value}
</button>
);
}
}
При нажатии на квадрат вызывается функция onClick()
,
предоставляемая Board
. Вот как это достигается:
-
Свойство
onClick()
в нативном DOM-компоненте<button>
указывает React установить слушатель событий щелчка. -
При нажатии на кнопку React вызывает обработчик события
onClick()
, определенный в методеrender()
компонентаSquare
. -
Этот обработчик событий вызывает
this.props.onClick()
. СвойствоonClick
компонентаSquare
было определено компонентомBoard
. -
Так как
Board
передалonClick = {() => this.handleClick(i)}
вSquare
,Square
при нажатии вызываетthis.handleClick(i)
. -
Мы пока не определили метод
handleClick()
, поэтому наш код выдает крэш.
Внимание!
АтрибутonClick
DOM-элемента <button>
имеет особое значение для React,
поскольку он является нативным компонентом. Для пользовательских компонентов,
таких как Square
, наименование зависит от вас. Мы могли бы как угодно назвать
метод onClick
компонента Square
или метод
handleClick
компонента Board
.
Однако в React принято использовать имена on[Event]
для свойств, которые
представляют события, и handle[Event]
для методов, которые обрабатывают события.
Когда мы попытаемся кликнуть по квадрату, мы должны получить ошибку, потому
что мы еще не определили handleClick
. Теперь мы добавим 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 = 'Next player: 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
и информируют компонент Board
при клике по ним. В терминах React-компоненты Square
теперь являются
контролируемыми компонентами. Board
их полностью контролирует.
Обратите внимание, что в handleClick
мы вызываем .slice()
, чтобы создать копию
массива квадратов для его изменения вместо изменения существующего массива. Мы объясним,
почему мы создаем копию массива квадратов в следующей главе.
Почему важна неизменяемость
В предыдущем примере кода мы предложили использовать оператор .slice()
, чтобы создать
копию массива квадратов для изменения вместо изменения существующего массива.
Теперь мы обсудим неизменяемость и то, почему важно её изучить.
Как правило, существует два подхода к изменению данных. Первый подход заключается в мутировании данных путем прямого изменения значений. Второй подход заключается в замене данных новой копией, которая имеет желаемые изменения.
Изменение данных с помощью мутации
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// Текущий игрок: {score: 2, name: 'Jeff'}
Изменение данных без мутации
var player = {score: 1, name: 'Jeff'};
var newPlayer = Object.assign({}, player, {score: 2});
// Теперь player неизменяемый, а newPlayer - {score: 2, name: 'Jeff'}
// Или, если вы используете оператор spread для объекта, можете написать:
// var newPlayer = {...player, score: 2};
Конечный результат такой же, но не изменяя (или не изменяя оригинальные данные) напрямую, мы получаем несколько преимуществ, описанных ниже.
Сложные функции становятся простыми
Неизменяемость делает сложные функции намного проще в реализации. Позже в этом учебнике мы реализуем функцию «путешествие во времени», которая позволяет нам просматривать историю игры в крестики-нолики и «перепрыгивать» к предыдущим ходам. Такая функциональность не является специфичной для игр - способность отменять и повторять определенные действия является распространенным требованием в приложениях. Избежание прямой мутации данных позволяет нам сохранить предыдущие версии истории игры нетронутыми и использовать их позже.
Отслеживание изменений
Обнаружить изменения в мутируемых (изменяемых) объектах сложно, потому что они изменяются напрямую. Такое обнаружение требует, чтобы изменяемый объект сравнивался с предыдущими копиями самого себя и всего дерева объектов перемещения.
Обнаружение изменений в неизменяемых объектах значительно проще. Если неизменяемый объект, на который ссылаются, отличается от предыдущего, то объект изменился.
Определение момента, когда необходима перерисовка в React
Основным преимуществом неизменяемости является то, что она помогает создавать чистые компоненты в React. Неизменяемые данные могут легко определить, были ли внесены изменения, что в свою очередь помогает определить, когда компонент требует повторной отрисовки.
Вы можете узнать больше о shouldComponentUpdate()
и о том, как создавать
чистые компоненты, прочитав раздел «Оптимизация производительности».
Компоненты-функции
Теперь мы изменим Square
на компонент-функцию.
В React компоненты-функции являются более простым способом написания
компонентов, которые содержат только метод отрисовки и не имеют своего
собственного состояния. Вместо определения класса, который расширяет 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}
(обратите внимание на отсутствие скобок с обеих сторон). В классе мы использовали стрелочную функцию
для доступа к правильному значению this
, но в компоненте функции нам не
нужно об этом беспокоиться.
По очереди
Теперь нам нужно исправить очевидный дефект в нашей игре в крестики-нолики: буквы «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
});
}
С этим изменением «Х» и «О» могут сменяться. Давайте также изменим текст
переменной 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;
}
Мы будем вызывать calculateWinner(squares)
в методе render
компонента Board
,
чтобы проверить, выиграл ли игрок. Если игрок выиграл, мы можем отобразить
текст, такой как «Победитель: X» или «Победитель: O». Заменим объявление
переменной status
в методе render
компонента Board
следующим кодом:
render() {
const winner = calculateWinner(this.state.squares);
let status;
if (winner) {
status = 'Победитель: ' + winner;
} else {
status = 'Следующий игрок: ' + (this.state.xIsNext ? 'X' : 'O');
}
return (
// остальное без изменений
Теперь мы можем изменить функцию handleClick
в Board
, чтобы
выполнять return
раньше, игнорируя клик, если кто-то выиграл
игру или Square
уже заполнен:
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
представляет все состояния Board
, от первого до последнего хода,
и имеет следующую форму:
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
для всех Square
, нам нужно будет передавать местоположение каждого Square
в
обработчик onClick
, чтобы указать, на какой квадрат кликнули. Вот необходимые
шаги для преобразования компонента Board
:
-
Удалить конструктор в 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,
представляющими кнопки на экране, и отобразить список кнопок, чтобы «перейти» к прошлым ходам.
Давайте сопоставим историю в методе 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 ?
'Go to move #' + move :
'Go to game start';
return (
<li>
<button onClick={() => this.jumpTo(move)}>{desc}</button>
</li>
);
});
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (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>
);
}
Для каждого хода в истории игры в крестики-нолики мы создаем элемент списка <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
для каждого элемента списка,
чтобы отличать его соседних элементов в этом списке. Один из вариантов - использовать
строки vasia
, petia
, lyonia
. Если бы мы отображали данные из базы данных, в качестве
ключей могли бы использоваться идентификаторы (поле id) базы данных для Васи, Пети и Лёни.
<li key={user.id}>{user.name}: выполнил {user.taskCount} задач</li>
При повторной отрисовке списка React берет ключ каждого элемента списка и ищет соответствующие элементы в предыдущем списке. Если в текущем списке есть ключ, которого раньше не было, React создает компонент. Если в текущем списке отсутствует ключ, который существовал в предыдущем списке, React уничтожает предыдущий компонент. Если два ключа совпадают, соответствующий компонент перемещается. Ключи сообщают React об идентичности каждого компонента, что позволяет React поддерживать состояние между повторными отрисовками. Если ключ компонента изменится, он будет уничтожен и воссоздан с новым состоянием.
Ключ - это специальное и зарезервированное свойство в React (наряду с ref
,
более продвинутой функцией). Когда элемент создан, React извлекает свойство
key
и сохраняет его непосредственно в возвращаемом элементе. Даже если
key
может выглядеть так, как будто он принадлежит props
, на него нельзя ссылаться,
используя this.props.key
. React автоматически использует key
, чтобы решить, какие
компоненты обновлять. Компонент не может узнать о своем ключе key
.
Настоятельно рекомендуется назначать правильные ключи key
при
создании динамических списков. Если у вас нет подходящего ключа key
,
вы можете подумать о реструктуризации ваших данных.
Если ключ не указан, React выдаст предупреждение и по умолчанию
будет использовать индекс массива в качестве ключа. Использование
индекса массива в качестве ключа вызывает проблемы при попытке изменить
порядок или при вставке/удалении элементов списка. Явная передача key= {i}
отключает предупреждение, но имеет те же проблемы, что и индексы массивов,
и в большинстве случаев не рекомендуется.
Ключи не должны быть глобально уникальными; они должны быть уникальными только между компонентами и их соседями. То есть уникальными только в пределах данного списка. В другом, к примеру соседнем списке, значения ключей могут совпадать со значениями текущего списка, но это никак не влияет на его работу, так как эти два подмножества ключей рассматриваются React полностью независимо, то есть они друг о друге не знают.
Реализация путешествия во времени
В истории игры в крестики-нолики каждый предыдущий ход имеет свой уникальный идентификатор - это порядковый номер хода. Ходы никогда не переупорядочиваются, не удаляются и не вставляются в середину, поэтому здесь безопасно использовать индекс хода в качестве ключа.
В методе 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
:
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() {
// данный метод неизменён
}
Теперь мы внесем несколько изменений в метод handleClick
комопнента Game
,
который срабатывает при нажатии на квадрат.
Добавленное нами состояние 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, включающие элементы, компоненты, свойства и состояние. Для более подробного объяснения каждой из этих тем ознакомьтесь с остальной документацией. Чтобы узнать больше об определении компонентов, ознакомьтесь со справкой React.Component.