В условиях изоляции дни долгие, поэтому я решил изучить ReactJs с нуля или почти.

Почти, потому что Javascript, даже если это не тот язык, который я знаю лучше всего, я все равно часто его использовал. Я также получил некоторые знания о VueJ.
Через 15 дней карантина вышла заявка. Посмотреть и клонировать можно здесь.

Я хотел бы поделиться с вами различными шагами, которые привели меня к созданию функционального приложения, написанного на ReactJs, с базой API на Symfony и платформе Api . Я также хотел бы показать, как новые функции платформы Api, такие как Vulcain и Mercure, могут быть естественным образом интегрированы с ReactJs.

Я старался быть постоянным в отношении времени, затрачиваемого на обучение и практику: 3 часа в день в течение 14 дней.

День 1 - Чтение документации и практика

В первый день я прочитал очень точную документацию ReactJs с официального сайта.

В частности, я сосредоточился на основных концепциях: Jsx, Компоненты, Свойства, Состояние, Условия, Списки и Формы. Я играл с CodePen, пытаясь переделать различные примеры документации.

День 2 - Чтение документации и практика

На второй день продолжил чтение документации. Я переключился на продвинутые руководства и узнал, что такое фрагменты. Я работал над пониманием контекста, статической проверки, типов свойств и хуков. Продолжая практиковаться с CodePen на протяжении всего процесса.

День 3 - Установка и настройка среды (Symfony, Api Platform, Yarn, React)

На третий день мне не терпелось начать что-то большее, чем примеры CodePen. Итак, я начал настраивать среду для разработки моего первого приложения ReactJs.

Я скачал последний дистрибутив Api Platform. У вас должен быть Docker, если он еще не установлен на вашем компьютере, вы можете установить его здесь.
Построенная на основе Symfony, платформа API позволяет вам создавать расширенные, API гипермедиа на базе JSON-LD.

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

Но пойдем шаг за шагом.

Я запускаю контейнеры Docker с помощью:

$ docker-compose up -d

Затем я создал сущности.

/**  
* @ApiResource()  
* ...  
*/ 
class Book 
{ 
   ...
   ...
}
/**  
* @ApiResource(
*     ...
* )  
* ...  
*/
class MediaObject()
{ 
   ...
   ...
}

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

Я устанавливаю светильники с помощью hautelook alice:

$ composer require - dev hautelook / alice-bundle

#src/fixtures/book.yaml
App\Entity\MediaObject:
    madia_object_1:
        filePath: 'cover1.jpg'
    madia_object_2:
        filePath: 'cover2.png'
    madia_object_3:
        filePath: 'cover3.png'
    madia_object_4:
        filePath: 'cover4.png'
    madia_object_5:
        filePath: 'cover5.png'
    madia_object_6:
        filePath: 'cover6.png'
    madia_object_7:
        filePath: 'cover7.png'
    madia_object_8:
        filePath: 'cover8.png'
    madia_object_9:
        filePath: 'cover9.png'
    madia_object_10:
        filePath: 'cover10.png'

App\Entity\Book:
    book_{1..10}:
        title: <sentence(4, true)>
        description: <text()>
        author: <name()>
        isbn: <isbn13()>
        stock: <numberBetween(1, 100)>
        price: <randomFloat(2, 2, 20)>
        image: '@madia_object_*'

Я установил Webpack Encore:

$ composer требуется symfony / webpack-encore-bundle

Я установил необходимые модули node. Обратите внимание, что на вашем компьютере уже должен быть установлен npm.

$ npm установить пряжу

$ yarn init

Webpack:

$ пряжа добавить @ symfony / webpack-encore

$ yarn добавить веб-пакет-уведомитель

Реагировать:

$ пряжа добавить реагировать реагировать-дом

$ пряжа добавить добавить core-js @ 3 @ babel / runtime-corejs3

SASS:

пряжа добавить [email protected] node-sass

Затем я настроил свой webpack.config.js, чтобы включить реакцию.

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .addEntry('app', './assets/js/app.js')
    .splitEntryChunks()
    .enableSingleRuntimeChunk()
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    .enableVersioning(Encore.isProduction())
    // enables @babel/preset-env polyfills
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = 3;
    })
    // enables Sass/SCSS support
    .enableSassLoader()
    // enables React 
    .enableReactPreset()
;
module.exports = Encore.getWebpackConfig();

Не забудьте добавить запись в файл base.html.twig :

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}

Теперь все для того, чтобы начать писать свое первое приложение на React. Точка входа (определенная в webpack.config.js) - assets / js / app.js.

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

//src/Controller/HomeController.php
<?php 
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class HomeController extends AbstractController
{
    /**
     *
     * @Route("/bookstore", name="bookstore_home")
     * @return Response
     */
    public function __invoke(): Response
    {
        return $this->render('index.html.twig');
    }
}
//templates/index.html.twig
{% extends 'base.html.twig' %}

{% block body %}
    <div id="root"></div>
{% endblock %}

А вот и приложение «Hello world».

//assets/js/app.js
import React from 'react';
import ReactDOM from 'react-dom';

require('../css/app.scss');

class App extends React.Component {
  render() {
    return (
      <div>Hello world</div>
    );
  }
}

ReactDOM.render(
  <App text='' />,
  document.getElementById('root')
);

