В последнее время вижу повышенный интерес к теме. На самом деле тема большая, в одной статье не описать, поэтому для более детального ознакомления буду безжалостно отправлять по ссылкам на подробные курсы и в документацию, а останавливаться буду лишь на наиболее значимых моментах.
Вкратце. Для того, чтобы сделать модное одностраничное приложение, нужно:
- создать REST-API;
- настроить систему сборки для фронтэнда;
- написать скрипты и стили.
Всего-то навсего.
Демо примера, который здесь разбирается: http://django-react-example.ww42.ru/
Полный исходный код разбираемого примера: https://github.com/and-nothing-else/django-react-example
Использовать будем:
- Django 1.9.5. Для бэкенда. Тут всё понятно;
- django-rest-framework. Для создания rest-api;
- react.js для описания компонентов;
- react-router для маршрутизации на стороне клиента;
- moment — просто под руку попался случайно, для работы с датами;
- babel — для перевода javascript в javascript;
- bootstrap — для оформления;
- sass — для написания своих стилей. Выбор обусловлен тем, что bootstrap написан на sass;
- webpack — для сборки клиентского приложения.
Напишем новостную ленту. Это чуть сложнее хэлловорлда. Будет страница списка статей, на которой даны заголовок, дата и анонс, и страница статьи детально, на которой даны заголовок, дата и полный текст статьи. Анонс необязателен, и если он не задан, вместо него берём небольшую часть из основного текста, аккуратно обрезая по словам, чтобы получилось не более 512 символов. Со страницы на страницу будем переходить с вычурной анимацией, с сервера будем запрашивать данные по мере необходимости, сама же страница будет загружена только один раз. При переходе по страницам, разумеется, адрес в браузере будет меняться.
Django
Полагаю, что базовыми знаниями Django обладают все здесь присутствующие. Установим, настроим, создадим приложение articles
, в котором сделаем модель:
#models.py
from django.db import models
from django.utils.translation import ugettext_lazy as _
class Article(models.Model):
title = models.CharField(_('title'), max_length=128)
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
announce_text = models.TextField(_('announce'), max_length=512, blank=True)
text = models.TextField(_('text'), max_length=4096)
def __str__(self):
return self.title
@property
def announce(self):
return self.announce_text or self.text[:512].rsplit(' ', 1)[0]
class Meta:
ordering = ['-created_at']
verbose_name = _('article')
verbose_name_plural = _('articles')
и выведем её в админку:
# admin.py
from django.contrib import admin
from .models import Article
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'created_at']
Создаём и применяем миграции, и можем вносить статьи через админку. Здесь всё предельно просто.
REST API
Теперь нужно создать API. Серверная и клиентская части будут жить несколько обособленно, общаться только через это API.
Для API создаётся отдельное приложение. С самого начала не забываем о версионировании, и поэтому именуем его, к примеру, api_v0, и в урлах подключаем как
url(r'^api/v0/', include('api_v0.urls')),
Теперь все обращения к нашему api будут с префиксом /api/v0/
. Обращений будет два:
GET /api/v0/articles/ — список статей
GET /api/v0/articles/1/ — статья с конкретным номером
В списке будем отдавать json с кратким текстом и id статьи, а детальную информацию уже с полным текстом. То есть, данные модели статьи нужно сериализовать двумя разными способами.
В приложении api_v0
создадим файл serializers.py
с двумя классами:
from rest_framework import serializers
from articles.models import Article
class ArticlePreviewSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = [
'id',
'title',
'created_at',
'announce',
'url',
]
class ArticleDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = [
'title',
'created_at',
'text',
]
serializers.ModelSerializer
просто в декларативной форме описывает, какие поля какой модели будут отданы. Подробнее об этом здесь.
Django-rest-framework предоставляет такую сущность как наборы представлений (ViewSets). Их можно сравнить с контроллерами в Ruby-on-Rails. Подробнее о них здесь.
Они обрабатывают стандартные для соглашения REST сочетания методов http и адресов. Но в нашем случае нужны только два действия: получение списка и получение объекта. Для этого есть особый класс ReadOnlyModelViewSet
, который мы и задействуем:
# views.py
from rest_framework import viewsets
from .serializers import *
class ArticleViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Article.objects.all()
def get_serializer_class(self):
if self.action == 'list':
return ArticlePreviewSerializer
return ArticleDetailSerializer
Согласно документации, в классе нужно определить свойство serializer_class
. Но мы вместо этого переопределим метод get_serializer_class
, поскольку в зависимости от действия у нас разные сериализаторы.
В урлах приложения зарегистрируем набор. Опять же это что-то напомнит знакомым с RoR, Yii, Laravel и всяким таким:
from rest_framework.routers import DefaultRouter
from .views import *
router = DefaultRouter()
router.register(r'articles', ArticleViewSet)
urlpatterns = router.urls
Теперь можно проверять работу api. Можно открыть в браузере /api/v0/
и увидеть страницу обзора api. Выглядит это так: Но возможно удобнее будет для этих целей использовать расширение Хрома Postman. В нём это выглядит так:
Страница публичной части
Первым делом подумаем об урлах. У нас уже определены урлы с префиксами /admin/
и /api/v0/
. Теперь, как будет выглядеть приложение для пользователя. По адресу /
нужно получить список статей. По адресу /1
— первую статью. И предположим, что приложение у нас может развиваться, и адреса могут стать вообще какими угодно, но обрабатываться должны всё тем же клиентским веб-приложением. Фактически, на все остальные адреса у нас одна статическая страница. Если неохота делать её именно статикой и натравливать на неё nginx, то можно сделать главное приложение в django-проекте, назвать его, к примеру, main
, и в нём сделать одну вьюху:
from django.views.generic import TemplateView
class IndexView(TemplateView):
template_name = 'main/index.html'
и один шаблон:
{% load staticfiles %}<!doctype html>
<title>Демо проект</title>
<div id="app"></div>
<script src="{% static "app.js" %}"></script>
Про шаблон: да, это всё, больше ничего не надо. Не шучу.
Лирическое отступление: был у меня заказчик, и у него менеджер — девочка «на лабутенах, ах!». Так эта девочка не хотела принимать работу из-за того, что в index.html
всего четыре строки. Она считала, что я её где-то обманываю. Ситуацию разрулил директор компании заказчика. Не знаю, где теперь работает та девочка «в восхитительных штанах». В общем, будьте готовы и к такому.
И вот теперь начинается самое интересное.
Сборка клиентской части
Клиентскую часть будем писать на react.js
. Чем он хорош — давайте пофлудим в специальной ветке на форуме. А можно об этом почитать где-нибудь на хабре. Пока же предлагаю просто поверить мне на слово. Хорош. Писать будем на javascript. Без вариантов. Других языков браузеры не понимают. Но беда в том, что javascript браузеры тоже не понимают. В 2015 году была принята спецификация ES2015, но поддержки её браузерами нет и поныне. А развитие уже идёт дальше. Поэтому придётся переводить javascript в тот javascript, который понимают браузеры (ES5). О том, каков javascript стал в 2015 году, можно почитать здесь.
Надо клиентскую часть ещё и как-то оформить. А мы же программисты, верстать не очень любим, но результата нужно достичь, и качественного. Поэтому для стилей задействуем проверенный фреймворк bootstrap
, и писать стили будем на языке sass
, на котором и написан bootstrap. Это всё тоже не понимают браузеры. Но работать нашему творению надо именно в браузерах. Так что задействуем ещё и webpack
. Именно он переведёт наши модные скрипты и стили в менее модные, зато понятные браузерам.
Шаг первый. Создадим проект npm. В корне нашего проекта исполним npm init
. Ряд вопросов, ответы по умолчанию, и в корне проекта появился файл package.json
.
Шаг второй. Устанавливаем зависимости.
Реакт и его улучшайзеры:
npm i --save-dev react react-dom react-addons-css-transition-group react-router
Система сборки:
npm i --save-dev webpack
Переводчик из javascript в javascript:
npm i --save-dev babel-core babel-loader babel-plugin-transform-runtime babel-preset-es2015 babel-preset-react babel-preset-stage-0
Стили:
npm i --save-dev bootstrap css-loader node-sass sass-loader style-loader postcss-loader
И ещё для красивого вывода даты поставим такое:
npm i --save-dev moment
Исходные скрипты веб-приложения расположим в каталоге web_client
, а скомпилированный скрипт будем складывать в main/static/app.js
, откуда его можно подключить в том самом опасно-минималистичном шаблоне.
Приведу настройку webpack для такой сборки:
import path from 'path'
import 'webpack'
export default {
entry: './web_client/app.js',
output: {
path: `${__dirname}/main/static`,
filename: 'app.js'
},
resolve: {
extensions: ['', '.js', '.jsx', '.scss'],
modulesDirectories: [
'node_modules'
]
},
module: {
loaders: [
{
test: /\.jsx?$/,
loader: ['babel'],
include: [
path.resolve(__dirname, "web_client")
],
query: {
plugins: ['transform-runtime'],
presets: ['es2015', 'stage-0', 'react']
}
},
{
test: /\.s[a|c]ss$/,
loader: 'style-loader!css-loader!sass-loader'
}
]
},
sassLoader: {
includePaths: [
path.resolve(__dirname, "./web_client"),
path.resolve(__dirname, "./node_modules/bootstrap/scss")
]
}
}
Подробнее о webpack здесь.
Входной точкой объявлен web_client/app.js
. Можно создать такой файл, вписать в него console.log(‘Hello World’)
, и… надо как-то увидеть, что оно работает. В консоли запускаем: ./node_modules/.bin/webpack --progress
Вебпак отрабатывает, появляется итоговый файл, можем обновить страницу и увидеть, что хэлловорлд работает. Очевидно, что такие команды писать в консоли утомит довольно быстро, поэтому стоит воспользоваться инструментом для упрощения жизни, который предоставляет npm
. В package.json
есть раздел scripts
, в котором можно перечислить подобные служебные команды. Запишем так:
"scripts": {
"dev": "./node_modules/.bin/webpack --progress --watch",
"build": "./node_modules/.bin/webpack --progress --production --optimize-minimize"
},
Теперь будут доступны команды npm run dev
и npm run build
. По опциям можно догадаться, что первая собирает и запускает наблюдение над файлами. Как только файл в проекте изменится, приложение будет пересобрано; а вторая собирает оптимизированную и минимизированную продакшн-версию.
Компоненты
Вообще, конечно, тема огромная. Чтобы вникнуть с нуля, читать здесь.
Компонент объединяет в себе логику и представление. Хороший компонент годен для повторного использования. Выделенные в отдельный файл шаблоны в реакте делать не принято, шаблоном является метод render()
класса компонента. А вот стили всё-таки в отдельном файле. Таким образом, на компонент два файла: .jsx
и .sass
. Их можно сложить в один каталог (по имени компонента), js-файлу дать имя index, а стили импортировать внутри js-модуля. Все каталоги компонентов в свою очередь сложить в один общий каталог, для которого сделать индекс вроде такого:
export { default as App } from './App'
export { default as List } from './List'
Получится что-то вроде библиотеки компонентов, можно будет в других модулях импортировать их так:
import { App, List } from ‘./components’
Каждый из компонентов должен делать что-то одно. В нашем минималистичном примере один компонент обеспечивает общую раскладку страницы (в реальном приложении, конечно, одним компонентом на такое не обойтись), один обеспечивает список статей, один — представление элемента списка и один — детальное представление статьи.
Например, список статей. Вот его полный код:
import React, { Component } from 'react'
import ListItem from '../ListItem'
import './style.sass'
class List extends Component {
state = {
articles: []
};
async loadArticles() {
this.setState({
articles: await fetch("/api/v0/articles/").then(response =>response.json())
})
}
componentDidMount() {
this.loadArticles();
}
render(){
return(
<ul className="content-list">
{this.state.articles.map((article, index) => (
<li className="content-list__item" key={index}>
<ListItem article={article} />
</li>
))}
</ul>
);
}
}
export default List
У компонента есть внутреннее состояние, хранящее данные, необходимые ему для работы. Оно хранится в свойстве state
и устанавливается только через метод setState
. Кроме непосредственно установки значений, запускаются механизмы, проверяющие, не надо ли чего перерисовать.
Компоненту списка статей нужны статьи, чтобы их отобразить. Изначально это пустой список:
state = {
articles: []
};
Но сразу после того, как компонент размещён на странице, нужно этот список заполнить. Напишем метод, который обращается к серверу, к написанному ранее rest api, и забирает данные оттуда:
async loadArticles() {
this.setState({
articles: await fetch("/api/v0/articles/").then(response =>response.json())
})
}
И после того, как компонент смонтирован, нужно этот метод вызвать:
componentDidMount() {
this.loadArticles();
}
У реакт-компонентов есть жизненный цикл, и есть методы, которые определяют, что на каком этапе нужно делать. В нашем примере componentDidMount — это один из встроенных методов жизненного цикла. Подробнее о жизненном цикле читать здесь.
В методе render
описывается шаблон компонента на xml-подобном языке jsx, который непонятен для js, зато удобен разработчику. Его-то babel и переводит при помощи babel-preset-react
. В jsx можно использовать другие компоненты, а также произвольные js-конструкции в фигурных скобках. Это мы здесь и делаем:
{this.state.articles.map((article, index) => (
<li className="content-list__item" key={index}>
<ListItem article={article} />
</li>
))}
Проходим по всему массиву this.state.articles
, который содержит список статей, и для каждого элемента вызываем функцию, возвращающую свой jsx с применением уже другого компонента. Данные статьи передаём внутрь другого компонента как атрибут. Внутри того компонента они будут доступны в свойстве props.
Стили
Странной могла показаться строка:
import './style.sass'
И действительно, импортировать так можно только js-модули. Однако мы рассчитываем на то, что в проекте работает webpack
, который умеет при помощи лоадеров правильно импортировать всё что угодно.
В конфигурации вебпака есть такая часть:
{
test: /\.s[a|c]ss$/,
loader: 'style-loader!css-loader!sass-loader!postcss-loader'
}
Означает, что все файлы с расширением .sass
или .scss будут переданы в style-loader
, css-loader
и sass-loader
. В итоге sass
будет переведён в понятный браузеру css
, где надо подставлены вендорные префиксы, и подключен на страницу традиционным образом.
В конфиге вебпака указан дополнительный путь для sass-loader
’а: "./node_modules/bootstrap/scss"
Значит, можно подключать бутстраповские модули и использовать их своих стилях. Например, хотим сбросить стили списка, делаем так:
@import "mixins"
.content-list
+list-unstyled()
Здесь надо быть аккуратнее. Поскольку все стили изолированы, есть опасность повторения кода. Но это отдельная большая тема.
Маршрутизация
При переходе со страницы на страницу в браузере должны меняться адреса, несмотря на то, что как такового запроса новой страницы с сервера не было. Browser history api
, ничего нового или оригинального. При разработке на реакте можно использовать react-router, который предоставляет компоненты для объявления таблицы маршрутизации и ссылок.
Так можно описать таблицу маршрутизации на jsx:
<Router history={ browserHistory }>
<Route path="/" component={ BaseLayout }>
<IndexRoute component={List} />
<Route path="/:article_id" component={Article} />
</Route>
</Router>
Будет подключен компонент BaseLayout
. А дальше зависит от адреса. При обращении к корню в props.children
компоненту BaseLayout
будет передан компонент List
, но если адрес выглядит как /<id статьи>
, то будет передан компонент Article
. Переменные из адреса попадают в подключаемый компонент в props.params
.
Анимация
Маршрутизация работает, но мы условились, что страницы сменяться должны с заметной анимацией. Можно такое сделать при помощи react-addons-css-transition-group.
Суть работы проста: обозначается контейнер, внутри которого один элемент сменяется другим. Уходящему даётся css-класс с постфиксом -leave
, а затем сразу -leave-active
. Приходящему же даётся класс с постфиксом -enter
, а затем сразу с -enter-active
. По завершении перехода все эти классы убираются. Сама анимация должна быть описана в стилях.
Такой контейнер — это компонент из react-addons-css-transition-group
. Пример применения:
<ReactCSSTransitionGroup
component="div"
className="app__content"
transitionName="app__content_page"
transitionEnterTimeout={500}
transitionLeaveTimeout={500}
>
{React.cloneElement(children, {
key: location.pathname
})}
</ReactCSSTransitionGroup>
Анимация будет продолжаться 500мс. Это и недостаточно долго, чтобы утомить посетителя, и в большинстве случаев достаточно, чтобы за это время успеть подгрузить данные для новой страницы. Опишем её в стилях:
.app
&__content
@extend .container
position: relative
perspective: 600px
transform-origin: center
&_page
&-leave, &-enter
transition: all $transition-duration ease
&-leave
transform: translateX(0px) rotateY(0deg)
&-active
transform: translateX(-600px) rotateY(-45deg)
&-enter
transform: translateX(600px) rotateY(45deg)
position: absolute
&-active
transform: translateX(0px) rotateY(0deg)
Чего-то автор недоговаривает!
А много чего. Например, вообще не затронуты вопросы оптимизации этого монстра и тестирования.
Не указано, что для статики следует использовать настройку
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
А это, пожалуй, стоит указать.
Кто уже применяет React в своей практике, возможно занёс руку, чтобы написать «так делать нельзя». Да, нельзя. Но статья обзорно-ознакомительная, и так великовата получилась, а по Redux
люди целые книги пишут. Конечно же, Redux обязателен к применению на проектах чуть сложнее, чем описанный здесь.
А как же SEO? Не знаю как. Рассказывают разное. Кто-то говорит, что гугл умеет уже выполнять js и индексировать собираемые скриптами страницы. Кто-то говорит, что это не так. Одно точно: отрендеренные на сервере страницы проиндексированы будут. Ну так реакт и на сервере генерить умеет, отдавать html, а потом уже на клиенте работать поверх сгенерированной на сервере страницы. Но это этом мы не будем, а то и до всяких крамольных мыслей недалеко. Не забываем, что мы здесь все джангисты как-никак :)