Разработка изоморфного RealWorld приложения с SSR и Progressive Enhancement. Часть 2 — Hello World

  • Tutorial
В предыдущей части туториала мы узнали что такое проект RealWorld, определились целями туториала, выбрали стек технологий и написали простой веб-сервер на Express в качестве основы для изоморфного фронтенда.

В этой части, мы допилим серверную часть и напишем изоморфный «Hello World» на Ractive, а также соберем все это с помощью Webpack.



Заранее спасибо всем тем, кто продолжает читать данный туториал! Если вы действительно серьезно интересуетесь темой универсальных веб-приложений, то вам также стоит ознакомиться с серией статей на ту же тему «Универсальные приложения React + Express». Особенно это будет интересно тем, кто любит писать много кода (я нет).

Дисклеймер
Данный туториал предназначен прежде всего для frontend-разработчиков среднего и выше уровня. Которые знакомы с современными инструментами разработки и в курсе, что такое SPA и изоморфность.

В рамках туториала НЕ будут раскрываться вопросы установки npm-модулей, вводного знакомства с webpack, работы с командной строкой или иных базовых вещей на сегодняшний день. Исхожу из того, что для большинства читатетей рутинные операции по настройки дев-среды и работы с инструментами разработки и т.п. уже знакомы и отлажены.

Допиливаем сервер




Проксирование запросов


Итак, исходя из выбранной «high-level» архитектуры, описанной в первой части, необходимо организовать проксирование запросов на бекенд-сервер через фронтенд.

Почему именно так
В комментариях к первой части туториала, был задан резонный вопрос — зачем проксировать запросы на бекенд? С точки зрения изоморфного подхода это, вобщем-то, не обязательно. Особенно если вы любите трудности и решение нестандартных задач, неочевидными способами.

Однако практика показывает, что именно этот способ является наиболее быстрым, удобным и безболезненным решением целого ряда вопросов и проблем изоморфности, о которых речь пойдет дальше.

Кроме того, проксирование открывает некоторые дополнительные возможности:

  • Перехват клиентских атак, типа XSS/CSRF/etc.;
  • Сокрытие бекенд сервера + нет необходимости включать CORS на бекенде;
  • Возможности изоморфного кэширования данных и запросов;
  • Более безопасная работа с сессиями/токенами/итп;
  • Перехват запросов/ответов и внесение точечных изменений;
  • Точечная адаптация API под нужны клиента;
  • Изменение способов авторизации;
  • Упрощенная реализация асинхронного «stateful» функционала поверх синхронных «stateless» бекендов;
  • Работа поверх нескольких бекендов (или микросервисов);

И т.д. и т.п.

Для этого сначала изучим спецификацию по REST API проекта RealWorld. Сам API располагается по адресу: conduit.productionready.io/api

Так как лишний код я писать не люблю, поэтому не буду изобретать велосипедов и воспользуюсь модулем express-http-proxy. Данный модуль умеет не только проксировать http-запросы, но и предоставляет ряд «хуков» для разных этапов данного процесса, которые, очевидно, нам еще пригодятся.

Сначала напишем простой json-конфиг для нашего API, где определим URL куда проксировать, а также несколько дополнительных настроек.

./config/api.json

{
    "backendURL": "https://conduit.productionready.io",
    "timeout": 3000,
    "https": true
}

«https»: true означает, что даже если оригинальный запрос был осуществлен по http, проксировать его нужно на https. Удобно при работе с localhost.

Еще в первой части я подготовил специальный «api middleware» для проксирования запросов. Пора его написать:

./middleware/api.js

const proxy = require('express-http-proxy');

const config = require('../config/api.json');

module.exports = () => (req, res, next) => {
    proxy(config.backendURL, {
        https: config.https,
        timeout: config.timeout
    })(req, res, next);
};

Пожалуй пока этого достаточно. Уже сейчас все запросы на /api/* фронтенда будут проксироваться на бекенд сервер. Иными словами, если запросить у фронтенд-сервера GET /api/articles, ответом придет JSON вида:

{
  "articles":[...],
  "articlesCount": 100
}

Так как мы планируем работать не только с GET-запросами, но и со всеми возможными REST-глаголами (POST/PUT/DELETE), а также выполнять запросы без JS на клиенте (т.е. по средствам html-форм), необходимо также внести парочку изменений в основной файл веб-сервера из первой части:

./server.js

const methodOverride = require('method-override');

server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));

Обратите внимание, что парсить тело запроса мы будем как в json (через ajax), так и urlencoded (сабмит формы). Модуль method-override будет перезаписывать метод http-запроса на тот, который указан в специальном URL Query параметре _method. Это связано с тем, что html формы поддерживают только методы GET и POST. Работать это будет примерно так:

<form action="/something?_method=PUT" method="POST">
.....
</form>

Если на клиенте отключен JS и форма была засабмичена, то данный модуль автоматически для нас заменит оригинальный POST на PUT и прокси получит уже правилный REST-глагол для дальнейшего проксирования. Просто и не напрягаясь.

Про перезапись http-метода
Интересный факт заключается в том, что некоторые «гуру» RESTful сервисов советуют использовать подобный query-параметр при проектировании REST API на бекендах. Ну типа, мы же хотим подумать и о тех клиентах нашего API, которые поддерживают лишь ограниченный список HTTP методов.

Однако, по факту, это актуально лишь для браузерных html-форм. В этом случае, не очень разумно осуществлять поддержку столь узкого кейса на бекенде, который используется различными типами клиентов. Большая часть из которых не нуждается в данной фиче. В то же время, использование этой техники в рамках фронтенд-севера вполне себе обосновано, а главное не затрагивает интересы других типов клиентов. Вот так вот.

Полный код server.js
const express = require('express'),
      helmet = require('helmet'),
      compress = require('compression'),
      cons = require('consolidate'),
      methodOverride = require('method-override');

const app = require('./middleware/app'),
      api = require('./middleware/api'),
      req = require('./middleware/req'),
      err = require('./middleware/err');

const config = require('./config/common');

const server = express();

server.engine('html', cons.mustache);
server.set('view engine', 'html');

server.use(helmet());

server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));

server.use(compress({ threshold: 0 }));
server.use(express.static('dist'));

server.use(req());

server.all('/api/*', api());

server.use(app());
server.use(err());

server.listen(config.port);


В итоге, мы имеем полноценный прокси запросов на бекенд, а также поддержку запросов из html-форм.



Проблема начального состояния


Сразу хочу оговориться, здесь под «начальным состоянием» я подразумеваю не начальное состояние данных приложения, а набор входных параметров с которыми должно запускаться приложение. Для каждого конкретного проекта подобный набор параметров будет разным, либо может вообще отсутствовать. Базовый кейс — наличие или отсутствие авторизации.

Скорее всего начальное состояние было выставлено вашим пользователем во время предыдущего сеанса работы (например, выполнен вход в аккаунт) и после того как пользователь вернулся, он ожидает увидеть приложение именно с таким начальным состоянием (залогинен).

Стандартное SPA сперва загружается на страницу, запускаются скрипты и потом уже делаются запросы на получение данных. В этот момент скрипт может получить параметры начального состояния исходя из данных браузера или еще как-то, и уже потом делать запросы к API.

В отличии от SPA, изоморфное приложение не имеет никакого, условно говоря, «загрузчика». Изоморфное приложение в этой части больше похоже на обыкновенный веб-сайт. Иными словами, начальное состояние должно быть получено в момент первого синхронного запроса, чтобы отрендеренная на сервере страница полностью соответствовала состоянию, которое ожидает пользователь. Конечно бывают случаи, когда разработчики ленятся и тогда мы видим как сервер рендерит страницу с каким-то состоянием по-умолчанию, потом запускаются скрипты на клиенте и клиент опять делает всю работу. Это не есть правильный подход, а в манифесте этого проекта четко написано — никаких костылей (п.7)!

Решается этот вопрос, пожалуй, единственным доступным на данный момент способом — с помощью печенек (думаю многие сразу догадались увидев постер). Да, действительно, куки это фактически единственный официальный способ передачи начального состояния между несколькими синхронными запросами к серверу. Конечно если речь идет о браузере.

Ну а так как мы хорошо понимаем, что в плане хранилища куки, как бы не очень подходят (всего 4Кб), а главное пока не понимаем какие именно параметры начального состояния нам в итоге придется использовать, рука так и тянется к сессиям! Тем самым обыкновенным сессиям, когда в куку записывается некий session_id (sid), а на сервере хранится целый ворох данных, связанных с этим идентификатором.

Думаю в общих чертах понятно зачем нам это нужно и опять же ничего тут придумывать я не буду. Возьму классический express-session, немного нашпигую его параметрами и мы получим вполне себе рабочий механизм без лишних трудностей.

В главный конфиг добавим объект «session» с настройками для модуля.

./config/common.json

{
    "port": 8080,
    "session": {
        "name": "_sid",
        "secret": "ntVA^CUnyb=6w3HAgUEh+!ur4gC-ubW%7^e=xf$_G#wVD53Cgp%7Gp$zrt!vp8SP",
        "resave": false,
        "rolling": false,
        "saveUninitialized": false,
        "cookie": {
            "httpOnly": true,
            "secure": false,
            "sameSite": true
        }
    }
}

Несколько ключевых настроек для нашей печеньки — всегда следует выставлять httpOnly и sameSite. При переходе на SSL, можно будет активировать еще и secure (кука будет отправляться только при работе через https).

Добавляем этот модуль в файл веб-сервера:

./server.js

const session = require('express-session');

server.use(session(config.session));

Полный код server.js
const express = require('express'),
      helmet = require('helmet'),
      compress = require('compression'),
      cons = require('consolidate'),
      methodOverride = require('method-override'),
      session = require('express-session');

const app = require('./middleware/app'),
      api = require('./middleware/api'),
      req = require('./middleware/req'),
      err = require('./middleware/err');

const config = require('./config/common');

const server = express();

server.engine('html', cons.mustache);
server.set('view engine', 'html');

server.use(helmet());
server.use(session(config.session));

server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(methodOverride('_method'));

server.use(compress({ threshold: 0 }));
server.use(express.static('dist'));

server.use(req());

server.all('/api/*', api());

server.use(app());
server.use(err());

server.listen(config.port);


Теперь, кроме всего прочего, у нас имеется механизм сохранения и передачи начального состояния приложения между несколькими синхронными запросами. Так как эта задача целиком и полностью вытекает из особенностей работы с браузером, фронтенд-сервер это наилучшее место для таких вещей. Если бы мы делали такие вещи на бекенде, это могло бы затронуть другие типы клиентов (например мобильные), для которых нет нужды в подобных ухищрениях. В этом еще один из плюсов универсальных веб-приложений — мы получаем безопасное окружение (фронтенд-сервер), в котором можем выполнять фронтенд-зависимые задачи без ущерба для безопасности и без необходимости «засорят» бекенд подобными вещами. Это особенно актуально когда бекенд работает со множеством типов клиентов.

Hello world




Ну что ж, пока это выглядит довольно просто, но черт возьми, скажете вы, где же изоморфность? Все будет, но для начала, давайте разберемся с такой ее частью как Server-side rendering (SSR).

Для начала напишем простой изоморфный «hello world», используя RactiveJS.

./src/app.js

const Ractive = require('ractive');

Ractive.DEBUG = (process.env.NODE_ENV === 'development');
Ractive.DEBUG_PROMISES = Ractive.DEBUG;

Ractive.defaults.enhance = true;
Ractive.defaults.lazy = true;
Ractive.defaults.sanitize = true;

const options = {
    el: '#app',
    template: `<div id="msg">Static text! + {{message}} + {{fullName}}</div>`,
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User'
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    }
};

module.exports = () => new Ractive(options);

Этот файл является входной точкой нашего изоморфного приложения. Давайте рассмотрим его подробнее.

RactiveJS экспортирует одноименный конструктор с помощью которого можно создавать Ractive-инстансы, а также Ractive-компоненты (подробнее об этом в следующих частях). Многим подобный подход может напомнить VueJS и это неспроста. Фактически, Ractive является одним из прообразов для Vue и их api до сих пор очень похожи.

Но вернемся к коду и сперва разберемся какие именно статические свойства конструктора я выставил для данного приложения. Во-первых, это Ractive.DEBUG и Ractive.DEBUG_PROMISES с помощью которых мы включаем или выключаем информационные сообщения об ошибках в зависимости от текущего окружения.

Далее идет флаг Ractive.defaults.enhance, активирующий один из ключевых аспектов изоморфности — переиспользование разметки полученный в результате SSR на клиентской стороне. Именно это сейчас чаще всего называют непонятным термином hydrate.

Теория по hydrate
Если по-простому, то фишка в том, что после того, как приложение инициализируется на клиенте оно может «захотеть» перерендерить всю разметку взамен той разметки, которая пришла с сервера (SSR). Не то чтобы это супер плохо для изоморфности — поддержку SEO и многие другие плюшки мы все равно получаем. Однако в любом случае это крайне нерационально.

Поэтому важно не просто уметь делать SSR (это сейчас умеют многие). Хорошо еще когда ваш фреймворк умеет делать эту самую «гидрацию», т.е. может проанализировать текущую разметку и данные, чтобы понять, что результатом повторного рендера приложения или отдельных его компонентов будет та же самая разметка, а значит делать этого не нужно (либо нужно, но частично). Далее, просто «оживить» существующую разметку, т.е. «навесить» все необходимые ивент-лисенеры, хендлеры или чего там еще ему надо.

Все это с относительно недавнего времени умеют все представители «большой тройки». Ractive научился этому еще раньше, именно поэтому использует свой собственный термин «enhance», вместо введенного реактом «hydrate». Просто не было тогда еще такого термина)))

Ractive.defaults.enhance = true;

По-умолчанию, этот флаг выставлен в false и данная строка кода активирует сею возможность сразу для всех компонентов Ractive. Другими словами, одной строчкой кода можно сделать так, чтобы ваше приложение на Ractive стало переиспользовать разметку с сервера. В то же время, если какой-то отдельный компонент вдруг не требует «гидрации», ее можно отключить локально через его опции.

Напоследок еще два флага, которые я всегда выставляю:

  • Ractive.defaults.sanitize позволяет на этапе парсинга шаблонов вырезать небезопасные html-теги.
  • Ractive.defaults.lazy говорит фреймверку использовать поздние DOM-события (change, blur), вместо немедленно исполняемых (keyup, keydown) для two-way bindings (да-да, двойное связывание рулит).

По поводу two-way bindings
Всех «two-way bindings» хейтеров и «one-way data flow» ловеров попрошу воздержаться от холивара в комментариях. Это не вопрос религии. Если вы считаете, что лично для вас двойное-связывание данных представляет угрозу, то не используйте его и будете правы. Никогда не нужно зазря подвергать себя опасности.

В своих приложениях я, как правило, активно использую двойное связывание там где это необходимо и удобно, и пока не испытываю с этим каких-то серьезных проблем. Благо Ractive не сильно религиозный фреймверк и не заносит в разработчика какие-то свои собственные морально-этические принципы. Подход, который использует Ractive отлично описан на главной странице его сайта:
Unlike other frameworks, Ractive works for you, not the other way around. It doesn't have an opinion about the other tools you want to use with it. It also adapts to the approach you want to take. You're not locked-in to a framework-specific way of thinking. Should you hate one of your tools for some reason, you can easily swap it out for another and move on with life.
Если вы ненавидите или боитесь двойного связывания, данная проблема решается в Ractive одной строкой:

Ractive.defaults.twoway = false;

После этого все ваши компоненты потеряют возможность двойного связывания, если конечно вы не захотите включить его для какого-то конкретного компонента (twoway: true в опциях) или же даже конкретного поля для ввода (деректива twoway=«true»). Как правило, это удобно.

Интересный кейс с lazy
Не смог удержаться. Дело в том, что lazy может применяться не только глобально и локально для каждого компонента, но и точечно в качестве директивы поля для ввода. Кроме того, lazy может принимать не только Boolean, но также число — кол-во миллисекунд для задержки.

С помощью этого замечательного свойства, можно, например, очень быстро и лаконично решить одну из часто встречающихся задач веб-разработки — строка поискового запроса:

<input type="search" value="{{q}}" lazy="1000"/>

Рабочий пример

В итоге, я формирую объект с опциями для инстанса приложения. Где сообщаю в какой существующий DOM-элемент рендерить приложение (el), определяю какой-то тестовый шаблон (template), пока в виде строки. Определяю объект с данными (data) и одно вычисляемое свойство (fullName).

Выглядит довольно тупо, потому что пока мы пишем лишь «Hello world» и цель его — протестировать SSR и «гидрацию» на клиенте. Позднее расскажу как мы это сможем проверить.

Теперь напишем серверное «app middleware», в которое попадают все запросы не попавшие в прокси.

./middleware/app.js

const run = require('../src/app');

module.exports = () => (req, res, next) => {

    const app = run();
	
    const meta = { title: 'Hello world', description: '', keywords: '' },
        content = app.toHTML(),
        styles = app.toCSS();

     app.teardown();

     res.render('index', { meta, content, styles });
};

Тут прям вообще все просто. Если вы обратили внимание, главный файл приложения (./src/app.js) экспортирует функцию, которая возвращает новый Ractive-инстанс, т.е. по сути новый объект приложения. Это не слишком важно, когда код исполняется на клиенте — мы скорее всего не будем создавать более чем один инстанс приложения в пределах вкладки. Однако для исполнения кода на «stateful» nodejs-сервере крайне важно иметь возможность создавать новый объект приложения для каждого синхронного запроса. Думаю это очевидно.

Итак, в момент запроса мы создаем новый объект приложения. Создаем некий объект с мета-тегами (пока статически), а далее 2 строчки кода с тем самым пресловутым SSR:

  • app.toHTML() — рендерит текущее состояние приложения в строку;
  • app.toCSS() — собирает все «component specific» стили, уже разбитые по неймспейсам и также возвращает их строкой.

Вот так просто это работает в Ractive. И да, функционал компонентных стилей уже имеется из коробки.

Далее, я уничтожаю текущий инстанс приложения вызовом метода teardown() и рендерю серверный шаблон "./views/index.html" с полученными значениями, одновременно отправляя ответ. Вот и весь великий и ужасный SSR.


Серверные шаблоны


Теперь немного «усов». Итак, у нас есть папочка "./views", где будут лежать серверные шаблоны и мы ожидаем в ней наличие того самого «single-page» index.html в который у нас будет рендерится наше замечательное изоморфное приложение. Однако писать index.html мы не будем.

Все потому, что итоговые клиентские бандлы, генерируемые Webpack, должны прописываться во время сборки с его же помощью. Поэтому придется создать шаблон для шаблона! ;-)

./views/_index.html

<!doctype html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="description" content="{{ meta.description }}">
        <meta name="keywords" content="{{ meta.keywords }}"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">

        <title>{{ meta.title }}</title>

        <link rel="stylesheet" href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css">
        <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic">
        <link rel="stylesheet" href="//demo.productionready.io/main.css">

        <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
        <link rel="icon" type="image/png" href="/img/favicon.png">
        <link rel="apple-touch-icon" href="/img/favicon.png">

        <link rel="manifest" href="/manifest.json">

        <style>
            {{& styles }}
        </style>
    </head>
    <body>

        <div id="app">
            {{& content }}
        </div>

        <script>
            window.msgEl = document.getElementById('msg');
        </script>

    </body>
</html>

Тут тоже ничего особенного, просто полноценный HTML файл, с доктайпом, всякими мета-тегами, ссылками на файлы стилей и шрифты, которые предоставляет RealWorld и так далее. Здесь мы видим как будут шаблонизироваться мета-теги title, description и keywords. Также обратите внимание, что компонентные стили просто помещаются на страницу в style-тег. И конечно же, наше отрендеренное приложение помещается в элемент с соответствующим идентификатором. Именно в этом html-элементе приложение будет искать и «гидрировать» разметку на клиенте (тот самый тег #app, который обычно пустует при классическом SPA-подходе)

После сборки клиентского кода, Webpack пропишет в конец данного файла (непосредственно перед закрывающимся тегом body) все файлы бандлов в той последовательности подключения и с теми именами, которая предусмотрены проектом и его конфигом, а также сгенерирует итоговый index.html(об этом в следующем разделе). Вот так как-то.



Webpack


Я обещал, что не буду использовать никаких специфичных инструментов для написания изоморфного приложения. Это же касается Webpack и его конфигов.

Поэтому я просто возьму существующий конфиг вебпака, который я использовал для предыдущего демо-проекта (подробнее в первой части) и буду использовать его в таком виде, как он есть. Более того, в тот демо-проект этот конфиг также попал из какого-то другого проекта и практически не дорабатывался. Там даже остались рудименты, которые по сути не нужны в этих проектах, но они не мешаются и мне лень их выпиливать. Работает и ладно.

В отличие от абсолютного большинства туториалов и стартер-китов на изоморфную тему, я не буду писать отдельные webpack-конфиги для клиента и сервера. Более того, я вообще не буду осуществлять сборку бандлов для сервера. Все файлы приложения на сервера будут работать без каких-либо манипуляций с ними.

Почему так
Краткий ответ: для сервера сборка не имеет практического смысла.

Если более подробно: мне часто приходится работать с множеством всевозможных окружений и единственное из них, которое полностью находится под моим контролем — это фронтенд-сервер. Поэтому я привык писать свой код исходя из возможностей подконтрольного окружения и на сервере мне не требуются всевозможные транспайлеры, минификация кода и уж тем более сборка кода в бандлы. Однако, все это необходимо для неопределенного списка неподконтрольных окружений, поэтому клиентский код собирается вебпаком со всеми выкрутасами.

Как я уже говорил, обучение работе с Webpack выходит за рамки данного туториала. Моя задача показать, что писать универсальные веб-приложения можно без каких-либо специфических конфигов. И все же я обращу внимание на некоторые ключевые моменты.

Входная точка у нас ./src/app.js

     entry: {
         app: [
	     path.resolve(__dirname, 'src/app'),
	 ]
    },

Бандлы генерятся в папку ./dist и именуются по следующим правилам:

    output: {
        path: path.resolve(__dirname, 'dist'),
	publicPath: '/',
	filename: `[name]-${ VERSION }.bundle.[hash].js`,
	chunkFilename:  `[name]-${ VERSION }.bundle.[chunkhash].js`,
    },

Весь код, кроме 3rd-party модулей пропускается через Babel:

    {
        test: /\.(js)$/,
	exclude: /(node_modules|bower_components)/,
	loader: 'babel-loader',
    },

Самый неоднозначный кусок конфига:

    new WrapperPlugin({
        test: /\app(.*).js$/,
	header: '(function(){"use strict";\nreturn\t',
	footer: '\n})()();'
    }),

Раньше я использовал WrapperPlugin чисто для того, чтобы применить strict-режим сразу для всего app-бандла. Однако для универсальных веб-приложений я также применяю его, чтобы экспортировать приложение в виде IIFE, т.е. сразу же запустить приложение, как только бандл скачался. К сожалению, Webpack не поддерживает IIFE в качестве libraryTarget. Пожалуй это единственный кусок конфига, который я дописал для изоморфного проекта. Хотя даже он не имеет к ней прямого отношения, ведь я мог бы вызвать функцию и вручную.

Далее, я выношу все 3rd-party модули в отдельный бандл:

    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',
        minChunks: ({ resource }) => (
            resource !== undefined &&
            resource.indexOf('node_modules') !== -1
        )
    }),

Как и обещал дописываю бандлы в конец моего шаблона для шаблона и генерирую index.html:

    new HtmlWebpackPlugin({
        template: './views/_index.html',
	filename: '../views/index.html',
	minify: {
	    html5: true
	},
	hash: true,
	cache: true,
	showErrors: false,
    }),

Отчищаю output-директорию и копирую туда статические ассеты:

    new CleanWebpackPlugin(['dist']),
    new CopyWebpackPlugin([{
	from: 'assets',
	force: true
     }]),

Остальные части конфига не представляют интереса в контексте проекта. Там всякие UglifyJSPlugin, BundleAnalyzerPlugin и другие полезные и не очень штуки.



Еще немного сервера


Остались без реализации два файла, анонсированные в первой части туториала: «req middleware» и «err middleware». Последний файл это обычный Error-handling middleware экспресса. С его помощью мы будем отдавать специальную страницу (./views/error.html) с чисто серверными ошибками, либо json, если серверная ошибка возникла во время ajax-запроса. Пока он будет выглядеть как-то так:

module.exports = () => (err, req, res, next) => {
    res.status(500);
    (req.accepts(['html', 'json']) === 'json') ?
        res.json({ errors: { [err.name]: [err.message] } }) : 
        res.render('error', { err });
};

Немного странный формат json-ответа обусловлен тем, что я сразу мимикрирую под формат ошибок, принятый в спецификации RealWorld. Для унификации так сказать.

Второе «req middleware» вообще пока оставим холостым, но уверен он еще пригодится.

module.exports = () => (req, res, next) => next();



Тестируем SSR и hydrate


Уверен, что все, итак, в курсе каким образом можно проверить работу SSR — просто открываем "Просмотр кода страницы" и видим что тег #app не является пустым (как это обычно бывает в SPA), а содержит разметку нашего приложения. Cool, с hydrate чуть сложнее.

Внимательный глаз мог заметить вот этот непонятный кусок кода, который как бы «ни к селу ни к городу» присутствует в нашем серверном шаблоне index.html:

window.msgEl = document.getElementById('msg');

Именно с его помощью мы сможем проверить, работает ли наша «гидрация» или нет. Открываем консоль и вводим:

msgEl === document.getElementById('msg');

Если true, значит элемент не был перерисован клиентским кодом. Можно также поэкспериментировать и выставить значение Ractive.defaults.enhance = false;, пересобрать и перезапустить приложение и убедиться, что в этом случае данная проверка вернет значение false. Что означает, что клиентский код перерисовал разметку.

Таким образом и SSR и «гидрация» прекрасно работают как со статическими, так и с динамическими, так и с очень динамическими значениями (вычисляемые свойства). Что и требовалось проверить.

Репозиторий
Демо

В следующей части данного туторила мы решим еще две ключевые проблемы изоморфных веб-приложений: изоморфный роутинг и навигация, и повторный фетчинг и начальное состояние данных. Не смотря на то, что я собираюсь посвятить этим вопросам отдельную статью, само решение займет у нас буквально 5 строк кода приложения. Не переключайтесь!

Спасибо за внимание! Если понравилось, следите за продолжением! Всех причастных с праздником и удачи!

UPD: Разработка изоморфного RealWorld приложения с SSR и Progressive Enhancement. Часть 3 — Routing & Fetching

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Про детализацию статей

Поделиться публикацией

Комментарии 7

    0
    Вопрос о серверных шаблонах — вы используете handlebars через модуль consolidate, почему не express-handlebars? Есть какие-то преисущества у consolidate?
      0
      Пожалуй это больше привычка)) Так то у consolidate есть некоторые дополнительные фичи, которые, однако, все равно не будут использоваться для такой примитивной шаблонизации (основная работа останется за Ractive). Кроме того consolidate чуть более «официальный», ибо от автора express, и обновляется он чуть чаще чем express-handlebars.

      Как бы там ни было, все это не принципиальные вещи в контексте проекта. Вполне можно юзать express-handlebars.
      +1
      Спасибо за интересную информацию. Ничего не знал о таком полезном проекте. Честно говоря примеры с ToDoApp меня уже давно перестали интересовать тк порой то что так красиво и элегантно реализовано в ToDoApp не всегда означает что проект из реального мира будет так же лаконичен. Мне чисто по работе больше всего понравился проект на Elm. Хотя его синтаксис сложен для понимания. И не знаю может ли он быть изоморфным а это я считаю основным если делать не админку или внутри корпоративное приложение. Вдохновленный Вашим примера я запилил react hot universal реализацию github.com/gothinkster/realworld/issues/186
        0
        Прикольно, спасибо что бросили ссылку. Посмотрю сорцы на досуге. За одно, теперь сможем сравнить результаты как только я все доделаю. Думаю это будет интересно.
        0
        Никак не могу перейти на термин универсальные и иногда проскальзывают у меня изоморфные.
          0
          Лично я предпочитаю именно термин «изоморфные» потому что он больше нигде не используется и сразу понятно о чем речь.

          Термин «универсальные» слишком универсален))) Например вот
            0
            Упс, ссылка с мобильного не добавилась почему-то, вот

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое