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 сейчас можно найти большое количество проектов, реализующих идею универсальноко веб-приложения. Однако всем этим проектам присущи общие недостатки:

  1. малая численность контрибьюторов проектов
  2. это заготовки проектов для быстрого старта, а не библиотеки
  3. проекты не обновлялись при выходе новых версий react.js
  4. в проектах реализована только часть функционала, необходимого для разработки универсального приложения.

Первым удачным решением стала библиотека 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г.