В предыдущей части туториала мы научили наше изоморфное приложение проксировать запросы к backend api, с помощью сессии передавать начальный стейт между синхронными запросами и осуществлять Server-side rendering с возможностью переиспользования разметки на клиенте (hydrate). В этой части мы решим еще две ключевые проблемы изоморфных веб-приложений: изоморфный роутинг и навигация, и повторный фетчинг и начальное состояние данных. И сделаем это буквально 5-ю строками кода. Погнали!
Пролог
Про манифест
Для начала хочу немного дополнить манифест проекта. Дело в том, что еще раз прочитав прошлогоднее сравнение frontend-фреймворков, я подумал, а почему бы не внести в манифест пункты как-то коррелирующие с этим сравнением?
К сожалению, на производительность Ractive я навряд ли смогу серьезно повлиять (хотя и предложу несколько оптимизаций). Однако, две другие характеристики — размер бандла и количество строк кода, я вполне могу внести в манифест проекта. Итак, обновленный манифест будет выглядеть так:
«Манифест» проекта:
- Соответствовать спецификации проекта RealWorld;
- Полностью поддерживать работу на сервере (SSR и все прочее);
- На клиенте работать как полноценное SPA;
- Индексироваться поисковиками;
- Работать с выключенным JS на клиенте;
- 100% изоморфного (общего) кода;
- Для реализации НЕ использовать «полумеры» и «костыли»;
- Использовать максимальной простой и общеизвестный стек технологий;
- Размер итогового бандла не должен превышать 100Кб gzip;
- Количество строк кода приложения не должно превышать 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>
Чем же плохи эти подходы? Ответ — ничем, пусть будут. Однако, оба подхода имеют ряд минусов:
- Config-based routing — слишком много бойлерплейта, слишком далеко от контекста. Как правило, роут резолвится в один определенный компонент, что не очень гибко.
- 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}}
Как видите, в при использовании этого подхода, нам не приходится выдумывать какой-то новый синтаксис, писать кучу дополнительных компонентов или конфигурить конфиги. Мы просто используем маршрут как часть общего состояния приложения и, по сути, можем делать всякие безумства парой строк кода.
А теперь по делу. У изоморфного роутинга есть только три основных момента, которые имеют значение:
- Ваш роутер должен давать возможность выставить текущий URL вручную и диспатчить эти изменения;
- Не ломаться в среде NodeJS, т.е. абстрагироваться от enviroment-specific вещей;
- Ваш роутинг должен быть «внутри» приложения, а не «снаружи».
Часто вижу, как разработчики выносят роутинг далеко «наружу», отдаляя его от общего стейта и от контекста. Также частенько те, кто пытаются писать изоморфные приложения, будто целенаправленно используют отдельно серверный (например средствами 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')
}));
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;
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'),
},
....
};
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);
Внимательный читатель, уверен, уже приметил несколько моментов. Подробнее о них добровольно читайте под спойлерами.
В данном случае, я использую банальный fade с настройками по-умолчанию, но плагины поддерживают установку настроек, например:
<div fade-in="{ duration: 500 }"><!-- только при появлении с duration 500 ms --></div>
<div fade-out="{ delay: 500 }"><!-- только при скрывании c delay 500 ms --></div>
// 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, которая запускается перед сборкой вебпаком.
<a href="/login" class-active="pathname === '/login'" class="nav-link">Login</a>
В данном случае мы отслеживаем изменение пути и подсвечиваем активный пункт меню.
// Component 1
this.set('@shared.foo', 'bar');
// Component 2
this.get('@shared.foo');
Так же как и локальный стейт компонентов, шаред-стейт является реактивным, его можно использовать в зависимостях вычисляемых свойств и подписываться на изменения.
{{#with foo.bar.baz.qux as qux, data as articles}}
{{ qux }}
{{ articles }}
{{/with}}
Результат:
Что имеем в итоге:
- Изоморфный роутинг, работающий как на клиенте, так и на сервере без каких-либо изменений;
- Полностью функциональная история браузера;
- Анимация переходов между страницами (пока выглядит не очень, но можно поднастроить);
- Актуальные мета-теги как на клиенте, так и во время SSR.
Data fetching
Следующая, наверное самая больная тема изоморфных приложений, работа с данными. В чем же проблема? На самом деле их даже две:
- Асинхронная загрузка данных на сервере;
- Повторная загрузка данных на клиенте.
На первый взгляд эти вопросы вполне себе понятные и даже тривиальные. Однако мы с вами не просто ищем какое-то решение, мы ищем красивое решение, а главное максимально изоморфное. Именно поэтому нам не подойдут решения «в лоб», например, когда данные на сервере загружаются заранее (по сути синхронно) до запуска приложения (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')());
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 });
});
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>
<!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__, там их будем искать на клиенте.
Такс, теперь нам нужно проверить, как все это хозяйство работает, а значит нужно выполнить хотя бы одну асинхронную операцию. Думаю, мы напишем тестовый запрос на получение списка статей и выведем их на главной странице. За одно протестируем проксирование запросов.
Для этого нам понадобиться:
{
"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 на основе спецификации.
<ul class="error-messages">
{{#errors}}
{{#each this as err}}
<li>{{ @key }} {{ err }}</li>
{{/each}}
{{/errors}}
</ul>
Этот partial можно вставить в любой шаблон, чтобы единообразно выводить сообщения об ошибках из API согласно макетам.
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');
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);
}
};
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}}
<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}}. Они нам еще не раз пригодятся.
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);
}
};
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__). Если данных нет, тогда делаем запрос и кладем промис в ожидания, указывая вторым аргументом ключ. В любом из вариантов устанавливаем значение в компонент. Вот и все.
Самый простой кейс — нам не нужно проверять руками существование свойств глобального объекта и даже его самого:
this.get('@global.foo.bar.baz'); // undefined, no errors
Ну и автоматические биндинги, но это уже совсем для извращенцев.
Короче говоря, теперь данные будут загружаться на сервере, ожидаться, рендериться во время SSR, приходить в структурированном виде на клиент, идентифицироваться и переиспользоваться без лишних запросов к API и с гидрацией разметки. Well done!
Эпилог
Подводя общий итог 3-х частей данного туториала можно отметить, что нам удалось создать довольно простую и лаконичную основу для изоморфного приложения. Я решил выделить эту основу в отдельный репозиторий, чтобы вы могли убедиться, что этот код действительно не является кодом приложения и может использоваться в любом проекте.
Текущие результаты по проекту тут:
→ Репозиторий
→ Демо
В следующей части мы наконец-то приступим к написанию RealWorld приложения! Начнем, пожалуй, с разбивки приложения на компоненты и реализации нескольких из них. Также планирую кратко рассказать какие виды компонентов бывают, чем они отличаются и когда их стоит использовать. Не переключайтесь!
Автор: Павел Малышев