furry.cat 13 января 2020

Твоё первое SPA на React: основные концепции и разработка

Разбираемся в базовых концепциях SPA и пишем первое одностраничное приложение на React.js.
2

React.js – замечательный фреймворк для фронтенд-разработки. У него удобный интерфейс и большие возможности, которые лучше всего реализуются при создании одностраничных приложений. Сегодня мы запилим небольшое Single Page Application и параллельно разберёмся в некоторых принципах React.

MPA vs SPA

Если в 2020 году вы ещё не знаете, чем SPA отличается от обычных многостраничных сайтов, пора исправить недоразумение. Если знаете – пролистайте раздел и переходите к практике.

MPA. Возьмём среднестатистический сайт, например, воображаемый интернет-магазин плюшевых игрушек MimimiShop. Вы заходите на главную страницу mimimi-shop.ru и видите много-много карточек с мягкими мишками. Чтобы вы смогли их увидеть, браузер послал запрос на сервер и получил файл index.php.

Один мишка очень вам нравится, и вы по нему кликаете. Ссылка меняется на mimimi-shop.ru/teddy-bears/92, страница перезагружается, браузер получает другой файл – product.php. Здесь совершенно другая информация – мишка только один, зато подробно описан.

Схема работы классического многостраничного приложения
Схема работы классического многостраничного приложения

В классическом Multi Page Application каждый url – это отдельный запрос к серверу с получением нового шаблона.

SPA. Перепишем MimimiShop как SPA. Вы заходите на главную страницу mimimi-shop.ru, и браузер загружает всего один файл index.html и большой скрипт mimimi.spa.js. После загрузки скрипт выводит на страницу компонент каталога – множество карточек с мягкими игрушками. Когда вы кликаете на понравившегося мишку, компонент каталога реактивно заменяется на компонент одного товара. Страница при этом не перезагружается, всё происходит красиво и плавно.

Схема работы SPA – одностраничного приложения на React
Схема работы SPA – одностраничного приложения на React

В итоге браузер отдал только одну страницу – index.html.

Сложно ли сделать SPA?

Несложно. Если вы используете инструменты из экосистемы React, то большая часть рутинной работы уже сделана за вас. Сосредоточьтесь на логике приложения, а скучное обновление DOM-дерева оставьте библиотеке.

Что нужно знать? Чтобы разобраться в React, достаточно знать на базовом уровне JavaScript версии ES6. Если неуверены в скиллах, то чекните их в нашей статье Больше JS, чем React: как фреймворк использует возможности языка.

Основы работы React

DOM. Загружаясь на страницу, скрипт берёт под контроль DOM-элемент, который вы ему предоставили. Теперь внутри этого элемента безраздельно властвует React. Он может отслеживать действия пользователя и менять дерево элементов на любом уровне. Для этого используется быстрый виртуальный DOM. В этот элемент может выводиться любой компонент (или дерево компонентов) в зависимости от текущего состояния приложения. Могут меняться какие-то мелочи или даже весь контент целиком.

JSX. Привычной MVC-модели в React нет, так как в компоненте объединяется и представление (рендер), и логика. Это значит, что в компоненте, например, селекта, вы определяете и его внешний вид, и поведение. Для удобства используется специфический JSX-синтаксис (JavaScript XML). Он похож на HTML, но на самом деле это чистой воды JavaScript. Убедиться в этом и поиграться вы можете в онлайн-компиляторе Babel.

Компиляция JSX-кода в обычный JavaScript
Компиляция JSX-кода в обычный JavaScript

Мы не будем подробно разбираться в тонкостях JSX. Если вы с ним незнакомы, то загляните сначала в Подробное руководство по JSX в React, а потом продолжайте чтение.

Запуск проекта

Настройка webpack. Настраивать всю среду разработки с чистого листа было бы долго и скучно. Воспользуемся готовым решением – утилитой create-react-app. Она подготовит начальную структуру проекта, скачает необходимые зависимости и настроит webpack. Если вы плохо знакомы с принципами сборки проекта, загляните во вводное руководство по webpack (и перестаньте уже его бояться).

Откройте рабочую директорию, запустите терминал – начинаем работать.

Устанавливаем утилиту:

        npm i create-react-app
    

Создаём проект:

        npx create-react-app react-spa

// или

npm init react-app react-spa

    

