Pull to refresh

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

Reading time 24 min
Views 4.8K
В предыдущей части туториала мы научили наше изоморфное приложение проксировать запросы к backend api, с помощью сессии передавать начальный стейт между синхронными запросами и осуществлять Server-side rendering с возможностью переиспользования разметки на клиенте (hydrate). В этой части мы решим еще две ключевые проблемы изоморфных веб-приложений: изоморфный роутинг и навигация, и повторный фетчинг и начальное состояние данных. И сделаем это буквально 5-ю строками кода. Погнали!

image

Пролог


Про манифест


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

К сожалению, на производительность Ractive я навряд ли смогу серьезно повлиять (хотя и предложу несколько оптимизаций). Однако, две другие характеристики — размер бандла и количество строк кода, я вполне могу внести в манифест проекта. Итак, обновленный манифест будет выглядеть так:

«Манифест» проекта:

  1. Соответствовать спецификации проекта RealWorld;
  2. Полностью поддерживать работу на сервере (SSR и все прочее);
  3. На клиенте работать как полноценное SPA;
  4. Индексироваться поисковиками;
  5. Работать с выключенным JS на клиенте;
  6. 100% изоморфного (общего) кода;
  7. Для реализации НЕ использовать «полумеры» и «костыли»;
  8. Использовать максимальной простой и общеизвестный стек технологий;
  9. Размер итогового бандла не должен превышать 100Кб gzip;
  10. Количество строк кода приложения не должно превышать 1000 loc.





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

Думаю мне будет достаточно, если я выполню все условия манифеста и при этом количество строк кода и размер бандла будут сопоставимы с минимальными значениями других реализаций. Проще говоря хочется, чтобы моя реализация была на уровне React/Mobx и Elm по размеру бандла и на уровне Apprun и CLJS re-frame по количеству строк кода. Это также будет своего рода достижение, учитывая, что другие реализации не обладают всеми задекларированными возможностями. Но, поживем — увидим.

Про логотип




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

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


Предыдущие части туториала содержали в себе опросы, результаты которых не могут не радовать. Более 80% читателей сочли тему туториала интересной и столько же, так или иначе, высказались за текущий уровень детализации. Однако, создавая опрос про детализацию, я, если честно, надеялся что результат будет другим. Что всем, итак, все понятно и уровень детализации, а значит и объем материала, можно будет сократить. Оказалось, что это не так.

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

Далее, мы будем эксплуатировать созданную «инфраструктуру» и шаг за шагом реализовывать спецификацию проекта RealWorld и пункты манифеста. Хочу еще раз обратить ваше внимание, что код самого приложения мы так и не начали писать, но уверяю — это не проблема. Дальше дело заметно ускорится. Думаю придется компенсировать это ускорение, а также неизбежное снижение детализации, путем обсуждения подробностей в комментариях. Так что welcome!

Routing




Сначала коротко расскажу основную идею, потом посмотрим реализацию. Так вышло, что в мире фронтенда доминируют 2 основных подхода к роутингу внутри SPA приложений:

Config-based routing (Angular & Co)

Способ определения списка путей (роутов) и их соответствия компонентам, которые выступают в виде своеобразных «страниц», в неком конфигурационном файле. Условно это может выглядеть так:

const routes = [
  { path: /some pattern/, component: MyComponentConstructor, ...otherOptions },
];

При этом в шаблоне, как правило, есть какой-то якорный элемент (компонент или просто тег), куда будет рендериться сработавший компонент.

Component-based routing (React & Co)

Маршруты определяются прямо в шаблоне с использованием специальных роут-компонентов, которые через свойства принимают паттерн маршрута и другие необходимые опции. Соответственно разметка, которая представляет собой «страницу», находится внутри тега роут-компонента, как-то так:

<Route path="some pattern" ...otherOptions>
  <MyComponent ...someProps />
</Route>