Чтобы создать активы, я запускаю наблюдатель с помощью:

$ yarn encore dev - смотреть

Теперь, когда я захожу в http: // localhost: 8443 / bookstore, я вижу свой Hello World!

День 4 - Основные концепции

На четвертый день я был очень мотивирован применить концепции, которые видел двумя днями ранее.

Цель дня: поле поиска, где вы можете выполнить поиск по названию или автору и получить итоговый список книг.

Обратите внимание, что я использовал bootstrap для этой демонстрации, чтобы установить его с помощью yarn, вы можете использовать следующую команду:

$ yarn add bootstrap –dev

И импортируйте загрузочный scss:

//assets/css/app.scss
@import "~bootstrap/scss/bootstrap";

У меня будет четыре компонента: SearchInput текстовый ввод, где я могу выполнять поиск, BookList список составленных книг из серии BookListItem, представляющей каждый элемент списка. И, наконец, HomePage, собирающий компоненты.

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

//assets/js/components/Book/SearchInput.js;
import React from "react";
export default class SearchInput extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      text: '',
    };
    this.handleChange = this.handleChange.bind(this);
  }
  handleChange (event) {
    if (event.target.value.length === 0) {
      this.props.onEmptyInput(1);
    }
    if (event.target.value.length < 3) {
      return;
    }
    	
    this.setState({text: event.target.value});
    fetch('/books/search/' + event.target.value)
      .then(response => response.json())
      .then(data => {
        this.props.onTextChange(data['searchResult'],  this.state.text);
      });
  }
  render () {
    const text = this.props.text;
    return (
      <div className="input-group input-group-sm mb-3">
        <div className="input-group-prepend">
          <span className="input-group-text" id="inputGroup-sizing-sm">
            <i className="fas fa-search" />
          </span>
        </div>
        <input type="text"
               value={text}
               onChange={this.handleChange}
               className="form-control"
               aria-label="Small"
               aria-describedby="inputGroup-sizing-sm"
        />
      </div>
    );
  }
}

И список обновляется, как только мы получаем данные

//assets/js/components/Book/Booklist.js;
import React from "react";
import BookListItem from "./BookListItem";

export default class BookList extends React.Component {
  constructor (props) {
    super(props);
  }
  render () {
    const bookItems = this.props.books.map((book) =>
      <BookListItem
        key={book.id}
        id={book.id}
        title={book.title}
        author={book.author}
        commentsNumber={book.comments.length}
        image={book.image}
      />
    );
    return (
        <div className="col-sm-8 mx-auto">
          <ul className="list-group">
            {bookItems}
          </ul>
        </div>
    );
  }
}

BookList перебирает результат и отображает элементы. BookListItem получить изображение и отобразить данные книги, полученные Props.

//assets/js/components/Book/BookListItem.js;
import React from "react";

export default class BookListItem extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      isHovered: false,
      imagePath: ''
    };
    this.handleHover = this.handleHover.bind(this);
  }

  handleHover () {
    this.setState(prevState => ({
      isHovered: !prevState.isHovered
    }));
  }

  componentDidMount () {
    fetch(this.props.image).then(response => response.json())
      .then(data => {
          this.setState({imagePath: data.contentUrl});
        }
      });
  }

  render () {
    const isActive = this.state.isHovered ? "active" : "";
    return (
      <li className={`d-flex justify-content-between align-items-center list-group-item
	${isActive}`} onMouseEnter={this.handleHover}
          onMouseLeave={this.handleHover}>
          {this.props.title} <br/>
          <small>by {this.props.author}</small> <br/>
          <small>{this.props.commentsNumber} avis</small>
        <div>
          <img className="image-item" src={this.state.imagePath} alt={this.state.imagePath}/>
        </div>
      </li>
    );
  }
}

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

//assets/js/components/Page/HomePage.js
import React from "react";
import BookList from "../Book/BookList";
import SearchInput from "../Search/SearchInput";

export default class HomePage extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      isLoading: true,
      books: [],
      totalBooks: 0
    };

    this.updateList = this.updateList.bind(this);
    this.searching = this.searching.bind(this);
    this.fetchBooks = this.fetchBooks.bind(this);
  }

  async fetchBooks (page) {
    this.setState({isLoading: true});

    await fetch('/books?page=' + page)
      .then(response => response.json())
      .then(data => {
        this.setState({
          isLoading: false,
          books: data['hydra:member'],
          totalBooks: data['hydra:totalItems']
        });
      });
  }

  async componentDidMount () {
    this.fetchBooks(1);
  }

  updateList (books, text) {
    this.setState(
      {
        isLoading: false,
        text: text,
        books: books,
      }
    );
  }

  searching () {
    this.setState({isLoading: true, books: [], totalBooks: 0});
  }

  render () {
    return (
      <main role="main">
        <div className="jumbotron">
          <div className="col-sm-8 mx-auto">
            <SearchInput onTextChange={this.updateList} onEmptyInput={this.fetchBooks} onSearching={this.searching}/>
          </div>
          <BookList books={this.state.books}/>
        </div>
      </main>
    );
  }
}

На стороне бэкэнда мне нужно создать DTO и DataProvider для получения данных, предоставляемых API:

//src/DataProvider/SearchDataProvider
<?php

declare(strict_types=1);

namespace App\DataProvider;

