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

В предыдущей части туториала мы научили наше изоморфное приложение проксировать запросы к 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 приложения! Начнем, пожалуй, с разбивки приложения на компоненты и реализации нескольких из них. Также планирую кратко рассказать какие виды компонентов бывают, чем они отличаются и когда их стоит использовать. Не переключайтесь!

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *