Посібник: знайомство з React

Даний посібник не потребує попереднього ознайомлення з React.

Перед початком роботи

У цьому посібнику ми працюватимемо над створенням маленької гри. Вам це може здатися непотрібним, оскільки ви не плануєте створювати ігри, але ми рекомендуємо спробувати. Методи, які ви вивчите у даному посібнику, є основоположними для створення будь-якого React-додатка, і освоєння цих методів допоможе вам глибше зрозуміти React.

Порада

Даний посібник призначений для людей, які надають перевагу навчанню на практиці. Якщо вам більше подобається вчитися з нуля, зверніться до нашого покрокового довідника. Можливо, ви виявите для себе, що обидва чудово доповнюють одне одного.

Посібник розбито на декілька розділів:

  • Налаштування допоможуть встановити відправну точку для роботи над грою.
  • Огляд ознайомить вас з такими основами React як компоненти, пропси та стан.
  • Створення гри допоможе розібратися з найпоширенішими методами у розробці React-додатків.
  • Додання подорожі у часі надасть можливість глибше осягнути сильні сторони React.

Щоб отримати користь від цього посібника, вам зовсім не потрібно опрацьовувати усі розділи відразу. Продовжуйте працювати стільки, скільки вважаєте за потрібне, навіть якщо це один чи два розділи.

Що ми створюємо?

У цьому посібнику ми розглянемо створення інтерактивної гри в хрестики-нулики за допомогою React.

Кінцевий результат ви можете розглянути за наступним посиланням: Завершена гра . Не хвилюйтесь, якщо код здається вам незрозумілим, або ви не знайомі з синтаксисом! Мета даного посібника — допомогти вам зрозуміти React і його синтаксис.

Ми радимо уважно роздивитися гру перед тим як продовжувати працювати над посібником. Одна з її помітних властивостей — пронумеровани список з правої сторони ігрового поля. Цей список відображає історію всіх ходів і оновлюється по ходу гри.

Ви можете закрити гру, коли закінчите ознайомлюватись. Ми почнемо з простішого зразка. Наш наступний крок — підготуватись до створення гри.

Передумови

Ми припустимо, що ви вже трохи знайомі з HTML і JavaScript. Але навіть якщо в повсякденному житті ви використовуєте іншу мову програмування, проходження даного посібника не має скласти труднощів. Також вважатимемо, що ви знайомі з функціями, об’єктами, масивами і, меншою мірою, класами.

Якщо вам потрібно повторити основи JavaScript, ми рекомендуємо проглянути цей довідник. Зверніть увагу, що ми також використовуємо деякі особливості ES6 — нещодавньої версії JavaScript. У цьому посібинку ми застосовуємо стрілкові функції, класи, let та const. Ви можете скористатися Babel REPL, щоб дізнатися у що компілюється код ES6.

Налаштування

Існує два способи проходження цього посібника: ви можете писати код у браузері, або налаштувати локальне середовище розробки на своєму комп’ютері.

Спосіб 1: Пишемо код у браузері

Якщо вам кортить почати, цей спосіб найшвидший!

Спершу, відкрийте стартовий код у новій вкладці. Ви побачите пусте поле для гри в хрестики-нулики і React-код. У даному розділі ми поступово змінюватимемо цей код.

Ви можете пропустити другий варіант налаштувань і відразу перейти до огляду React.

Спосіб 2: Локальне середовище розробки

Цей крок необов’язковий і не вимагається для проходження даного посібника!


Необов'язково: інструкції для написання коду в улюбленому текстовому редакторі

Це налаштування вимагає трохи більше роботи, але дозволяє опрацьовувати посібник у власному редакторі. Ось що вам потрібно зробити:

  1. Переконайтесь, що на вашому комп’ютері встановлено останню версію Node.js.
  2. Дотримуйтеся інструкцій налаштування Create React App для створення нового проекту.
npx create-react-app my-app
  1. Видаліть усі файли з папки src/ нового проекту.

Примітка:

Не видаляйте саму папку src, тільки вихідні файли, що містяться в ній. Наступним кроком ми замінимо ці файли прикладами, потрібними для проекту.

cd my-app
cd src

# Якщо ви використовуєте Mac або Linux:
rm -f *

# Або якщо використовуєте Windows:
del *

# Після цього поверніться до папки проекту
cd ..
  1. Створіть файл index.css у папці src/ з цим CSS кодом.

  2. Створіть файл index.js у папці src/ з цим JS кодом.

  3. Впишіть наступні три рядки на початку index.js у папці src/:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

Тепер, якщо ви запустите npm start у папці проекту і відкриєте http://localhost:3000 у браузері, перед вами має відкритися пусте поле для гри в хрестики-нулики.

Ми рекомендуємо слідувати цим інструкціям, щоб налаштувати підсвічування синтаксису у вашому редакторі.

Допоможіть, я застряг!

Якщо ви застрягли, зверніться до ресурсів підтримки спільноти. Зокрема, чат Reactiflux — чудовий спосіб швидко знайти допомогу. Якщо ви не отримали належну відповідь і все ще не знаєте, як вирішити проблему, будь ласка, напишіть нам, і ми вам допоможемо.

Огляд

Тепер, коли ви закінчили попередні налаштування, давайте перейдемо до огляду React!

Що таке React?

React — це декларативна, ефективна і гнучка JavaScript-бібліотека, призначена для створення інтерфейсів користувача. Вона дозволяє компонувати складні інтерфейси з невеликих окремих частин коду — “компонентів”.

У 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" />

Ми ще повернемося до незвичних, схожих на XML-тегів. Ми використовуємо компоненти, щоб повідомити React, що саме хочемо бачити на екрані. Кожного разу при зміні даних, React ефективно оновлює і повторно рендерить наші компоненти.

Так, ShoppingList вище — це класовий компонент React. Компонент приймає параметри, які називаються props (скорочено від “properties” — властивості), і повертає ієрархію перегляду, використовуючи метод render.

Метод render повертає опис того, що ви хочете бачити на екрані. React приймає цей опис і відтворює результат. Зокрема, render повертає React-елемент — полегшену версію того, що треба відрендерити. Більшість React-розробників користується спеціальним синтаксисом під назвою “JSX”, який спрощує написання цих конструкцій. Під час компіляції синтаксис <div /> перетворюється у React.createElement('div'), тож приклад вище рівноцінний до:

return React.createElement('div', {className: 'shopping-list'},
  React.createElement('h1', /* ... дочірні компоненти h1 ... */),
  React.createElement('ul', /* ... дочірні компоненти ul ... */)
);

Проглянути повну розширену версію.

Якщо вам цікаво, детальніше про createElement() можна дізнатися у довіднику API. Ми не використовуватимемо даний синтаксис у цьому посібнику, натомість ми продовжимо працювати з JSX.

JSX має повну силу JavaScript. У межах фігурних дужок JSX ви можете використовувати будь-які JavaScript-вирази. Кожен елемент React є об’єктом, який можна зберегти у змінній або розповсюдити у вашій програмі.

Компонент ShoppingList вище рендерить тільки вбудовані DOM-компоненти як <div /> або <li />. Але ви також можете створювати і рендерити власні React-компоненти. Наприклад, тепер ми можемо посилатися на весь список покупок відразу використовуючи <ShoppingList />. Кожен React-компонент інкапсульований і може використовуватись незалежно від інших; це дозволяє створювати складні інтерфейси з простих компонентів.

Розглянемо стартовий код

Якщо ви збираєтесь працювати над посібником у браузері, відкрийте цей код у новій вкладці: стартовий код. Якщо ж ви працюватимете на власному комп’ютері, відкрийте src/index.js у папці проекту (ви вже працювали з цим файлом під час налаштувань).

Цей стартовий код є фундаментом того, що ми будуватимемо. Ми завчасно подбали про CSS-стиль, тож ви можете повністю сконцентруватися на вивченні React і створенні гри у хрестики-нулики.

Розглядаючи код, ви помітите, що ми маємо три React-компоненти:

  • Square (Клітинка)
  • Board (Поле)
  • Game (Гра)

Компонент Square рендерить окремий елемент <button>, а компонент Board рендерить 9 таких клітинок. Компонент Game рендерить ігрове поле з заповнювачами, які ми змінимо пізніше. Наразі ми не маємо жодного інтерактивного компонента.