use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use App\Dto\Search;
use App\Repository\BookRepository;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
final class SearchDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
{
    /** @var BookRepository */
    private $bookRepository;

    public function __construct(BookRepository $bookRepository)
    {
        $this->bookRepository = $bookRepository;
    }
    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
    {
        return Search::class === $resourceClass;
    }
 
    public function getItem(string $resourceClass, $searchText, string $operationName = null, array $context = [])
    {
        $books = $this->bookRepository->searchBookByTitleOrAuthorName($searchText);

        return new Search($searchText, $books);
    }
}

И связанный DTO:

<?php

declare(strict_types=1);

namespace App\Dto;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ApiResource(
 *      itemOperations={
 *          "get"={
 *              "method"="GET",
 *              "path"="/books/search/{id}",
 *              "swagger_context"={
 *                  "tags"={"Book"}
 *              }
 *          }
 *      },
 *      collectionOperations={}
 *  )
 */
final class Search
{
    /**
     * @var string
     *
     * @ApiProperty(identifier=true)
     *
     * @Assert\NotBlank()
     *
     * @Groups({"read"})
     */
    private $text;

    /**
     * @var array|null
     *
     * @Groups({"read"})
     */
    private $searchResult = [];

    public function __construct(string $text, ?array $searchResult)
    {
        $this->text = $text;
        $this->searchResult = $searchResult;
    }

    public function getText(): string
    {
        return $this->text;
    }

    public function getSearchResult(): ?array
    {
        return $this->searchResult;
    }
}

Теперь мы можем получить список книг при перезагрузке страницы:

И мы можем искать книги по автору или названию:

Сегодня я практиковался на реквизитах React со списками и условными инструкциями, а также с управлением внутренним состоянием компонента. Я также применил концепцию подъема состояния и использовал формы.

День 5 - Основные концепции

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

//assets/js/components/BookPagination.js
import React from 'react';

export default class BookPagination extends React.Component {
  constructor (props) {
    super(props);

    this.perPage = 10;

    this.state = {
      currentPage: 1,
      totalPages: 0
    };

    this.changePage = this.changePage.bind(this);
    this.previousPage = this.previousPage.bind(this);
    this.nextPage = this.nextPage.bind(this);
  }

  previousPage () {
    this.changePage(this.state.currentPage - 1);
  }

  nextPage () {
    this.changePage(this.state.currentPage + 1);
  }

  changePage (page) {
    this.props.onChangePage(page);
    this.setState({currentPage: page});
  }

  render () {
    //previous button
    let previousButton = '';
    if (this.state.currentPage > 1 && this.props.totalBooks > 0) {
      previousButton = <li className="page-item">
        <a className="page-link" href="#" onClick={() => this.changePage(this.state.currentPage - 1)}>Previous</a>
      </li>
      ;
    }

    let totalPages = Math.ceil(this.props.totalBooks / this.perPage);

    //pages
    let pages = [];
    if (this.props.totalBooks > 0) {
      for (let i = 1; i < totalPages; i++) {
        let classActive ='';
        if (i === this.state.currentPage) {
          classActive = 'active';
        }

        pages.push(
          <li className={`page-item ${classActive}`} key={i}>
            <a className="page-link" href="#" onClick={() => this.changePage(i)}>{i}</a>
          </li>
        );
      }
    }

    //next button
    let nextButton = '';
    if (this.state.currentPage < totalPages && this.state.currentPage > 0) {
      nextButton = <li className="page-item">
        <a className="page-link" href="#" onClick={() => this.changePage(this.state.currentPage + 1)}>Next</a>
      </li>
      ;
    }

    return (
        <ul className="pagination justify-content-center">
          {previousButton}
          {pages}
          {nextButton}
        </ul>
    );
  }
}

Каждый раз, когда пользователь нажимает на страницу, вызывается обратный вызов свойств.

this.props.onChangePage (страница);

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

Домашняя страница, содержащая BookPagination и Loader, затем изменяется следующим образом:

//assets/js/components/HomePage.js
...
...
render () {
  let content = <Loader />;
  if (this.state.isLoading !== true) {
    content = <BookList books={this.state.books}/>;
  }

  return (
    <main role="main">
      <div className="jumbotron">
        <div className="col-sm-8 mx-auto">
          <SearchInput onTextChange={this.updateList} onEmptyInput={this.fetchBooks} onSearching={this.searching}/>
        </div>
        {content}
        <div className="col-sm-8 mx-auto">
          <BookPagination totalBooks={this.state.totalBooks} onChangePage={this.fetchBooks}/>
        </div>
      </div>
    </main>
  );
}
...
...

И Загрузчик:

//assets/js/components/Loader.js
import React from 'react';

export default class Loader extends React.Component {
  constructor (props) {
    super(props);
  }

  render () {
    return (
      <div className="col-sm-8 mx-auto">
        <div className="d-flex align-items-center">
          <strong>Loading...</strong>
          <div className="spinner-border ml-auto" role="status" aria-hidden="true"/>
        </div>
      </div>
    );
  }
}

День 6 - Маршрутизатор

Цель дня - просмотреть подробности книги на специальной странице.

Для этого мне пришлось создать компонент, представляющий страницу книги. Компонент BookPage. Для отображения этой страницы нам понадобится маршрутизатор, потому что пользователь должен иметь возможность щелкнуть один из элементов в списке книг (BookListItem) и быть перенаправлен на страницу книги.

