Учебник: введение в 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). Выполните следующие шаги:

  1. Убедитесь, что у вас установлена последняя версия Node.js.

  2. Следуйте инструкциям по установке Create React App, чтобы создать новый проект.


    Код
        
     npx create-react-app my-app
        
    

  3. Удалите все файлы в папке src/ нового проекта.

    Внимание!

    Не удаляйте всю папку src, только файлы c кодом внутри нее. На следующем шаге мы заменим эти файлы примерами, необходимыми для нашего проекта.


    Код
        
     cd my-app
     cd src
    
     # Если вы используете Mac или Linux:
     rm -f *
    
     # Или, если вы на Windows:
     del *
    
     # Затем переключитесь обратно на папку проекта
     cd ..
        
    

  4. Добавьте файл с именем index.css в папку src/ с этим кодом CSS.

  5. Добавьте файл с именем index.js в папку src/ с этим кодом JS.

  6. Добавьте следующие три строки в начало файла 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:

  1. Войдите или зарегистрируйтесь и подтвердите свой адрес электронной почты (необходим для предотвращения спама).

  2. Нажмите кнопку «Fork».

  3. Нажмите «Change View», а затем выберите «Debug mode».

  4. В открывшейся новой вкладке инструменты разработчика теперь должны иметь вкладку 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. Вот как это достигается:

  1. Свойство onClick() в нативном DOM-компоненте <button> указывает React установить слушатель событий щелчка.

  2. При нажатии на кнопку React вызывает обработчик события onClick(), определенный в методе render() компонента Square.

  3. Этот обработчик событий вызывает this.props.onClick(). Свойство onClick компонента Square было определено компонентом Board.

  4. Так как Board передал onClick = {() => this.handleClick(i)} в Square, Square при нажатии вызывает this.handleClick(i).

  5. Мы пока не определили метод 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, вот несколько идей по улучшению, которые вы можете добавить в игру в крестики-нолики, перечисленные в порядке возрастания сложности:

  1. Отображение местоположения для каждого хода в формате (столбец, строка) в списке истории ходов.

  2. Выделите текущий выбранный элемент в списке ходов.

  3. Перепишите компонент Board, чтобы использовать два цикла для создания квадратов вместо их жесткого кодирования.

  4. Добавьте кнопку-переключатель, которая позволяет сортировать ходы в порядке возрастания или убывания.

  5. Когда кто-то выигрывает, выделите три квадрата, которые привели к победе.

  6. Когда никто не выигрывает, выведите сообщение о ничье.

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