Передаємо дані через пропси

Для початку спробуємо передати деякі дані з компоненту 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>
    );
  }
}

До:

React Devtools

Після: ви маєте бачити число всередині кожної відрендереної клітинки.

React Devtools

Проглянути повний код цього кроку

Вітаємо! Ви щойно “передали проп” від батьківського компонента Board до дочірнього компонента Square. Передача даних через пропси від батьківського компонента до дочірнього — це те як дані перетікають у React-додатках.

Створюємо інтерактивний компонент

Давайте при натисканні заповнимо компонент Square позначкою “X”. Для початку змінимо тег кнопки, який повертається з функції render(), на наступний код:

class Square extends React.Component {
  render() {
    return (
      <button className="square" onClick={function() { alert('click'); }}>
        {this.props.value}
      </button>
    );
  }
}

Тепер, при натисканні на Square, у браузері щоразу має з’являтись повідомлення.

Примітка

Щоб не вводити зайві символи і уникнути заплутаної поведінки this, тут і далі для обробників події ми використовуватимемо синтаксис стрілкової функції:

class Square extends React.Component {
 render() {
   return (
     <button className="square" onClick={() => alert('click')}>
       {this.props.value}
     </button>
   );
 }
}

Зверніть увагу, що у onClick={() => alert('click')} ми передаємо функцію як проп onClick. React викличе цю функцію тільки після натискання. Типовою помилкою є використання синтаксису onClick={alert('click')} без () =>, оскільки такий код буде спрацьовувати при кожному рендері компонента.

Наступним кроком ми хочемо, щоб компонент Square “запам’ятав”, що на нього клікнули і відобразив позначку “X”. Для “запам’ятовування” компоненти використовують стан.

Щоб налаштувати стан у компоненті React, вам потрібно вписати this.state у його конструктор. this.state варто розглядати як особисту властивість компонента, у якому його визначено. Давайте збережемо поточне значення Square у this.state і змінюватимемо його при кожному натисканні.

Спершу додамо конструктор до класу, щоб ініціалізувати стан:

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, що мають constructor, повинні починатися з виклику super(props).

Тепер змінимо метод render компонента Square, щоб відобразити значення поточного стану під час натискання:

  • Замініть this.props.value на this.state.value усередині тегу <button>.
  • Замініть обробник події onClick={...} на onClick={() => this.setState({value: 'X'})}.
  • Для кращої читабельності розташуйте пропси className та onClick на окремих рядках.

Після цих змін тег <button>, повернений з метода render компонента Square, має виглядати наступним чином:

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

Викликаючи this.setState з обробника onClick у методі render компонента Square, ми наказуємо React перерендерити компонент щоразу під час натиску на <button>. Після оновлення, this.state.value компонента набуде значення 'X', що ми також побачимо на ігровому полі. При натиску на будь-який Square-компонент, відповідна клітинка заповниться позначкою X.

Під час виклику setState у компоненті, React також автоматично оновлює його дочірні компоненти.

Проглянути повний код цього кроку

Інструменти розробки

Розширення React Devtools для Chrome та Firefox дозволяє вам інспектувати дерево React-компонентів у панелі інструметів розробника вашого браузера.

React Devtools

React DevTools дозволяють перевірити пропси і стан вашого React-компонента.

Після встановлення React DevTools натисніть на будь-який елемент на сторінці, виберіть “Inspect”, і у панелі інструментів розробника з правого краю ви побачите дві нові вкладки React (“⚛️ Components” та “⚛️ Profiler”). Виберіть “⚛️ Components”, щоб перевірити дерево компонентів.

Зауважте, що для коректної роботи інструментів розробника на CodePen вам слід зробити декілька додаткових кроків:

  1. Увійдіть або зареєструйтесь і підтвердіть вашу електронну пошту (необхідно для запобігання спаму).
  2. Натисніть кнопку “Fork”.
  3. Натисніть “Change View” і виберіть “Debug mode”.
  4. У новій вкладці, яка щойно відкрилась, інструменти розробника тепер мають також містити вкладку React.

Завершуємо гру

Тепер у нашому розпорядженні ми маємо базові елементи для створення гри у хрестики-нулики. Щоб гра набула завершеного вигляду, нам потрібно всановити почерговість “X” та “O” на ігровому полі і відобразити переможця по завершенню гри.