Чем же плохи эти подходы? Ответ — ничем, пусть будут. Однако, оба подхода имеют ряд минусов:

  1. Config-based routing — слишком много бойлерплейта, слишком далеко от контекста. Как правило, роут резолвится в один определенный компонент, что не очень гибко.
  2. Component-based routing — близко к контексту, однако зачем-то используется теги компонентов фактически в качестве условных операторов. Сложно предугадать все необходимые опции для роутинга, поэтому он всегда ограничен возможностями роут-компонента (т.е. теми настройками, которые он может принимать).

Вполне возможно, что все это притянуто за уши. Однако один минус присущ обоим этим подходам совершенно точно — низкая гибкость маршрутизации, по типу «этот роут — такой компонент, тот роут — сякой компонент».

В то же время, чаще всего логика клиентской маршрутизации не ограничивается лишь сопоставлением регулярки с текущим URL. Как мне кажется, вообще не совсем корректно рассматривать роутинг в отрыве от общего состояния приложения (state). Это такая же часть состояния, как и любые другие данные. И поэтому, чтобы иметь возможность максимально гибко работать с состоянием приложения и UI-состоянием (как визуальной его составляющей), нам необходимо использовать иные подходы.

State-based routing

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

{{#if ! loggedIn}}
<a href="">Login</a>
{{/if}}

Это совершенно нормально, здесь мы проверяем текущее состояние на предмет того, залогинен пользователь или нет.

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

'/*#login'

Довольно прикольно, что можно, просто указав соответствующий hash, открыть модальное окно на любой странице вообще без дополнительных действий:

{{#if ! loggedIn}}
<a href="/{{currentPath}}#login">Login</a>
{{/if}}

А также закрыть данное модальное окно простым нажатием кнопки «Назад» в браузере или даже history.back().

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

<Route path="/*#login">
  <NotAuthorized>
     <Modal>
         <form>...</form>
     </Modal>
  </NotAuthorized>
</Route>

Ну что ж, наверное можно и так. А что, если таких дополнительных условий будет несколько? Хм.

И все же, что если призадуматься и рассмотреть маршрут как часть общего стейта приложения? Можно придумать уйму кейсов, где роут работает совместно с другими частями состояния. Тогда почему же мы так стремимся выделить его каким-то дополнительным синтаксисом, всячески отделить от остального стейта? Вот и я не знаю.

К чему я все это пишу и как это относится к изоморфности? На самом деле никак)))) Просто хочу, чтобы вы не удивлялись, когда увидите в моем коде подобные незатейливые конструкции, выступающие в роли роутинга:

{{#if $route.match('/*#login') && ! loggedIn }}
<modal>
    <form>...</form>
</modal>
{{/if}}

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

А теперь по делу. У изоморфного роутинга есть только три основных момента, которые имеют значение:

  1. Ваш роутер должен давать возможность выставить текущий URL вручную и диспатчить эти изменения;
  2. Не ломаться в среде NodeJS, т.е. абстрагироваться от enviroment-specific вещей;
  3. Ваш роутинг должен быть «внутри» приложения, а не «снаружи».

Часто вижу, как разработчики выносят роутинг далеко «наружу», отдаляя его от общего стейта и от контекста. Также частенько те, кто пытаются писать изоморфные приложения, будто целенаправленно используют отдельно серверный (например средствами Express) и клиентский роутинг. Иногда с общими конфигами, иногда даже с отдельными. Но хватит о грустном.

В своих проектах я использую плагин роутера для Ractive. По факту это не более чем обертка над PageJS и qs, которая реализует State-based подход к маршрутизации. Собственный код этого «роутера» занимает от силы 100 строк кода и фактически тупо проксирует стейт роутера на реактивный стейт Ractive и обратно. Роутер может быть применен как глобально и сразу быть доступным для всех компонентов, так и изолированно к конкретному инстансу компонента. С его помощью можно делать всякие такие штуки:

{{#if $route.match('/products/:id') }} 

     <product id="{{$route.params.id}}" cart="{{$route.state.cart}}"></product>
     
     {{#if ! loggerIn }}
      <a href="#login">Login to buy it</a>
     {{/if}}
 
{{elseif $route.match('/products') }}
     <products filters="{{$route.query}}"></products>
{{else}}
      <p>404 - Not found</p>
      <a href="/products">Go to search the best products</a>
{{/if}}

{{#if $route.match('/*#login') && ! loggerIn  }}
<modal>
    <form>...</form>
</modal>
{{/if}}

И даже такие:


// get route or a parts
this.get('$route');
this.get('$route.pathname');
this.get('$route.query');
this.get('$route.params');
this.get('$route.state');

// navigate to another route
this.set('$route.pathname', '/product/1');

// set history state
this.set('$route.state', state);

// listen route changes
this.observe('$route', (val, old, keypath) => {});

Пишем код


Давайте сперва подключим наш роутер к приложению и научим его быть изоморфным:

./src/app.js
Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

Полный код ./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;

Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

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);


./middleware/app.js
const route = app.$page.show(req.url, null, true, false);
...
const meta = route.state.meta;

Полный код ./middleware/app.js
const run = require('../src/app');

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

    const app = run(),
          route = app.$page.show(req.url, null, true, false);
	
    const meta = route.state.meta,
          content = app.toHTML(),
          styles = app.toCSS();

     app.teardown();

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


Поздравляю, теперь в нашем приложении есть полностью изоморфный роутинг! Обратите внимание, что на сервере я просто выставил текущий URL в роутер и задиспатчил его. Это все что нужно сделать, если ваш роутер отвечает обозначенным условиям. Также я использую совершенно канонические ссылки — это очень важно в контексте изоморфности и прогрессивного улучшения.

Кроме того, и клиент и сервер теперь поддерживают динамические мета-теги (title, description и keywords), которые прописываются в специальном конфге и подключаются к роутеру в момент его инициализации. Данный конфиг выглядит очень просто и не является обязательным:

./config/meta.json
{
  "/" : {
    "title": "Global Feed",
    "description": "",
    "keywords": ""
  },
  ...
}

Давайте теперь используем наш роутер, чтобы создать несколько страниц. Для этого создадим основной шаблон приложения (app.html) и partials для шапки (navbar.html) и подвала (footer.html). Для этого просто скопируем туда готовую разметку из спецификации RealWorld и добавим немного динамики:

./src/templates/partials/navbar.html
<nav class="navbar navbar-light">
    <div class="container">
       {{#with @shared.$route.pathname as pathname}}
        <a class="navbar-brand" href="/">conduit</a>
        <ul class="nav navbar-nav pull-xs-right">
            <li class="nav-item">
                <a href="/" class-active="pathname  === '/'" class="nav-link">
                    Home
                </a>
            </li>
            <li class="nav-item">
                <a href="/login" class-active="pathname  === '/login'" class="nav-link">
                    Sign in
                </a>
            </li>
            <li class="nav-item">
                <a href="/register" class-active="pathname === '/register'" class="nav-link">
                    Sign up
                </a>
            </li>
        </ul>
        {{/with}}
    </div>
</nav>

./src/templates/partials/footer.html
<footer>
    <div class="container">
        <a href="/" class="logo-font">conduit</a>
        <span class="attribution">
            An interactive learning project from <a href="https://thinkster.io">Thinkster</a>. 
            Code & design licensed under MIT.
        </span>
    </div>
</footer>

./src/templates/app.html
<div id="page">
{{>navbar}}

{{#with @shared.$route as $route }}

{{#if $route.match('/login')}}
<div fade-in-out>
    <div class="alert alert-info"><strong>Login</strong>. {{message}}</div>
</div>
{{elseif $route.match('/register')}}
<div fade-in-out>
    <div class="alert alert-info"><strong>Register</strong>. {{message}}</div>
</div>
{{elseif $route.match('/')}}
<div fade-in-out>
    <div class="alert alert-info">
        <strong>Hello, {{fullName}}!</strong> You successfully read please <a href="/login" class="alert-link">login</a>.
    </div>
</div>
{{else}}
<div fade-in-out>
    <p>404 page</p>
</div>  
{{/if}}

{{/with}}

{{>footer}}
</div>

И не забудем зарегистрировать эти шаблоны в инстансе приложения:

./src/app.js
const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    ....
};

Полный код ./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;

Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User'
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    }
};

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


Внимательный читатель, уверен, уже приметил несколько моментов. Подробнее о них добровольно читайте под спойлерами.

Анимация перехода
Ractive из коробки имеет возможность осуществлять анимированные переходы (transition) при появлении или исчезновении элементов. Для этого, необходимо импортировать соответствующий transition-плагин (ractive-transitions-fade), зарегистрировать его либо глобально, либо локально, как сделано у меня, и использовать плагин с помощью специальной директивы (fade-in-out).

В данном случае, я использую банальный fade с настройками по-умолчанию, но плагины поддерживают установку настроек, например:

<div fade-in="{ duration: 500 }"><!-- только при появлении с duration 500 ms --></div>
<div fade-out="{ delay: 500 }"><!-- только при скрывании c delay 500 ms --></div>


Пре-парсинг шаблонов
Ractive поддерживает несколько вариантов регистрации шаблона для компонента:

// Selector (script tag with type="text/ractive")
template: '#my-template',

// HTML string
template: `<p>{{greeting}} world!</p>`,

// Template AST
template: {"v":3,"t":[{"t":7,"e":"p","f":[{"t":2,"r":"greeting"}," world!"]}]},

// Function
template (data, p) {
  return `<p>{{greeting}} world!</p>`;
},

Как вы уже поняли, Ractive имеет полную поддержку абстрактного синтаксического дерева (AST). По сути, все варианты в итоге приводятся к AST и на его основе идет работа в runtime. Поэтому, чтобы оптимизировать скорость работы я заранее компилирую .html шаблоны в AST и в runtime не трачу ресурсы на парсинг. Делается это с помощью команды npm run parse, которая запускается перед сборкой вебпаком.

Про class-*
Специальная директива Ractive, которая позволяет легко переключать классы в зависимости от условия:

<a href="/login" class-active="pathname === '/login'" class="nav-link">Login</a>

В данном случае мы отслеживаем изменение пути и подсвечиваем активный пункт меню.

Про @shared
Эта штука используется в Ractive для шаринга некоторых данных между компонентами, например:

// Component 1
this.set('@shared.foo', 'bar');

// Component 2
this.get('@shared.foo');


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

Про {{#with}}
Аналогично javascript конструкции with, данное блочное выражение создает новую область видимости, а точнее контекст внутри шаблона. Это очень удобно, чтобы использовать укороченные пути (keypaths) или более семантическое именование:

{{#with foo.bar.baz.qux as qux, data as articles}}
    {{ qux }}
    {{ articles }}
{{/with}}


Результат:



Что имеем в итоге:

  • Изоморфный роутинг, работающий как на клиенте, так и на сервере без каких-либо изменений;
  • Полностью функциональная история браузера;
  • Анимация переходов между страницами (пока выглядит не очень, но можно поднастроить);
  • Актуальные мета-теги как на клиенте, так и во время SSR.

Data fetching




Следующая, наверное самая больная тема изоморфных приложений, работа с данными. В чем же проблема? На самом деле их даже две:

  1. Асинхронная загрузка данных на сервере;
  2. Повторная загрузка данных на клиенте.

На первый взгляд эти вопросы вполне себе понятные и даже тривиальные. Однако мы с вами не просто ищем какое-то решение, мы ищем красивое решение, а главное максимально изоморфное. Именно поэтому нам не подойдут решения «в лоб», например, когда данные на сервере загружаются заранее (по сути синхронно) до запуска приложения (sync/prefetch), на клиенте асинхронно и «лениво» (async/lazy). Многие именно так и делают, но это не наш вариант.

Мы хотим иметь возможность фетчить данные единообразно, где и как угодно, внутри любого компонента, на любом уровне вложенности. В любом месте кода, в хуках компонента или еще как-то. А главное максимально «лениво», т.е. реально подгружать лишь те данные, которые требуются для отображения текущего состояния приложения как на клиенте, так и на сервере. И при всем при этом, мы хотим, чтобы код загрузки данных для клиента и для сервера был общим. Круто! Так чего же мы ждем?



На клиенте со всем этим проблем нет, потому что там мы веселые и асинхронные. На сервере мы тоже асинхронные, но HTTP-запрос пришедший к нам для SSR, к сожалению, нет. Это означает, что в какой-то момент, мы должны отрендерить состояние приложения в HTML и отправить его клиенту. А главное сделать этот лишь тогда, когда все необходимые данные, для всех компонентов, на всех уровнях вложенности, уже загрузились. Проблемка и рука сразу тянется к пре-фетчингу, но мы будем себя сдерживать для общего блага.

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

// add async operation to "waitings"
this.wait(promise[, key]);

// callback when all "waitings" ready 
this.ready(callback);

// return "keychain" of instance in components hierarchy
this.keychain();

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

Итак, мы научили наш сервер ожидать загрузку необходимых данных и рендерить HTML вовремя. Далее, готовая разметка приходит на клиент и наш «умный» Ractive намеревается ее гидрировать (см. часть 2). Запускается ровно тот же код, что и на сервере, иерархия компонентов начинает раскручиваться и тот код, который на сервере фетчил необходимые данные, также начинает исполняться.

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



Очевидным решение здесь будет передать на клиент собранные на сервере данные (желательно в нормализованном виде), а главное каким-то образом на клиенте раскидать эти данные по компонентам так, чтобы предотвратить повторную загрузку данных и не сломать гидрацию. Задачка, но на самом деле решается она просто.

Пишем код


Итак, сперва зарегистрируем плагин (ractive-ready), научимся вовремя рендерить наше приложение на сервере, а также получим все собранные данные в структурированном виде:

./src/app.js
Ractive.use(require('ractive-ready')());

Полный код ./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;

Ractive.use(require('ractive-ready')());
Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User'
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    }
};

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


./middleware/app.js
app.ready((error, data) => {
        ....

        data = JSON.stringify(data || {});
        error = error && error.message ? error.message : error;
		
        res.render('index', { meta, content, styles, data, error });
});

Полный код ./middleware/app.js
const run = require('../src/app');

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

    const app = run(),
	  route = app.$page.show(req.url, null, true, false);

    app.ready((error, data) => {

        const meta = route.state.meta,
	      content = app.toHTML(),
              styles = app.toCSS();

        app.teardown();
		
        data = JSON.stringify(data || {});
        error = error && error.message ? error.message : error;
		
        res.render('index', { meta, content, styles, data, error });
    });
};


Как бы и все. Ready-коллбек не только позволяет подождать загрузки данных, но и получает эти данные в структурированном виде в качестве второго аргумента. Первый аргумент, как принято в NodeJS, это ошибка, которая может возникнуть во время данного процесса. Данные структурируются сообразно иерархии компонентов, что позволит каждому компоненту на клиенте найти свой кусок данных в общей структуре. Далее просто закидываем эти значения для серверного рендеринга и помещаем на страницу:

./src/templates/_index.html
{{#error}}
<div class="alert alert-danger">{{ error }}</div>
{{/error}}
...
<script>
    window.__DATA__ = {{& data }}
</script>

Полный код ./src/templates/_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>
        
        {{#error}}
        <div class="alert alert-danger">{{ error }}</div>
        {{/error}}

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

        <script>
            window.pageEl = document.getElementById('page');
        </script>

        <script>
            window.__DATA__ = {{& data }}
        </script>

    </body>
</html>


Данные мы просто положили в window.__DATA__, там их будем искать на клиенте.

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

Для этого нам понадобиться:

Сервис работы с API
./config/api.json
{
    "backendURL": "https://conduit.productionready.io",
    "timeout": 3000,
    "https": true,
    "baseURL": "http://localhost:8080/api",
    "maxContentLength": 10000,
    "maxRedirects": 5,
    "withCredentials": true,
    "responseType": "json"
}


./src/services/api.js
const axios = require('axios');
const config = require('../../config/api.json');

const source = axios.CancelToken.source();

const api = axios.create({
    baseURL: config.baseURL,
    timeout: config.timeout,
    maxRedirects: config.maxRedirects,
    withCredentials: config.withCredentials,
    responseType: config.responseType,
    cancelToken: source.token
});

const resolve = res => JSON.parse(JSON.stringify(res.data).replace(/( |<([^>]+)>)/ig, ''));
const reject = err => {
    throw (err.response && err.response.data && err.response.data.errors) || {message: [err.message]};
};

const auth = {
    current: () => api.get(`/user`).then(resolve).catch(reject),
    logout: () => api.delete(`/users/logout`).then(resolve).catch(reject),
    login: (email, password) => api.post(`/users/login`, { user: { email, password } }).then(resolve).catch(reject),
    register: (username, email, password) => api.post(`/users`, { user: { username, email, password } }).then(resolve).catch(reject),
    save: user => api.put(`/user`, { user }).then(resolve).catch(reject)
};

const tags = {
    fetchAll: () => api.get('/tags').then(resolve).catch(reject)
};

const articles = {
    fetchAll: (type, params) => api.get(`/articles/${type || ''}`, { params }).then(resolve).catch(reject),
    fetch: slug => api.get(`/articles/${slug}`).then(resolve).catch(reject),
    create: article => api.post(`/articles`, { article }).then(resolve).catch(reject),
    update: article => api.put(`/articles/${article.slug}`, { article }).then(resolve).catch(reject),
    delete: slug => api.delete(`/articles/${slug}`).catch(reject)
};

const comments = {
    fetchAll: slug => api.get(`/articles/${slug}/comments`).then(resolve).catch(reject),
    create: (slug, comment) => api.post(`/articles/${slug}/comments`, { comment }).then(resolve).catch(reject),
    delete: (slug, commentId) => api.delete(`/articles/${slug}/comments/${commentId}`).catch(reject)
};

const favorites = {
    add: slug => api.post(`/articles/${slug}/favorite`).then(resolve).catch(reject),
    remove: slug => api.delete(`/articles/${slug}/favorite`).then(resolve).catch(reject)
};

const profiles = {
    fetch: username => api.get(`/profiles/${username}`).then(resolve).catch(reject),
    follow: username => api.post(`/profiles/${username}/follow`).then(resolve).catch(reject),
    unfollow: username => api.delete(`/profiles/${username}/follow`).then(resolve).catch(reject),
};

const cancel = msg => source.cancel(msg);

const request = api.request;

module.exports = {
    auth,
    tags,
    articles,
    comments,
    favorites,
    profiles,
    cancel,
    request
};

Сервис просто создает новый инстанс Axios, конфигурирует его и экспортирует интерфейс для взаимодействия с RealWorld Backend API на основе спецификации.

Partial для вывода ошибок API
./src/templates/partials/errors.html
<ul class="error-messages">
{{#errors}}
    {{#each this as err}}
    <li>{{ @key }} {{ err }}</li>
    {{/each}}
{{/errors}}
</ul>

Этот partial можно вставить в любой шаблон, чтобы единообразно выводить сообщения об ошибках из API согласно макетам.

Хелпер для форматирования дат
./src/helpers/formatDate.js
const options = {
    year: 'numeric', 
    month: 'long', 
    day: 'numeric'
};

const formatter = new Intl.DateTimeFormat('en-us', options);

module.exports = function (val) {
    return formatter.format(new Date(val));
};


Регистрируем все это глобально:

./src/app.js
Ractive.defaults.data.formatDate = require('./helpers/formatDate');
Ractive.defaults.data.errors = null;

Ractive.partials.errors = require('./templates/parsed/errors');

Полный код ./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;

Ractive.defaults.data.formatDate = require('./helpers/formatDate');
Ractive.defaults.data.errors = null;

Ractive.partials.errors = require('./templates/parsed/errors');

Ractive.use(require('ractive-ready')());
Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User'
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    }
};

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


Далее, все там же импортируем api-сервис и пишем простой запрос на получение списка статей в хуке oninit и, внимание, добавляем «обещание» в «ожидание» (LOL):

./src/app.js
const api = require('./services/api');
const options = {
    ...
    oninit () {
        let articles = api.articles.fetchAll();   
        this.wait(articles);
        this.set('articles', articles);
    }
};

Полный код ./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;

Ractive.defaults.data.formatDate = require('./helpers/formatDate');
Ractive.defaults.data.errors = null;

Ractive.partials.errors = require('./templates/parsed/errors');

Ractive.use(require('ractive-ready')());
Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

const api = require('./services/api');

const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User',
        articles: []
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    },
    oninit () {
        let articles = api.articles.fetchAll();    
        this.wait(articles);
        this.set('articles', articles);
    }
};

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


Ну и выводим список статей на главной (пока все не красиво и в кучу, потому что для теста):

./src/templates/app.html
{{#await articles}}
    <div class="alert alert-light">Loading articles...</div>
{{then data}}
    <div class="list-group">
    {{#each data.articles as article}}
        <div class="list-group-item list-group-item-action flex-column align-items-start">
            <div class="d-flex w-100 justify-content-between">
                <h5 class="mb-1">{{ article.title }}</h5>
                <small>{{ formatDate(article.createdAt) }}</small>
            </div>
        </div>
    {{else}}
        <div class="list-group-item">No articles are here... yet.</div>
    {{/each}}
    </div>
{{catch errors}}
    {{>errors}}
{{/await}}

Полный код ./src/templates/app.html
<div id="page">
{{>navbar}}

{{#with @shared.$route as $route }}
{{#if $route.match('/login')}}
<div fade-in-out>
    <div class="alert alert-info"><strong>Login</strong>. {{message}}</div>
</div>
{{elseif $route.match('/register')}}
<div fade-in-out>
    <div class="alert alert-info"><strong>Register</strong>. {{message}}</div>
</div>
{{elseif $route.match('/')}}
<div fade-in-out>
    <div class="alert alert-info">
        <strong>Hello, {{fullName}}!</strong> You successfully read please <a href="/login" class="alert-link">login</a>.
    </div>
    {{#await articles}}
        <div class="alert alert-light">Loading articles...</div>
    {{then data}}
        <div class="list-group">
        {{#each data.articles as article}}
            <div class="list-group-item list-group-item-action flex-column align-items-start">
                <div class="d-flex w-100 justify-content-between">
                    <h5 class="mb-1">{{ article.title }}</h5>
                    <small>{{ formatDate(article.createdAt) }}</small>
                </div>
            </div>
        {{else}}
            <div class="list-group-item">No articles are here... yet.</div>
        {{/each}}
        </div>
    {{catch errors}}
        {{>errors}}
    {{/await}}
</div>
{{else}}
<div fade-in-out>
    <p>404 page</p>
</div>  
{{/if}}
{{/with}}

{{>footer}}
</div>


«Эм, погодите мы что положили промис в данные и разрешили его прямо в шаблоне?» Ну да, так и есть. Здесь же мы используем хелпер {{ formatDate() }} и partial {{>errors}}. Они нам еще не раз пригодятся.

Про {{#await}}
Совсем недавно (в документации пока ни слова), Ractive научился нативно работать с промисами. Раньше это было возможно только с помощью адаптора. Иными словами, мы можем хранить в реактивных данных компонента промисы и резолвить их «по месту». Это крайне удобно и позволяет сократить количество шаблонного кода:

this.set('foo', fetchFoo());


{{#await foo}}
    <p>Loading....</p>
{{then val}}
    <p>{{ val }}</p>
{{catch err}}
    <p>{{ err }}</p>
{{/await}}

Profit!

Теперь SSR будет выполняться вместе со списком статей, которые также будут помещены в объект window.__DATA__. Однако пока клиентский код все равно будет выполнять повторный запрос к API, что не есть хорошо. Исправим это:

./src/app.js
const options = {
    ...
    oninit () {

        const key = 'articlesList';
   
        let articles = this.get(`@global.__DATA__.${key}`);
        
        if ( ! articles ) {
            articles = api.articles.fetchAll();
            this.wait(articles, key);
        }

        this.set('articles', articles);
    }
};

Полный код ./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;

Ractive.defaults.data.formatDate = require('./helpers/formatDate');
Ractive.defaults.data.errors = null;

Ractive.partials.errors = require('./templates/parsed/errors');

Ractive.use(require('ractive-ready')());
Ractive.use(require('ractive-page')({
    meta: require('../config/meta.json')
}));

const options = {
    el: '#app',
    template: require('./templates/parsed/app'),
    partials: {
        navbar: require('./templates/parsed/navbar'),
        footer: require('./templates/parsed/footer')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    data: {
        message: 'Hello world',
        firstName: 'Habr',
        lastName: 'User',
        articles: []
    },
    computed: {
        fullName() {
            return this.get('firstName') + ' ' + this.get('lastName');
        }
    },
    oninit () {

        const key = 'articlesList';
        
        let articles = this.get(`@global.__DATA__.${key}`);
        
        if ( ! articles ) {
            articles = api.articles.fetchAll();
            this.wait(articles, key);
        }

        this.set('articles', articles);
    }
};

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



Да не, ничего тут сложного. Мы явно определяем ключ, по которому будут лежать (или уже лежат) данные (articlesList), и путь в объекте с данными (window.__DATA__ === @global.__DATA__). Если данных нет, тогда делаем запрос и кладем промис в ожидания, указывая вторым аргументом ключ. В любом из вариантов устанавливаем значение в компонент. Вот и все.

Интересный кейс с @global
Ractive весьма и весьма «feature rich». @global — это специальная ссылка на глобальный объект (в случае браузера это window). Фишка в том, что мы получаем удобный способ взаимодействия с window.

Самый простой кейс — нам не нужно проверять руками существование свойств глобального объекта и даже его самого:

this.get('@global.foo.bar.baz'); // undefined, no errors


Ну и автоматические биндинги, но это уже совсем для извращенцев.

Короче говоря, теперь данные будут загружаться на сервере, ожидаться, рендериться во время SSR, приходить в структурированном виде на клиент, идентифицироваться и переиспользоваться без лишних запросов к API и с гидрацией разметки. Well done!



Эпилог


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

Текущие результаты по проекту тут:

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

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

UPD: Разработка изоморфного RealWorld приложения с SSR и Progressive Enhancement. Часть 4 — Компоненты и композиция
Only registered users can participate in poll. Log in, please.
Понравилась ли вам идея State-based роутинга?
20% Да, это решает многие проблемы! 2
40% Возможно, это может быть удобно… 4
40% Ересь! Хватит это терпеть! 4
10 users voted. 12 users abstained.
Tags:
Hubs:
+5
Comments 0
Comments Leave a comment

Articles