Твоё первое SPA на React: основные концепции и разработка
Разбираемся в базовых концепциях SPA и пишем первое одностраничное приложение на React.js.
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
. После загрузки скрипт выводит на страницу компонент каталога – множество карточек с мягкими игрушками. Когда вы кликаете на понравившегося мишку, компонент каталога реактивно заменяется на компонент одного товара. Страница при этом не перезагружается, всё происходит красиво и плавно.
В итоге браузер отдал только одну страницу – 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. Если вы с ним незнакомы, то загляните сначала в Подробное руководство по 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
, удалите всё ненужное, и разместите следующий код:
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;
Стили можете написать свои или взять готовые:
.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-синтаксис и удобные фичи типа циклов, мы создаём разметку простой страницы с шапкой, блоком приветствия и списком выполненных проектов.
На что обратить внимание:
- Вместо атрибута
class
используетсяclassName
; - Все теги, включая
img
, должны быть закрыты; - Для вывода массива элементов используется метод
map
; - Каждый элемент массива, создаваемого методом
map
, должен иметь уникальный ключkey
.
Структура проекта и компоненты
Заботимся о структуре. Если продолжить писать всё в одном файле, SPA очень быстро разрастётся, и мы запутаемся в коде. Нужно разделить проект на отдельные компоненты.
Сейчас у нас наметились несколько самостоятельных блоков:
Header
– шапка с брендом;About
– блок с приветствием;PortfolioItem
– карточка одного проекта.
Создадим директорию src/components
, а внутри – отдельные папки для каждого компонента. Код и стили блоком переносим практически без изменений:
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;
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;
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:
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
.
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
.
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:
- Переход между страницами должен происходить без перезагрузки.
- Каждый переход должен сохраняться в истории браузера (кнопки Назад/Вперед должны функционировать).
- При вводе в адресную строку некоторого маршрута должен выводиться тот контент, который этому маршруту соответствует, как в мультистраничном приложении.
Пакет react-router-dom. Не переживайте, нам не придётся заниматься этим вручную. Честно говоря, нам вообще мало что придётся делать – всё уже сделано. Пакет react-router инкапсулирует логику маршрутизации, а react-router-dom обеспечивает его взаимодействие с DOM в браузере.
Устанавливаем модуль:
npm i -D react-router-dom
Теперь откройте файл src/index.js
и оберните всё приложение в компонент BrowserRouter
.
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
создадим разметку для новой страницы проекта.
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;
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> ); } }
Все исходники вы можете найти здесь. Демонстрационная версия приложения имеет ограниченную функциональность – нет редиректа на главную при вводе несуществующего маршрута.
На что обратить внимание:
- Идентификатор конкретного проекта передается прямо в url как параметр
:id
. - Извлечь его в компоненте можно из
this.props.match.params.id
. - В методе
componentDidMount
компонентаProjectPage
можно сделать запрос к серверу для получения данных проекта. В коде задержку запроса имитирует методsetTimeout
. - Все внутренние ссылки (в компонентах
Header
иProjectPreview
) заменены на компонентLink
, это обеспечивает переход между маршрутами без перезагрузки. - Компонент
App
больше не имеет состояния – оно перешло в компонентHomePage
– поэтому его снова можно сделать функциональным.
Чтобы глубже разобраться в работе роутера, загляните в этот туториал по использованию React Router.
Резюмируем: основы создания SPA
Итак, что требуется для создания Single Page Application на React?
- Разделить код проекта на компоненты, которые можно заменять.
- Отслеживать действия пользователя для изменения состояния приложения.
- Настроить маршрутизацию без перезагрузки, передавая при необходимости параметры маршрутам для более тонкой настройки.
Ваше первое простое SPA на React готово!
Не останавливайтесь на достигнутом, продолжайте развиваться, учиться и создавать самые крутые приложения.