В директории появилась новая папка react-spa. Заходим в папку, запускаем команду:

        npm start
    

Запустится сервер для локальной разработки, и в браузере откроется новая вкладка с текущим проектом.

Исходники. Все интересующие исходники лежат в папке src:

  • src/index.js – входная точка сборки. Здесь указывается корневой DOM-элемент, в который помещается главный компонент App, представляющий собой наше React-приложение.
  • src/App.js – это собственно код компонента App.

А другие файлы? Помимо этих файлов, в папке src лежат стили, тесты, несколько служебных скриптов и svg-файл логотипа – всё это нам сейчас неважно. Основные боевые действия будут разворачиваться внутри App.js.

В документации React подробно описано, что такое компонент и как с ним работать. Нам же сейчас достаточно понимать, что компонент – просто функция, которая принимает извне некоторые свойства (props), а возвращает разметку для рендера в формате JSX.

Первая страница

Наше SPA будет представлять собой портфолио React-разработчика.

Откройте файл src/App.js, удалите всё ненужное, и разместите следующий код:

src/App.js
        import React from 'react';
import './App.css';

import works from './works';

function App() {
    return (
        <div className='app'>
            <header className='header'>
                <div className='container'>
                    <div className='header-brand'>Иван Иванов</div>
                </div>
            </header>

            <main className='main'>
                <div className='about'>
                    <div className='about__bg'></div>
                    <div className='container'>
                        <h1 className='about__title'>
                            React-разработчик Иван Иванов
                        </h1>
                        <div className='about__description'>
                            <p>
                                Разрабатываю на самом крутом в мире фреймворке
                                <br />
                                самые крутые в мире SPA!
                            </p>
                            <p>
                                С удовольствием и вам что-нибудь разработаю ;)
                            </p>
                        </div>
                    </div>
                </div>

                <div className='portfolio'>
                    <div className='container'>
                        {works.map((work, index) => (
                            <a
                                href={work.link}
                                className='portfolio-item'
                                key={index}
                            >
                                <img
                                    className='portfolio-item__screenshot'
                                    src={work.screenshot}
                                    alt={work.title}
                                />
                                <div className='portfolio-item__title'>
                                    {work.title}
                                </div>
                            </a>
                        ))}
                    </div>
                </div>
            </main>
        </div>
    );
}

export default App;
    

Стили можете написать свои или взять готовые:

src/App.css
        .app .container {
    width: 1000px;
    padding: 0 20px;
    margin: 0 auto;
}

.header {
    height: 60px;
    background-color: darkcyan;
    color: white;
}

.header .container {
    height: 100%;
    display: flex;
    align-items: center;
}

.header-brand {
    font-size: 24px;
    font-weight: bold;
}

.about {
    position: relative;
    padding: 100px 0;
    text-align: center;
    font-weight: bold;
    color: white;
}

.about__title {
    font-size: 50px;
    margin-bottom: 30px;
}

.about__description p {
    margin: 20px 0;
    font-size: 24px;
}

.about__bg {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: -1;
    background-image: url(./img/react.jpeg);
    background-position: center;
    background-size: cover;
    background-attachment: fixed;
    filter: brightness(0.3);
}

.portfolio {
    padding: 40px 0;
}

.portfolio .container {
    display: flex;
    flex-wrap: wrap;
}

.portfolio-item {
    box-sizing: border-box;
    flex-basis: 33.33%;
    padding: 10px;
    text-decoration: none;
    color: inherit;
}

.portfolio-item {
    display: block;
}

.portfolio-item img {
    max-width: 100%;
}

.portfolio-item__title {
    margin: 10px 0;
    text-align: center;
    font-size: 16px;
    font-weight: bold;
}
    

Ссылки: исходники, демо.

Используя JSX-синтаксис и удобные фичи типа циклов, мы создаём разметку простой страницы с шапкой, блоком приветствия и списком выполненных проектов.

На что обратить внимание:

  1. Вместо атрибута class используется className;
  2. Все теги, включая img, должны быть закрыты;
  3. Для вывода массива элементов используется метод map;
  4. Каждый элемент массива, создаваемого методом map, должен иметь уникальный ключ key.

Структура проекта и компоненты

Заботимся о структуре. Если продолжить писать всё в одном файле, SPA очень быстро разрастётся, и мы запутаемся в коде. Нужно разделить проект на отдельные компоненты.