Устанавливаю библиотеку реактивного маршрутизатора по официальной документации:

$ пряжа добавить реагировать-маршрутизатор реагировать-маршрутизатор-дом

Компонент BookPage выглядит так

//assets/js/components/BookPage.js
import React from "react";
import Book from "../Book/Book";
import Loader from "../Common/Loader";

export default class BookPage extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      book: '',
      isLoaded: false,
      imagePath: '',
      comments: []
    };
  }

  componentDidMount () {
    //fetch book
    const bookJSON = fetch('/books/' + this.props.match.params.id);
    
    this.setState({book: bookJSON, isLoaded: true});

    //fetch image
    fetch(bookJSON.image)
      .then(response => response.json())
      .then(data => {
        this.setState({imagePath: data.contentUrl});
      });
  }

  render () {
    const isLoaded = this.state.isLoaded;

    let imagePath = '';
    if (this.state.imagePath !== '') {
      imagePath = this.state.imagePath;
    }

    if (isLoaded) {
      return (
        <main role="main">
          <Book book={this.state.book} imagePath={imagePath} />
        </main>
      );
    }

    return (
      <main role="main">
        <div className="jumbotron">
          <Loader/>
        </div>
      </main>);
  }
}

Обратите внимание, что реквизиты this.props.match.parms.id - это реквизиты, предоставляемые компоненту маршрутизатором и соответствующие значению параметра маршрута.

/ bookstore / book / 1

Я определил маршрутизатор в верхней части приложения, то есть в app.js.

//assets/js/app.js
import React from 'react';
import ReactDOM from 'react-dom';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";

import BookPage from "./components/Page/BookPage";
import HomePage from "./components/Page/HomePage";

...
...

class App extends React.Component {
  render () {
    return (
        <Router>
            <NavBar />
            <Switch>
              <Route exact path="/bookstore">
                <HomePage />
              </Route>
              <Route
                 path="/book/:id"
                 render={(props) => <BookPage {...props} />}
              />
          </Switch>
        </Router>
    );
  }
}

ReactDOM.render(
  <App text='' />,
  document.getElementById('root')
);

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

‹Маршрут
path =” / book /: id ”
render = {(props) =› ‹BookPage {… props} /›}
/ ›

И я также добавляю NavBar, чтобы можно было легко вернуться в Дом.

import React from 'react';
import {Link} from "react-router-dom";

export default class NavBar extends React.Component {
  constructor (props) {
    super(props);
  }

  render () {
    return(
      <nav className="navbar navbar-expand-lg navbar-light bg-light rounded">
        <div className="collapse navbar-collapse">
          <ul className="navbar-nav text-right">
            <li className="nav-item">
              <Link to="/bookstore">
                <i className="fas fa-home"/>Home
              </Link>
            </li>
          </ul>
        </div>
      </nav>
    );
  }
}

День 7 и День 8 - Управление состоянием с Redux

Теперь, когда я могу отображать страницу книги, я хотел бы добавить книгу в корзину.
Статус корзины должен быть постоянным, чтобы ее можно было использовать в любом месте приложения. Для этого мне нужен менеджер состояний, и, по логике вещей, я выбрал Redux.

Я знаю, что в идеале нам нужно постоянно сохранять данные, например, в локальном хранилище. Но для этой демонстрации хранилища redux вполне достаточно.

После быстрого прочтения документации я установил его:

$ yarn добавить redux react-redux - сохранить

На практическом уровне мне нужно действие тележки и редуктор тележки, первое указывает действие, которое нужно выполнить, а второе фактически выполняет его:

Сначала я сосредотачиваюсь на одном действии: Добавить книгу в корзину.

//assets/js/actions/cart.js
export const addToCart = book => ({
  type: 'ADD_TO_CART',
  book: book,
});

Если книга уже присутствует в корзине для покупок, когда пользователь добавляет ее, я изменяю только количество, не добавляя ее снова физически.
Я также предоставляю свойство «total», которое указывает количество элементов, помещенных в корзину. Каждый раз, когда пользователь вставляет новую книгу, свойство увеличивается. Вот как выглядит мой редуктор:

//assets/js/reducers/cart.js
const initialState = {
  books: [],
  total: 0,
};
const cartItems = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD_TO_CART':
      if (typeof action.book.quantity === 'undefined') {
        action.book.quantity = 1;
      }

      state.books.forEach(function (book, index, books) {
        if (book.id === action.book.id) {
          action.book.quantity = book.quantity + 1;
          books.splice(index, 1);
        }
      });

      return {
        ...state,
        books: [...state.books, action.book],
        total: state.total + 1,
      };
   
    default:
      return state;
  }
};

export default cartItems;

Теперь нам нужно подключить компонент к магазину.
Для этого вам нужно сделать две вещи: первая - передать хранилище всем компонентам приложения. Для этого я использую Provider в моем app.js:

//assets/js/app.js
import React from 'react';
...
...
import { Provider } from 'react-redux';
import { createStore } from 'redux';
...
import cartItems from './reducers/cart';

...
class App extends React.Component {
  render () {
    const store = createStore(cartItems);

    return (
      <Provider store={store}>
        <Router>
          <NavBar />
          <Switch>
              ...
              ...
          </Switch>
        </Router>
      </Provider>
    );
  }
}

ReactDOM.render(
  <App text='' />,
  document.getElementById('root')
);

Во-вторых, необходимо действительно подключить компонент к хранилищу с помощью функции connect () Redux Api.
Представим, что у нас есть кнопка, с помощью которой пользователь может добавить текущую книгу в корзину. Компонент AddToCartButton:

//assets/js/components/AddToCartButton.js
import React from 'react';
import { connect } from 'react-redux';
import { addToCart } from '../../actions/cart';

class AddToCartButton extends React.Component {
  constructor (props) {
    super(props);

    this.addBookToCart = this.addBookToCart.bind(this);

    this.state = {
      confirmation: false
    };
  }

  addBookToCart () {
    //mapDispatchToProps
    this.props.addItemToCart(this.props.book);

    this.setState({ confirmation: true });
    setTimeout(() => {
      this.setState({ confirmation: false });
    }, 3000);
  }


  render () {
    return (
      <div>
         <button type="button" className="btn btn-primary btn-lg btn-block add-cart-btn" onClick={this.addBookToCart}>
          <i className="fas fa-shopping-cart"/>&nbsp;Add to cart
        </button>
        <Confirmation confirmation={this.state.confirmation}/>;
      </div>
    );
  }
}

function Confirmation (props) {
  if (props.confirmation === true) {
    return (
      <div className="alert alert-success add-cart-button-confirmation" role="alert">
        Item added to cart
      </div>
    );
  }

  return (<div/>);
}


const mapDispatchToProps = {
  addItemToCart: addToCart
};

export default connect({}, mapDispatchToProps)(AddToCartButton);

АргументmapDispatchToProp имеет дело с функцией отправки Store. Это означает, что когда в моем компоненте я вызываю addItemToCart, то отправляется действие AddToCart.
Теперь каждый раз, когда пользователь нажимает кнопку, состояние изменяется.

Нам просто нужно добавить AddToCartButton в компонент Book:

//assets/js/components/Book.js
import React from "react";
import BookImage from "./BookImage";
import AddToCartButton from "../Cart/AddToCartButton";

export default class Book extends React.Component {
  ...
  ...

  render () {
    let book = this.props.book;

    return (
      ...
      ...
      
      <div className="row">
         <div className="col-sm-2">
            <div className="row add-cart-button">
               <AddToCartButton book={book} />
            </div>
         </div>
      </div>
      
      ...
      ...
    );
  }
}

Теперь легко использовать состояние где угодно. Например, если в NavBar мы хотим показать количество товаров в корзине:

//assets/js/components/Navbar.js
import React from 'react';
import {Link} from "react-router-dom";
import { connect } from 'react-redux';

class NavBar extends React.Component {
  ...
  ...
  
  render () {
    return(
      <nav className="navbar navbar-expand-lg navbar-light bg-light rounded">
        <div className="collapse navbar-collapse">
          <ul className="navbar-nav text-right">
            <li className="nav-item">
              <Link to="/bookstore"><i className="fas fa-home"/>&nbsp;Home</Link>
            </li>
            <li className="nav-item">
                <i className="fas fa-shopping-cart"/>                                       {this.props.cart.total}
            </li>
          </ul>
        </div>
      
      </nav>
    );
  }
}

function mapStateToProps (state) {
  return { cart: state };
}

export default connect(mapStateToProps, {})(NavBar);

mapStateToProps позволяет подписаться на обновления магазина Redux. Это означает, что каждый раз при обновлении магазина обновляется и свойство cart. Таким образом, объект cart является частью внутреннего состояния компонента.

День 9 - Улучшите свое приложение с помощью HTTP / 2 с помощью Vulcain

Как описано в официальной документации

Vulcain - это совершенно новый протокол, использующий HTTP / 2 Server Push для создания быстрых и идиоматических клиентских REST API.
Создан для устранения узких мест производительности, влияющих на веб-API: избыточная выборка, недостаточная выборка, проблема n + 1.

HTTP-заголовок Preload, представленный Vulcain, может использоваться, чтобы попросить сервер немедленно отправить ресурсы, связанные с запрошенным, с помощью HTTP / 2 Server Push.

Посмотрим, как это реализовать в нашем приложении.

Представьте, что мы хотим загрузить комментарии, которые пользователи оставили к книге.

На стороне сервера нам нужно только создать связь один ко многим между сущностью Book и сущностью Комментарий.

//src/Entity/Book.php
<?php 
declare(strict_types=1);

namespace App\Entity;

...
...
/**
 * @ApiResource(attributes={"pagination_items_per_page"=10}, mercure=true)
 *
 * @ORM\Entity(repositoryClass="App\Repository\BookRepository")
 */
final class Book
{
    ...
    ...
    /**
     * @ApiSubresource()
     *
     * @ORM\OneToMany(targetEntity="App\Entity\Comment",   mappedBy="book")
     */
     private $comments;
     
     ...
     ...
}

Обратите внимание, что свойство $comments - это ApiSubresource.

//src/Entity/Comment.php
<?php
declare(strict_types=1);
namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource(attributes={"order"={"createdAt": "DESC"}})
 * @ORM\Entity(repositoryClass="App\Repository\CommentRepository")
 */