Підйом стану

На даний момент кожен Square-компонент зберігає у собі стан гри. Для визначення переможця ми збережемо значення кожної клітинки в одному місці.

Може здатися, що Board має надсилати запит до кожного Square-компонента, щоб дізнатися стан. І хоча такий підхід можливий, ми не рекомендуємо звертатися до нього, оскільки це робить код важким для розуміння, вразливим до помилок та ускладнює рефакторинг. Натомість краще зберегти стан гри у батьківському Board-компоненті замість кожного окремого Square-компонента. Компонент Board може вказувати що відображати Square-компонентам, передаючи стан через пропси. Схожим чином ми передали число кожному Square-компоненту.

Щоб зібрати дані з кількох дочірніх компонентів чи дати можливість двом дочірнім компонентам контактувати один з одним, вам потрібно визначити спільний стан у батьківському компоненті. Батьківський компонент може передавати стан до дочірніх компонентів через пропси. Цей спосіб підтримує синхронізацію дочірніх компонентів один з одним і з батьківським компонентом.

Підйом стану до батьківського компонента — звична справа при рефакторингу React-компонентів, тож давайте скористаємося нагодою і спробуємо цей спосіб.

Додайте конструктор до компонента Board і визначте початковий стан, який міститиме масив з 9 null-елементами, що відповідають 9 клітинкам:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  renderSquare(i) {
    return <Square value={i} />;
  }

Пізніше, коли ми заповнимо поле, масив this.state.squaresвиглядатиме приблизно так:

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

Метод renderSquare компонента Board зараз виглядає наступним чином:

  renderSquare(i) {
    return <Square value={i} />;
  }

На початку ми передали проп value з компонента Board, щоб відобразити числа від 0 до 8 у кожному Square. Попереднім кроком ми замінили числа на позначку “X”, що визначалась власним станом компонента Square. Саме тому на даному етапі компонент Square ігнорує проп value, переданий компонентом Board.

Ми знову скористаємося способом передачі пропсів. Ми модифікуємо Board, щоб передати кожному Square-компоненту його поточні значення ('X', 'O', або null). Ми вже визначили масив squares у конструкторі Board і тепер модифікуємо метод renderSquare цього компонента, щоб мати змогу читати дані з цього масиву:

  renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }

Поглянути повний код цього кроку

Кожен компонент Square тепер отримуватиме проп value, який відповідатиме 'X', 'O', або null для пустих клітинок.

Далі нам потрібно налаштувати подію, що спрацьовуватиме при натиску на компонент Square. Компонент Board тепер зберігає інформацію про натиснуті клітинки. Нам потрібно визначити спосіб, щоб оновити стан Board зі Square-компонента. Оскільки стан є приватним для компонента у якому його визначено, ми не можемо оновити стан Board з дочірнього Square.

Натомість ми передамо функцію від Board до Square і налаштуємо виклик цієї функції зі Square, коли на нього натиснуто. Змінимо метод renderSquare компонента Board на наступний код:

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

Примітка

Ми розбили код вище для кращої читабельності і додали круглі дужки, щоб JavaScript не зруйнував код, вставляючи крапку з комою після return.

Тепер, від Board до Square, ми передаємо два пропси вниз: value та onClick. Проп onClick — функція, що спрацьовує коли клітинку компонента Square натиснуто. Внесемо наступні зміни до Square:

  • Замініть this.state.value на this.props.value у методі render компонента Square
  • Замініть this.setState() на this.props.onClick() у методі render компонента Square
  • Видаліть constructor зі Square, тому що цей компонент більше не відслідковує стан гри

Після цих змін компонент Square має виглядати так:

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}

При натисканні Square з компонента Board викликається функція onClick. Розглянемо, чому так відбувається:

  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(), тож наш код не працюватиме як слід. Якщо ви натисните на клітинку, то побачите червоний екран з помилкою, яка зазначає: “this.handleClick is not a function”.

Примітка

Атрибут onClick DOM-елемента <button> має для React особливе значення, оскільки це вбудований компонент. Для звичайних компонентів, як Square, найменування пропсів може бути довільним. Ми можемо назвати проп onClick компонента Square чи метод handleClick компонента Board будь-яким іменем, і код працюватиме так само. У React загальноприйнятим вважається використання on[Event] імен для пропсів, що представляють події, і handle[Event] для методів, що цю подію обробляють.

Якщо ми натиснемо клітинку Square, то отримаємо помилку, оскільки ми ще не визначили handleClick. Додамо цей метод до класу Board:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = '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 і інформують його при кожному натиску. Згідно з термінологією React, компоненти Square є контрольованими компонентами, оскільки Board тепер має повний контроль над ними.

Зверніть увагу, як усередині handleClick ми використали метод .slice(), щоб створити копію масиву squares, яку ми змінюватимемо замість уже існуючого масиву. Ми пояснимо, навіщо ми створили цю копію у наступному розділі.

Чому незмінність важлива?

У попередньому прикладі коду ми запропонували використати метод .slice() для створення копії масиву squares, щоб у подальшому модифікувати цю копію замість оригінального масиву. Тепер ми обговоримо, що таке незмінність, і чому важливо її вивчати.

Загалом існує два загальних підходи до зміни данних. Перший підхід — змінити дані напряму, встановлюючи нові значення. Другий підхід — замінити дані копією з уже включеними змінами.

Пряма зміна даних

var player = {score: 1, name: 'Андрій'};
player.score = 2;
// Тепер черга гравця {score: 2, name: 'Андрій'}

Опосередкована зміна даних

var player = {score: 1, name: 'Андрій'};

var newPlayer = Object.assign({}, player, {score: 2});
// У цьому прикладі player залишився незмінним, а newPlayer набув значення {score: 2, name: 'Андрій'}

// Або якщо ви використовуєте синтаксис розширення, ви можете написати:
// var newPlayer = {...player, score: 2};

Кінцевий результат залишиться таким самим, але без прямої зміни базових даних. Нижче розглянуті переваги даного способу.

Спрощення складних властивостей

Незмінність робить реалізацію складних властивостей набагато простішою. Пізніше у цьому посібнику ми втілимо властивість “подорожі у часі”, що дозволить нам переглянути історію гри у хрестики-нулики і “повернутися” до попередніх ходів. Дана властивість не обмежена іграми, можливість відмінити і повторити певні дії знову є необхідною умовою багатьох додатків. Уникаючи прямої зміни даних, ми можемо звертатись до попередніх версій історії гри і повторно використовувати їх.

Виявлення змін

Виявити зміни у змінних об’єктах досить важко, оскільки вони модифіковані напряму. У цьому випадку нам доведеться порівнювати змінений об’єкт як з його попередніми копіями, так і з усім деревом об’єктів.

Виявити зміни в незмінних об’єктах набагато легше. Якщо згаданий незмінний об’єкт відрізняється від попереднього, тоді він змінився.

Визначення повторного рендерингу у React

Головною перевагою незмінності у React є те, що вона допомагає створити чисті компоненти. Незмінні дані дозволяють легко виявити наявність змін, що дозволяє встановити необхідність повторного рендерингу.

Більше про shouldComponentUpdate() і як створювати чисті компоненти ви можете дізнатись прочитавши розділ Оптимізація продуктивності.

Функціональні компоненти

Змінимо Square на функціональний компонент.

У React функціональні компоненти — це спрощений спосіб написання компонентів, що складаються тільки з render-метода і не мають власного стану. Замість визначення класу, який поширює React.Component, ми можемо створити функцію, яка приймає пропси і повертає те, що треба відрендерити. Функціональні компоненти коротші у написанні, і більшість компонентів можна оформити таким чином.

Замінимо клас Square на наступну функцію:

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

Ми замінили this.props на props обидва рази.

Проглянути повний код цього кроку

Примітка

Коли ми перетворили Square у функціональний компонент, ми також змінили onClick={() => this.props.onClick()} на коротший onClick={props.onClick} (зверніть увагу на відсутність круглих дужок з обох сторін).

Встановлюємо почерговість

Тепер нам треба виправити один очевидний дефект у нашій грі — на полі не можна поставити “O”.

За замовчуванням встановимо перший хід за “X”. Зробити це можливо модифікувавши початковий стан у конструкторі компонента Board:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

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

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

Після цих змін позначки “X” та “O” матимуть можливість чергуватися. Спробуйте!

Давайте також змінимо текст “статусу” у методі render компонента Board таким чином, щоб він відображав який гравець ходить наступним:

  render() {
    const status = 'Next player: ' + (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 = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

Проглянути повний код цього кроку

Визначення переможця

Тепер, коли ми показуємо, який гравець ходить наступним, ми також маємо показати переможця у кінці гри і зробити наступні ходи неможливими. Скопіюйте цю допоміжну функцію і вставте її в кінці файлу:

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

Маючи масив з 9 клітинок, ця функція перевірить на наявність переможця і поверне 'X', 'O', або null.

Викличемо calculateWinner(squares) у методі render компонента Board, щоб перевірити чи гравець виграв. Якщо гравець виграв, ми можемо відобразити текст: “Переможець: X” або “Переможець: O”. Замінимо значення status у render-методі компонента Board наступним кодом:

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

    return (
      // решта не змінилась

Тепер ми можемо змінити метод handleClick у Board для завершення функції та ігнорування натиску, якщо хтось вже переміг, або поле повністю заповнене:

  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

Проглянути повний код цього кроку

Вітаємо! Тепер ви маєте повністю робочу гру у хрестики-нулики. І ви щойно освоїли основи React. Схоже, справжній переможець тут це ви.

Додаємо подорож у часі

Наостанок давайте створимо здатність “подорожувати у часі”, щоб мати змогу повернутися до попередніх ходів у грі.

Зберігаємо історію ходів

Якби ми змінили масив squares, реалізувати подорожі у часі було б дуже важко.

Утім, ми скористалися методом slice() для створення нової копії squares після кожного ходу і залишили оригінальний масив незмінним. Це дозволить нам зберегти усі попередні версії масиву squares і переміщатися між уже зробленими ходами.

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

history = [
  // До першого ходу
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // Після першого ходу
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // Після другого ходу
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]

Залишилось вирішити, який компонент відповідатиме за стан history.

Піднімаємо стан, знову

Нам потрібно, щоб вищепоставлений компонент Game відображав список попередніх ходів. Для цього йому потрібно мати доступ до history, тож логічним буде помістити стан history у компоненті Game.

Розміщення history у компоненті Game дозволяє нам видалити стан squares з його дочірнього компонента Board. Так само, як ми “підняли стан” з компонента Square у компонент Board, тепер ми піднімемо його з Board до вищепоставленого Game. Це надасть компоненту Game повний контроль над даними Board і дозволить вказувати, коли рендерити попередні ходи з history.

Спершу встановимо початковий стан у конструкторі компонента Game:

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      xIsNext: true,
    };
  }

  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

Після цього нам потрібно, щоб компонент Board отримував пропси squares та onClick з компонента Game. Оскільки тепер у Board ми маємо єдиний обробник події натиску для усіх Squares, нам досить передати розташування кожного Square в обробник onClick, щоб вказати яку клітинку було натиснуто. Щоб змінити компонент Board нам необхідно зробити наступні кроки:

  • Видалити constructor з Board.
  • Замінити this.state.squares[i] на this.props.squares[i] у renderSquare компонента Board.
  • Замінити this.handleClick(i) на this.props.onClick(i) у renderSquare компонента Board.

Компонент Board тепер має виглядати наступним чином:

class Board extends React.Component {
  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (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: ' + 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>{/* TODO */}</ol>
        </div>
      </div>
    );
  }

Оскільки компонент Game тепер рендерить статус гри, ми можемо видалити відповідний код з методу render компонента Board. Після цих змін функція render у Board має виглядати так:

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }

Наостанок нам потрібно перенести метод handleClick з компонента Board у компонент Game. Нам також потрібно змінити handleClick, оскільки стан компонента Game має іншу структуру. Усередині методу handleClick компонента Game додамо новий запис до history.

  handleClick(i) {
    const history = this.state.history;
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares,
      }]),
      xIsNext: !this.state.xIsNext,
    });
  }

Примітка

На відміну від більш знайомого методу push(), метод concat() не змінює оригінального масиву, тому ми й надаємо йому перевагу.

На даний момент компоненту Board потрібні тільки renderSquare та render методи. Стан гри та метод handleClick мають знаходитись у компоненті Game.

Проглянути повний код цього кроку

Показуємо попередні ходи

Оскільки ми записуємо історію гри у хрестики-нулики, то тепер, у вигляді списку попередніх ходів, ми можемо показати її гравцю.

Як ми вже довідались раніше, елементи React — це першокласні об’єкти JavaScript, які ми можемо передавати всередині нашого додатку. Щоб відрендерити численні об’єкти у React, ми можемо скористатися масивом React-елементів.

У JavaScript масиви мають метод map(), який зазвичай використовється для перетворення данних, наприклад:

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

Використовуючи метод map, ми можемо відтворити історію ходів у вигляді React-елементів, репрезентованих кнопками на екрані, і відобразити їх у вигляді списку, щоб мати змогу “перескочити” до попередніх ходів.

Тож давайте застосуємо map до history у методі render компонента Game:

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        '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(). На даний момент ми маємо бачити список ходів, зроблених під час гри, та попередження в інструментах розробника, що перекладається наступним чином:

Попередження: Кожен дочірній компонент у масиві або ітераторі повинен мати унікальний проп “key”. Перевірте метод render у “Game”.

Давайте обговоримо, що значить це попередження.

Обираємо ключ

Коли ми рендеримо список, React зберігає певну інформацію про кожен відрендерений пункт списку. Якщо ми оновлюємо список, React має визначити, що у ньому змінилося. Ми могли б додати, видалити, пересунути або оновити список пунктів.

Уявимо зміни від

<li>Олег: 7 задач залишилось</li>
<li>Данило: 5 задач залишилося</li>

до

<li>Данило: 9 задач залишилось</li>
<li>Катерина: 8 задач залишилось</li>
<li>Олег: 5 задач залишилось</li>

На додачу до оновлених чисел, людина, що читатиме цей код, можливо, скаже, що ми поміняли Олега та Данила місцями, а між ними додали Катерину. Але React — це комп’ютерна програма, яка не знає нашого наміру. І саме через це нам потрібно визначити властивість key для кожного пункту у списку, щоб мати змогу розрізнити їх одне від одного. Одним з варіантів можуть бути рядки oleg, danylo, kateryna. Або якщо ми беремо дані з бази даних, то у якості ключів ми могли б використати ідентифікатори Олега, Данила та Катерини.

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

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

key — це особлива зарезервована властивість React (разом з ref, більш передовою особливістю). Коли елемент створено, React видобуває властивість key і зберігає її безпосередньо у поверненому елементі. Хоча key і виглядає як props, на нього не можна посилатися використовуючи this.props.key. React автоматично використовує key, щоб визначити який компонент оновити. Компонент не має доступу до key.

Ми наполегливо рекомендуємо призначати належні ключі при створенні динамічних списків. Якщо у вас не має відповідного ключа, вам варто розглянути можливість перебудови даних, щоб він у вас з’явився.

Якщо жодного ключа не зазначено, React видасть попередження і за замовчуванням використовуватиме індекси масиву у ролі ключів. Використання індексів масиву може бути проблематичним при реорганізації списку або доданні/видаленні пунктів. Явне створення key={i} змусить попередження зникнути, але матиме ті самі проблеми, що і використання індексів масиву, тому не рекомендується у більшості випадків.

Ключам не потрібно бути унікальними глобально, тільки між компонентами та їх родичами

Втілюємо подорож у часі

У історії гри в хрестики-нулики кожен попередній хід має унікальний, асоційований з ним ідентифікатор — порядковий номер ходу. Ходи мають чітку послідовність і ніколи не видаляються чи додаються у середині списку, тож ми безпечно можемо використовувати індекси у ролі ключів.

У методі render компонента Game ми можемо додати ключ <li key={move}> і відповідне попередження від React має зникнути:

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

Проглянути повний код цього кроку

Натиснення будь-якої з кнопок списку видасть помилку, оскільки метод jumpTo ще не визначено. Перед тим як створити цей метод, додамо stepNumber до стану компонента Game, щоб вказати який хід ми наразі розглядаємо.

Спершу додамо stepNumber: 0 до початкового стану у constructor компонента 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 у довіднику API.