We don’t need no traffic building,
We don’t need no SEO,
No link exchanges in your network,
Spammers! leave us all alone.
Anna Filina
Немного истории
В далеком 2013 году Spike Brehm из Airbnb опубликовал программную статью, в которой проанализировал недостатки SPA-приложений (Single Page Application), и в качестве альтернативы предложил модель изоморфных веб-приложений. Сейчас чаще используется термин универсальные веб-приложение (см. дискуссию).
В универсальном веб-приложении каждая страница может формироваться как веб-сервером, так и средствами JavaScript на стороне веб-браузера. При этом, исходный код программ, которые выполняются веб-сервером и веб-браузером должен быть единым (универсальным), чтобы исключить несогласованность и повышение затрат на разработку.
История с автором идеи, Spike Brehm из Airbnb, в настоящее время закончилась полной победой, и недавно, 7 декабря 2017 года в своем Twitter он сообщил о том что сайт Airbnb перешел на серверный рендеринг SPA-приложений.
Критика SPA-приложений
Что же не так со SPA-приложениями? И какие проблемы возникают при разработке универсальных приложений?
SPA-приложения критикуют, прежде всего, за низкий рейтинг в поисковых системах (SEO), скорость работы, доступность. (Имеется в виду доступность как она понимается в документе https://www.w3.org/Translations/WCAG20-ru. Есть сведения что приложения React могут быть недоступны для скрин-ридеров.)
Частично вопрос с SEO SPA-приложений решает Prerender — сервер с “безголовым” веб-браузером, который реализован при помощи chrome-remote-interface (раньше использовался phantomjs). Можно развернуть свой собственный сервер с Prerender или обратиться к общедоступному сервису. В последнем случае доступ будет бесплатным с лимитом на количество страниц. Процесс генерации страницы средствами Prerender затратный по времени — обычно больше 3 с., а это значит, что поисковые системы будут считать такой сервис не оптимизированным по скорости, и его рейтинг все равно будет низким.
Проблемы с производительностью могут не проявляться в процессе разработки и стать заметными при работе с низкоскоростным интернет или на маломощном мобильном устройстве (например телефон или планшет с параметрами 1Гб оперативной памяти и частотой процессора 1,2Ггц). В этом случае страница, которая “летает”, может загру��аться неожиданно долго. Например, одну минуту. Причин для такой медленной загрузки больше, чем обычно указывают. Для начала давайте разберемся — как приложение загружает JavaScript. Если скриптов много (что было характерно при использовании require.js и amd-модулей), то время загрузки увеличивалось за счет накладных расходов на соединение с сервером для каждого из запрашиваемых файлов. Решение было очевидным: соединить все модули в один файл (при помощи rjs, webpack или другого компоновщика). Это повлекло новую проблему: для веб-приложения с богатыми интерфейсом и логикой, при загрузке первой страницы загружался весь код JavaScript, скомпонованный в единый файл. Поэтому современный тренд это code spliting. Мы еще вернемся к этому вопросу когда будем рассматривать необходимый функционал для построения универсальных веб-приложений. Вопрос не в том, что это невозможно или сложно сделать. Вопрос в том, что желательно иметь средства, которые делают это оптимально и без дополнительных усилий со стороны разработчика. И, наконец, когда весь код JavaScript был загружен и интерпретирован, начинается построение DOM документа и… наконец начинается загрузка картинок.
Библиотеки для создания универсальных проиложений
На github.com сейчас можно найти большое количество проектов, реализующих идею универсальноко веб-приложения. Однако всем этим проектам присущи общие недостатки:
- малая численность контрибьюторов проектов
- это заготовки проектов для быстрого старта, а не библиотеки
- проекты не обновлялись при выходе новых версий react.js
- в проектах реализована только часть функционала, необходимого для разработки универсального приложения.
Первым удачным решением стала библиотека Next.js, которая по состоянию на 14 января 2018 года имеет 338 контрибьюторов и 21137 “звезд” на github.com. Чтобы оценить преимущества этой библиотеки, рассмотрим какой именно функционал нужно обеспечить для работы универсального веб-приложения.
Серверный рендеринг
Такие библиотеки, как react.js, vue.js, angular.js, riot.js и другие — поддерживают серверный рендеринг. Серверный рендеринг работает, как правило, синхронно. Это означает, что асинхронные запросы к API в событиях жизненного цикла будут запущены на выполнение, но их результат будет потерян. (Ограниченную поддержку асинхронного серверного рендеринга пр��доставляет riot.js)
Асинхронная загрузка данных
Для того чтобы результаты асинхронных запросов были получены до начала серверного рендеринга, в Next.js реализован специальный тип компонента “страница”, у которого есть асинхронное событие жизненного цикла static async getInitialProps({ req }).
Передача состояния серверного компонента на клиент
В результате серверного рендеринга компонента, клиенту отправляется HTML-документ, но состояние компонента теряется. Для передачи состояния компонента, обычно веб-сервер генерирует скрипт для веб-браузера, который в глобальную переменную JavaScript записывают состояние серверного компонента.
Создание компонента на стороне веб-браузера и его привязка к HTML-документу
HTML-документ, который получен в результате серверного рендеринга компонента, содержит текст и не содержит компонентов (объектов JavaScript). Компоненты должны быть заново воссозданы в веб-браузере и “привязаны” к документу без повторного рендеринга. В react.js для этого выполняется метод hydrate(). Аналогичный по функции метод есть в библиотеке vue.js.
Роутинг
Роутинг на сервере и на клиенте должен быть также универсальным. То есть, одно и то же определение роутинга должно работать и для серверного и для клиентского кода.
Code splitting
Для каждой страницы должен загружаться только необходимый код JavaScript, а не все приложение. При переходе на следующую страницу должен догружаться недостающий код — без повторной загрузки одних и тех же модулей, и без лишних модулей.
Все эти задачи успешно решает библиотека Next.js. В основе этой библиотеки лежит очень простая идея. Предлагается ввести новый тип компонента — “страница”, в котором есть асинхронный метод static async getInitialProps({ req }). Компонент типа “страница” — это обычный React-компонент. Об этом типе компонентов можно думать, как о новом типе в ряду: “компонент”, “контейнер”, “страница”.
Работающий пример
Для работы нам понадобится node.js и менеджер пакетов npm. Если они еще не установлены — проще всего это сделать при помощи nvm (Node Version Manager), который устанавливается из командной строки и не требует доступа sudo:
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
После установки обязательно закрыть и заново открыть терминал, чтобы установить переменную окружения PATH. Список всех доступных версий выводит команда:
nvm ls-remote
Загрузите необходимую версию node.js и совместимую с ней версию менеджера пакетов npm командой:
nvm install 8.9.4
Создайте новый каталог (папку) и в ней выполните команду:
npm init
В результате будет сформирован файл package.json.
Загрузите и добавьте в зависимости проекта необходимые для работы пакеты:
npm install --save axios next next-redux-wrapper react react-dom react-redux redux redux-logger
В корневом каталоге проекта создайте каталог pages. В этом каталоге будут содержаться компоненты типа “страница”. Путь к файлам внутри каталога pages соответствует url, по которому эти компоненты будут доступны. Как обычно, “магическое имя” index.js отображается на url /index и /. Более сложные правила для url c wildcard тоже реализуемы.
Создайте файл pages/index.js:
import React from 'react'
export default class extends React.Component {
static async getInitialProps({ req }) {
const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
return { userAgent }
}
render() {
return (
<div>
Hello World {this.props.userAgent}
</div>
)
}
}
В этом простом компоненте задействованы основные возможности Next.js:
- доступен синтаксис es7 (import, export, async, class) “из коробки”.
- Hot-reloading также работает “из коробки”.
- Функция static async getInitialProps({ req }) будет асинхронно выполнена перед рендерингом компонента на сервере или на клиенте — при этом только один раз. Если компонент рендерится на сервере, ему передается параметр req. Функция вызывается только у компонентов типа “страница” и не вызывается у вложенных компонентов.
В файл package.json в атрибут “scripts” добавьте три команды:
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
Запустите сервер разработчика командой:
npm run dev
Чтобы реализовать переход на другую страницу без загрузки страницы с сервера, ссылки оборачиваются в специальный компонент Link. Добавьте в page/index.js зависимость:
import Link from 'next/link'
и компонент Link:
<Link href="/time">
<a>Click me</a>
</Link>
При переходе по ссылке отобразится страница с 404 ошибкой.
Скопируйте файл pages/index.js в файл pages/time.js. В новом компоненте time.js мы будем отображать текущее время полученное асинхронно с сервера. А пока поменяйте в этом компоненте ссылку, так чтобы она вела на главную страницу:
<Link href="/">
<a>Back</a>
</Link>
Попробуйте несколько раз перезагрузить каждую из страниц с сервера, а потом перейти с одной страницы на другую и вернуться обратно. Во всех случаях загрузка с сервера будет проходить с серверным рендерингом, а все последующие переходы — средствами рендеринга на стороне веб-браузера.
На странице pages/time.js разместим таймер, который показывает текущее время полученное с сервера. Это позволит познакомиться с асинхронной загрузкой данных при серверном рендеринге — то что выгодно отличает Next.js от других библиотек.
Для хранения данных в store задействуем redux. Асинхронные действия в redux выполняют при помощи middleware redux-thunk. Обычно (но не всегда), одно асинхронное действие имеет три состояния: START, SUCCESS FAILURE. Поэтому код определения асинхронного действия часто выглядит (во всяком случае для меня) сложным. В одном issue библиотеки redux-thunk обсуждался упрощенный вариант middleware, который позволяет определить все три состояния в одну строку. К сожалению, этот вариант так и не был оформлен в библиотеку, поэтому включим его в наш проект в виде модуля.
Создайте новый каталог redux в корневом каталоге приложения, и в нем — файл redux/promisedMiddlewate.js:
export default (...args) => ({ dispatch, getState }) => (next) => (action) => {
const { promise, promised, types, ...rest } = action;
if (!promised) {
return next(action);
}
if (typeof promise !== 'undefined') {
throw new Error('In promised middleware you mast not use "action"."promise"');
}
if (typeof promised !== 'function') {
throw new Error('In promised middleware type of "action"."promised" must be "function"');
}
const [REQUEST, SUCCESS, FAILURE] = types;
next({ ...rest, type: REQUEST });
action.promise = promised()
.then(
data => next({ ...rest, data, type: SUCCESS }),
).catch(
error => next({ ...rest, error, type: FAILURE })
);
};
Несколько разъяснений к работе этой функции. Функция midleware в redux имеет сигнатуру (store) => (next) => (action). Индикатором того, что действие асинхронное и должно обрабатываться именно этой функцией, служит свойство promised. Если это свойство не определено, то обработка завершается и управление передается следующему middleware: return next(action). В свойстве action.promise сохраняется ссылка на объект Promise, что позволяет “удержать” асинхронную функцию static async getInitialProps({ req, store }) до завершения асинхронного действия.
Все что связано с хранилищем даных поместим в файл redux/store.js:
import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import axios from 'axios';
import promisedMiddleware from './promisedMiddleware';
const promised = promisedMiddleware(axios);
export const initStore = (initialState = {}) => {
const store = createStore(reducer, {...initialState}, applyMiddleware(promised, logger));
store.dispatchPromised = function(action) {
this.dispatch(action);
return action.promise;
}
return store;
}
export function getTime(){
return {
promised: () => axios.get('http://time.jsontest.com/'),
types: ['START', 'SUCCESS', 'FAILURE'],
};
}
export const reducer = (state = {}, action) => {
switch (action.type) {
case 'START':
return state
case 'SUCCESS':
return {...state, ...action.data.data}
case 'FAILURE':
return Object.assign({}, state, {error: true} )
default: return state
}
}
Действие getTime() будет обработано promisedMiddleware(). Для этого в свойстве promised задана функция, возвращающая Promise, а в свойстве types — массив из трех элементов, содержащих константы ‘START’, ‘SUCCESS’, ‘FAILURE’. Значения констант могут быть произвольными, важным является их порядок в списке.
Теперь остается применить эти действия в компоненте pages/time.js:
import React from 'react';
import {bindActionCreators} from 'redux';
import Link from 'next/link';
import { initStore, getTime } from '../redux/store';
import withRedux from 'next-redux-wrapper';
function mapStateToProps(state) {
return state;
}
function mapDispatchToProps(dispatch) {
return {
getTime: bindActionCreators(getTime, dispatch),
};
}
class Page extends React.Component {
static async getInitialProps({ req, store }) {
await store.dispatchPromised(getTime());
return;
}
componentDidMount() {
this.intervalHandle = setInterval(() => this.props.getTime(), 3000);
}
componentWillUnmount() {
clearInterval(this.intervalHandle);
}
render() {
return (
<div>
<div>{this.props.time}</div>
<div>
<Link href="/">
<a>Return</a>
</Link>
</div>
</div>
)
}
}
export default withRedux(initStore, mapStateToProps, mapDispatchToProps)(Page);
Обращаю внимание что, здесь используется метод withRedux() из библиотеки next-redux-wrapper. Все остальные библиотеки общие для react.js и не требуют адаптации к Next.js.
Когда я впервые познакомился с библиотекой Next.js — она меня не очень впечатлила из-за достаточно примитивного роутинга “из коробки”. Мне казалось, что применимость этой библиотеки не выше сайтов-визиток. Сейчас я так уже не думаю, и планировал в этой же статье рассказать о библиотеке next-routes, которая существенно расширяет возможности роутинга. Но сейчас я понимаю, что это материал лучше вынести в отдельный пост. И еще в планах рассказать о библиотеке react-i18next, которая — внимание! — прямого отношения к Next.js не имеет, но очень удачно подходит для совместного применения.
apapacy@gmail.com
14 января 2018г.