javascript

Как начать разрабатывать универсальные приложения с библиотекой Next.js

  • пятница, 19 января 2018 г. в 03:14:32
https://habrahabr.ru/post/346960/
  • Разработка веб-сайтов
  • Поисковая оптимизация
  • ReactJS
  • Node.JS
  • JavaScript


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г.