final class Comment
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $text;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Book", inversedBy="comments")
     * @ORM\JoinColumn(nullable=false)
     */
    private $book;

    ...
    ...
}

После добавления некоторых приспособлений

#fixtures/book.yaml
...
...
App\Entity\Comment:
    comment_{1..200}:
        name: <name()>
        title: <sentence(4, true)>
        text: <text()>
        createdAt: <DateTime('now')>
        book: '@book*'

если мы запрашиваем ресурс:

https://localhost:8443/books/1

Тело ответа:

{
  "@context": "/contexts/Book",
  "@id": "/books/1",
  "@type": "Book",
  "image": "/media_objects/1",
  "id": 1,
  "title": "Recusandae asperiores accusamus nihil repellat.",
  "description": "Doloribus nisi placeat cumque est ducimus temporibus. Saepe architecto unde non dicta. Exercitationem aut porro sed magni cupiditate sit vitae.",
  "author": "Kaylee Will",
  "isbn": "9798643071181",
  "stock": 1,
  "price": 16.26,
  "comments": [
    "/comments/31",
    "/comments/49",
    "/comments/68",
    "/comments/138",
    "/comments/160"
  ]
}

Дистрибутив платформы Api, который я использую для этого сообщения, уже настроен для использования Vulcain.
На стороне React мы можем использовать HTTP-заголовок Preload, представленный Vulcain, чтобы попросить сервер немедленно отправить ресурсы, связанные с запрошенным, с помощью HTTP / 2 Server Push.

//assets/js/components/BookPage.js
import React from "react";
import Book from "../Book/Book";
import Loader from "../Common/Loader";
import CommentList from "../Comment/CommentList";

export default class BookPage extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      book: '',
      isLoaded: false,
      imagePath: '',
      comments: []
    };

    this.fetchJson = this.fetchJson.bind(this);
    this.updateComments = this.updateComments.bind(this);
  }

  async fetchJson (url, opts = {}) {
    const resp = await fetch(url, opts);
    return resp.json();
  }

  async componentDidMount () {
    
    //fetch book
    const bookJSON = await this.fetchJson('/books/' + this.props.match.params.id, {headers: {Preload: '/comments/*'}});
    this.setState({book: bookJSON, isLoaded: true});

    //fetch comments
    bookJSON.comments.forEach(async commentURL => {
      const comment = await this.fetchJson(commentURL);

      this.updateComments(comment);
    });

      ...
      ...
  }

  updateComments (comment) {
    let comments = this.state.comments;
    comments.push(comment);

    this.setState({comments: comments});
  }

  render () {
    const isLoaded = this.state.isLoaded;

    let imagePath = '';
    if (this.state.imagePath !== '') {
      imagePath = this.state.imagePath;
    }

    let comments = '';
    if (this.state.comments.length > 0) {
      comments = <CommentList comments={this.state.comments}/>;
    }
    if (isLoaded) {
      return (
        <main role="main">
          <Book book={this.state.book} imagePath={imagePath} />
          {comments}
        </main>
      );
    }

    return (
      <main role="main">
        <div className="jumbotron">
          <Loader/>
        </div>
      </main>);
  }
}

Благодаря мультиплексированию HTTP / 2 отправленные ответы будут отправляться параллельно.
В дополнение к /book/1 сервер Vulcain будет использовать HTTP / 2 Server Push для отправки связанных ресурсов комментариев.

Компонент CommentList будет обновлен при получении списка комментариев.

//assets/js/components/CommentList.js
import React from "react";
import CommentItem from "./CommentItem";

export default class CommentList extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      comments: []
    };
  }

  render () {
    return (
      <div className="jumbotron">
        <h2>Comments</h2>
        {
          this.props.comments.map((comment) =>
            <CommentItem
              key={comment.id}
              comment={comment}
            />
          )
        }
      </div>
    );
  }
}

День 11 - Улучшите свое приложение с помощью HTTP / 2 с помощью Mercure

Дистрибутив Api Platform уже настроен, даже для Mercure. Если вы не используете его, вы можете следовать официальной документации Symfony.

Mercure - это открытый протокол, разработанный с нуля для публикации обновлений с сервера для клиентов. Концентратор Mercure отправляет обновления всем подключенным клиентам, используя События, отправленные сервером (SSE).

Для этой демонстрации я просто добавляю параметр mercure к объекту Book.

//src/Entity/Book.php
/**
 * @ApiResource(mercure=true)
 *
 * @ORM\Entity(repositoryClass="App\Repository\BookRepository")
 */
class Book
{
    /**
     * @var int
     *
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;
...
...

Таким образом, платформа API будет отправлять обновления в центр Mercure каждый раз, когда Книга создается, обновляется или удаляется. Затем хаб информирует всех подписанных клиентов.

А подписаться на обновления в Javascript просто:

const u = new URL('https://localhost:1337/.well-known/mercure');
    u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);
const es = new EventSource(u);
console.log(es);
es.onmessage = e => {
      const book = JSON.parse(e.data);
      this.setState({stock: book.stock});
    };

В приведенном выше коде я подписался на центр Mercure для получения обновлений одной книги.

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

Таким образом я могу изменить компонент "Книга".

import React from "react";
...
...
export default class Book extends React.Component {
  constructor (props) {
    super(props);

    this.state = {
      stock: 0
    };
  }

  componentDidMount () {
    this.setState({stock: this.props.book.stock});

    const u = new URL('https://localhost:1337/.well-known/mercure');
    u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);

    const es = new EventSource(u);

    es.onmessage = e => {
      const book = JSON.parse(e.data);
      this.setState({stock: book.stock});
    };
  }

  render () {
    ...
    ...
    let addToCartButton = (this.state.stock > 0) ? <AddToCartButton book={book} /> : '';

    return (
      <div className="jumbotron">
              ...
              ...
             <div className="row book-detail">
                <StockInfo stock={this.state.stock}/>
              </div>
              <div className="row">
                <div className="col-sm-2">
                  <div className="row add-cart-button">
                    {addToCartButton}
                  </div>
                </div>
              </div>
             ...
             ...   
    );
  }
}

function StockInfo (props)
{
  if (props.stock > 0) {
    return <button type="button" className="btn btn-success">
      <i className="fas fa-check"/>&nbsp;
      In stock <span className="badge badge-light">{props.stock}</span>
      <span className="sr-only">stock</span>
    </button>;
  }

  return <button type="button" className="btn btn-danger">
    <i className="fas fa-check"/>&nbsp;
    Out of stock
  </button>;
}

День 12 - Модульные тесты с Jest

Для тестирования стороны javascript я выбрал Jest. Это проект с открытым исходным кодом, поддерживаемый Facebook, и он особенно хорошо подходит для тестирования кода React.

Документация React и Jest исчерпывающая, легкая для чтения «» полна советов и практических примеров. Я рекомендую их прочитать.

Во-первых, нам нужно установить некоторые зависимости через yarn:

$ пряжа добавить - шутка разработчика

$ пряжа add - dev jest babel-jest @ babel / preset-env @ babel / preset-react-test-renderer

$ пряжа добавить - dev @ babel / plugin-transform-runtime

Я также должен добавить одну строку в webpack.config.js, чтобы активировать preset response для компиляции JSX и других вещей вплоть до Javascript.

.enableReactPreset()

Чтобы протестировать свое приложение, я хотел начать тестирование простого компонента, поэтому я начал с BookList.
Как было сказано ранее, этот компонент заботится об отображении полученного набора данных в виде списка. Поэтому идея состоит в том, чтобы убедиться, что список отображается правильно.

//assets/js/tests/components/book/book-list.test.js
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import renderer from "react-test-renderer";
import { act } from "react-dom/test-utils";

import BookList from "../../../components/Book/BookList";

//mocking component used from BookList
jest.mock("../../../components/Book/BookListItem", () => {
  return function FakeItem (props) {
    return (
      <li id={props.id} key={props.id}>
        {props.title}
      </li>
    );
  };
});

let container = null;
beforeEach(() => {
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

const listBook = [
  {
    id: 1,
    title: "title 1",
    author: "author 1",
    comments: ['comment', 'comment'],
    image: '/media_objects/1',
  },
  {
    id: 2,
    title: "title 2",
    author: "author 2",
    comments: ['comment', 'comment'],
    image: '/media_objects/1',
  }
];

it("should show 2 item", () => {
  act(() => {
    render(
        <BookList books={listBook} />,
        container
    );
  });
  expect(container.querySelector("li").textContent).toBe('title 1');
  expect(container.querySelector("li:nth-child(2").textContent).toBe('title 2');
});

it('item render correctly', () => {
  const tree = renderer
    .create(<BookList books={listBook} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

Я создал имитацию BookListItem. Это позволяет нам исключить реальный компонент BookListItem из теста и сосредоточиться на реальном тестируемом компоненте, то есть BookList.

Я также создал заглушку bookList, представляющую данные, и передал ее компоненту, заключив его в метод act ().

А потом я проверил, действительно ли фейковые данные присутствуют в списке.

expect(container.querySelector("li").textContent).toBe('title 1');
expect(container.querySelector("li:nth-child(2").textContent).toBe('title 2');
});

Когда я бегу:

$ пряжа jest assets / js / tests / components / book / book-list.test.js

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

it('item render correctly', () => {
  const tree = renderer
    .create(<BookList books={listBook} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
})

В репозитории проекта вы найдете и другие тесты.

День 13 - Функциональные тесты с пантерой.

Сегодня я хочу написать сквозной тест для своего приложения.

Panther - удобная автономная библиотека для очистки веб-сайтов и выполнения сквозных тестов с использованием реальных браузеров.

Сначала я устанавливаю какой-то пакет:

$ composer req --dev tests
$ composer req --dev symfony/panther

В следующем тесте я смоделировал навигацию из дома в корзину через страницу книги.

<?php

namespace App\Tests\Functional\EndToEnd;

use Symfony\Component\Panther\PantherTestCase;

final class FromHomeToShoppingCartTest extends PantherTestCase
{
    public function testNavigateFromHomeToBookPage(): void
    {
        $client = static::createPantherClient();

        //go to homepage
        $crawler = $client->request('GET', '/bookstore');

        //find books list
        $container = $crawler->filter('.jumbotron');
        $ul = $container->filter('div')->eq(3)->filter('ul');
        //select first book          
        $li = $ul->filter('li')->eq(0);

        //select link
        $a = $li->filter('a');
        $link = $a->link();

        //click on the book item and go to the book page
        $crawler = $client->click($link);

        //waiting for loading data
        $client->waitFor('.book-detail');

        //find "add to cart" button
        $container = $crawler->filter('.jumbotron');
        $div = $container->filter('div')->eq(0);
        $addToCartButton = $div->filter('.add-cart-button');
        self::assertStringContainsString('Add to cart', $addToCartButton->getText());

        //click on the button
        $client->executeScript('document.querySelector(".add-cart-btn").click();');

        //find and click the shopping cart icon of navabar
        $navbar = $crawler->filter('.navbar');
        $items = $navbar->filter('div')->eq(0)->filter('ul');
        $cartItem = $items->filter('li')->eq(1)->filter('a');
        $cartItemLink = $cartItem->link();
        $client->click($cartItemLink);

        //asserts that we are on shopping cart page
        self::assertPageTitleContains('Welcome to the bookstore');
        self::assertStringContainsString('CHECKOUT', $crawler->filter('.jumbotron'));
    }
}

Я провожу свой тест:

$ bin / phpunit -c. тесты / Функциональные / EndToEnd / FromHomeToShoppingCartTest.php

День 14 - Некоторое улучшение

В последний день я хотел внести небольшие улучшения, используя React Context.

В компоненте Book, где реализован процесс отправки Mercure, URL-адрес для подписки на концентратор представляет собой статический код.

const u = new URL('https://localhost:1337/.well-known/mercure');
    u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);

const es = new EventSource(u);

es.onmessage = e => {
    const book = JSON.parse(e.data);
    this.setState({stock: book.stock});
};

Конечно, это плохая практика. Например, вы можете использовать переменную env file, чтобы определить URL-адрес, а затем передать его в свой код javascript.

#.env
MERCURE_SUBSCRIBE_URL=https://localhost:1337/.well-known/mercure
BOOK_API_URL=https://localhost:8443/

$ yarn add dotenv - сэкономить

//webpack.config.js
...
Encore
   ...
   ...
  .configureDefinePlugin(options => {
    var dotenv = require('dotenv');
    const env = dotenv.config();

    if (env.error) {
      throw env.error;
    }

   ... 
   ...
module.exports = Encore.getWebpackConfig();

Теперь я могу изменить компонент Book следующим образом:

const u = new URL(process.env.MERCURE_SUBSCRIBE_URL);
u.searchParams.append('topic', process.env.BOOK_API_URL + 'books/' + this.props.book.id);

const es = new EventSource(u);

es.onmessage = e => {
  const book = JSON.parse(e.data);
  this.setState({stock: book.stock});
};

И это работает, но сегодня я хочу попробовать использовать React Context, поэтому я могу попробовать передать это значение компоненту через Context. Начиная с app.js, я передам это значение компоненту Book.
Это еще более интересно, потому что мы должны сделать так, чтобы Магазин, Маршрутизатор и Контекст сосуществовали.

Итак, я собираюсь отредактировать app.js следующим образом:

import React from 'react';
import ReactDOM from 'react-dom';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import { Provider } from 'react-redux';
import { createStore } from 'redux';

import NavBar from './components/Common/NavBar';
import BookPage from "./components/Page/BookPage";
import HomePage from "./components/Page/HomePage";
import CartPage from "./components/Page/CartPage";
import cartItems from './reducers/cart';

require('../css/app.scss');
console.log('app.js');

export const UrlContext = React.createContext();

class App extends React.Component {
  render () {
    const store = createStore(cartItems);

    return (
      <Provider store={store}>
        <Router>
          <NavBar />
          <Switch>
              <Route exact path="/bookstore">
                <HomePage />
              </Route>
              <Route exact path="/bookstore/cart">
                <CartPage />
              </Route>
              <Route path="/bookstore/book/:id"
                 render={
                   (props) =>
                     <UrlContext.Provider value={{mercure: process.env.MERCURE_SUBSCRIBE_URL, api: process.env.BOOK_API_URL}}>
                      <BookPage {...props} />
                     </UrlContext.Provider>
                 }
              />
          </Switch>
        </Router>
      </Provider>
    );
  }
}

ReactDOM.render(
  <App text='' />,
  document.getElementById('root')
);

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

С другой стороны, я использовал Consumer для восстановления данных контекста в компонентах, которым они нужны.

...
...
import {UrlContext} from "../../app.js";
...
<UrlContext.Consumer>
  {value => <Book book={this.state.book} imagePath={imagePath} mercureUrl={value.mercure} apiUrl={value.api}/>}
</UrlContext.Consumer>

Выводы

Изучить React с нуля не так уж и сложно. Очевидно, что быть хорошим разработчиком ReactJs - это еще одна тема, потому что, как и все, для этого нужны усилия и практика. Но с чего-то надо начинать… И я надеюсь, что этот (длинный) пост поможет тем, кто хочет начать его использовать.

Как вы видели, его очень легко интегрировать с Symfony. Самый полный, надежный и гибкий фреймворк PHP.

Платформа API - наиболее очевидный выбор, если вы хотите предоставить доступ к API Rest с PHP. Вы можете использовать его потрясающие функции для разработки сложных и высокопроизводительных проектов, ориентированных на API.

Особая благодарность моему коллеге с france.tv, Antoine Buzaud, за рецензирование этого поста.