Сейчас у нас наметились несколько самостоятельных блоков:

  1. Header – шапка с брендом;
  2. About – блок с приветствием;
  3. PortfolioItem – карточка одного проекта.

Создадим директорию src/components, а внутри – отдельные папки для каждого компонента. Код и стили блоком переносим практически без изменений:

src/components/Header/Header.js
        import React from 'react';
import './Header.css';

function Header(props) {
    return (
        <header className='header'>
            <div className='container'>
                <div className='header-brand'>{props.brand}</div>
            </div>
        </header>
    );
}

export default Header;
    
src/components/About/About.js
        import React from 'react';
import './About.css';

function About(props) {
    return (
        <div className='about'>
            <div className='about__bg'></div>
            <div className='container'>
                <h1 className='about__title'>{props.title}</h1>
                <div className='about__description'>{props.children}</div>
            </div>
        </div>
    );
}

export default About;
    
src/components/PortfolioItem/PortfolioItem.js
        import React from 'react';
import './PortfolioItem.css';

function PortfolioItem({ work }) {
    return (
        <a href={work.link} className='portfolio-item'>
            <img
                className='portfolio-item__screenshot'
                src={work.screenshot}
                alt={work.title}
            />
            <div className='portfolio-item__title'>{work.title}</div>
        </a>
    );
}

export default PortfolioItem;
    

Подключаем компоненты внутри App:

src/App.js
        function App() {
    return (
        <div className='app'>
            <Header brand='Иван Иванов'></Header>

            <main className='main'>
                <About title='React-разработчик Иван Иванов'>
                    <p>
                        Разрабатываю на самом крутом в мире фреймворке
                        <br />
                        самые крутые в мире SPA!
                    </p>
                    <p>С удовольствием и вам что-нибудь разработаю ;)</p>
                </About>

                <div className='portfolio'>
                    <div className='container'>
                        {works.map(work => (
                            <PortfolioItem key={work.id} work={work} />
                        ))}
                    </div>
                </div>
            </main>
        </div>
    );
}
    

Разметка. Каждый компонент создаёт определённую разметку и заполняет её данными, полученными из родительского компонента через props. Обратите внимание, что компонент может получать также вложенную в него разметку – через свойство props.children.

Что изменилось? В качестве уникального ключа для элементов массива мы теперь используем id проекта, а не порядковый индекс элемента. Эта практика лучше, так как позволяет React оптимизировать рендер.

Директория src теперь выглядит так:

Файловая структура проекта
Файловая структура проекта

Все исходники можно посмотреть здесь. Визуально ничего не изменилось.

Чем больше проект, тем важнее становится его структура. Загляните в большое руководство по структурированию react-проектов, чтобы создавать удобные и легко поддерживаемые приложения.

Взаимодействие с пользователем

Добавляем формы. Теперь нужно научить наше SPA отслеживать действия пользователя и реагировать на них. Для демонстрации лучше всего подходят формы, поэтому мы добавим форму обратной связи в наше портфолио. Вот так это будет выглядеть на странице.

Форма обратной связи. Добавляем новый компонент ContactForm.

src/components/ContactForm/ContactForm.js
        import React, { Component } from 'react';
import './ContactForm.css';

class ContactForm extends Component {
    state = {
        email: '',
        emailError: null,
        offer: '',
        offerError: null
    };

    emailChangeHandler = event => {
        const email = event.target.value;
        this.setState({
            email,
            emailError: !email
        });
    };

    offerChangeHandler = event => {
        const offer = event.target.value;
        this.setState({
            offer,
            offerError: !offer
        });
    };

    submitHandler = event => {
        event.preventDefault();

        const { email, offer } = this.state;

        if (email && offer) {
            this.setState({
                email: '',
                emailError: false,
                offer: '',
                offerError: false
            });
            this.props.onSubmit();
            return;
        }

        this.setState({
            emailError: !email,
            offerError: !offer
        });
    };

    render() {
        const { email, emailError, offer, offerError } = this.state;

        return (
            <form className='contact-form' onSubmit={this.submitHandler}>
                <div className='contact-form__field'>
                    <input
                        value={email}
                        onChange={this.emailChangeHandler}
                        placeholder='Email для связи'
                    />
                    {emailError ? (
                        <div className='error'>Заполните поле</div>
                    ) : null}
                </div>
                <div className='contact-form__field'>
                    <textarea
                        rows='10'
                        value={offer}
                        onChange={this.offerChangeHandler}
                        placeholder='Ваше предложение'
                    ></textarea>
                    {offerError ? (
                        <div className='error'>Заполните поле</div>
                    ) : null}
                </div>
                <button className='button' type='submit'>
                    Отправить
                </button>
            </form>
        );
    }
}

export default ContactForm;
    

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

Обработчики событий. У ContactForm есть также методы-обработчики событий onSubmit (отправка формы) и onChange (изменение значений полей). Поля ввода полностью контролируются React – мы отслеживаем ввод и указываем, что выводить. Чтобы лучше понять, что такое контролируемые компоненты и как происходит изменение данных в формах, загляните в документацию React.

Храним состояние формы. Теперь у компонента App появляется состояние: форма может быть закрытой или открытой. Чтобы хранить этот параметр переделаем App из функционального компонента (stateless) в классовый и добавим ему поле state.

Внизу разместим кнопку, клики по которой будем отслеживать с помощью атрибута onClick.

src/App.js
        class App extends React.Component {
    state = {
        closed: true
    };

    openForm() {
        this.setState({
            closed: false
        });
    }

    closeForm() {
        this.setState({
            closed: true
        });
    }

    render() {
        return (
            <div className='app'>
                <Header brand='Иван Иванов'></Header>

                <main className='main'>
                    <About title='React-разработчик Иван Иванов'>
                        <p>
                            Разрабатываю на самом крутом в мире фреймворке
                            <br />
                            самые крутые в мире SPA!
                        </p>
                        <p>С удовольствием и вам что-нибудь разработаю ;)</p>
                    </About>

                    <div className='portfolio'>
                        <div className='container'>
                            {works.map(work => (
                                <PortfolioItem key={work.id} work={work} />
                            ))}
                        </div>
                    </div>

                    <div className='contacts'>
                        <div className='container'>
                            {this.state.closed ? (
                                <button
                                    className='button'
                                    onClick={() => this.openForm()}
                                >
                                    Напишите мне
                                </button>
                            ) : (
                                <div>
                                    <hr />
                                    <ContactForm
                                        onSubmit={() => this.closeForm()}
                                    />
                                </div>
                            )}
                        </div>
                    </div>
                </main>
            </div>
        );
    }
}
    

Все исходники здесь.

Обратите внимание: Для изменения состояния компонента предназначен метод this.setState, который принимает новый объект состояния. Подробнее о состоянии вы можете прочитать в документации.

Страница проекта

Добавим в приложение страницы. Хоть мы и используем React, до сих пор в нашем проекте не было ничего особенного. Мы работаем в пределах одной страницы, отслеживаем события и меняем DOM. Чтобы прикоснуться к магии Single Page Application, нужно в наше приложение добавить страниц. Например, было бы неплохо показывать детальную информацию о выполненном проекте по адресу /projects/{идентификатор_проекта}.

Требования к маршрутизации SPA:

  1. Переход между страницами должен происходить без перезагрузки.
  2. Каждый переход должен сохраняться в истории браузера (кнопки Назад/Вперед должны функционировать).
  3. При вводе в адресную строку некоторого маршрута должен выводиться тот контент, который этому маршруту соответствует, как в мультистраничном приложении.

Пакет react-router-dom. Не переживайте, нам не придётся заниматься этим вручную. Честно говоря, нам вообще мало что придётся делать – всё уже сделано. Пакет react-router инкапсулирует логику маршрутизации, а react-router-dom обеспечивает его взаимодействие с DOM в браузере.

Устанавливаем модуль:

        npm i -D react-router-dom
    

Теперь откройте файл src/index.js и оберните всё приложение в компонент BrowserRouter.

src/index.js
        import { BrowserRouter } from 'react-router-dom';

const app = (
    <BrowserRouter>
        <App />
    </BrowserRouter>
);

ReactDOM.render(app, document.getElementById('root'));
    

Страница проекта будет иметь тот же хедер, что и главная, но другой контент. Чтобы логически разделить их, создадим в проекте директорию src/pages с двумя папками: home и project. В home/index.js перенесем имеющийся код из компонента App, а в project/index.js создадим разметку для новой страницы проекта.

Файловая структура проекта
Файловая структура проекта
src/pages/home/index.js
        import React from 'react';

import About from '../../components/About/About';
import PortfolioItem from '../../components/PortfolioItem/PortfolioItem';
import ContactForm from '../../components/ContactForm/ContactForm';

import works from '../../works';

class HomePage extends React.Component {
    state = {
        closed: true,
    };

    openForm() {
        this.setState({
            closed: false,
        });
    }

    closeForm() {
        this.setState({
            closed: true,
        });
    }

    render() {
        return (
            <div>
                <About title='React-разработчик Иван Иванов'>
                    <p>
                        Разрабатываю на самом крутом в мире фреймворке
                        <br />
                        самые крутые в мире SPA!
                    </p>
                    <p>С удовольствием и вам что-нибудь разработаю ;)</p>
                </About>

                <div className='portfolio'>
                    <div className='container'>
                        {works.map((work) => (
                            <PortfolioItem key={work.id} work={work} />
                        ))}
                    </div>
                </div>

                <div className='contacts'>
                    <div className='container'>
                        {this.state.closed ? (
                            <button
                                className='button'
                                onClick={() => this.openForm()}
                            >
                                Напишите мне
                            </button>
                        ) : (
                            <div>
                                <hr />
                                <ContactForm
                                    onSubmit={() => this.closeForm()}
                                />
                            </div>
                        )}
                    </div>
                </div>
            </div>
        );
    }
}

export default HomePage;
    
src/pages/project/index.js
        import React from 'react';
import works from '../../works';

import './index.css';

class ProjectPage extends React.Component {
    state = {
        project: null,
        error: false
    };

    componentDidMount() {
        const id = this.props.match.params.id;
        setTimeout(() => {
            const project = works.find(work => work.id === id);

            this.setState({
                project: project,
                error: !project
            });
        }, 1000);
    }

    render() {
        const { project, error } = this.state;

        if (error)
            return <div className='container'>Что-то пошло не так...</div>;

        if (!project) return <div className='container'>Loading...</div>;

        return (
            <div className='project'>
                <div className='container'>
                    <img
                        className='project__screenshot'
                        src={project.screenshot}
                        alt={project.title}
                    />

                    <h1 className='project__title'>{project.title}</h1>

                    <p className='project__description'>
                        {project.description}
                    </p>

                    <div className='project__stack'>
                        {project.stack.join(', ')}
                    </div>

                    <div>
                        <a href={project.link} className='project__link'>
                            Ссылка на проект
                        </a>
                    </div>
                </div>
            </div>
        );
    }
}

export default ProjectPage;
    

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

        class App extends React.Component {
    render() {
        return (
            <div className='app'>
                <Header brand='Иван Иванов'></Header>

                <main className='main'>
                    <Switch>
                        <Route path='/project/:id' component={ProjectPage} />
                        <Route exact path='/' component={HomePage} />
                        <Redirect to='/' />
                    </Switch>
                </main>
            </div>
        );
    }
}
    

Все исходники вы можете найти здесь. Демонстрационная версия приложения имеет ограниченную функциональность – нет редиректа на главную при вводе несуществующего маршрута.

На что обратить внимание:

  1. Идентификатор конкретного проекта передается прямо в url как параметр :id.
  2. Извлечь его в компоненте можно из this.props.match.params.id.
  3. В методе componentDidMount компонента ProjectPage можно сделать запрос к серверу для получения данных проекта. В коде задержку запроса имитирует метод setTimeout.
  4. Все внутренние ссылки (в компонентах Header и ProjectPreview) заменены на компонент Link, это обеспечивает переход между маршрутами без перезагрузки.
  5. Компонент App больше не имеет состояния – оно перешло в компонент HomePage – поэтому его снова можно сделать функциональным.

Чтобы глубже разобраться в работе роутера, загляните в этот туториал по использованию React Router.

Резюмируем: основы создания SPA

Итак, что требуется для создания Single Page Application на React?

  1. Разделить код проекта на компоненты, которые можно заменять.
  2. Отслеживать действия пользователя для изменения состояния приложения.
  3. Настроить маршрутизацию без перезагрузки, передавая при необходимости параметры маршрутам для более тонкой настройки.

Ваше первое простое SPA на React готово!

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

МЕРОПРИЯТИЯ

Комментарии 2

ВАКАНСИИ

Unity 3D Engineer
по итогам собеседования
Technical Lead
от 250000 RUB
Team Leader (back-end)
Тверь, от 100000 RUB до 120000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG